Compare commits

...

3 Commits

Author SHA1 Message Date
a471487092 fix(admin): 修复选手管理「激活状态」「注册日期」筛选被静默丢弃
现象(人工测试反馈):选手管理页「激活状态」下拉选择后列表无变化。

根因 —— 两层都在丢参且零报错:
  - controller/admin.js 只解构 group/keyword/page/pageSize,
    前端已发出的 activated 从未透传给 service
  - service/admin.js 即使收到也未解构 activated,where 中根本没有该条件
  - 同族缺陷:前端还发了 startDate/endDate(注册日期范围),
    controller 同样未转发 => 该筛选同样失效(此前无人报告)

修复:
  - controller:将 activated/startDate/endDate 整体透传给 service
  - service:解构 activated,并对 BOOLEAN 列做白名单归一化
    前端 el-select 传的是字符串 'true'/'false',直接入 where 会被
    MySQL 隐式转为 0,导致「已激活」错查出「未激活」(比不筛选更难察觉);
    白名单之外的值(含 undefined/'')一律视为未指定忽略,
    避免畸形入参被当作 false 而静默缩窄结果集

验证(scripts/verify_accounts_filter.py,9/9 PASS,真实 HTTP + 独立 DB 真值):
  A1 无筛选 total=26 == DB 26
  A2 activated=true  -> 10 == DB 已激活数
  A3 activated=false -> 16
  A4 分区性 10 + 16 == 26(关键判别器)
  D1 日期限定 DB 实际存在那天 -> 20 == DB 当日
  D2 负向对照(范围早于最早记录)-> 0
  变异自证:拆掉 where.activated 分支重跑 -> A2/A2b/A3/A3b/A4 全红 EXIT=1,
  失败信息精确复现原现象(total=26 != 10);还原后 diff 空、md5 一致、无标记残留
  同族扫描:全后端 ctx.query 仅 11 处,逐点核对 => 仅 getAccounts 一对丢参

部署(生产 back-end):
  线上 md5 与本地逐文件一致(controller ad0e60f8 / service 93eb3dbb)
  MainPID 3341710 -> 3343212;8023 监听正常;err 级日志 -- No entries --
  路由探针:health 200 / admin-login 错误口令 HTTP200+code4004 / 不存在路由 404 /
            未授权 accounts 401
2026-10-08 11:22:18 +08:00
15c9ab6f91 fix(big-question): 小问改为与后台一致的「选填」,模板说明补充常见疑问
去掉 Excel 导入里「三个小问必须全部填写」的硬拦截:后台手工录入的弹窗标签写明
「可留空」,且 validateQuestionPayload 对小问无非空校验 ⇒ 导入比手工更严会让测试人员
困惑「手工能录、Excel 被拒」。现行为与后台一致,「建议三问写全」改为在模板说明中提示。

模板「填写说明」新增【常见疑问】5 问(29 行 → 46 行),直接回答:排序/状态填 0
是否有误、为何建议状态 0、导入后是否会一直不参与考试、题干与题干HTML 填哪个、小问能否留空。
「排序」补上「数值越小越靠前」(此前说明缺失,与后台弹窗标签不一致);
「状态」补上「1=启用(参与随机抽题)· 0=停用」的完整语义与启用操作路径。
2026-10-08 10:23:49 +08:00
154338e5dd 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 之前。
2026-10-08 10:08:41 +08:00
4 changed files with 361 additions and 4 deletions

View File

@@ -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;

View File

@@ -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);

View File

@@ -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' });
}

View File

@@ -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({