feat(admin): 新增小组激活状态清单接口 /api/admin/teams
- router:GET /api/admin/teams(adminAuth;固定路径段,与既有路由无通配冲突) - controller:teams() 整体透传 group/activated/includeTest(防前端筛选静默失效) - service:getTeams(),键 = 组别+队伍号;成员并集 aggregation.team_uid ∪ contestants.team_id;学校取 aggregation.organization_cn;激活 = 全组成员均已激活;附 summary 与 schoolConflict - model/aggregation:补声明运行库既有列 organization_cn / group(此前模型漏声明 ⇒ 查询被当未知列) 线上验证:R86 已部署上线,公网 /api/admin/teams 返回 401(鉴权在位)
This commit is contained in:
@@ -126,6 +126,30 @@ class AdminController extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取「小组(团队)激活状态」清单(R84 续六新增)
|
||||||
|
* GET /api/admin/teams?group=xxx&activated=true|false&includeTest=0|1
|
||||||
|
*
|
||||||
|
* 需求:「选手管理」中查看已激活/未激活小组,**必须携带小组所属学校名称**。
|
||||||
|
* 口径(键 = 组别 + 队伍号;成员 = aggregation.team_uid ∪ contestants.team_id;
|
||||||
|
* 学校 = aggregation.organization_cn;激活 = 全组成员均已激活)详见
|
||||||
|
* `service/admin.js#getTeams` 的方法头注释 —— 那是唯一真相源。
|
||||||
|
*/
|
||||||
|
async teams() {
|
||||||
|
const { ctx, service } = this;
|
||||||
|
if (!this._ensureAdmin()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ⚠️ 筛选条件必须**整体透传**(同 accounts():曾因只解构部分参数导致前端筛选静默失效)
|
||||||
|
const { group, activated, includeTest } = ctx.query;
|
||||||
|
const result = await service.admin.getTeams({ group, activated, includeTest });
|
||||||
|
ctx.body = ctx.helper.success(result, '获取小组列表成功');
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.error('获取小组列表失败:', err);
|
||||||
|
ctx.body = ctx.helper.error(500, '获取小组列表失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从 Excel 文件导入账号数据
|
* 从 Excel 文件导入账号数据
|
||||||
* POST /api/admin/import-excel
|
* POST /api/admin/import-excel
|
||||||
|
|||||||
@@ -78,6 +78,25 @@ module.exports = app => {
|
|||||||
defaultValue: null,
|
defaultValue: null,
|
||||||
comment: '驳回原因',
|
comment: '驳回原因',
|
||||||
},
|
},
|
||||||
|
// 所属单位 / 学校名称(报名表原始来源,R84 续六新增声明)
|
||||||
|
// ⚠️ 该列在运行库中**一直存在**(`SHOW COLUMNS FROM aggregation` 可见),
|
||||||
|
// 只是模型漏声明 ⇒ 任何 `attributes: ['organization_cn']` 查询都会
|
||||||
|
// 被 Sequelize 当作未知列。此处补齐仅为让模型与运行库一致,
|
||||||
|
// 声明为 allowNull:true,不改变既有写入路径的语义。
|
||||||
|
organization_cn: {
|
||||||
|
type: STRING(200),
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: null,
|
||||||
|
comment: '所属单位/学校名称',
|
||||||
|
},
|
||||||
|
// 参赛组别(zhongzhi/gaozhi/benke/jiaoshi)—— 同为运行库既有列,模型漏声明。
|
||||||
|
// ⚠️ `group` 是 MySQL 保留字,Sequelize 会自动加反引号;手写 SQL 时须自行反引号。
|
||||||
|
group: {
|
||||||
|
type: STRING(20),
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: null,
|
||||||
|
comment: '参赛组别',
|
||||||
|
},
|
||||||
// 记录创建时间
|
// 记录创建时间
|
||||||
created_at: {
|
created_at: {
|
||||||
type: DATE,
|
type: DATE,
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ module.exports = app => {
|
|||||||
|
|
||||||
// 账号管理
|
// 账号管理
|
||||||
router.get('/api/admin/accounts', adminAuth, controller.admin.accounts);
|
router.get('/api/admin/accounts', adminAuth, controller.admin.accounts);
|
||||||
|
// 小组(团队)激活状态清单(R84 续六):查看已激活 / 未激活小组,并携带所属学校名称。
|
||||||
|
// ⚠️ 路径段固定、无通配,与既有路由无匹配冲突。
|
||||||
|
router.get('/api/admin/teams', adminAuth, controller.admin.teams);
|
||||||
router.post('/api/admin/import-excel', adminAuth, controller.admin.importExcel);
|
router.post('/api/admin/import-excel', adminAuth, controller.admin.importExcel);
|
||||||
|
|
||||||
// 提交管理
|
// 提交管理
|
||||||
|
|||||||
@@ -190,6 +190,165 @@ class AdminService extends Service {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取「小组(团队)激活状态」清单(R84 续六新增 · 人工测试反馈需求)
|
||||||
|
*
|
||||||
|
* 需求原文:「在选手管理中加入一个查看已激活/未激活小组的功能,且必须携带小组所属学校名称」。
|
||||||
|
*
|
||||||
|
* 口径(本函数的**唯一真相源**,改动前请先读这里):
|
||||||
|
* - **小组的键 = (组别 group, 队伍号 teamUid)**。⛔ 绝不能用 teamUid 单独作键:
|
||||||
|
* 实测 `team_uid=1` 同时存在于 zhongzhi 与 benke(见 `service/exam.js` checkTeamLock
|
||||||
|
* 的跨组复用防护注释),单独用 teamUid 会把两个不同组的小组并成一个。
|
||||||
|
* - **成员来源为双来源并集**(与 checkTeamLock 保持一致):
|
||||||
|
* ① `aggregation.team_uid`(报名表聚合填充,**同时是学校名称的来源**)
|
||||||
|
* ② `contestants.team_id`(导入流程填充,兼容旧数据)
|
||||||
|
* 同一选手若两处都有归属,**只计一次**(以 aggregation 为准,避免重复计数)。
|
||||||
|
* - **学校名称**取自 `aggregation.organization_cn`。同一小组理论上同校;
|
||||||
|
* 若出现多校(异常数据),并列展示并置 `schoolConflict=true` 供排查,**不静默取一个**。
|
||||||
|
* - **激活判据**:`activated` = 组内**全部**成员 `contestants.activated=1`。
|
||||||
|
* 三个细分状态:`activated`(全激活)/ `partial`(部分激活)/ `inactive`(全未激活)。
|
||||||
|
* ⚠️ `partial` 在「已激活/未激活」二选一筛选里归入**未激活**(因为尚未全员可登录)。
|
||||||
|
* - **测试数据口径**:默认 `includeTest=false` ⇒ 只统计 `test_flag=0` 的成员;
|
||||||
|
* 某小组若成员全为测试数据,则该小组整体不出现(这正是「生产口径」想要的)。
|
||||||
|
*
|
||||||
|
* @param {Object} options - { group?, activated?, includeTest? }
|
||||||
|
* @returns {Promise<Object>} { list, summary, allSummary, filters }
|
||||||
|
*/
|
||||||
|
async getTeams(options = {}) {
|
||||||
|
const { ctx } = this;
|
||||||
|
const { group, activated } = options;
|
||||||
|
const includeTest = options.includeTest === true || options.includeTest === 'true' || options.includeTest === '1';
|
||||||
|
|
||||||
|
const GROUP_CN = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' };
|
||||||
|
|
||||||
|
// ---- 1) 成员(选手):只取本功能所需列,避免把 password 等带出来 ----
|
||||||
|
const contestantWhere = {};
|
||||||
|
if (group) contestantWhere.group = group;
|
||||||
|
if (!includeTest) contestantWhere.test_flag = 0;
|
||||||
|
const contestants = await ctx.model.Contestant.findAll({
|
||||||
|
where: contestantWhere,
|
||||||
|
attributes: ['id', 'group', 'team_id', 'activated'],
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
const contestantById = new Map();
|
||||||
|
for (const c of contestants) contestantById.set(c.id, c);
|
||||||
|
|
||||||
|
// ---- 2) 聚合报名(团队归属 + 学校名称)----
|
||||||
|
const aggs = await ctx.model.Aggregation.findAll({
|
||||||
|
attributes: ['account_uid', 'team_uid', 'organization_cn'],
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 3) 团队元信息(人类可读队伍编号 team_id / team_number)----
|
||||||
|
const teamWhere = group ? { group_name: group } : {};
|
||||||
|
const teamRows = await ctx.model.Team.findAll({
|
||||||
|
where: teamWhere,
|
||||||
|
attributes: ['team_uid', 'group_name', 'team_id', 'team_number'],
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
const teamMeta = new Map();
|
||||||
|
for (const t of teamRows) teamMeta.set(t.group_name + '#' + t.team_uid, t);
|
||||||
|
|
||||||
|
// ---- 4) 归集:(组别, 队伍号) → 成员 / 激活数 / 学校 ----
|
||||||
|
const buckets = new Map();
|
||||||
|
const keyOf = (g, uid) => g + '#' + uid;
|
||||||
|
const touch = (g, uid) => {
|
||||||
|
const k = keyOf(g, uid);
|
||||||
|
let b = buckets.get(k);
|
||||||
|
if (!b) {
|
||||||
|
b = { group: g, teamUid: uid, memberIds: new Set(), activatedCount: 0, schools: new Set() };
|
||||||
|
buckets.set(k, b);
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4a) 来源一:aggregation.team_uid(先跑,作为「已归属」判定的基准)
|
||||||
|
const assigned = new Set();
|
||||||
|
for (const a of aggs) {
|
||||||
|
if (a.team_uid === null || a.team_uid === undefined) continue;
|
||||||
|
const c = contestantById.get(a.account_uid);
|
||||||
|
if (!c) continue; // 不在本次口径内(被组别/测试数据过滤掉)
|
||||||
|
const b = touch(c.group, a.team_uid);
|
||||||
|
if (!b.memberIds.has(c.id)) {
|
||||||
|
b.memberIds.add(c.id);
|
||||||
|
if (c.activated) b.activatedCount += 1;
|
||||||
|
}
|
||||||
|
assigned.add(c.id);
|
||||||
|
const sch = typeof a.organization_cn === 'string' ? a.organization_cn.trim() : '';
|
||||||
|
if (sch) b.schools.add(sch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4b) 来源二:contestants.team_id(兜底;已由 aggregation 计过的选手跳过 ⇒ 防重复计数)
|
||||||
|
for (const c of contestants) {
|
||||||
|
if (c.team_id === null || c.team_id === undefined) continue;
|
||||||
|
if (assigned.has(c.id)) continue;
|
||||||
|
const b = touch(c.group, c.team_id);
|
||||||
|
if (!b.memberIds.has(c.id)) {
|
||||||
|
b.memberIds.add(c.id);
|
||||||
|
if (c.activated) b.activatedCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 5) 组装 + 状态判定 ----
|
||||||
|
const all = [];
|
||||||
|
for (const b of buckets.values()) {
|
||||||
|
const memberCount = b.memberIds.size;
|
||||||
|
const activatedCount = b.activatedCount;
|
||||||
|
const meta = teamMeta.get(keyOf(b.group, b.teamUid)) || null;
|
||||||
|
const status = activatedCount === 0
|
||||||
|
? 'inactive'
|
||||||
|
: (activatedCount === memberCount ? 'activated' : 'partial');
|
||||||
|
all.push({
|
||||||
|
group: b.group,
|
||||||
|
groupName: GROUP_CN[b.group] || b.group,
|
||||||
|
teamUid: b.teamUid,
|
||||||
|
// teams 表可能没有对应行(旧数据只写了 aggregation)⇒ 回退为空,前端展示「—」
|
||||||
|
teamId: meta ? meta.team_id : null,
|
||||||
|
teamNumber: meta ? (meta.team_number || meta.team_id) : null,
|
||||||
|
schoolName: Array.from(b.schools).join(' / '),
|
||||||
|
schoolConflict: b.schools.size > 1,
|
||||||
|
memberCount,
|
||||||
|
activatedCount,
|
||||||
|
activated: memberCount > 0 && activatedCount === memberCount,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 稳定排序:组别 → 队伍号(数值升序;非数值队号排后面)
|
||||||
|
const GROUP_ORDER = { zhongzhi: 0, gaozhi: 1, benke: 2, jiaoshi: 3 };
|
||||||
|
all.sort((x, y) => {
|
||||||
|
const gx = GROUP_ORDER[x.group] === undefined ? 9 : GROUP_ORDER[x.group];
|
||||||
|
const gy = GROUP_ORDER[y.group] === undefined ? 9 : GROUP_ORDER[y.group];
|
||||||
|
if (gx !== gy) return gx - gy;
|
||||||
|
return (x.teamUid || 0) - (y.teamUid || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const summarize = arr => ({
|
||||||
|
total: arr.length,
|
||||||
|
activated: arr.filter(t => t.status === 'activated').length,
|
||||||
|
partial: arr.filter(t => t.status === 'partial').length,
|
||||||
|
inactive: arr.filter(t => t.status === 'inactive').length,
|
||||||
|
memberTotal: arr.reduce((s, t) => s + t.memberCount, 0),
|
||||||
|
memberActivated: arr.reduce((s, t) => s + t.activatedCount, 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 筛选(与 getAccounts 的 activated 归一化同理:前端 el-select 传字符串)
|
||||||
|
let list = all;
|
||||||
|
if ([true, 1, '1', 'true'].includes(activated)) {
|
||||||
|
list = all.filter(t => t.activated === true);
|
||||||
|
} else if ([false, 0, '0', 'false'].includes(activated)) {
|
||||||
|
list = all.filter(t => t.activated === false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
list,
|
||||||
|
// summary 作用于**筛选前**的全集(给总览用);filteredTotal 说明筛选后条数
|
||||||
|
summary: summarize(all),
|
||||||
|
filteredTotal: list.length,
|
||||||
|
filters: { group: group || '', activated: activated === undefined ? '' : activated, includeTest },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Excel 导入 ====================
|
// ==================== Excel 导入 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -226,6 +385,12 @@ class AdminService extends Service {
|
|||||||
手机号: ['手机号', '手机号码', '联系电话', 'phone'],
|
手机号: ['手机号', '手机号码', '联系电话', 'phone'],
|
||||||
组别: ['组别', 'group'],
|
组别: ['组别', 'group'],
|
||||||
身份: ['身份', 'identity'],
|
身份: ['身份', 'identity'],
|
||||||
|
// R84 续六新增:上游「报名信息表」确实带「学校/单位名称全称」列,但此前列名契约
|
||||||
|
// **有意忽略**该列 ⇒ 落库时丢失 ⇒ 生产库 `aggregation.organization_cn`
|
||||||
|
// 600 行全为空(2026-10-08 只读取证)⇒「小组所属学校」无从展示。
|
||||||
|
// 现补入契约,写入 `aggregation.organization_cn`(该列运行库既有,模型声明见
|
||||||
|
// `model/aggregation.js`)。列名以上游导出为准,另兼容几种常见写法以防改版。
|
||||||
|
学校: ['学校/单位名称全称', '学校/单位名称全称(英文)', '学校名称', '单位名称', '学校', 'organization_cn'],
|
||||||
};
|
};
|
||||||
// 按别名顺序取首个非空值(统一 trim,返回字符串)
|
// 按别名顺序取首个非空值(统一 trim,返回字符串)
|
||||||
const pick = (row, key) => {
|
const pick = (row, key) => {
|
||||||
@@ -356,6 +521,8 @@ class AdminService extends Service {
|
|||||||
const teamIdRaw = pick(row, '队伍编号');
|
const teamIdRaw = pick(row, '队伍编号');
|
||||||
// 聚合记录的身份口径与作品/答题列表保持一致(作品列表统一记为「选手」)
|
// 聚合记录的身份口径与作品/答题列表保持一致(作品列表统一记为「选手」)
|
||||||
const identity = '选手';
|
const identity = '选手';
|
||||||
|
// R84 续六:学校 / 单位名称(上游「报名信息表」的「学校/单位名称全称」列)
|
||||||
|
const schoolName = pick(row, '学校');
|
||||||
|
|
||||||
if (!idNumber) {
|
if (!idNumber) {
|
||||||
result.errors.push({ row: rowIndex, message: '身份证号为空' });
|
result.errors.push({ row: rowIndex, message: '身份证号为空' });
|
||||||
@@ -425,8 +592,16 @@ class AdminService extends Service {
|
|||||||
account_uid: contestant.id,
|
account_uid: contestant.id,
|
||||||
identity,
|
identity,
|
||||||
is_registrant: true,
|
is_registrant: true,
|
||||||
|
// R84 续六:把「学校/单位名称全称」落库,供「选手管理 · 小组视图」展示所属学校。
|
||||||
|
// 取不到时写 null(而非 ''),与「未填写」语义一致。
|
||||||
|
organization_cn: schoolName || null,
|
||||||
}, { transaction: t });
|
}, { transaction: t });
|
||||||
}
|
}
|
||||||
|
// ⚠️ R84 续六注记(勿误以为「再导一次就能补学校」):
|
||||||
|
// 本函数在第 541-553 行已按 `id_number` 判重,**已存在的选手会整行 skip 并 rollback**,
|
||||||
|
// 根本走不到这里 ⇒ **重复导入不会回填历史行的学校**。
|
||||||
|
// 历史数据(生产 600 行 organization_cn 全空)必须走一次性回填工具:
|
||||||
|
// `scripts/backfill_organization_cn.py`(默认 dry-run,只填空、不覆盖)。
|
||||||
|
|
||||||
await t.commit();
|
await t.commit();
|
||||||
result.imported++;
|
result.imported++;
|
||||||
|
|||||||
Reference in New Issue
Block a user