Compare commits
2 Commits
c101b15e2e
...
29ce734ffa
| Author | SHA1 | Date | |
|---|---|---|---|
| 29ce734ffa | |||
| 26a29b2214 |
@@ -113,6 +113,11 @@ class AdminService extends Service {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取分页账号列表
|
* 获取分页账号列表
|
||||||
|
*
|
||||||
|
* R90 增补:每行附带 `teamUid / teamId / teamNumber / schoolName`(与 getTeams 同源口径),
|
||||||
|
* 供「选手管理 → 选手列表」与「小组视图」相互对照(人工测试反馈:选手列表缺小组编号列)。
|
||||||
|
* 既有字段一律不变。
|
||||||
|
*
|
||||||
* @param {Object} options - 查询选项 { group, keyword, activated, page, pageSize, startDate, endDate }
|
* @param {Object} options - 查询选项 { group, keyword, activated, page, pageSize, startDate, endDate }
|
||||||
* @returns {Promise<Object>} { list, total, page, pageSize }
|
* @returns {Promise<Object>} { list, total, page, pageSize }
|
||||||
*/
|
*/
|
||||||
@@ -166,8 +171,52 @@ class AdminService extends Service {
|
|||||||
limit: pageSize,
|
limit: pageSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- R90:补齐「小组编号 / 所属学校」(与 getTeams 同源口径,供选手列表对照)----
|
||||||
|
// 口径(勿另立第二套):
|
||||||
|
// · 小组键 = (组别 group, 队伍号 teamUid),⛔ 不可单用 teamUid(跨组重号)
|
||||||
|
// · teamUid 解析优先级:aggregation.team_uid(报名表聚合,权威) → contestants.team_id(兜底)
|
||||||
|
// · schoolName 取自 aggregation.organization_cn
|
||||||
|
// 批量查询(2 条 SQL)而非逐行查,避免 N+1。
|
||||||
|
const Op = ctx.model.Sequelize.Op;
|
||||||
|
const pageIds = rows.map(r => r.id);
|
||||||
|
const aggTeamUid = new Map(); // contestant.id → teamUid
|
||||||
|
const aggSchool = new Map(); // contestant.id → 学校名
|
||||||
|
if (pageIds.length > 0) {
|
||||||
|
const aggRows = await ctx.model.Aggregation.findAll({
|
||||||
|
where: { account_uid: { [Op.in]: pageIds } },
|
||||||
|
attributes: ['account_uid', 'team_uid', 'organization_cn'],
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
for (const a of aggRows) {
|
||||||
|
if (a.team_uid !== null && a.team_uid !== undefined && !aggTeamUid.has(a.account_uid)) {
|
||||||
|
aggTeamUid.set(a.account_uid, a.team_uid);
|
||||||
|
}
|
||||||
|
const sch = typeof a.organization_cn === 'string' ? a.organization_cn.trim() : '';
|
||||||
|
if (sch && !aggSchool.has(a.account_uid)) aggSchool.set(a.account_uid, sch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 队伍元信息(人类可读队号 team_id / team_number),按 (group, teamUid) 建索引
|
||||||
|
const resolveTeamUid = row => {
|
||||||
|
if (aggTeamUid.has(row.id)) return aggTeamUid.get(row.id);
|
||||||
|
return row.team_id === null || row.team_id === undefined ? null : row.team_id;
|
||||||
|
};
|
||||||
|
const teamUids = Array.from(new Set(rows.map(resolveTeamUid).filter(u => u !== null)));
|
||||||
|
const teamMeta = new Map();
|
||||||
|
if (teamUids.length > 0) {
|
||||||
|
const teamRows = await ctx.model.Team.findAll({
|
||||||
|
where: { team_uid: { [Op.in]: teamUids } },
|
||||||
|
attributes: ['team_uid', 'group_name', 'team_id', 'team_number'],
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
for (const t of teamRows) teamMeta.set(t.group_name + '#' + t.team_uid, t);
|
||||||
|
}
|
||||||
|
|
||||||
// 转换为前端期望的字段格式
|
// 转换为前端期望的字段格式
|
||||||
const list = rows.map(row => ({
|
const list = rows.map(row => {
|
||||||
|
const teamUid = resolveTeamUid(row);
|
||||||
|
const meta = teamUid === null ? null : (teamMeta.get(row.group + '#' + teamUid) || null);
|
||||||
|
return {
|
||||||
uid: row.id,
|
uid: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
name_cn: row.name,
|
name_cn: row.name,
|
||||||
@@ -180,7 +229,13 @@ class AdminService extends Service {
|
|||||||
test_flag: row.test_flag,
|
test_flag: row.test_flag,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
}));
|
// R90 新增(既有字段一律不变)
|
||||||
|
teamUid,
|
||||||
|
teamId: meta ? meta.team_id : null,
|
||||||
|
teamNumber: meta ? (meta.team_number || meta.team_id) : null,
|
||||||
|
schoolName: aggSchool.get(row.id) || '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
list,
|
list,
|
||||||
@@ -205,14 +260,19 @@ class AdminService extends Service {
|
|||||||
* 同一选手若两处都有归属,**只计一次**(以 aggregation 为准,避免重复计数)。
|
* 同一选手若两处都有归属,**只计一次**(以 aggregation 为准,避免重复计数)。
|
||||||
* - **学校名称**取自 `aggregation.organization_cn`。同一小组理论上同校;
|
* - **学校名称**取自 `aggregation.organization_cn`。同一小组理论上同校;
|
||||||
* 若出现多校(异常数据),并列展示并置 `schoolConflict=true` 供排查,**不静默取一个**。
|
* 若出现多校(异常数据),并列展示并置 `schoolConflict=true` 供排查,**不静默取一个**。
|
||||||
* - **激活判据**:`activated` = 组内**全部**成员 `contestants.activated=1`。
|
* - **三态判定**(字段 `status`):`activated`(全员激活)/ `partial`(部分激活)/ `inactive`(全未激活)。
|
||||||
* 三个细分状态:`activated`(全激活)/ `partial`(部分激活)/ `inactive`(全未激活)。
|
* - **🔴「已激活」语义(R90 续三修正)**:行字段 `activated` = 组内**至少 1 名**成员已激活
|
||||||
* ⚠️ `partial` 在「已激活/未激活」二选一筛选里归入**未激活**(因为尚未全员可登录)。
|
* —— 设计目的即为「确认该小组是否已有人激活、**可以参赛**」,故 `partial` 归入**已激活**侧,
|
||||||
|
* 仅 `inactive` 归入**未激活**侧(旧口径「全员激活」与产品语义不符,已修正)。
|
||||||
|
* 严格「全员激活」语义另存于 `allActivated`;前端表格按 `status` 渲染三态标签,显示不受影响。
|
||||||
* - **测试数据口径**:默认 `includeTest=false` ⇒ 只统计 `test_flag=0` 的成员;
|
* - **测试数据口径**:默认 `includeTest=false` ⇒ 只统计 `test_flag=0` 的成员;
|
||||||
* 某小组若成员全为测试数据,则该小组整体不出现(这正是「生产口径」想要的)。
|
* 某小组若成员全为测试数据,则该小组整体不出现(这正是「生产口径」想要的)。
|
||||||
*
|
*
|
||||||
* @param {Object} options - { group?, activated?, includeTest? }
|
* @param {Object} options - { group?, activated?, includeTest? }
|
||||||
* @returns {Promise<Object>} { list, summary, allSummary, filters }
|
* @returns {Promise<Object>} { list, summary, filteredTotal, filters }
|
||||||
|
* summary 为**筛选前全集**口径,含 total/activated/partial/inactive/memberTotal/memberActivated
|
||||||
|
* 以及 R90 增补的 memberActivatedInPartial / filteredTotal / filteredMemberTotal / filteredMemberActivated
|
||||||
|
* 与 R90 续三增补的 filteredActivated / filteredPartial / filteredInactive(筛选后三态分布)
|
||||||
*/
|
*/
|
||||||
async getTeams(options = {}) {
|
async getTeams(options = {}) {
|
||||||
const { ctx } = this;
|
const { ctx } = this;
|
||||||
@@ -309,7 +369,12 @@ class AdminService extends Service {
|
|||||||
schoolConflict: b.schools.size > 1,
|
schoolConflict: b.schools.size > 1,
|
||||||
memberCount,
|
memberCount,
|
||||||
activatedCount,
|
activatedCount,
|
||||||
activated: memberCount > 0 && activatedCount === memberCount,
|
// 🔴 R90 续三:「已激活」= 组内**至少 1 名**成员已激活(= 可以参赛),partial 归入已激活侧。
|
||||||
|
// 旧口径「全员激活」与产品语义(确认小组是否已有人激活可参赛)不符,已修正。
|
||||||
|
// 筛选逻辑随之无需改动(下方 list 过滤仍按本字段判定)。
|
||||||
|
activated: memberCount > 0 && activatedCount > 0,
|
||||||
|
// 严格「全员激活」语义(前端表格按 status 渲染三态标签,本项供需严格判定的场景)
|
||||||
|
allActivated: memberCount > 0 && activatedCount === memberCount,
|
||||||
status,
|
status,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -333,6 +398,8 @@ class AdminService extends Service {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 筛选(与 getAccounts 的 activated 归一化同理:前端 el-select 传字符串)
|
// 筛选(与 getAccounts 的 activated 归一化同理:前端 el-select 传字符串)
|
||||||
|
// 🔴 R90 续三口径:activated=true ⇒「至少 1 名成员已激活」= activated 组 + partial 组(可参赛);
|
||||||
|
// activated=false ⇒ 仅 inactive 组(无任何成员激活)。判据统一挂在行字段 activated 上。
|
||||||
let list = all;
|
let list = all;
|
||||||
if ([true, 1, '1', 'true'].includes(activated)) {
|
if ([true, 1, '1', 'true'].includes(activated)) {
|
||||||
list = all.filter(t => t.activated === true);
|
list = all.filter(t => t.activated === true);
|
||||||
@@ -340,10 +407,33 @@ class AdminService extends Service {
|
|||||||
list = all.filter(t => t.activated === false);
|
list = all.filter(t => t.activated === false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// R90:给前端「双口径」汇总用(人工测试反馈:筛选后小组数 3 与成员激活数 4 看似矛盾)。
|
||||||
|
// 既有 6 个字段语义一律不变(仍为**筛选前全集**口径),仅新增下列字段:
|
||||||
|
// · memberActivatedInPartial —— 「部分激活」组内已激活的成员数(恒为全集口径)。
|
||||||
|
// · filteredTotal / filteredMemberTotal / filteredMemberActivated —— **当前筛选后**口径。
|
||||||
|
// R90 续三:口径修正为「activated = 至少 1 名成员激活」后,选中「已激活」时 partial 组一并纳入
|
||||||
|
// ⇒ filteredMemberActivated 恒等于全集 memberActivated(inactive 组贡献 0),
|
||||||
|
// 「选手列表激活人数」与「已激活小组成员数」不再有差额。
|
||||||
|
// 另补 filteredActivated / filteredPartial / filteredInactive,供前端展示筛选后三态分布。
|
||||||
|
const allSummary = summarize(all);
|
||||||
|
const filteredSummary = summarize(list);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
list,
|
list,
|
||||||
|
summary: {
|
||||||
|
...allSummary,
|
||||||
|
memberActivatedInPartial: all
|
||||||
|
.filter(t => t.status === 'partial')
|
||||||
|
.reduce((s, t) => s + t.activatedCount, 0),
|
||||||
|
filteredTotal: filteredSummary.total,
|
||||||
|
filteredMemberTotal: filteredSummary.memberTotal,
|
||||||
|
filteredMemberActivated: filteredSummary.memberActivated,
|
||||||
|
// R90 续三:筛选后三态分布(选中「已激活」时 = 全员激活 + 部分激活)
|
||||||
|
filteredActivated: filteredSummary.activated,
|
||||||
|
filteredPartial: filteredSummary.partial,
|
||||||
|
filteredInactive: filteredSummary.inactive,
|
||||||
|
},
|
||||||
// summary 作用于**筛选前**的全集(给总览用);filteredTotal 说明筛选后条数
|
// summary 作用于**筛选前**的全集(给总览用);filteredTotal 说明筛选后条数
|
||||||
summary: summarize(all),
|
|
||||||
filteredTotal: list.length,
|
filteredTotal: list.length,
|
||||||
filters: { group: group || '', activated: activated === undefined ? '' : activated, includeTest },
|
filters: { group: group || '', activated: activated === undefined ? '' : activated, includeTest },
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user