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:
@@ -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) ====================
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user