From c15b46a0f99a535b2b59ea6fce5c2fe5876c475b Mon Sep 17 00:00:00 2001
From: qubiaobiao <3294694717@qq.com>
Date: Thu, 22 Oct 2026 17:25:22 +0800
Subject: [PATCH] =?UTF-8?q?feat(score):=20=E6=88=90=E7=BB=A9=E6=8E=92?=
=?UTF-8?q?=E5=90=8D=E8=A1=A8=E5=AF=BC=E5=87=BA=20R220=EF=BC=88=E5=AE=A2?=
=?UTF-8?q?=E8=A7=82=E9=A2=98=E5=88=86=E6=8E=92=E5=90=8D=20+=20=E4=B8=89?=
=?UTF-8?q?=E6=A1=A3=E5=BA=95=E8=89=B2=20+=20=E5=89=8D80=E7=BA=A2=E6=A1=86?=
=?UTF-8?q?=EF=BC=89+=20=E4=BF=AE=E5=85=AC=E7=A4=BA=E8=A1=A8=E5=8F=82?=
=?UTF-8?q?=E8=B5=9B=E5=8D=95=E4=BD=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
中职/高职按小组客观题分降序全量导出,一行 = 一个小组,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%。
---
app/controller/admin.js | 57 +++
app/lib/adminPermissions.js | 2 +
app/lib/scoreRankSheet.js | 685 ++++++++++++++++++++++++++++++++++++
app/router.js | 5 +
app/service/admin.js | 163 ++++++++-
5 files changed, 904 insertions(+), 8 deletions(-)
create mode 100644 app/lib/scoreRankSheet.js
diff --git a/app/controller/admin.js b/app/controller/admin.js
index fe99018..8651f94 100644
--- a/app/controller/admin.js
+++ b/app/controller/admin.js
@@ -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) ====================
/**
diff --git a/app/lib/adminPermissions.js b/app/lib/adminPermissions.js
index 0f25006..ac4cd7d 100644
--- a/app/lib/adminPermissions.js
+++ b/app/lib/adminPermissions.js
@@ -116,6 +116,8 @@ const ROUTE_TABLE = [
['GET', '/api/admin/export-csv/:group', ['scores']],
['GET', '/api/admin/export-excel/: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']],
['PUT', '/api/admin/competition-item', ['scores']],
['GET', '/api/admin/big-answers/:group', ['scores', 'review']],
diff --git a/app/lib/scoreRankSheet.js b/app/lib/scoreRankSheet.js
new file mode 100644
index 0000000..e8720bd
--- /dev/null
+++ b/app/lib/scoreRankSheet.js
@@ -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"` + `文本`
+ * (这是「公式字符串」语义,无 `` 时并不规范)。既然要逐行改样式,
+ * 索性用 `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