Files
competition-website/app/lib/scoreRankSheet.js
qubiaobiao c15b46a0f9 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%。
2026-10-22 17:25:22 +08:00

686 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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,
};