feat(activity&admin): 优化组局起止时段与性别限制,PC后台核销看板与登录页体验升级

这个提交包含在:
xcbyy
2026-11-05 16:06:18 +08:00
父节点 a1907f18a8
当前提交 f3da2e4a63
共有 21 个文件被更改,包括 1990 次插入 和 203 次删除

查看文件

@@ -36,6 +36,7 @@ export async function initDatabase(): Promise<void> {
max_participants INT DEFAULT 4,
allow_dynamic_price TINYINT DEFAULT 1,
price_adjust_rule VARCHAR(32) DEFAULT 'UNIFIED_HIGH',
gender_requirement VARCHAR(32) DEFAULT 'ALL',
status VARCHAR(32) DEFAULT 'RECRUITING',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -47,6 +48,12 @@ export async function initDatabase(): Promise<void> {
// 字段已存在忽略
}
try {
await conn.query(`ALTER TABLE activities ADD COLUMN gender_requirement VARCHAR(32) DEFAULT 'ALL' AFTER price_adjust_rule`);
} catch (_) {
// 字段已存在忽略
}
try {
await conn.query(`ALTER TABLE activities ADD COLUMN latitude DECIMAL(10,6) DEFAULT NULL AFTER location`);
} catch (_) {

查看文件

@@ -48,6 +48,7 @@ export interface ActivityRow {
totalFeeCents: number;
perPersonFeeCents: number;
maxParticipants: number;
genderRequirement?: string;
allowDynamicPrice: boolean;
priceAdjustRule: string;
joinedCount?: number;
@@ -143,6 +144,7 @@ export class MysqlRepository {
a.location, COALESCE(a.start_time, '') as startTime, a.pay_type as payType, a.total_fee_cents as totalFeeCents,
a.per_person_fee_cents as perPersonFeeCents, a.max_participants as maxParticipants,
a.allow_dynamic_price as allowDynamicPrice, a.price_adjust_rule as priceAdjustRule,
COALESCE(a.gender_requirement, 'ALL') as genderRequirement,
COALESCE(a.status, 'RECRUITING') as status,
a.created_at as createdAt,
u.nickname as creatorNickname,
@@ -217,6 +219,7 @@ export class MysqlRepository {
a.location, COALESCE(a.start_time, '') as startTime, a.pay_type as payType, a.total_fee_cents as totalFeeCents,
a.per_person_fee_cents as perPersonFeeCents, a.max_participants as maxParticipants,
a.allow_dynamic_price as allowDynamicPrice, a.price_adjust_rule as priceAdjustRule,
COALESCE(a.gender_requirement, 'ALL') as genderRequirement,
COALESCE(a.status, 'RECRUITING') as status,
u.nickname as creatorNickname,
u.avatar as creatorAvatar
@@ -253,6 +256,31 @@ export class MysqlRepository {
};
}
async getAllActivityParticipants(): Promise<any[]> {
const [ptRows]: any = await pool.query(`
SELECT
p.id, p.activity_id as activityId, p.user_id as userId,
COALESCE(u.nickname, '同城组局参与者') as nickname,
COALESCE(u.avatar, '') as avatar,
p.pay_amount_cents as payAmountCents, p.verify_code as verifyCode,
p.checkin_status as checkinStatus, p.verified_at as verifiedAt,
p.verified_by as verifiedBy, p.created_at as createdAt,
COALESCE(a.title, '未知活动') as activityTitle,
COALESCE(a.game_category, '组局') as gameCategory,
COALESCE(a.status, 'RECRUITING') as activityStatus,
COALESCE(a.location, '') as activityLocation
FROM participants p
LEFT JOIN users u ON u.id = p.user_id
LEFT JOIN activities a ON a.id = p.activity_id
ORDER BY p.created_at DESC
`);
return (ptRows || []).map((p: any) => ({
...p,
checkinStatus: Boolean(p.checkinStatus),
}));
}
async createActivity(data: {
creatorId: number;
title: string;
@@ -267,6 +295,7 @@ export class MysqlRepository {
maxParticipants?: number;
allowDynamicPrice?: number | boolean;
priceAdjustRule?: string;
genderRequirement?: string;
}): Promise<any> {
const [userRows]: any = await pool.query('SELECT status FROM users WHERE id = ?', [data.creatorId]);
if (userRows && userRows.length > 0 && (userRows[0].status === '已封禁' || userRows[0].status === '封禁')) {
@@ -294,10 +323,18 @@ export class MysqlRepository {
const allowDynamicPrice = data.allowDynamicPrice !== undefined ? (data.allowDynamicPrice ? 1 : 0) : 1;
const priceAdjustRule = data.priceAdjustRule || 'UNIFIED_HIGH';
let genderRequirement = 'ALL';
const rawGender = String(data.genderRequirement || '').trim();
if (rawGender === 'FEMALE_ONLY' || rawGender === '仅女生' || rawGender === '女') {
genderRequirement = 'FEMALE_ONLY';
} else if (rawGender === 'MALE_ONLY' || rawGender === '仅男生' || rawGender === '男') {
genderRequirement = 'MALE_ONLY';
}
await pool.query(`
INSERT INTO activities
(id, creator_id, title, game_category, location, latitude, longitude, start_time, pay_type, total_fee_cents, per_person_fee_cents, max_participants, allow_dynamic_price, price_adjust_rule)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(id, creator_id, title, game_category, location, latitude, longitude, start_time, pay_type, total_fee_cents, per_person_fee_cents, max_participants, allow_dynamic_price, price_adjust_rule, gender_requirement)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
id,
data.creatorId,
@@ -313,6 +350,7 @@ export class MysqlRepository {
maxParticipants,
allowDynamicPrice,
priceAdjustRule,
genderRequirement,
]);
return this.getActivityById(id);
@@ -400,7 +438,7 @@ export class MysqlRepository {
await conn.beginTransaction();
// 1. 封禁校验(事务内)
const [userRows]: any = await conn.query('SELECT status FROM users WHERE id = ?', [params.userId]);
const [userRows]: any = await conn.query('SELECT status, gender FROM users WHERE id = ?', [params.userId]);
if (userRows && userRows.length > 0 && (userRows[0].status === '已封禁' || userRows[0].status === '封禁')) {
const err: any = new Error('您的账号已被管理员封禁,无法报名参加活动');
err.status = 403;
@@ -410,10 +448,26 @@ export class MysqlRepository {
// 2. 行级锁锁定活动,串行化同一活动的并发报名,从根上杜绝超卖
const [actRows]: any = await conn.query(
'SELECT id, max_participants, pay_type, per_person_fee_cents FROM activities WHERE id = ? FOR UPDATE',
'SELECT id, max_participants, pay_type, per_person_fee_cents, COALESCE(gender_requirement, "ALL") as genderRequirement FROM activities WHERE id = ? FOR UPDATE',
[activityId]
);
if (!actRows || actRows.length === 0) throw new Error('活动不存在');
// 组局性别限制合规校验
const genderReq = String(actRows[0].genderRequirement || 'ALL');
const userGender = String(userRows?.[0]?.gender || '').trim();
if (genderReq === 'FEMALE_ONLY' && userGender === '男') {
const err: any = new Error('该组局仅限女生报名参与');
err.status = 400;
err.code = 40011;
throw err;
}
if (genderReq === 'MALE_ONLY' && userGender === '女') {
const err: any = new Error('该组局仅限男生报名参与');
err.status = 400;
err.code = 40012;
throw err;
}
const maxParticipants = Number(actRows[0].max_participants || 0);
// 报名费由服务端活动配置决定(pay_type=1 为 AA 收取人均分摊,其余为房主请客 0 元),
// 严禁信任客户端传入的 payAmountCents,否则可"免费报名 → 退款/清算套现"。
@@ -847,6 +901,7 @@ export class MysqlRepository {
a.location, COALESCE(a.start_time, '') as startTime, a.pay_type as payType, a.total_fee_cents as totalFeeCents,
a.per_person_fee_cents as perPersonFeeCents, a.max_participants as maxParticipants,
a.allow_dynamic_price as allowDynamicPrice, a.price_adjust_rule as priceAdjustRule,
COALESCE(a.gender_requirement, 'ALL') as genderRequirement,
COALESCE(a.status, 'RECRUITING') as status,
a.created_at as createdAt,
u.nickname as creatorNickname,

查看文件

@@ -1104,6 +1104,28 @@ app.get('/api/activities', async (req: Request, res: Response) => {
}
});
// 管理端全量活动核销状态大盘数据
app.get('/api/activities/all', async (req: Request, res: Response) => {
try {
const isAdmin = isVerifiedAdminRequest(req);
if (!isAdmin) {
return res.status(403).json({ code: 40300, message: '仅管理员可查看全部活动核销状态' });
}
const participants = await repo.getAllActivityParticipants();
return res.json({
code: 20000,
data: {
id: 'all',
title: '全部活动',
gameCategory: '全品类',
participants,
},
});
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
}
});
app.get('/api/activities/:id', async (req: Request, res: Response) => {
try {
const act = await repo.getActivityById(String(req.params.id));
@@ -1214,6 +1236,20 @@ app.post('/api/activities/:id/join', async (req: Request, res: Response) => {
}
});
// 管理端全量视图下4位核销码核销
app.post('/api/activities/all/verify', async (req: Request, res: Response) => {
try {
const { operatorId, verifyCode } = req.body;
let operator = resolveSecureUserId(req, Number(operatorId || 0), '核销组局门票');
const result = await repo.universalVerifyCheckin(String(verifyCode), operator);
res.json({ code: 20000, data: result, message: '核销成功,已完成现场到场签到' });
} catch (err: any) {
const statusCode = err.status || 400;
const errCode = err.code || 40000;
res.status(statusCode).json({ code: errCode, message: err.message });
}
});
// 房主/管理员核销 4 位核销码 (防重落盘 MySQL)
app.post('/api/activities/:id/verify', async (req: Request, res: Response) => {
try {

查看文件

@@ -0,0 +1,73 @@
// 已读取AGENTS.md
import * as fs from 'fs';
import * as path from 'path';
/**
* 第一百零二批回归闸门:组局性别限制功能全链路闭环测试
*/
describe('第一百零二批组局性别限制闭环测试', () => {
const rootDir = path.resolve(__dirname, '../../');
const mysqlTsPath = path.join(rootDir, 'server/src/db/mysql.ts');
const repoTsPath = path.join(rootDir, 'server/src/db/repository.ts');
const apiJsPath = path.join(rootDir, 'pwuniapp/common/api.js');
const createVuePath = path.join(rootDir, 'pwuniapp/pages/activity/create.vue');
const detailVuePath = path.join(rootDir, 'pwuniapp/pages/activity/detail.vue');
const listVuePath = path.join(rootDir, 'pwuniapp/pages/activity/list.vue');
const mysqlContent = fs.readFileSync(mysqlTsPath, 'utf-8');
const repoContent = fs.readFileSync(repoTsPath, 'utf-8');
const apiJsContent = fs.readFileSync(apiJsPath, 'utf-8');
const createVueContent = fs.readFileSync(createVuePath, 'utf-8');
const detailVueContent = fs.readFileSync(detailVuePath, 'utf-8');
const listVueContent = fs.readFileSync(listVuePath, 'utf-8');
it('数据库结构必须包含 gender_requirement 字段及迁移补丁', () => {
expect(mysqlContent).toContain('gender_requirement VARCHAR(32) DEFAULT \'ALL\'');
expect(mysqlContent).toContain('ADD COLUMN gender_requirement VARCHAR(32)');
});
it('仓储层 createActivity 必须支持并归一化 genderRequirement', () => {
expect(repoContent).toContain('genderRequirement?: string;');
expect(repoContent).toContain("genderRequirement = 'FEMALE_ONLY'");
expect(repoContent).toContain("genderRequirement = 'MALE_ONLY'");
expect(repoContent).toContain('gender_requirement');
});
it('仓储层 joinActivity 必须严格执行性别限制校验,违规时拦截', () => {
expect(repoContent).toContain('该组局仅限女生报名参与');
expect(repoContent).toContain('该组局仅限男生报名参与');
expect(repoContent).toContain('genderRequirement || \'ALL\'');
});
it('小程序 API 层必须透传与格式化 genderRequirement', () => {
expect(apiJsContent).toContain('genderRequirement: payload.genderRequirement || \'ALL\'');
expect(apiJsContent).toContain('raw.genderRequirement || raw.gender_requirement || \'ALL\'');
});
it('组局发布页 create.vue 必须提供「凑局性别限制」配置项及三选一胶囊', () => {
expect(createVueContent).toContain('凑局性别限制');
expect(createVueContent).toContain('genderOptions');
expect(createVueContent).toContain("'仅女生'");
expect(createVueContent).toContain("'仅男生'");
expect(createVueContent).toContain("'不限'");
expect(createVueContent).toContain('genderRequirement: \'ALL\'');
expect(createVueContent).toContain('.gender-limit-block');
});
it('详情页与大厅列表必须展示对应性别限制标签', () => {
expect(detailVueContent).toContain('性别限制');
expect(detailVueContent).toContain('formatGenderRequirement');
expect(listVueContent).toContain('仅限女生');
expect(listVueContent).toContain('仅限男生');
expect(listVueContent).toContain('gender-req-tag');
});
it('全量修改文件必须严格遵守零 Emoji 门禁规范', () => {
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
expect(emojiRegex.test(mysqlContent)).toBe(false);
expect(emojiRegex.test(repoContent)).toBe(false);
expect(emojiRegex.test(createVueContent)).toBe(false);
expect(emojiRegex.test(detailVueContent)).toBe(false);
expect(emojiRegex.test(listVueContent)).toBe(false);
});
});

查看文件

@@ -0,0 +1,68 @@
// 已读取AGENTS.md
import * as fs from 'fs';
import * as path from 'path';
/**
* 第一百零一批回归闸门:活动时段与快捷时段起止时间闭环校验
*/
describe('第一百零一批活动时段与快捷时段起止时间闭环', () => {
const rootDir = path.resolve(__dirname, '../../');
const createVuePath = path.join(rootDir, 'pwuniapp/pages/activity/create.vue');
const detailVuePath = path.join(rootDir, 'pwuniapp/pages/activity/detail.vue');
const createVueContent = fs.readFileSync(createVuePath, 'utf-8');
const detailVueContent = fs.readFileSync(detailVuePath, 'utf-8');
it('快捷时段必须包含起止时间,不得仅有单点开始时间', () => {
expect(createVueContent).toContain("'今晚 19:30 - 21:30'");
expect(createVueContent).toContain("'今晚 20:30 - 22:30'");
expect(createVueContent).toContain("'明天 14:00 - 17:00'");
expect(createVueContent).toContain("'周六 18:30 - 21:30'");
expect(createVueContent).toContain("'周日 15:00 - 18:00'");
// 确保不再包含旧的无结束时间单点快捷项
expect(createVueContent).not.toMatch(/'今晚 19:30',/);
expect(createVueContent).not.toMatch(/'周六 18:30',/);
});
it('时间选择器必须升级为三列多列选择器(日期、开始时间、结束时间)', () => {
expect(createVueContent).toContain('const timePickerStartSlots =');
expect(createVueContent).toContain('const timePickerEndSlots =');
expect(createVueContent).toContain('const timePickerRange = ref([timePickerDays, timePickerStartSlots, timePickerEndSlots]);');
expect(createVueContent).toContain('const timePickerIndex = ref([5, 19, 24]);');
});
it('时间选择变动必须输出带有起止时间范围的完整时段字符串', () => {
expect(createVueContent).toContain('${dayStr} ${startStr} - ${endStr}');
expect(createVueContent).toContain('toSlotMinutes');
});
it('快捷时段点击必须支持解析起止时间并同步更新三列索引', () => {
expect(createVueContent).toContain('const match = String(timeItem).trim().match(/^(\\S+)\\s+([\\d:]+)(?:\\s*-\\s*([\\d:]+))?$/);');
expect(createVueContent).toContain('timePickerIndex.value = [');
});
it('活动详情页必须将展示文案统一为「活动时段」', () => {
expect(detailVueContent).toContain('<text class="info-label">活动时段</text>');
expect(detailVueContent).not.toContain('<text class="info-label">开始时间</text>');
});
it('时段选择器布局必须具备防挤压换行保护(无换行、省略截断与标签不压缩)', () => {
// 占位符必须精炼,避免超长换行
expect(createVueContent).toContain("{{ form.startTime || '请选择活动时段' }}");
expect(createVueContent).not.toContain('请选择活动时段 (起止时间)');
// 标签必须 flex-shrink: 0 且 white-space: nowrap 防止文字折行
expect(createVueContent).toContain('flex-shrink: 0;');
expect(createVueContent).toContain('white-space: nowrap;');
expect(createVueContent).toContain('text-overflow: ellipsis;');
// 基础信息卡片针对短标签优化宽度
expect(createVueContent).toContain('&.basic-info-card');
});
it('修改文件必须严格遵循零 Emoji 门禁规范', () => {
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
expect(emojiRegex.test(createVueContent)).toBe(false);
expect(emojiRegex.test(detailVueContent)).toBe(false);
});
});

查看文件

@@ -0,0 +1,45 @@
// 已读取AGENTS.md
import * as fs from 'fs';
import * as path from 'path';
/**
* 第一百二十二批全量活动核销状态看板闸门 (查看所有活动的核销状态)
*/
describe('第一百二十二批全量活动核销状态看板闸门', () => {
const rootDir = path.resolve(__dirname, '../../');
const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8');
const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8');
const verifyVuePath = path.join(
rootDir,
'admin/arco-design-pro-vite/src/views/activity/verify/index.vue'
);
const verifyVueCode = fs.readFileSync(verifyVuePath, 'utf-8');
it('仓储层必须实现 getAllActivityParticipants 方法并关联活动与用户详情', () => {
expect(repoCode).toContain('getAllActivityParticipants(): Promise<any[]>');
expect(repoCode).toContain('FROM participants p');
expect(repoCode).toContain('LEFT JOIN activities a ON a.id = p.activity_id');
expect(repoCode).toContain('checkinStatus: Boolean(p.checkinStatus)');
});
it('服务端路由必须挂载 GET /api/activities/all 并限制管理员权限', () => {
expect(indexCode).toContain("app.get('/api/activities/all'");
expect(indexCode).toContain('isVerifiedAdminRequest(req)');
expect(indexCode).toContain('repo.getAllActivityParticipants()');
expect(indexCode).toContain("app.post('/api/activities/all/verify'");
});
it('管理后台核销看板必须提供「全部」选项并支持全量核销追踪', () => {
expect(verifyVueCode).toContain('value="all"');
expect(verifyVueCode).toContain('全部 (查看所有活动核销状态)');
expect(verifyVueCode).toContain('所属活动 / 组局');
expect(verifyVueCode).toContain('filterStatus');
expect(verifyVueCode).toContain('selectedActivityId.value === \'all\'');
});
it('新增的前后端代码与测试严禁包含任何系统 Emoji 字符', () => {
const emojiRegex = /(?:\p{Extended_Pictographic}|\p{Emoji_Presentation}|[\u2600-\u27BF])/u;
expect(emojiRegex.test(indexCode)).toBe(false);
expect(emojiRegex.test(verifyVueCode)).toBe(false);
});
});

查看文件

@@ -130,7 +130,8 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安
const raw = fs.readFileSync(pagesJsonPath, 'utf-8');
const config = JSON.parse(raw);
expect(Array.isArray(config.pages)).toBe(true);
expect(config.pages.length).toBeGreaterThanOrEqual(28);
const totalPages = config.pages.length + (config.subPackages || []).reduce((acc: number, sub: any) => acc + (sub.pages || []).length, 0);
expect(totalPages).toBeGreaterThanOrEqual(28);
for (const pageItem of config.pages) {
const vueFilePath = path.join(rootDir, 'pwuniapp', `${pageItem.path}.vue`);
@@ -142,6 +143,19 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安
exists: true
});
}
for (const sub of (config.subPackages || [])) {
for (const pageItem of sub.pages) {
const vueFilePath = path.join(rootDir, 'pwuniapp', sub.root, `${pageItem.path}.vue`);
expect({
path: `${sub.root}/${pageItem.path}`,
exists: fs.existsSync(vueFilePath)
}).toEqual({
path: `${sub.root}/${pageItem.path}`,
exists: true
});
}
}
});
it('主包核心 TabBar 页面保持精炼且四大主页全部就绪', () => {
@@ -484,7 +498,7 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安
expect(fs.existsSync(applyPath)).toBe(true);
const pagesJsonPath = path.join(rootDir, 'pwuniapp', 'pages.json');
const pagesJson = fs.readFileSync(pagesJsonPath, 'utf-8');
expect(pagesJson).toContain('pages/expert/apply');
expect(pagesJson.includes('pages/expert/apply') || (pagesJson.includes('pages/expert') && pagesJson.includes('"path": "apply"'))).toBe(true);
expect(pagesJson).toContain('大神入驻认证');
});