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') {
|
||||
for (const q of questions) {
|
||||
// 分值必须为大于 0 的合法数值;0 分题属非法业务数据
|
||||
if (q.score == null || !isFinite(Number(q.score)) || Number(q.score) <= 0) {
|
||||
ctx.body = ctx.helper.error(400, '题目分值(score)必须为大于 0 的合法数值');
|
||||
return;
|
||||
}
|
||||
// 客观题(choice/judge)分值由 service 层强制为 1(赛制口径),此处不再要求
|
||||
// 调用方传 score;word 题型的合法性由 service 层 _resolveQuestionScore 校验。
|
||||
await service.admin.addQuestion(q, group, qType);
|
||||
}
|
||||
ctx.body = ctx.helper.success({ added: questions.length }, '题目添加成功');
|
||||
@@ -494,14 +491,12 @@ class AdminController extends Controller {
|
||||
}
|
||||
ctx.body = ctx.helper.success({ deleted: questions.length }, '题目删除成功');
|
||||
} else if (action === 'setTypeScore') {
|
||||
// 批量按题型设置分值:将该组该题型全部题目统一设为指定分值
|
||||
if (score == null || isNaN(parseInt(score, 10))) {
|
||||
ctx.body = ctx.helper.error(400, '批量设分需要合法的 score 数值');
|
||||
return;
|
||||
}
|
||||
const updated = await service.admin.setTypeScore(group, qType, parseInt(score, 10));
|
||||
// 分值口径已统一(2026-09-23):客观题(choice/judge)恒为 1 分,
|
||||
// 传入的 score 被忽略;仅综合性任务(word)按传入值设置(service 层校验)。
|
||||
const updated = await service.admin.setTypeScore(group, qType, score);
|
||||
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') {
|
||||
// 批量删除题目: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
|
||||
|
||||
Reference in New Issue
Block a user