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
This commit is contained in:
@@ -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` 输出错误上下文
|
||||
|
||||
@@ -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<AutoMarkResult> | null, formData: FormData) => Promise<ActionState<AutoMarkResult>>",
|
||||
"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<string> | null, formData: FormData) => Promise<ActionState<string>>",
|
||||
"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<ActionState<ScanAttachment[]>>",
|
||||
"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<ActionState<null>>",
|
||||
"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",
|
||||
|
||||
18
src/app/(dashboard)/teacher/exams/new/page.tsx
Normal file
18
src/app/(dashboard)/teacher/exams/new/page.tsx
Normal file
@@ -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<JSX.Element> {
|
||||
const t = await getTranslations("examHomework")
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1400px] space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("exam.richEditor.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("exam.richEditor.description")}</p>
|
||||
</div>
|
||||
<ExamRichForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<JSX.Element> {
|
||||
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
|
||||
<span className="capitalize">Status: {submission.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/teacher/homework/submissions/${submissionId}/scan-grading`}>
|
||||
<ScanLine className="mr-2 h-4 w-4" />
|
||||
{t("homework.grade.scanGrading")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AiClientProvider service={aiClientService}>
|
||||
|
||||
@@ -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<JSX.Element> {
|
||||
const { submissionId } = await params
|
||||
const submission = await getHomeworkSubmissionDetails(submissionId)
|
||||
|
||||
if (!submission) return notFound()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-6">
|
||||
<HomeworkScanGradingView
|
||||
submissionId={submission.id}
|
||||
studentName={submission.studentName}
|
||||
assignmentTitle={submission.assignmentTitle}
|
||||
submittedAt={submission.submittedAt}
|
||||
status={submission.status}
|
||||
totalScore={submission.totalScore}
|
||||
answers={submission.answers}
|
||||
prevSubmissionId={submission.prevSubmissionId}
|
||||
nextSubmissionId={submission.nextSubmissionId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const VALID_TARGET_TYPES: FileTargetType[] = [
|
||||
"textbook",
|
||||
"question",
|
||||
"announcement",
|
||||
"homework",
|
||||
]
|
||||
|
||||
const isTargetType = (v: string | null): v is FileTargetType =>
|
||||
|
||||
@@ -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<AutoMarkResult> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<AutoMarkResult>> {
|
||||
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<AutoMarkResult>(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<AutoMarkResult>(error.message)
|
||||
}
|
||||
console.error("[autoMarkExamAction]", error instanceof Error ? error.message : String(error))
|
||||
return handleActionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
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<string, unknown> => 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<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
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<string>(parsed.error, { useFirstMessage: true })
|
||||
}
|
||||
|
||||
const input = parsed.data
|
||||
const editorDoc = safeJsonParse(input.editorDoc, "试卷内容格式无效")
|
||||
if (!editorDoc) {
|
||||
return failState<string>("试卷内容解析失败")
|
||||
}
|
||||
|
||||
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<string>(error.message)
|
||||
}
|
||||
console.error("[createExamFromRichEditorAction]", error instanceof Error ? error.message : String(error))
|
||||
return handleActionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
431
src/modules/exams/components/exam-rich-form.tsx
Normal file
431
src/modules/exams/components/exam-rich-form.tsx
Normal file
@@ -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<ExamRichEditorHandle>(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<ExamRichFormValues>({
|
||||
title: "",
|
||||
subject: "",
|
||||
grade: "",
|
||||
difficulty: "3",
|
||||
totalScore: 100,
|
||||
durationMin: 90,
|
||||
scheduledAt: "",
|
||||
})
|
||||
|
||||
const [sourceText, setSourceText] = useState("")
|
||||
const [editorDoc, setEditorDoc] = useState<EditorJSONContent | null>(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 (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("exam.richEditor.basicInfo")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title">{t("exam.form.title")}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="例如:2024-2025学年度下期期末学业质量监测"
|
||||
value={values.title}
|
||||
onChange={(e) => setValues((v) => ({ ...v, title: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("exam.form.subject")}</Label>
|
||||
<Select
|
||||
value={values.subject}
|
||||
onValueChange={(val) => setValues((v) => ({ ...v, subject: val }))}
|
||||
disabled={loadingSubjects}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingSubjects ? t("exam.richEditor.loadingScans") : t("exam.form.subject")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{subjects.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("exam.form.grade")}</Label>
|
||||
<Select
|
||||
value={values.grade}
|
||||
onValueChange={(val) => setValues((v) => ({ ...v, grade: val }))}
|
||||
disabled={loadingGrades}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingGrades ? t("exam.richEditor.loadingScans") : t("exam.form.grade")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{grades.map((g) => (
|
||||
<SelectItem key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("exam.form.difficulty")}</Label>
|
||||
<Select
|
||||
value={values.difficulty}
|
||||
onValueChange={(val) => setValues((v) => ({ ...v, difficulty: val }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("exam.form.difficulty")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DIFFICULTY_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="totalScore">{t("exam.form.totalScore")}</Label>
|
||||
<Input
|
||||
id="totalScore"
|
||||
type="number"
|
||||
value={String(values.totalScore)}
|
||||
onChange={(e) => setValues((v) => ({ ...v, totalScore: Number(e.target.value) || 0 }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="durationMin">{t("exam.form.durationMin")}</Label>
|
||||
<Input
|
||||
id="durationMin"
|
||||
type="number"
|
||||
value={String(values.durationMin)}
|
||||
onChange={(e) => setValues((v) => ({ ...v, durationMin: Number(e.target.value) || 0 }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>{t("exam.richEditor.editorArea")}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAutoMark}
|
||||
disabled={isMarking || !sourceText.trim()}
|
||||
className="gap-2"
|
||||
title={t("exam.richEditor.aiMarkHint")}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{isMarking ? t("exam.richEditor.aiMarking") : t("exam.richEditor.aiAutoMark")}
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="sourceText">{t("exam.richEditor.sourceText")}</Label>
|
||||
<textarea
|
||||
id="sourceText"
|
||||
value={sourceText}
|
||||
onChange={(e) => setSourceText(e.target.value)}
|
||||
placeholder={t("exam.richEditor.sourceTextPlaceholder")}
|
||||
className="mt-1.5 min-h-[120px] w-full rounded-md border bg-background p-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-[600px] rounded-md border">
|
||||
<ResizablePanel
|
||||
initialLeft={60}
|
||||
minLeft={30}
|
||||
minRight={25}
|
||||
left={
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
{t("exam.richEditor.editorArea")}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<ExamRichEditor
|
||||
ref={editorRef}
|
||||
onChange={handleEditorChange}
|
||||
className="h-full rounded-none border-0 shadow-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
{t("exam.richEditor.previewArea")}
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4">
|
||||
{previewStructure && previewStructure.questions.length > 0 ? (
|
||||
<ExamPreview structure={previewStructure} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center py-20 text-center text-sm text-muted-foreground">
|
||||
<div>
|
||||
<FileText className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
{t("exam.richEditor.emptyPreview")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
{t("exam.actions.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending} className="gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
{isPending ? t("exam.richEditor.saving") : t("exam.richEditor.saveDraft")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function ExamPreview({ structure }: { structure: EditorDoc }) {
|
||||
const counter = { value: 0 }
|
||||
const renderNode = (
|
||||
node: EditorDoc["structure"][number],
|
||||
depth: number = 0
|
||||
): React.ReactNode => {
|
||||
if (node.type === "group") {
|
||||
return (
|
||||
<div key={node.id} className="mb-4">
|
||||
<h3
|
||||
className={cn(
|
||||
"font-semibold text-foreground/90",
|
||||
depth === 0 ? "text-base" : "text-sm"
|
||||
)}
|
||||
>
|
||||
{node.title || "大题"}
|
||||
</h3>
|
||||
<div className="mt-2 space-y-2">
|
||||
{(node.children ?? []).map((child) => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const question = structure.questions.find((q) => q.id === node.questionId)
|
||||
if (!question) return null
|
||||
counter.value += 1
|
||||
return (
|
||||
<div key={node.id} className="flex gap-2">
|
||||
<span className="min-w-[28px] font-semibold text-foreground">
|
||||
{counter.value}.
|
||||
</span>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
|
||||
{question.content.text || "未命名题目"}
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({question.score}分)
|
||||
</span>
|
||||
</div>
|
||||
{question.content.options && question.content.options.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{question.content.options.map((opt) => (
|
||||
<div
|
||||
key={opt.id}
|
||||
className="text-sm text-foreground/80 flex gap-2"
|
||||
>
|
||||
<span className="min-w-[20px]">{opt.id}.</span>
|
||||
<span>{opt.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{structure.structure.map((node) => renderNode(node))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
155
src/modules/exams/editor/editor-to-structure.ts
Normal file
155
src/modules/exams/editor/editor-to-structure.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import type { JSONContent } from "@tiptap/react"
|
||||
import type {
|
||||
EditorDoc,
|
||||
EditorQuestion,
|
||||
EditorStructureNode,
|
||||
RichQuestionContent,
|
||||
RichQuestionType,
|
||||
} from "./exam-rich-editor-types"
|
||||
|
||||
const extractText = (node: JSONContent | undefined): string => {
|
||||
if (!node) return ""
|
||||
if (node.type === "text") return node.text ?? ""
|
||||
if (Array.isArray(node.content)) return node.content.map(extractText).join("")
|
||||
return ""
|
||||
}
|
||||
|
||||
const collectBlanks = (nodes: JSONContent[]): Array<{ id: string }> => {
|
||||
const blanks: Array<{ id: string }> = []
|
||||
let i = 0
|
||||
const walk = (n: JSONContent) => {
|
||||
if (n.type === "blank") {
|
||||
i += 1
|
||||
blanks.push({ id: String(i) })
|
||||
}
|
||||
if (Array.isArray(n.content)) n.content.forEach(walk)
|
||||
}
|
||||
nodes.forEach(walk)
|
||||
return blanks
|
||||
}
|
||||
|
||||
const collectImages = (
|
||||
nodes: JSONContent[]
|
||||
): Array<{ fileId: string; url: string; alt?: string }> => {
|
||||
const imgs: Array<{ fileId: string; url: string; alt?: string }> = []
|
||||
const walk = (n: JSONContent) => {
|
||||
if (n.type === "image" && n.attrs) {
|
||||
const fileId =
|
||||
typeof n.attrs.fileId === "string" ? n.attrs.fileId : ""
|
||||
const url = typeof n.attrs.src === "string" ? n.attrs.src : ""
|
||||
if (fileId && url)
|
||||
imgs.push({
|
||||
fileId,
|
||||
url,
|
||||
alt: typeof n.attrs.alt === "string" ? n.attrs.alt : undefined,
|
||||
})
|
||||
}
|
||||
if (Array.isArray(n.content)) n.content.forEach(walk)
|
||||
}
|
||||
nodes.forEach(walk)
|
||||
return imgs
|
||||
}
|
||||
|
||||
const parseOptions = (
|
||||
nodes: JSONContent[]
|
||||
): Array<{ id: string; text: string; isCorrect?: boolean }> => {
|
||||
const options: Array<{ id: string; text: string; isCorrect?: boolean }> = []
|
||||
for (const n of nodes) {
|
||||
if (n.type === "orderedList" || n.type === "bulletList") {
|
||||
if (Array.isArray(n.content)) {
|
||||
n.content.forEach((item, idx) => {
|
||||
const text = extractText(item).trim()
|
||||
const match = text.match(/^([A-Z])[.、)]\s*(.+)$/)
|
||||
if (match) {
|
||||
options.push({ id: match[1]!, text: match[2]! })
|
||||
} else {
|
||||
options.push({ id: String.fromCharCode(65 + idx), text })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
const buildQuestion = (qb: JSONContent): EditorQuestion => {
|
||||
const attrs = qb.attrs ?? {}
|
||||
const id =
|
||||
typeof attrs.questionId === "string" && attrs.questionId
|
||||
? attrs.questionId
|
||||
: createId()
|
||||
const type = (typeof attrs.type === "string"
|
||||
? attrs.type
|
||||
: "single_choice") as RichQuestionType
|
||||
const score = typeof attrs.score === "number" ? attrs.score : 0
|
||||
const inner = qb.content ?? []
|
||||
const text = extractText({
|
||||
type: "doc",
|
||||
content: inner.filter(
|
||||
(n) =>
|
||||
n.type !== "orderedList" &&
|
||||
n.type !== "bulletList" &&
|
||||
n.type !== "image"
|
||||
),
|
||||
})
|
||||
const options = parseOptions(inner)
|
||||
const blanks = collectBlanks(inner)
|
||||
const images = collectImages(inner)
|
||||
const content: RichQuestionContent = { text: text.trim() }
|
||||
if (options.length > 0) content.options = options
|
||||
if (blanks.length > 0) content.blanks = blanks
|
||||
if (images.length > 0) content.images = images
|
||||
return { id, type, score, content }
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Tiptap 编辑器文档(JSONContent)转换为试卷结构(EditorDoc)。
|
||||
* 遍历顶层块:groupBlock → 分组,questionBlock → 题目。
|
||||
*/
|
||||
export const editorDocToStructure = (
|
||||
doc: JSONContent,
|
||||
title: string
|
||||
): EditorDoc => {
|
||||
const questions: EditorQuestion[] = []
|
||||
const structure: EditorStructureNode[] = []
|
||||
const topBlocks = doc.content ?? []
|
||||
|
||||
for (const block of topBlocks) {
|
||||
if (block.type === "groupBlock") {
|
||||
const groupTitle =
|
||||
typeof block.attrs?.title === "string" ? block.attrs.title : ""
|
||||
const children: EditorStructureNode[] = []
|
||||
const innerBlocks = block.content ?? []
|
||||
for (const qb of innerBlocks) {
|
||||
if (qb.type === "questionBlock") {
|
||||
const q = buildQuestion(qb)
|
||||
questions.push(q)
|
||||
children.push({
|
||||
id: createId(),
|
||||
type: "question",
|
||||
questionId: q.id,
|
||||
score: q.score,
|
||||
})
|
||||
}
|
||||
}
|
||||
structure.push({
|
||||
id: createId(),
|
||||
type: "group",
|
||||
title: groupTitle,
|
||||
children,
|
||||
})
|
||||
} else if (block.type === "questionBlock") {
|
||||
const q = buildQuestion(block)
|
||||
questions.push(q)
|
||||
structure.push({
|
||||
id: createId(),
|
||||
type: "question",
|
||||
questionId: q.id,
|
||||
score: q.score,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { title, questions, structure }
|
||||
}
|
||||
42
src/modules/exams/editor/exam-rich-editor-types.ts
Normal file
42
src/modules/exams/editor/exam-rich-editor-types.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { JSONContent } from "@tiptap/react"
|
||||
|
||||
export type RichQuestionType =
|
||||
| "single_choice"
|
||||
| "multiple_choice"
|
||||
| "judgment"
|
||||
| "text"
|
||||
| "composite"
|
||||
|
||||
export interface RichQuestionContent {
|
||||
text: string
|
||||
options?: Array<{ id: string; text: string; isCorrect?: boolean }>
|
||||
blanks?: Array<{ id: string; answer?: string; score?: number }>
|
||||
images?: Array<{ fileId: string; url: string; alt?: string }>
|
||||
subQuestions?: Array<{ id: string; text: string; answer?: string; score?: number }>
|
||||
correctAnswer?: unknown
|
||||
}
|
||||
|
||||
export interface EditorQuestion {
|
||||
id: string
|
||||
type: RichQuestionType
|
||||
score: number
|
||||
content: RichQuestionContent
|
||||
}
|
||||
|
||||
export interface EditorStructureNode {
|
||||
id: string
|
||||
type: "group" | "question"
|
||||
title?: string
|
||||
questionId?: string
|
||||
score?: number
|
||||
children?: EditorStructureNode[]
|
||||
}
|
||||
|
||||
export interface EditorDoc {
|
||||
title: string
|
||||
questions: EditorQuestion[]
|
||||
structure: EditorStructureNode[]
|
||||
}
|
||||
|
||||
/** 用于编辑器与试卷结构互转的 JSONContent 别名 */
|
||||
export type EditorJSONContent = JSONContent
|
||||
309
src/modules/exams/editor/exam-rich-editor.tsx
Normal file
309
src/modules/exams/editor/exam-rich-editor.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, forwardRef } from "react"
|
||||
import { useEditor, EditorContent, type Editor } from "@tiptap/react"
|
||||
import StarterKit from "@tiptap/starter-kit"
|
||||
import Placeholder from "@tiptap/extension-placeholder"
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Redo,
|
||||
Strikethrough,
|
||||
Underline,
|
||||
Undo,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import {
|
||||
BlankNode,
|
||||
DottedMark,
|
||||
GroupBlock,
|
||||
ImageNode,
|
||||
QuestionBlock,
|
||||
type QuestionBlockType,
|
||||
} from "./extensions"
|
||||
import { SelectionToolbar } from "./selection-toolbar"
|
||||
import type { EditorJSONContent } from "./exam-rich-editor-types"
|
||||
|
||||
export interface ExamRichEditorHandle {
|
||||
/** 获取当前编辑器文档(Tiptap JSONContent) */
|
||||
getJSON: () => EditorJSONContent | null
|
||||
/** 设置编辑器文档 */
|
||||
setJSON: (doc: EditorJSONContent) => void
|
||||
/** 获取纯文本(用于标题等) */
|
||||
getText: () => string
|
||||
/** 插入题目块 */
|
||||
insertQuestion: (type: QuestionBlockType, score?: number) => void
|
||||
/** 插入大题分组 */
|
||||
insertGroup: (title?: string) => void
|
||||
/** 清空编辑器 */
|
||||
clear: () => void
|
||||
/** 获取 Editor 实例(高级用法) */
|
||||
getEditor: () => Editor | null
|
||||
}
|
||||
|
||||
interface ExamRichEditorProps {
|
||||
/** 初始内容(JSONContent) */
|
||||
initialContent?: EditorJSONContent | null
|
||||
/** 占位提示文本 */
|
||||
placeholder?: string
|
||||
/** 内容变化回调 */
|
||||
onChange?: (doc: EditorJSONContent) => void
|
||||
/** 是否只读 */
|
||||
readOnly?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
onClick: () => void
|
||||
icon: React.ElementType
|
||||
title: string
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function ToolbarButton({ onClick, icon: Icon, title, active, disabled }: ToolbarButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={active ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
active && "bg-muted text-foreground hover:bg-muted"
|
||||
)}
|
||||
disabled={disabled}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
onClick()
|
||||
}}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 试卷富文本编辑器 —— 基于 Tiptap,集成自定义节点(题目块/大题分组/填空/加点字/图片)。
|
||||
* 选中文本时浮现 SelectionToolbar 提供快捷标记操作。
|
||||
* 通过 ref 暴露 getJSON/setJSON 等方法供父组件调用。
|
||||
*/
|
||||
export const ExamRichEditor = forwardRef<ExamRichEditorHandle, ExamRichEditorProps>(
|
||||
function ExamRichEditor(
|
||||
{
|
||||
initialContent,
|
||||
placeholder = "在此粘贴或输入试卷内容,选中文本可标记为题目/分组/加点字/填空...",
|
||||
onChange,
|
||||
readOnly = false,
|
||||
className,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const onChangeRef = useRef(onChange)
|
||||
onChangeRef.current = onChange
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
emptyEditorClass:
|
||||
"is-editor-empty before:content-[attr(data-placeholder)] before:text-muted-foreground before:float-left before:pointer-events-none before:h-0",
|
||||
}),
|
||||
DottedMark,
|
||||
BlankNode,
|
||||
ImageNode,
|
||||
QuestionBlock,
|
||||
GroupBlock,
|
||||
],
|
||||
[placeholder]
|
||||
)
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
extensions,
|
||||
editable: !readOnly,
|
||||
content: initialContent ?? undefined,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"prose prose-sm dark:prose-invert max-w-none min-h-[400px] p-4 focus:outline-none",
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
onChangeRef.current?.(editor.getJSON() as EditorJSONContent)
|
||||
},
|
||||
})
|
||||
|
||||
// 当 initialContent 变化时(如切换试卷),重置编辑器内容
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (initialContent) {
|
||||
editor.commands.setContent(initialContent)
|
||||
}
|
||||
// 仅在 initialContent 引用变化时执行
|
||||
}, [initialContent, editor])
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
(): ExamRichEditorHandle => ({
|
||||
getJSON: () => (editor ? (editor.getJSON() as EditorJSONContent) : null),
|
||||
setJSON: (doc) => {
|
||||
editor?.commands.setContent(doc)
|
||||
},
|
||||
getText: () => editor?.getText() ?? "",
|
||||
insertQuestion: (type, score = 2) => {
|
||||
editor?.chain().focus().insertQuestion({ type, score }).run()
|
||||
},
|
||||
insertGroup: (title) => {
|
||||
editor?.chain().focus().insertGroup(title).run()
|
||||
},
|
||||
clear: () => {
|
||||
editor?.commands.clearContent(true)
|
||||
},
|
||||
getEditor: () => editor,
|
||||
}),
|
||||
[editor]
|
||||
)
|
||||
|
||||
const handleBold = useCallback(() => {
|
||||
editor?.chain().focus().toggleBold().run()
|
||||
}, [editor])
|
||||
|
||||
const handleItalic = useCallback(() => {
|
||||
editor?.chain().focus().toggleItalic().run()
|
||||
}, [editor])
|
||||
|
||||
const handleStrike = useCallback(() => {
|
||||
editor?.chain().focus().toggleStrike().run()
|
||||
}, [editor])
|
||||
|
||||
const handleDotted = useCallback(() => {
|
||||
editor?.chain().focus().toggleDotted().run()
|
||||
}, [editor])
|
||||
|
||||
const handleBulletList = useCallback(() => {
|
||||
editor?.chain().focus().toggleBulletList().run()
|
||||
}, [editor])
|
||||
|
||||
const handleOrderedList = useCallback(() => {
|
||||
editor?.chain().focus().toggleOrderedList().run()
|
||||
}, [editor])
|
||||
|
||||
const handleBlockquote = useCallback(() => {
|
||||
editor?.chain().focus().toggleBlockquote().run()
|
||||
}, [editor])
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
editor?.chain().focus().undo().run()
|
||||
}, [editor])
|
||||
|
||||
const handleRedo = useCallback(() => {
|
||||
editor?.chain().focus().redo().run()
|
||||
}, [editor])
|
||||
|
||||
// 计算当前选区所在题目块类型(用于工具栏状态显示)
|
||||
const activeQuestionType = useMemo<QuestionBlockType | undefined>(() => {
|
||||
if (!editor) return undefined
|
||||
const $from = editor.state.selection.$from
|
||||
for (let d = $from.depth; d > 0; d--) {
|
||||
const node = $from.node(d)
|
||||
if (node.type.name === "questionBlock") {
|
||||
return (node.attrs.type as QuestionBlockType) ?? "single_choice"
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editor, editor?.state.selection])
|
||||
|
||||
if (!editor) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center p-8 text-muted-foreground", className)}>
|
||||
编辑器加载中...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative flex flex-col rounded-md border bg-background shadow-sm overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!readOnly && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-1 border-b bg-background/95 p-1 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<ToolbarButton
|
||||
onClick={handleBold}
|
||||
icon={Bold}
|
||||
title="加粗"
|
||||
active={editor.isActive("bold")}
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={handleItalic}
|
||||
icon={Italic}
|
||||
title="斜体"
|
||||
active={editor.isActive("italic")}
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={handleStrike}
|
||||
icon={Strikethrough}
|
||||
title="删除线"
|
||||
active={editor.isActive("strike")}
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={handleDotted}
|
||||
icon={Underline}
|
||||
title="加点字(下加点)"
|
||||
active={editor.isActive("dotted")}
|
||||
/>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
|
||||
<ToolbarButton
|
||||
onClick={handleBulletList}
|
||||
icon={List}
|
||||
title="无序列表"
|
||||
active={editor.isActive("bulletList")}
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={handleOrderedList}
|
||||
icon={ListOrdered}
|
||||
title="有序列表(选项)"
|
||||
active={editor.isActive("orderedList")}
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={handleBlockquote}
|
||||
icon={Quote}
|
||||
title="引用"
|
||||
active={editor.isActive("blockquote")}
|
||||
/>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<ToolbarButton onClick={handleUndo} icon={Undo} title="撤销" />
|
||||
<ToolbarButton onClick={handleRedo} icon={Redo} title="重做" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SelectionToolbar editor={editor} activeQuestionType={activeQuestionType} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<EditorContent editor={editor} className="flex-1 overflow-y-auto min-h-0" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
56
src/modules/exams/editor/extensions/blank-node.tsx
Normal file
56
src/modules/exams/editor/extensions/blank-node.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core"
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
blank: {
|
||||
/** 插入填空占位(下划线空) */
|
||||
insertBlank: () => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const BlankView = ({ selected }: NodeViewProps) => (
|
||||
<NodeViewWrapper as="span" className="inline-block align-baseline">
|
||||
<span
|
||||
data-blank="true"
|
||||
className="mx-1 inline-block border-b border-current align-baseline"
|
||||
style={{
|
||||
minWidth: "80px",
|
||||
height: "1.2em",
|
||||
display: "inline-block",
|
||||
boxShadow: selected ? "0 0 0 2px rgb(var(--primary) / 0.4))" : undefined,
|
||||
}}
|
||||
contentEditable={false}
|
||||
aria-label="填空"
|
||||
/>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
|
||||
/**
|
||||
* 填空占位节点 —— 原子节点,渲染为下划线空。
|
||||
* 用于看拼音写词语、填反义词、补充词语、古诗词默写等多空填空题。
|
||||
*/
|
||||
export const BlankNode = Node.create({
|
||||
name: "blank",
|
||||
group: "inline",
|
||||
inline: true,
|
||||
atom: true,
|
||||
selectable: true,
|
||||
parseHTML: () => [{ tag: "span[data-blank]" }],
|
||||
renderHTML: ({ HTMLAttributes }) => [
|
||||
"span",
|
||||
mergeAttributes(HTMLAttributes, { "data-blank": "true" }),
|
||||
],
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(BlankView)
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
insertBlank:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.insertContent({ type: "blank" }),
|
||||
}
|
||||
},
|
||||
})
|
||||
42
src/modules/exams/editor/extensions/dotted-mark.ts
Normal file
42
src/modules/exams/editor/extensions/dotted-mark.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Mark, mergeAttributes } from "@tiptap/core"
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
dotted: {
|
||||
/** 切换加点字(下加点)标记 */
|
||||
toggleDotted: () => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加点字 mark —— 用于语文试卷中"加点字"注音题、词语运用题等。
|
||||
* 渲染为下加点虚线下划线(text-decoration: underline dotted)。
|
||||
*/
|
||||
export const DottedMark = Mark.create({
|
||||
name: "dotted",
|
||||
inclusive: true,
|
||||
parseHTML: () => [
|
||||
{ tag: "span[data-dotted]" },
|
||||
{
|
||||
style: "text-decoration",
|
||||
getAttrs: (v) => (v === "underline dotted" ? null : false),
|
||||
},
|
||||
],
|
||||
renderHTML: ({ HTMLAttributes }) => [
|
||||
"span",
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
"data-dotted": "true",
|
||||
style: "text-decoration: underline dotted; text-underline-offset: 3px;",
|
||||
}),
|
||||
0,
|
||||
],
|
||||
addCommands() {
|
||||
return {
|
||||
toggleDotted:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.toggleMark(this.name),
|
||||
}
|
||||
},
|
||||
})
|
||||
59
src/modules/exams/editor/extensions/group-block.tsx
Normal file
59
src/modules/exams/editor/extensions/group-block.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core"
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent, type NodeViewProps } from "@tiptap/react"
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
groupBlock: {
|
||||
/** 插入大题分组(如"一、选择题") */
|
||||
insertGroup: (title?: string) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const GroupView = ({ node, updateAttributes }: NodeViewProps) => (
|
||||
<NodeViewWrapper className="my-4 rounded-md border-l-4 border-l-primary bg-primary/5 p-3">
|
||||
<input
|
||||
type="text"
|
||||
value={(node.attrs.title as string) || ""}
|
||||
onChange={(e) => updateAttributes({ title: e.target.value })}
|
||||
placeholder="大题标题(如:一、选择题)"
|
||||
className="w-full bg-transparent text-base font-semibold focus:outline-none"
|
||||
/>
|
||||
<NodeViewContent className="mt-2 block" />
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
|
||||
/**
|
||||
* 分组块节点 —— 大题分组,包含标题输入区 + 子内容区。
|
||||
* 用于"一、选择题""二、填空题"等大题结构。
|
||||
*/
|
||||
export const GroupBlock = Node.create({
|
||||
name: "groupBlock",
|
||||
group: "block",
|
||||
content: "block+",
|
||||
defining: true,
|
||||
isolating: true,
|
||||
addAttributes() {
|
||||
return { title: { default: "" } }
|
||||
},
|
||||
parseHTML: () => [{ tag: "div[data-group-block]" }],
|
||||
renderHTML: ({ HTMLAttributes }) => [
|
||||
"div",
|
||||
mergeAttributes(HTMLAttributes, { "data-group-block": "true" }),
|
||||
0,
|
||||
],
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(GroupView)
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
insertGroup:
|
||||
(title) =>
|
||||
({ commands }) =>
|
||||
commands.insertContent({
|
||||
type: "groupBlock",
|
||||
attrs: { title: title || "" },
|
||||
}),
|
||||
}
|
||||
},
|
||||
})
|
||||
49
src/modules/exams/editor/extensions/image-node.ts
Normal file
49
src/modules/exams/editor/extensions/image-node.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core"
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
image: {
|
||||
/** 插入图片(绑定 fileAttachments 的 fileId) */
|
||||
setImage: (attrs: { src: string; fileId?: string; alt?: string }) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片节点 —— 自实现(不依赖 @tiptap/extension-image)。
|
||||
* 存储 src + fileId(关联 fileAttachments 表),用于书签图、宣传照等图片附件。
|
||||
*/
|
||||
export const ImageNode = Node.create({
|
||||
name: "image",
|
||||
group: "block",
|
||||
draggable: true,
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute("src") || "",
|
||||
},
|
||||
alt: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute("alt"),
|
||||
renderHTML: (attrs) => (attrs.alt ? { alt: attrs.alt } : {}),
|
||||
},
|
||||
fileId: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute("data-file-id"),
|
||||
renderHTML: (attrs) =>
|
||||
attrs.fileId ? { "data-file-id": attrs.fileId } : {},
|
||||
},
|
||||
}
|
||||
},
|
||||
parseHTML: () => [{ tag: "img[src]" }],
|
||||
renderHTML: ({ HTMLAttributes }) => ["img", mergeAttributes(HTMLAttributes)],
|
||||
addCommands() {
|
||||
return {
|
||||
setImage:
|
||||
(attrs) =>
|
||||
({ commands }) =>
|
||||
commands.insertContent({ type: "image", attrs }),
|
||||
}
|
||||
},
|
||||
})
|
||||
5
src/modules/exams/editor/extensions/index.ts
Normal file
5
src/modules/exams/editor/extensions/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { DottedMark } from "./dotted-mark"
|
||||
export { BlankNode } from "./blank-node"
|
||||
export { ImageNode } from "./image-node"
|
||||
export { QuestionBlock, type QuestionBlockType, type QuestionBlockAttrs } from "./question-block"
|
||||
export { GroupBlock } from "./group-block"
|
||||
97
src/modules/exams/editor/extensions/question-block.tsx
Normal file
97
src/modules/exams/editor/extensions/question-block.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core"
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent, type NodeViewProps } from "@tiptap/react"
|
||||
|
||||
export type QuestionBlockType =
|
||||
| "single_choice"
|
||||
| "multiple_choice"
|
||||
| "judgment"
|
||||
| "text"
|
||||
| "composite"
|
||||
|
||||
export interface QuestionBlockAttrs {
|
||||
questionId: string
|
||||
type: QuestionBlockType
|
||||
score: number
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
questionBlock: {
|
||||
/** 插入题目块(含题型/分值/题干) */
|
||||
insertQuestion: (attrs?: Partial<QuestionBlockAttrs>) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const QuestionView = ({ node, updateAttributes }: NodeViewProps) => {
|
||||
const { type, score } = node.attrs as QuestionBlockAttrs
|
||||
return (
|
||||
<NodeViewWrapper className="my-3 rounded-md border bg-card p-3">
|
||||
<div className="mb-2 flex items-center gap-2 border-b pb-2">
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => updateAttributes({ type: e.target.value })}
|
||||
className="rounded border bg-background px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="single_choice">单选</option>
|
||||
<option value="multiple_choice">多选</option>
|
||||
<option value="judgment">判断</option>
|
||||
<option value="text">填空/简答</option>
|
||||
<option value="composite">复合</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={score}
|
||||
onChange={(e) => updateAttributes({ score: Number(e.target.value) || 0 })}
|
||||
className="w-16 rounded border bg-background px-2 py-1 text-xs"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">分</span>
|
||||
</div>
|
||||
<NodeViewContent className="block text-sm" />
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 题目块节点 —— 单道题,含题型/分值/题干。
|
||||
* 题干内容写在 NodeViewContent 中,支持富文本(加点字、填空、图片、选项列表)。
|
||||
*/
|
||||
export const QuestionBlock = Node.create({
|
||||
name: "questionBlock",
|
||||
group: "block",
|
||||
content: "block+",
|
||||
defining: true,
|
||||
isolating: true,
|
||||
addAttributes() {
|
||||
return {
|
||||
questionId: { default: "" },
|
||||
type: { default: "single_choice" },
|
||||
score: { default: 0 },
|
||||
}
|
||||
},
|
||||
parseHTML: () => [{ tag: "div[data-question-block]" }],
|
||||
renderHTML: ({ HTMLAttributes }) => [
|
||||
"div",
|
||||
mergeAttributes(HTMLAttributes, { "data-question-block": "true" }),
|
||||
0,
|
||||
],
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(QuestionView)
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
insertQuestion:
|
||||
(attrs) =>
|
||||
({ commands }) =>
|
||||
commands.insertContent({
|
||||
type: "questionBlock",
|
||||
attrs: {
|
||||
type: attrs?.type ?? "single_choice",
|
||||
score: attrs?.score ?? 0,
|
||||
questionId: attrs?.questionId ?? "",
|
||||
},
|
||||
}),
|
||||
}
|
||||
},
|
||||
})
|
||||
23
src/modules/exams/editor/index.ts
Normal file
23
src/modules/exams/editor/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export { ExamRichEditor, type ExamRichEditorHandle } from "./exam-rich-editor"
|
||||
export { SelectionToolbar } from "./selection-toolbar"
|
||||
export {
|
||||
DottedMark,
|
||||
BlankNode,
|
||||
ImageNode,
|
||||
QuestionBlock,
|
||||
GroupBlock,
|
||||
type QuestionBlockType,
|
||||
type QuestionBlockAttrs,
|
||||
} from "./extensions"
|
||||
export {
|
||||
editorDocToStructure,
|
||||
} from "./editor-to-structure"
|
||||
export { structureToEditorDoc } from "./structure-to-editor"
|
||||
export type {
|
||||
RichQuestionType,
|
||||
RichQuestionContent,
|
||||
EditorQuestion,
|
||||
EditorStructureNode,
|
||||
EditorDoc,
|
||||
EditorJSONContent,
|
||||
} from "./exam-rich-editor-types"
|
||||
202
src/modules/exams/editor/selection-toolbar.tsx
Normal file
202
src/modules/exams/editor/selection-toolbar.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import type { Editor } from "@tiptap/react"
|
||||
import {
|
||||
Box,
|
||||
Brackets,
|
||||
CircleSlash,
|
||||
FileText,
|
||||
Heading,
|
||||
Underline,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import type { QuestionBlockType } from "./extensions/question-block"
|
||||
|
||||
interface SelectionToolbarProps {
|
||||
editor: Editor | null
|
||||
/** 当前选区所在题目块类型(用于显示状态) */
|
||||
activeQuestionType?: QuestionBlockType
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
onClick: () => void
|
||||
icon: React.ElementType
|
||||
label: string
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
function ToolbarButton({ onClick, icon: Icon, label, active }: ToolbarButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={active ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 gap-1 px-2 text-xs",
|
||||
active && "bg-primary/15 text-primary hover:bg-primary/20"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
// 防止点击按钮时编辑器失焦
|
||||
e.preventDefault()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
onClick()
|
||||
}}
|
||||
title={label}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span>{label}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 选区标记浮层 —— 选中文本时浮现,提供"标记为题目/分组/加点字/填空"等快捷操作。
|
||||
* 跟随选区位置定位,使用 getBoundingClientRect 计算坐标。
|
||||
*/
|
||||
export function SelectionToolbar({
|
||||
editor,
|
||||
activeQuestionType,
|
||||
className,
|
||||
}: SelectionToolbarProps) {
|
||||
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null)
|
||||
const [hasSelection, setHasSelection] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
|
||||
const updatePosition = () => {
|
||||
const { from, to, empty } = editor.state.selection
|
||||
if (empty || from === to) {
|
||||
setCoords(null)
|
||||
setHasSelection(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查选区是否在 questionBlock/groupBlock 内部
|
||||
const $from = editor.state.selection.$from
|
||||
const depth = $from.depth
|
||||
let inBlock = false
|
||||
for (let d = depth; d > 0; d--) {
|
||||
const node = $from.node(d)
|
||||
if (node.type.name === "questionBlock" || node.type.name === "groupBlock") {
|
||||
inBlock = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 即使不在块内也允许显示(可标记为题目/分组)
|
||||
void inBlock
|
||||
|
||||
const view = editor.view
|
||||
const start = view.coordsAtPos(from)
|
||||
const end = view.coordsAtPos(to)
|
||||
const top = Math.min(start.top, end.top) - 44 // 浮在选区上方
|
||||
const left = (start.left + end.right) / 2
|
||||
setCoords({ top, left })
|
||||
setHasSelection(true)
|
||||
}
|
||||
|
||||
editor.on("selectionUpdate", updatePosition)
|
||||
editor.on("blur", () => {
|
||||
// 延迟以允许点击工具栏按钮
|
||||
setTimeout(() => {
|
||||
if (!editor.isFocused) {
|
||||
setCoords(null)
|
||||
setHasSelection(false)
|
||||
}
|
||||
}, 200)
|
||||
})
|
||||
|
||||
return () => {
|
||||
editor.off("selectionUpdate", updatePosition)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
if (!editor || !hasSelection || !coords) return null
|
||||
|
||||
const insertQuestion = (type: QuestionBlockType) => {
|
||||
editor.chain().focus().insertQuestion({ type, score: type === "composite" ? 5 : 2 }).run()
|
||||
}
|
||||
|
||||
const insertGroup = () => {
|
||||
editor.chain().focus().insertGroup("一、选择题").run()
|
||||
}
|
||||
|
||||
const toggleDotted = () => {
|
||||
editor.chain().focus().toggleDotted().run()
|
||||
}
|
||||
|
||||
const insertBlank = () => {
|
||||
editor.chain().focus().insertBlank().run()
|
||||
}
|
||||
|
||||
const insertImage = () => {
|
||||
// 触发隐藏的文件输入
|
||||
const input = document.createElement("input")
|
||||
input.type = "file"
|
||||
input.accept = "image/*"
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
formData.append("targetType", "exam")
|
||||
try {
|
||||
const res = await fetch("/api/upload", { method: "POST", body: formData })
|
||||
const data = await res.json()
|
||||
if (data?.success && data?.url) {
|
||||
editor.chain().focus().setImage({ src: data.url, fileId: data.id }).run()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[SelectionToolbar] upload failed", e)
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="选区标记工具栏"
|
||||
className={cn(
|
||||
"fixed z-50 flex items-center gap-0.5 rounded-md border bg-popover/95 p-1 shadow-md backdrop-blur",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
top: `${Math.max(8, coords.top)}px`,
|
||||
left: `${coords.left}px`,
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
>
|
||||
<ToolbarButton onClick={insertGroup} icon={Heading} label="大题" />
|
||||
<ToolbarButton
|
||||
onClick={() => insertQuestion("single_choice")}
|
||||
icon={CircleSlash}
|
||||
label="单选"
|
||||
active={activeQuestionType === "single_choice"}
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => insertQuestion("text")}
|
||||
icon={FileText}
|
||||
label="填空/简答"
|
||||
active={activeQuestionType === "text"}
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => insertQuestion("composite")}
|
||||
icon={Brackets}
|
||||
label="复合"
|
||||
active={activeQuestionType === "composite"}
|
||||
/>
|
||||
<div className="mx-1 h-5 w-px bg-border" />
|
||||
<ToolbarButton onClick={toggleDotted} icon={Underline} label="加点字" />
|
||||
<ToolbarButton onClick={insertBlank} icon={Box} label="填空" />
|
||||
<ToolbarButton onClick={insertImage} icon={FileText} label="图片" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
src/modules/exams/editor/structure-to-editor.ts
Normal file
69
src/modules/exams/editor/structure-to-editor.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { JSONContent } from "@tiptap/react"
|
||||
import type { EditorDoc, EditorQuestion } from "./exam-rich-editor-types"
|
||||
|
||||
const textToParagraphs = (text: string): JSONContent[] => {
|
||||
const lines = text.split("\n").filter((l) => l.trim().length > 0)
|
||||
return lines.map((line) => ({
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: line }],
|
||||
}))
|
||||
}
|
||||
|
||||
const questionToBlock = (q: EditorQuestion): JSONContent => {
|
||||
const content: JSONContent[] = textToParagraphs(q.content.text)
|
||||
if (q.content.options) {
|
||||
content.push({
|
||||
type: "orderedList",
|
||||
content: q.content.options.map((o) => ({
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: `${o.id}. ${o.text}` }],
|
||||
},
|
||||
],
|
||||
})),
|
||||
})
|
||||
}
|
||||
if (q.content.images) {
|
||||
for (const img of q.content.images) {
|
||||
content.push({
|
||||
type: "image",
|
||||
attrs: { src: img.url, "data-file-id": img.fileId, alt: img.alt ?? "" },
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: "questionBlock",
|
||||
attrs: { questionId: q.id, type: q.type, score: q.score },
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将试卷结构(EditorDoc)转换为 Tiptap 编辑器文档(JSONContent)。
|
||||
* 用于回填已有试卷到编辑器。
|
||||
*/
|
||||
export const structureToEditorDoc = (doc: EditorDoc): JSONContent => {
|
||||
const content: JSONContent[] = []
|
||||
for (const node of doc.structure) {
|
||||
if (node.type === "group") {
|
||||
const children: JSONContent[] = []
|
||||
for (const child of node.children ?? []) {
|
||||
if (child.type === "question") {
|
||||
const q = doc.questions.find((x) => x.id === child.questionId)
|
||||
if (q) children.push(questionToBlock(q))
|
||||
}
|
||||
}
|
||||
content.push({
|
||||
type: "groupBlock",
|
||||
attrs: { title: node.title ?? "" },
|
||||
content: children,
|
||||
})
|
||||
} else if (node.type === "question") {
|
||||
const q = doc.questions.find((x) => x.id === node.questionId)
|
||||
if (q) content.push(questionToBlock(q))
|
||||
}
|
||||
}
|
||||
return { type: "doc", content }
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// 文件关联的目标资源类型(多态关联)
|
||||
export type FileTargetType = "exam" | "textbook" | "question" | "announcement"
|
||||
export type FileTargetType = "exam" | "textbook" | "question" | "announcement" | "homework"
|
||||
|
||||
// 文件附件记录(DB 行的 TypeScript 表示)
|
||||
export interface FileAttachment {
|
||||
|
||||
@@ -433,3 +433,122 @@ export async function batchAutoGradeSubmissionsAction(
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 答题拍照上传:扫描图管理(基于 fileAttachments 表)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ScanAttachment {
|
||||
fileId: string
|
||||
url: string
|
||||
filename: string
|
||||
originalName: string
|
||||
/** 页码(按创建时间排序,从 1 开始) */
|
||||
page: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某次提交的所有答题扫描图。
|
||||
* 扫描图存储在 fileAttachments 表中,targetType="homework", targetId=submissionId。
|
||||
*
|
||||
* 支持两类访问者:
|
||||
* - 学生(HOMEWORK_SUBMIT):仅可访问自己的提交
|
||||
* - 教师(HOMEWORK_GRADE):仅可访问自己创建的作业的提交
|
||||
*/
|
||||
export async function getScansAction(
|
||||
submissionId: string
|
||||
): Promise<ActionState<ScanAttachment[]>> {
|
||||
try {
|
||||
// 优先尝试教师批改权限
|
||||
let isAuthorizedAsTeacher = false
|
||||
try {
|
||||
const gradeCtx = await requirePermission(Permissions.HOMEWORK_GRADE)
|
||||
const submissionForGrading = await getHomeworkSubmissionForGrading(submissionId)
|
||||
if (!submissionForGrading) {
|
||||
return { success: false, message: "提交记录不存在" }
|
||||
}
|
||||
// 管理员(dataScope.type === "all")或作业创建者可访问
|
||||
if (gradeCtx.dataScope.type === "all" || submissionForGrading.creatorId === gradeCtx.userId) {
|
||||
isAuthorizedAsTeacher = true
|
||||
}
|
||||
} catch {
|
||||
// 教师权限不足,继续尝试学生权限
|
||||
}
|
||||
|
||||
if (!isAuthorizedAsTeacher) {
|
||||
// 回退到学生权限:仅允许提交者本人访问
|
||||
const submitCtx = await requirePermission(Permissions.HOMEWORK_SUBMIT)
|
||||
const submission = await getHomeworkSubmissionForPermission(submissionId)
|
||||
if (!submission) {
|
||||
return { success: false, message: "提交记录不存在" }
|
||||
}
|
||||
if (submission.studentId !== submitCtx.userId) {
|
||||
return { success: false, message: "无权访问此提交" }
|
||||
}
|
||||
}
|
||||
|
||||
const { db } = await import("@/shared/db")
|
||||
const { fileAttachments } = await import("@/shared/db/schema")
|
||||
const { eq, and, asc } = await import("drizzle-orm")
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: fileAttachments.id,
|
||||
url: fileAttachments.url,
|
||||
filename: fileAttachments.filename,
|
||||
originalName: fileAttachments.originalName,
|
||||
createdAt: fileAttachments.createdAt,
|
||||
})
|
||||
.from(fileAttachments)
|
||||
.where(
|
||||
and(
|
||||
eq(fileAttachments.targetType, "homework"),
|
||||
eq(fileAttachments.targetId, submissionId)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(fileAttachments.createdAt))
|
||||
|
||||
const scans: ScanAttachment[] = rows.map((row, idx) => ({
|
||||
fileId: row.id,
|
||||
url: row.url ?? "",
|
||||
filename: row.filename,
|
||||
originalName: row.originalName,
|
||||
page: idx + 1,
|
||||
}))
|
||||
|
||||
return { success: true, message: "OK", data: scans }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除某张答题扫描图。
|
||||
* 仅允许提交者本人删除,且仅在提交状态为 started 时允许。
|
||||
*/
|
||||
export async function deleteScanAction(
|
||||
submissionId: string,
|
||||
fileId: string
|
||||
): Promise<ActionState<null>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.HOMEWORK_SUBMIT)
|
||||
|
||||
const submission = await getHomeworkSubmissionForPermission(submissionId)
|
||||
if (!submission) {
|
||||
return { success: false, message: "提交记录不存在" }
|
||||
}
|
||||
if (submission.studentId !== ctx.userId) {
|
||||
return { success: false, message: "无权操作此提交" }
|
||||
}
|
||||
if (submission.status !== "started") {
|
||||
return { success: false, message: "提交已锁定,无法修改" }
|
||||
}
|
||||
|
||||
const { deleteFileAttachment } = await import("@/modules/files/data-access")
|
||||
await deleteFileAttachment(fileId)
|
||||
|
||||
return { success: true, message: "已删除", data: null }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,12 +150,20 @@ export function HomeworkBatchGradingView({ submissions }: HomeworkBatchGradingVi
|
||||
<TableCell className="text-muted-foreground tabular-nums">{s.submittedAt ? formatDate(s.submittedAt) : "-"}</TableCell>
|
||||
<TableCell className="tabular-nums">{typeof s.score === "number" ? s.score : "-"}</TableCell>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`/teacher/homework/submissions/${s.id}`}
|
||||
className="text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("homework.grade.title")}
|
||||
</a>
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href={`/teacher/homework/submissions/${s.id}`}
|
||||
className="text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("homework.grade.title")}
|
||||
</a>
|
||||
<a
|
||||
href={`/teacher/homework/submissions/${s.id}/scan-grading`}
|
||||
className="text-sm text-muted-foreground underline-offset-4 hover:underline hover:text-foreground"
|
||||
>
|
||||
{t("homework.grade.scanGrading")}
|
||||
</a>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
|
||||
287
src/modules/homework/components/homework-scan-grading-view.tsx
Normal file
287
src/modules/homework/components/homework-scan-grading-view.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { Save, ChevronLeft, ChevronRight, User, Clock } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { ResizablePanel } from "@/shared/components/ui/resizable-panel"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
|
||||
import { gradeHomeworkSubmissionAction, getScansAction, type ScanAttachment } from "../actions"
|
||||
import { QuestionRenderer } from "./question-renderer"
|
||||
import { ScanImageViewer } from "./scan-image-viewer"
|
||||
|
||||
type QuestionContent = { text?: string } & Record<string, unknown>
|
||||
|
||||
type Answer = {
|
||||
id: string
|
||||
questionId: string
|
||||
questionContent: QuestionContent | null
|
||||
questionType: string
|
||||
maxScore: number
|
||||
studentAnswer: unknown
|
||||
score: number | null
|
||||
feedback: string | null
|
||||
order: number
|
||||
}
|
||||
|
||||
type HomeworkScanGradingViewProps = {
|
||||
submissionId: string
|
||||
studentName: string
|
||||
assignmentTitle: string
|
||||
submittedAt: string | null
|
||||
status: string
|
||||
totalScore: number | null
|
||||
answers: Answer[]
|
||||
prevSubmissionId?: string | null
|
||||
nextSubmissionId?: string | null
|
||||
}
|
||||
|
||||
interface GradingState {
|
||||
score: string
|
||||
feedback: string
|
||||
}
|
||||
|
||||
export function HomeworkScanGradingView({
|
||||
submissionId,
|
||||
studentName,
|
||||
assignmentTitle,
|
||||
submittedAt,
|
||||
status,
|
||||
totalScore,
|
||||
answers,
|
||||
prevSubmissionId,
|
||||
nextSubmissionId,
|
||||
}: HomeworkScanGradingViewProps) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("examHomework")
|
||||
const [scans, setScans] = useState<ScanAttachment[]>([])
|
||||
const [loadingScans, setLoadingScans] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const [grading, setGrading] = useState<Record<string, GradingState>>(() => {
|
||||
const obj: Record<string, GradingState> = {}
|
||||
for (const ans of answers) {
|
||||
obj[ans.id] = {
|
||||
score: ans.score?.toString() ?? "",
|
||||
feedback: ans.feedback ?? "",
|
||||
}
|
||||
}
|
||||
return obj
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const result = await getScansAction(submissionId)
|
||||
if (result.success && result.data) {
|
||||
setScans(result.data)
|
||||
}
|
||||
setLoadingScans(false)
|
||||
})()
|
||||
}, [submissionId])
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const answersPayload = answers.map((ans) => {
|
||||
const g = grading[ans.id]
|
||||
return {
|
||||
id: ans.id,
|
||||
score: g ? Number(g.score) || 0 : 0,
|
||||
feedback: g?.feedback ?? null,
|
||||
}
|
||||
})
|
||||
const fd = new FormData()
|
||||
fd.set("submissionId", submissionId)
|
||||
fd.set("answersJson", JSON.stringify(answersPayload))
|
||||
const result = await gradeHomeworkSubmissionAction(null, fd)
|
||||
if (result.success) {
|
||||
toast.success(t("homework.grade.gradesSaved"))
|
||||
} else {
|
||||
toast.error(result.message || t("homework.grade.gradesSaveFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("homework.grade.saveFailed"))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNavigate = (targetId: string | null | undefined) => {
|
||||
if (!targetId) return
|
||||
router.push(`/teacher/homework/submissions/${targetId}/scan-grading`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-10rem)] flex-col gap-4">
|
||||
{/* 顶部信息栏 */}
|
||||
<div className="flex items-center justify-between rounded-md border bg-card p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.back()}
|
||||
className="gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t("homework.grade.back")}
|
||||
</Button>
|
||||
<div className="h-6 w-px bg-border" />
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{studentName}</span>
|
||||
</div>
|
||||
<div className="h-6 w-px bg-border" />
|
||||
<span className="text-sm text-muted-foreground">{assignmentTitle}</span>
|
||||
{submittedAt && (
|
||||
<>
|
||||
<div className="h-6 w-px bg-border" />
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDate(submittedAt)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{status}
|
||||
</Badge>
|
||||
{totalScore !== null && (
|
||||
<Badge variant="secondary">{t("homework.grade.totalScore")}: {totalScore}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleNavigate(prevSubmissionId)}
|
||||
disabled={!prevSubmissionId}
|
||||
className="gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
{t("homework.grade.prevSubmission")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleNavigate(nextSubmissionId)}
|
||||
disabled={!nextSubmissionId}
|
||||
className="gap-1"
|
||||
>
|
||||
{t("homework.grade.nextSubmission")}
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving} className="gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
{isSaving ? t("homework.grade.saving") : t("homework.grade.saveGrading")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主体:左题目右扫描图 */}
|
||||
<div className="flex-1 min-h-0 rounded-md border">
|
||||
<ResizablePanel
|
||||
initialLeft={55}
|
||||
minLeft={30}
|
||||
minRight={25}
|
||||
left={
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
{t("homework.grade.questionsCount", { count: answers.length })}
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-4 p-4">
|
||||
{answers.map((ans, idx) => (
|
||||
<Card key={ans.id} className="border-l-4 border-l-primary">
|
||||
<CardHeader className="pb-2">
|
||||
<QuestionRenderer
|
||||
questionId={ans.questionId}
|
||||
questionType={ans.questionType}
|
||||
questionContent={ans.questionContent}
|
||||
maxScore={ans.maxScore}
|
||||
index={idx}
|
||||
mode="review"
|
||||
value={ans.studentAnswer}
|
||||
disabled
|
||||
showCorrectAnswer
|
||||
feedback={ans.feedback}
|
||||
/>
|
||||
<div className="mt-3 grid gap-2 border-t pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`score-${ans.id}`} className="text-xs whitespace-nowrap">
|
||||
{t("homework.grade.scoreLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`score-${ans.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={ans.maxScore}
|
||||
value={grading[ans.id]?.score ?? ""}
|
||||
onChange={(e) =>
|
||||
setGrading((prev) => ({
|
||||
...prev,
|
||||
[ans.id]: {
|
||||
score: e.target.value,
|
||||
feedback: prev[ans.id]?.feedback ?? "",
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="h-8 w-20"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("homework.grade.scoreOutOf", { max: ans.maxScore })}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder={t("homework.grade.scanFeedbackPlaceholder")}
|
||||
value={grading[ans.id]?.feedback ?? ""}
|
||||
onChange={(e) =>
|
||||
setGrading((prev) => ({
|
||||
...prev,
|
||||
[ans.id]: {
|
||||
score: prev[ans.id]?.score ?? "",
|
||||
feedback: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="min-h-[60px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
{loadingScans
|
||||
? t("homework.grade.loadingScans")
|
||||
: t("homework.grade.scanPagesCount", { count: scans.length })}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
{loadingScans ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{t("homework.grade.loadingImages")}
|
||||
</div>
|
||||
) : (
|
||||
<ScanImageViewer images={scans} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -21,12 +21,13 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert, CloudUpload, CloudOff, Check, Loader2, Timer } from "lucide-react"
|
||||
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert, CloudUpload, CloudOff, Check, Loader2, Timer, Camera } from "lucide-react"
|
||||
import { formatDate, cn } from "@/shared/lib/utils"
|
||||
|
||||
import type { StudentHomeworkTakeData } from "../types"
|
||||
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions"
|
||||
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction, getScansAction, deleteScanAction } from "../actions"
|
||||
import { QuestionRenderer } from "./question-renderer"
|
||||
import { ScanUploader, type ScanImage } from "./scan-uploader"
|
||||
import { parseSavedAnswer } from "../lib/question-content-utils"
|
||||
import { useDebouncedAutoSave, loadOfflineCache, clearOfflineCache } from "../hooks/use-debounced-auto-save"
|
||||
import { useExamCountdown } from "../hooks/use-exam-countdown"
|
||||
@@ -43,6 +44,26 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
const [submissionStatus, setSubmissionStatus] = useState<string>(initialData.submission?.status ?? "not_started")
|
||||
const [isBusy, setIsBusy] = useState(false)
|
||||
const [showSubmitConfirm, setShowSubmitConfirm] = useState(false)
|
||||
const [scanImages, setScanImages] = useState<ScanImage[]>([])
|
||||
|
||||
// 加载已有答题扫描图(拍照上传)
|
||||
useEffect(() => {
|
||||
if (!submissionId) return
|
||||
void (async () => {
|
||||
const result = await getScansAction(submissionId)
|
||||
if (result.success && result.data) {
|
||||
setScanImages(result.data)
|
||||
}
|
||||
})()
|
||||
}, [submissionId])
|
||||
|
||||
const handleDeleteScan = async (fileId: string) => {
|
||||
if (!submissionId) return
|
||||
const result = await deleteScanAction(submissionId, fileId)
|
||||
if (!result.success) {
|
||||
toast.error(result.message || t("homework.take.saveFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
const initialAnswersByQuestionId = useMemo(() => {
|
||||
const map = new Map<string, { answer: unknown }>()
|
||||
@@ -357,6 +378,29 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
|
||||
{showQuestions && submissionId && (
|
||||
<Card className="border-l-4 border-l-blue-500 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Camera className="h-4 w-4 text-blue-500" />
|
||||
<h3 className="font-semibold text-sm">{t("homework.take.scanTitle")}</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("homework.take.scanDescription")}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<ScanUploader
|
||||
images={scanImages}
|
||||
onChange={setScanImages}
|
||||
onDeleteScan={handleDeleteScan}
|
||||
submissionId={submissionId}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
206
src/modules/homework/components/scan-image-viewer.tsx
Normal file
206
src/modules/homework/components/scan-image-viewer.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Maximize2, RotateCw } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import type { ScanImage } from "./scan-uploader"
|
||||
|
||||
interface ScanImageViewerProps {
|
||||
images: ScanImage[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描图查看器 —— 用于阅卷式批改时查看学生答题图片。
|
||||
* 支持翻页、缩放、旋转、全屏。
|
||||
*/
|
||||
export function ScanImageViewer({ images, className }: ScanImageViewerProps) {
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [rotation, setRotation] = useState(0)
|
||||
|
||||
const goToPage = useCallback(
|
||||
(page: number) => {
|
||||
if (images.length === 0) return
|
||||
const clamped = Math.max(0, Math.min(images.length - 1, page))
|
||||
setCurrentPage(clamped)
|
||||
setZoom(1)
|
||||
setRotation(0)
|
||||
},
|
||||
[images.length]
|
||||
)
|
||||
|
||||
const handlePrev = useCallback(() => goToPage(currentPage - 1), [currentPage, goToPage])
|
||||
const handleNext = useCallback(() => goToPage(currentPage + 1), [currentPage, goToPage])
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
setZoom((z) => Math.min(3, z + 0.25))
|
||||
}, [])
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
setZoom((z) => Math.max(0.5, z - 0.25))
|
||||
}, [])
|
||||
|
||||
const handleRotate = useCallback(() => {
|
||||
setRotation((r) => (r + 90) % 360)
|
||||
}, [])
|
||||
|
||||
const handleFullscreen = useCallback(() => {
|
||||
const img = document.getElementById("scan-image-fullscreen")
|
||||
if (img?.requestFullscreen) {
|
||||
void img.requestFullscreen()
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<div className={cn("flex h-full items-center justify-center text-muted-foreground", className)}>
|
||||
<div className="text-center">
|
||||
<p>该学生未上传答题图片</p>
|
||||
<p className="mt-1 text-xs">学生可在答题页拍摄上传纸质答案</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const currentImage = images[currentPage]
|
||||
if (!currentImage) return null
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)}>
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center justify-between border-b bg-muted/30 px-3 py-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoom <= 0.5}
|
||||
title="缩小"
|
||||
>
|
||||
<ZoomOut className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<span className="min-w-[44px] text-center text-xs tabular-nums">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleZoomIn}
|
||||
disabled={zoom >= 3}
|
||||
title="放大"
|
||||
>
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleRotate}
|
||||
title="旋转"
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleFullscreen}
|
||||
title="全屏"
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
第 {currentPage + 1} / {images.length} 页
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图片显示区 */}
|
||||
<div className="relative flex-1 overflow-auto bg-neutral-100 dark:bg-neutral-900">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<div
|
||||
id="scan-image-fullscreen"
|
||||
className="relative"
|
||||
style={{
|
||||
transform: `scale(${zoom}) rotate(${rotation}deg)`,
|
||||
transformOrigin: "center center",
|
||||
transition: "transform 0.2s ease-out",
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={currentImage.url}
|
||||
alt={`答题图 第${currentImage.page}页`}
|
||||
className="max-h-full max-w-full object-contain shadow-lg"
|
||||
style={{ maxHeight: "80vh" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 翻页按钮 */}
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2"
|
||||
onClick={handlePrev}
|
||||
disabled={currentPage === 0}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2"
|
||||
onClick={handleNext}
|
||||
disabled={currentPage === images.length - 1}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 缩略图导航 */}
|
||||
{images.length > 1 && (
|
||||
<div className="flex gap-1.5 overflow-x-auto border-t bg-muted/20 p-2">
|
||||
{images.map((img, idx) => (
|
||||
<button
|
||||
key={img.fileId}
|
||||
type="button"
|
||||
onClick={() => goToPage(idx)}
|
||||
className={cn(
|
||||
"relative h-16 w-12 shrink-0 overflow-hidden rounded border-2 transition-colors",
|
||||
idx === currentPage
|
||||
? "border-primary"
|
||||
: "border-transparent hover:border-muted-foreground/30"
|
||||
)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={img.url}
|
||||
alt={`缩略图 ${img.page}`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<span className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 text-center text-[10px] text-white">
|
||||
{img.page}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
266
src/modules/homework/components/scan-uploader.tsx
Normal file
266
src/modules/homework/components/scan-uploader.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useTransition, type ChangeEvent } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { Upload, X, Loader2, ImageIcon } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
export interface ScanImage {
|
||||
fileId: string
|
||||
url: string
|
||||
filename: string
|
||||
originalName?: string
|
||||
/** 页码(从 1 开始) */
|
||||
page: number
|
||||
}
|
||||
|
||||
interface ScanUploaderProps {
|
||||
/** 已上传的扫描图列表 */
|
||||
images: ScanImage[]
|
||||
/** 图片列表变化回调(增删/排序后触发) */
|
||||
onChange: (images: ScanImage[]) => void
|
||||
/** 删除单张扫描图时的服务端回调(可选,用于删除 fileAttachments 记录) */
|
||||
onDeleteScan?: (fileId: string) => Promise<void>
|
||||
/** 关联的提交 ID(用于权限校验) */
|
||||
submissionId: string
|
||||
/** 是否禁用(如已提交) */
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描图上传组件 —— 学生在纸上作答后,按页拍摄上传。
|
||||
* 调用 /api/upload 上传图片,返回 fileId + url。
|
||||
* 上传后将 fileId 列表通过 onChange 暴露给父组件。
|
||||
*/
|
||||
export function ScanUploader({
|
||||
images,
|
||||
onChange,
|
||||
onDeleteScan,
|
||||
submissionId,
|
||||
disabled = false,
|
||||
className,
|
||||
}: ScanUploaderProps) {
|
||||
const [isUploading, startUpload] = useTransition()
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const t = useTranslations("examHomework")
|
||||
|
||||
const uploadFiles = (files: FileList | File[]) => {
|
||||
if (disabled) return
|
||||
const fileArray = Array.from(files).filter(
|
||||
(f) => f.type.startsWith("image/") || f.type === "application/pdf"
|
||||
)
|
||||
if (fileArray.length === 0) {
|
||||
toast.error(t("homework.take.selectImageFiles"))
|
||||
return
|
||||
}
|
||||
|
||||
startUpload(async () => {
|
||||
const uploaded: ScanImage[] = []
|
||||
const basePage = images.length
|
||||
for (let i = 0; i < fileArray.length; i++) {
|
||||
const file = fileArray[i]
|
||||
if (!file) continue
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
formData.append("targetType", "homework")
|
||||
formData.append("targetId", submissionId)
|
||||
try {
|
||||
const res = await fetch("/api/upload", { method: "POST", body: formData })
|
||||
const data = await res.json()
|
||||
if (data?.success && data?.url && data?.id) {
|
||||
uploaded.push({
|
||||
fileId: data.id,
|
||||
url: data.url,
|
||||
filename: data.originalName || data.filename || `page-${basePage + i + 1}`,
|
||||
page: basePage + i + 1,
|
||||
})
|
||||
} else {
|
||||
toast.error(`${t("homework.take.uploadFailed")}: ${data?.message || ""}`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[ScanUploader] upload failed", e)
|
||||
toast.error(`${t("homework.take.uploadFailed")}: ${file.name}`)
|
||||
}
|
||||
}
|
||||
if (uploaded.length > 0) {
|
||||
const next = [...images, ...uploaded]
|
||||
onChange(next)
|
||||
toast.success(t("homework.take.uploadSuccess", { count: uploaded.length }))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleFileInput = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
uploadFiles(e.target.files)
|
||||
e.target.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
uploadFiles(e.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = (page: number) => {
|
||||
if (disabled) return
|
||||
const target = images.find((img) => img.page === page)
|
||||
const next = images.filter((img) => img.page !== page)
|
||||
// 重新编号
|
||||
const renumbered = next.map((img, idx) => ({ ...img, page: idx + 1 }))
|
||||
onChange(renumbered)
|
||||
// 服务端删除(不阻塞 UI)
|
||||
if (target && onDeleteScan) {
|
||||
void onDeleteScan(target.fileId).catch((e) => {
|
||||
console.error("[ScanUploader] delete failed", e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleMove = (page: number, direction: "up" | "down") => {
|
||||
if (disabled) return
|
||||
const idx = images.findIndex((img) => img.page === page)
|
||||
if (idx === -1) return
|
||||
const targetIdx = direction === "up" ? idx - 1 : idx + 1
|
||||
if (targetIdx < 0 || targetIdx >= images.length) return
|
||||
const next = [...images]
|
||||
const [moved] = next.splice(idx, 1)
|
||||
next.splice(targetIdx, 0, moved)
|
||||
const renumbered = next.map((img, i) => ({ ...img, page: i + 1 }))
|
||||
onChange(renumbered)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setDragOver(true)
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"rounded-md border-2 border-dashed p-6 text-center transition-colors",
|
||||
dragOver ? "border-primary bg-primary/5" : "border-border",
|
||||
disabled && "opacity-50"
|
||||
)}
|
||||
>
|
||||
<Upload className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("homework.take.dragDropHint")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
支持 JPG/PNG/WebP,每张不超过 10MB
|
||||
</p>
|
||||
<label className="mt-3 inline-block">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFileInput}
|
||||
disabled={disabled || isUploading}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled || isUploading}
|
||||
className="gap-2"
|
||||
onClick={() => {
|
||||
// 触发隐藏的 file input
|
||||
const input = document.createElement("input")
|
||||
input.type = "file"
|
||||
input.accept = "image/*"
|
||||
input.multiple = true
|
||||
input.onchange = () => {
|
||||
if (input.files) uploadFiles(input.files)
|
||||
}
|
||||
input.click()
|
||||
}}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{isUploading ? t("homework.take.submitting") : t("homework.take.scanTitle")}
|
||||
</Button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{images.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||
{images.map((img) => (
|
||||
<div
|
||||
key={img.fileId}
|
||||
className="group relative overflow-hidden rounded-md border bg-muted"
|
||||
>
|
||||
<div className="aspect-[3/4] w-full">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={img.url}
|
||||
alt={img.filename}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute left-1 top-1 rounded bg-black/60 px-1.5 py-0.5 text-xs text-white">
|
||||
{t("homework.take.pageLabel", { page: img.page })}
|
||||
</div>
|
||||
{!disabled && (
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-1 bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleMove(img.page, "up")}
|
||||
disabled={img.page === 1}
|
||||
title={t("homework.take.moveUp")}
|
||||
>
|
||||
↑
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleMove(img.page, "down")}
|
||||
disabled={img.page === images.length}
|
||||
title={t("homework.take.moveDown")}
|
||||
>
|
||||
↓
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleRemove(img.page)}
|
||||
title={t("homework.take.deleteScan")}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{images.length === 0 && !disabled && (
|
||||
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
|
||||
<ImageIcon className="mr-2 h-4 w-4" />
|
||||
{t("homework.take.noScans")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
src/shared/components/ui/resizable-panel.tsx
Normal file
78
src/shared/components/ui/resizable-panel.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
interface ResizablePanelProps {
|
||||
/** 左侧最小宽度百分比 */
|
||||
minLeft?: number
|
||||
/** 右侧最小宽度百分比 */
|
||||
minRight?: number
|
||||
/** 初始左侧宽度百分比 */
|
||||
initialLeft?: number
|
||||
left: ReactNode
|
||||
right: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 可拖拽分栏容器(左右两栏,中间分隔条可拖拽调整宽度)。
|
||||
* 自实现,无新依赖。用于试卷编辑器左编辑右预览、阅卷式批改左题目右图片等场景。
|
||||
*/
|
||||
export function ResizablePanel({
|
||||
minLeft = 20,
|
||||
minRight = 20,
|
||||
initialLeft = 50,
|
||||
left,
|
||||
right,
|
||||
className,
|
||||
}: ResizablePanelProps) {
|
||||
const [leftPct, setLeftPct] = useState(initialLeft)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const draggingRef = useRef(false)
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault()
|
||||
draggingRef.current = true
|
||||
document.body.style.cursor = "col-resize"
|
||||
document.body.style.userSelect = "none"
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: PointerEvent) => {
|
||||
if (!draggingRef.current || !containerRef.current) return
|
||||
const rect = containerRef.current.getBoundingClientRect()
|
||||
const pct = ((e.clientX - rect.left) / rect.width) * 100
|
||||
const clamped = Math.min(100 - minRight, Math.max(minLeft, pct))
|
||||
setLeftPct(clamped)
|
||||
}
|
||||
const onUp = () => {
|
||||
draggingRef.current = false
|
||||
document.body.style.cursor = ""
|
||||
document.body.style.userSelect = ""
|
||||
}
|
||||
window.addEventListener("pointermove", onMove)
|
||||
window.addEventListener("pointerup", onUp)
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onMove)
|
||||
window.removeEventListener("pointerup", onUp)
|
||||
}
|
||||
}, [minLeft, minRight])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn("flex h-full w-full", className)}>
|
||||
<div style={{ width: `${leftPct}%` }} className="h-full min-w-0 overflow-hidden">
|
||||
{left}
|
||||
</div>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
onPointerDown={onPointerDown}
|
||||
className="w-1.5 shrink-0 cursor-col-resize bg-border transition-colors hover:bg-primary/40"
|
||||
/>
|
||||
<div style={{ width: `${100 - leftPct}%` }} className="h-full min-w-0 overflow-hidden">
|
||||
{right}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -123,6 +123,57 @@
|
||||
"highErrorWarningDesc": "The following questions have an error rate above 70% and are recommended for focused review",
|
||||
"noData": "No analytics data yet. Data will be available after students submit and grading is complete.",
|
||||
"viewAnalytics": "View Analytics"
|
||||
},
|
||||
"richEditor": {
|
||||
"title": "Rich Text Exam Builder",
|
||||
"description": "Paste exam text, use toolbar to mark questions/groups/blanks/dotted chars, live preview on the right",
|
||||
"basicInfo": "Basic Info",
|
||||
"sourceText": "Source Text",
|
||||
"sourceTextPlaceholder": "Paste the full exam text here...",
|
||||
"aiAutoMark": "AI Auto-Mark",
|
||||
"aiMarking": "AI marking...",
|
||||
"aiMarkSuccess": "AI auto-marking complete",
|
||||
"aiMarkFailed": "AI auto-marking failed",
|
||||
"aiMarkHint": "After pasting exam text, click this button to let AI auto-detect question structure",
|
||||
"editorArea": "Editor",
|
||||
"previewArea": "Preview",
|
||||
"emptyEditor": "Enter or paste exam content in the editor on the left",
|
||||
"emptyPreview": "Preview will appear here",
|
||||
"markQuestion": "Mark Question",
|
||||
"markGroup": "Mark Group",
|
||||
"markDotted": "Dotted Char",
|
||||
"markBlank": "Blank",
|
||||
"insertImage": "Insert Image",
|
||||
"questionType": "Type",
|
||||
"score": "Score",
|
||||
"groupTitle": "Group Title",
|
||||
"saveDraft": "Save Draft",
|
||||
"saving": "Saving...",
|
||||
"saveSuccess": "Exam draft created",
|
||||
"saveFailed": "Failed to create",
|
||||
"loadFormFailed": "Failed to load form data",
|
||||
"titleRequired": "Please fill in the exam title",
|
||||
"subjectGradeRequired": "Please select subject and grade",
|
||||
"emptyContent": "Exam content is empty",
|
||||
"pasteSourceFirst": "Please paste exam text first",
|
||||
"bold": "Bold",
|
||||
"italic": "Italic",
|
||||
"strike": "Strikethrough",
|
||||
"dotted": "Dotted",
|
||||
"bulletList": "Bullet List",
|
||||
"orderedList": "Ordered List",
|
||||
"quote": "Quote",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"questionTypes": {
|
||||
"single_choice": "Single Choice",
|
||||
"multiple_choice": "Multiple Choice",
|
||||
"true_false": "True/False",
|
||||
"fill_blank": "Fill in the Blank",
|
||||
"short_answer": "Short Answer",
|
||||
"essay": "Essay",
|
||||
"text": "Text"
|
||||
}
|
||||
}
|
||||
},
|
||||
"homework": {
|
||||
@@ -238,7 +289,18 @@
|
||||
"autoSaveSaved": "Auto-saved",
|
||||
"autoSaveError": "Auto-save failed. Will retry when network recovers.",
|
||||
"autoSaveRestored": "Restored unsaved answers from offline cache",
|
||||
"autoSaveCacheError": "Failed to restore offline cache"
|
||||
"autoSaveCacheError": "Failed to restore offline cache",
|
||||
"scanTitle": "Answer Scan Upload",
|
||||
"scanDescription": "After answering on paper, photograph and upload the full paper",
|
||||
"selectImageFiles": "Please select image files",
|
||||
"uploadFailed": "Upload failed",
|
||||
"uploadSuccess": "Uploaded {{count}} images",
|
||||
"deleteScan": "Delete",
|
||||
"moveUp": "Move Up",
|
||||
"moveDown": "Move Down",
|
||||
"dragDropHint": "Drag images here, or click to select files",
|
||||
"pageLabel": "Page {{page}}",
|
||||
"scanDisabled": "Submitted, scan images cannot be modified"
|
||||
},
|
||||
"grade": {
|
||||
"title": "Grade",
|
||||
@@ -286,7 +348,21 @@
|
||||
"batchSelectAtLeastOne": "Please select at least one submission",
|
||||
"batchFailed": "Batch grading failed",
|
||||
"selectAll": "Select All",
|
||||
"selectRow": "Select this row"
|
||||
"selectRow": "Select this row",
|
||||
"scanGrading": "Scan Grading",
|
||||
"prevSubmission": "Previous",
|
||||
"nextSubmission": "Next",
|
||||
"saveGrading": "Save Grading",
|
||||
"questionsAndGrading": "Questions & Grading",
|
||||
"studentScanImages": "Student Scan Images",
|
||||
"loadingScans": "Loading...",
|
||||
"loadingImages": "Loading scan images...",
|
||||
"noScans": "No scan images",
|
||||
"saveFailed": "Save failed",
|
||||
"scanFeedbackPlaceholder": "Grading feedback (optional)...",
|
||||
"scoreOutOf": "/ {{max}} pts",
|
||||
"questionsCount": "Questions & Grading ({{count}} items)",
|
||||
"scanPagesCount": "Student Scan Images ({{count}} pages)"
|
||||
},
|
||||
"review": {
|
||||
"title": "Review",
|
||||
|
||||
@@ -123,6 +123,57 @@
|
||||
"highErrorWarningDesc": "以下题目错误率超过 70%,建议重点讲解",
|
||||
"noData": "暂无分析数据,需等待学生提交并批改后生成",
|
||||
"viewAnalytics": "查看分析"
|
||||
},
|
||||
"richEditor": {
|
||||
"title": "富文本组卷",
|
||||
"description": "粘贴试卷文本,使用工具栏标记题目/分组/填空/加点字,右侧实时预览",
|
||||
"basicInfo": "基本信息",
|
||||
"sourceText": "试卷原文",
|
||||
"sourceTextPlaceholder": "在此粘贴完整试卷文本...",
|
||||
"aiAutoMark": "AI 自动标记",
|
||||
"aiMarking": "AI 标记中...",
|
||||
"aiMarkSuccess": "AI 自动标记完成",
|
||||
"aiMarkFailed": "AI 自动标记失败",
|
||||
"aiMarkHint": "粘贴试卷文本后,点击此按钮让 AI 自动识别题目结构",
|
||||
"editorArea": "编辑区",
|
||||
"previewArea": "预览区",
|
||||
"emptyEditor": "请在左侧编辑区输入或粘贴试卷内容",
|
||||
"emptyPreview": "预览将在此处显示",
|
||||
"markQuestion": "标记题目",
|
||||
"markGroup": "标记分组",
|
||||
"markDotted": "加点字",
|
||||
"markBlank": "填空",
|
||||
"insertImage": "插入图片",
|
||||
"questionType": "题型",
|
||||
"score": "分值",
|
||||
"groupTitle": "分组标题",
|
||||
"saveDraft": "保存草稿",
|
||||
"saving": "保存中...",
|
||||
"saveSuccess": "试卷草稿已创建",
|
||||
"saveFailed": "创建失败",
|
||||
"loadFormFailed": "加载表单数据失败",
|
||||
"titleRequired": "请填写试卷标题",
|
||||
"subjectGradeRequired": "请选择科目和年级",
|
||||
"emptyContent": "试卷内容为空",
|
||||
"pasteSourceFirst": "请先粘贴试卷文本",
|
||||
"bold": "加粗",
|
||||
"italic": "斜体",
|
||||
"strike": "删除线",
|
||||
"dotted": "加点字",
|
||||
"bulletList": "无序列表",
|
||||
"orderedList": "有序列表",
|
||||
"quote": "引用",
|
||||
"undo": "撤销",
|
||||
"redo": "重做",
|
||||
"questionTypes": {
|
||||
"single_choice": "单选题",
|
||||
"multiple_choice": "多选题",
|
||||
"true_false": "判断题",
|
||||
"fill_blank": "填空题",
|
||||
"short_answer": "简答题",
|
||||
"essay": "作文题",
|
||||
"text": "文本题"
|
||||
}
|
||||
}
|
||||
},
|
||||
"homework": {
|
||||
@@ -238,7 +289,18 @@
|
||||
"autoSaveSaved": "已自动保存",
|
||||
"autoSaveError": "自动保存失败,将在网络恢复后重试",
|
||||
"autoSaveRestored": "已从离线缓存恢复未提交的答案",
|
||||
"autoSaveCacheError": "离线缓存恢复失败"
|
||||
"autoSaveCacheError": "离线缓存恢复失败",
|
||||
"scanTitle": "答题拍照上传",
|
||||
"scanDescription": "在纸上作答后,拍照上传整卷答题图片",
|
||||
"selectImageFiles": "请选择图片文件",
|
||||
"uploadFailed": "上传失败",
|
||||
"uploadSuccess": "已上传 {{count}} 张图片",
|
||||
"deleteScan": "删除",
|
||||
"moveUp": "上移",
|
||||
"moveDown": "下移",
|
||||
"dragDropHint": "拖拽图片到此处,或点击选择文件",
|
||||
"pageLabel": "第 {{page}} 页",
|
||||
"scanDisabled": "已提交,无法修改答题图片"
|
||||
},
|
||||
"grade": {
|
||||
"title": "批改",
|
||||
@@ -286,7 +348,21 @@
|
||||
"batchSelectAtLeastOne": "请至少选择一份提交",
|
||||
"batchFailed": "批量批改失败",
|
||||
"selectAll": "全选",
|
||||
"selectRow": "选择此行"
|
||||
"selectRow": "选择此行",
|
||||
"scanGrading": "阅卷批改",
|
||||
"prevSubmission": "上一份",
|
||||
"nextSubmission": "下一份",
|
||||
"saveGrading": "保存批改",
|
||||
"questionsAndGrading": "题目与批改",
|
||||
"studentScanImages": "学生答题图片",
|
||||
"loadingScans": "加载中...",
|
||||
"loadingImages": "加载答题图片...",
|
||||
"noScans": "暂无答题图片",
|
||||
"saveFailed": "保存失败",
|
||||
"scanFeedbackPlaceholder": "批改评语(可选)...",
|
||||
"scoreOutOf": "/ {{max}} 分",
|
||||
"questionsCount": "题目与批改({{count}} 题)",
|
||||
"scanPagesCount": "学生答题图片({{count}} 页)"
|
||||
},
|
||||
"review": {
|
||||
"title": "复习",
|
||||
|
||||
Reference in New Issue
Block a user