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) ==================== // ==================== 赛项信息配置(R176) ====================
/** /**

View File

@@ -116,6 +116,8 @@ const ROUTE_TABLE = [
['GET', '/api/admin/export-csv/:group', ['scores']], ['GET', '/api/admin/export-csv/:group', ['scores']],
['GET', '/api/admin/export-excel/:group', ['scores']], ['GET', '/api/admin/export-excel/:group', ['scores']],
['GET', '/api/admin/export-publicity/:group', ['scores']], ['GET', '/api/admin/export-publicity/:group', ['scores']],
// R220 成绩排名表:同属「成绩管理」导出能力,与 export-excel / export-publicity 同权限
['GET', '/api/admin/export-score-rank/:group', ['scores']],
['GET', '/api/admin/competition-item', ['scores']], ['GET', '/api/admin/competition-item', ['scores']],
['PUT', '/api/admin/competition-item', ['scores']], ['PUT', '/api/admin/competition-item', ['scores']],
['GET', '/api/admin/big-answers/:group', ['scores', 'review']], ['GET', '/api/admin/big-answers/:group', ['scores', 'review']],

685
app/lib/scoreRankSheet.js Normal file
View File

@@ -0,0 +1,685 @@
'use strict';
/**
* 成绩排名表导出引擎(R220)
*
* 目标:中职组 / 高职组考试结束后,按小组客观题分降序全量导出排名表,
* 整行按分数区间上底色,表尾给出三区间的小组数统计。
*
* 🔴 为什么要自己在 zip 层动 styles.xml:
* `@e965/xlsx`(SheetJS CE)写 xlsx 时**完全丢弃样式**(连 `s=` 属性都不写),
* 实测 2026-10-19:模板 214 个样式引用写回后变 0、styles.xml 从 22773 → 1546 字节。
* ⇒ 唯一能上色的做法 = 先用该库产出一个合法的 xlsx 骨架,
* 再解压 → 改 `xl/styles.xml`(追加 fills + cellXfs)→ 重建 `sheetData`(写 `s=`)
* → 按原始压缩字节重新打包。
*
* 🔴 零新依赖:zip 读写与 XML 片段工具全部复用 `lib/publicitySheet.js`
* (该模块只用 Node 内置 zlib + 自实现 CRC32)⇒ 生产上线**不需要 npm install**,
* 也就不必为此停机。
*
* 🔴 全量重建 sheetData 而不是「只补 s 属性」:该库把字符串写成 `t="str"` + `<v>文本</v>`
* (这是「公式字符串」语义,无 `<f>` 时并不规范)。既然要逐行改样式,
* 索性用 `cellXml()` 以 `inlineStr` 重建每一行 ⇒ 顺带消掉这个隐患,
* 也保证「整行」A..H 每格都存在(该库会跳过空值单元格,底色会断成小块)。
*
* 🔴 分数口径:**必须由调用方传入实时口径**(客观题分 + 大题分)。
* `contestants.score` 只有管理员「成绩管理」里保存过分数后才有值,
* 考试刚结束恒为 0 —— 直接读它会导出「页面 78 分、表格 0 分」的假表。
*/
const XLSX = require('@e965/xlsx');
const {
readZip,
writeZip,
entryText,
replaceEntryText,
cellXml,
} = require('./publicitySheet');
// ==================== 常量 ====================
/**
* 表头(6 列,顺序即写入顺序)。
*
* 🔴 R220-C 用户裁定:本表**按客观题分筛选**,故「大题分」「总分」两列不出现。
* 删掉总分列有个强连带约束:底色/名次若仍按总分算,用户看不到总分就无法
* 解释某行为什么是绿的 ⇒ 排序键、并列判据、区间判定**三者必须同为客观题分**。
*/
const HEADERS = ['名次', '小组编号', '学校', '参赛选手', '人数', '客观题分'];
/** 6 个数据列 */
const DATA_COLS = ['A', 'B', 'C', 'D', 'E', 'F'];
/** 列宽(字符数) */
const COL_WIDTHS = [
{ wch: 7 }, // 名次
{ wch: 18 }, // 小组编号
{ wch: 34 }, // 学校
{ wch: 34 }, // 参赛选手
{ wch: 7 }, // 人数
{ wch: 11 }, // 客观题分
];
/** 默认统计口径:前 N 名 */
const DEFAULT_TOP_N = 80;
/**
* 客观题满分(R220-D 用户补充的事实)。
*
* ⚠️ 它**只用于说明行文字**,不参与任何判定:
* 区间阈值仍是用户裁定的 60 / 50(70 分制下 60 分 = 86%,属高分段)。
* ⛔ 不要因为满分变化就自动缩放阈值 —— 那是用户裁定过的业务规则。
* 若某次考试的满分不是 70,由调用方传 fullScore 覆盖即可。
*/
const DEFAULT_FULL_SCORE = 70;
/**
* 三个分数区间的**单一判定源**。
* 顺序即优先级:先 ≥60,再 ≥50,其余归红。
* 边界按用户裁定:≥60 绿 / 50–59.99 黄 / <50 红。
*
* 🔴 判定输入是**客观题分**(examScore),不是总分(R220-C 用户裁定)。
* 阈值本身未变,只是被量的对象换了 —— 因为表格里已无总分列。
*/
const BANDS = [
{ key: 'green', label: '≥60 分(绿)', min: 60, rgb: 'FFC6EFCE' },
{ key: 'yellow', label: '50–59.99 分(黄)', min: 50, rgb: 'FFFFEB9C' },
{ key: 'red', label: '<50 分(红)', min: -Infinity, rgb: 'FFFFC7CE' },
];
/** 表头底色(灰) */
const HEADER_RGB = 'FFD9D9D9';
/** 前 N 名「红框」的线色(R220-D) */
const RED_BORDER_RGB = 'FFFF0000';
// ==================== 纯函数层 ====================
/** 转数字,非法值归 0 */
function toNum(v) {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
/** 保留两位小数,消除浮点噪声(如 59.99999999) */
function round2(v) {
return Math.round(toNum(v) * 100) / 100;
}
/**
* 分数区间判定(唯一入口)。
* @param {number} score 小组**客观题分**(examScore)
* @returns {'green'|'yellow'|'red'}
*/
function scoreBand(score) {
const n = toNum(score);
for (const b of BANDS) {
if (n >= b.min) return b.key;
}
return 'red';
}
/**
* 名次编排:按**客观题分**(examScore)降序,同分并列(1、2、2、4)。
*
* 🔴 排序键 = 客观题分,与「区间底色判定」同源(R220-C 用户裁定)。
* ⛔ 不要改成 total:表格里已无总分列,按总分排序会让名次与可见分数矛盾
* (出现「客观题 90 分排第 5、客观题 70 分排第 1」这种用户无法理解的结果)。
*
* 并列组内的先后顺序用「人数升序 → 小组编号升序」定,纯为了结果可复现,
* 不影响名次数值本身。
*
* 注:返回项里仍保留 wordScore / total 字段供内部排查与既有断言使用,
* 但 **不写入表格**(HEADERS 只有 6 列)。
*
* @param {Array<Object>} teams 每项 {teamId, org, players, memberCount, examScore, wordScore, total}
* @returns {Array<Object>} 追加了 rank 字段的新数组(不改入参)
*/
function buildRankRows(teams) {
const list = (teams || []).map(t => ({
teamId: t && t.teamId != null ? String(t.teamId) : '',
org: t && t.org != null ? String(t.org) : '',
players: t && t.players != null ? String(t.players) : '',
memberCount: Math.trunc(toNum(t && t.memberCount)),
examScore: round2(t && t.examScore),
wordScore: round2(t && t.wordScore),
total: round2(t && t.total),
}));
list.sort((a, b) => {
if (b.examScore !== a.examScore) return b.examScore - a.examScore;
if (a.memberCount !== b.memberCount) return a.memberCount - b.memberCount;
return a.teamId < b.teamId ? -1 : (a.teamId > b.teamId ? 1 : 0);
});
let prevScore = null;
let prevRank = 0;
for (let i = 0; i < list.length; i++) {
const cur = list[i].examScore;
if (prevScore !== null && cur === prevScore) {
list[i].rank = prevRank; // 并列:沿用上一个名次
} else {
list[i].rank = i + 1; // 跳号:1、2、2、4
prevScore = cur;
prevRank = list[i].rank;
}
}
return list;
}
/**
* 三区间小组数统计,两套口径(全量 / 全量排名前 N)。
*
* 🔴 「前 N 名」= 全部小组降序排序后**连续**取前 N 个(跨区间截取),
* ⛔ 不是「名次 ≤ N」,⛔ 也不是「每个区间各取前 N 个」。
* 保证该列合计恒 = min(总数, N),与「筛选出排名靠前的 80 个小组」的口径一致。
* selfCheck() 里有对应断言,误实现成区间各取会在自检阶段直接抛错。
*
* @param {Array<Object>} rows buildRankRows 的产物
* @param {number} topN 默认 80
*/
function buildStats(rows, topN) {
const n = Math.max(0, Math.trunc(toNum(topN == null ? DEFAULT_TOP_N : topN)));
const all = rows || [];
const top = all.slice(0, n);
const countOf = arr => {
const c = { green: 0, yellow: 0, red: 0 };
// 区间判定与排序键同源:客观题分(R220-C)
for (const r of arr) c[scoreBand(r.examScore)]++;
return c;
};
return {
topN: n,
total: all.length,
topTotal: top.length,
all: countOf(all),
top: countOf(top),
};
}
/**
* 统计块下方的口径说明文字。
*
* 🔴 为什么需要它(R220 用户反馈):当小组总数 ≤ topN 时,「全量小组」与
* 「前N名(总排名)」两列数字必然完全相同 —— 用户看到 7 / 7 会合理怀疑
* 「是不是每个区间各取了 7 个」。列名再清楚也压不住这种猜疑,
* 干脆用一行文字把口径写死,同时把「为何两列一样」当场解释掉。
*/
function buildScopeNote(stats, fullScore) {
const n = stats.topN;
const full = fullScore == null ? DEFAULT_FULL_SCORE : fullScore;
const head = `说明:本表按客观题分(满分 ${full} 分)降序排列,名次、区间底色与红框范围均以客观题分为准;`;
const boxed = Math.min(n, stats.total);
const tail = `表格中前 ${boxed} 行已用红色外框圈出(跨区间连续,行间不画横线,以便看成一个整体)。`;
if (stats.total <= n) {
return `${head}本次共 ${stats.total} 个小组,未超过 ${n} 个,故「前${n}名(总排名)」列等于全量,两列数字相同属正常。`
+ `前${n}名 = 全部小组按客观题分降序排列后连续取前 ${n} 个,不是各分数区间分别取前 ${n} 个。${tail}`;
}
return `${head}「前${n}名(总排名)」= 全部 ${stats.total} 个小组按客观题分降序排列后连续取前 ${n} 个,可跨分数区间截取;`
+ `不是各分数区间分别取前 ${n} 个(若按区间各取,合计会远超 ${n})。${tail}`;
}
// ==================== styles.xml 手术 ====================
/** 取某个元素块内的直接子元素个数(按标签名首段统计) */
function countChildren(inner, tag) {
const re = new RegExp(`<${tag}\\b`, 'g');
return (inner.match(re) || []).length;
}
/**
* 在 styles.xml 末尾追加「表头灰底 + 三档底色 + 前 N 名红框」的
* fills / fonts / borders / cellXfs。
*
* ⚠️ 不假定基线 count 的具体数值:一律按**实际子元素个数**取索引并回写 count,
* 这样即使将来 @e965/xlsx 版本升级改了默认 fills 数量也不会把索引错位。
*
* 🔴 红框为什么要 4 个 border + 12 个 xf(R220-D):
* 用户要「前 80 个小组被一个红框整体圈住」,而不是每行各画一个框。
* 单格边框只能表达「这条边上有没有线」,所以整体外框得靠**按行位置分工**拼出来:
* 首行 → 上边 + 左右;中间行 → 只有左右;末行 → 下边 + 左右。
* 每行还有 3 种底色,故 3 底色 × 4 位置(含「只有 1 行」的四边全画)= 12 个 xf。
* 若改成「每行四边全画」,相邻行之间的红线会叠成红色网格,视觉上不再是「框选」。
*
* @param {string} xml styles.xml 原文
* @returns {{ xml: string, style: Object }} style 含 header/green/yellow/red 与 box[位置][颜色]
*/
function patchStyles(xml) {
const fillsRe = /<fills\b([^>]*)>([\s\S]*?)<\/fills>/;
const fontsRe = /<fonts\b([^>]*)>([\s\S]*?)<\/fonts>/;
const bordersRe = /<borders\b([^>]*)>([\s\S]*?)<\/borders>/;
const xfsRe = /<cellXfs\b([^>]*)>([\s\S]*?)<\/cellXfs>/;
const fillsM = fillsRe.exec(xml);
const fontsM = fontsRe.exec(xml);
const bordersM = bordersRe.exec(xml);
const xfsM = xfsRe.exec(xml);
if (!fillsM) throw new Error('styles.xml 缺少 <fills>');
if (!fontsM) throw new Error('styles.xml 缺少 <fonts>');
if (!bordersM) throw new Error('styles.xml 缺少 <borders>');
if (!xfsM) throw new Error('styles.xml 缺少 <cellXfs>');
const baseFillCount = countChildren(fillsM[2], 'fill');
const baseFontCount = countChildren(fontsM[2], 'font');
const baseBorderCount = countChildren(bordersM[2], 'border');
const baseXfCount = countChildren(xfsM[2], 'xf');
if (baseFillCount < 2) throw new Error(`styles.xml fills 基线异常(${baseFillCount})`);
if (baseFontCount < 1) throw new Error(`styles.xml fonts 基线异常(${baseFontCount})`);
if (baseXfCount < 1) throw new Error(`styles.xml cellXfs 基线异常(${baseXfCount})`);
// ---- fills:表头灰 + 三档底色 ----
const solid = rgb => `<fill><patternFill patternType="solid"><fgColor rgb="${rgb}"/><bgColor indexed="64"/></patternFill></fill>`;
const addedFills = [HEADER_RGB, ...BANDS.map(b => b.rgb)].map(solid).join('');
const fHeader = baseFillCount;
const fBand = {};
BANDS.forEach((b, i) => { fBand[b.key] = baseFillCount + 1 + i; });
let out = xml.replace(fillsM[0], () =>
`<fills count="${baseFillCount + 1 + BANDS.length}">${fillsM[2]}${addedFills}</fills>`);
// ---- fonts:表头加粗 ----
const boldFontId = baseFontCount;
const boldFont = '<font><b/><sz val="12"/><color theme="1"/><name val="Calibri"/><family val="2"/><scheme val="minor"/></font>';
out = out.replace(fontsM[0], () =>
`<fonts count="${baseFontCount + 1}">${fontsM[2]}${boldFont}</fonts>`);
// ---- borders:4 种红框(顺序必须是 left/right/top/bottom/diagonal)----
const side = name => `<${name} style="medium"><color rgb="${RED_BORDER_RGB}"/></${name}>`;
const empty = name => `<${name}/>`;
const borderXml = (top, bottom) =>
`<border>${side('left')}${side('right')}${top ? side('top') : empty('top')}${bottom ? side('bottom') : empty('bottom')}<diagonal/></border>`;
// 位置 → [是否有上边, 是否有下边]
const BOX_POS = {
single: [true, true], // 前 N 名只有 1 行时,这一个格子四边都要有
top: [true, false],
mid: [false, false],
bot: [false, true],
};
const BOX_KEYS = ['single', 'top', 'mid', 'bot'];
const addedBorders = BOX_KEYS.map(k => borderXml(BOX_POS[k][0], BOX_POS[k][1])).join('');
const bId = {};
BOX_KEYS.forEach((k, i) => { bId[k] = baseBorderCount + i; });
out = out.replace(bordersM[0], () =>
`<borders count="${baseBorderCount + BOX_KEYS.length}">${bordersM[2]}${addedBorders}</borders>`);
// ---- cellXfs ----
const xf = (fillId, opt) => {
const o = opt || {};
const borderId = o.borderId == null ? 0 : o.borderId;
const attrs = [
'numFmtId="0"',
`fontId="${o.bold ? boldFontId : 0}"`,
`fillId="${fillId}"`,
`borderId="${borderId}"`,
'xfId="0"',
];
if (fillId !== 0) attrs.push('applyFill="1"');
if (o.bold) attrs.push('applyFont="1"');
if (borderId !== 0) attrs.push('applyBorder="1"');
return `<xf ${attrs.join(' ')}/>`;
};
const addedXfs = [xf(fHeader, { bold: true })];
// 纯底色(不在前 N 名里的行)
BANDS.forEach(b => addedXfs.push(xf(fBand[b.key])));
// 前 N 名:底色 × 位置
const xfIndex = {};
BOX_KEYS.forEach(k => {
xfIndex[k] = {};
BANDS.forEach(b => {
xfIndex[k][b.key] = baseXfCount + addedXfs.length;
addedXfs.push(xf(fBand[b.key], { borderId: bId[k] }));
});
});
const style = {
header: baseXfCount,
box: xfIndex,
};
BANDS.forEach(b => { style[b.key] = baseXfCount + 1 + BANDS.indexOf(b); });
out = out.replace(xfsM[0], () =>
`<cellXfs count="${baseXfCount + addedXfs.length}">${xfsM[2]}${addedXfs.join('')}</cellXfs>`);
return { xml: out, style };
}
// ==================== sheetData 重建 ====================
function rowXml(r, cells) {
return `<row r="${r}">${cells.join('')}</row>`;
}
/**
* 生成完整的 sheetData(含表头行、数据行、空行、统计块)。
*
* 行号布局:
* 1 表头
* 2 .. 1+N 数据(N = 小组数;N=0 时数据区为空)
* 2+N 空行
* 3+N 统计表头(区间统计 / 全量小组 / 前 K 名(总排名))
* 4+N .. 6+N 三个区间
* 7+N 合计
* 8+N 口径说明(一行文字,解释「前 K 名」是全量跨区间截取)
*
* @returns {{ xml:string, lastRow:number }}
*/
function buildSheetData(rows, stats, style, fullScore) {
const out = [];
const R = k => k;
// 表头
out.push(rowXml(R(1), HEADERS.map((h, i) => cellXml(DATA_COLS[i], 1, style.header, h))));
// 数据行(6 列:名次/小组编号/学校/参赛选手/人数/客观题分)
// 🔴 底色取值按 examScore 判定 —— 与排序键同源,否则「分数高低」与「颜色」矛盾
// 🔴 前 N 行额外套红框:红框范围与统计块「前N名」列**同一口径**(排序后前 N 行),
// 保证「框住的」和「统计里算的」永远一致,不会一个框 80 行、一个算 80 组。
const boxCount = Math.min(stats.topN, rows.length);
for (let i = 0; i < rows.length; i++) {
const r = i + 2;
const row = rows[i];
const band = scoreBand(row.examScore);
let s;
if (i < boxCount) {
// 首行画上边、末行画下边、中间只画左右 ⇒ 拼成一个整体外框(行间无横线)
const pos = boxCount === 1 ? 'single' : (i === 0 ? 'top' : (i === boxCount - 1 ? 'bot' : 'mid'));
s = style.box[pos][band];
} else {
s = style[band];
}
out.push(rowXml(r, [
cellXml('A', r, s, row.rank),
cellXml('B', r, s, row.teamId),
cellXml('C', r, s, row.org),
cellXml('D', r, s, row.players),
cellXml('E', r, s, row.memberCount),
cellXml('F', r, s, row.examScore),
]));
}
const n = rows.length;
const blankRow = n + 2;
const statHeadRow = n + 3;
// 统计块表头
// 🔴 列标题曾写「其中前80名」,被用户读成「各个区间其中前80名」(R220 反馈)。
// 实际口径是**全量排名**前 N,故列名改为「前N名(总排名)」消除歧义。
out.push(rowXml(statHeadRow, [
cellXml('A', statHeadRow, style.header, '区间统计'),
cellXml('B', statHeadRow, style.header, '全量小组'),
cellXml('C', statHeadRow, style.header, `前${stats.topN}名(总排名)`),
]));
// 三个区间(A 列带该区间的底色,作为图例)
BANDS.forEach((b, i) => {
const r = statHeadRow + 1 + i;
out.push(rowXml(r, [
cellXml('A', r, style[b.key], b.label),
cellXml('B', r, undefined, stats.all[b.key]),
cellXml('C', r, undefined, stats.top[b.key]),
]));
});
// 合计
const sumRow = statHeadRow + 1 + BANDS.length;
out.push(rowXml(sumRow, [
cellXml('A', sumRow, style.header, '合计'),
cellXml('B', sumRow, undefined, stats.total),
cellXml('C', sumRow, undefined, stats.topTotal),
]));
// 口径说明行(无底色,A 列文字自然溢出到右侧空列)
const noteRow = sumRow + 1;
out.push(rowXml(noteRow, [
cellXml('A', noteRow, undefined, buildScopeNote(stats, fullScore)),
]));
return { xml: `<sheetData>${out.join('')}</sheetData>`, lastRow: noteRow, blankRow };
}
// ==================== 组装 ====================
/** 造一个最小合法 xlsx 骨架(表头一行的空表),后续全部靠 zip 层替换 */
function buildBaseBuffer(sheetName) {
const ws = XLSX.utils.aoa_to_sheet([HEADERS.slice()]);
ws['!cols'] = COL_WIDTHS.map(c => ({ wch: c.wch }));
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, String(sheetName || 'Sheet1').slice(0, 31));
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
}
/** 从 zip 条目里取某文件文本 */
function textOf(entries, name) {
const e = entries.find(x => x.name === name);
if (!e) throw new Error(`xlsx 缺少条目 ${name}`);
return entryText(e);
}
/**
* 主入口:生成成绩排名表 xlsx。
*
* @param {Object} opt
* @param {Array<Object>} opt.teams 原始小组数组(未经排序)
* @param {string} [opt.sheetName] 工作表名
* @param {number} [opt.topN=80] 统计口径里的「前 N 名」
* @returns {{ buffer: Buffer, colored: boolean, rows: Array<Object>, stats: Object, lastRow: number }}
* colored=false 表示上色链路失败已自动降级为无色版
*/
function buildXlsx(opt) {
const options = opt || {};
const rows = buildRankRows(options.teams);
const topN = options.topN == null ? DEFAULT_TOP_N : options.topN;
const fullScore = options.fullScore == null ? DEFAULT_FULL_SCORE : options.fullScore;
const stats = buildStats(rows, topN);
const sheetName = options.sheetName || '成绩排名';
const base = buildBaseBuffer(sheetName);
// ---- 第一路:带底色 ----
try {
const entries = readZip(base);
const stylesPatched = patchStyles(textOf(entries, 'xl/styles.xml'));
let out = replaceEntryText(entries, 'xl/styles.xml', stylesPatched.xml);
const built = buildSheetData(rows, stats, stylesPatched.style, fullScore);
let sheetXml = textOf(out, 'xl/worksheets/sheet1.xml');
const sdRe = /<sheetData\b[^>]*>[\s\S]*?<\/sheetData>/;
if (!sdRe.test(sheetXml)) throw new Error('sheet1.xml 缺少 <sheetData>');
sheetXml = sheetXml.replace(sdRe, built.xml);
// dimension 必须同步,否则 Excel 的滚动区/打印区按旧范围截断
sheetXml = sheetXml.replace(
/<dimension\b[^>]*\bref="[^"]*"/,
`<dimension ref="A1:${DATA_COLS[DATA_COLS.length - 1]}${built.lastRow}"`
);
// 骨架带出来的 ignoredErrors 指向 A1:H1,范围已失真,直接删掉避免误报
sheetXml = sheetXml.replace(/<ignoredErrors\b[^>]*\/>/g, '')
.replace(/<ignoredErrors\b[^>]*>[\s\S]*?<\/ignoredErrors>/g, '');
out = replaceEntryText(out, 'xl/worksheets/sheet1.xml', sheetXml);
const buffer = writeZip(out);
selfCheck(buffer, rows, stats, true);
return { buffer, colored: true, rows, stats, lastRow: built.lastRow };
} catch (err) {
// ---- 第二路:降级无色 ----
const buffer = buildPlain(rows, stats, sheetName, fullScore);
selfCheck(buffer, rows, stats, false);
return {
buffer,
colored: false,
rows,
stats,
lastRow: rows.length + 8,
degradeReason: err && err.message ? err.message : String(err),
};
}
}
/** 降级版:不上任何底色,但数据/表头/统计块与带色版完全一致 */
function buildPlain(rows, stats, sheetName, fullScore) {
const aoa = [HEADERS.slice()];
for (const row of rows) {
aoa.push([row.rank, row.teamId, row.org, row.players, row.memberCount, row.examScore]);
}
aoa.push([]);
aoa.push(['区间统计', '全量小组', `前${stats.topN}名(总排名)`]);
for (const b of BANDS) aoa.push([b.label, stats.all[b.key], stats.top[b.key]]);
aoa.push(['合计', stats.total, stats.topTotal]);
aoa.push([buildScopeNote(stats, fullScore)]);
const ws = XLSX.utils.aoa_to_sheet(aoa);
ws['!cols'] = COL_WIDTHS.map(c => ({ wch: c.wch }));
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, String(sheetName || '成绩排名').slice(0, 31));
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
}
/**
* 自检:回读产物,确认「能读通 + 行数对 + 首行值对 +(带色版)styles 里真有底色」。
* 任一条不满足就抛错,由上层降级。
*/
function selfCheck(buffer, rows, stats, expectColor) {
const wb = XLSX.read(buffer, { type: 'buffer' });
const name = wb.SheetNames[0];
const ws = wb.Sheets[name];
if (!ws) throw new Error('自检失败:产物无工作表');
const aoa = XLSX.utils.sheet_to_json(ws, { header: 1, blankrows: true });
if (!aoa.length) throw new Error('自检失败:产物为空');
const head = aoa[0] || [];
for (let i = 0; i < HEADERS.length; i++) {
if (String(head[i] == null ? '' : head[i]) !== HEADERS[i]) {
throw new Error(`自检失败:表头第 ${i + 1} 列应为「${HEADERS[i]}」,实为「${head[i]}」`);
}
}
// 数据区行数
const dataRows = aoa.slice(1, 1 + rows.length);
if (dataRows.length !== rows.length) {
throw new Error(`自检失败:数据行数 ${dataRows.length} ≠ 期望 ${rows.length}`);
}
// 首行关键值抽查(名次 / 小组编号 / 客观题分)
if (rows.length) {
const first = dataRows[0] || [];
if (String(first[0]) !== String(rows[0].rank)) throw new Error('自检失败:首行名次不符');
if (String(first[1]) !== String(rows[0].teamId)) throw new Error('自检失败:首行小组编号不符');
if (Number(first[5]) !== Number(rows[0].examScore)) throw new Error('自检失败:首行客观题分不符');
}
// 🔴 列数锁死:本表只出 6 列,「大题分」「总分」不得回流(R220-C 用户裁定)
if (HEADERS.length !== 6) throw new Error(`自检失败:表头列数应为 6,实为 ${HEADERS.length}`);
for (let i = 6; i < head.length; i++) {
if (String(head[i] == null ? '' : head[i]).trim() !== '') {
throw new Error(`自检失败:第 ${i + 1} 列应不存在,实为「${head[i]}」`);
}
}
// 排序/名次必须由客观题分驱动:逐行校验「客观题分不得逆序」
for (let i = 1; i < rows.length; i++) {
if (rows[i - 1].examScore < rows[i].examScore) {
throw new Error(`自检失败:第 ${i} 行客观题分逆序(${rows[i - 1].examScore} < ${rows[i].examScore})`);
}
}
// 统计块
const statHead = aoa[1 + rows.length + 1] || [];
if (String(statHead[0] || '') !== '区间统计') throw new Error('自检失败:统计块表头缺失');
const expectC = `前${stats.topN}名(总排名)`;
if (String(statHead[2] || '') !== expectC) {
throw new Error(`自检失败:统计块 C 列表头应为「${expectC}」,实为「${statHead[2]}」`);
}
// 🔴 口径自证:「前N名」列合计必须 = min(总数, N)。
// 若被误实现成「各分数区间分别取前 N 个」,总数 > N 时该值会超 N ⇒ 此处直接红。
const expectTopTotal = Math.min(stats.total, stats.topN);
if (stats.topTotal !== expectTopTotal) {
throw new Error(
`自检失败:前${stats.topN}名合计 ${stats.topTotal} ≠ min(总数 ${stats.total}, ${stats.topN})`
);
}
const topSum = stats.top.green + stats.top.yellow + stats.top.red;
if (topSum !== stats.topTotal) {
throw new Error(`自检失败:三区间前N名之和 ${topSum} ≠ 合计 ${stats.topTotal}`);
}
for (const b of BANDS) {
if (stats.top[b.key] > stats.all[b.key]) {
throw new Error(`自检失败:${b.label} 的「前N名」数 ${stats.top[b.key]} 超过全量数 ${stats.all[b.key]}`);
}
}
if (expectColor) {
const entries = readZip(buffer);
const styles = textOf(entries, 'xl/styles.xml');
for (const b of BANDS) {
if (styles.indexOf(b.rgb) < 0) throw new Error(`自检失败:styles.xml 缺底色 ${b.rgb}`);
}
const sheetXml = textOf(entries, 'xl/worksheets/sheet1.xml');
if (!/\bs="\d+"/.test(sheetXml)) throw new Error('自检失败:sheet1.xml 无任何样式引用');
// ---- 红框校验(R220-D):从产物反推,不依赖内存里的 style 索引 ----
// 先找「含红色 + medium 线宽」的 border 定义,再看哪些 xf 引用了它们,
// 最后逐行核对「前 min(topN, 行数) 行必须是红框 xf,其余行必须不是」。
const bordersBlock = /<borders\b[^>]*>([\s\S]*?)<\/borders>/.exec(styles);
const borderList = bordersBlock ? (bordersBlock[1].match(/<border\b[\s\S]*?<\/border>/g) || []) : [];
const redBorderIds = new Set();
borderList.forEach((b, i) => {
if (b.indexOf(RED_BORDER_RGB) >= 0 && /style="medium"/.test(b)) redBorderIds.add(i);
});
if (redBorderIds.size === 0) throw new Error('自检失败:styles.xml 里没有红色粗边框定义');
const xfsBlock = /<cellXfs\b[^>]*>([\s\S]*?)<\/cellXfs>/.exec(styles);
const xfList = xfsBlock ? (xfsBlock[1].match(/<xf\b[^>]*\/>/g) || []) : [];
const boxXfIds = new Set();
xfList.forEach((x, i) => {
const mm = /borderId="(\d+)"/.exec(x);
if (mm && redBorderIds.has(Number(mm[1]))) boxXfIds.add(i);
});
if (boxXfIds.size === 0) throw new Error('自检失败:没有 cellXfs 引用红色边框');
const expectBox = Math.min(stats.topN, rows.length);
const styleAt = k => {
const rm = new RegExp(`<row r="${k}">([\\s\\S]*?)</row>`).exec(sheetXml);
const sm = rm ? /\bs="(\d+)"/.exec(rm[1]) : null;
return sm ? Number(sm[1]) : -1;
};
for (let k = 0; k < rows.length; k++) {
const sx = styleAt(k + 2);
const hasBox = boxXfIds.has(sx);
const want = k < expectBox;
if (hasBox !== want) {
throw new Error(
`自检失败:第 ${k + 2} 行红框归属错误(应为「${want ? '有框' : '无框'}」,`
+ `实为「${hasBox ? '有框' : '无框'}」,s=${sx},前 ${expectBox} 行应有框)`
);
}
}
}
return { rows: aoa.length, sheetName: name };
}
module.exports = {
HEADERS,
DATA_COLS,
COL_WIDTHS,
DEFAULT_TOP_N,
DEFAULT_FULL_SCORE,
BANDS,
HEADER_RGB,
RED_BORDER_RGB,
toNum,
round2,
scoreBand,
buildRankRows,
buildStats,
buildScopeNote,
patchStyles,
buildSheetData,
buildXlsx,
buildPlain,
selfCheck,
};

