feat(score): 成绩排名表导出 R220(客观题分排名 + 三档底色 + 前80红框)+ 修公示表参赛单位

中职/高职按小组客观题分降序全量导出,一行 = 一个小组,6 列:
名次 / 小组编号 / 学校 / 参赛选手 / 人数 / 客观题分(不含大题分与总分)。

- 整行按客观题分区间上底色:>=60 绿 / 50-59.99 黄 / <50 红
- 前 min(80, 行数) 行用**一个红色外框整体圈住**(首行画上边、末行画下边、
  行间不画横线),红框范围与统计块「前80名」同源,恒等于 min(topN, 行数)
- 表尾三区间小组数统计:全量 + 全量排名前 80 两列(是"全部小组降序后连续取
  前 80",不是各分数区间分别取 80)
- 零新增运行时依赖:CE 版 @e965/xlsx 写盘会丢弃全部单元格样式 => zip 层手改
  styles.xml 追加 fills/fonts/borders/cellXfs,再重建 sheetData 写入 s=,
  复用 publicitySheet 的 readZip/writeZip(Node 内置 zlib)
- 分数走 getScores(includeIdle:true) 实时口径(autoScore+wordScore),
  不读 contestants.score;includeIdle 必须传,否则"从未参赛"者被隐藏
- 组归属只认 resolveTeamLabels;代表成员按客观题分最高者选取
- 上色/红框失败自动降级为无色版(文件名加「-无底色」),自检从产物反查
  红边索引逐行核对,错误不会静默流到用户手里
- 路由登记 adminPermissions(scores 模块);未登记会被"默认拒绝"判 403

修复 buildPublicityXlsx 参赛单位取值(R220):
where:{group} -> account_uid IN (contestants.id)。
生产库 aggregation.group 中职/高职 161 行全 NULL,旧写法导出的公示表
「参赛单位名称」整列空白;改后非空率 100%。
This commit is contained in:
2026-10-22 17:25:22 +08:00
parent 78f34ea9f5
commit c15b46a0f9
5 changed files with 904 additions and 8 deletions

View File

@@ -349,6 +349,63 @@ class AdminController extends Controller {
}
}
/**
* 成绩排名表导出(R220)
* GET /api/admin/export-score-rank/:group
*
* 一行 = 一个参赛小组,按小组**客观题分**降序**全量**导出(不截断 80 个),
* 整行按客观题分区间上底色(≥60 绿 / 50–59.99 黄 / <50 红),
* 表尾给三区间小组数统计(全量 + 全量排名前 80)。
*
* ⛔ 与 exportExcel 用途不同:那个是内部核查表(含身份证号 / 手机号);
* 本表只有名次 / 小组编号 / 学校 / 姓名 / 客观题分,可直接给评审看。
* 🔴 R220-C 用户裁定:本表按**客观题分**筛选 ⇒ 只出 6 列,「大题分」「总分」不出现;
* 排序键 / 并列判据 / 区间底色三者同源,均为客观题分。
* ⚠️ 客观题分仍走「实时口径」(与成绩管理页面同源),⛔ 不读 contestants.score。
*/
async exportScoreRank() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
// 第二参数 false = 只允许考试组(中职 / 高职)
if (!this._validateGroup(group, false)) return;
const built = await service.admin.buildScoreRankXlsx(group);
// `?meta=1` 只回统计、不下载:前端在点「导出」前用它提示
// 「本组 N 人无小组归属」「底色生成失败已降级」这类需要管理员知情的事实。
// ⚠️ 不能靠自定义响应头带这些字段 —— 生产经 X-Accel 时上游自定义头会被丢弃。
if (String(ctx.query.meta || '') === '1') {
ctx.body = ctx.helper.success({
fileName: built.fileName,
rowCount: built.rowCount,
colored: built.colored,
degradeReason: built.degradeReason,
stats: built.stats,
topN: built.topN,
totalContestants: built.totalContestants,
noTeamCount: built.noTeamCount,
});
return;
}
ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
// 中文文件名:content-disposition 会输出 filename*=UTF-8''… 形式,
// nginx 的 X-Accel 只丢弃自定义头、不丢弃 Content-Disposition(实测)
ctx.attachment(built.fileName);
ctx.body = built.buffer;
} catch (err) {
ctx.logger.error('导出成绩排名表失败:', err);
if (err && err.bizCode === 'BAD_GROUP') {
ctx.body = ctx.helper.error(400, err.message);
return;
}
ctx.body = ctx.helper.error(500, (err && err.message) ? err.message : '导出成绩排名表失败');
}
}
// ==================== 赛项信息配置(R176) ====================
/**