9.4 KiB
9.4 KiB
任务 07 检查记录:题库管理与导入导出
日期:2026-09-01
交付内容
后端(Handler + Schema + Routes)
server/src/handlers/questions.ts- 新增
contentFingerprint():按「题干 + 模块 + 答案 + 选项文本(排序)」拼接(\u0000分隔)生成内容指纹,用于导入去重。 list:按module/difficulty/keyword筛选 +page/pageSize分页,返回{ items, total, page, pageSize }。importQuestions:读现有题库建立seen指纹集合 → 逐条校验(任何一条格式错误不影响其它有效题导入)→ 后台生成 UUID(newId('q'))→createdAt取当前时间 →updateData原子写入 → 返回{ total, success, skipped, failed, errors }导入结果报告。exportQuestions:导出{ version: '1.0', exportedAt, questions },剔除id/createdAt,保证「导出的数据可再次导入」。countReferences(id):共享的引用统计助手(错题/作答记录/刷题会话),供refs预检与remove校验共用。refs(新增):GET /api/questions/:id/refs返回该题引用数量,供前端删除弹层预检(题目不存在抛 404)。remove:读questions→ 不存在抛 404 →countReferences查引用 → 被引用抛ApiError.conflict(409)→ 无引用则过滤移除。
- 新增
server/src/errors.ts:新增static conflict(message, details?)→new ApiError(409, 'CONFLICT', ...)。server/src/server.ts:修复删除接口 400 报错根因。默认 JSON 解析器遇到「Content-Type: application/json 但空 body」会抛 400「请求参数不合法」(前端openapi-fetch给所有请求带该头,含无 body 的 DELETE)。通过addContentTypeParser('application/json', { parseAs: 'string' }, ...)把空 body 视为undefined,非空才JSON.parse。server/src/routes.ts:5 条路由GET /api/questions/list、POST /api/questions/import、GET /api/questions/export、DELETE /api/questions/:id、GET /api/questions/:id/refs(tags: 题库管理)。
前端(View + API)
client/src/api/index.ts:新增类型QuestionListItem/QuestionListPage/QuestionImportBody/QuestionExport/QuestionRefs(从generated/schema推导);questionsApi新增refs(id)。client/src/views/questions/QuestionsView.vue(重写,原为 ComingSoon 占位):「列表 / 手动录入」双模式,对照原型「桌面-题库录入.png」还原。- 手动录入模式(核心,对照原型):左表单 + 右实时题目预览。
- 题型 tab:单选题 / 多选题 / 判断题(需求 4.1 仅支持单选题,多选/判断点击保留单选并 toast 提示)。
- 模块 / 考点下拉 + 输入、难度下拉、题干 textarea、A-D 选项行(点击序号标记正确答案)、答案解析。
- 右侧「题目预览」实时联动:模块 / 难度徽章、题干、选项(正确项绿色 + ✓ 正确答案)、答案解析块。
- 「保存题目并继续录入」复用
importQuestions(单条)保存 → toast + 表单自动清空。 - 页头「返回列表」。
- 列表模式:页头「导入真题」(新增题目)+「新增题目」;筛选栏(模块/难度/关键词防抖/重置 + 计数 + 导出);桌面表格、移动卡片、分页、删除确认弹层。
mode开关控制列表 / 录入两视图。- 复用 AppPageHeader / AppCard / AppButton / AppIcon / AppBadge / AppModal / AppLoading / AppError / AppEmpty。
- 手动录入模式(核心,对照原型):左表单 + 右实时题目预览。
与原型图对照
原型「桌面-题库录入.png」仅一张,主题是手动录入 + 实时预览,此前误做成纯列表页已修正。现页面在保留原有列表/筛选/导入/导出/删除基础上,新增手动录入模式,左表单 + 右预览与原型结构、字段(题型/模块考点/题干/选项/正确答案/解析)、「保存题目并继续录入」按钮一致。
接口实测(curl,演示数据 18 条)
| 接口 | 结果 |
|---|---|
GET /api/questions/list?page=1&pageSize=3 |
total 18,返回 3 条演示题 |
GET /api/questions/list?module=数量关系 |
total 4,module 全为数量关系 |
GET /api/questions/list?difficulty=困难 |
total 4,difficulty 全为困难 |
GET /api/questions/list?keyword=相遇 |
total 1 |
POST /api/questions/import(新题 1 条) |
{total:1, success:1, skipped:0, failed:0},total 18→19 |
POST /api/questions/import(重复内容再导) |
{total:1, success:0, skipped:1, failed:0},total 保持 19(指纹去重) |
GET /api/questions/export |
version 1.0、19 条,字段 type/module/subModule/difficulty/stem/options/answer/analysis/tags/source,无 id/createdAt(可再次导入) |
GET /api/questions/:id/refs(demo-q-001) |
{wrongQuestions:0, practiceRecords:4, practiceSessions:0, total:4},供删除弹层预检 |
GET /api/questions/:id/refs(未引用新题) |
{wrongQuestions:0, practiceRecords:0, practiceSessions:0, total:0} |
GET /api/questions/:id/refs(不存在的 id) |
404 NOT_FOUND:「题目不存在」 |
DELETE /api/questions/:id(带 Content-Type: application/json 头 + 空 body) |
修复前 400「请求参数不合法」→ 修复后 200 {success:true}(server.ts addContentTypeParser 把空 body 视作 undefined) |
DELETE /api/questions/:id(未引用新题) |
{success:true},total 19→18 |
DELETE /api/questions/demo-q-001(被作答记录引用 4 条) |
409 CONFLICT:「题目已被作答记录 4 条引用,无法删除」,total 不变 |
浏览器检查(agent-browser)
| 场景 | 结果 |
|---|---|
| 桌面 1440 题库管理 | 页头按钮 + 筛选栏 + 表格 + 分页完整渲染,与原型结构对齐;共 18 道题目 |
| 移动 390 题库管理 | 卡片布局渲染正常;scrollWidth 380 ≤ 390 无横向溢出 |
| JSON 导入弹层 | 打开 → 粘贴 JSON → 开始导入 → 展示导入结果报告(成功 1 / 跳过 0 / 失败 0 + ✅ 全部导入成功) |
| 重复导入去重实测 | 再导相同 JSON 显示「成功 0 · 跳过 1 · 失败 0」,列表 total 不变 |
| 导出 | exportQuestions 返回无 id/createdAt 的合法 JSON(Blob 下载逻辑已就绪,接口数据经 curl 核对) |
| 删除确认弹层 | 点击删除 → 弹层先展示「正在检查引用…」spinner,随后题干预览 + 引用提醒文案正确 |
| 删除(被引用题) | 打开弹层即预检:显示黄色警示块「该题已被引用,将无法删除 / 作答记录 4 条」+ 引用明细,确认按钮 disabled=true |
| 删除(未引用题) | 弹层显示「该题暂无关联引用,删除后不可恢复」,确认按钮可点,点击后 total 回落到演示基线 18 |
| 关键词搜索 | 输入「相遇」→ 共 1 道题目,刷新防抖生效 |
| 手动录入 → 新增题目 | 左表单 + 右「题目预览」实时联动(模块/难度徽章、题干、选项、答案解析均随输入更新) |
| 手动录入 → 标记正确答案 | 点击选项 A 序号 → 预览 A 项绿色高亮 + ✓ 正确答案 |
| 手动录入 → 保存 | toast「题目已保存,可继续录入下一题」→ 表单自动清空 → 题库 total 18→19,保存题含 q- UUID / module=数量关系 / subModule=行程问题 / answer=A / 4 选项 / 完整解析 |
| 手动录入 → 返回列表 | 「返回列表」回到列表视图,共 19 道题目 |
| 移动 390 手动录入 | 表单纵向单列,预览在表单下方;scrollWidth 380 ≤ 390 无横向溢出 |
| 数据复位 | 删除手动测试题后题库恢复演示基线 total=18 |
截图:deliverables/checks/task-07-questions-{desktop,mobile}.png、task-07-entry-mode.png、task-07-entry-preview.png、task-07-entry-mobile.png、task-07-list-after-entry.png、task-07-import-modal.png、task-07-import-result.png、task-07-delete-confirm.png、task-07-delete-referenced.png、task-07-delete-unreferenced.png、task-07-search-filter.png。
完成标准核对
- ✅ 导入文件不含 ID 也能成功(后台
newId('q')生成 UUID)。 - ✅ 重复题目被跳过(内容指纹去重,实测成功 0 / 跳过 1)。
- ✅ 错误题目不影响有效题目导入(逐条 try/catch,返回 errors 报告)。
- ✅ 导出的数据可再次导入(导出剔除 id/createdAt,字段与 import body 对齐)。
- ✅ 删除前引用校验(错题/作答记录/刷题会话),被引用抛 409;未引用删除成功。
- ✅ 删除弹层引用预检(
GET /api/questions/:id/refs):被引用题展示警示并禁用确认按钮,未引用题可直接删除。 - ✅ 空 body 的 JSON 请求不再误抛 400(server.ts
addContentTypeParser修复)。 - ✅ 页面(列表筛选/导入报告/导出/删除确认)双端渲染与原型结构对齐,无横向溢出。
当前结论
任务 07 完成:题库管理与导入导出在前后端打通并持久化。列表筛选分页、JSON 批量导入(去重 + UUID + 逐条校验报告)、导出(可再导入格式)、删除(引用校验 + 确认弹层)全部实测通过;修复了「空 body 的 JSON 请求误抛 400」的删除接口报错根因,并新增删除弹层引用预检(GET /api/questions/:id/refs,被引用题警示 + 禁用确认)。类型检查(vue-tsc / tsc)与生产构建(119 模块)全部通过。测试数据已清理,题库恢复演示基线 18 条。