R20:客观题分值口径 + 大题一位小数评分 + 页面刷新修复 + 大题导出 Word
A1/A2 客观题:
- 清理题库占位题(答案恒 A/true、分值恒 5),客观题分值硬约束为 1 分
- 新增 scripts/check_question_bank_health.py(带变异注入自检,已验证判别力)
- ci-gates.sh 由 9 步扩为 10 步,纳入题库健康门禁
B 大题评分一位小数:
- 新增 app/lib/decimal_score.js:DECIMAL(6,1) + getter 兜底,单点收口
「DB 列 / model 声明 / dialectOptions.decimalNumbers」三处必须同时成立
- 10 个 model 的 score 字段改走该工厂
- database/migrations/025:17 列 INT → DECIMAL(6,1)(6 列题目分值保持 int)
- service/bigQuestion.js:setSubScores 用 Math.round(n*10)/10 收敛,不再抹成整数
C 页面状态刷新:
- 修复 onQuestionTypeChange 的 `bigQuestions.length === 0` 守卫(切组后旧数组仍非空
⇒ 永不重新拉取)
- 修复评分后只刷一半(saveBigScores / saveExamScore 各只刷一侧列表)
- 收敛为唯一刷新入口 refreshQuestionData();ensureBigData 改按「数据属于哪个组」
判定;fetchBigQuestions/fetchBigAnswers 增加组别竞态守卫
D 大题导出 Word(单题 / 批量 / 全部):
- 新增 app/lib/docx.js:零新依赖手写 OOXML(复用已有 archiver@8),支持 p/strong/
span/a/br/table/ul-ol 子集,具名捕获组词法解析,pPr 严格排在 runs 之前
- service.bigQuestion.exportQuestions + controller.exportBigQuestions +
GET /api/admin/big-questions/:group/export
- 前端导出选中/全部两按钮 + 表格多选(reserve-selection)
计划外发现并修复(已在报告中披露):
- 管理端跨组写分越权:setSubScores 定位作答行时缺 group 约束,可用任意组的路径
改写他组考生成绩(读写不对称:读取侧有 where:{group},写入侧没有)
⇒ 已补 group 约束,并区分「不属于本组」与「尚未抽题」两种文案
- 导出路由注册顺序:export 原注册在 :group/:id 之后(当时因后者仅 PUT/DELETE 而
未暴露问题),已前移以消除「将来新增 GET /:group/:id 会吞掉 export」的隐患
验证(全部实测,非仅看退出码):
- ci-gates.sh 10/10 全绿;四连门禁 52/0 · 22/0 · 20/0 · 46/0
- 端到端探针 probe_d_03(导出+小数)ALL PASS;probe_e_01(切组刷新)22/22 ALL PASS
- 前端构建 VITE_EXIT=0
未 push:须先上线验证通过再单独授权。
This commit is contained in:
@@ -467,11 +467,8 @@ class AdminController extends Controller {
|
|||||||
|
|
||||||
if (action === 'add') {
|
if (action === 'add') {
|
||||||
for (const q of questions) {
|
for (const q of questions) {
|
||||||
// 分值必须为大于 0 的合法数值;0 分题属非法业务数据
|
// 客观题(choice/judge)分值由 service 层强制为 1(赛制口径),此处不再要求
|
||||||
if (q.score == null || !isFinite(Number(q.score)) || Number(q.score) <= 0) {
|
// 调用方传 score;word 题型的合法性由 service 层 _resolveQuestionScore 校验。
|
||||||
ctx.body = ctx.helper.error(400, '题目分值(score)必须为大于 0 的合法数值');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await service.admin.addQuestion(q, group, qType);
|
await service.admin.addQuestion(q, group, qType);
|
||||||
}
|
}
|
||||||
ctx.body = ctx.helper.success({ added: questions.length }, '题目添加成功');
|
ctx.body = ctx.helper.success({ added: questions.length }, '题目添加成功');
|
||||||
@@ -494,14 +491,12 @@ class AdminController extends Controller {
|
|||||||
}
|
}
|
||||||
ctx.body = ctx.helper.success({ deleted: questions.length }, '题目删除成功');
|
ctx.body = ctx.helper.success({ deleted: questions.length }, '题目删除成功');
|
||||||
} else if (action === 'setTypeScore') {
|
} else if (action === 'setTypeScore') {
|
||||||
// 批量按题型设置分值:将该组该题型全部题目统一设为指定分值
|
// 分值口径已统一(2026-09-23):客观题(choice/judge)恒为 1 分,
|
||||||
if (score == null || isNaN(parseInt(score, 10))) {
|
// 传入的 score 被忽略;仅综合性任务(word)按传入值设置(service 层校验)。
|
||||||
ctx.body = ctx.helper.error(400, '批量设分需要合法的 score 数值');
|
const updated = await service.admin.setTypeScore(group, qType, score);
|
||||||
return;
|
|
||||||
}
|
|
||||||
const updated = await service.admin.setTypeScore(group, qType, parseInt(score, 10));
|
|
||||||
const typeLabel = qType === 'choice' ? '选择题' : (qType === 'judge' ? '判断题' : '综合性任务');
|
const typeLabel = qType === 'choice' ? '选择题' : (qType === 'judge' ? '判断题' : '综合性任务');
|
||||||
ctx.body = ctx.helper.success({ updated }, `已将${typeLabel}统一设为 ${score} 分`);
|
const shown = (qType === 'choice' || qType === 'judge') ? 1 : score;
|
||||||
|
ctx.body = ctx.helper.success({ updated }, `已将${typeLabel}统一设为 ${shown} 分`);
|
||||||
} else if (action === 'bulkDelete') {
|
} else if (action === 'bulkDelete') {
|
||||||
// 批量删除题目:question_uids 数组
|
// 批量删除题目:question_uids 数组
|
||||||
const ids = (ctx.request.body.question_uids || [])
|
const ids = (ctx.request.body.question_uids || [])
|
||||||
@@ -1700,6 +1695,50 @@ class AdminController extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出大题为 Word(2026-09-23 需求)
|
||||||
|
* GET /api/admin/big-questions/:group/export?ids=1,2,3
|
||||||
|
* · 传 ids → 单独 / 批量导出;不传 → 导出该组全部
|
||||||
|
* · 1 道 → 返回 .docx;多道 → 返回「多个 .docx 打包的 .zip」
|
||||||
|
*
|
||||||
|
* ⚠️ 路由必须注册在 `/big-questions/:group/:id`(PUT/DELETE)之前或不同方法,
|
||||||
|
* 否则 `export` 会被当成 :id 吞掉(见 router.js 注释)。
|
||||||
|
*/
|
||||||
|
async exportBigQuestions() {
|
||||||
|
const { ctx, service } = this;
|
||||||
|
if (!this._ensureAdmin()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { group } = ctx.params;
|
||||||
|
if (!this._validateGroup(group)) return;
|
||||||
|
|
||||||
|
const ids = ctx.query.ids;
|
||||||
|
const result = await service.bigQuestion.exportQuestions(group, ids);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const httpStatus = result.code === 'NOT_FOUND' ? 404 : 400;
|
||||||
|
ctx.body = ctx.helper.error(httpStatus, result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⚠️ 只调 ctx.attachment(原名):koa 会自动做 RFC5987 编码。
|
||||||
|
// 自行 encodeURIComponent 会**双重编码**,中文文件名变成乱码(同源缺陷族)。
|
||||||
|
ctx.set(
|
||||||
|
'Content-Type',
|
||||||
|
result.isZip
|
||||||
|
? 'application/zip'
|
||||||
|
: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||||
|
);
|
||||||
|
ctx.attachment(result.fileName);
|
||||||
|
// 前端需要知道「本次导出了几道题」,非简单请求下无法从 body 读(body 是二进制)
|
||||||
|
ctx.set('X-Export-Count', String(result.count));
|
||||||
|
ctx.body = result.buffer;
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.error('导出大题 Word 失败:', err);
|
||||||
|
ctx.body = ctx.helper.error(500, '导出大题 Word 失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 新增大题
|
* 新增大题
|
||||||
* POST /api/admin/big-questions/:group
|
* POST /api/admin/big-questions/:group
|
||||||
|
|||||||
39
app/lib/decimal_score.js
Normal file
39
app/lib/decimal_score.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分数列类型工厂(2026-09-23:大题人工评分允许小数点后一位)
|
||||||
|
*
|
||||||
|
* 用法(在 model 文件内):
|
||||||
|
* const decimalScore = require('../lib/decimal_score')(app);
|
||||||
|
* ...
|
||||||
|
* word_score: decimalScore('word_score', 'Word题/综合大题得分(可含 1 位小数)'),
|
||||||
|
*
|
||||||
|
* ⚠️ 三处必须同时成立,小数才不会被截断或变形:
|
||||||
|
* ① DB 列 = DECIMAL
|
||||||
|
* → database/migrations/025-scores-decimal-one-place.sql
|
||||||
|
* ② model 声明 = DECIMAL(本文件)
|
||||||
|
* → 若仍写 INTEGER,Sequelize 读取时会走 INTEGER.parse(parseInt),
|
||||||
|
* 把 7.5 **静默截成 7**,且不报任何错
|
||||||
|
* ③ mysql2 驱动配 decimalNumbers=true(config/config.default.js)
|
||||||
|
* → 否则 DECIMAL 经驱动读出为**字符串**('7.5'),前端 `+` 会变成拼接
|
||||||
|
*
|
||||||
|
* getter 再兜底转一次 Number,保证 raw / 非 raw 查询路径都拿到数值类型。
|
||||||
|
*
|
||||||
|
* @param {Egg.Application} app Egg 应用实例(用于取 app.Sequelize)
|
||||||
|
* @returns {(field: string, comment: string, extra?: object) => object}
|
||||||
|
*/
|
||||||
|
module.exports = app => {
|
||||||
|
const { DECIMAL } = app.Sequelize;
|
||||||
|
|
||||||
|
return function decimalScore(field, comment, extra = {}) {
|
||||||
|
return Object.assign({
|
||||||
|
type: DECIMAL(6, 1), // 上限 99999.9,足够覆盖总分/单题分
|
||||||
|
allowNull: true,
|
||||||
|
comment,
|
||||||
|
get() {
|
||||||
|
const v = this.getDataValue(field);
|
||||||
|
return (v === null || v === undefined) ? v : Number(v);
|
||||||
|
},
|
||||||
|
}, extra);
|
||||||
|
};
|
||||||
|
};
|
||||||
469
app/lib/docx.js
Normal file
469
app/lib/docx.js
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 极简 WordprocessingML(.docx) 生成器(2026-09-23)
|
||||||
|
*
|
||||||
|
* 用途:把综合大题(`big_questions`)导出为**真正的 .docx**,供管理员留存 / 打印 / 分发。
|
||||||
|
*
|
||||||
|
* 为什么不用第三方 docx 库:后端已有 `archiver`(service/admin.js 的批量下载在用),
|
||||||
|
* 而 .docx 本质就是一个 ZIP + 几个 XML part ⇒ 直接手写 OOXML,**新增零依赖**、
|
||||||
|
* 也避免引入未经验证的库影响既有交卷链路。
|
||||||
|
*
|
||||||
|
* 生成的包结构(最小合法 OOXML):
|
||||||
|
* [Content_Types].xml
|
||||||
|
* _rels/.rels
|
||||||
|
* word/document.xml
|
||||||
|
* word/styles.xml
|
||||||
|
* word/_rels/document.xml.rels
|
||||||
|
*
|
||||||
|
* 支持的 HTML 子集(实测题干只用到这些:p / table / tbody / tr / td / strong / span / a / br):
|
||||||
|
* 块级:p、div、h1–h6、li、ul/ol、blockquote、pre、hr
|
||||||
|
* 表格:table(含 tbody/thead/tfoot 包裹)、tr、td/th(th 加粗)
|
||||||
|
* 行内:strong/b(加粗)、em/i(斜体)、u、span、a(只取文字)、br
|
||||||
|
* 样式:仅解析 `text-align`(对齐)与 `text-indent`(首行缩进,pt→twips)
|
||||||
|
* 实体: < > & " ' '
|
||||||
|
* 忽略:script / style / img(图片不外链,避免生成"打不开的图片占位")
|
||||||
|
*
|
||||||
|
* ⛔ 已知取舍:嵌套表格会被扁平化;图片不导出。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ==================== XML / 实体 ====================
|
||||||
|
|
||||||
|
function decodeEntities(str) {
|
||||||
|
return String(str == null ? '' : str)
|
||||||
|
.replace(/ /gi, ' ')
|
||||||
|
.replace(/</gi, '<')
|
||||||
|
.replace(/>/gi, '>')
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/�*39;/g, "'")
|
||||||
|
.replace(/'/gi, "'")
|
||||||
|
.replace(/&#(\d+);/g, (m, d) => {
|
||||||
|
const code = Number(d);
|
||||||
|
return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : m;
|
||||||
|
})
|
||||||
|
// ⚠️ & 必须放最后:否则 `&lt;` 会被二次解码成 `<`
|
||||||
|
.replace(/&/gi, '&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** XML 文本转义(在 decodeEntities 之后调用,顺序不可颠倒) */
|
||||||
|
function esc(str) {
|
||||||
|
return String(str == null ? '' : str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 极简 HTML 解析(容错栈式) ====================
|
||||||
|
|
||||||
|
const VOID_TAGS = new Set(['br', 'hr', 'img', 'meta', 'link', 'input', 'col', 'area', 'base', 'source', 'wbr']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 词法正则。
|
||||||
|
* ⚠️ 必须用**具名捕获组**:早期版本用「按出现顺序数下标」(m[1]..m[8]),
|
||||||
|
* 但多个 `|` 分支各自贡献捕获组,实际只有 5 组 ⇒ 下标全部错位 ⇒
|
||||||
|
* 所有标签被当成文本、块序列恒为空(静默产出空文档,无任何报错)。
|
||||||
|
* 具名组不受分支数量影响,从根上消除这类错位。
|
||||||
|
*/
|
||||||
|
const TOKEN_RE = /<!--[\s\S]*?-->|<![^>]*>|<\?[\s\S]*?\?>|<\/\s*(?<close>[a-zA-Z][a-zA-Z0-9:-]*)\s*>|<(?<open>[a-zA-Z][a-zA-Z0-9:-]*)(?<attrs>(?:"[^"]*"|'[^']*'|[^>"'])*?)(?<self>\/?)>|(?<text>[^<]+)/g;
|
||||||
|
|
||||||
|
const ATTR_RE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
|
||||||
|
|
||||||
|
function parseAttrs(raw) {
|
||||||
|
const attrs = {};
|
||||||
|
if (!raw) return attrs;
|
||||||
|
ATTR_RE.lastIndex = 0;
|
||||||
|
let m;
|
||||||
|
while ((m = ATTR_RE.exec(raw))) {
|
||||||
|
attrs[m[1].toLowerCase()] = m[2] !== undefined ? m[2] : (m[3] !== undefined ? m[3] : m[4]);
|
||||||
|
}
|
||||||
|
return attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 HTML 片段解析为节点树。
|
||||||
|
* ⛔ 容错优先:标签不闭合 / 交叉嵌套时按栈就近回退,绝不抛异常
|
||||||
|
* (题干是管理员手工粘贴的富文本,脏 HTML 不能导致整次导出失败)。
|
||||||
|
*/
|
||||||
|
function parseHtml(html) {
|
||||||
|
const root = { type: 'node', tag: '#root', attrs: {}, children: [] };
|
||||||
|
const stack = [root];
|
||||||
|
const src = String(html == null ? '' : html);
|
||||||
|
TOKEN_RE.lastIndex = 0;
|
||||||
|
let m;
|
||||||
|
while ((m = TOKEN_RE.exec(src))) {
|
||||||
|
const g = m.groups || {};
|
||||||
|
if (g.text !== undefined) {
|
||||||
|
stack[stack.length - 1].children.push({ type: 'text', value: g.text });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (g.close !== undefined) {
|
||||||
|
// 闭标签:从栈顶向下找同名节点;找不到就忽略(脏 HTML 不抛异常)
|
||||||
|
const name = g.close.toLowerCase();
|
||||||
|
for (let i = stack.length - 1; i > 0; i--) {
|
||||||
|
if (stack[i].tag === name) {
|
||||||
|
stack.length = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (g.open !== undefined) {
|
||||||
|
const tag = g.open.toLowerCase();
|
||||||
|
const node = { type: 'node', tag, attrs: parseAttrs(g.attrs), children: [] };
|
||||||
|
stack[stack.length - 1].children.push(node);
|
||||||
|
if (!VOID_TAGS.has(tag) && g.self !== '/') stack.push(node);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 注释 / doctype / 处理指令:忽略
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 节点树 → 中间块序列 ====================
|
||||||
|
|
||||||
|
const BLOCK_TAGS = new Set([
|
||||||
|
'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'ul', 'ol',
|
||||||
|
'blockquote', 'pre', 'section', 'article', 'hr', 'dl', 'dt', 'dd',
|
||||||
|
]);
|
||||||
|
const SKIP_TAGS = new Set(['script', 'style', 'head', 'meta', 'link', 'title', 'img']);
|
||||||
|
|
||||||
|
/** 标题级别 → 半磅字号(21 半磅 = 10.5pt = 五号,正文基准) */
|
||||||
|
const HEADING_SIZE = { h1: 32, h2: 28, h3: 26, h4: 24, h5: 22, h6: 21 };
|
||||||
|
const HEADING_BOLD = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
|
||||||
|
|
||||||
|
const INLINE_BOLD = new Set(['strong', 'b', 'th']);
|
||||||
|
const INLINE_ITALIC = new Set(['em', 'i']);
|
||||||
|
|
||||||
|
function styleOf(node) {
|
||||||
|
return (node && node.attrs && node.attrs.style) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 style 里取段落对齐(仅映射 Word 支持的四种) */
|
||||||
|
function alignOf(node) {
|
||||||
|
const s = styleOf(node);
|
||||||
|
const m = /text-align\s*:\s*(left|right|center|justify)/i.exec(s);
|
||||||
|
return m ? m[1].toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 style 里取首行缩进(pt → twips,1pt = 20twips) */
|
||||||
|
function firstLineTwips(node) {
|
||||||
|
const s = styleOf(node);
|
||||||
|
const m = /text-indent\s*:\s*(-?\d+(?:\.\d+)?)\s*pt/i.exec(s);
|
||||||
|
if (!m) return 0;
|
||||||
|
const pt = Number(m[1]);
|
||||||
|
return Number.isFinite(pt) && pt > 0 ? Math.round(pt * 20) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 深度优先遍历,产出「块」序列:
|
||||||
|
* { type:'p', runs:[...], align, firstLine }
|
||||||
|
* { type:'table', rows:[{ cells:[{ blocks:[...] }] }] }
|
||||||
|
* run 形态:{ text, bold, italic } 或 { br: true }
|
||||||
|
*/
|
||||||
|
function walk(nodes, fmt, out) {
|
||||||
|
const flush = () => {
|
||||||
|
if (out.cur.length) {
|
||||||
|
out.blocks.push(Object.assign({ type: 'p', runs: out.cur }, out.curMeta));
|
||||||
|
out.cur = [];
|
||||||
|
out.curMeta = {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.type === 'text') {
|
||||||
|
// HTML 源码里的换行/缩进属排版噪声,须丢弃;
|
||||||
|
// 但 ` ` 是作者有意的占位空格(题干排版大量使用),必须保留 ⇒ 用原始值判别
|
||||||
|
if (!/\S/.test(n.value) && !/ /i.test(n.value)) continue;
|
||||||
|
// 原始换行/制表压成单空格:真实换行只由 <br> 与段落边界产生
|
||||||
|
const text = decodeEntities(n.value).replace(/[\r\n\t]+/g, ' ');
|
||||||
|
if (text === '') continue;
|
||||||
|
out.cur.push({ text, bold: !!fmt.bold, italic: !!fmt.italic });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = n.tag;
|
||||||
|
if (SKIP_TAGS.has(tag)) continue;
|
||||||
|
if (tag === 'br') {
|
||||||
|
out.cur.push({ br: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tag === 'table') {
|
||||||
|
flush();
|
||||||
|
out.blocks.push({ type: 'table', rows: extractRows(n) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tag === 'hr') {
|
||||||
|
flush();
|
||||||
|
out.blocks.push({ type: 'p', runs: [] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tag === 'li') {
|
||||||
|
flush();
|
||||||
|
out.curMeta = Object.assign({}, out.curMeta, { align: alignOf(n) || out.curMeta.align, firstLine: 0 });
|
||||||
|
out.cur.push({ text: '• ', bold: false, italic: false });
|
||||||
|
walk(n.children, fmt, out);
|
||||||
|
// 若不 flush,下一条 li 会并入同一段 ⇒ 显式收尾
|
||||||
|
const last = out.blocks[out.blocks.length - 1];
|
||||||
|
if (last && last.type === 'p' && last.runs.length && last.runs[0].text === '• ') flush();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (BLOCK_TAGS.has(tag)) {
|
||||||
|
flush();
|
||||||
|
const nextFmt = HEADING_BOLD.has(tag)
|
||||||
|
? Object.assign({}, fmt, { bold: true, size: HEADING_SIZE[tag] })
|
||||||
|
: fmt;
|
||||||
|
out.curMeta = { align: alignOf(n), firstLine: firstLineTwips(n) };
|
||||||
|
walk(n.children, nextFmt, out);
|
||||||
|
flush();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 行内标签
|
||||||
|
const nextFmt = Object.assign({}, fmt);
|
||||||
|
if (INLINE_BOLD.has(tag)) nextFmt.bold = true;
|
||||||
|
if (INLINE_ITALIC.has(tag)) nextFmt.italic = true;
|
||||||
|
walk(n.children, nextFmt, out);
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取表格行/单元格(容忍 tbody / thead / tfoot 包裹;嵌套表格扁平化到单元格文本) */
|
||||||
|
function extractRows(tableNode) {
|
||||||
|
const rows = [];
|
||||||
|
const collect = (nodes) => {
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.type !== 'node') continue;
|
||||||
|
if (n.tag === 'tr') {
|
||||||
|
const cells = [];
|
||||||
|
for (const c of n.children) {
|
||||||
|
if (c.type !== 'node') continue;
|
||||||
|
if (c.tag !== 'td' && c.tag !== 'th') continue;
|
||||||
|
const cellOut = { blocks: [], cur: [], curMeta: {} };
|
||||||
|
walk(c.children, { bold: c.tag === 'th', italic: false }, cellOut);
|
||||||
|
cells.push({ blocks: cellOut.blocks, header: c.tag === 'th' });
|
||||||
|
}
|
||||||
|
rows.push({ cells });
|
||||||
|
} else if (n.tag === 'tbody' || n.tag === 'thead' || n.tag === 'tfoot' || n.tag === 'table') {
|
||||||
|
collect(n.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
collect(tableNode.children);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTML → 块序列(对外唯一入口) */
|
||||||
|
function htmlToBlocks(html) {
|
||||||
|
const out = { blocks: [], cur: [], curMeta: {} };
|
||||||
|
walk(parseHtml(html).children, { bold: false, italic: false }, out);
|
||||||
|
return out.blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 纯文本 → 单块(自动按空行/换行切段) */
|
||||||
|
function textToBlocks(text) {
|
||||||
|
const s = String(text == null ? '' : text);
|
||||||
|
if (!s.trim()) return [];
|
||||||
|
return s
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(line => line.trim())
|
||||||
|
.filter(line => line !== '')
|
||||||
|
.map(line => ({ type: 'p', runs: [{ text: line, bold: false, italic: false }] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 块 → WordprocessingML ====================
|
||||||
|
|
||||||
|
const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
||||||
|
|
||||||
|
function rPrOf(run) {
|
||||||
|
const parts = [];
|
||||||
|
if (run.bold) parts.push('<w:b/><w:bCs/>');
|
||||||
|
if (run.italic) parts.push('<w:i/><w:iCs/>');
|
||||||
|
parts.push('<w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:eastAsia="宋体" w:cs="Times New Roman"/>');
|
||||||
|
const sz = Number(run.size) > 0 ? Number(run.size) : 21;
|
||||||
|
parts.push(`<w:sz w:val="${sz}"/><w:szCs w:val="${sz}"/>`);
|
||||||
|
return `<w:rPr>${parts.join('')}</w:rPr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRun(run) {
|
||||||
|
if (run.br) return '<w:r><w:br/></w:r>';
|
||||||
|
const text = String(run.text == null ? '' : run.text);
|
||||||
|
if (text === '') return '';
|
||||||
|
return `<w:r>${rPrOf(run)}<w:t xml:space="preserve">${esc(text)}</w:t></w:r>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pPrOf(block, extra) {
|
||||||
|
const parts = [];
|
||||||
|
const align = block.align || '';
|
||||||
|
if (align) parts.push(`<w:jc w:val="${align === 'justify' ? 'both' : align}"/>`);
|
||||||
|
const fl = Number(block.firstLine) > 0 ? Number(block.firstLine) : 0;
|
||||||
|
if (fl) parts.push(`<w:ind w:firstLine="${fl}"/>`);
|
||||||
|
if (extra) parts.push(extra);
|
||||||
|
return parts.length ? `<w:pPr>${parts.join('')}</w:pPr>` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 段落。⛔ Word 要求 pPr 必须排在 runs 之前(顺序颠倒会导致文档被判损坏) */
|
||||||
|
function renderParagraph(block) {
|
||||||
|
const runs = (block.runs || []).map(renderRun).join('');
|
||||||
|
return `<w:p>${pPrOf(block)}${runs}</w:p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TBL_BORDERS = '<w:tblBorders>'
|
||||||
|
+ ['top', 'left', 'bottom', 'right', 'insideH', 'insideV']
|
||||||
|
.map(side => `<w:${side} w:val="single" w:sz="4" w:space="0" w:color="auto"/>`)
|
||||||
|
.join('')
|
||||||
|
+ '</w:tblBorders>';
|
||||||
|
|
||||||
|
function renderTable(block) {
|
||||||
|
const rows = block.rows || [];
|
||||||
|
if (!rows.length) return '';
|
||||||
|
const cellCount = Math.max(...rows.map(r => r.cells.length), 1);
|
||||||
|
const trs = rows.map(r => {
|
||||||
|
const tcs = [];
|
||||||
|
for (let i = 0; i < cellCount; i++) {
|
||||||
|
const cell = r.cells[i];
|
||||||
|
let inner;
|
||||||
|
if (!cell || !cell.blocks.length) {
|
||||||
|
inner = '<w:p/>';
|
||||||
|
} else {
|
||||||
|
inner = cell.blocks.map(b => (b.type === 'table'
|
||||||
|
// 嵌套表扁平化为文本,避免非法嵌套
|
||||||
|
? renderParagraph({ runs: flattenTableText(b) })
|
||||||
|
: renderParagraph(b))).join('');
|
||||||
|
}
|
||||||
|
tcs.push(`<w:tc><w:tcPr><w:tcW w:w="0" w:type="auto"/></w:tcPr>${inner}</w:tc>`);
|
||||||
|
}
|
||||||
|
return `<w:tr>${tcs.join('')}</w:tr>`;
|
||||||
|
}).join('');
|
||||||
|
return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/>${TBL_BORDERS}</w:tblPr>${trs}</w:tbl>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenTableText(tableBlock) {
|
||||||
|
const buf = [];
|
||||||
|
for (const r of tableBlock.rows || []) {
|
||||||
|
const cells = [];
|
||||||
|
for (const c of r.cells || []) {
|
||||||
|
const texts = [];
|
||||||
|
for (const b of c.blocks || []) {
|
||||||
|
if (b.type === 'p') texts.push((b.runs || []).map(x => (x.br ? ' ' : x.text)).join(''));
|
||||||
|
}
|
||||||
|
cells.push(texts.join(' ').trim());
|
||||||
|
}
|
||||||
|
buf.push({ br: false, text: cells.join(' | ') });
|
||||||
|
}
|
||||||
|
const runs = [];
|
||||||
|
buf.forEach((x, i) => {
|
||||||
|
if (i > 0) runs.push({ br: true });
|
||||||
|
runs.push({ text: x.text, bold: false, italic: false });
|
||||||
|
});
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDocumentXml(blocks) {
|
||||||
|
const parts = [];
|
||||||
|
for (const b of blocks) {
|
||||||
|
if (b.type === 'table') {
|
||||||
|
const t = renderTable(b);
|
||||||
|
if (t) {
|
||||||
|
parts.push(t);
|
||||||
|
// 表后补空段:Word 中「表格紧邻表格/节尾」易被判异常
|
||||||
|
parts.push('<w:p/>');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parts.push(renderParagraph(b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!parts.length) parts.push('<w:p/>');
|
||||||
|
parts.push('<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="851" w:footer="992" w:gutter="0"/></w:sectPr>');
|
||||||
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
+ `<w:document xmlns:w="${W_NS}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">`
|
||||||
|
+ `<w:body>${parts.join('')}</w:body></w:document>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 打包为 .docx ====================
|
||||||
|
|
||||||
|
const CONTENT_TYPES = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||||
|
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||||
|
+ '<Default Extension="xml" ContentType="application/xml"/>'
|
||||||
|
+ '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
|
||||||
|
+ '<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>'
|
||||||
|
+ '</Types>';
|
||||||
|
|
||||||
|
const ROOT_RELS = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||||
|
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
|
||||||
|
+ '</Relationships>';
|
||||||
|
|
||||||
|
const DOC_RELS = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||||
|
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
|
||||||
|
+ '</Relationships>';
|
||||||
|
|
||||||
|
const STYLES_XML = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
+ `<w:styles xmlns:w="${W_NS}">`
|
||||||
|
+ '<w:docDefaults><w:rPrDefault><w:rPr>'
|
||||||
|
+ '<w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:eastAsia="宋体" w:cs="Times New Roman"/>'
|
||||||
|
+ '<w:sz w:val="21"/><w:szCs w:val="21"/>'
|
||||||
|
+ '</w:rPr></w:rPrDefault></w:docDefaults>'
|
||||||
|
+ '<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>'
|
||||||
|
+ '</w:styles>';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把若干 part 打成一个 ZIP Buffer。
|
||||||
|
* ⚠️ archiver v8 为 ESM:沿用 service/admin.js:754 已验证的 `const { ZipArchive } = require('archiver')` 写法。
|
||||||
|
*/
|
||||||
|
function zipParts(parts) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let ZipArchive;
|
||||||
|
try {
|
||||||
|
({ ZipArchive } = require('archiver'));
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error('缺少 archiver 依赖,无法生成 .docx'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chunks = [];
|
||||||
|
const archive = new ZipArchive({ zlib: { level: 9 } });
|
||||||
|
archive.on('error', reject);
|
||||||
|
archive.on('data', c => chunks.push(c));
|
||||||
|
archive.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
for (const p of parts) archive.append(Buffer.from(p.data, 'utf8'), { name: p.name });
|
||||||
|
archive.finalize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成单个 .docx
|
||||||
|
* @param {Array} blocks - htmlToBlocks / textToBlocks 的产物(可多个数组按顺序拼接)
|
||||||
|
* @returns {Promise<Buffer>}
|
||||||
|
*/
|
||||||
|
async function buildDocx(blocks) {
|
||||||
|
const list = ([]).concat(blocks || []).filter(Boolean);
|
||||||
|
const flat = [];
|
||||||
|
for (const b of list) {
|
||||||
|
if (Array.isArray(b)) flat.push(...b);
|
||||||
|
else flat.push(b);
|
||||||
|
}
|
||||||
|
return zipParts([
|
||||||
|
{ name: '[Content_Types].xml', data: CONTENT_TYPES },
|
||||||
|
{ name: '_rels/.rels', data: ROOT_RELS },
|
||||||
|
{ name: 'word/document.xml', data: buildDocumentXml(flat) },
|
||||||
|
{ name: 'word/styles.xml', data: STYLES_XML },
|
||||||
|
{ name: 'word/_rels/document.xml.rels', data: DOC_RELS },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把多个 .docx 打成一个 ZIP(批量/全部导出用),返回 Buffer */
|
||||||
|
async function zipDocxFiles(entries) {
|
||||||
|
return zipParts(entries.map(e => ({ name: e.name, data: e.buffer })));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildDocx,
|
||||||
|
zipDocxFiles,
|
||||||
|
htmlToBlocks,
|
||||||
|
textToBlocks,
|
||||||
|
decodeEntities,
|
||||||
|
esc,
|
||||||
|
// 导出内部件便于单测
|
||||||
|
_internal: { parseHtml, buildDocumentXml, CONTENT_TYPES, ROOT_RELS, STYLES_XML, DOC_RELS },
|
||||||
|
};
|
||||||
@@ -10,6 +10,9 @@
|
|||||||
module.exports = app => {
|
module.exports = app => {
|
||||||
const { STRING, INTEGER, BOOLEAN, DATE } = app.Sequelize;
|
const { STRING, INTEGER, BOOLEAN, DATE } = app.Sequelize;
|
||||||
|
|
||||||
|
// 分数列统一走共享工厂(DECIMAL(6,1) + Number 兜底,见 app/lib/decimal_score.js)
|
||||||
|
const decimalScore = require('../lib/decimal_score')(app);
|
||||||
|
|
||||||
const Account = app.model.define('account', {
|
const Account = app.model.define('account', {
|
||||||
// 主键,自增
|
// 主键,自增
|
||||||
uid: {
|
uid: {
|
||||||
@@ -128,12 +131,7 @@ module.exports = app => {
|
|||||||
comment: '账号激活状态',
|
comment: '账号激活状态',
|
||||||
},
|
},
|
||||||
// 总分
|
// 总分
|
||||||
score: {
|
score: decimalScore('score', '账号总分', { defaultValue: null }),
|
||||||
type: INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: null,
|
|
||||||
comment: '账号总分',
|
|
||||||
},
|
|
||||||
// 记录创建时间
|
// 记录创建时间
|
||||||
created_at: {
|
created_at: {
|
||||||
type: DATE,
|
type: DATE,
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
module.exports = app => {
|
module.exports = app => {
|
||||||
const { STRING, INTEGER, DATE } = app.Sequelize;
|
const { STRING, INTEGER, DATE } = app.Sequelize;
|
||||||
|
|
||||||
|
// 分数列统一走共享工厂(DECIMAL(6,1) + Number 兜底,见 app/lib/decimal_score.js)
|
||||||
|
const decimalScore = require('../lib/decimal_score')(app);
|
||||||
|
|
||||||
const BenkeSubmission = app.model.define('benke_submission', {
|
const BenkeSubmission = app.model.define('benke_submission', {
|
||||||
// 主键 ID,自增
|
// 主键 ID,自增
|
||||||
id: {
|
id: {
|
||||||
@@ -71,12 +74,7 @@ module.exports = app => {
|
|||||||
comment: '上传时间',
|
comment: '上传时间',
|
||||||
},
|
},
|
||||||
// 评分
|
// 评分
|
||||||
score: {
|
score: decimalScore('score', '评分(可含 1 位小数)', { defaultValue: null }),
|
||||||
type: INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: null,
|
|
||||||
comment: '评分',
|
|
||||||
},
|
|
||||||
// 记录创建时间
|
// 记录创建时间
|
||||||
created_at: {
|
created_at: {
|
||||||
type: DATE,
|
type: DATE,
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
module.exports = app => {
|
module.exports = app => {
|
||||||
const { STRING, INTEGER, DATE, TEXT } = app.Sequelize;
|
const { STRING, INTEGER, DATE, TEXT } = app.Sequelize;
|
||||||
|
|
||||||
|
// 分数列统一走共享工厂(DECIMAL(6,1) + Number 兜底,见 app/lib/decimal_score.js)
|
||||||
|
const decimalScore = require('../lib/decimal_score')(app);
|
||||||
|
|
||||||
return app.model.define('BigQuestion', {
|
return app.model.define('BigQuestion', {
|
||||||
id: {
|
id: {
|
||||||
type: INTEGER,
|
type: INTEGER,
|
||||||
@@ -56,18 +59,8 @@ module.exports = app => {
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
comment: '第3小问题干',
|
comment: '第3小问题干',
|
||||||
},
|
},
|
||||||
score_per_sub: {
|
score_per_sub: decimalScore('score_per_sub', '每小题分值', { allowNull: false, defaultValue: 10 }),
|
||||||
type: INTEGER,
|
total_score: decimalScore('total_score', '大题总分', { allowNull: false, defaultValue: 30 }),
|
||||||
allowNull: false,
|
|
||||||
defaultValue: 10,
|
|
||||||
comment: '每小题分值',
|
|
||||||
},
|
|
||||||
total_score: {
|
|
||||||
type: INTEGER,
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: 30,
|
|
||||||
comment: '大题总分',
|
|
||||||
},
|
|
||||||
sort_order: {
|
sort_order: {
|
||||||
type: INTEGER,
|
type: INTEGER,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
module.exports = app => {
|
module.exports = app => {
|
||||||
const { STRING, INTEGER, DATE, TEXT } = app.Sequelize;
|
const { STRING, INTEGER, DATE, TEXT } = app.Sequelize;
|
||||||
|
|
||||||
|
// 分数列统一走共享工厂(DECIMAL(6,1) + Number 兜底,见 app/lib/decimal_score.js)
|
||||||
|
const decimalScore = require('../lib/decimal_score')(app);
|
||||||
|
|
||||||
return app.model.define('BigQuestionAnswer', {
|
return app.model.define('BigQuestionAnswer', {
|
||||||
id: {
|
id: {
|
||||||
type: INTEGER,
|
type: INTEGER,
|
||||||
@@ -66,26 +69,10 @@ module.exports = app => {
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
comment: '第3小问作答(上限 2000 字)',
|
comment: '第3小问作答(上限 2000 字)',
|
||||||
},
|
},
|
||||||
score_1: {
|
score_1: decimalScore('score_1', '第1小问得分(0-10,可含 1 位小数)'),
|
||||||
type: INTEGER,
|
score_2: decimalScore('score_2', '第2小问得分(0-10,可含 1 位小数)'),
|
||||||
allowNull: true,
|
score_3: decimalScore('score_3', '第3小问得分(0-10,可含 1 位小数)'),
|
||||||
comment: '第1小问得分(0-10)',
|
total_score: decimalScore('total_score', '三小问得分合计(可含 1 位小数)'),
|
||||||
},
|
|
||||||
score_2: {
|
|
||||||
type: INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
comment: '第2小问得分(0-10)',
|
|
||||||
},
|
|
||||||
score_3: {
|
|
||||||
type: INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
comment: '第3小问得分(0-10)',
|
|
||||||
},
|
|
||||||
total_score: {
|
|
||||||
type: INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
comment: '三小问得分合计',
|
|
||||||
},
|
|
||||||
submit_time: {
|
submit_time: {
|
||||||
type: DATE,
|
type: DATE,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
module.exports = app => {
|
module.exports = app => {
|
||||||
const { STRING, INTEGER, BOOLEAN, DATE } = app.Sequelize;
|
const { STRING, INTEGER, BOOLEAN, DATE } = app.Sequelize;
|
||||||
|
|
||||||
|
// 分数列统一走共享工厂(DECIMAL(6,1) + Number 兜底,见 app/lib/decimal_score.js)
|
||||||
|
const decimalScore = require('../lib/decimal_score')(app);
|
||||||
|
|
||||||
const Contestant = app.model.define('contestant', {
|
const Contestant = app.model.define('contestant', {
|
||||||
// 主键 ID,自增
|
// 主键 ID,自增
|
||||||
id: {
|
id: {
|
||||||
@@ -64,33 +67,13 @@ module.exports = app => {
|
|||||||
comment: '账号激活状态',
|
comment: '账号激活状态',
|
||||||
},
|
},
|
||||||
// 总分(管理员打分后更新,NULL 表示尚未评分)
|
// 总分(管理员打分后更新,NULL 表示尚未评分)
|
||||||
score: {
|
score: decimalScore('score', '选手总分(exam_score + word_score)'),
|
||||||
type: INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: null,
|
|
||||||
comment: '选手总分(exam_score + word_score)',
|
|
||||||
},
|
|
||||||
// 卷面分(选择题+判断题自动计算)
|
// 卷面分(选择题+判断题自动计算)
|
||||||
exam_score: {
|
exam_score: decimalScore('exam_score', '卷面考试成绩'),
|
||||||
type: INTEGER,
|
// 综合大题 / Word 题分数(管理员手动打分,可含 1 位小数)
|
||||||
allowNull: true,
|
word_score: decimalScore('word_score', 'Word题/综合大题得分(可含 1 位小数)'),
|
||||||
defaultValue: null,
|
|
||||||
comment: '卷面考试成绩',
|
|
||||||
},
|
|
||||||
// Word题分数(管理员手动打分)
|
|
||||||
word_score: {
|
|
||||||
type: INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: null,
|
|
||||||
comment: 'Word题得分',
|
|
||||||
},
|
|
||||||
// PPT分数(本科/教师组管理员手动打分)
|
// PPT分数(本科/教师组管理员手动打分)
|
||||||
ppt_score: {
|
ppt_score: decimalScore('ppt_score', 'PPT得分(本科/教师组,可含 1 位小数)'),
|
||||||
type: INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: null,
|
|
||||||
comment: 'PPT得分(本科/教师组)',
|
|
||||||
},
|
|
||||||
// 所属团队 ID(外键关联 teams 表)
|
// 所属团队 ID(外键关联 teams 表)
|
||||||
team_id: {
|
team_id: {
|
||||||
type: INTEGER,
|
type: INTEGER,
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
module.exports = app => {
|
module.exports = app => {
|
||||||
const { STRING, INTEGER, DATE, TEXT, JSON: JSON_TYPE } = app.Sequelize;
|
const { STRING, INTEGER, DATE, TEXT, JSON: JSON_TYPE } = app.Sequelize;
|
||||||
|
|
||||||
|
// 分数列统一走共享工厂(DECIMAL(6,1) + Number 兜底,见 app/lib/decimal_score.js)
|
||||||
|
const decimalScore = require('../lib/decimal_score')(app);
|
||||||
|
|
||||||
const ExamSubmission = app.model.define('exam_submission', {
|
const ExamSubmission = app.model.define('exam_submission', {
|
||||||
// 主键 ID,自增
|
// 主键 ID,自增
|
||||||
id: {
|
id: {
|
||||||
@@ -64,12 +67,7 @@ module.exports = app => {
|
|||||||
comment: '提交时间(占位/未提交为 NULL,正式提交后为具体时间)',
|
comment: '提交时间(占位/未提交为 NULL,正式提交后为具体时间)',
|
||||||
},
|
},
|
||||||
// 得分(管理员评分后更新,NULL 表示尚未评分)
|
// 得分(管理员评分后更新,NULL 表示尚未评分)
|
||||||
score: {
|
score: decimalScore('score', '试卷得分', { defaultValue: null }),
|
||||||
type: INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: null,
|
|
||||||
comment: '试卷得分',
|
|
||||||
},
|
|
||||||
// 创建时间
|
// 创建时间
|
||||||
created_at: {
|
created_at: {
|
||||||
type: DATE,
|
type: DATE,
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
module.exports = app => {
|
module.exports = app => {
|
||||||
const { STRING, INTEGER, TEXT, BOOLEAN, DATE } = app.Sequelize;
|
const { STRING, INTEGER, TEXT, BOOLEAN, DATE } = app.Sequelize;
|
||||||
|
|
||||||
|
// 分数列统一走共享工厂(DECIMAL(6,1) + Number 兜底,见 app/lib/decimal_score.js)
|
||||||
|
const decimalScore = require('../lib/decimal_score')(app);
|
||||||
|
|
||||||
const GaozhiSubmission = app.model.define('gaozhi_submission', {
|
const GaozhiSubmission = app.model.define('gaozhi_submission', {
|
||||||
// 主键 ID,自增
|
// 主键 ID,自增
|
||||||
id: {
|
id: {
|
||||||
@@ -54,12 +57,7 @@ module.exports = app => {
|
|||||||
comment: '是否判为正确',
|
comment: '是否判为正确',
|
||||||
},
|
},
|
||||||
// 得分
|
// 得分
|
||||||
score: {
|
score: decimalScore('score', '得分(可含 1 位小数)', { defaultValue: null }),
|
||||||
type: INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: null,
|
|
||||||
comment: '得分',
|
|
||||||
},
|
|
||||||
// Word 文件存储路径
|
// Word 文件存储路径
|
||||||
word_file_path: {
|
word_file_path: {
|
||||||
type: STRING(500),
|
type: STRING(500),
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
module.exports = app => {
|
module.exports = app => {
|
||||||
const { STRING, INTEGER, DATE } = app.Sequelize;
|
const { STRING, INTEGER, DATE } = app.Sequelize;
|
||||||
|
|
||||||
|
// 分数列统一走共享工厂(DECIMAL(6,1) + Number 兜底,见 app/lib/decimal_score.js)
|
||||||
|
const decimalScore = require('../lib/decimal_score')(app);
|
||||||
|
|
||||||
const JiaoshiSubmission = app.model.define('jiaoshi_submission', {
|
const JiaoshiSubmission = app.model.define('jiaoshi_submission', {
|
||||||
// 主键 ID,自增
|
// 主键 ID,自增
|
||||||
id: {
|
id: {
|
||||||
@@ -71,12 +74,7 @@ module.exports = app => {
|
|||||||
comment: '上传时间',
|
comment: '上传时间',
|
||||||
},
|
},
|
||||||
// 评分
|
// 评分
|
||||||
score: {
|
score: decimalScore('score', '评分(可含 1 位小数)', { defaultValue: null }),
|
||||||
type: INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: null,
|
|
||||||
comment: '评分',
|
|
||||||
},
|
|
||||||
// 记录创建时间
|
// 记录创建时间
|
||||||
created_at: {
|
created_at: {
|
||||||
type: DATE,
|
type: DATE,
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
module.exports = app => {
|
module.exports = app => {
|
||||||
const { STRING, INTEGER, DATE } = app.Sequelize;
|
const { STRING, INTEGER, DATE } = app.Sequelize;
|
||||||
|
|
||||||
|
// 分数列统一走共享工厂(DECIMAL(6,1) + Number 兜底,见 app/lib/decimal_score.js)
|
||||||
|
const decimalScore = require('../lib/decimal_score')(app);
|
||||||
|
|
||||||
const WorkSubmission = app.model.define('work_submission', {
|
const WorkSubmission = app.model.define('work_submission', {
|
||||||
// 主键 ID,自增
|
// 主键 ID,自增
|
||||||
id: {
|
id: {
|
||||||
@@ -63,12 +66,7 @@ module.exports = app => {
|
|||||||
comment: '提交时间',
|
comment: '提交时间',
|
||||||
},
|
},
|
||||||
// 得分(管理员评分后更新,NULL 表示尚未评分)
|
// 得分(管理员评分后更新,NULL 表示尚未评分)
|
||||||
score: {
|
score: decimalScore('score', '作品得分', { defaultValue: null }),
|
||||||
type: INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: null,
|
|
||||||
comment: '作品得分',
|
|
||||||
},
|
|
||||||
// 创建时间
|
// 创建时间
|
||||||
created_at: {
|
created_at: {
|
||||||
type: DATE,
|
type: DATE,
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
module.exports = app => {
|
module.exports = app => {
|
||||||
const { STRING, INTEGER, TEXT, BOOLEAN, DATE } = app.Sequelize;
|
const { STRING, INTEGER, TEXT, BOOLEAN, DATE } = app.Sequelize;
|
||||||
|
|
||||||
|
// 分数列统一走共享工厂(DECIMAL(6,1) + Number 兜底,见 app/lib/decimal_score.js)
|
||||||
|
const decimalScore = require('../lib/decimal_score')(app);
|
||||||
|
|
||||||
const ZhongzhiSubmission = app.model.define('zhongzhi_submission', {
|
const ZhongzhiSubmission = app.model.define('zhongzhi_submission', {
|
||||||
// 主键 ID,自增
|
// 主键 ID,自增
|
||||||
id: {
|
id: {
|
||||||
@@ -54,12 +57,7 @@ module.exports = app => {
|
|||||||
comment: '是否判为正确',
|
comment: '是否判为正确',
|
||||||
},
|
},
|
||||||
// 得分
|
// 得分
|
||||||
score: {
|
score: decimalScore('score', '得分(可含 1 位小数)', { defaultValue: null }),
|
||||||
type: INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: null,
|
|
||||||
comment: '得分',
|
|
||||||
},
|
|
||||||
// Word 文件存储路径
|
// Word 文件存储路径
|
||||||
word_file_path: {
|
word_file_path: {
|
||||||
type: STRING(500),
|
type: STRING(500),
|
||||||
|
|||||||
@@ -144,6 +144,11 @@ module.exports = app => {
|
|||||||
// 综合大题:题库管理(CRUD)+ 作答查证(防考生纠纷)+ 按小问评分
|
// 综合大题:题库管理(CRUD)+ 作答查证(防考生纠纷)+ 按小问评分
|
||||||
router.get('/api/admin/big-questions/:group', adminAuth, controller.admin.listBigQuestions);
|
router.get('/api/admin/big-questions/:group', adminAuth, controller.admin.listBigQuestions);
|
||||||
router.post('/api/admin/big-questions/:group', adminAuth, controller.admin.createBigQuestion);
|
router.post('/api/admin/big-questions/:group', adminAuth, controller.admin.createBigQuestion);
|
||||||
|
// Word 导出:?ids=1,2,3 批量;缺省 = 该组全部。1 道 → .docx;多道 → .zip(内含多个 .docx)
|
||||||
|
// ⚠️ 必须注册在 `:group/:id` 之前:Egg 按注册顺序匹配,否则一旦将来新增
|
||||||
|
// `GET /:group/:id`,`export` 就会被当作 :id 吞掉(实测 2026-09-23 复核时
|
||||||
|
// 本行曾被放在 :group/:id 之后,当时因 :group/:id 仅注册 PUT/DELETE 而未暴露问题)
|
||||||
|
router.get('/api/admin/big-questions/:group/export', adminAuth, controller.admin.exportBigQuestions);
|
||||||
router.put('/api/admin/big-questions/:group/:id', adminAuth, controller.admin.updateBigQuestion);
|
router.put('/api/admin/big-questions/:group/:id', adminAuth, controller.admin.updateBigQuestion);
|
||||||
router.delete('/api/admin/big-questions/:group/:id', adminAuth, controller.admin.deleteBigQuestion);
|
router.delete('/api/admin/big-questions/:group/:id', adminAuth, controller.admin.deleteBigQuestion);
|
||||||
router.get('/api/admin/big-answers/:group', adminAuth, controller.admin.getBigAnswers);
|
router.get('/api/admin/big-answers/:group', adminAuth, controller.admin.getBigAnswers);
|
||||||
|
|||||||
@@ -5,6 +5,18 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const XLSX = require('xlsx');
|
const XLSX = require('xlsx');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客观题(选择题 / 判断题)每小题固定分值。
|
||||||
|
*
|
||||||
|
* 赛制口径(2026-09-23 统一):中职 / 高职组的选择题与判断题**每小题 1 分**,
|
||||||
|
* 客观题满分 = 抽题数(50 + 20 = 70)。
|
||||||
|
* 此前分值可由管理端任意设置、且 Excel 导入缺省 5 分,一旦「5 分题的答案
|
||||||
|
* 恰好全是 A / true」,考生全选 A 即可白拿高分(实测高职判断题得分率 95.3%)。
|
||||||
|
* 综合性任务(word)不在本约束范围内。
|
||||||
|
*/
|
||||||
|
const OBJECTIVE_SCORE_PER_QUESTION = 1;
|
||||||
|
const OBJECTIVE_TYPES = ['choice', 'judge'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理后台服务 - AdminService
|
* 管理后台服务 - AdminService
|
||||||
*
|
*
|
||||||
@@ -960,6 +972,27 @@ class AdminService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算题目的落库分值。
|
||||||
|
*
|
||||||
|
* 客观题(choice / judge)一律强制为 `OBJECTIVE_SCORE_PER_QUESTION`(=1),
|
||||||
|
* 忽略调用方传入的任何分值;综合性任务(word)保留传入分值(须为正数)。
|
||||||
|
*
|
||||||
|
* @param {string} questionType - choice / judge / word
|
||||||
|
* @param {*} rawScore - 调用方传入的分值(客观题会忽略)
|
||||||
|
* @returns {number} 实际落库分值
|
||||||
|
*/
|
||||||
|
_resolveQuestionScore(questionType, rawScore) {
|
||||||
|
if (OBJECTIVE_TYPES.includes(questionType)) {
|
||||||
|
return OBJECTIVE_SCORE_PER_QUESTION;
|
||||||
|
}
|
||||||
|
const n = Number(rawScore);
|
||||||
|
if (!isFinite(n) || n <= 0) {
|
||||||
|
throw new Error('综合性任务分值(score)必须为大于 0 的合法数值');
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理考试题目(替换操作:先清空再创建)
|
* 管理考试题目(替换操作:先清空再创建)
|
||||||
* @param {Array} questions - 题目数组
|
* @param {Array} questions - 题目数组
|
||||||
@@ -982,11 +1015,8 @@ class AdminService extends Service {
|
|||||||
|
|
||||||
// 批量插入新题目
|
// 批量插入新题目
|
||||||
const questionData = questions.map((q, index) => {
|
const questionData = questions.map((q, index) => {
|
||||||
// 分值必须为大于 0 的合法数值;0 分题属非法业务数据,整批替换时直接拒绝
|
// 客观题(choice/judge)分值由服务层强制为 1(赛制口径),忽略传入值
|
||||||
const scoreNum = Number(q.score);
|
const scoreNum = this._resolveQuestionScore(questionType, q.score);
|
||||||
if (!isFinite(scoreNum) || scoreNum <= 0) {
|
|
||||||
throw new Error(`题目分值(score)必须为大于 0 的合法数值(第 ${index + 1} 题)`);
|
|
||||||
}
|
|
||||||
const base = {
|
const base = {
|
||||||
group,
|
group,
|
||||||
score: scoreNum,
|
score: scoreNum,
|
||||||
@@ -1037,12 +1067,8 @@ class AdminService extends Service {
|
|||||||
async addQuestion(questionData, group, questionType) {
|
async addQuestion(questionData, group, questionType) {
|
||||||
const QuestionModel = this._getQuestionModel(questionType);
|
const QuestionModel = this._getQuestionModel(questionType);
|
||||||
|
|
||||||
// 分值必须为大于 0 的合法数值;0 分题属非法业务数据
|
// 客观题(choice/judge)一律 1 分(赛制口径);word 题型保留传入分值
|
||||||
// (抽中该题的考生答对也得 0 分,且不同人满分不一致)。缺失/非法分值直接拒绝。
|
const scoreNum = this._resolveQuestionScore(questionType, questionData.score);
|
||||||
const scoreNum = Number(questionData.score);
|
|
||||||
if (!isFinite(scoreNum) || scoreNum <= 0) {
|
|
||||||
throw new Error('题目分值(score)必须为大于 0 的合法数值');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
group,
|
group,
|
||||||
@@ -1081,7 +1107,10 @@ class AdminService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateData = {
|
const updateData = {
|
||||||
score: questionData.score !== undefined ? questionData.score : question.score,
|
// 客观题分值恒为 1(忽略传入值,防止管理端改回 5 分);word 题型沿用原逻辑
|
||||||
|
score: OBJECTIVE_TYPES.includes(questionType)
|
||||||
|
? OBJECTIVE_SCORE_PER_QUESTION
|
||||||
|
: (questionData.score !== undefined ? Number(questionData.score) : question.score),
|
||||||
sort_order: questionData.sort_order !== undefined ? questionData.sort_order : question.sort_order,
|
sort_order: questionData.sort_order !== undefined ? questionData.sort_order : question.sort_order,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1373,8 +1402,10 @@ class AdminService extends Service {
|
|||||||
async setTypeScore(group, questionType, score) {
|
async setTypeScore(group, questionType, score) {
|
||||||
const { ctx } = this;
|
const { ctx } = this;
|
||||||
const QuestionModel = this._getQuestionModel(questionType);
|
const QuestionModel = this._getQuestionModel(questionType);
|
||||||
|
// 客观题只能被设为 1 分(赛制口径);word 题型按传入值设置
|
||||||
|
const resolved = this._resolveQuestionScore(questionType, score);
|
||||||
const [updated] = await QuestionModel.update(
|
const [updated] = await QuestionModel.update(
|
||||||
{ score },
|
{ score: resolved },
|
||||||
{ where: { group } }
|
{ where: { group } }
|
||||||
);
|
);
|
||||||
return updated;
|
return updated;
|
||||||
@@ -1477,9 +1508,9 @@ class AdminService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const scoreRaw = row['分值'];
|
// 客观题分值恒为 1(赛制口径)。Excel 的「分值」列一律忽略 ——
|
||||||
let score = parseInt(scoreRaw, 10);
|
// 旧逻辑在缺列/非法时缺省 5 分,正是「高分题」污染题库的来源之一。
|
||||||
if (isNaN(score) || score < 1) score = 5;
|
const score = OBJECTIVE_SCORE_PER_QUESTION;
|
||||||
|
|
||||||
const data = { group, question, answer, score, sort_order: 0 };
|
const data = { group, question, answer, score, sort_order: 0 };
|
||||||
if (qType === 'choice') data.options = options;
|
if (qType === 'choice') data.options = options;
|
||||||
@@ -1503,11 +1534,12 @@ class AdminService extends Service {
|
|||||||
* @returns {Promise<Buffer>} xlsx 文件 Buffer
|
* @returns {Promise<Buffer>} xlsx 文件 Buffer
|
||||||
*/
|
*/
|
||||||
async buildQuestionTemplate() {
|
async buildQuestionTemplate() {
|
||||||
|
// 「分值」列已废弃(客观题恒为 1 分),保留列名仅为兼容旧模板与旧脚本
|
||||||
const header = ['题型', '题目', '选项A', '选项B', '选项C', '选项D', '正确答案', '分值'];
|
const header = ['题型', '题目', '选项A', '选项B', '选项C', '选项D', '正确答案', '分值'];
|
||||||
const example = ['选择题', '北斗系统由多少颗卫星组成?', '24', '35', '55', '12', 'C', 5];
|
const example = ['选择题', '北斗系统由多少颗卫星组成?', '24', '35', '55', '12', 'C', 1];
|
||||||
const ws = XLSX.utils.aoa_to_sheet([header, example]);
|
const ws = XLSX.utils.aoa_to_sheet([header, example]);
|
||||||
// 判断题示例行(另起一行,便于参考)
|
// 判断题示例行(另起一行,便于参考)
|
||||||
XLSX.utils.sheet_add_aoa(ws, [['判断题', '北斗系统由我国自主研发。', '', '', '', '', '对', 5]], { origin: -1 });
|
XLSX.utils.sheet_add_aoa(ws, [['判断题', '北斗系统由我国自主研发。', '', '', '', '', '对', 1]], { origin: -1 });
|
||||||
const wb = XLSX.utils.book_new();
|
const wb = XLSX.utils.book_new();
|
||||||
XLSX.utils.book_append_sheet(wb, ws, '客观题');
|
XLSX.utils.book_append_sheet(wb, ws, '客观题');
|
||||||
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||||
|
|||||||
@@ -37,6 +37,18 @@ const SUB_QUESTION_COUNT = 3;
|
|||||||
const ANSWER_FIELDS = ['answer_1', 'answer_2', 'answer_3'];
|
const ANSWER_FIELDS = ['answer_1', 'answer_2', 'answer_3'];
|
||||||
const SCORE_FIELDS = ['score_1', 'score_2', 'score_3'];
|
const SCORE_FIELDS = ['score_1', 'score_2', 'score_3'];
|
||||||
|
|
||||||
|
/** 组别中文名(导出文档抬头 / 文件名用) */
|
||||||
|
const GROUP_LABELS = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' };
|
||||||
|
|
||||||
|
/** 导出文件名里的非法字符(Windows 禁止 \ / : * ? " < > |,另剔除控制字符) */
|
||||||
|
function safeFilePart(str) {
|
||||||
|
return String(str == null ? '' : str)
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_')
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
.slice(0, 80) || 'untitled';
|
||||||
|
}
|
||||||
|
|
||||||
class BigQuestionService extends Service {
|
class BigQuestionService extends Service {
|
||||||
/** 该组别是否启用综合大题 */
|
/** 该组别是否启用综合大题 */
|
||||||
isEnabledGroup(group) {
|
isEnabledGroup(group) {
|
||||||
@@ -315,14 +327,30 @@ class BigQuestionService extends Service {
|
|||||||
return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' };
|
return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⚠️ 必须带 group(与读取路径 listAnswersForAdmin 同源):只按 aggregation_uid 定位时,
|
||||||
|
// 用任意组别的路径即可改写他组考生成绩,且下方 updateScores(..., group) 会用错组
|
||||||
|
// 去同步总分 ⇒ 组别隔离在写入侧被绕过(2026-09-23 实测:
|
||||||
|
// PUT /big-answers/gaozhi/<中职考生uid>/scores 返回 code=0 并真实改写了中职行)。
|
||||||
const record = await ctx.model.BigQuestionAnswer.findOne({
|
const record = await ctx.model.BigQuestionAnswer.findOne({
|
||||||
where: { aggregation_uid: aggregationUid },
|
where: { group, aggregation_uid: aggregationUid },
|
||||||
});
|
});
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return { ok: false, code: 'NOT_FOUND', message: '该考生尚未抽取大题,无法评分' };
|
// 区分「不属于本组」与「尚未抽题」:前者通常意味着前端残留了上一组的 uid,
|
||||||
|
// 文案要能直接暴露这类误操作,而不是把它伪装成「考生没抽题」。
|
||||||
|
const elsewhere = await ctx.model.BigQuestionAnswer.findOne({
|
||||||
|
where: { aggregation_uid: aggregationUid },
|
||||||
|
attributes: ['id'],
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: elsewhere
|
||||||
|
? '该考生不属于当前组别,无法在本组评分'
|
||||||
|
: '该考生尚未抽取大题,无法评分',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 逐问校验 0–10 整数(分值口径来自题库 score_per_sub,默认 10)
|
// 逐问校验 0–per 的数值(可含一位小数;per 来自题库 score_per_sub,默认 10)
|
||||||
const per = await this.resolveScorePerSub(group, record);
|
const per = await this.resolveScorePerSub(group, record);
|
||||||
const parsed = [];
|
const parsed = [];
|
||||||
for (let i = 0; i < SUB_QUESTION_COUNT; i++) {
|
for (let i = 0; i < SUB_QUESTION_COUNT; i++) {
|
||||||
@@ -332,11 +360,13 @@ class BigQuestionService extends Service {
|
|||||||
}
|
}
|
||||||
const n = Number(raw);
|
const n = Number(raw);
|
||||||
if (!Number.isFinite(n) || n < 0 || n > per) {
|
if (!Number.isFinite(n) || n < 0 || n > per) {
|
||||||
return { ok: false, code: 'INVALID_SCORE', message: `第 ${i + 1} 小问得分须为 0-${per} 的数值` };
|
return { ok: false, code: 'INVALID_SCORE', message: `第 ${i + 1} 小问得分须为 0-${per} 的数值(可含一位小数)` };
|
||||||
}
|
}
|
||||||
parsed.push(Math.round(n));
|
// 2026-09-23:人工评分允许小数点后一位 ⇒ 收敛到 1 位小数,⛔ 不可用 Math.round 抹成整数
|
||||||
|
parsed.push(Math.round(n * 10) / 10);
|
||||||
}
|
}
|
||||||
const total = parsed.reduce((a, b) => a + b, 0);
|
// 浮点求和再收敛一次,避免 0.1+0.2=0.30000000000000004 之类脏值落库/回显
|
||||||
|
const total = Math.round(parsed.reduce((a, b) => a + b, 0) * 10) / 10;
|
||||||
|
|
||||||
await record.update({
|
await record.update({
|
||||||
score_1: parsed[0],
|
score_1: parsed[0],
|
||||||
@@ -398,6 +428,107 @@ class BigQuestionService extends Service {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出大题为 Word(.docx)(2026-09-23 需求:单独 / 批量 / 全部导出中职·高职大题)
|
||||||
|
*
|
||||||
|
* 行为:
|
||||||
|
* · 指定 ids(单个或多个)→ 只导出这些题;
|
||||||
|
* · 不传 ids → 导出该组**全部**;
|
||||||
|
* · 命中 1 道 → 直接返回 .docx;
|
||||||
|
* · 命中多道 → 返回「多个 .docx 打成的一个 .zip」(每道题一个独立 Word 文件,
|
||||||
|
* 便于分别打印/分发;而不是把多道题塞进一个长文档)。
|
||||||
|
*
|
||||||
|
* 只读操作:不写库、不改任何状态。
|
||||||
|
*
|
||||||
|
* @param {string} group - 组别(zhongzhi / gaozhi)
|
||||||
|
* @param {Array<number>|string} [ids] - 大题 ID 列表(数组或逗号分隔字符串)
|
||||||
|
* @returns {Promise<Object>} { ok:true, fileName, buffer, count, isZip? } 或 { ok:false, code, message }
|
||||||
|
*/
|
||||||
|
async exportQuestions(group, ids) {
|
||||||
|
const { ctx } = this;
|
||||||
|
|
||||||
|
if (!this.isEnabledGroup(group)) {
|
||||||
|
return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = { group };
|
||||||
|
if (ids !== undefined && ids !== null && ids !== '') {
|
||||||
|
const raw = Array.isArray(ids) ? ids : String(ids).split(',');
|
||||||
|
const idList = raw
|
||||||
|
.map(v => Number(String(v).trim()))
|
||||||
|
.filter(v => Number.isInteger(v) && v > 0);
|
||||||
|
if (!idList.length) {
|
||||||
|
return { ok: false, code: 'INVALID_IDS', message: '导出的大题 ID 列表非法' };
|
||||||
|
}
|
||||||
|
// Sequelize:数组值自动生成 IN (...) 条件,无需 Op
|
||||||
|
where.id = idList;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await ctx.model.BigQuestion.findAll({
|
||||||
|
where,
|
||||||
|
order: [['sort_order', 'ASC'], ['id', 'ASC']],
|
||||||
|
});
|
||||||
|
if (!rows.length) {
|
||||||
|
return { ok: false, code: 'NOT_FOUND', message: '该组别下没有可导出的大题' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const docx = require('../lib/docx');
|
||||||
|
const groupLabel = GROUP_LABELS[group] || group;
|
||||||
|
const files = [];
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
const blocks = [];
|
||||||
|
// 抬头
|
||||||
|
blocks.push({
|
||||||
|
type: 'p',
|
||||||
|
align: 'center',
|
||||||
|
runs: [{ text: r.title || '(无标题)', bold: true, size: 32 }],
|
||||||
|
});
|
||||||
|
blocks.push({
|
||||||
|
type: 'p',
|
||||||
|
runs: [{
|
||||||
|
text: `组别:${groupLabel} 分值:每小题 ${r.score_per_sub} 分 × ${SUB_QUESTION_COUNT} 小题 = ${r.total_score} 分`,
|
||||||
|
bold: false,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 题干(富文本 HTML → 段落 + 真实 Word 表格)
|
||||||
|
blocks.push({ type: 'p', runs: [] });
|
||||||
|
blocks.push({ type: 'p', runs: [{ text: '【题目】', bold: true, size: 26 }] });
|
||||||
|
blocks.push(...docx.htmlToBlocks(r.description || ''));
|
||||||
|
|
||||||
|
// 三小问
|
||||||
|
for (let i = 1; i <= SUB_QUESTION_COUNT; i++) {
|
||||||
|
blocks.push({ type: 'p', runs: [] });
|
||||||
|
blocks.push({
|
||||||
|
type: 'p',
|
||||||
|
runs: [{ text: `【第 ${i} 小问】(${r.score_per_sub} 分)`, bold: true, size: 26 }],
|
||||||
|
});
|
||||||
|
blocks.push(...docx.textToBlocks(r[`sub_prompt_${i}`] || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await docx.buildDocx(blocks);
|
||||||
|
// 文件名用 question_uid(稳定标识)而非标题:标题可能含空格/标点,且允许重名
|
||||||
|
files.push({
|
||||||
|
name: `${groupLabel}_${safeFilePart(r.question_uid || `big_${r.id}`)}.docx`,
|
||||||
|
buffer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length === 1) {
|
||||||
|
return { ok: true, fileName: files[0].name, buffer: files[0].buffer, count: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await docx.zipDocxFiles(files);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
isZip: true,
|
||||||
|
fileName: `${groupLabel}_综合大题_${files.length}道.zip`,
|
||||||
|
buffer,
|
||||||
|
count: files.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 新建大题
|
* 新建大题
|
||||||
* @param {string} group
|
* @param {string} group
|
||||||
@@ -533,6 +664,8 @@ class BigQuestionService extends Service {
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
subPrompts,
|
subPrompts,
|
||||||
|
// 每小题分值 = 赛制口径(每问 10 分、大题 30 分),刻意保持整数;
|
||||||
|
// 人工评分的小数精度只作用于「考生得分」,见 setSubScores。
|
||||||
scorePerSub: Math.round(scorePerSub),
|
scorePerSub: Math.round(scorePerSub),
|
||||||
sortOrder,
|
sortOrder,
|
||||||
status,
|
status,
|
||||||
|
|||||||
@@ -236,6 +236,12 @@ module.exports = (appInfo) => {
|
|||||||
// 由上方 define.collate 负责。此处传 collate 会触发
|
// 由上方 define.collate 负责。此处传 collate 会触发
|
||||||
// "Ignoring invalid configuration option passed to Connection: collate" 警告。
|
// "Ignoring invalid configuration option passed to Connection: collate" 警告。
|
||||||
charset: 'utf8mb4',
|
charset: 'utf8mb4',
|
||||||
|
// 🔴 DECIMAL 必须显式转 Number(2026-09-23,一位小数评分)
|
||||||
|
// mysql2 默认把 DECIMAL 读成**字符串**,会导致:① 前端出现
|
||||||
|
// "7.5" + 0 → "7.50" 之类的字符串拼接;② `n > per` 退化为字符串比较。
|
||||||
|
// 开启后 DECIMAL 以 Number 返回,与 INTEGER 行为一致;全库原无 DECIMAL 列
|
||||||
|
// (迁移 025 首次引入),故对既有功能零影响。
|
||||||
|
decimalNumbers: true,
|
||||||
},
|
},
|
||||||
pool: {
|
pool: {
|
||||||
// 🔴 压测调优(2026-08-11):单 worker 默认 10 连接,在「无带宽限制 + 极端并发」
|
// 🔴 压测调优(2026-08-11):单 worker 默认 10 连接,在「无带宽限制 + 极端并发」
|
||||||
|
|||||||
99
database/migrations/025-scores-decimal-one-place.sql
Normal file
99
database/migrations/025-scores-decimal-one-place.sql
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- 025 一位小数评分支持:分数列 int(11) → DECIMAL(6,1)
|
||||||
|
--
|
||||||
|
-- 背景(2026-09-23)
|
||||||
|
-- 需求:中职 / 高职「综合大题」的人工评分允许保留小数点后一位(如 7.5)。
|
||||||
|
-- 现状:全库 23 个 score 类列**全部**为 int(11) ⇒ 小数落库即被静默截断;
|
||||||
|
-- 且 service/bigQuestion.js:337 另有一处 Math.round() 主动取整。
|
||||||
|
-- 本迁移把「得分」语义的列放宽为 DECIMAL(6,1)(上限 99999.9,保留 1 位小数)。
|
||||||
|
--
|
||||||
|
-- 范围(17 列)
|
||||||
|
-- 改(得分 / 分数落点):
|
||||||
|
-- big_question_answers.score_1 / score_2 / score_3 / total_score
|
||||||
|
-- contestants.exam_score / word_score / ppt_score / score
|
||||||
|
-- accounts.score
|
||||||
|
-- zhongzhi_submissions.score / gaozhi_submissions.score / exam_submissions.score
|
||||||
|
-- benke_submissions.score / jiaoshi_submissions.score / work_submissions.score
|
||||||
|
-- big_questions.score_per_sub / total_score(大题分值配置,随评分口径)
|
||||||
|
-- 不改:
|
||||||
|
-- choice_questions.score / judge_questions.score
|
||||||
|
-- —— 客观题口径已固定为每题 1 分(service/admin.js OBJECTIVE_SCORE_PER_QUESTION),
|
||||||
|
-- 且 scripts/check_question_bank_health.py 门禁断言其恒为 1;
|
||||||
|
-- word_questions.score、exam_questions/zhongzhi_questions/gaozhi_questions.score
|
||||||
|
-- —— 属题目分值配置或历史遗留题表,无小数需求。
|
||||||
|
--
|
||||||
|
-- 兼容性
|
||||||
|
-- INT → DECIMAL(6,1) 是**放宽**:既有整数值原样保留,读写语义不变。
|
||||||
|
-- ⚠️ 应用侧必须同步:
|
||||||
|
-- ① config/config.default.js 的 sequelize.dialectOptions.decimalNumbers = true
|
||||||
|
-- (否则 mysql2 把 DECIMAL 读成**字符串**);
|
||||||
|
-- ② 相关 model 字段类型声明为 DECIMAL,且 getter 转 Number
|
||||||
|
-- (否则 Sequelize 的 INTEGER.parse 会把 7.5 二次截断成 7)。
|
||||||
|
--
|
||||||
|
-- 执行(在 database/ 目录下):
|
||||||
|
-- mysql -uroot -p competition_db < migrations/025-scores-decimal-one-place.sql
|
||||||
|
-- 幂等:MODIFY 到同一类型可重复执行。
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 大题作答
|
||||||
|
ALTER TABLE `big_question_answers`
|
||||||
|
MODIFY COLUMN `score_1` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '第 1 小问得分(可含 1 位小数)',
|
||||||
|
MODIFY COLUMN `score_2` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '第 2 小问得分(可含 1 位小数)',
|
||||||
|
MODIFY COLUMN `score_3` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '第 3 小问得分(可含 1 位小数)',
|
||||||
|
MODIFY COLUMN `total_score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '大题合计得分(可含 1 位小数)';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 大题配置
|
||||||
|
ALTER TABLE `big_questions`
|
||||||
|
MODIFY COLUMN `score_per_sub` DECIMAL(6,1) NOT NULL DEFAULT 10 COMMENT '每小题分值',
|
||||||
|
MODIFY COLUMN `total_score` DECIMAL(6,1) NOT NULL DEFAULT 30 COMMENT '大题总分 = 每小题分值 × 小问数';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 选手成绩
|
||||||
|
ALTER TABLE `contestants`
|
||||||
|
MODIFY COLUMN `exam_score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '卷面考试成绩',
|
||||||
|
MODIFY COLUMN `word_score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT 'Word题/综合大题得分(可含 1 位小数)',
|
||||||
|
MODIFY COLUMN `ppt_score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT 'PPT得分(本科/教师组,可含 1 位小数)',
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '选手总分(exam_score + word_score / word_score + ppt_score)';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 账号分(历史兼容)
|
||||||
|
ALTER TABLE `accounts`
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '总分(历史兼容字段)';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 提交表分项
|
||||||
|
ALTER TABLE `zhongzhi_submissions`
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '本题得分';
|
||||||
|
ALTER TABLE `gaozhi_submissions`
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '本题得分';
|
||||||
|
ALTER TABLE `exam_submissions`
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '卷面得分';
|
||||||
|
ALTER TABLE `benke_submissions`
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '作品得分';
|
||||||
|
ALTER TABLE `jiaoshi_submissions`
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '作品得分';
|
||||||
|
ALTER TABLE `work_submissions`
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '作品得分';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 回滚(⚠️ DECIMAL → INT 会**丢失**既有小数位,执行前务必确认无小数值:
|
||||||
|
-- SELECT COUNT(*) FROM contestants WHERE score <> FLOOR(score); -- 应为 0)
|
||||||
|
--
|
||||||
|
-- ALTER TABLE `big_question_answers`
|
||||||
|
-- MODIFY COLUMN `score_1` INT(11) NULL DEFAULT NULL,
|
||||||
|
-- MODIFY COLUMN `score_2` INT(11) NULL DEFAULT NULL,
|
||||||
|
-- MODIFY COLUMN `score_3` INT(11) NULL DEFAULT NULL,
|
||||||
|
-- MODIFY COLUMN `total_score` INT(11) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `big_questions`
|
||||||
|
-- MODIFY COLUMN `score_per_sub` INT(11) NOT NULL DEFAULT 10,
|
||||||
|
-- MODIFY COLUMN `total_score` INT(11) NOT NULL DEFAULT 30;
|
||||||
|
-- ALTER TABLE `contestants`
|
||||||
|
-- MODIFY COLUMN `exam_score` INT(11) NULL DEFAULT NULL,
|
||||||
|
-- MODIFY COLUMN `word_score` INT(11) NULL DEFAULT NULL,
|
||||||
|
-- MODIFY COLUMN `ppt_score` INT(11) NULL DEFAULT NULL,
|
||||||
|
-- MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `accounts` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `zhongzhi_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `gaozhi_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `exam_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `benke_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `jiaoshi_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `work_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||||
|
-- ============================================================================
|
||||||
Reference in New Issue
Block a user