'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"` + `文本` * (这是「公式字符串」语义,无 `` 时并不规范)。既然要逐行改样式, * 索性用 `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} teams 每项 {teamId, org, players, memberCount, examScore, wordScore, total} * @returns {Array} 追加了 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} 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 = /]*)>([\s\S]*?)<\/fills>/; const fontsRe = /]*)>([\s\S]*?)<\/fonts>/; const bordersRe = /]*)>([\s\S]*?)<\/borders>/; const xfsRe = /]*)>([\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 缺少 '); if (!fontsM) throw new Error('styles.xml 缺少 '); if (!bordersM) throw new Error('styles.xml 缺少 '); if (!xfsM) throw new Error('styles.xml 缺少 '); 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 => ``; 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], () => `${fillsM[2]}${addedFills}`); // ---- fonts:表头加粗 ---- const boldFontId = baseFontCount; const boldFont = ''; out = out.replace(fontsM[0], () => `${fontsM[2]}${boldFont}`); // ---- borders:4 种红框(顺序必须是 left/right/top/bottom/diagonal)---- const side = name => `<${name} style="medium">`; const empty = name => `<${name}/>`; const borderXml = (top, bottom) => `${side('left')}${side('right')}${top ? side('top') : empty('top')}${bottom ? side('bottom') : empty('bottom')}`; // 位置 → [是否有上边, 是否有下边] 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], () => `${bordersM[2]}${addedBorders}`); // ---- 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 ``; }; 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], () => `${xfsM[2]}${addedXfs.join('')}`); return { xml: out, style }; } // ==================== sheetData 重建 ==================== function rowXml(r, cells) { return `${cells.join('')}`; } /** * 生成完整的 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: `${out.join('')}`, 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} opt.teams 原始小组数组(未经排序) * @param {string} [opt.sheetName] 工作表名 * @param {number} [opt.topN=80] 统计口径里的「前 N 名」 * @returns {{ buffer: Buffer, colored: boolean, rows: Array, 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 = /]*>[\s\S]*?<\/sheetData>/; if (!sdRe.test(sheetXml)) throw new Error('sheet1.xml 缺少 '); sheetXml = sheetXml.replace(sdRe, built.xml); // dimension 必须同步,否则 Excel 的滚动区/打印区按旧范围截断 sheetXml = sheetXml.replace( /]*\bref="[^"]*"/, `]*\/>/g, '') .replace(/]*>[\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 = /]*>([\s\S]*?)<\/borders>/.exec(styles); const borderList = bordersBlock ? (bordersBlock[1].match(//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 = /]*>([\s\S]*?)<\/cellXfs>/.exec(styles); const xfList = xfsBlock ? (xfsBlock[1].match(/]*\/>/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(`([\\s\\S]*?)`).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, };