Compare commits
3 Commits
d44d466a90
...
a471487092
| Author | SHA1 | Date | |
|---|---|---|---|
| a471487092 | |||
| 15c9ab6f91 | |||
| 154338e5dd |
@@ -111,8 +111,14 @@ class AdminController extends Controller {
|
||||
if (!this._ensureAdmin()) return;
|
||||
|
||||
try {
|
||||
const { group, keyword, page = 1, pageSize = 20 } = ctx.query;
|
||||
const result = await service.admin.getAccounts({ group, keyword, page, pageSize });
|
||||
// ⚠️ 筛选条件必须**整体透传**给 service。此处曾只解构 group/keyword/page/pageSize,
|
||||
// 导致前端已发出的 activated(激活状态)与 startDate/endDate(注册日期范围)
|
||||
// 被静默丢弃 —— 选手管理页这两个筛选条件完全失效,且不报任何错
|
||||
// (2026-10-08 人工测试反馈「激活状态搜索条件无效」)。
|
||||
const { group, keyword, activated, startDate, endDate, page = 1, pageSize = 20 } = ctx.query;
|
||||
const result = await service.admin.getAccounts({
|
||||
group, keyword, activated, startDate, endDate, page, pageSize,
|
||||
});
|
||||
ctx.body = ctx.helper.success(result, '获取账号列表成功');
|
||||
} catch (err) {
|
||||
ctx.logger.error('获取账号列表失败:', err);
|
||||
@@ -1783,6 +1789,83 @@ class AdminController extends Controller {
|
||||
* ⚠️ 路由必须注册在 `/big-questions/:group/:id`(PUT/DELETE)之前或不同方法,
|
||||
* 否则 `export` 会被当成 :id 吞掉(见 router.js 注释)。
|
||||
*/
|
||||
/**
|
||||
* 下载综合大题 Excel 导入模板(R73)
|
||||
* GET /api/admin/big-questions/:group/template
|
||||
*/
|
||||
async downloadBigQuestionTemplate() {
|
||||
const { ctx, service } = this;
|
||||
if (!this._ensureAdmin()) return;
|
||||
|
||||
try {
|
||||
const { group } = ctx.params;
|
||||
if (!this._validateGroup(group)) return;
|
||||
|
||||
const buffer = await service.bigQuestion.buildTemplate();
|
||||
ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
// ⚠️ 只调 ctx.attachment(原名):koa 自动做 RFC5987 编码,自行 encodeURIComponent 会双重编码
|
||||
ctx.attachment(`综合大题导入模板_${group}.xlsx`);
|
||||
ctx.body = buffer;
|
||||
} catch (err) {
|
||||
ctx.logger.error('下载大题导入模板失败:', err);
|
||||
ctx.body = ctx.helper.error(500, '生成模板失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 综合大题 Excel 批量导入(R73)
|
||||
* POST /api/admin/big-questions/:group/import-excel
|
||||
* multipart/form-data: file 字段为 .xlsx/.xls
|
||||
* sheet:首个 sheet 为数据表(表头在第 1 行),可选携带「填写说明」sheet
|
||||
*/
|
||||
async importBigQuestionsExcel() {
|
||||
const { ctx, service, app } = this;
|
||||
if (!this._ensureAdmin()) return;
|
||||
|
||||
const { group } = ctx.params;
|
||||
if (!this._validateGroup(group)) return;
|
||||
|
||||
let tempFilePath = null;
|
||||
try {
|
||||
const stream = await ctx.getFileStream();
|
||||
if (!stream) {
|
||||
ctx.body = ctx.helper.error(400, '未接收到文件');
|
||||
return;
|
||||
}
|
||||
const ext = path.extname(stream.filename).toLowerCase();
|
||||
if (!['.xlsx', '.xls'].includes(ext)) {
|
||||
ctx.body = ctx.helper.error(400, '仅支持 .xlsx / .xls 格式');
|
||||
return;
|
||||
}
|
||||
|
||||
const tempDir = path.join(app.baseDir, 'app/public/tmp/excel');
|
||||
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
|
||||
tempFilePath = path.join(tempDir, `big_questions_import_${Date.now()}${ext}`);
|
||||
const writeStream = fs.createWriteStream(tempFilePath);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
stream.pipe(writeStream);
|
||||
writeStream.on('finish', resolve);
|
||||
writeStream.on('error', reject);
|
||||
stream.on('error', reject);
|
||||
});
|
||||
|
||||
const result = await service.bigQuestion.importFromExcel(tempFilePath, { group });
|
||||
if (!result.ok) {
|
||||
ctx.body = ctx.helper.error(400, result.message);
|
||||
return;
|
||||
}
|
||||
ctx.body = ctx.helper.success(result, `导入完成:成功 ${result.imported} 条,跳过 ${result.skipped} 条`);
|
||||
} catch (err) {
|
||||
ctx.logger.error('综合大题 Excel 导入失败:', err);
|
||||
const msg = (err && err.message) ? err.message : '未知错误';
|
||||
ctx.body = ctx.helper.error(500, '导入失败:' + msg);
|
||||
} finally {
|
||||
// 无论成功/失败都清临时文件:题目内容属敏感数据,不留副本
|
||||
if (tempFilePath) { try { fs.unlinkSync(tempFilePath); } catch (e) { /* ignore */ } }
|
||||
}
|
||||
}
|
||||
|
||||
async exportBigQuestions() {
|
||||
const { ctx, service } = this;
|
||||
if (!this._ensureAdmin()) return;
|
||||
|
||||
@@ -151,6 +151,10 @@ module.exports = app => {
|
||||
// `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);
|
||||
// ⚠️ 注册顺序是硬约束:Egg 按注册序匹配,下面两条含**具体段**(template / import-excel),
|
||||
// 必须排在通配段 `/:id` 之前,否则会被它抢走(GET .../template → id='template')。
|
||||
router.get('/api/admin/big-questions/:group/template', adminAuth, controller.admin.downloadBigQuestionTemplate);
|
||||
router.post('/api/admin/big-questions/:group/import-excel', adminAuth, controller.admin.importBigQuestionsExcel);
|
||||
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.get('/api/admin/big-answers/:group', adminAuth, controller.admin.getBigAnswers);
|
||||
|
||||
@@ -109,12 +109,12 @@ class AdminService extends Service {
|
||||
|
||||
/**
|
||||
* 获取分页账号列表
|
||||
* @param {Object} options - 查询选项 { group, keyword, page, pageSize, startDate, endDate }
|
||||
* @param {Object} options - 查询选项 { group, keyword, activated, page, pageSize, startDate, endDate }
|
||||
* @returns {Promise<Object>} { list, total, page, pageSize }
|
||||
*/
|
||||
async getAccounts(options = {}) {
|
||||
const { ctx } = this;
|
||||
const { group, keyword, startDate, endDate } = options;
|
||||
const { group, keyword, activated, startDate, endDate } = options;
|
||||
const page = parseInt(options.page, 10) || 1;
|
||||
const pageSize = parseInt(options.pageSize, 10) || 20;
|
||||
const offset = (page - 1) * pageSize;
|
||||
@@ -124,6 +124,18 @@ class AdminService extends Service {
|
||||
if (group) {
|
||||
where.group = group;
|
||||
}
|
||||
// 激活状态筛选(2026-10-08 修复:此前 controller 未透传、本函数也未解构该参数,
|
||||
// ⇒ 选手管理页「激活状态」下拉完全失效且不报错)。
|
||||
// ⚠️ 前端 el-select 传的是字符串 'true'/'false',**不能直接塞进 where**:
|
||||
// activated 是 BOOLEAN(TINYINT(1)) 列,MySQL 会把 'true' 隐式转成 0,
|
||||
// 结果「已激活」错查出「未激活」。必须显式归一化为布尔。
|
||||
// 白名单之外的值(含 undefined/'' 等)一律视为「未指定」忽略,
|
||||
// 避免畸形入参被当成 false 而静默缩窄结果集。
|
||||
if ([true, 1, '1', 'true'].includes(activated)) {
|
||||
where.activated = true;
|
||||
} else if ([false, 0, '0', 'false'].includes(activated)) {
|
||||
where.activated = false;
|
||||
}
|
||||
if (keyword) {
|
||||
where[ctx.model.Sequelize.Op.or] = [
|
||||
{ name: { [ctx.model.Sequelize.Op.like]: `%${keyword}%` } },
|
||||
@@ -1638,6 +1650,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,188 @@ 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、2、3'],
|
||||
['状态', '选填', '★ **1 = 启用(参与考生随机抽题)· 0 = 停用(留在题库但不参与抽题)**。本模板建议填 0,理由见下方【常见疑问】'],
|
||||
[],
|
||||
['【组别说明】'],
|
||||
['本文件不需要填写组别。导入时以「管理端当前选择的组别」为准,表格里的组别一律忽略。'],
|
||||
'',
|
||||
['【★ 关于「状态」列的强烈建议】'],
|
||||
['建议全部填 0(停用)。导入后题目会进入题库但不参与抽题;'],
|
||||
['待管理员人工校对无误后,再在管理端逐条启用。'],
|
||||
['原因:考生抽到题目后题目会被快照固化,事后再改题库不会影响已抽到的考生。'],
|
||||
[],
|
||||
['【常见被拒原因(导入时会逐行报错)】'],
|
||||
['1. 标题为空,或超过 200 字'],
|
||||
['2. 同一组内标题重复 → 跳过并报错'],
|
||||
['3. 每小题分值不是大于 0 的数字'],
|
||||
['4. 题干或小问中出现「参考答案 / 标准答案 / 评分标准 / 评分细则 / 答案要点」等字样 → 判定为泄题并拒绝'],
|
||||
['5. 题干中直接给出可照抄的成品答案(如带具体参数的指令示例)→ 判定为泄题并拒绝'],
|
||||
[' 说明:题干提供「指令格式定义」是允许的,给出「能照抄的成品」才构成泄题。'],
|
||||
[],
|
||||
['【常见疑问】'],
|
||||
['Q1:「排序」和「状态」都填 0,是填错了吗?'],
|
||||
['A:不是填错。排序 0 = 默认显示顺序(数值越小越靠前,可按需改成 1、2、3);'],
|
||||
[' 状态 0 = 停用,即题目已入库但暂不参与抽题。'],
|
||||
['Q2:为什么建议「状态」填 0 而不是 1?'],
|
||||
['A:这是有意设计的保险。考生抽到题目时,题目会被「快照固化」,事后再怎么改题库,'],
|
||||
[' 也影响不到已经抽到该题的考生。所以未经人工校对的题不应该直接进考卷 ——'],
|
||||
[' 先以停用状态入库,校对无误后再启用。'],
|
||||
['Q3:导入后题目会不会一直不参与考试?'],
|
||||
['A:不会。到管理端「题库管理 → 综合大题」列表点「编辑」,'],
|
||||
[' 把「状态」开关拨到「启用」并保存,该题即刻参与抽题。'],
|
||||
['Q4:「题干」和「题干HTML」填哪个?'],
|
||||
['A:普通文字填「题干」即可;需要表格、加粗等排版时填「题干HTML」(写 HTML 代码,'],
|
||||
[' 建议写成一行避免换行被拆断)。两列都填时以「题干HTML」为准。'],
|
||||
[' 例:<p>北斗三号由 <b>空间段</b>、<b>地面段</b>、<b>用户段</b> 三部分组成。</p>'],
|
||||
['Q5:小问可以留空吗?'],
|
||||
['A:可以,与后台手工录入一致。但建议三问都写全,否则该小问考生作答区没有提示文字。'],
|
||||
];
|
||||
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;
|
||||
}
|
||||
// 小问允许留空:与后台手工录入保持一致(弹窗标签写明「可留空」,且
|
||||
// validateQuestionPayload 对小问无非空校验),故 Excel 导入也不在此处拦截。
|
||||
// 说明 sheet 中仍提示「建议三问都写全」,由录入人自行把握。
|
||||
|
||||
// 题干: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