gwy-exam/docs/checks/task-07.md

94 lines
9.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 任务 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 的合法 JSONBlob 下载逻辑已就绪,接口数据经 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 请求不再误抛 400server.ts `addContentTypeParser` 修复)。
- ✅ 页面(列表筛选/导入报告/导出/删除确认)双端渲染与原型结构对齐,无横向溢出。
## 当前结论
任务 07 完成题库管理与导入导出在前后端打通并持久化。列表筛选分页、JSON 批量导入(去重 + UUID + 逐条校验报告)、导出(可再导入格式)、删除(引用校验 + 确认弹层)全部实测通过;修复了「空 body 的 JSON 请求误抛 400」的删除接口报错根因并新增删除弹层引用预检GET `/api/questions/:id/refs`,被引用题警示 + 禁用确认。类型检查vue-tsc / tsc与生产构建119 模块)全部通过。测试数据已清理,题库恢复演示基线 18 条。