feat(big-question): 综合大题 Excel 模板下载 + 批量导入(R73)
新增 GET /api/admin/big-questions/:group/template —— 生成自解释模板: 数据 sheet 排第 1(导入侧按 SheetNames[0] 解析,是承重契约)+「填写说明」sheet, 含列名含义/状态建议/常见被拒原因,让测试人员下载即会用,不依赖外部文档。 新增 POST /api/admin/big-questions/:group/import-excel —— 逐行调用 service.bigQuestion.createQuestion,标题长度/三小问/分值/泄题检测(detectAnswerLeak) 全部自动继承,与手工录入同一入口同一校验;批量导入绝不成为绕过防呆的后门。 组别以 URL :group 为准,忽略表格里的组别列;同组内标题重复即跳过并报错; 状态缺省 0(停用),批量录入的题未经人工校对不直接进抽题池。 客观题模板 buildQuestionTemplate 同步加「填写说明」sheet:数据 sheet 仍排第 1、 原 8 列不变,故 importQuestionsFromExcel 的 SheetNames[0] 解析不受影响(已离线验证)。 路由顺序硬约束:含具体段的 template / import-excel 必须排在通配段 /:id 之前。
This commit is contained in:
@@ -1638,6 +1638,30 @@ class AdminService extends Service {
|
||||
XLSX.utils.sheet_add_aoa(ws, [['判断题', '北斗系统由我国自主研发。', '', '', '', '', '对', 1]], { origin: -1 });
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, '客观题');
|
||||
// ⚠️ sheet 顺序是**契约**:导入侧(importQuestionsFromExcel)按 `SheetNames[0]` 解析,
|
||||
// 因此数据 sheet 必须排第 1,「填写说明」只能排第 2(删掉也不影响导入)。
|
||||
// R73:测试人员反馈「不知道导入模板长什么样」⇒ 让模板自解释,不必依赖外部文档。
|
||||
const notes = [
|
||||
['客观题导入模板 — 填写说明'],
|
||||
[],
|
||||
['列名', '是否必填', '说明'],
|
||||
['题型', '必填', '只能填「选择题」或「判断题」(英文 choice / judge 也能识别)'],
|
||||
['题目', '必填', '题干内容'],
|
||||
['选项A', '选填', '选项内容。选择题至少需 2 个非空选项'],
|
||||
['选项B', '选填', ''],
|
||||
['选项C', '选填', ''],
|
||||
['选项D', '选填', ''],
|
||||
['正确答案', '必填', '选择题填选项字母(如 C,多选如 AC);判断题填「对」或「错」'],
|
||||
['分值', '已废弃', '客观题分值口径为「每小题固定 1 分」,本列会被忽略,无需填写'],
|
||||
[],
|
||||
['【注意事项】'],
|
||||
['· 表头必须位于第 1 行,请勿删除或改写表头文字。'],
|
||||
['· 一行一道题;判断题请把选项列(A..D)留空。'],
|
||||
['· 导入完成后请在题库列表中核对数量是否与预期一致。'],
|
||||
];
|
||||
const notesWs = XLSX.utils.aoa_to_sheet(notes);
|
||||
notesWs['!cols'] = [{ wch: 14 }, { wch: 10 }, { wch: 66 }];
|
||||
XLSX.utils.book_append_sheet(wb, notesWs, '填写说明');
|
||||
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const Service = require('egg').Service;
|
||||
const XLSX = require('xlsx');
|
||||
|
||||
/**
|
||||
* 综合大题服务 - BigQuestionService
|
||||
@@ -35,6 +36,57 @@ const MAX_ANSWER_CHARS = 2000;
|
||||
const SUB_QUESTION_COUNT = 3;
|
||||
|
||||
const ANSWER_FIELDS = ['answer_1', 'answer_2', 'answer_3'];
|
||||
|
||||
/** 大题 Excel 导入:逻辑字段 → 表头别名(R73)。
|
||||
* 归一化思路与客观题导入同源:测试人员的表头常带前后空格 / 全角空格 / BOM,
|
||||
* 或用近义词;不归一化就会出现「列名取不到 ⇒ 整表 0 导入」的老问题
|
||||
* (参照 service/admin.js importQuestionsFromExcel 的 keyAlias)。
|
||||
*/
|
||||
const BIG_Q_COLUMNS = {
|
||||
title: ['标题', '题目标题', '大题标题', 'title'],
|
||||
description: ['题干', '题目描述', 'description'],
|
||||
descriptionHtml: ['题干HTML', '题干html', '题干富文本', 'descriptionHtml', 'description_html'],
|
||||
sub1: ['小问1', '第1小问', '小问一', 'sub_prompt_1', 'subPrompt1'],
|
||||
sub2: ['小问2', '第2小问', '小问二', 'sub_prompt_2', 'subPrompt2'],
|
||||
sub3: ['小问3', '第3小问', '小问三', 'sub_prompt_3', 'subPrompt3'],
|
||||
scorePerSub: ['每小题分值', '每问分值', '小问分值', 'score_per_sub', 'scorePerSub'],
|
||||
sortOrder: ['排序', '排序值', 'sort_order', 'sortOrder'],
|
||||
status: ['状态', '启用状态', 'status'],
|
||||
};
|
||||
|
||||
/** 大题 Excel 模板的列顺序(首行表头,必须落在第 1 行) */
|
||||
const BIG_Q_HEADER = [
|
||||
'标题', '题干', '题干HTML', '小问1', '小问2', '小问3',
|
||||
'每小题分值', '排序', '状态',
|
||||
];
|
||||
|
||||
/** 归一化表头:去首尾空白 / 全角空格 / BOM,并与别名表对齐 */
|
||||
function normalizeBigQRow(raw) {
|
||||
const out = {};
|
||||
for (const k of Object.keys(raw || {})) {
|
||||
const nk = String(k).replace(/[\s ]+/g, '');
|
||||
out[nk] = raw[k];
|
||||
}
|
||||
const pick = (field) => {
|
||||
const aliases = BIG_Q_COLUMNS[field];
|
||||
for (const a of aliases) {
|
||||
const v = out[a];
|
||||
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v).trim();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
return {
|
||||
title: pick('title'),
|
||||
description: pick('description'),
|
||||
descriptionHtml: pick('descriptionHtml'),
|
||||
sub1: pick('sub1'),
|
||||
sub2: pick('sub2'),
|
||||
sub3: pick('sub3'),
|
||||
scorePerSub: pick('scorePerSub'),
|
||||
sortOrder: pick('sortOrder'),
|
||||
status: pick('status'),
|
||||
};
|
||||
}
|
||||
const SCORE_FIELDS = ['score_1', 'score_2', 'score_3'];
|
||||
|
||||
/** 组别中文名(导出文档抬头 / 文件名用) */
|
||||
@@ -405,6 +457,173 @@ class BigQuestionService extends Service {
|
||||
* @param {string} group
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
/**
|
||||
* 生成综合大题 Excel 导入模板(R73)。
|
||||
*
|
||||
* sheet 顺序是**契约**:`综合大题`(数据)必须在第 1 个 —— 导入侧按
|
||||
* `SheetNames[0]` 解析(与客观题 importQuestionsFromExcel 同口径);
|
||||
* `填写说明` 放第 2 个,测试人员下载后点底部标签即可查看,删掉也不影响导入。
|
||||
*
|
||||
* @returns {Promise<Buffer>} xlsx 二进制
|
||||
*/
|
||||
async buildTemplate() {
|
||||
const example = [
|
||||
'卫星导航系统组成与定位原理',
|
||||
'简述北斗三号全球卫星导航系统的组成,并说明其基本定位原理。',
|
||||
'',
|
||||
'北斗三号由哪三大部分组成?各部分的主要功能是什么?',
|
||||
'说明单点定位与差分定位的基本原理差异。',
|
||||
'分析卫星信号传播延时对定位精度的影响。',
|
||||
10,
|
||||
0,
|
||||
0,
|
||||
];
|
||||
const ws = XLSX.utils.aoa_to_sheet([BIG_Q_HEADER, example]);
|
||||
ws['!cols'] = [
|
||||
{ wch: 28 }, { wch: 46 }, { wch: 40 }, { wch: 34 }, { wch: 34 },
|
||||
{ wch: 34 }, { wch: 12 }, { wch: 8 }, { wch: 8 },
|
||||
];
|
||||
|
||||
const notes = [
|
||||
['综合大题导入模板 — 填写说明'],
|
||||
[],
|
||||
['列名', '是否必填', '说明'],
|
||||
['标题', '必填', '大题标题,不超过 200 字。同一组内标题不可重复(重复的行会被跳过并报错)'],
|
||||
['题干', '选填', '纯文本题干。若题目需要表格/加粗等富文本,请把 HTML 填进右侧「题干HTML」列;两列都填时以 HTML 为准'],
|
||||
['题干HTML', '选填', '富文本 HTML,可用 Excel 单元格编辑。例:<p>系统由 <b>3</b> 部分组成</p>。留空则使用「题干」纯文本'],
|
||||
['小问1', '必填', '第 1 小问题干'],
|
||||
['小问2', '必填', '第 2 小问题干'],
|
||||
['小问3', '必填', '第 3 小问题干'],
|
||||
['每小题分值', '选填', '每道小问的分值,默认 10。大题总分 = 每小题分值 × 3'],
|
||||
['排序', '选填', '数字,缺省 0'],
|
||||
['状态', '选填', '1 = 启用(参与随机抽题);0 = 停用'],
|
||||
[],
|
||||
['【组别说明】'],
|
||||
['本文件不需要填写组别。导入时以「管理端当前选择的组别」为准,表格里的组别一律忽略。'],
|
||||
'',
|
||||
['【★ 关于「状态」列的强烈建议】'],
|
||||
['建议全部填 0(停用)。导入后题目会进入题库但不参与抽题;'],
|
||||
['待管理员人工校对无误后,再在管理端逐条启用。'],
|
||||
['原因:考生抽到题目后题目会被快照固化,事后再改题库不会影响已抽到的考生。'],
|
||||
[],
|
||||
['【常见被拒原因(导入时会逐行报错)】'],
|
||||
['1. 标题为空,或超过 200 字'],
|
||||
['2. 同一组内标题重复 → 跳过并报错'],
|
||||
['3. 三个小问未填全'],
|
||||
['4. 每小题分值不是大于 0 的数字'],
|
||||
['5. 题干或小问中出现「参考答案 / 标准答案 / 评分标准 / 评分细则 / 答案要点」等字样 → 判定为泄题并拒绝'],
|
||||
['6. 题干中直接给出可照抄的成品答案(如带具体参数的指令示例)→ 判定为泄题并拒绝'],
|
||||
[' 说明:题干提供「指令格式定义」是允许的,给出「能照抄的成品」才构成泄题。'],
|
||||
];
|
||||
const notesWs = XLSX.utils.aoa_to_sheet(notes);
|
||||
notesWs['!cols'] = [{ wch: 16 }, { wch: 10 }, { wch: 92 }];
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, '综合大题');
|
||||
XLSX.utils.book_append_sheet(wb, notesWs, '填写说明');
|
||||
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 综合大题 Excel 批量导入(R73)。
|
||||
*
|
||||
* 设计要点:
|
||||
* 1. **逐行调用 createQuestion** —— 标题长度、三小问、分值、**泄题检测**
|
||||
* (detectAnswerLeak)全部自动继承,与管理端手工录入**同一入口、同一校验**。
|
||||
* 批量导入绝不成为绕过防呆的后门(唯一守点就在录入入口)。
|
||||
* 2. **组别以入参 group 为准**,忽略表格里的组别列,避免误导到别的组。
|
||||
* 3. **判重 = 同组内标题**(同组标题重复即视为同一道题),跳过并报错。
|
||||
* 4. 状态缺省 **0(停用)**:批量录入的题未经人工校对,不直接进抽题池。
|
||||
*
|
||||
* @param {string} filePath - xlsx 路径
|
||||
* @param {Object} options - { group }
|
||||
* @returns {Promise<Object>} { ok, imported, skipped, errors: [{ row, message }] }
|
||||
*/
|
||||
async importFromExcel(filePath, options = {}) {
|
||||
const { ctx } = this;
|
||||
const group = options.group;
|
||||
if (!BIG_QUESTION_GROUPS.includes(group)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'GROUP_NOT_SUPPORTED',
|
||||
message: '综合大题仅支持 ' + BIG_QUESTION_GROUPS.join(' / ') + ' 组,当前:' + group,
|
||||
};
|
||||
}
|
||||
|
||||
let rows;
|
||||
try {
|
||||
const wb = XLSX.readFile(filePath);
|
||||
const sheet = wb.Sheets[wb.SheetNames[0]];
|
||||
rows = XLSX.utils.sheet_to_json(sheet);
|
||||
} catch (e) {
|
||||
return { ok: false, code: 'PARSE_FAILED', message: 'Excel 解析失败:' + e.message };
|
||||
}
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return { ok: false, code: 'EMPTY_FILE', message: 'Excel 内容为空(请确认上传的是「综合大题」sheet,且首行是表头)' };
|
||||
}
|
||||
|
||||
const result = { imported: 0, skipped: 0, errors: [] };
|
||||
|
||||
// 判重集合:库中已有标题 + 本次已导入标题(防同一文件内重复)
|
||||
const existing = await ctx.model.BigQuestion.findAll({
|
||||
where: { group },
|
||||
attributes: ['title'],
|
||||
raw: true,
|
||||
});
|
||||
const titleSet = new Set(existing.map(r => String(r.title || '').trim()));
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = normalizeBigQRow(rows[i]);
|
||||
const rowIndex = i + 2; // Excel 行号(首行是表头)
|
||||
|
||||
if (!row.title) {
|
||||
// 整行为空 ⇒ 静默跳过(模板示例后的空行常见),不计入错误
|
||||
const raw = rows[i];
|
||||
const hasAny = Object.keys(raw || {}).some(k => String(raw[k] || '').trim() !== '');
|
||||
if (!hasAny) continue;
|
||||
result.skipped++;
|
||||
result.errors.push({ row: rowIndex, message: '标题为空' });
|
||||
continue;
|
||||
}
|
||||
if (titleSet.has(row.title)) {
|
||||
result.skipped++;
|
||||
result.errors.push({ row: rowIndex, message: `标题「${row.title}」在本组已存在,跳过(避免重复题)` });
|
||||
continue;
|
||||
}
|
||||
if (!row.sub1 || !row.sub2 || !row.sub3) {
|
||||
result.skipped++;
|
||||
result.errors.push({ row: rowIndex, message: '三个小问必须全部填写' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 题干:HTML 列优先;未填时用纯文本包 <p>
|
||||
const html = row.descriptionHtml;
|
||||
const description = html || (row.description ? '<p>' + row.description + '</p>' : '');
|
||||
|
||||
const payload = {
|
||||
title: row.title,
|
||||
description,
|
||||
sub_prompt_1: row.sub1,
|
||||
sub_prompt_2: row.sub2,
|
||||
sub_prompt_3: row.sub3,
|
||||
score_per_sub: row.scorePerSub || 10,
|
||||
sort_order: row.sortOrder || 0,
|
||||
status: row.status === '' ? 0 : Number(row.status),
|
||||
};
|
||||
|
||||
const created = await this.createQuestion(group, payload);
|
||||
if (!created.ok) {
|
||||
result.skipped++;
|
||||
result.errors.push({ row: rowIndex, message: `${row.title}:${created.message}` });
|
||||
continue;
|
||||
}
|
||||
titleSet.add(row.title);
|
||||
result.imported++;
|
||||
}
|
||||
|
||||
return Object.assign({ ok: true }, result);
|
||||
}
|
||||
|
||||
async listQuestions(group) {
|
||||
const { ctx } = this;
|
||||
const rows = await ctx.model.BigQuestion.findAll({
|
||||
|
||||
Reference in New Issue
Block a user