From 6114607c1e2499abe1685f8d6ec83cd7babe56fb Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:16:33 +0800 Subject: [PATCH] feat(exams,homework): add rich text exam editor and scan-based grading - Add Tiptap-based rich text editor with custom extensions (dotted-mark, blank-node, image-node, group-block, question-block) for exam creation - Add AI auto-marking action to convert pasted exam text to structured editor doc - Add resizable split-panel layout for editor + live preview - Add student scan upload (photo of paper answers) with drag-drop and reorder - Add scan image viewer with zoom/rotate/fullscreen for teachers - Add scan grading view with side-by-side questions and scan images - Add /teacher/exams/new and /teacher/homework/submissions/[id]/scan-grading routes - Fix getScansAction to support both teacher (HOMEWORK_GRADE) and student (HOMEWORK_SUBMIT) permission scopes - Add i18n keys for rich editor, scan upload, and scan grading (zh-CN/en) - Sync architecture diagrams (004/005) with new modules, routes, and deps --- .../004_architecture_impact_map.md | 44 +- docs/architecture/005_architecture_data.json | 454 +++++++++++++++++- .../(dashboard)/teacher/exams/new/page.tsx | 18 + .../submissions/[submissionId]/page.tsx | 11 + .../[submissionId]/scan-grading/page.tsx | 34 ++ src/app/api/upload/route.ts | 1 + src/modules/exams/actions.ts | 287 +++++++++++ .../exams/components/exam-rich-form.tsx | 431 +++++++++++++++++ .../exams/editor/editor-to-structure.ts | 155 ++++++ .../exams/editor/exam-rich-editor-types.ts | 42 ++ src/modules/exams/editor/exam-rich-editor.tsx | 309 ++++++++++++ .../exams/editor/extensions/blank-node.tsx | 56 +++ .../exams/editor/extensions/dotted-mark.ts | 42 ++ .../exams/editor/extensions/group-block.tsx | 59 +++ .../exams/editor/extensions/image-node.ts | 49 ++ src/modules/exams/editor/extensions/index.ts | 5 + .../editor/extensions/question-block.tsx | 97 ++++ src/modules/exams/editor/index.ts | 23 + .../exams/editor/selection-toolbar.tsx | 202 ++++++++ .../exams/editor/structure-to-editor.ts | 69 +++ src/modules/files/types.ts | 2 +- src/modules/homework/actions.ts | 119 +++++ .../homework-batch-grading-view.tsx | 20 +- .../components/homework-scan-grading-view.tsx | 287 +++++++++++ .../components/homework-take-view.tsx | 48 +- .../homework/components/scan-image-viewer.tsx | 206 ++++++++ .../homework/components/scan-uploader.tsx | 266 ++++++++++ src/shared/components/ui/resizable-panel.tsx | 78 +++ .../i18n/messages/en/exam-homework.json | 80 ++- .../i18n/messages/zh-CN/exam-homework.json | 80 ++- 30 files changed, 3548 insertions(+), 26 deletions(-) create mode 100644 src/app/(dashboard)/teacher/exams/new/page.tsx create mode 100644 src/app/(dashboard)/teacher/homework/submissions/[submissionId]/scan-grading/page.tsx create mode 100644 src/modules/exams/components/exam-rich-form.tsx create mode 100644 src/modules/exams/editor/editor-to-structure.ts create mode 100644 src/modules/exams/editor/exam-rich-editor-types.ts create mode 100644 src/modules/exams/editor/exam-rich-editor.tsx create mode 100644 src/modules/exams/editor/extensions/blank-node.tsx create mode 100644 src/modules/exams/editor/extensions/dotted-mark.ts create mode 100644 src/modules/exams/editor/extensions/group-block.tsx create mode 100644 src/modules/exams/editor/extensions/image-node.ts create mode 100644 src/modules/exams/editor/extensions/index.ts create mode 100644 src/modules/exams/editor/extensions/question-block.tsx create mode 100644 src/modules/exams/editor/index.ts create mode 100644 src/modules/exams/editor/selection-toolbar.tsx create mode 100644 src/modules/exams/editor/structure-to-editor.ts create mode 100644 src/modules/homework/components/homework-scan-grading-view.tsx create mode 100644 src/modules/homework/components/scan-image-viewer.tsx create mode 100644 src/modules/homework/components/scan-uploader.tsx create mode 100644 src/shared/components/ui/resizable-panel.tsx diff --git a/docs/architecture/004_architecture_impact_map.md b/docs/architecture/004_architecture_impact_map.md index d231aae..fbe9fb0 100644 --- a/docs/architecture/004_architecture_impact_map.md +++ b/docs/architecture/004_architecture_impact_map.md @@ -445,6 +445,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | **UI 组件** | `ConfirmDeleteDialog` | `components/ui/confirm-delete-dialog.tsx` | 通用删除确认对话框(AlertDialog 包装,支持自定义 confirmText/cancelText) | 5 个(P0-1: announcement-detail, message-detail, course-plan-detail, grade-classes-view, students-table) | | **UI 组件** | `Pagination` | `components/ui/pagination.tsx` | 通用分页 UI(Showing X-Y of Z + Page X of Y + 上一页/下一页按钮) | 3 个(P0-2: audit-log-table, login-log-table, data-change-log-table) | | **UI 组件** | `EmptyTableRow` | `components/ui/empty-table-row.tsx` | 表格空状态行(TableRow + TableCell 居中显示空状态文案) | 3 个(P0-3: audit-log-table, login-log-table, data-change-log-table) | +| **UI 组件** | `ResizablePanel` | `components/ui/resizable-panel.tsx` | 自实现可拖拽分栏容器(左右两栏 + 中间分隔条 pointer 拖拽调整宽度,无新依赖;用于试卷富文本编辑器左编辑右预览、阅卷式批改左题目右扫描图等场景) | 2 个(exams/components/exam-rich-form, homework/components/homework-scan-grading-view) | | **UI 组件** | `StatusBadge` | `components/ui/status-badge.tsx` | 通用状态徽章(Badge + 状态→variant/label/className 映射表,修复 in_progress 颜色不一致 bug) | 9+ 个(P1-1: audit 3 文件, grades 2 文件, student/learning/assignments, parent/child-homework-summary, student-upcoming-assignments-card, question-columns) | | **表单字段** | `TextField` | `components/form-fields/text-field.tsx` | 通用文本字段(FormField + Input 包装,支持 text/number/password/datetime-local 类型 + value 转换器) | 3 个文件 16 处(P1-2: profile-settings-form 6, exam-basic-info-form 4, ai-provider-settings-card 4) | | **表单字段** | `SelectField` | `components/form-fields/select-field.tsx` | 通用选择字段(FormField + Select 包装,支持 toSelectValue/fromSelectValue 处理 number↔string) | 4 个文件 8 处(P1-2: exam-basic-info-form 3, ai-provider-settings-card 1, create-question-dialog 2, profile-settings-form 1) | @@ -521,6 +522,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | `components/ui/chip-nav.tsx` | 78 | ChipNav 芯片导航(P1-b 新增) | | `components/ui/page-header.tsx` | 44 | PageHeader 页面头部(P2-b 新增,含 icon 属性) | | `components/ui/filter-bar.tsx` | 124 | FilterBar + FilterSearchInput + FilterResetButton(P3-b 新增) | +| `components/ui/resizable-panel.tsx` | - | ResizablePanel 可拖拽分栏容器(自实现,无新依赖;用于富文本编辑器与阅卷式批改的左右分栏布局) | | `components/charts/chart-card-shell.tsx` | 90 | ChartCardShell 图表卡片外壳(P3-c 新增) | | `components/charts/trend-line-chart.tsx` | 153 | TrendLineChart 趋势折线图(P3-c 新增) | | `components/charts/simple-bar-chart.tsx` | 162 | SimpleBarChart 柱状图(P3-c 新增) | @@ -540,13 +542,20 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" **职责**:考试全生命周期管理(创建/编辑/预览/发布/删除/复制)+ AI 辅助出题。 **导出函数**: -- Actions:`createExamAction` / `createAiExamAction` / `previewAiExamAction` / `regenerateAiQuestionAction` / `updateExamAction` / `deleteExamAction` / `duplicateExamAction` / `getExamPreviewAction` / `getSubjectsAction` / `getGradesAction` / `getExamsByGradeIdAction`(✅ v4-P2-7 新增:年级仪表盘维度3,按 gradeId 查询年级下所有考试 + 提交统计,EXAM_READ 权限)(✅ P1-2 已修复:actions 层不再直接访问 DB,全部下沉到 data-access) +- Actions:`createExamAction` / `createAiExamAction` / `previewAiExamAction` / `regenerateAiQuestionAction` / `updateExamAction` / `deleteExamAction` / `duplicateExamAction` / `getExamPreviewAction` / `getSubjectsAction` / `getGradesAction` / `getExamsByGradeIdAction`(✅ v4-P2-7 新增:年级仪表盘维度3,按 gradeId 查询年级下所有考试 + 提交统计,EXAM_READ 权限)/ `autoMarkExamAction`(✅ 2026-06-24 新增:AI 自动标记试卷文本,将粘贴的试卷文本交给 AI 解析为带题目块/分组/填空/加点字标记的 Tiptap JSONContent 文档,EXAM_AI_GENERATE 权限,返回 `{ doc, title }` 供富文本编辑器载入)/ `createExamFromRichEditorAction`(✅ 2026-06-24 新增:从富文本编辑器保存试卷草稿,将 Tiptap JSONContent 通过 `editor/editor-to-structure.editorDocToStructure` 转换为 questions + structure 后复用 `persistAiGeneratedExamDraft` 持久化,EXAM_CREATE 权限)(✅ P1-2 已修复:actions 层不再直接访问 DB,全部下沉到 data-access) - Data-access:`getExams` / `getExamById` / `persistExamDraft` / `persistAiGeneratedExamDraft` / `buildExamDescription` / `resolveSubjectGradeNames` / `getExamCreatorId` / `updateExamWithQuestions` / `deleteExamById` / `duplicateExam` / `getExamPreview` / `getExamSubjects` / `getExamGrades` / `getExamsByGradeId`(✅ v4-P2-7 新增:年级仪表盘维度3,exams 表有直接 gradeId 字段,配合 examSubmissions 聚合提交数/已评分数/平均分,支持 scope 行级过滤)(后 8 个为 P1-2 新增)/ `getExamsForGradeEntry`(✅ 2026-06-24 新增:按 scope 过滤试卷列表,只返回有题目的试卷,供成绩录入页试卷选择器使用,返回 id/title/subjectName/gradeName/questionCount/totalScore)/ `getExamForGradeEntry`(✅ 2026-06-24 新增:获取单个试卷详情含题目列表,innerJoin questions 获取 type,含 scope 校验,返回 id/title/subjectId/gradeId/totalScore/questions[{id,order,score,type}],供 grades 模块按试卷录入成绩使用) - AI Pipeline:`generateAiCreateDraftFromSource` / `generateAiPreviewData` / `regenerateAiQuestionByInstruction` - Utils:`normalizeStructure`(v3 新增:将持久化的 `exam.structure` unknown JSON 运行时校验并归一化为类型安全的 `ExamNode[]`,类型守卫模式无 `as` 断言,从 `teacher/exams/[id]/build/page.tsx` 提取) - Stats-service(V3-8 新增):`getExamAnalytics`(cache 包装,聚合考试所有作业的已批改提交,计算平均分/及格率/分数段分布/逐题错误率与难度等级,对标智学网考试分析)+ `ExamAnalyticsSummary` 类型 - Types(✅ 2026-06-24 新增成绩录入相关类型):`ExamQuestionItem`(试卷中单个题目的精简结构 { id, order, score, type })/ `ExamForGradeEntry`(成绩录入用的试卷详情,含题目列表)/ `ExamOptionForEntry`(成绩录入页试卷选择器选项 { id, title, subjectName, gradeName, questionCount, totalScore }) +- Editor 子模块(✅ 2026-06-24 新增 `editor/` 目录,基于 Tiptap 的试卷富文本编辑器): + - 组件:`ExamRichEditor`(富文本编辑器主体,forwardRef 暴露 `ExamRichEditorHandle` 以便父组件获取 JSONContent)/ `SelectionToolbar`(浮动选择工具栏,对选中文本应用 DottedMark 等标记) + - 自定义 Tiptap 扩展(`editor/extensions/`):`DottedMark`(加点字标记,拼音注音题)/ `BlankNode`(填空空位节点,原子节点)/ `ImageNode`(图片节点,含 fileId/url/alt 属性)/ `QuestionBlock`(题目块节点,含 type/score 等 attrs)/ `GroupBlock`(大题分组节点,含 title) + - 类型(`editor/exam-rich-editor-types.ts`):`RichQuestionType`(`single_choice | multiple_choice | judgment | text | composite`)/ `RichQuestionContent`(题干文本+选项+填空+图片+子题+正确答案)/ `EditorQuestion` / `EditorStructureNode`(group|question 递归)/ `EditorDoc`({ title, questions, structure })/ `EditorJSONContent`(JSONContent 别名) + - 转换工具:`editorDocToStructure`(`editor/editor-to-structure.ts`,Tiptap JSONContent → EditorDoc,供 `createExamFromRichEditorAction` 持久化使用)/ `structureToEditorDoc`(`editor/structure-to-editor.ts`,EditorDoc → Tiptap JSONContent,供编辑器回填已保存试卷使用) + - Barrel 导出:`editor/index.ts` 聚合导出上述所有组件、扩展、类型与转换函数 - Components(V3-8 新增):`ExamAnalyticsDashboard`(考试分析仪表盘:汇总卡片+分数段分布+逐题分析表) +- Components(✅ 2026-06-24 新增):`ExamRichForm`(富文本编辑器试卷创建表单,使用 `ResizablePanel` 左编辑右预览布局,集成 `autoMarkExamAction` 一键 AI 标记 + `createExamFromRichEditorAction` 保存草稿,调用 `getSubjectsAction`/`getGradesAction` 填充科目年级选项) **依赖关系**: - 依赖:`shared/*`、`@/auth`、`questions`(✅ P0-1 已修复:通过 data-access.createQuestionWithRelations)、`classes`(✅ P0-2 已修复:通过 data-access.getClassGradeIdsByClassIds)、`school`(✅ P1-1 已修复:通过 school data-access.getSubjectOptions/getGradeOptions)、`homework`(V3-8 新增:stats-service 通过 `homework/data-access.getHomeworkAssignmentsByExamId` / `getGradedSubmissionsByExamId` 获取作业与提交数据,合理跨模块调用) @@ -564,16 +573,29 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" **文件清单**: | 文件 | 行数 | 职责 | |------|------|------| -| `actions.ts` | 691 | 10 个 Server Action(P1-2 已修复,无直接 DB 操作) | +| `actions.ts` | 691+ | 12 个 Server Action(P1-2 已修复,无直接 DB 操作;2026-06-24 新增 `autoMarkExamAction`/`createExamFromRichEditorAction`) | | `ai-pipeline.ts` | 857 | AI 出题管线(超限) | | `data-access.ts` | 560+ | 考试 CRUD(含 P1-2 新增 7 个写/查询函数,P0-1/P0-2 已修复:通过 questions/classes data-access 跨模块通信;v4-P2-7 新增 getExamsByGradeId;2026-06-24 新增 getExamsForGradeEntry/getExamForGradeEntry 供 grades 模块按试卷录入成绩) | | `stats-service.ts` | - | V3-8 新增:考试分析数据聚合(`getExamAnalytics` + `ExamAnalyticsSummary` 类型) | -| `types.ts` | 50+ | 类型定义(2026-06-24 新增:ExamQuestionItem/ExamForGradeEntry/ExamOptionForEntry 供成绩录入使用) | +| `types.ts` | 50+ | 类型定义(2026-06-24 新增:ExamQuestionItem/ExamForGradeEntry/ExamOptionForEntry 供成绩录入使用;新增 `AutoMarkResult`/`AutoMarkSchema`/`RichExamCreateSchema` 供富文本编辑器使用) | +| `editor/index.ts` | 23 | 2026-06-24 新增:富文本编辑器 barrel 导出(聚合组件/扩展/类型/转换函数) | +| `editor/exam-rich-editor.tsx` | - | 2026-06-24 新增:ExamRichEditor 富文本编辑器主体(forwardRef + ExamRichEditorHandle) | +| `editor/selection-toolbar.tsx` | - | 2026-06-24 新增:SelectionToolbar 浮动选择工具栏 | +| `editor/exam-rich-editor-types.ts` | 42 | 2026-06-24 新增:富文本编辑器类型定义(RichQuestionType/RichQuestionContent/EditorQuestion/EditorStructureNode/EditorDoc/EditorJSONContent) | +| `editor/editor-to-structure.ts` | - | 2026-06-24 新增:editorDocToStructure(Tiptap JSONContent → EditorDoc) | +| `editor/structure-to-editor.ts` | - | 2026-06-24 新增:structureToEditorDoc(EditorDoc → Tiptap JSONContent) | +| `editor/extensions/index.ts` | 5 | 2026-06-24 新增:Tiptap 扩展 barrel 导出 | +| `editor/extensions/dotted-mark.ts` | - | 2026-06-24 新增:DottedMark 加点字标记扩展 | +| `editor/extensions/blank-node.tsx` | - | 2026-06-24 新增:BlankNode 填空空位节点扩展 | +| `editor/extensions/image-node.ts` | - | 2026-06-24 新增:ImageNode 图片节点扩展 | +| `editor/extensions/group-block.tsx` | - | 2026-06-24 新增:GroupBlock 大题分组节点扩展 | +| `editor/extensions/question-block.tsx` | - | 2026-06-24 新增:QuestionBlock 题目块节点扩展 | | `hooks/use-exam-preview.ts` | 295 | 预览 Hook | | `utils/normalize-structure.ts` | 57 | v3 新增:exam.structure 运行时校验与归一化(从 build/page.tsx 提取) | | `components/exam-analytics-dashboard.tsx` | - | V3-8 新增:考试分析仪表盘组件 | | `components/exam-actions.tsx` | - | V3-5/V3-8/V3-12 增强:角色化菜单+analytics 链接+移动端触摸优化 | -| `components/*` | 19 文件 | 考试表单/组卷/预览/分析组件 | +| `components/exam-rich-form.tsx` | - | 2026-06-24 新增:富文本编辑器试卷创建表单(ResizablePanel 左编辑右预览 + AI 自动标记 + 保存草稿) | +| `components/*` | 19+ 文件 | 考试表单/组卷/预览/分析组件 | --- @@ -582,15 +604,17 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" **职责**:作业全生命周期(创建/发布/作答/批改/分析)。 **导出函数**: -- Actions:`createHomeworkAssignmentAction` / `startHomeworkSubmissionAction` / `saveHomeworkAnswerAction` / `submitHomeworkAction` / `gradeHomeworkSubmissionAction` / `batchAutoGradeSubmissionsAction`(V3-7 新增:批量自动批改,HOMEWORK_GRADE 权限+非管理员仅可批改自己创建的作业)(✅ P1-2 已修复:actions 层不再直接访问 DB,全部下沉到 data-access/data-access-write) +- Actions:`createHomeworkAssignmentAction` / `startHomeworkSubmissionAction` / `saveHomeworkAnswerAction` / `submitHomeworkAction` / `gradeHomeworkSubmissionAction` / `batchAutoGradeSubmissionsAction`(V3-7 新增:批量自动批改,HOMEWORK_GRADE 权限+非管理员仅可批改自己创建的作业)/ `getScansAction`(✅ 2026-06-24 新增:获取某次提交的所有答题扫描图,扫描图存储在 fileAttachments 表中 targetType="homework"/targetId=submissionId;支持两类访问者——教师 HOMEWORK_GRADE 仅可访问自己创建的作业的提交,学生 HOMEWORK_SUBMIT 仅可访问自己的提交)/ `deleteScanAction`(✅ 2026-06-24 新增:删除答题扫描图,仅允许提交者本人删除且仅在提交状态为 started 时允许,调用 `files/data-access.deleteFileAttachment` 删除 DB 记录)(✅ P1-2 已修复:actions 层不再直接访问 DB,全部下沉到 data-access/data-access-write) - Data-access:`getHomeworkAssignments` / `getHomeworkAssignmentById` / `getHomeworkSubmissions` / `getStudentHomeworkAssignments` / `getStudentHomeworkTakeData` / `getHomeworkAssignmentReviewList` / `getHomeworkSubmissionDetails` / `getDemoStudentUser`(已迁移至 users 模块 `getCurrentStudentUser`,此处为 re-export 向后兼容)/ `isRecord` / `toQuestionContent` / `getAssignmentMaxScoreById`(后三者供 stats-service 使用)/ `getHomeworkAssignmentsByExamId`(V3-8 新增:按考试 ID 查作业+目标/提交/批改计数)/ `getGradedSubmissionsByExamId`(V3-8 新增:按考试 ID 查已批改提交,按学生去重)/ `getStudentSubmissionResult`(V3-9 新增:查学生指定作业最新提交,用于结果页)/ `getStudentExamResults`(V3-11 新增:查学生考试结果列表,供家长端展示) - Data-access-classes:`getAssignmentIdsForStudents` / `getHomeworkAssignmentsWithSubject` / `getHomeworkAssignmentsByIds` / `getAssignmentTargetCounts` / `getHomeworkSubmissionsForStudents` / `getPublishedHomeworkAssignmentsWithSubject` / `getHomeworkSubmissionsForAssignments`(P0-7 新增,供 classes 模块跨模块调用;✅ P1-1 已修复:内部不再直查 exams/subjects 表,改为调用 `exams/data-access.getExamSubjectIdMap` + `school/data-access.getSubjectNameMapByIds`) - Data-access-write:11 个写操作函数(P1-2 新增 10 个从 actions 下沉 + V3-7 新增 `batchAutoGradeSubmissions`) - Stats-service:`getTeacherGradeTrends` / `getHomeworkAssignmentAnalytics` / `getStudentDashboardGrades`(从 data-access.ts re-export 以保持向后兼容) - Components(V3-7/V3-9 新增):`HomeworkBatchGradingView`(批量批改视图:勾选+一键批改+toast 反馈)/ `HomeworkSubmissionResult`(提交后即时反馈:分数汇总+对错分布+错题预览) +- Components(✅ 2026-06-24 新增扫描图相关):`ScanUploader`(学生扫描图上传组件,调用 /api/upload 上传图片,targetType="homework",支持增删/排序,导出 `ScanImage` 类型)/ `ScanImageViewer`(扫描图查看器,支持翻页/缩放/旋转/全屏,用于阅卷式批改时查看学生答题图片)/ `HomeworkScanGradingView`(教师阅卷式批改视图,使用 `ResizablePanel` 左侧题目+批改表单、右侧扫描图查看器,集成 `getScansAction` 拉取扫描图、`gradeHomeworkSubmissionAction` 批改,支持上一份/下一份提交快速切换) +- Types(✅ 2026-06-24 新增):`ScanAttachment`(扫描图附件结构 { fileId, url, filename, originalName, page },由 `getScansAction` 返回)/ `ScanImage`(扫描图 UI 结构,由 `ScanUploader` 导出供 `ScanImageViewer` 复用) **依赖关系**: -- 依赖:`shared/*`、`@/auth`、`exams`(✅ P1-1 已修复:通过 exams data-access.getExamIdsByGradeIds/getExamSubjectIdMap/getExamWithQuestionsForHomework/getExamForProctoringCrossModule)、`classes`(✅ P1-1 已修复:通过 classes data-access.getStudentIdsByClassId 等 7 个函数)、`school`(✅ P1-1 已修复:通过 school data-access.getSubjectOptions/getSubjectNameMapByIds)、`users`(✅ P1-1 已修复:通过 users data-access.getUserWithRole/getUserNamesByIds) +- 依赖:`shared/*`、`@/auth`、`exams`(✅ P1-1 已修复:通过 exams data-access.getExamIdsByGradeIds/getExamSubjectIdMap/getExamWithQuestionsForHomework/getExamForProctoringCrossModule)、`classes`(✅ P1-1 已修复:通过 classes data-access.getStudentIdsByClassId 等 7 个函数)、`school`(✅ P1-1 已修复:通过 school data-access.getSubjectOptions/getSubjectNameMapByIds)、`users`(✅ P1-1 已修复:通过 users data-access.getUserWithRole/getUserNamesByIds)、`files`(✅ 2026-06-24 新增:`deleteScanAction` 调用 `files/data-access.deleteFileAttachment` 删除扫描图 DB 记录,合理跨模块调用) - 被依赖:`dashboard`(通过 data-access,合理)、`parent`(通过 data-access,合理;V3-11 新增 `getStudentExamResults` 供 parent 调用)、`classes`(✅ P0-7 已修复:classes 通过 `homework/data-access-classes` 获取作业数据,不再反向直查 homework/exams 表)、`exams`(V3-8 新增:`exams/stats-service.getExamAnalytics` 调用 `getHomeworkAssignmentsByExamId` / `getGradedSubmissionsByExamId`,合理跨模块调用) **已知问题**: @@ -614,12 +638,15 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | `data-access-write.ts` | 285+ | 作业写操作(P1-2 新增 10 个写函数从 actions 下沉;V3-7 新增 `batchAutoGradeSubmissions`) | | `data-access-classes.ts` | 240+ | 跨模块查询封装(P0-7 新增;✅ P1-1 已修复:内部通过 exams/school data-access 获取考试科目信息,不再直查 exams/subjects 表) | | `stats-service.ts` | 425 | 统计分析(教师趋势/作业分析/学生仪表盘成绩) | -| `actions.ts` | 239+ | 6 个 Server Action(P1-2 已修复,无直接 DB 操作;V3-7 新增 `batchAutoGradeSubmissionsAction`) | +| `actions.ts` | 239+ | 8 个 Server Action(P1-2 已修复,无直接 DB 操作;V3-7 新增 `batchAutoGradeSubmissionsAction`;2026-06-24 新增 `getScansAction`/`deleteScanAction`;新增 `ScanAttachment` 类型导出) | | `types.ts` | 186 | 类型定义 | | `schema.ts` | 29 | Zod 校验 | | `components/homework-batch-grading-view.tsx` | - | V3-7 新增:批量批改视图(use client) | | `components/homework-submission-result.tsx` | - | V3-9 新增:提交后即时反馈页 | | `components/homework-take-view.tsx` | - | V3-9/V3-12 增强:提交后跳转结果页+移动端触摸优化;✅ P0-竞品:集成限时/监考模式倒计时(useExamCountdown)+ 到时自动提交 | +| `components/scan-uploader.tsx` | - | 2026-06-24 新增:学生扫描图上传组件(调用 /api/upload,targetType="homework",导出 `ScanImage` 类型) | +| `components/scan-image-viewer.tsx` | - | 2026-06-24 新增:扫描图查看器(翻页/缩放/旋转/全屏) | +| `components/homework-scan-grading-view.tsx` | - | 2026-06-24 新增:教师阅卷式批改视图(ResizablePanel 左题目右扫描图 + 上一份/下一份切换) | | `hooks/use-exam-countdown.ts` | 122 | P0-竞品新增:考试倒计时 Hook(每秒更新、紧急状态高亮、到时回调自动提交) | --- @@ -1311,10 +1338,11 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" **导出函数**: - Data-access:`getAllFileAttachments` / `getFileAttachmentsByOwner` / `getFileAttachmentById` / `createFileAttachment` / `updateFileAttachment` / `deleteFileAttachment` / `batchDeleteFileAttachments` / `getFileStats`(✅ P2 已修复:7 个读函数使用 `React.cache()` 包装实现请求级 memoization:`getFileAttachment` / `getFileAttachmentsByTarget` / `getFileAttachmentsByUploader` / `getAllFileAttachments` / `getFileAttachmentsWithFilters` / `getFileStats` / `getFileAttachmentsByIds`) +- Types(✅ 2026-06-24 更新):`FileTargetType` 新增 `"homework"` 枚举值,现为 `"exam" | "textbook" | "question" | "announcement" | "homework"`,用于学生答题扫描图附件(targetId = homeworkSubmissions.id);`app/api/upload/route.ts` 的 `VALID_TARGET_TYPES` 同步新增 `"homework"` **依赖关系**: - 依赖:`shared/*`、`@/auth` -- 被依赖:`app/api/upload` / `app/api/files/[id]` / `app/api/files/batch-delete` +- 被依赖:`app/api/upload` / `app/api/files/[id]` / `app/api/files/batch-delete` / `homework`(✅ 2026-06-24 新增:`homework/actions.deleteScanAction` 调用 `deleteFileAttachment` 删除扫描图 DB 记录;`homework/components/scan-uploader` 调用 `/api/upload` 上传扫描图,targetType="homework") **已知问题**: - ✅ P2-13 已修复:~~所有函数 try-catch 吞错误返回空数组/null~~ 所有 catch 块已添加 `console.error` 输出错误上下文 diff --git a/docs/architecture/005_architecture_data.json b/docs/architecture/005_architecture_data.json index d893e7b..ad68be9 100644 --- a/docs/architecture/005_architecture_data.json +++ b/docs/architecture/005_architecture_data.json @@ -1544,6 +1544,23 @@ "audit/components/data-change-log-table.tsx" ] }, + { + "name": "ResizablePanel", + "file": "components/ui/resizable-panel.tsx", + "props": "{ minLeft?: number, minRight?: number, initialLeft?: number, left: ReactNode, right: ReactNode, className?: string }", + "purpose": "2026-06-24 新增:自实现可拖拽分栏容器(左右两栏 + 中间分隔条 pointer 拖拽调整宽度,无新依赖)。用于试卷富文本编辑器左编辑右预览、阅卷式批改左题目右扫描图等场景。pointerdown/move/up 事件驱动,document.body cursor=userSelect=none 防选中,clamp 到 minLeft/minRight 范围。", + "internalDeps": [ + "cn", + "useState", + "useRef", + "useCallback", + "useEffect" + ], + "usedBy": [ + "exams/components/exam-rich-form.tsx", + "homework/components/homework-scan-grading-view.tsx" + ] + }, { "name": "StatusBadge", "file": "components/ui/status-badge.tsx", @@ -2337,7 +2354,8 @@ "createdAt" ], "usedBy": [ - "files" + "files", + "homework" ] }, "gradeRecords": { @@ -2906,6 +2924,44 @@ "usedBy": [ "management/grade/dashboard" ] + }, + { + "name": "autoMarkExamAction", + "permission": "EXAM_AI_GENERATE", + "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", + "purpose": "2026-06-24 新增:AI 自动标记试卷文本。将粘贴的试卷文本交给 AI 解析为带题目块/分组/填空/加点字标记的 Tiptap JSONContent 文档,教师可在富文本编辑器中基于此结果继续微调。返回 { doc, title } 供编辑器载入。", + "deps": [ + "requirePermission", + "shared/lib/ai.createAiChatCompletion", + "shared/lib/action-utils.handleActionError", + "exams/ai-pipeline/parse.parseAiResponse", + "exams/actions.buildTiptapDocFromAiResponse", + "exams/actions.extractTitleFromAiResponse", + "AutoMarkSchema" + ], + "usedBy": [ + "exams/components/exam-rich-form.tsx" + ] + }, + { + "name": "createExamFromRichEditorAction", + "permission": "EXAM_CREATE", + "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", + "purpose": "2026-06-24 新增:从富文本编辑器保存试卷草稿。将 Tiptap JSONContent 通过 editor/editor-to-structure.editorDocToStructure 转换为 questions + structure 后复用 persistAiGeneratedExamDraft 持久化。revalidatePath('/teacher/exams/all')。", + "deps": [ + "requirePermission", + "shared/lib/action-utils.handleActionError", + "shared/lib/action-utils.safeJsonParse", + "exams/editor/editor-to-structure.editorDocToStructure", + "exams/data-access.persistAiGeneratedExamDraft", + "exams/actions.prepareExamCreateContext", + "exams/actions.parseExamModeConfig", + "RichExamCreateSchema", + "revalidatePath" + ], + "usedBy": [ + "exams/components/exam-rich-form.tsx" + ] } ], "dataAccess": [ @@ -3294,6 +3350,37 @@ "grades/components/batch-grade-entry", "teacher/grades/entry/page.tsx" ] + }, + { + "name": "AutoMarkResult", + "type": "interface", + "file": "actions.ts", + "definition": "{ doc: unknown; title: string }", + "purpose": "2026-06-24 新增:AI 自动标记结果。doc 为 Tiptap JSONContent 文档(可直接载入富文本编辑器),title 为解析出的试卷标题(如有)", + "usedBy": [ + "exams/actions.autoMarkExamAction", + "exams/components/exam-rich-form.tsx" + ] + }, + { + "name": "AutoMarkSchema", + "type": "const", + "file": "actions.ts", + "definition": "z.object({ sourceText: z.string().min(1), aiProviderId: z.string().optional() })", + "purpose": "2026-06-24 新增:autoMarkExamAction 的 Zod 输入校验 schema", + "usedBy": [ + "exams/actions.autoMarkExamAction" + ] + }, + { + "name": "RichExamCreateSchema", + "type": "const", + "file": "actions.ts", + "definition": "z.object({ title, subject, grade, difficulty: z.coerce.number().int().min(1).max(5), totalScore: z.coerce.number().int().min(1), durationMin: z.coerce.number().int().min(1), scheduledAt: z.string().optional().nullable(), editorDoc: z.string().min(1) })", + "purpose": "2026-06-24 新增:createExamFromRichEditorAction 的 Zod 输入校验 schema。editorDoc 为 Tiptap JSONContent 的 JSON 字符串", + "usedBy": [ + "exams/actions.createExamFromRichEditorAction" + ] } ], "components": [ @@ -3510,6 +3597,11 @@ "types": [ "ExamAnalyticsSummary" ] + }, + { + "name": "ExamRichForm", + "file": "exam-rich-form.tsx", + "purpose": "2026-06-24 新增:富文本编辑器试卷创建表单(use client)。使用 ResizablePanel 左编辑右预览布局,集成 autoMarkExamAction 一键 AI 标记(粘贴试卷文本 → AI 解析为 Tiptap doc 载入编辑器)+ createExamFromRichEditorAction 保存草稿。调用 getSubjectsAction/getGradesAction 填充科目年级选项。包含 difficulty/totalScore/durationMin/scheduledAt 等基本信息字段。" } ], "hooks": [ @@ -3569,7 +3661,211 @@ "exams/components/exam-analytics-dashboard.tsx" ] } - ] + ], + "editor": { + "_description": "2026-06-24 新增:基于 Tiptap 的试卷富文本编辑器子模块(src/modules/exams/editor/)。提供自定义扩展(题目块/分组/填空/加点字/图片)、Tiptap JSONContent ↔ ExamStructure 双向转换、浮动选择工具栏。Barrel 导出位于 editor/index.ts。", + "components": [ + { + "name": "ExamRichEditor", + "file": "editor/exam-rich-editor.tsx", + "type": "component", + "purpose": "富文本编辑器主体(use client + forwardRef)。基于 Tiptap StarterKit + 自定义扩展(DottedMark/BlankNode/ImageNode/QuestionBlock/GroupBlock)。通过 ExamRichEditorHandle 暴露 getJSON() 供父组件获取 JSONContent。集成 SelectionToolbar 浮动工具栏。", + "usedBy": [ + "exams/components/exam-rich-form.tsx" + ] + }, + { + "name": "ExamRichEditorHandle", + "file": "editor/exam-rich-editor.tsx", + "type": "interface", + "definition": "{ getJSON: () => EditorJSONContent | null }", + "purpose": "ExamRichEditor 的 ref 句柄类型,暴露 getJSON 方法获取当前编辑器内容的 Tiptap JSONContent", + "usedBy": [ + "exams/components/exam-rich-form.tsx" + ] + }, + { + "name": "SelectionToolbar", + "file": "editor/selection-toolbar.tsx", + "type": "component", + "purpose": "浮动选择工具栏(use client)。当编辑器内有文本选中时显示,提供 DottedMark(加点字)等标记的快捷应用按钮。", + "usedBy": [ + "exams/editor/exam-rich-editor.tsx" + ] + } + ], + "extensions": [ + { + "name": "DottedMark", + "file": "editor/extensions/dotted-mark.ts", + "type": "tiptap-extension", + "purpose": "加点字标记扩展(Mark)。用于拼音注音题中加点的字,渲染为带下点样式的 span。", + "usedBy": [ + "exams/editor/exam-rich-editor.tsx", + "exams/editor/selection-toolbar.tsx" + ] + }, + { + "name": "BlankNode", + "file": "editor/extensions/blank-node.tsx", + "type": "tiptap-extension", + "purpose": "填空空位节点扩展(Node,原子节点)。渲染为可填写的空位占位符,含 id/answer/score 属性。", + "usedBy": [ + "exams/editor/exam-rich-editor.tsx" + ] + }, + { + "name": "ImageNode", + "file": "editor/extensions/image-node.ts", + "type": "tiptap-extension", + "purpose": "图片节点扩展(Node)。含 fileId/url/alt 属性,用于在题目中嵌入图片。", + "usedBy": [ + "exams/editor/exam-rich-editor.tsx" + ] + }, + { + "name": "GroupBlock", + "file": "editor/extensions/group-block.tsx", + "type": "tiptap-extension", + "purpose": "大题分组节点扩展(Node,块级)。含 title 属性,用于「一、选择题」「二、填空题」等大题分组。", + "usedBy": [ + "exams/editor/exam-rich-editor.tsx" + ] + }, + { + "name": "QuestionBlock", + "file": "editor/extensions/question-block.tsx", + "type": "tiptap-extension", + "purpose": "题目块节点扩展(Node,块级)。含 type(QuestionBlockType: single_choice|multiple_choice|judgment|text|composite)/score 等 attrs(QuestionBlockAttrs),用于包裹一道题目。", + "usedBy": [ + "exams/editor/exam-rich-editor.tsx" + ] + }, + { + "name": "QuestionBlockType", + "file": "editor/extensions/question-block.tsx", + "type": "type", + "definition": "\"single_choice\" | \"multiple_choice\" | \"judgment\" | \"text\" | \"composite\"", + "purpose": "题目块类型枚举", + "usedBy": [ + "exams/editor/extensions/question-block.tsx" + ] + }, + { + "name": "QuestionBlockAttrs", + "file": "editor/extensions/question-block.tsx", + "type": "interface", + "definition": "{ type: QuestionBlockType; score: number; questionId?: string }", + "purpose": "题目块属性接口", + "usedBy": [ + "exams/editor/extensions/question-block.tsx" + ] + } + ], + "types": [ + { + "name": "RichQuestionType", + "file": "editor/exam-rich-editor-types.ts", + "type": "type", + "definition": "\"single_choice\" | \"multiple_choice\" | \"judgment\" | \"text\" | \"composite\"", + "purpose": "富文本编辑器题目类型枚举", + "usedBy": [ + "exams/editor/*", + "exams/editor/editor-to-structure.ts", + "exams/editor/structure-to-editor.ts" + ] + }, + { + "name": "RichQuestionContent", + "file": "editor/exam-rich-editor-types.ts", + "type": "interface", + "definition": "{ text: string; options?: Array<{ id, text, isCorrect? }>; blanks?: Array<{ id, answer?, score? }>; images?: Array<{ fileId, url, alt? }>; subQuestions?: Array<{ id, text, answer?, score? }>; correctAnswer?: unknown }", + "purpose": "富文本编辑器题目内容结构(题干文本+选项+填空+图片+子题+正确答案)", + "usedBy": [ + "exams/editor/exam-rich-editor-types.ts.EditorQuestion", + "exams/editor/editor-to-structure.ts", + "exams/editor/structure-to-editor.ts" + ] + }, + { + "name": "EditorQuestion", + "file": "editor/exam-rich-editor-types.ts", + "type": "interface", + "definition": "{ id: string; type: RichQuestionType; score: number; content: RichQuestionContent }", + "purpose": "富文本编辑器题目结构", + "usedBy": [ + "exams/editor/exam-rich-editor-types.ts.EditorDoc", + "exams/editor/editor-to-structure.ts", + "exams/editor/structure-to-editor.ts" + ] + }, + { + "name": "EditorStructureNode", + "file": "editor/exam-rich-editor-types.ts", + "type": "interface", + "definition": "{ id: string; type: \"group\" | \"question\"; title?: string; questionId?: string; score?: number; children?: EditorStructureNode[] }", + "purpose": "富文本编辑器结构节点(递归,group|question)", + "usedBy": [ + "exams/editor/exam-rich-editor-types.ts.EditorDoc", + "exams/editor/editor-to-structure.ts", + "exams/editor/structure-to-editor.ts" + ] + }, + { + "name": "EditorDoc", + "file": "editor/exam-rich-editor-types.ts", + "type": "interface", + "definition": "{ title: string; questions: EditorQuestion[]; structure: EditorStructureNode[] }", + "purpose": "富文本编辑器文档结构(标题+题目列表+结构树),由 editorDocToStructure 产出", + "usedBy": [ + "exams/editor/editor-to-structure.ts", + "exams/editor/structure-to-editor.ts", + "exams/components/exam-rich-form.tsx" + ] + }, + { + "name": "EditorJSONContent", + "file": "editor/exam-rich-editor-types.ts", + "type": "type", + "definition": "JSONContent (from @tiptap/react)", + "purpose": "Tiptap JSONContent 别名,用于编辑器与试卷结构互转", + "usedBy": [ + "exams/editor/exam-rich-editor.tsx", + "exams/components/exam-rich-form.tsx" + ] + } + ], + "converters": [ + { + "name": "editorDocToStructure", + "file": "editor/editor-to-structure.ts", + "type": "function", + "signature": "(doc: EditorJSONContent, fallbackTitle: string) => EditorDoc", + "purpose": "将 Tiptap JSONContent 转换为 EditorDoc 结构(title + questions + structure)。遍历 doc.nodes,识别 GroupBlock/QuestionBlock/BlankNode/DottedMark/ImageNode 等自定义节点,提取题目内容与结构树。供 createExamFromRichEditorAction 持久化使用。", + "deps": [ + "@paralleldrive/cuid2.createId", + "exams/editor/exam-rich-editor-types.ts" + ], + "usedBy": [ + "exams/actions.createExamFromRichEditorAction", + "exams/components/exam-rich-form.tsx" + ] + }, + { + "name": "structureToEditorDoc", + "file": "editor/structure-to-editor.ts", + "type": "function", + "signature": "(doc: EditorDoc) => EditorJSONContent", + "purpose": "将 EditorDoc 结构转换回 Tiptap JSONContent,供编辑器回填已保存的试卷内容。", + "deps": [ + "exams/editor/exam-rich-editor-types.ts" + ], + "usedBy": [ + "exams/editor/exam-rich-editor.tsx" + ] + } + ] + } } }, "homework": { @@ -3659,6 +3955,39 @@ "usedBy": [ "homework-batch-grading-view.tsx" ] + }, + { + "name": "getScansAction", + "permission": "HOMEWORK_GRADE | HOMEWORK_SUBMIT", + "signature": "(submissionId: string) => Promise>", + "purpose": "2026-06-24 新增:获取某次提交的所有答题扫描图。扫描图存储在 fileAttachments 表中 targetType=\"homework\"/targetId=submissionId。支持两类访问者:教师 HOMEWORK_GRADE(仅可访问自己创建的作业的提交,通过 getHomeworkSubmissionForGrading 校验 creatorId===ctx.userId 或 dataScope.type===\"all\")/ 学生 HOMEWORK_SUBMIT(仅可访问自己的提交,通过 getHomeworkSubmissionForPermission 校验 studentId===ctx.userId)。按 createdAt asc 排序,page 从 1 开始递增。", + "deps": [ + "requirePermission", + "shared/lib/action-utils.handleActionError", + "data-access-write.getHomeworkSubmissionForGrading", + "data-access-write.getHomeworkSubmissionForPermission", + "shared/db", + "shared/db.schema.fileAttachments", + "drizzle-orm.eq/and/asc" + ], + "usedBy": [ + "homework/components/homework-scan-grading-view.tsx" + ] + }, + { + "name": "deleteScanAction", + "permission": "HOMEWORK_SUBMIT", + "signature": "(submissionId: string, fileId: string) => Promise>", + "purpose": "2026-06-24 新增:删除答题扫描图。仅允许提交者本人删除(通过 getHomeworkSubmissionForPermission 校验 studentId===ctx.userId),且仅在提交状态为 started 时允许(已提交/已批改的提交锁定不可修改)。调用 files/data-access.deleteFileAttachment 删除 DB 记录(磁盘文件由 files 模块处理)。", + "deps": [ + "requirePermission", + "shared/lib/action-utils.handleActionError", + "data-access-write.getHomeworkSubmissionForPermission", + "files/data-access.deleteFileAttachment" + ], + "usedBy": [ + "homework/components/scan-uploader.tsx" + ] } ], "dataAccess": [ @@ -4153,6 +4482,29 @@ "usedBy": [ "student/dashboard" ] + }, + { + "name": "ScanAttachment", + "type": "interface", + "file": "actions.ts", + "definition": "{ fileId: string; url: string; filename: string; originalName: string; page: number }", + "purpose": "2026-06-24 新增:扫描图附件结构(由 getScansAction 返回)。page 为页码(按 createdAt asc 排序,从 1 开始递增)。", + "usedBy": [ + "homework/actions.getScansAction", + "homework/components/homework-scan-grading-view.tsx" + ] + }, + { + "name": "ScanImage", + "type": "interface", + "file": "components/scan-uploader.tsx", + "definition": "{ fileId: string; url: string; filename: string; originalName?: string; page: number }", + "purpose": "2026-06-24 新增:扫描图 UI 结构(由 ScanUploader 导出,供 ScanImageViewer 复用)。与 ScanAttachment 字段一致,但 originalName 可选。", + "usedBy": [ + "homework/components/scan-uploader.tsx", + "homework/components/scan-image-viewer.tsx", + "homework/components/homework-scan-grading-view.tsx" + ] } ], "components": [ @@ -4215,6 +4567,62 @@ "name": "HomeworkSubmissionResult", "file": "homework-submission-result.tsx", "purpose": "V3-9 新增:提交后即时反馈页。学生提交后立即看到分数汇总(总分/满分、得分率 Progress)、对错分布(正确/错误/部分正确/待批改)、错题预览(题目文本、学生答案、正确答案)。对标智学网/猿题库提交后反馈。" + }, + { + "name": "ScanUploader", + "file": "scan-uploader.tsx", + "purpose": "2026-06-24 新增:学生扫描图上传组件(use client)。学生在纸上作答后按页拍摄上传,调用 /api/upload 上传图片(targetType=\"homework\"/targetId=submissionId),返回 fileId+url。上传后将 fileId 列表通过 onChange 暴露给父组件。支持增删/排序,onDeleteScan 回调用于删除 fileAttachments 记录(调用 deleteScanAction)。已提交(disabled)时禁止操作。导出 ScanImage 类型供 ScanImageViewer 复用。", + "deps": [ + "shared/components/ui/button", + "shared/lib/utils.cn", + "next-intl.useTranslations", + "sonner.toast", + "lucide-react (Upload/X/Loader2/ImageIcon)" + ], + "usedBy": [ + "homework/components/homework-scan-grading-view.tsx" + ] + }, + { + "name": "ScanImageViewer", + "file": "scan-image-viewer.tsx", + "purpose": "2026-06-24 新增:扫描图查看器(use client)。用于阅卷式批改时查看学生答题图片。支持翻页(上一页/下一页+页码)、缩放(ZoomIn/ZoomOut,0.25 步进,1-3 范围)、旋转(RotateCw,90° 步进)、全屏(Maximize2)。切换页面时重置缩放与旋转。", + "deps": [ + "shared/components/ui/button", + "shared/lib/utils.cn", + "lucide-react (ChevronLeft/ChevronRight/ZoomIn/ZoomOut/Maximize2/RotateCw)", + "homework/components/scan-uploader.ScanImage" + ], + "usedBy": [ + "homework/components/homework-scan-grading-view.tsx" + ] + }, + { + "name": "HomeworkScanGradingView", + "file": "homework-scan-grading-view.tsx", + "purpose": "2026-06-24 新增:教师阅卷式批改视图(use client)。使用 ResizablePanel 左侧题目+批改表单、右侧 ScanImageViewer 扫描图查看器。集成 getScansAction 拉取扫描图、gradeHomeworkSubmissionAction 批改。支持上一份/下一份提交快速切换(prevSubmissionId/nextSubmissionId)。显示学生姓名、作业标题、提交时间、状态、总分。左侧每题显示题干、学生答案、得分输入、反馈输入。", + "deps": [ + "shared/components/ui/button", + "shared/components/ui/card", + "shared/components/ui/input", + "shared/components/ui/label", + "shared/components/ui/textarea", + "shared/components/ui/badge", + "shared/components/ui/scroll-area", + "shared/components/ui/resizable-panel.ResizablePanel", + "shared/lib/utils.formatDate", + "shared/lib/utils.cn", + "next-intl.useTranslations", + "sonner.toast", + "homework/actions.gradeHomeworkSubmissionAction", + "homework/actions.getScansAction", + "homework/components/question-renderer.QuestionRenderer", + "homework/components/scan-image-viewer.ScanImageViewer", + "lucide-react (Save/ChevronLeft/ChevronRight/User/Clock)" + ], + "usedBy": [ + "app/(dashboard)/teacher/homework/submissions/[submissionId]/scan-grading/page.tsx" + ] } ], "hooks": [ @@ -9055,10 +9463,15 @@ "name": "FileTargetType", "type": "type", "file": "types.ts", - "definition": "\"exam\" | \"textbook\" | \"question\" | \"announcement\"", + "definition": "\"exam\" | \"textbook\" | \"question\" | \"announcement\" | \"homework\"", + "purpose": "文件附件关联资源类型枚举。2026-06-24 新增 \"homework\" 枚举值,用于学生答题扫描图附件(targetId = homeworkSubmissions.id)", "usedBy": [ "types.FileAttachment.targetType", - "file-upload.tsx" + "file-upload.tsx", + "app/api/upload/route.ts (VALID_TARGET_TYPES)", + "homework/actions.getScansAction", + "homework/actions.deleteScanAction", + "homework/components/scan-uploader.tsx" ] }, { @@ -16352,7 +16765,8 @@ "exams", "classes", "school", - "users" + "users", + "files" ], "uses": { "shared": [ @@ -16384,6 +16798,9 @@ "users": [ "data-access.getUserWithRole", "data-access.getUserNamesByIds" + ], + "files": [ + "data-access.deleteFileAttachment" ] } }, @@ -18514,6 +18931,19 @@ ], "permission": "exam:create" }, + "/teacher/exams/new": { + "component": "NewExamPage + ExamRichForm", + "type": "server", + "module": "exams", + "actions": [ + "autoMarkExamAction", + "createExamFromRichEditorAction", + "getSubjectsAction", + "getGradesAction" + ], + "permission": "exam:create", + "description": "2026-06-24 新增:富文本编辑器试卷创建页面。基于 Tiptap 的所见即所得试卷编辑器,支持 AI 自动标记(粘贴试卷文本 → AI 解析为 Tiptap doc)、手动编辑题目块/分组/填空/加点字/图片、左编辑右预览分栏布局(ResizablePanel)。保存时将 Tiptap JSONContent 转换为 questions + structure 后持久化为试卷草稿。" + }, "/teacher/questions": { "component": "QuestionDataTable", "type": "server", @@ -18645,6 +19075,20 @@ ], "permission": "homework:grade" }, + "/teacher/homework/submissions/[submissionId]/scan-grading": { + "component": "HomeworkScanGradingPage + HomeworkScanGradingView", + "type": "server", + "module": "homework", + "actions": [ + "gradeHomeworkSubmissionAction", + "getScansAction" + ], + "dataAccess": [ + "homework/data-access.getHomeworkSubmissionDetails" + ], + "permission": "homework:grade", + "description": "2026-06-24 新增:教师阅卷式批改页面。左侧显示题目+批改表单,右侧显示学生答题扫描图(ScanImageViewer,支持翻页/缩放/旋转/全屏)。使用 ResizablePanel 可拖拽分栏。支持上一份/下一份提交快速切换。适用于学生纸上作答后上传扫描图的场景。" + }, "/teacher/exams": { "component": "重定向", "type": "server", diff --git a/src/app/(dashboard)/teacher/exams/new/page.tsx b/src/app/(dashboard)/teacher/exams/new/page.tsx new file mode 100644 index 0000000..764cd47 --- /dev/null +++ b/src/app/(dashboard)/teacher/exams/new/page.tsx @@ -0,0 +1,18 @@ +import type { JSX } from "react" +import { getTranslations } from "next-intl/server" +import { ExamRichForm } from "@/modules/exams/components/exam-rich-form" + +export const dynamic = "force-dynamic" + +export default async function NewExamPage(): Promise { + const t = await getTranslations("examHomework") + return ( +
+
+

{t("exam.richEditor.title")}

+

{t("exam.richEditor.description")}

+
+ +
+ ) +} diff --git a/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/page.tsx b/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/page.tsx index a57465a..564cee2 100644 --- a/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/page.tsx +++ b/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/page.tsx @@ -1,7 +1,11 @@ import type { JSX } from "react" +import Link from "next/link" import { notFound } from "next/navigation" +import { getTranslations } from "next-intl/server" import { getHomeworkSubmissionDetails } from "@/modules/homework/data-access" import { HomeworkGradingView } from "@/modules/homework/components/homework-grading-view" +import { Button } from "@/shared/components/ui/button" +import { ScanLine } from "lucide-react" import { formatDate } from "@/shared/lib/utils" import { AiClientProvider, @@ -37,6 +41,7 @@ function createAiClientService(): AiClientService { export default async function HomeworkSubmissionGradingPage({ params }: { params: Promise<{ submissionId: string }> }): Promise { const { submissionId } = await params + const t = await getTranslations("examHomework") const submission = await getHomeworkSubmissionDetails(submissionId) if (!submission) return notFound() @@ -58,6 +63,12 @@ export default async function HomeworkSubmissionGradingPage({ params }: { params Status: {submission.status} + diff --git a/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/scan-grading/page.tsx b/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/scan-grading/page.tsx new file mode 100644 index 0000000..6afd7a8 --- /dev/null +++ b/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/scan-grading/page.tsx @@ -0,0 +1,34 @@ +import type { JSX } from "react" +import { notFound } from "next/navigation" + +import { getHomeworkSubmissionDetails } from "@/modules/homework/data-access" +import { HomeworkScanGradingView } from "@/modules/homework/components/homework-scan-grading-view" + +export const dynamic = "force-dynamic" + +export default async function HomeworkScanGradingPage({ + params, +}: { + params: Promise<{ submissionId: string }> +}): Promise { + const { submissionId } = await params + const submission = await getHomeworkSubmissionDetails(submissionId) + + if (!submission) return notFound() + + return ( +
+ +
+ ) +} diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index e043580..eaf708a 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -20,6 +20,7 @@ const VALID_TARGET_TYPES: FileTargetType[] = [ "textbook", "question", "announcement", + "homework", ] const isTargetType = (v: string | null): v is FileTargetType => diff --git a/src/modules/exams/actions.ts b/src/modules/exams/actions.ts index 8a2b79c..019482a 100644 --- a/src/modules/exams/actions.ts +++ b/src/modules/exams/actions.ts @@ -878,4 +878,291 @@ export async function getExamsByGradeIdAction( } } +// --------------------------------------------------------------------------- +// 富文本编辑器:AI 自动标记 + 保存草稿 +// --------------------------------------------------------------------------- + +const AutoMarkSchema = z.object({ + sourceText: z.string().min(1, "试卷文本不能为空"), + aiProviderId: z.string().optional(), +}) + +export interface AutoMarkResult { + /** Tiptap JSONContent 文档,可直接载入编辑器 */ + doc: unknown + /** 解析出的标题(如有) */ + title: string +} + +/** + * AI 自动标记 —— 将粘贴的试卷文本交给 AI,返回带题目块/分组/填空/加点字标记的 Tiptap JSONContent 文档。 + * 教师可在编辑器中基于此结果继续微调。 + */ +export async function autoMarkExamAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + await requirePermission(Permissions.EXAM_AI_GENERATE) + + const parsed = AutoMarkSchema.safeParse({ + sourceText: getStringValue(formData, "sourceText"), + aiProviderId: getStringValue(formData, "aiProviderId") || undefined, + }) + if (!parsed.success) { + return invalidFormState(parsed.error, { useFirstMessage: true }) + } + + const { sourceText, aiProviderId } = parsed.data + + const systemPrompt = [ + "你是一个试卷结构解析引擎。", + "将给定的试卷文本解析为结构化 JSON,用于在富文本编辑器中渲染为可编辑的题目块。", + "识别以下元素:", + "1. 大题分组(如\"一、选择题\"\"二、填空题\"),输出到 sections", + "2. 每道题目,标注 type(single_choice/multiple_choice/judgment/text/composite)和 score", + "3. 选项列表(A. B. C. D. 等),输出到 content.options", + "4. 填空位置(如\"_______\"或横线空),在 content.blanks 中标记", + "5. 加点字(拼音注音题中加点的字),在 content.dottedTexts 中列出加点的原文片段", + "6. 子题(如\"1. 2. 3.\"小题),输出到 content.subQuestions", + "输出 JSON,不要输出 markdown 代码块。", + "输出 schema:", + "{", + ' "title": "试卷标题(可选)",', + ' "sections": [', + ' { "title": "一、选择题", "questions": [', + ' { "type": "single_choice", "score": 2, "content": { "text": "题干文本", "options": [{"id":"A","text":"选项A"}], "blanks": [], "dottedTexts": [], "subQuestions": [] } }', + " ] }", + " ]", + "}", + "如果没有 sections,可直接返回 { \"questions\": [...] }", + "不要输出 ... 或 [...] 等占位符。", + ].join("\n") + + const { createAiChatCompletion } = await import("@/shared/lib/ai") + const { env } = await import("@/env.mjs") + const { parseAiResponse } = await import("./ai-pipeline/parse") + + const aiResult = await createAiChatCompletion({ + model: String(env.AI_MODEL ?? "gpt-4o-mini"), + providerId: aiProviderId, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: sourceText }, + ], + temperature: 0, + maxTokens: 8000, + }) + + const parsedJson = await parseAiResponse(aiResult.content, aiProviderId) + const doc = buildTiptapDocFromAiResponse(parsedJson) + const title = extractTitleFromAiResponse(parsedJson) + + return successState({ doc, title }, "AI 自动标记完成") + } catch (error) { + if (error instanceof PermissionDeniedError) { + return failState(error.message) + } + console.error("[autoMarkExamAction]", error instanceof Error ? error.message : String(error)) + return handleActionError(error) + } +} + +const isRecord = (v: unknown): v is Record => + typeof v === "object" && v !== null + +const extractTitleFromAiResponse = (data: unknown): string => { + if (!isRecord(data)) return "" + return typeof data.title === "string" ? data.title : "" +} + +/** + * 将 AI 返回的结构化 JSON 转换为 Tiptap JSONContent 文档。 + * 支持 sections(分组)和顶层 questions 两种形式。 + */ +const buildTiptapDocFromAiResponse = (data: unknown): unknown => { + if (!isRecord(data)) return { type: "doc", content: [] } + + const content: unknown[] = [] + const sections = Array.isArray(data.sections) ? data.sections : [] + const topQuestions = Array.isArray(data.questions) ? data.questions : [] + + const buildQuestionBlock = (q: unknown): unknown | null => { + if (!isRecord(q)) return null + const type = typeof q.type === "string" ? q.type : "text" + const score = typeof q.score === "number" ? q.score : 0 + const contentNode = isRecord(q.content) ? q.content : {} + const text = typeof contentNode.text === "string" ? contentNode.text : "" + + const inner: unknown[] = [] + // 题干段落(按行拆分) + const lines = text.split("\n").filter((l) => l.trim().length > 0) + for (const line of lines) { + inner.push({ type: "paragraph", content: [{ type: "text", text: line }] }) + } + + // 选项列表 + const options = Array.isArray(contentNode.options) ? contentNode.options : [] + if (options.length > 0) { + inner.push({ + type: "orderedList", + content: options.map((opt) => { + const o = isRecord(opt) ? opt : {} + const id = typeof o.id === "string" ? o.id : "" + const optText = typeof o.text === "string" ? o.text : "" + return { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: `${id}. ${optText}` }], + }, + ], + } + }), + }) + } + + return { + type: "questionBlock", + attrs: { questionId: "", type, score }, + content: inner, + } + } + + for (const section of sections) { + if (!isRecord(section)) continue + const title = typeof section.title === "string" ? section.title : "" + const questions = Array.isArray(section.questions) ? section.questions : [] + const children = questions + .map(buildQuestionBlock) + .filter((b): b is Record => b !== null) + content.push({ + type: "groupBlock", + attrs: { title }, + content: children, + }) + } + + if (sections.length === 0) { + for (const q of topQuestions) { + const block = buildQuestionBlock(q) + if (block) content.push(block) + } + } + + return { type: "doc", content } +} + +const RichExamCreateSchema = z.object({ + title: z.string().min(1, "标题不能为空"), + subject: z.string().min(1), + grade: z.string().min(1), + difficulty: z.coerce.number().int().min(1).max(5), + totalScore: z.coerce.number().int().min(1), + durationMin: z.coerce.number().int().min(1), + scheduledAt: z.string().optional().nullable(), + /** Tiptap JSONContent 文档(JSON 字符串) */ + editorDoc: z.string().min(1, "试卷内容不能为空"), +}) + +/** + * 从富文本编辑器保存试卷草稿。 + * 将 Tiptap JSONContent 转换为 questions + structure 后持久化。 + */ +export async function createExamFromRichEditorAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.EXAM_CREATE) + + const parsed = RichExamCreateSchema.safeParse({ + title: getStringValue(formData, "title"), + subject: getStringValue(formData, "subject"), + grade: getStringValue(formData, "grade"), + difficulty: getStringValue(formData, "difficulty"), + totalScore: getStringValue(formData, "totalScore"), + durationMin: getStringValue(formData, "durationMin"), + scheduledAt: getStringValue(formData, "scheduledAt") ?? null, + editorDoc: getStringValue(formData, "editorDoc"), + }) + if (!parsed.success) { + return invalidFormState(parsed.error, { useFirstMessage: true }) + } + + const input = parsed.data + const editorDoc = safeJsonParse(input.editorDoc, "试卷内容格式无效") + if (!editorDoc) { + return failState("试卷内容解析失败") + } + + const context = await prepareExamCreateContext({ + subject: input.subject, + grade: input.grade, + difficulty: input.difficulty, + totalScore: input.totalScore, + durationMin: input.durationMin, + scheduledAt: input.scheduledAt, + }) + + // 将 Tiptap doc 转换为 EditorDoc 结构 + const { editorDocToStructure } = await import("./editor/editor-to-structure") + const structure = editorDocToStructure(editorDoc as never, input.title) + + // 转换为 AI 生成格式以复用 persistAiGeneratedExamDraft + const generated = structure.questions.map((q) => ({ + id: q.id, + type: q.type as "single_choice" | "multiple_choice" | "text" | "judgment", + difficulty: input.difficulty, + score: q.score, + content: q.content as never, + })) + + const aiStructure = structure.structure.map((node) => { + if (node.type === "group") { + return { + id: node.id, + type: "group" as const, + title: node.title ?? "", + children: (node.children ?? []).map((c) => ({ + id: c.id, + type: "question" as const, + questionId: c.questionId ?? "", + score: c.score ?? 0, + })), + } + } + return { + id: node.id, + type: "question" as const, + questionId: node.questionId ?? "", + score: node.score ?? 0, + } + }) + + await persistAiGeneratedExamDraft({ + examId: context.examId, + title: input.title, + creatorId: ctx.userId, + subjectId: input.subject, + gradeId: input.grade, + scheduledAt: context.scheduled, + description: context.buildDescription(), + structure: aiStructure, + generated, + examModeConfig: parseExamModeConfig(formData), + }) + + revalidatePath("/teacher/exams/all") + return successState(context.examId, "试卷草稿已创建") + } catch (error) { + if (error instanceof PermissionDeniedError) { + return failState(error.message) + } + console.error("[createExamFromRichEditorAction]", error instanceof Error ? error.message : String(error)) + return handleActionError(error) + } +} + diff --git a/src/modules/exams/components/exam-rich-form.tsx b/src/modules/exams/components/exam-rich-form.tsx new file mode 100644 index 0000000..cf1a30b --- /dev/null +++ b/src/modules/exams/components/exam-rich-form.tsx @@ -0,0 +1,431 @@ +"use client" + +import { useEffect, useRef, useState, useTransition, type FormEvent } from "react" +import { useRouter } from "next/navigation" +import { useTranslations } from "next-intl" +import { toast } from "sonner" +import { Sparkles, Save, FileText } from "lucide-react" + +import { Button } from "@/shared/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select" +import { ResizablePanel } from "@/shared/components/ui/resizable-panel" +import { ScrollArea } from "@/shared/components/ui/scroll-area" +import { cn } from "@/shared/lib/utils" +import { + autoMarkExamAction, + createExamFromRichEditorAction, + getGradesAction, + getSubjectsAction, +} from "../actions" +import { + ExamRichEditor, + type ExamRichEditorHandle, + type EditorJSONContent, + editorDocToStructure, + type EditorDoc, +} from "../editor" + +const DIFFICULTY_OPTIONS = [ + { value: "1", label: "Level 1 (Easy)" }, + { value: "2", label: "Level 2" }, + { value: "3", label: "Level 3 (Medium)" }, + { value: "4", label: "Level 4" }, + { value: "5", label: "Level 5 (Hard)" }, +] + +interface ExamRichFormValues { + title: string + subject: string + grade: string + difficulty: string + totalScore: number + durationMin: number + scheduledAt: string +} + +export function ExamRichForm() { + const router = useRouter() + const t = useTranslations("examHomework") + const editorRef = useRef(null) + const [isPending, startTransition] = useTransition() + const [isMarking, startMarking] = useTransition() + + const [subjects, setSubjects] = useState<{ id: string; name: string }[]>([]) + const [loadingSubjects, setLoadingSubjects] = useState(true) + const [grades, setGrades] = useState<{ id: string; name: string }[]>([]) + const [loadingGrades, setLoadingGrades] = useState(true) + + const [values, setValues] = useState({ + title: "", + subject: "", + grade: "", + difficulty: "3", + totalScore: 100, + durationMin: 90, + scheduledAt: "", + }) + + const [sourceText, setSourceText] = useState("") + const [editorDoc, setEditorDoc] = useState(null) + + useEffect(() => { + const fetchMetadata = async () => { + try { + const [subjectsResult, gradesResult] = await Promise.all([ + getSubjectsAction(), + getGradesAction(), + ]) + if (subjectsResult.success && subjectsResult.data) { + const data = subjectsResult.data + setSubjects(data) + if (data.length > 0) { + setValues((v) => ({ ...v, subject: data[0].id })) + } + } + if (gradesResult.success && gradesResult.data) { + const data = gradesResult.data + setGrades(data) + if (data.length > 0) { + setValues((v) => ({ ...v, grade: data[0].id })) + } + } + } catch (e) { + console.error("[ExamRichForm]", e) + toast.error(t("exam.richEditor.loadFormFailed")) + } finally { + setLoadingSubjects(false) + setLoadingGrades(false) + } + } + void fetchMetadata() + }, [t]) + + const handleAutoMark = () => { + if (!sourceText.trim()) { + toast.error(t("exam.richEditor.pasteSourceFirst")) + return + } + startMarking(async () => { + const formData = new FormData() + formData.append("sourceText", sourceText) + const result = await autoMarkExamAction(null, formData) + if (result.success && result.data) { + const doc = result.data.doc as EditorJSONContent + editorRef.current?.setJSON(doc) + setEditorDoc(doc) + if (result.data.title) { + setValues((v) => ({ ...v, title: result.data!.title })) + } + toast.success(result.message || t("exam.richEditor.aiMarkSuccess")) + } else { + toast.error(result.message || t("exam.richEditor.aiMarkFailed")) + } + }) + } + + const handleSubmit = (e: FormEvent) => { + e.preventDefault() + const doc = editorRef.current?.getJSON() + if (!doc) { + toast.error(t("exam.richEditor.emptyContent")) + return + } + if (!values.title.trim()) { + toast.error(t("exam.richEditor.titleRequired")) + return + } + if (!values.subject || !values.grade) { + toast.error(t("exam.richEditor.subjectGradeRequired")) + return + } + + startTransition(async () => { + const formData = new FormData() + formData.append("title", values.title) + formData.append("subject", values.subject) + formData.append("grade", values.grade) + formData.append("difficulty", values.difficulty) + formData.append("totalScore", String(values.totalScore)) + formData.append("durationMin", String(values.durationMin)) + formData.append("scheduledAt", values.scheduledAt) + formData.append("editorDoc", JSON.stringify(doc)) + formData.append("examMode", "homework") + + const result = await createExamFromRichEditorAction(null, formData) + if (result.success && result.data) { + toast.success(result.message || t("exam.richEditor.saveSuccess")) + router.push(`/teacher/exams/${result.data}/build`) + } else { + toast.error(result.message || t("exam.richEditor.saveFailed")) + } + }) + } + + const handleEditorChange = (doc: EditorJSONContent) => { + setEditorDoc(doc) + } + + const previewStructure = editorDoc + ? editorDocToStructure(editorDoc, values.title) + : null + + return ( +
+ + + {t("exam.richEditor.basicInfo")} + + +
+ + setValues((v) => ({ ...v, title: e.target.value }))} + /> +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + setValues((v) => ({ ...v, totalScore: Number(e.target.value) || 0 }))} + /> +
+
+ + setValues((v) => ({ ...v, durationMin: Number(e.target.value) || 0 }))} + /> +
+
+
+
+ + + + + {t("exam.richEditor.editorArea")} + + + + +
+ +