View File

@@ -155,6 +155,11 @@ module.exports = app => {
// ⚠️ 与 export-excel 是两个不同产物:export-excel 是**内部核查表**(含身份证号/手机号), // ⚠️ 与 export-excel 是两个不同产物:export-excel 是**内部核查表**(含身份证号/手机号),
// 本接口是**对外公示表**(不含任何身份证号/手机号),⛔ 两者不可互相替代。 // 本接口是**对外公示表**(不含任何身份证号/手机号),⛔ 两者不可互相替代。
router.get('/api/admin/export-publicity/:group', adminAuth, controller.admin.exportPublicity); router.get('/api/admin/export-publicity/:group', adminAuth, controller.admin.exportPublicity);
// 成绩排名表导出(R220):中职/高职按小组**客观题分**降序**全量**导出,整行按客观题分区间上底色。
// ⚠️ 第三个产物,⛔ 不可与上面两个互换:export-excel = 内部核查表(含身份证号/手机号)、
// export-publicity = 对外公示表(模板半成品、获奖等级留空)、
// 本接口 = 排名速览表(一小组一行、含区间底色与区间统计、无任何敏感字段)。
router.get('/api/admin/export-score-rank/:group', adminAuth, controller.admin.exportScoreRank);
// 赛项信息(R176):成绩公示表标题的「赛项名称 / 赛项编号」,管理后台可维护。 // 赛项信息(R176):成绩公示表标题的「赛项名称 / 赛项编号」,管理后台可维护。
// ⚠️ 两个值只影响公示表标题文案,与成绩/账号/队伍无关。 // ⚠️ 两个值只影响公示表标题文案,与成绩/账号/队伍无关。
router.get('/api/admin/competition-item', adminAuth, controller.admin.competitionItemInfo); router.get('/api/admin/competition-item', adminAuth, controller.admin.competitionItemInfo);

View File

@@ -1532,16 +1532,23 @@ answeredCount,
} }
// ---- 4) 参赛单位 ---- // ---- 4) 参赛单位 ----
// 🔴 R220 修复:必须按 account_uid 关联,⛔ 不能用 where:{ group }。
// 生产实测(2026-10-22):`aggregation.group` 只有本科 600 行有值,
// 中职/高职 161 行全 NULL ⇒ 旧写法导出的中职/高职公示表「参赛单位名称」
// 整列空白。改按 account_uid IN (contestants.id) 取:生产 518 名中高职选手
// organization_cn 非空率 100%。
const orgByCid = new Map();
if (contestants.length > 0) {
const aggRows = await ctx.model.Aggregation.findAll({ const aggRows = await ctx.model.Aggregation.findAll({
attributes: ['account_uid', 'organization_cn'], attributes: ['account_uid', 'organization_cn'],
where: { group }, where: { account_uid: { [Op.in]: contestants.map(c => c.id) } },
raw: true, raw: true,
}); });
const orgByCid = new Map();
for (const a of aggRows) { for (const a of aggRows) {
const v = a.organization_cn === null || a.organization_cn === undefined ? '' : String(a.organization_cn).trim(); const v = a.organization_cn === null || a.organization_cn === undefined ? '' : String(a.organization_cn).trim();
if (v && a.account_uid !== null && a.account_uid !== undefined) orgByCid.set(a.account_uid, v); if (v && a.account_uid !== null && a.account_uid !== undefined) orgByCid.set(a.account_uid, v);
} }
}
// ---- 5) 按小组聚合 ---- // ---- 5) 按小组聚合 ----
const bucket = new Map(); const bucket = new Map();
@@ -1610,6 +1617,146 @@ answeredCount,
}; };
} }
/**
* 成绩排名表(R220)—— 中职组 / 高职组按小组客观题分降序全量导出。
*
* 一行 = 一个参赛小组;整行按分数区间上底色;表尾给三区间小组数统计。
*
* 🔴 分数口径必须走 getScores(实时口径 autoScore + wordScore):
* `contestants.score` 只有管理员在「成绩管理」保存过分数后才有值,
* 考试刚结束恒为 0 —— 直接读它会导出「页面 78 分、表格 0 分」的假表。
*
* 🔴 必须 includeIdle=true:本表要「所有小组都导出」,而 getScores 默认
* 隐藏「从未参赛」者(判据 answeredCount>0 || hasWordFile || formalSubmitted)。
*
* 🔴 组归属只认 resolveTeamLabels 给出的 teamUid(aggregation.team_uid 优先,
* contestants.team_id 兜底),⛔ 不另起一套归组逻辑。
*
* 🔴 代表成员按**客观题分**最高者选取(⛔ 不是总分最高者);
* 该成员的客观题分/大题分一次带出,⛔ 不各项独立取 MAX
* (否则会出现「客观题分 + 大题分 ≠ 总分」,被当成 bug 报上来)。
*
* @param {string} group zhongzhi | gaozhi
* @returns {Promise<Object>} { buffer, fileName, rowCount, colored, stats, ... }
*/
async buildScoreRankXlsx(group) {
const { ctx } = this;
const scoreRankSheet = require('../lib/scoreRankSheet');
const { Op } = ctx.app.Sequelize;
if (group !== 'zhongzhi' && group !== 'gaozhi') {
const err = new Error('成绩排名表仅支持中职组 / 高职组');
err.bizCode = 'BAD_GROUP';
throw err;
}
const GROUP_LABEL = { zhongzhi: '中职组', gaozhi: '高职组' };
const groupLabel = GROUP_LABEL[group];
// ---- 1) 实时分数口径(含从未参赛者)----
const list = await this.getScores(group, { includeIdle: true });
// ---- 2) 学校(参赛单位)----
// 🔴 必须按 account_uid 关联:生产库 `aggregation.group` 只有本科行有值,
// 中职/高职 161 行全 NULL ⇒ 用 where:{group} 取会整列为空。
const ids = list.map(x => x.uid);
const orgByCid = new Map();
if (ids.length > 0) {
const aggRows = await ctx.model.Aggregation.findAll({
where: { account_uid: { [Op.in]: ids } },
attributes: ['account_uid', 'organization_cn'],
raw: true,
});
for (const a of aggRows) {
const v = a.organization_cn === null || a.organization_cn === undefined
? '' : String(a.organization_cn).trim();
if (v && a.account_uid !== null && a.account_uid !== undefined && !orgByCid.has(a.account_uid)) {
orgByCid.set(a.account_uid, v);
}
}
}
// ---- 3) 按小组聚合 ----
const bucket = new Map();
let noTeamCount = 0;
for (const it of list) {
const hasTeam = it.teamUid !== null && it.teamUid !== undefined;
if (!hasTeam) noTeamCount++;
// 无队伍元信息的人各自成行(覆盖率不足是数据问题,⛔ 不在导出时静默猜)
const key = hasTeam ? `team:${it.teamUid}` : `single:${it.uid}`;
let b = bucket.get(key);
if (!b) {
b = {
teamId: hasTeam
? String(it.teamId || it.teamNumber || it.teamUid)
: `未分小组#${it.uid}`,
org: '',
names: [],
examScore: -Infinity, // 哨兵:保证 0 分小组也会被「首个成员」覆盖赋值
wordScore: 0,
total: 0,
memberCount: 0,
};
bucket.set(key, b);
}
b.memberCount += 1;
if (it.name) {
const n = String(it.name).trim();
if (n) b.names.push(n);
}
const examScore = Number(it.examScore) || 0;
const wordScore = Number(it.wordScore) || 0;
const total = Number(it.score) || 0;
// 代表成员 = 组内**客观题分**最高者(R220-C)。
// 🔴 取值维度必须与排序维度一致:表格按客观题分排名,若代表成员仍按总分挑,
// 会出现「展示 72 分的组排在展示 68 分的组前面」——名次无法自洽解释。
if (examScore > b.examScore) {
b.examScore = examScore;
b.wordScore = wordScore;
b.total = total;
}
const org = orgByCid.get(it.uid);
if (org && !b.org) b.org = org;
}
const teams = Array.from(bucket.values()).map(b => ({
teamId: b.teamId,
org: b.org,
players: Array.from(new Set(b.names)).join('、'),
memberCount: b.memberCount,
examScore: b.examScore === -Infinity ? 0 : b.examScore,
wordScore: b.wordScore,
total: b.total,
}));
// ---- 4) 出表(内部含自检;上色失败自动降级为无色版)----
const built = scoreRankSheet.buildXlsx({
teams,
sheetName: `${groupLabel}成绩排名`,
topN: scoreRankSheet.DEFAULT_TOP_N,
});
const now = new Date();
const pad = n => String(n).padStart(2, '0');
const dateTag = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
return {
buffer: built.buffer,
fileName: `成绩排名-${groupLabel}-${dateTag}${built.colored ? '' : '-无底色'}.xlsx`,
rowCount: built.rows.length,
colored: built.colored,
degradeReason: built.degradeReason || '',
stats: built.stats,
topN: built.stats.topN,
totalContestants: list.length,
noTeamCount,
groupLabel,
};
}
// ==================== 赛项信息配置(R176) ==================== // ==================== 赛项信息配置(R176) ====================
/** 成绩公示表标题要用的两项配置。读:`competition_info` 的 key-value 行 */ /** 成绩公示表标题要用的两项配置。读:`competition_info` 的 key-value 行 */