Compare commits
3 Commits
0a380371ec
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bca48369f | |||
| c15b46a0f9 | |||
| 78f34ea9f5 |
@@ -15,6 +15,9 @@ const { recordAudit, ACTIONS: AUDIT_ACTIONS } = require('../lib/auditLog');
|
|||||||
// 🔴 R106-D7(2026-10-12):测试数据默认口令改走唯一入口 —— 生产未显式注入
|
// 🔴 R106-D7(2026-10-12):测试数据默认口令改走唯一入口 —— 生产未显式注入
|
||||||
// TEST_DEFAULT_PASSWORD 时**明确失败**,⛔ 不再静默批量设置内置弱口令 '123456'。
|
// TEST_DEFAULT_PASSWORD 时**明确失败**,⛔ 不再静默批量设置内置弱口令 '123456'。
|
||||||
const { requireTestPassword } = require('../lib/passwordPolicy');
|
const { requireTestPassword } = require('../lib/passwordPolicy');
|
||||||
|
// 🔴 R195:下载文件名的「小组编号」前缀与拼接规则唯一 SoT(与落盘目录名同源)。
|
||||||
|
// 中职/高职大题单个下载与批量 ZIP 包内名**必须**共用 buildTeamDownloadName。
|
||||||
|
const { buildTeamDownloadName } = require('../lib/team_folder');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理控制器 - AdminController
|
* 管理控制器 - AdminController
|
||||||
@@ -346,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) ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -417,9 +477,12 @@ class AdminController extends Controller {
|
|||||||
if (!this._validateGroup(group, true)) return;
|
if (!this._validateGroup(group, true)) return;
|
||||||
|
|
||||||
// 中职/高职组使用简化版查询(contestants + exam_submissions)
|
// 中职/高职组使用简化版查询(contestants + exam_submissions)
|
||||||
|
// R195:默认只返回「参赛过的人」(有作答 / 有大题文件 / 已交卷);
|
||||||
|
// `?includeIdle=1` 才返回全部报名者(前端「显示未参赛」开关)。
|
||||||
const isExamGroup = group === 'zhongzhi' || group === 'gaozhi';
|
const isExamGroup = group === 'zhongzhi' || group === 'gaozhi';
|
||||||
|
const includeIdle = ctx.query.includeIdle === '1' || ctx.query.includeIdle === 'true';
|
||||||
const submissions = isExamGroup
|
const submissions = isExamGroup
|
||||||
? await service.admin.getExamSubmissions(group)
|
? await service.admin.getExamSubmissions(group, { includeIdle })
|
||||||
: await service.admin.getSubmissions(group);
|
: await service.admin.getSubmissions(group);
|
||||||
ctx.body = ctx.helper.success({
|
ctx.body = ctx.helper.success({
|
||||||
group,
|
group,
|
||||||
@@ -546,6 +609,17 @@ class AdminController extends Controller {
|
|||||||
ctx.helper.safeCleanup(path.join(zipRoot, group), { label: 'batchDownload-zipDir' });
|
ctx.helper.safeCleanup(path.join(zipRoot, group), { label: 'batchDownload-zipDir' });
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// 🔴 R195 修既有缺陷:「该组一个已上传文件都没有」是**正常业务态**,不是服务故障。
|
||||||
|
// `service.admin.batchDownloadZip` 为此专门抛 `bizCode:'EMPTY'`,但这里原先没有分支
|
||||||
|
// 接住它 ⇒ 落到兜底 500「批量下载失败」,管理端完全看不出真实原因,
|
||||||
|
// 且每点一次下载就刷一条 ERROR 日志(噪音淹没真故障)。
|
||||||
|
// ⚠️ 不能用 HTTP 4xx:本项目约定 `helper.error()` 不改状态码,业务错 = 200 + code≠0。
|
||||||
|
if (err && err.bizCode === 'EMPTY') {
|
||||||
|
ctx.logger.warn(`[batchDownload] 组 ${ctx.params.group} 暂无已上传文件(业务 404)`);
|
||||||
|
ctx.body = ctx.helper.error(404, err.message || '该组暂无已上传的作品文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.logger.error('批量下载失败:', err);
|
ctx.logger.error('批量下载失败:', err);
|
||||||
|
|
||||||
// 如果 archiver 不可用,返回友好错误
|
// 如果 archiver 不可用,返回友好错误
|
||||||
@@ -794,7 +868,10 @@ class AdminController extends Controller {
|
|||||||
const { group } = ctx.params;
|
const { group } = ctx.params;
|
||||||
if (!this._validateGroup(group, true)) return;
|
if (!this._validateGroup(group, true)) return;
|
||||||
|
|
||||||
const scores = await service.admin.getScores(group);
|
// R195:默认隐藏「从未参赛」的报名者(中职/高职按小组参赛,未上场组员全 0 分占行);
|
||||||
|
// `?includeIdle=1` 返回全量(前端「显示未参赛」开关)。
|
||||||
|
const includeIdle = ctx.query.includeIdle === '1' || ctx.query.includeIdle === 'true';
|
||||||
|
const scores = await service.admin.getScores(group, { includeIdle });
|
||||||
ctx.body = ctx.helper.success({ list: scores });
|
ctx.body = ctx.helper.success({ list: scores });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ctx.logger.error('获取评分列表失败:', err);
|
ctx.logger.error('获取评分列表失败:', err);
|
||||||
@@ -1600,8 +1677,23 @@ class AdminController extends Controller {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = wordFileName || path.basename(filePath);
|
let fileName = wordFileName || path.basename(filePath);
|
||||||
filePathCache.setFull(wordCacheKey, wordFilePath, wordFileName);
|
if (isExamGroup) {
|
||||||
|
// 🔴 R195:中职/高职大题下载名 = `<小组编号>-<姓名>-<原文件名>`
|
||||||
|
// 小组编号口径走 resolveTeamLabels(teamNumber → 未编组_<uid>),
|
||||||
|
// 与「选手管理 / 提交记录」列表展示、作品落盘目录**同源**,⛔ 不取 contestants.team_id。
|
||||||
|
// 缓存里存的是**最终名**,故命中缓存时无需再查库。
|
||||||
|
const teamFolders = await service.admin.getTeamFolders(group, [id]);
|
||||||
|
const tf = teamFolders.get(String(id)) || {};
|
||||||
|
fileName = buildTeamDownloadName({
|
||||||
|
teamNumber: tf.teamNumber,
|
||||||
|
teamUid: tf.teamUid,
|
||||||
|
aggregationUid: id,
|
||||||
|
personName: tf.name,
|
||||||
|
originalName: fileName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
filePathCache.setFull(wordCacheKey, wordFilePath, fileName);
|
||||||
// 🔴 X-Accel-Redirect 根治:生产经 nginx 内部重定向吐文件,开发回退流式。
|
// 🔴 X-Accel-Redirect 根治:生产经 nginx 内部重定向吐文件,开发回退流式。
|
||||||
// 文件名编码与路径穿越防护、文件 IO 并发护栏统一在 _sendFile 内处理(见其注释)。
|
// 文件名编码与路径穿越防护、文件 IO 并发护栏统一在 _sendFile 内处理(见其注释)。
|
||||||
await this._sendFile(
|
await this._sendFile(
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ class UploadController extends Controller {
|
|||||||
* 上传作品文件
|
* 上传作品文件
|
||||||
* POST /api/upload/:group
|
* POST /api/upload/:group
|
||||||
*
|
*
|
||||||
* 支持 .pdf / .doc / .docx / .ppt / .pptx / .zip 格式,最大 50MB
|
* 支持 .pdf / .doc / .docx / .ppt / .pptx / .zip 格式,最大 150MB
|
||||||
|
* (🔴 R225:原注释写「最大 50MB」早已失效,实际上限只由
|
||||||
|
* config.multipart.fileSize 决定,当时为 '100mb',现为 '150mb'。
|
||||||
|
* 本注释不再复述数字,改为指向配置项,避免再次漂移。)
|
||||||
*/
|
*/
|
||||||
async upload() {
|
async upload() {
|
||||||
const { ctx, service } = this;
|
const { ctx, service } = this;
|
||||||
|
|||||||
@@ -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
685
app/lib/scoreRankSheet.js
Normal 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,
|
||||||
|
};
|
||||||
@@ -123,10 +123,69 @@ function teamFolderRelative(group, folderName) {
|
|||||||
.join('/');
|
.join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载文件名主体(不含扩展名)的字符上限。
|
||||||
|
*
|
||||||
|
* 🔴 为什么要有上限:下载名 = 小组编号(≤60) + 姓名(≤60) + 原文件名(≤120),
|
||||||
|
* 三者直接拼接可到 240 字符。Windows 资源管理器对单文件名上限 255 **字符**
|
||||||
|
* (NTFS 255 UTF-16 单元),240 已贴边,再加上浏览器自动加的 `(1)` 后缀就会失败。
|
||||||
|
* 故统一收敛到 150,保证「编号 + 姓名 + 关键文件名」都能看见,又不触系统上限。
|
||||||
|
*/
|
||||||
|
const MAX_DOWNLOAD_NAME = 150;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼接「管理端下载文件名」—— 中职/高职大题(Word)单个下载与批量 ZIP 包内名共用。
|
||||||
|
*
|
||||||
|
* 格式:`<小组编号>-<姓名>-<原文件名>`(R195 需求:小组编号放最前)
|
||||||
|
* 例:`BRICS2026-ST-027-94001-张三-大题作答.docx`
|
||||||
|
*
|
||||||
|
* 🔴 单一口径:小组编号三态(teamNumber → `未编组_<teamUid>` → `未编组_<aggUid>`)
|
||||||
|
* 完全复用 resolveTeamFolderName,与本组上传落盘目录名**必然一致**
|
||||||
|
* ⇒ 管理员把下载文件解压回工作目录时,文件名前缀可直接对上作品目录。
|
||||||
|
* ⛔ 不得在 controller / service 各拼一套(尤其是「有队伍但 teams 表缺元信息」这一档,
|
||||||
|
* 两处拼法分叉后会出现「同一个人下载出两个不一样的前缀」)。
|
||||||
|
*
|
||||||
|
* @param {Object} p
|
||||||
|
* @param {?string} p.teamNumber - teams.team_number || teams.team_id
|
||||||
|
* @param {?number} p.teamUid - 队伍号(组内唯一)
|
||||||
|
* @param {?number} p.aggregationUid - 聚合报名 ID(最终兜底,防多人同名前缀重号)
|
||||||
|
* @param {?string} p.personName - 选手姓名(缺省则跳过该段)
|
||||||
|
* @param {?string} p.originalName - 原文件名(选手上传名)
|
||||||
|
* @param {string} [p.fallbackStem] - 原文件名为空时的兜底主体
|
||||||
|
* @returns {string} 安全文件名(非空、无路径分隔符、长度受限)
|
||||||
|
*/
|
||||||
|
function buildTeamDownloadName({
|
||||||
|
teamNumber,
|
||||||
|
teamUid,
|
||||||
|
aggregationUid,
|
||||||
|
personName,
|
||||||
|
originalName,
|
||||||
|
fallbackStem = '大题作答',
|
||||||
|
} = {}) {
|
||||||
|
const prefix = resolveTeamFolderName({ teamNumber, teamUid, aggregationUid });
|
||||||
|
const person = personName === null || personName === undefined
|
||||||
|
? ''
|
||||||
|
: sanitizeSegment(personName, '');
|
||||||
|
const base = sanitizeFileName(originalName, fallbackStem);
|
||||||
|
|
||||||
|
// 拆扩展名:截断只能砍主体,⛔ 不能把 `.docx` 砍掉(砍掉后浏览器会存成未知类型)
|
||||||
|
const dot = base.lastIndexOf('.');
|
||||||
|
const stem = dot > 0 ? base.slice(0, dot) : base;
|
||||||
|
const ext = dot > 0 ? base.slice(dot) : '';
|
||||||
|
|
||||||
|
const head = [prefix, person].filter(Boolean).join('-');
|
||||||
|
// 预留 1 个字符给分隔符 '-'
|
||||||
|
const room = MAX_DOWNLOAD_NAME - head.length - ext.length - 1;
|
||||||
|
const finalStem = stem.length > room ? stem.slice(0, Math.max(1, room)).trim() : stem;
|
||||||
|
return `${head}-${finalStem}${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
MAX_SEGMENT,
|
MAX_SEGMENT,
|
||||||
|
MAX_DOWNLOAD_NAME,
|
||||||
sanitizeSegment,
|
sanitizeSegment,
|
||||||
sanitizeFileName,
|
sanitizeFileName,
|
||||||
resolveTeamFolderName,
|
resolveTeamFolderName,
|
||||||
teamFolderRelative,
|
teamFolderRelative,
|
||||||
|
buildTeamDownloadName,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const { inspectIdNumber } = require('../lib/idCard');
|
|||||||
const {
|
const {
|
||||||
resolveTeamFolderName,
|
resolveTeamFolderName,
|
||||||
teamFolderRelative,
|
teamFolderRelative,
|
||||||
|
buildTeamDownloadName,
|
||||||
} = require('../lib/team_folder');
|
} = require('../lib/team_folder');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1029,11 +1030,13 @@ class AdminService extends Service {
|
|||||||
// ==================== 提交记录管理 ====================
|
// ==================== 提交记录管理 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取考试答题记录(查询组别对应的提交表)
|
* 获取考试组提交记录(查询组别对应的提交表;中职/高职一行一个选手)
|
||||||
* @param {string} group - 组别
|
* @param {string} group - 组别
|
||||||
|
* @param {Object} [options]
|
||||||
|
* @param {boolean} [options.includeIdle=false] - 是否包含「从未参赛」的报名者(默认隐藏)
|
||||||
* @returns {Promise<Array>} 答题记录
|
* @returns {Promise<Array>} 答题记录
|
||||||
*/
|
*/
|
||||||
async getExamSubmissions(group) {
|
async getExamSubmissions(group, options = {}) {
|
||||||
const { ctx } = this;
|
const { ctx } = this;
|
||||||
|
|
||||||
const SubmissionModel = group === 'zhongzhi'
|
const SubmissionModel = group === 'zhongzhi'
|
||||||
@@ -1108,7 +1111,7 @@ class AdminService extends Service {
|
|||||||
Math.min(timeRow && timeRow.choice_count != null ? timeRow.choice_count : 50, availableChoice) +
|
Math.min(timeRow && timeRow.choice_count != null ? timeRow.choice_count : 50, availableChoice) +
|
||||||
Math.min(timeRow && timeRow.judge_count != null ? timeRow.judge_count : 20, availableJudge);
|
Math.min(timeRow && timeRow.judge_count != null ? timeRow.judge_count : 20, availableJudge);
|
||||||
|
|
||||||
return contestants.map(c => {
|
const rows = contestants.map(c => {
|
||||||
// 归集键(含歧义消解后的兜底键);首元素必为主键
|
// 归集键(含歧义消解后的兜底键);首元素必为主键
|
||||||
const candidateKeys = aggKeysByContestant[String(c.id)] || [String(c.id)];
|
const candidateKeys = aggKeysByContestant[String(c.id)] || [String(c.id)];
|
||||||
const subs = candidateKeys.flatMap(k => submissionsByUser[k] || []);
|
const subs = candidateKeys.flatMap(k => submissionsByUser[k] || []);
|
||||||
@@ -1157,6 +1160,12 @@ answeredCount,
|
|||||||
examScore: autoScore,
|
examScore: autoScore,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🔴 R195:默认隐藏「从未参赛」的报名者(与 getScores 完全同一判据,⛔ 两边各写一套)。
|
||||||
|
// 「考生答题记录 / 提交记录」表同样一行一个选手,未上场组员会占满屏幕且全为 0。
|
||||||
|
// 需要全量时前端传 includeIdle=1。
|
||||||
|
if (options.includeIdle) return rows;
|
||||||
|
return rows.filter(r => r.answeredCount > 0 || r.hasWordFile || r.formalSubmitted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1365,7 +1374,9 @@ answeredCount,
|
|||||||
// 中职/高职组:客观题(系统自动评分) + 综合大题(人工阅卷)
|
// 中职/高职组:客观题(系统自动评分) + 综合大题(人工阅卷)
|
||||||
// 说明:大题分落库列名为 contestants.word_score(历史沿用,语义已变为「综合大题得分」),
|
// 说明:大题分落库列名为 contestants.word_score(历史沿用,语义已变为「综合大题得分」),
|
||||||
// 故此处取 item.wordScore,仅表头文案按现行口径显示为「大题分」。
|
// 故此处取 item.wordScore,仅表头文案按现行口径显示为「大题分」。
|
||||||
const submissions = await this.getExamSubmissions(group);
|
// ⚠️ 导出保持**全量**(含未参赛报名者):成绩导出用于存档/公示核对,
|
||||||
|
// 与页面「默认隐藏未参赛」是两种用途,⛔ 不要让页面开关改变导出内容。
|
||||||
|
const submissions = await this.getExamSubmissions(group, { includeIdle: true });
|
||||||
return {
|
return {
|
||||||
// 🔴 R178(B④):原「是否提交」列取 `answeredCount > 0`,与页面/接口口径(submit_time)
|
// 🔴 R178(B④):原「是否提交」列取 `answeredCount > 0`,与页面/接口口径(submit_time)
|
||||||
// 不一致,同一份数据导出与页面显示会互相矛盾。现在拆为「是否交卷」(submit_time)
|
// 不一致,同一份数据导出与页面显示会互相矛盾。现在拆为「是否交卷」(submit_time)
|
||||||
@@ -1521,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();
|
||||||
@@ -1599,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 行 */
|
||||||
@@ -1679,6 +1837,46 @@ answeredCount,
|
|||||||
return zipFilePath;
|
return zipFilePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量取选手的「小组编号」标签(下载文件名前缀用)
|
||||||
|
*
|
||||||
|
* 口径唯一来源 = `resolveTeamLabels` → `resolveTeamFolderName`:
|
||||||
|
* · `teams.team_number || teams.team_id`(人类可读报名编号)
|
||||||
|
* · 有队伍归属但 teams 表缺元信息 ⇒ `未编组_<teamUid>`(实测中职张明远 team_uid=1 无 teams 行)
|
||||||
|
* · 连队伍归属都没有 ⇒ `未编组_<contestantId>`(每人独立,防撞名)
|
||||||
|
* ⛔ 不可直接展示 / 拼接 `contestants.team_id`(内部数字,与报名编号两套序列)。
|
||||||
|
*
|
||||||
|
* @param {string} group
|
||||||
|
* @param {Array<number|string>} contestantIds
|
||||||
|
* @returns {Promise<Map<string, {teamNumber:?string, teamUid:?number, name:string}>>}
|
||||||
|
*/
|
||||||
|
async getTeamFolders(group, contestantIds) {
|
||||||
|
const { ctx } = this;
|
||||||
|
const ids = (contestantIds || [])
|
||||||
|
.filter(x => x !== null && x !== undefined && String(x).trim() !== '');
|
||||||
|
const out = new Map();
|
||||||
|
if (ids.length === 0) return out;
|
||||||
|
|
||||||
|
const contestants = await ctx.model.Contestant.findAll({
|
||||||
|
where: { id: ids, group },
|
||||||
|
// team_id 供 resolveTeamLabels 在「aggregation.team_uid 缺失」时兜底
|
||||||
|
attributes: ['id', 'name', 'group', 'team_id'],
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
if (contestants.length === 0) return out;
|
||||||
|
|
||||||
|
const labelMap = await resolveTeamLabels(ctx, contestants);
|
||||||
|
for (const c of contestants) {
|
||||||
|
const label = labelMap.get(String(c.id)) || {};
|
||||||
|
out.set(String(c.id), {
|
||||||
|
teamNumber: label.teamNumber || null,
|
||||||
|
teamUid: label.teamUid === undefined ? null : label.teamUid,
|
||||||
|
name: c.name || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量下载某组所有提交文件,打包为 ZIP
|
* 批量下载某组所有提交文件,打包为 ZIP
|
||||||
*
|
*
|
||||||
@@ -1686,7 +1884,10 @@ answeredCount,
|
|||||||
* 旧实现把所有文件平铺在 zip 根下,命名为 `姓名-word-原名`,
|
* 旧实现把所有文件平铺在 zip 根下,命名为 `姓名-word-原名`,
|
||||||
* 两个文件(Word/PPT)混在一堆同类文件里,评分时无法判断哪两个属于同一小组。
|
* 两个文件(Word/PPT)混在一堆同类文件里,评分时无法判断哪两个属于同一小组。
|
||||||
* 新结构:`<小组编号>/<原始文件名>`,与管理端「单个小组压缩包」完全同构。
|
* 新结构:`<小组编号>/<原始文件名>`,与管理端「单个小组压缩包」完全同构。
|
||||||
* 中职/高职组(Word 答题答案)保持原有平铺命名,不受影响。
|
*
|
||||||
|
* 🔴 R195 改造:中职/高职组(Word 大题答案)包内名改为
|
||||||
|
* `<小组编号>-<姓名>-<原文件名>`(原为 `<姓名>-<原文件名>`,小组编号无处体现)。
|
||||||
|
* 格式与单个下载共用 `buildTeamDownloadName`,⛔ 不在此另拼一套。
|
||||||
*
|
*
|
||||||
* @param {string} group - 组别
|
* @param {string} group - 组别
|
||||||
* @returns {Promise<string>} ZIP 文件路径
|
* @returns {Promise<string>} ZIP 文件路径
|
||||||
@@ -1697,7 +1898,8 @@ answeredCount,
|
|||||||
// 获取该组的所有提交记录(考试组与作品组数据形状不同)
|
// 获取该组的所有提交记录(考试组与作品组数据形状不同)
|
||||||
const isExam = group === 'zhongzhi' || group === 'gaozhi';
|
const isExam = group === 'zhongzhi' || group === 'gaozhi';
|
||||||
const submissions = isExam
|
const submissions = isExam
|
||||||
? await this.getExamSubmissions(group)
|
// ⚠️ 打包取**全量**:是否入包由「有无 word 文件」决定,与页面过滤无关。
|
||||||
|
? await this.getExamSubmissions(group, { includeIdle: true })
|
||||||
: await this.getSubmissions(group);
|
: await this.getSubmissions(group);
|
||||||
|
|
||||||
// works 列表提供「选手 → 小组目录名」映射(口径与上传落盘同源)
|
// works 列表提供「选手 → 小组目录名」映射(口径与上传落盘同源)
|
||||||
@@ -1707,6 +1909,11 @@ answeredCount,
|
|||||||
folderByContestant.set(String(w.contestantId), w.teamFolder);
|
folderByContestant.set(String(w.contestantId), w.teamFolder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 考试组:小组编号映射(R195)。口径 = resolveTeamLabels,与前端展示 / 落盘目录同源。
|
||||||
|
const teamFolders = isExam
|
||||||
|
? await this.getTeamFolders(group, submissions.map(s => s.contestantId))
|
||||||
|
: new Map();
|
||||||
|
|
||||||
const entries = [];
|
const entries = [];
|
||||||
const usedNames = new Set();
|
const usedNames = new Set();
|
||||||
/** 同名条目去重(历史数据里同组两人都传过时会撞名) */
|
/** 同名条目去重(历史数据里同组两人都传过时会撞名) */
|
||||||
@@ -1727,18 +1934,22 @@ answeredCount,
|
|||||||
return cand;
|
return cand;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 本科/教师组需要 account.name_cn;考试组使用 getExamSubmissions 的 userName
|
|
||||||
for (const item of submissions) {
|
for (const item of submissions) {
|
||||||
const account = item.account || {};
|
|
||||||
const userName = (isExam ? (item.userName || '未知') : (account.name_cn || '未知')).replace(/[\\/:*?"<>|]/g, '_');
|
|
||||||
|
|
||||||
if (isExam) {
|
if (isExam) {
|
||||||
// 中职/高职组:收集 Word 答案文件(保持原平铺命名)
|
// 中职/高职组:Word 大题答案,平铺,名 = 小组编号-姓名-原文件名(R195)
|
||||||
if (item.wordFilePath) {
|
if (item.wordFilePath) {
|
||||||
const fullPath = path.join(app.baseDir, 'app/public', item.wordFilePath);
|
const fullPath = path.join(app.baseDir, 'app/public', item.wordFilePath);
|
||||||
if (fs.existsSync(fullPath)) {
|
if (fs.existsSync(fullPath)) {
|
||||||
const origName = item.wordFileName || path.basename(fullPath);
|
const origName = item.wordFileName || path.basename(fullPath);
|
||||||
entries.push({ absPath: fullPath, zipName: uniqueZipName(`${userName}-${origName}`) });
|
const tf = teamFolders.get(String(item.contestantId)) || {};
|
||||||
|
const zipName = buildTeamDownloadName({
|
||||||
|
teamNumber: tf.teamNumber,
|
||||||
|
teamUid: tf.teamUid,
|
||||||
|
aggregationUid: item.contestantId,
|
||||||
|
personName: tf.name || item.userName,
|
||||||
|
originalName: origName,
|
||||||
|
});
|
||||||
|
entries.push({ absPath: fullPath, zipName: uniqueZipName(zipName) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -2352,11 +2563,13 @@ answeredCount,
|
|||||||
// ==================== 评分查询 ====================
|
// ==================== 评分查询 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取某组评分列表
|
* 获取成绩列表(管理端「成绩管理 → 成绩总览」)
|
||||||
* @param {string} group - 组别
|
* @param {string} group - 组别
|
||||||
|
* @param {Object} [options]
|
||||||
|
* @param {boolean} [options.includeIdle=false] - 是否包含「从未参赛」的报名者(默认隐藏)
|
||||||
* @returns {Promise<Array>} 评分列表
|
* @returns {Promise<Array>} 评分列表
|
||||||
*/
|
*/
|
||||||
async getScores(group) {
|
async getScores(group, options = {}) {
|
||||||
const { ctx } = this;
|
const { ctx } = this;
|
||||||
const isExam = group === 'zhongzhi' || group === 'gaozhi';
|
const isExam = group === 'zhongzhi' || group === 'gaozhi';
|
||||||
const contestants = await ctx.model.Contestant.findAll({
|
const contestants = await ctx.model.Contestant.findAll({
|
||||||
@@ -2478,6 +2691,16 @@ answeredCount,
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔴 R195:默认隐藏「从未参赛」的报名者。
|
||||||
|
// 中职/高职是**小组参赛**:一组报名多人、实际只一人上场,其余组员在成绩表里
|
||||||
|
// 各占一行 0 分 —— 管理员按小组核对成绩时这些行纯属噪音。
|
||||||
|
// 判据 = 有作答 || 有大题文件 || 已正式交卷(任一即算「参赛过」);
|
||||||
|
// ⛔ 不用「已激活」判断:激活 ≠ 参赛(存在激活后从未进考场的小组)。
|
||||||
|
// 需要全量时前端传 includeIdle=1(「显示未参赛」开关)。
|
||||||
|
// ⚠️ 只对考试组生效:本科/教师的提交判据是「上传作品」,口径不同,不在本次范围。
|
||||||
|
if (isExam && !options.includeIdle) {
|
||||||
|
return list.filter(it => it.answeredCount > 0 || it.hasWordFile || it.formalSubmitted);
|
||||||
|
}
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2899,11 +3122,10 @@ answeredCount,
|
|||||||
attachment_url: fileUrl,
|
attachment_url: fileUrl,
|
||||||
attachment_name: built.fileName,
|
attachment_name: built.fileName,
|
||||||
});
|
});
|
||||||
// 旧附件清理:只删本流程自己产出的文件(前缀 + 目录双重限定,绝不误删人工上传的图/文件)
|
// 旧附件清理:复用 _removeNewsFile 的安全闸门,并把可删范围**收窄**到本流程
|
||||||
if (oldUrl && oldUrl !== fileUrl && oldUrl.indexOf('/public/news/news_file_publicity_') === 0) {
|
// 自己产出的公示表(`news_file_publicity_` 前缀),绝不误删人工上传的图/文件。
|
||||||
ctx.helper.safeCleanup(path.join(this.app.baseDir, 'app', oldUrl.replace(/^\//, '')), {
|
if (oldUrl && oldUrl !== fileUrl) {
|
||||||
label: 'publicity-draft-old',
|
this._removeNewsFile(oldUrl, 'publicity-draft-old', /^news_file_publicity_/);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
results.push({ group: g, newsId: exist.id, action: 'updated', fileUrl });
|
results.push({ group: g, newsId: exist.id, action: 'updated', fileUrl });
|
||||||
} else {
|
} else {
|
||||||
@@ -3029,12 +3251,72 @@ answeredCount,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除新闻
|
* 安全删除 `app/public/news/` 下的**本系统产出**文件(新闻图片 / 新闻附件 / 公示表)。
|
||||||
|
*
|
||||||
|
* 🔴 R195b(2026-10-22):`deleteNews` 原来只 `News.destroy`,**不删盘** ⇒ 每轮公示表
|
||||||
|
* 测试都在 `public/news/` 留下 4 个孤儿 xlsx(实测累积十余个,DB 已无引用)。
|
||||||
|
* 这里把「库/盘双清」收成唯一入口,供 deleteNews 与公示表覆盖写复用。
|
||||||
|
*
|
||||||
|
* 安全闸门(三重,任一不过即跳过并告警):
|
||||||
|
* ① 必须是 `/public/news/<单段文件名>`(拒绝子路径、外域、其它目录);
|
||||||
|
* ② 文件名必须匹配本系统自产的命名前缀(见 controller/admin.js 的三处 upload);
|
||||||
|
* ③ `path.resolve` 归一后必须仍落在 `app/public/news` 内(防 `..` 绕过)。
|
||||||
|
*
|
||||||
|
* ⛔ 绝不删人工放到其它目录的文件;⛔ 不删正文内嵌图(见 deleteNews 的边界说明)。
|
||||||
|
*
|
||||||
|
* @param {string} url - 形如 `/public/news/news_file_1792.xlsx`
|
||||||
|
* @param {string} label - 日志标签
|
||||||
|
* @param {RegExp} [allow] - 允许的 basename 前缀正则(默认覆盖图片+附件+公示表)
|
||||||
|
* @returns {'removed'|'missing'|'skipped'} 与 helper.safeCleanup 同口径(永不抛错)
|
||||||
|
*/
|
||||||
|
_removeNewsFile(url, label = 'deleteNews', allow = /^(news_|news_file_)/) {
|
||||||
|
const { ctx } = this;
|
||||||
|
if (!url || typeof url !== 'string') return 'missing';
|
||||||
|
|
||||||
|
const m = /^\/public\/news\/([^/\\]+)$/.exec(url.trim());
|
||||||
|
if (!m) {
|
||||||
|
ctx.logger.warn('[%s] 拒绝删除:非 news 目录或含子路径(安全闸门): %s', label, url);
|
||||||
|
return 'skipped';
|
||||||
|
}
|
||||||
|
const name = m[1];
|
||||||
|
if (name.indexOf('..') >= 0 || !allow.test(name)) {
|
||||||
|
ctx.logger.warn('[%s] 拒绝删除:非本系统自产命名(安全闸门): %s', label, url);
|
||||||
|
return 'skipped';
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = path.resolve(path.join(this.app.baseDir, 'app/public/news'));
|
||||||
|
const absPath = path.resolve(path.join(dir, name));
|
||||||
|
if (path.dirname(absPath) !== dir) {
|
||||||
|
ctx.logger.warn('[%s] 拒绝删除:解析后越界(安全闸门): %s ⇒ %s', label, url, absPath);
|
||||||
|
return 'skipped';
|
||||||
|
}
|
||||||
|
return ctx.helper.safeCleanup(absPath, { label });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除新闻(连带清理附件文件)
|
||||||
|
*
|
||||||
* @param {number} id - 新闻 ID
|
* @param {number} id - 新闻 ID
|
||||||
|
* @returns {Promise<number>} 删除的行数(0 = 本就不存在,幂等)
|
||||||
*/
|
*/
|
||||||
async deleteNews(id) {
|
async deleteNews(id) {
|
||||||
const { ctx } = this;
|
const { ctx } = this;
|
||||||
|
const news = await ctx.model.News.findByPk(id);
|
||||||
|
// 幂等:行不存在与「删了 0 行」同语义,且**不碰磁盘**(避免 id 猜中别人的附件)
|
||||||
|
if (!news) return 0;
|
||||||
|
|
||||||
|
const attachmentUrl = news.attachment_url;
|
||||||
await ctx.model.News.destroy({ where: { id } });
|
await ctx.model.News.destroy({ where: { id } });
|
||||||
|
|
||||||
|
// 🔴 顺序铁律:**先删库后删盘**。库是 SoT —— 若反过来先删盘、再删库失败,就留下
|
||||||
|
// 「DB 有行但文件已没了」的死链(前端点下载 404);而当前顺序即使删盘失败,
|
||||||
|
// 最坏也只是多一个孤儿文件(可被 scan_orphan_files.py 离线发现)。
|
||||||
|
// ⚠️ 已知边界:正文 content 内嵌的 `<img src="/public/news/...">` 不在此清理范围,
|
||||||
|
// 因为无法可靠区分「本系统上传」与「外链/人工插入」;如需彻底治理走孤儿扫描。
|
||||||
|
const fileResult = this._removeNewsFile(attachmentUrl, 'deleteNews');
|
||||||
|
ctx.logger.info('[deleteNews] id=%s 行已删,附件清理=%s url=%s',
|
||||||
|
id, fileResult, attachmentUrl || '(none)');
|
||||||
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -379,7 +379,16 @@ module.exports = (appInfo) => {
|
|||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
config.multipart = {
|
config.multipart = {
|
||||||
mode: 'stream',
|
mode: 'stream',
|
||||||
fileSize: '100mb',
|
// 🔴 2026-10-23 R225:'100mb' -> '150mb'
|
||||||
|
// 缘由:教师组(jiaoshi)作品上传唯一通道 = POST /api/upload/:group 单次流式整包
|
||||||
|
// (分片通道前端已于 R43 删除),故全组共用这一个硬闸门。原值与前端
|
||||||
|
// uploadCombo.ZIP_MAX=200MB 长期不一致 ⇒ 100~200MB 的 zip 前端放行、后端拒收
|
||||||
|
// (MultipartFileTooLargeError),多位教师报障。
|
||||||
|
// ⚠️ 本值是**所有** multipart 通道的唯一上限,调整时须与下列三处同步:
|
||||||
|
// frontend/src/utils/uploadCombo.js (ZIP_MAX / OTHER_MAX)
|
||||||
|
// frontend/src/views/Competition.vue (同上常量 + 文案)
|
||||||
|
// deploy/competition_website.conf (client_max_body_size 200m,须 > 本值)
|
||||||
|
fileSize: '150mb',
|
||||||
whitelist: [
|
whitelist: [
|
||||||
// 文档类
|
// 文档类
|
||||||
'.pdf',
|
'.pdf',
|
||||||
|
|||||||
Reference in New Issue
Block a user