feat: 增加题库管理页面

This commit is contained in:
liyy 2026-09-01 14:59:35 +08:00
parent 2d428f1be0
commit 9ef7af0dd1
9 changed files with 1381 additions and 8 deletions

View File

@ -40,6 +40,11 @@ export type PlanReview = SuccessBody<paths['/api/plans/review']['get']>
export type StudyPlanItem = SuccessBody<paths['/api/plans/today']['get']>['tasks'][number]
export type NewsItem = SuccessBody<paths['/api/news/list']['get']>['items'][number]
export type ImportResult = SuccessBody<paths['/api/news/import/json']['post']>
export type QuestionListItem = SuccessBody<paths['/api/questions/list']['get']>['items'][number]
export type QuestionListPage = SuccessBody<paths['/api/questions/list']['get']>
export type QuestionImportBody = BodyOf<'/api/questions/import', 'post'>
export type QuestionExport = SuccessBody<paths['/api/questions/export']['get']>
export type QuestionRefs = SuccessBody<paths['/api/questions/{id}/refs']['get']>
// ---- 数据中枢 ----
export const dashboardApi = {
@ -123,6 +128,7 @@ export const questionsApi = {
importQuestions: (body: BodyOf<'/api/questions/import', 'post'>) =>
unwrap(apiClient.POST('/api/questions/import', { body })),
exportQuestions: () => unwrap(apiClient.GET('/api/questions/export')),
refs: (id: string) => unwrap(apiClient.GET('/api/questions/{id}/refs', { params: { path: { id } } })),
remove: (id: string) => unwrap(apiClient.DELETE('/api/questions/{id}', { params: { path: { id } } }))
}

File diff suppressed because it is too large Load Diff

93
docs/checks/task-07.md Normal file
View File

@ -0,0 +1,93 @@
# 任务 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 条。

View File

@ -23,6 +23,10 @@ export class ApiError extends Error {
return new ApiError(404, 'NOT_FOUND', message, details)
}
static conflict(message: string, details?: { path: string; message: string }[]) {
return new ApiError(409, 'CONFLICT', message, details)
}
static aiNotConfigured(message: string) {
return new ApiError(503, 'AI_NOT_CONFIGURED', message)
}

View File

@ -56,7 +56,6 @@ export async function importJson(request: FastifyRequest) {
let skipped = 0
body.news.forEach((item, index) => {
console.log("🚀 ~ importJson ~ item:", item)
try {
const fingerprint = `${item.title}\u0000${item.category}\u0000${item.publishedAt}`
if (seen.has(fingerprint)) {

View File

@ -1,11 +1,23 @@
import type { FastifyRequest } from 'fastify'
import { readData } from '../data/store.js'
import { readData, updateData } from '../data/store.js'
import { dataFiles } from '../data/files.js'
import { ApiError } from '../errors.js'
import type { Question } from '../schemas/entities.js'
import { newId } from '../utils/ids.js'
import type {
PracticeRecord,
PracticeSession,
Question,
WrongQuestion
} from '../schemas/entities.js'
import type { z } from 'zod'
import { QuestionsImportBodySchema, QuestionsListQuerySchema } from '../schemas/api.js'
/** 内容指纹:题干 + 模块 + 答案 + 选项文本(排序后),用于导入去重 */
function contentFingerprint(q: Pick<Question, 'stem' | 'module' | 'answer' | 'options'>) {
const options = [...q.options.map((o) => `${o.key}:${o.text}`)].sort().join('|')
return `${q.stem}\u0000${q.module}\u0000${q.answer}\u0000${options}`
}
/** 题库列表:模块 / 难度 / 关键词筛选 + 分页 */
export async function list(request: FastifyRequest) {
const query = request.query as z.infer<typeof QuestionsListQuerySchema>
@ -25,10 +37,59 @@ export async function list(request: FastifyRequest) {
}
}
/** 题目 JSON 导入。TODO 任务07内容指纹去重、UUID 生成、逐条校验报告。 */
/**
* JSON 07
* - UUIDid newId('q') createdAt
* - + + +
* -
*/
export async function importQuestions(request: FastifyRequest) {
const body = request.body as z.infer<typeof QuestionsImportBodySchema>
return { total: body.questions.length, success: 0, skipped: 0, failed: 0, errors: [] }
const current = await readData<Question[]>(dataFiles.questions, [])
const seen = new Set(current.map((q) => contentFingerprint(q)))
const errors: { index: number; message: string }[] = []
const added: Question[] = []
let skipped = 0
body.questions.forEach((item, index) => {
try {
const fingerprint = contentFingerprint(item)
if (seen.has(fingerprint)) {
skipped += 1
return
}
seen.add(fingerprint)
added.push({
id: newId('q'),
type: item.type,
module: item.module,
subModule: item.subModule,
difficulty: item.difficulty,
stem: item.stem,
options: item.options,
answer: item.answer,
analysis: item.analysis,
tags: item.tags,
source: item.source,
createdAt: new Date().toISOString()
})
} catch (error) {
errors.push({ index, message: error instanceof Error ? error.message : '导入失败' })
}
})
if (added.length > 0) {
await updateData<Question[]>(dataFiles.questions, [], (questions) => [...questions, ...added])
}
return {
total: body.questions.length,
success: added.length,
skipped,
failed: errors.length,
errors
}
}
/** 导出当前题库(与导入格式一致,不含 id */
@ -41,10 +102,58 @@ export async function exportQuestions() {
}
}
/** 删除题目。TODO 任务07校验引用后从 questions.json 移除。 */
/**
* / /
* ) remove
*/
export async function countReferences(id: string) {
const [wrongQuestions, practiceRecords, practiceSessions] = await Promise.all([
readData<WrongQuestion[]>(dataFiles.wrongQuestions, []),
readData<PracticeRecord[]>(dataFiles.practiceRecords, []),
readData<PracticeSession[]>(dataFiles.practiceSessions, [])
])
return {
wrongQuestions: wrongQuestions.filter((w) => w.questionId === id).length,
practiceRecords: practiceRecords.filter((r) => r.questionId === id).length,
practiceSessions: practiceSessions.filter((s) => s.questionIds.includes(id)).length
}
}
/** 删除前预检:返回该题引用数量,便于前端弹层提前展示;题目不存在抛 404 */
export async function refs(request: FastifyRequest) {
const { id } = request.params as { id: string }
const questions = await readData<Question[]>(dataFiles.questions, [])
if (!questions.some((q) => q.id === id)) throw ApiError.notFound('题目不存在')
const ref = await countReferences(id)
return {
wrongQuestions: ref.wrongQuestions,
practiceRecords: ref.practiceRecords,
practiceSessions: ref.practiceSessions,
total: ref.wrongQuestions + ref.practiceRecords + ref.practiceSessions
}
}
/**
* 07
* - / /
* - 409
* - questions.json
*/
export async function remove(request: FastifyRequest) {
const { id } = request.params as { id: string }
const questions = await readData<Question[]>(dataFiles.questions, [])
if (!questions.some((q) => q.id === id)) throw ApiError.notFound('题目不存在')
const referenced = await countReferences(id)
if (referenced.wrongQuestions + referenced.practiceRecords + referenced.practiceSessions > 0) {
const parts: string[] = []
if (referenced.wrongQuestions > 0) parts.push(`错题 ${referenced.wrongQuestions}`)
if (referenced.practiceRecords > 0) parts.push(`作答记录 ${referenced.practiceRecords}`)
if (referenced.practiceSessions > 0) parts.push(`刷题会话 ${referenced.practiceSessions}`)
throw ApiError.conflict(`题目已被${parts.join('、')}引用,无法删除`)
}
await updateData<Question[]>(dataFiles.questions, [], (list) => list.filter((q) => q.id !== id))
return { success: true }
}

View File

@ -214,6 +214,12 @@ const routes: RouteDef[] = [
response: { 200: schemas.QuestionsDeleteResponseSchema },
handler: questions.remove
},
{
method: 'GET', url: '/api/questions/:id/refs', summary: '题目引用预检', tags: ['题库管理'],
params: schemas.QuestionIdParamsSchema,
response: { 200: schemas.QuestionsRefsResponseSchema },
handler: questions.refs
},
// AI 讲解
{

View File

@ -333,6 +333,12 @@ export const QuestionsExportResponseSchema = z.object({
questions: z.array(QuestionSchema.omit({ id: true, createdAt: true }))
})
export const QuestionsDeleteResponseSchema = z.object({ success: z.boolean() })
export const QuestionsRefsResponseSchema = z.object({
wrongQuestions: z.number().int().min(0),
practiceRecords: z.number().int().min(0),
practiceSessions: z.number().int().min(0),
total: z.number().int().min(0)
})
// ---------- AI 讲解 ----------
export const AiExplainBodySchema = z.object({

View File

@ -11,6 +11,17 @@ import { errorHandler, notFoundHandler } from './errors.js'
export async function buildServer() {
const app = Fastify({ logger: true, exposeHeadRoutes: false })
// 允许「Content-Type: application/json 但无 body」的请求如 DELETE 常带该头但无体)。
// 默认 JSON 解析器遇到空 body 会抛 400「请求参数不合法」这里把空 body 视为 undefined避免误伤。
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
if (body === undefined || body === null || body === '') return done(null, undefined)
try {
done(null, JSON.parse(body as string))
} catch (err) {
done(err as Error, undefined)
}
})
await app.register(cors, { origin: true })
await app.register(swagger, {