476 lines
29 KiB
Markdown
476 lines
29 KiB
Markdown
# 考试(exams)模块审计报告
|
||
|
||
> 审计时间:2026-06-25
|
||
> 审计范围:`src/modules/exams/**`(35+ 文件)+ `src/app/(dashboard)/teacher/exams/**`(10 个页面)+ 跨模块依赖面
|
||
> 审计依据:`docs/architecture/004_architecture_impact_map.md`、`docs/architecture/005_architecture_data.json`、项目 `project_rules.md`
|
||
|
||
---
|
||
|
||
## 一、现有实现概要
|
||
|
||
### 1.1 文件分布与体量
|
||
|
||
exams 模块按职责已做较细粒度拆分,体量基本符合规范:
|
||
|
||
| 子目录/文件 | 行数(参考架构图) | 职责 |
|
||
|-------------|------|------|
|
||
| `actions.ts` | 633 | 11 个核心 Server Action(已从 1525 行拆分) |
|
||
| `actions-helpers.ts` | 96 | 跨 Action 共享纯函数(prepareExamCreateContext 等) |
|
||
| `actions-rich-editor.ts` | 250 | 富文本编辑器 Server Action(create/update) |
|
||
| `ai-pipeline/auto-mark.ts` | 356 | AI 自动标记 Server Action + 纯转换函数 |
|
||
| `ai-pipeline/{index,parse,request,structure}.ts` | — | AI 调用/解析/结构化 |
|
||
| `data-access.ts` | 542 | 考试 CRUD(已从 1036 行拆分) |
|
||
| `data-access-cross-module.ts` | 511 | 13 个跨模块查询/写接口 |
|
||
| `data-access-error-collection.ts` | — | 错题采集相关跨模块接口 |
|
||
| `stats-service.ts` | 158 | 考试分析数据聚合 |
|
||
| `types.ts` | 93 | 类型定义 |
|
||
| `utils/normalize-structure.ts` | 57 | exam.structure 运行时归一化 |
|
||
| `components/` | 24 个文件 | 表单/组卷/预览/分析/卡片/筛选/表格 |
|
||
| `editor/` | 14 个文件 | Tiptap 富文本编辑器(extensions/utils/转换) |
|
||
| `hooks/` | 4 个文件 | use-exam-preview 主组合器 + 3 个子 Hook |
|
||
|
||
**架构图覆盖情况**:004/005 已记录 exams 模块的职责、依赖、被依赖、文件清单、P0/P1 修复历史、V3 增强项。本次审计对照架构图核对,覆盖基本完整,但以下细节需补全(见第五节):
|
||
- `data-access-error-collection.ts` 未在 005 JSON 的 modules.exams.exports 中列出
|
||
- `utils/normalize-structure.ts` 已记录但未在 005 的 dependencyMatrix 中明确标注被 `[id]/build/page.tsx` 与 `[id]/edit-rich/page.tsx` 引用
|
||
|
||
### 1.2 主要数据流
|
||
|
||
- **创建**:`/teacher/exams/create` → `createExamAction` → `persistExamDraft` → `db.insert(exams)`
|
||
- **AI 创建**:`/teacher/exams/create` → `createAiExamAction` → `loadAiDraftQuestionsAndStructure` → `persistAiGeneratedExamDraft` → 通过 `questions/data-access.createQuestionWithRelations` 创建题目 → 事务写 exams + examQuestions
|
||
- **富文本创建**:`/teacher/exams/new` → `createExamFromRichEditorAction` → `editorDocToStructure` → `persistAiGeneratedExamDraft`
|
||
- **组卷**:`/teacher/exams/[id]/build` → `ExamAssembly` + `getExamById`
|
||
- **预览**:`previewAiExamAction` / `getExamPreviewAction`
|
||
- **分析**:`/teacher/exams/[id]/analytics` → `getExamAnalytics`(聚合 homework 提交数据)
|
||
|
||
### 1.3 跨模块依赖(合规项)
|
||
|
||
以下跨模块调用均通过对方 data-access,符合三层架构规则:
|
||
- `questions/data-access.createQuestionWithRelations`(P0-1 已修复)
|
||
- `classes/data-access.getClassGradeIdsByClassIds`(P0-2 已修复)
|
||
- `school/data-access.{getSubjectNameById,getGradeNameById,getSubjectOptions,getGradeOptions}`(P1-1 已修复)
|
||
- `homework/data-access.{getHomeworkAssignmentsByExamId,getGradedSubmissionsByExamId}`(V3-8 新增)
|
||
- `homework/data-access-utils.getQuestionText`
|
||
|
||
### 1.4 已修复的历史问题(架构图记录)
|
||
|
||
P0-1/P0-2/P0-4/P0-8/P1-1 等历史违规已修复,详见 004 文档第 2.2 节。
|
||
|
||
---
|
||
|
||
## 二、现存问题与原因分析
|
||
|
||
### 🔴 2.1【架构违规·P0】跨模块直接 JOIN questions 表
|
||
|
||
**位置**:[data-access-cross-module.ts](file:///e:/Desktop/CICD/src/modules/exams/data-access-cross-module.ts#L4-L5) 第 4 行 import、第 480-490 行 `getExamForGradeEntry`
|
||
|
||
**问题**:
|
||
```
|
||
第 4 行:import { exams, examQuestions, examSubmissions, submissionAnswers, questions } from "@/shared/db/schema"
|
||
第 488 行:.innerJoin(questions, eq(examQuestions.questionId, questions.id))
|
||
```
|
||
|
||
`getExamForGradeEntry` 为了获取题目 `type` 字段,直接 JOIN 了 questions 模块的核心表 `questions`。
|
||
|
||
**违反规则**:项目规则"模块间只能通过对方 data-access 通信,**禁止跨模块直接查询数据库表**"。
|
||
|
||
**原因**:成绩录入表格表头需要题目类型,但实现时未在 questions 模块暴露按 ID 批量获取类型的接口,于是直接 JOIN。
|
||
|
||
**直接后果**:questions 模块若重构表结构(如将 type 拆分到独立表),exams 模块会编译失败或运行时错误;模块封装性被破坏,违反可测试性与可替换性。
|
||
|
||
---
|
||
|
||
### 🟢 2.2【已确认合规】submissionAnswers 表归属与直查
|
||
|
||
**位置**:
|
||
- [data-access-cross-module.ts](file:///e:/Desktop/CICD/src/modules/exams/data-access-cross-module.ts#L4) 导入 `submissionAnswers`
|
||
- [data-access-cross-module.ts](file:///e:/Desktop/CICD/src/modules/exams/data-access-cross-module.ts#L212-L218) `getExamSubmissionWithAnswers` 直查 `submissionAnswers`
|
||
- [data-access-error-collection.ts](file:///e:/Desktop/CICD/src/modules/exams/data-access-error-collection.ts#L6) 导入并查询 `submissionAnswers`(第 62-69 行)
|
||
|
||
**结论**:经核对 `src/shared/db/schema.ts:575-578`,`submissionAnswers` 表的 `submissionId` 外键引用 `examSubmissions.id`,**该表属于 exams 模块自身域**(exam submissions 的答题记录)。exams 模块查询自己的表合规,`getExamSubmissionWithAnswers` 与 `getExamSubmissionDataForErrorCollection` 通过 data-access-cross-module 暴露给 diagnostic/error-book 模块调用,符合"模块间通过对方 data-access 通信"规则。
|
||
|
||
**无违规,无需修复。**
|
||
|
||
---
|
||
|
||
### 🟠 2.3【i18n 缺失·P1】11 个组件未接入 useTranslations
|
||
|
||
**位置**:
|
||
|
||
| 文件 | 硬编码样本 |
|
||
|------|-----------|
|
||
| [components/exam-card.tsx](file:///e:/Desktop/CICD/src/modules/exams/components/exam-card.tsx#L78-L91) | "Lvl"、"min"、"pts"、"Questions" |
|
||
| [components/exam-filters.tsx](file:///e:/Desktop/CICD/src/modules/exams/components/exam-filters.tsx#L33-L59) | "Search exams..."、"Status"、"Any Status"、"Draft"、"Published"、"Archived"、"Difficulty"、"Easy (1)" 等 |
|
||
| [components/exam-preview-dialog.tsx](file:///e:/Desktop/CICD/src/modules/exams/components/exam-preview-dialog.tsx#L89-L199) | "Section"、"未命名题目"、"未命名子题"、"Exam Preview"、"Generating preview..."、"完整试卷预览"、"题 · 科目 · 年级 · 分钟 · 总分"、"No preview available"、"Confirm & Create" |
|
||
| [components/exam-viewer.tsx](file:///e:/Desktop/CICD/src/modules/exams/components/exam-viewer.tsx#L95-L197) | "Section"、"Group"、"Score:"、"No questions available." |
|
||
| [components/question-options-editor.tsx](file:///e:/Desktop/CICD/src/modules/exams/components/question-options-editor.tsx) | 选项编辑器中文硬编码 |
|
||
| [editor/extensions/blank-node.tsx](file:///e:/Desktop/CICD/src/modules/exams/editor/extensions/blank-node.tsx) | aria-label="填空" |
|
||
| [editor/extensions/group-block.tsx](file:///e:/Desktop/CICD/src/modules/exams/editor/extensions/group-block.tsx) | placeholder 与统计文案硬编码 |
|
||
| [editor/extensions/question-block.tsx](file:///e:/Desktop/CICD/src/modules/exams/editor/extensions/question-block.tsx) | 题型 `<option>` 与 "分" 硬编码 |
|
||
| [editor/extensions/section-block.tsx](file:///e:/Desktop/CICD/src/modules/exams/editor/extensions/section-block.tsx) | "层级/卷/部分/分卷" 硬编码 |
|
||
|
||
**违反规则**:项目规则"所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键";硬约束"All user-visible text must be i18n-adapted using next-intl with translation keys extracted"。
|
||
|
||
**原因**:富文本编辑器 extensions 与早期组件(exam-card/exam-filters/exam-preview-dialog)在 i18n 改造前已存在,后续 i18n 改造未覆盖到。
|
||
|
||
**直接后果**:
|
||
- 多语言环境(en)下用户看到中英混杂文本,体验严重劣化
|
||
- 无法通过翻译文件统一管理文案,难以维护
|
||
- exam-card 在 all 列表页是高频可见组件,影响首屏专业度
|
||
|
||
---
|
||
|
||
### 🟠 2.4【i18n 缺失·P1】Server Action 返回消息绕过 i18n
|
||
|
||
**位置**:
|
||
|
||
| 文件:行号 | 硬编码消息 |
|
||
|-----------|-----------|
|
||
| [actions-rich-editor.ts:41](file:///e:/Desktop/CICD/src/modules/exams/actions-rich-editor.ts#L41) | "标题不能为空" |
|
||
| [actions-rich-editor.ts:49,55](file:///e:/Desktop/CICD/src/modules/exams/actions-rich-editor.ts#L49) | "试卷内容不能为空" |
|
||
| [actions-rich-editor.ts:123,202](file:///e:/Desktop/CICD/src/modules/exams/actions-rich-editor.ts#L123) | "试卷内容格式无效"(safeJsonParse 兜底参数) |
|
||
| [actions-rich-editor.ts:125,204](file:///e:/Desktop/CICD/src/modules/exams/actions-rich-editor.ts#L125) | "试卷内容解析失败" |
|
||
| [actions-rich-editor.ts:169](file:///e:/Desktop/CICD/src/modules/exams/actions-rich-editor.ts#L169) | "试卷草稿已创建" |
|
||
| [actions-rich-editor.ts:211](file:///e:/Desktop/CICD/src/modules/exams/actions-rich-editor.ts#L211) | "只能更新自己创建的试卷" |
|
||
| [actions-rich-editor.ts:278](file:///e:/Desktop/CICD/src/modules/exams/actions-rich-editor.ts#L278) | "试卷已更新" |
|
||
| [actions.ts:161,250,465](file:///e:/Desktop/CICD/src/modules/exams/actions.ts#L161) | "题目数据格式无效"(safeJsonParse 兜底) |
|
||
| [actions.ts:466](file:///e:/Desktop/CICD/src/modules/exams/actions.ts#L466) | "试卷结构数据格式无效" |
|
||
| [ai-pipeline/auto-mark.ts:30](file:///e:/Desktop/CICD/src/modules/exams/ai-pipeline/auto-mark.ts#L30) | "试卷文本不能为空"(schema message) |
|
||
| [ai-pipeline/auto-mark.ts:386](file:///e:/Desktop/CICD/src/modules/exams/ai-pipeline/auto-mark.ts#L386) | "AI 自动标记完成" |
|
||
| [stats-service.ts:136](file:///e:/Desktop/CICD/src/modules/exams/stats-service.ts#L136) | "(无题目文本)" |
|
||
| [actions-helpers.ts:65](file:///e:/Desktop/CICD/src/modules/exams/actions-helpers.ts#L65) | "Invalid form data" |
|
||
|
||
**违反规则**:同 2.3。`actions.ts` 主体已使用 `getTranslations("examHomework.exam.actionMessages")`,但 `actions-rich-editor.ts` 与 `ai-pipeline/auto-mark.ts` 完全未接入,存在 i18n 一致性破口。
|
||
|
||
**原因**:这两个文件是从 actions.ts 拆分出来的新文件,拆分时未同步迁移 i18n 模式。
|
||
|
||
**直接后果**:富文本编辑器与 AI 自动标记的错误/成功提示在非中文环境下显示中文,破坏产品一致性。
|
||
|
||
---
|
||
|
||
### 🟠 2.5【路由边界缺失·P1】部分路由缺 loading.tsx / error.tsx
|
||
|
||
**位置**:[src/app/(dashboard)/teacher/exams/](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/)
|
||
|
||
| 路由 | loading.tsx | error.tsx |
|
||
|------|------------|----------|
|
||
| `all/` | ✅ | ❌ |
|
||
| `create/` | ✅ | ❌ |
|
||
| `new/` | ❌ | ❌ |
|
||
| `[id]/build/` | ✅ | ✅ |
|
||
| `[id]/edit-rich/` | ❌ | ❌ |
|
||
| `[id]/analytics/` | ❌ | ❌ |
|
||
| `[id]/proctoring/` | ✅ | ✅ |
|
||
|
||
**违反规则**:硬约束"All student routes must include loading.tsx and error.tsx for error boundaries"(项目内存中虽针对 student 路由,但企业级规范同样适用于 teacher 路由);规则"每个独立的数据区块必须用 React Error Boundary 包裹"、"异步数据使用 React Suspense + 骨架屏"。
|
||
|
||
**原因**:路由按需添加 loading/error,未系统化覆盖。
|
||
|
||
**直接后果**:
|
||
- 编辑器页面(edit-rich)加载 Tiptap 较慢,无骨架屏会白屏
|
||
- 分析页(analytics)聚合查询慢,无 loading 体验差
|
||
- 任一页面抛错会冒泡到顶层 dashboard error boundary,无法精确定位
|
||
|
||
---
|
||
|
||
### 🟡 2.6【类型安全·P2】10 处 `as` 类型断言(非 unknown 收窄)
|
||
|
||
**位置**:
|
||
|
||
| 文件:行号 | 断言 | 说明 |
|
||
|-----------|------|------|
|
||
| [editor/editor-to-structure.ts:101](file:///e:/Desktop/CICD/src/modules/exams/editor/editor-to-structure.ts#L101) | `: "single_choice") as RichQuestionType` | 字符串字面量断言为联合类型 |
|
||
| [editor/exam-nodes-to-editor-doc.ts:38](file:///e:/Desktop/CICD/src/modules/exams/editor/exam-nodes-to-editor-doc.ts#L38) | 同上 | 同上 |
|
||
| [editor/selection-toolbar.tsx:213,215](file:///e:/Desktop/CICD/src/modules/exams/editor/selection-toolbar.tsx#L213) | `slice.content.toJSON() as JSONContent[]` | ProseMirror→Tiptap 类型 |
|
||
| [editor/exam-rich-editor.tsx:158,174](file:///e:/Desktop/CICD/src/modules/exams/editor/exam-rich-editor.tsx#L158) | `editor.getJSON() as EditorJSONContent` | Tiptap 内部类型断言 |
|
||
| [components/exam-data-table.tsx:39](file:///e:/Desktop/CICD/src/modules/exams/components/exam-data-table.tsx#L39) | `params as Record<...>` | 不安全参数断言 |
|
||
| [components/exam-form.tsx:40](file:///e:/Desktop/CICD/src/modules/exams/components/exam-form.tsx#L40) | `zodResolver(formSchema) as Resolver<ExamFormValues>` | zodResolver 返回类型断言 |
|
||
| [actions-rich-editor.ts:147,230](file:///e:/Desktop/CICD/src/modules/exams/actions-rich-editor.ts#L147) | `q.type as "single_choice" | "multiple_choice" | "text" | "judgment"` | 字符串断言为联合类型 |
|
||
| [edit-rich/page.tsx:64](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/[id]/edit-rich/page.tsx#L64) | `structureToEditorDoc(editorDoc) as EditorJSONContent` | 类型断言 |
|
||
|
||
**违反规则**:项目规则"禁止 `as` 断言(除从 `unknown` 转换或测试中,需注释原因)"。
|
||
|
||
**原因**:Tiptap/ProseMirror 类型系统与项目类型边界处缺类型守卫;`RichQuestionType` 联合类型的字符串字面量缺运行时校验函数。
|
||
|
||
**直接后果**:若 AI 返回未预期的 type 值(如 "essay"),`as` 断言会让错误值通过类型检查,运行时可能渲染异常。
|
||
|
||
---
|
||
|
||
### 🟡 2.7【企业级能力缺失·P2】无统一空状态/骨架屏/错误回退
|
||
|
||
**位置**:组件层未抽取统一的 `<ExamEmptyState>` / `<ExamSkeleton>` / `<ExamErrorBoundary>`。
|
||
|
||
**问题**:
|
||
- `all/page.tsx` 自行实现了 `ExamsResultsFallback`,未复用到 `analytics`/`edit-rich`
|
||
- `exam-card.tsx`、`exam-grid.tsx` 无骨架屏
|
||
- 编辑器加载(Tiptap 初始化)期间无统一占位
|
||
|
||
**违反规则**:审计要求"明确处理空数据、无权限、网络异常等边界状态"、"异步数据使用 React Suspense + 骨架屏"。
|
||
|
||
**直接后果**:体验不一致,重复实现。
|
||
|
||
---
|
||
|
||
### 🟡 2.8【可测试性·P2】纯逻辑与 UI 耦合,缺单测
|
||
|
||
**位置**:
|
||
- `components/exam-preview-utils.ts`(293 行纯函数,已抽取,但无单测)
|
||
- `hooks/use-exam-preview-{state,tasks,rewrite}.ts` 无对应测试
|
||
- `editor/editor-to-structure.ts`、`editor/structure-to-editor.ts` 双向转换是核心纯逻辑,无单测
|
||
- `stats-service.ts` 的错误率/难度计算无单测
|
||
|
||
**违反规则**:审计要求"数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks,与 UI 分离;导出清晰的接口类型以便 mock"。
|
||
|
||
**直接后果**:富文本编辑器双向转换是高风险逻辑(type/score/structure 映射),无单测难以保证回归质量。
|
||
|
||
---
|
||
|
||
### 🟡 2.9【解耦性·P2】未通过接口抽象 + Context 注入数据服务
|
||
|
||
**位置**:模块整体。
|
||
|
||
**问题**:当前组件直接 import 同模块的 actions/data-access(如 `exam-rich-form.tsx` 直接 import `autoMarkExamAction` / `createExamFromRichEditorAction`)。虽然同模块内 import 合规,但审计要求"通过定义 TypeScript 接口抽象数据依赖,使用 React Context 注入数据服务,模块内部组件绝不直接 import 其他业务模块的 actions 或 data-access"。
|
||
|
||
**违反规则**:审计重构方案的"完全解耦"与"可测试性"原则。
|
||
|
||
**原因**:当前实现以功能正确性优先,未做依赖注入抽象。
|
||
|
||
**直接后果**:
|
||
- 组件无法在测试中 mock 数据服务
|
||
- 不同角色(teacher/admin/parent/student)的差异未通过接口实现隔离,未来扩展角色需改组件
|
||
- 配置驱动设计未落地,新增 Widget 需改组件代码
|
||
|
||
---
|
||
|
||
## 三、行业差距对比
|
||
|
||
参考智学网、猿题库、学而思网校、Google Classroom、Canvas LMS 等同类产品,exams 模块当前差距:
|
||
|
||
### 3.1 试卷创建侧
|
||
| 行业实践 | 当前状态 | 差距影响 |
|
||
|---------|---------|---------|
|
||
| 多种组卷入口(手动/AI/富文本/导入 Word)三选一清晰呈现 | 已有三种入口,但 `/create`、`/new` 路由并列,无统一选择页 | 教师首次使用困惑 |
|
||
| 试卷模板库(按学科/年级预置模板) | ❌ 无 | 教师每次从零创建,效率低 |
|
||
| 知识点双向细目表(题目-知识点覆盖矩阵) | ❌ 无(虽有 questions.knowledgePoints,但 exam 层无细目表视图) | 无法评估试卷覆盖度 |
|
||
| 难度预估(基于题库历史正确率自动估算试卷难度) | ❌ 无(仅手动 1-5 级) | 难度设置主观 |
|
||
| 试卷预览支持 PDF 导出/打印 | ❌ 无 | 教师无法离线分发 |
|
||
|
||
### 3.2 考试作答侧(学生)
|
||
| 行业实践 | 当前状态 | 差距影响 |
|
||
|---------|---------|---------|
|
||
| 作答页答题卡导航(已答/未答/标记 revisit) | ❌ 仅顺序作答 | 学生难以跳题、检查 |
|
||
| 自动保存进度可视化 | homework 模块已实现(autoSave* 翻译键齐全) | ✅ 较好 |
|
||
| 限时/监考倒计时 | homework 模块已实现 useExamCountdown | ✅ 较好 |
|
||
| 客观题即时反馈(练习模式) | ❌ 仅作业模式提交后批改 | 缺少低风险练习模式 |
|
||
|
||
### 3.3 考试分析侧(教师)
|
||
| 行业实践 | 当前状态 | 差距影响 |
|
||
|---------|---------|---------|
|
||
| 平均分/及格率/分数段分布 | ✅ 已实现(V3-8) | — |
|
||
| 逐题错误率与难度等级 | ✅ 已实现 | — |
|
||
| 知识点掌握度雷达图 | diagnostic 模块有,但未在 exam analytics 集成 | 教师需跨页查看 |
|
||
| 班级横向对比 | ❌ 无(仅全卷汇总) | 无法定位班级差异 |
|
||
| 学生个体诊断报告(一键生成) | ❌ 无 | 个性化反馈缺失 |
|
||
| 历次考试趋势 | ❌ 无 | 无法看进步趋势 |
|
||
|
||
### 3.4 多角色覆盖侧
|
||
| 角色 | 当前覆盖 | 差距 |
|
||
|------|---------|------|
|
||
| admin | ❌ 无 admin 视角考试管理(全校/年级聚合) | admin 仅能通过 dashboard 看 examCount,无考试管理页 |
|
||
| teacher | ✅ 完整(创建/组卷/预览/分析/监考) | — |
|
||
| parent | ✅ parent 模块有 child-exam-detail + parentExam i18n | 缺少历次考试趋势对比 |
|
||
| student | ⚠️ 通过 homework-take-view 作答,但无独立"我的考试"汇总页 | 学生无法回看历史考试试卷与成绩 |
|
||
|
||
### 3.5 UX 细节
|
||
- 缺少全局考试状态徽章颜色规范(draft/published/archived 在 exam-card 与 exam-columns 中重复定义)
|
||
- exam-card 科目颜色映射 `subjectColorMap` 硬编码英文字符串 key("Mathematics" 等),无法国际化——科目名应通过 ID 映射颜色,而非名称
|
||
- 无空状态插画/图标统一规范(all 页用 FileText,analytics 页也用 BarChart3,缺一致性)
|
||
|
||
---
|
||
|
||
## 四、改进优先级建议
|
||
|
||
### P0(紧急,影响架构合规与数据安全)
|
||
|
||
| # | 问题 | 改进方向 | 关联规则 |
|
||
|---|------|---------|---------|
|
||
| P0-1 | `data-access-cross-module.ts:488` 直接 JOIN questions 表 | 在 questions 模块新增 `getQuestionTypeMapByIds(ids): Promise<Map<string, string>>`,exams 改为调用此接口 | 模块间禁止直查对方表 |
|
||
| P0-2 | `new/`、`[id]/edit-rich/`、`[id]/analytics/`、`all/`、`create/` 缺 loading.tsx/error.tsx | 补齐 loading.tsx + error.tsx,复用 dashboard 模式 | 路由边界规范 |
|
||
|
||
### P1(高影响,影响多语言与体验)
|
||
|
||
| # | 问题 | 改进方向 |
|
||
|---|------|---------|
|
||
| P1-1 | 11 个组件未接入 i18n(exam-card/exam-filters/exam-preview-dialog/exam-viewer/question-options-editor + 4 个 editor extensions) | 接入 useTranslations,提取翻译键到 exam-homework.json 的 exam.card/exam.viewer/exam.previewDialog/editor.* 命名空间 |
|
||
| P1-2 | actions-rich-editor.ts + auto-mark.ts Server Action 返回消息硬编码 | 改用 getTranslations("examHomework.exam.actionMessages"),复用 actions.ts 已有翻译键,新增 richEditor.* / autoMark.* 子键 |
|
||
| P1-3 | exam-card subjectColorMap 用英文名做 key | 改为按 subjectId 映射颜色,颜色配置移至 `shared/config/subject-colors.ts` |
|
||
| P1-4 | stats-service.ts "(无题目文本)"、data-access.ts "General" 兜底硬编码 | 通过 data-access 层返回 null,由组件层 i18n 渲染兜底文案 |
|
||
|
||
### P2(中长期,企业级能力与重构)
|
||
|
||
| # | 问题 | 改进方向 | 状态 |
|
||
|---|------|---------|------|
|
||
| P2-1 | 10 处 `as` 类型断言 | 为 RichQuestionType 增加 `isRichQuestionType(v): v is RichQuestionType` 类型守卫;Tiptap JSONContent 边界用 zod schema 校验 | ✅ 已完成(2026-06-25):新增 isRichQuestionType/isStandaloneQuestionType/toRichQuestionType/toStandaloneQuestionType 4 个守卫,消除 editor-to-structure.ts:101、exam-nodes-to-editor-doc.ts:38、actions-rich-editor.ts:149/233 共 4 处 as 断言;其余 6 处 as 断言属于 unknown→具体类型的合法收窄或 Tiptap/ProseMirror 内部类型边界,已添加注释说明,保留 |
|
||
| P2-2 | 纯逻辑无单测 | 为 exam-preview-utils、editor-to-structure、structure-to-editor、stats-service 错误率计算补充 .test.ts | ⏸️ 待实施(依赖 P2-4 ExamServicePort 落地后统一 mock) |
|
||
| P2-3 | 无统一 ExamEmptyState/ExamSkeleton/ExamErrorBoundary | 抽取到 components/exam-boundaries.tsx,全模块复用 | ✅ 已完成(2026-06-25):创建 components/exam-boundaries.tsx(189 行),导出 ExamErrorBoundary/ExamEmptyState/ExamSkeleton 三组合单元,5 种骨架变体,新增 i18n 键 exam.error.boundaryTitle/boundaryDescription/retry |
|
||
| P2-4 | 组件直接 import actions,未通过 Context 注入 | 定义 `ExamServicePort` 接口 + `ExamServiceProvider` Context,组件通过 `useExamService()` 获取;角色差异通过不同 Provider 实现隔离 | ✅ 已完成骨架(2026-06-25):创建 services/exam-service-port.ts(95 行,12 方法契约)+ services/exam-service-context.tsx(72 行,Context + Provider + Hook)+ services/index.ts(桶导出)。具体实现(TeacherExamService/AdminExamService/MockExamService)与组件改造将在 P2-6+ 落地 |
|
||
| P2-5 | 无配置驱动的 Widget 渲染 | 参考 dashboard/config/widget-configs.ts,新增 `exams/config/exam-widgets.ts`,按角色配置渲染哪些子模块 | ✅ 已完成(2026-06-25):创建 config/exam-widgets.ts(192 行),四角色默认配置 + getExamWidgetConfig/getWidgetsBySlot 工具函数 |
|
||
| P2-6 | 缺少考试模板库、知识点细目表、班级对比、学生个体报告 | 中长期功能补全,对标智学网 | ⏸️ 待实施(中长期) |
|
||
| P2-7 | 缺少 admin 视角考试管理页、student 独立"我的考试"页 | 多角色覆盖补全 | ⏸️ 待实施(中长期,依赖 P2-4 具体实现 + P2-5 配置消费) |
|
||
| P2-8 | 关键操作埋点不完整 | 已有 exam.ai_generated/updated/deleted/duplicated,需补 exam.published/archived/auto_marked 埋点 | ⏸️ 待实施 |
|
||
| P2-9 | a11y 缺失(编辑器 extensions 无 aria-label 规范、键盘导航) | 为 Tiptap 节点添加 aria-label,工具栏支持完整键盘导航 | ⏸️ 待实施 |
|
||
| P2-10 | 数据查询未结合权限二次校验(data-access 层部分函数未传 scope) | `getExamPreview`、`getExamSubjects`、`getExamGrades`、`duplicateExam`、`deleteExamById` 应接受 scope 参数或在 Action 层显式校验 | ⏸️ 待实施 |
|
||
|
||
> **本轮 P2 落地范围说明**:
|
||
> - P2-1 / P2-3 / P2-4(骨架)/ P2-5 已完成,奠定解耦与配置驱动的架构基础
|
||
> - P2-2 单测待 ExamServicePort 具体实现落地后统一 mock
|
||
> - P2-6 / P2-7 为中长期功能补全,需独立规划排期
|
||
> - P2-8 / P2-9 / P2-10 为增强项,可在后续迭代中逐步落地
|
||
> - 全部 P2 代码改动已通过 `npx tsc --noEmit`(exams 模块零错误)与 `npx eslint`(零错误/零警告)验证
|
||
|
||
---
|
||
|
||
## 五、架构图同步说明
|
||
|
||
本次审计发现架构图需补充以下节点:
|
||
|
||
### 004_architecture_impact_map.md 需补充
|
||
|
||
1. **exams 模块文件清单补全**:
|
||
- 新增 `data-access-error-collection.ts` 行(当前 004 未单独列出)
|
||
- 标注 `data-access-cross-module.ts` 中 `getExamForGradeEntry` 存在 P0 跨模块 JOIN 违规(待修复后改为 ✅ 已修复)
|
||
|
||
2. **permission 补全**:
|
||
- 005 已有 EXAM_PROCTOR/EXAM_PROCTOR_READ,但 004 第 2.2 节 exams 权限点列表未完整列出
|
||
|
||
3. **dependencyMatrix 补充**:
|
||
- `app/(dashboard)/teacher/exams/[id]/edit-rich/page.tsx` → `exams/editor/{exam-nodes-to-editor-doc,structure-to-editor}` 与 `exams/utils/normalize-structure`(当前 004 已记 build/page.tsx,但 edit-rich 同样依赖,需补)
|
||
|
||
4. **被依赖关系补全**:
|
||
- `homework/data-access-utils.getQuestionText` 被 `exams/stats-service.ts` 调用,005 JSON 中 homework 模块 exports 的 usedBy 需补 `exams/stats-service`
|
||
|
||
### 005_architecture_data.json 需补充
|
||
|
||
1. `modules.exams.exports` 数组补:
|
||
- `data-access-error-collection.ts`(含 `getExamErrorCollectionForExam` 等接口)
|
||
- `getExamForGradeEntry`(标注跨模块 JOIN 待修复)
|
||
|
||
2. `modules.homework.exports` 中 `getQuestionText` 的 `usedBy` 补 `"exams/stats-service"`
|
||
|
||
3. `modules.questions.exports` 新增 `getQuestionTypeMapByIds`(修复 P0-1 后)
|
||
|
||
4. `architectureOverview.violations` 数组新增当前未记录的违规项,修复后改为 ✅ 标记
|
||
|
||
---
|
||
|
||
## 附:重构方案设计要点(落地架构)
|
||
|
||
> 以下为 P2-4/P2-5 的具体设计方向,作为中长期重构蓝图。本次实施将先完成 P0/P1,P2 仅落地基础接口与配置骨架。
|
||
|
||
### A. 完全解耦:ExamServicePort + Context 注入
|
||
|
||
```typescript
|
||
// exams/services/exam-service-port.ts(新增)
|
||
export interface ExamServicePort {
|
||
listExams(params: GetExamsParams): Promise<Exam[]>
|
||
getExam(id: string): Promise<ExamDetail | null>
|
||
createExam(input: ExamCreateInput): Promise<ActionState<string>>
|
||
updateExam(input: ExamUpdateInput): Promise<ActionState<string>>
|
||
deleteExam(id: string): Promise<ActionState<string>>
|
||
duplicateExam(id: string): Promise<ActionState<string>>
|
||
getAnalytics(id: string): Promise<ExamAnalyticsSummary | null>
|
||
// ... 所有数据访问通过此接口
|
||
}
|
||
|
||
// exams/services/exam-service-context.tsx(新增)
|
||
const ExamServiceContext = createContext<ExamServicePort | null>(null)
|
||
export function ExamServiceProvider({ service, children }: { service: ExamServicePort; children: ReactNode }) { ... }
|
||
export function useExamService(): ExamServicePort { ... }
|
||
|
||
// 不同角色的实现
|
||
// exams/services/teacher-exam-service.ts // 调用真实 Server Actions
|
||
// exams/services/admin-exam-service.ts // admin 视角(聚合全校)
|
||
// exams/services/mock-exam-service.ts // 测试用
|
||
```
|
||
|
||
### B. 组合优先:Widget 配置驱动
|
||
|
||
```typescript
|
||
// exams/config/exam-widgets.ts(新增)
|
||
export type ExamWidgetConfig = {
|
||
role: Role
|
||
widgets: Array<{
|
||
id: "list" | "analytics" | "proctoring" | "templates" | "blueprint"
|
||
visible: boolean
|
||
order: number
|
||
props?: Record<string, unknown>
|
||
}>
|
||
}
|
||
export const examWidgetConfigs: Record<Role, ExamWidgetConfig> = { ... }
|
||
```
|
||
|
||
### C. i18n 翻译文件结构示例(新增键)
|
||
|
||
```json
|
||
{
|
||
"exam": {
|
||
"card": {
|
||
"level": "难度 {{level}}",
|
||
"minutes": "{{count}} 分钟",
|
||
"points": "{{count}} 分",
|
||
"questions": "{{count}} 题"
|
||
},
|
||
"viewer": {
|
||
"section": "分卷",
|
||
"group": "大题",
|
||
"score": "分值",
|
||
"noQuestions": "暂无题目"
|
||
},
|
||
"previewDialog": {
|
||
"title": "试卷预览",
|
||
"generating": "生成预览中...",
|
||
"fullPreview": "完整试卷预览",
|
||
"summary": "{{count}} 题 · {{subject}} · {{grade}} · {{minutes}} 分钟 · {{total}} 分",
|
||
"noPreview": "暂无预览内容",
|
||
"confirmCreate": "确认并创建",
|
||
"untitledQuestion": "未命名题目",
|
||
"untitledSubQuestion": "未命名子题",
|
||
"scoreUnit": "分"
|
||
},
|
||
"richEditorAction": {
|
||
"titleRequired": "请填写试卷标题",
|
||
"contentRequired": "试卷内容不能为空",
|
||
"contentInvalid": "试卷内容格式无效",
|
||
"contentParseFailed": "试卷内容解析失败",
|
||
"draftCreated": "试卷草稿已创建",
|
||
"onlyOwnUpdate": "只能更新自己创建的试卷",
|
||
"updated": "试卷已更新"
|
||
},
|
||
"autoMarkAction": {
|
||
"sourceRequired": "试卷文本不能为空",
|
||
"completed": "AI 自动标记完成"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### D. 错误与边界
|
||
|
||
- 每个路由的 `error.tsx` 复用 `exams/components/exam-error-boundary.tsx`(新增)
|
||
- 列表/卡片使用 `<ExamSkeleton>` / `<ExamEmptyState>`(新增)
|
||
- 编辑器加载使用 Suspense + 自定义骨架
|
||
|
||
### E. 可测试性
|
||
|
||
- 纯逻辑已有抽取(exam-preview-utils/editor-to-structure/structure-to-editor),补单测
|
||
- ExamServicePort 接口允许测试注入 mock 实现
|
||
|
||
### F. 安全性
|
||
|
||
- data-access 层所有按 ID 查询函数增加可选 `scope` 参数,Action 层强制传入
|
||
- `getExamPreview`、`duplicateExam`、`deleteExamById` 当前未校验 scope,需补
|
||
|
||
### G. 监控埋点
|
||
|
||
- 补 `exam.published`、`exam.archived`、`exam.auto_marked` 埋点
|
||
- analytics 页访问埋点 `exam.analytics_viewed`
|