中职/高职按小组客观题分降序全量导出,一行 = 一个小组,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%。
686 lines
28 KiB
JavaScript
686 lines
28 KiB
JavaScript
'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,
|
||
};
|