feat: 增加题库管理页面
This commit is contained in:
parent
2d428f1be0
commit
9ef7af0dd1
@ -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
93
docs/checks/task-07.md
Normal 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 的合法 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 条。
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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:
|
||||
* - 后台生成 UUID(id 由 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 }
|
||||
}
|
||||
|
||||
@ -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 讲解
|
||||
{
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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, {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user