Compare commits

..

13 Commits

Author SHA1 Message Date
SpecialX
e27efb6282 feat(exams): update question bank list, rich form, and selection toolbar
Some checks failed
Security / deep-security-scan (push) Failing after 2m23s
DR Drill / dr-drill (push) Failing after 11m59s
CI / scheduled-backup (push) Failing after 37s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
- Update question-bank-list component for exam assembly

- Update exam-rich-form for rich text exam editing

- Update selection-toolbar for editor selection actions
2026-06-24 15:37:10 +08:00
SpecialX
90f7d395f2 fix(exams): fix toolbar tracking and prevent list-to-options for non-choice questions
Two fixes:

1. Selection toolbar tracking: add pointerup listener so the toolbar
   updates its position immediately after the user finishes dragging
   the selection, not just on selectionUpdate events which can lag.

2. List-to-options conversion: parseOptions now only converts
   orderedList/bulletList to A/B/C options for choice question types
   (single_choice, multiple_choice, judgment). For text/composite
   questions, list content is preserved as part of the question stem
   text, preventing unwanted 'A.' prefixes.
2026-06-24 15:13:49 +08:00
SpecialX
e9429935b9 revert: roll back to ccf6c03 state (composite sub-questions preserved)
Reverts the following commits that broke composite question handling:
- 85661a5 auto-detect composite sub-questions from text patterns
- 064b3cf use slice to preserve full content when wrapping selections
- 2562de7 remove isolating to allow nested question blocks

Restores the working state from ccf6c03 where:
- wrapInQuestion fails gracefully in isolating nodes
- selected text is preserved when creating sub-questions
- composite question structure is intact
2026-06-24 15:01:56 +08:00
SpecialX
85661a5ba9 fix(exams): auto-detect composite sub-questions from text patterns
Root cause: When users paste reading comprehension content into a
composite question block without manually marking each sub-question
as a questionBlock, the sub-questions were treated as plain text and
merged into the question stem. This caused:
1. Sub-questions not showing in the preview's sub-question area
2. Text from different paragraphs being concatenated without newlines
3. "A." appearing because parseOptions misidentified list items

Fix:
1. extractText now inserts newlines between paragraphs/listItems,
   preserving text structure so pattern detection can work
2. Added detectSubQuestionsFromText: for composite questions without
   explicit questionBlock children, auto-detect sub-questions from
   text patterns:
   - Numbered lines: "1.xxx", "2.xxx", "(1)xxx", "①xxx"
   - Lines with score markers: "xxx(3分)"
   - If numbered sub-questions are found, check preceding lines for
     un-numbered sub-questions (e.g., the first sub-question that
     lacks a number but has a score marker)
3. extractMaterialText removes detected sub-question text from the
   question stem, keeping only the reading material/passage

This allows users to paste reading comprehension content directly
into a composite question block and have sub-questions automatically
detected, without needing to manually mark each one.
2026-06-24 14:53:05 +08:00
SpecialX
064b3cf736 fix(exams): use slice to preserve full content when wrapping selections
Root cause: wrapIn (Tiptap's built-in command) can fail or lose content
when the selection spans multiple paragraphs or partial paragraphs. When
users selected a long reading passage and clicked "复合" (composite),
the content was destroyed — only fragments remained.

Fix: replace wrapIn with a manual slice-based approach:
1. Use doc.slice(from, to) to get the complete node structure of the
   selection (preserves paragraphs, lists, images, etc.)
2. deleteRange to remove the original selection
3. insertContentAt to insert a new questionBlock/groupBlock/sectionBlock
   containing the sliced content

This is more reliable than wrapIn because it doesn't depend on
ProseMirror's wrapping logic, which has edge cases with multi-paragraph
selections. The slice API captures the exact node structure, so no
content is lost.

Applied to all three wrapping operations:
- insertQuestion (questionBlock)
- insertGroup (groupBlock)
- insertSection (sectionBlock)
2026-06-24 14:37:01 +08:00
SpecialX
2562de76b7 fix(exams): remove isolating to allow nested question blocks
Root cause: questionBlock/groupBlock/sectionBlock had `isolating: true`,
which prevents ProseMirror operations (wrapIn, insertContentAt) from
inserting nodes inside these blocks. When users selected text inside a
composite question and clicked "填空/简答" to create a sub-question,
the new questionBlock was inserted OUTSIDE the composite block instead
of inside it, so buildQuestion couldn't detect it as a subQuestion.

Fix: remove `isolating: true` from all three block extensions. The
`defining: true` property is sufficient to maintain node boundaries
during normal editing operations.

This allows wrapInQuestion to work correctly inside composite questions,
creating properly nested sub-question blocks that are detected by
buildQuestion's recursive parsing.
2026-06-24 14:33:04 +08:00
SpecialX
ccf6c03096 fix(exams): preserve selected text when wrapIn fails in isolating nodes
When wrapIn fails inside isolating nodes (e.g. composite question block),
the previous fallback used insertContent which replaced the entire
selection with an empty questionBlock, causing other sub-questions to
disappear and content to be cleared.

New approach: when wrapIn fails, extract the selected text, delete the
selection, then insert a new node (questionBlock/groupBlock/sectionBlock)
containing the selected text as paragraphs. This preserves the content
and converts it into the desired structure.

Applied to all three wrap operations:
- insertQuestion (questionBlock)
- insertGroup (groupBlock)
- insertSection (sectionBlock)
2026-06-24 14:24:04 +08:00
SpecialX
df9561128b fix(exams): fix duplicate React keys and composite question marking
- Fix parseOptions: deduplicate option ids within a single question to
  avoid multiple "A" keys when a question contains multiple lists
- Fix ExamPreview: use composite keys (questionId-optId, questionId-sub-id)
  to ensure global uniqueness across questions
- Fix selection-toolbar: when wrapInQuestion fails inside isolating nodes
  (e.g. composite question block), fall back to insertQuestion instead of
  silently doing nothing
2026-06-24 14:19:46 +08:00
SpecialX
1f28efbeb6 feat(exams): add section/group structure nodes with auto stats
Distinguish structural levels from questions:
- sectionBlock (new): top-level volume like "第Ⅰ卷 选择题(共24分)"
- groupBlock (enhanced): section like "一、选择题" with instruction field
- questionBlock (composite): reserved for reading comprehension

Key changes:
- New section-block.tsx extension with level attr (1=卷, 2=部分) and
  auto-computed question count + total score in NodeView
- group-block.tsx: add instruction field ("每小题3分"), auto stats display
- editor-to-structure.ts: recursive buildStructureNode supports arbitrary
  nesting (section > group > question), computeStats accumulates scores
- exam-rich-form.tsx ExamPreview: render section/group/question with
  distinct styles and stats badges
- selection-toolbar.tsx: add "分卷" button (Layers icon)
- exam-rich-editor.tsx: register SectionBlock, expose insertSection/
  wrapInSection via ref
- actions.ts: AI prompt now outputs volumes[] + groups[] structure with
  instruction; buildTiptapDocFromAiResponse generates nested sectionBlock
- i18n: add markSection keys (zh-CN/en)

Structural nodes are NOT questions: their question count and total score
are automatically computed from child questions, not manually set.
2026-06-24 14:07:29 +08:00
SpecialX
f260720443 fix(exams): fix composite question sub-questions not showing in preview
- Fix buildQuestion in editor-to-structure.ts: recursively detect nested
  questionBlock nodes as subQuestions (composite questions now properly
  extract child questions instead of treating them as plain text)
- Fix buildQuestionBlock in actions.ts: AI auto-mark now generates nested
  questionBlock nodes for sub-questions instead of plain paragraphs, so
  they are properly structured and detectable by the parser
- Rewrite ExamPreview component with proper layout:
  - Question header: number + type label badge + score
  - Indented question text and options
  - Composite sub-questions shown in nested block with left border
  - Image thumbnails
  - Empty state message
  - Title centered at top
  - Group titles with bottom border
2026-06-24 13:54:24 +08:00
SpecialX
7380f1e6c8 fix(exams): fix rich editor crashes and redesign form layout
- Fix groupBlock/questionBlock insertContent error: empty content violates
  schema "block+", now provide default empty paragraph
- Fix buildTiptapDocFromAiResponse: ensure groupBlock/questionBlock always
  have content (fallback to empty paragraph)
- Add wrapInGroup/wrapInQuestion commands to wrap selected text into
  question/group blocks (vs insert which creates empty blocks)
- Update SelectionToolbar: use wrap when text selected, insert when not
- Redesign exam-rich-form layout:
  - Merge basic info into single-row toolbar (title/subject/grade/difficulty/score/duration)
  - Remove separate "source text" textarea (user pastes directly in editor)
  - AI auto-mark now reads from editor content via getText()
  - Editor + preview takes full height (calc(100vh-180px))
- Enhance AI prompt for Chinese exam papers:
  - Support reading material (阅读理解选段)
  - Support dotted chars (加点字注音)
  - Support sub-questions (阅读理解小题)
  - Better type detection (single/multiple choice, judgment, fill, essay, composite)
- Add splitByDottedTexts helper to mark dotted chars in Tiptap doc
- Add i18n keys for titlePlaceholder, editorArea description
2026-06-24 13:41:39 +08:00
SpecialX
d1e4ccbf98 refactor(exams): redesign exam creation page with 3-mode selector
- Replace cramped 3-column grid with vertical layout
- Add 3 large selectable cards at top: Manual / AI / Rich Text Editor
- Rich Text Editor mode redirects to /teacher/exams/new
- Basic info form is now always visible (not hidden in AI mode)
- Exam mode config always visible at bottom
- Add "rich" to mode enum with validation bypass
- Replace all hardcoded English/Chinese strings with i18n keys
- Add 20+ new i18n keys to zh-CN and en (mode labels, descriptions, actions)
- Clean up mixed-language UI text
2026-06-24 13:23:13 +08:00
SpecialX
6114607c1e 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
2026-06-24 13:16:33 +08:00
37 changed files with 4592 additions and 168 deletions

View File

@@ -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` | 通用分页 UIShowing 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 + FilterResetButtonP3-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 新增年级仪表盘维度3exams 表有直接 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-serviceV3-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` 聚合导出上述所有组件、扩展、类型与转换函数
- ComponentsV3-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 ActionP1-2 已修复,无直接 DB 操作) |
| `actions.ts` | 691+ | 12 个 Server ActionP1-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 新增 getExamsByGradeId2026-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 新增editorDocToStructureTiptap JSONContent → EditorDoc |
| `editor/structure-to-editor.ts` | - | 2026-06-24 新增structureToEditorDocEditorDoc → 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-write11 个写操作函数P1-2 新增 10 个从 actions 下沉 + V3-7 新增 `batchAutoGradeSubmissions`
- Stats-service`getTeacherGradeTrends` / `getHomeworkAssignmentAnalytics` / `getStudentDashboardGrades`(从 data-access.ts re-export 以保持向后兼容)
- ComponentsV3-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 ActionP1-2 已修复,无直接 DB 操作V3-7 新增 `batchAutoGradeSubmissionsAction` |
| `actions.ts` | 239+ | 8 个 Server ActionP1-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/uploadtargetType="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` 输出错误上下文

View File

@@ -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块级。含 typeQuestionBlockType: single_choice|multiple_choice|judgment|text|composite/score 等 attrsQuestionBlockAttrs用于包裹一道题目。",
"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/ZoomOut0.25 步进1-3 范围、旋转RotateCw90° 步进、全屏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",

View File

@@ -1,18 +1,18 @@
import type { JSX } from "react"
import { getTranslations } from "next-intl/server"
import { ExamForm } from "@/modules/exams/components/exam-form"
export const dynamic = "force-dynamic"
export default function CreateExamPage(): JSX.Element {
export default async function CreateExamPage(): Promise<JSX.Element> {
const t = await getTranslations("examHomework")
return (
<div className="flex w-full justify-center items-center min-h-[calc(100vh-160px)] p-8 max-w-[1200px] mx-auto">
<div className="w-full space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Create Exam</h1>
<p className="text-muted-foreground">Configure a new exam for your classes.</p>
</div>
<ExamForm />
<div className="mx-auto w-full max-w-[1200px] space-y-6 p-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t("exam.form.createTitle")}</h1>
<p className="text-muted-foreground">{t("exam.form.createDescription")}</p>
</div>
<ExamForm />
</div>
)
}

View 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>
)
}

View File

@@ -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}>

View File

@@ -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>
)
}

View File

@@ -20,6 +20,7 @@ const VALID_TARGET_TYPES: FileTargetType[] = [
"textbook",
"question",
"announcement",
"homework",
]
const isTargetType = (v: string | null): v is FileTargetType =>

View File

@@ -878,4 +878,481 @@ 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. **分卷**:识别\"第Ⅰ卷\"\"第Ⅱ卷\"\"第一部分\"等顶层分卷标记,输出到 volumes。每个 volume 含 title、instruction(可选)和 groups。",
"2. **大题分组**:识别\"一、选择题\"\"二、填空题\"\"三、阅读理解\"等大题标题,输出到 groups(若在 volume 内则归入对应 volume,否则归入顶层 groups)。每个 group 含 title、instruction(如\"每小题3分,共24分\")和 questions。",
"3. **题型识别**:",
" - 选择题(单选/多选):题干 + A/B/C/D 选项 → type: \"single_choice\" 或 \"multiple_choice\"",
" - 判断题:题干 + 对/错 → type: \"judgment\"",
" - 填空题:题干含横线空(_______或( )) → type: \"text\",在 blanks 中标记空数",
" - 简答/作文题:题干 + \"不少于X字\" → type: \"text\"",
" - 阅读理解(含选段+多小题)→ type: \"composite\",subQuestions 存小题",
"4. **分值**:从\"每小题3分\"\"共24分\"\"(12分)\"等提取,分摊到每题。",
"5. **选项**:A. B. C. D. 格式,输出到 content.options,每项含 id 和 text。",
"6. **填空**:横线\"_______\"或括号\"( )\"位置,在 content.blanks 中标记(数组长度=空数)。",
"7. **加点字**:拼音注音题中加点的字(如\"解\"剖),在 content.dottedTexts 中列出原文片段。",
"8. **子题**:阅读理解下的\"1. 2. 3.\"小题,输出到 content.subQuestions,每项含 text 和 score。",
"9. **阅读材料**:阅读理解的文章选段,放到 content.readingMaterial 字段。",
"10. **说明文字**:\"每小题3分,共24分\"\"选择正确答案的番号填涂在答题卡上\"等放到 group.instruction,不要混入题干。",
"",
"## 输出 JSON schema",
"",
"```json",
"{",
' "title": "试卷标题",',
' "volumes": [',
" {",
' "title": "第Ⅰ卷 选择题",',
' "groups": [',
" {",
' "title": "一、选择正确答案的番号填涂在答题卡上",',
' "instruction": "每小题3分,共24分",',
' "questions": [',
" {",
' "type": "single_choice",',
' "score": 3,',
' "content": {',
' "text": "下面加点字的注音全部正确的一项是",',
' "options": [{"id":"A","text":"解剖(pō) 蹭饭(cènɡ)"},{"id":"B","text":"栖息(qī) 譬如(pì)"}],',
' "blanks": [],',
' "dottedTexts": ["解","蹭","徉"],',
' "subQuestions": []',
" }",
" }",
" ]",
" }",
" ]",
" }",
" ],",
' "groups": []',
"}",
"```",
"",
"## 注意事项",
"- 不要输出 markdown 代码块标记(```),直接输出 JSON。",
"- 不要输出 ... 或 [...] 等占位符,必须输出完整数据。",
"- 如果没有 volumes,可直接返回 { \"groups\": [...] } 或 { \"questions\": [...] }。",
"- 阅读理解的选段文本放到 content.readingMaterial,不要混入题干 text。",
"- 作文题的\"不少于300字\"等要求保留在 text 中。",
"- \"共24分\"等总分信息不要写进 title,放到 instruction;实际总分由子题自动累加。",
].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 : ""
}
/**
* 将文本按加点字片段切分,返回带 dotted 标记的片段数组。
* 用于在 Tiptap 文档中标记加点字(下加点)。
*/
const splitByDottedTexts = (
text: string,
dottedTexts: string[]
): Array<{ text: string; dotted: boolean }> => {
if (dottedTexts.length === 0) return [{ text, dotted: false }]
const result: Array<{ text: string; dotted: boolean }> = []
let remaining = text
while (remaining.length > 0) {
// 找到最早出现的加点字
let earliestIdx = -1
let earliestText = ""
for (const dt of dottedTexts) {
if (!dt) continue
const idx = remaining.indexOf(dt)
if (idx >= 0 && (earliestIdx === -1 || idx < earliestIdx)) {
earliestIdx = idx
earliestText = dt
}
}
if (earliestIdx === -1) {
// 没有更多加点字,剩余文本作为普通片段
if (remaining.length > 0) {
result.push({ text: remaining, dotted: false })
}
break
}
// 加点字之前的普通文本
if (earliestIdx > 0) {
result.push({ text: remaining.slice(0, earliestIdx), dotted: false })
}
// 加点字片段
result.push({ text: earliestText, dotted: true })
remaining = remaining.slice(earliestIdx + earliestText.length)
}
return result
}
/**
* 将 AI 返回的结构化 JSON 转换为 Tiptap JSONContent 文档。
* 支持 sections(分组)和顶层 questions 两种形式。
*/
const buildTiptapDocFromAiResponse = (data: unknown): unknown => {
if (!isRecord(data)) return { type: "doc", content: [] }
const content: unknown[] = []
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 readingMaterial =
typeof contentNode.readingMaterial === "string"
? contentNode.readingMaterial
: ""
if (readingMaterial) {
const materialLines = readingMaterial
.split("\n")
.filter((l) => l.trim().length > 0)
for (const line of materialLines) {
inner.push({
type: "paragraph",
content: [{ type: "text", text: line, marks: [{ type: "italic" }] }],
})
}
}
// 题干段落(按行拆分),处理加点字标记
const dottedTexts = Array.isArray(contentNode.dottedTexts)
? (contentNode.dottedTexts as unknown[])
.filter((s): s is string => typeof s === "string")
: []
const lines = text.split("\n").filter((l) => l.trim().length > 0)
for (const line of lines) {
// 如果有加点字,在文本中标记对应片段
if (dottedTexts.length > 0) {
const segments = splitByDottedTexts(line, dottedTexts)
const textNodes = segments.map((seg) =>
seg.dotted
? {
type: "text",
text: seg.text,
marks: [{ type: "dotted" }],
}
: { type: "text", text: seg.text }
)
inner.push({ type: "paragraph", content: textNodes })
} else {
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}` }],
},
],
}
}),
})
}
// 子题(阅读理解的小题)—— 生成为嵌套的 questionBlock,便于编辑器层级展示与解析
const subQuestions = Array.isArray(contentNode.subQuestions)
? contentNode.subQuestions
: []
for (const sub of subQuestions) {
if (!isRecord(sub)) continue
const subText = typeof sub.text === "string" ? sub.text : ""
const subScore = typeof sub.score === "number" ? sub.score : 0
if (subText) {
// 子题文本按行拆分为段落
const subLines = subText.split("\n").filter((l) => l.trim().length > 0)
const subInner =
subLines.length > 0
? subLines.map((line) => ({
type: "paragraph",
content: [{ type: "text", text: line }],
}))
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
inner.push({
type: "questionBlock",
attrs: { questionId: "", type: "text", score: subScore },
content: subInner,
})
}
}
// questionBlock 要求 content: "block+",无内容时给空段落
const innerContent =
inner.length > 0
? inner
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
return {
type: "questionBlock",
attrs: { questionId: "", type, score },
content: innerContent,
}
}
// 构建 groupBlock(大题分组),含 instruction
const buildGroupBlock = (g: unknown): unknown | null => {
if (!isRecord(g)) return null
const title = typeof g.title === "string" ? g.title : ""
const instruction = typeof g.instruction === "string" ? g.instruction : ""
const questions = Array.isArray(g.questions) ? g.questions : []
const children = questions
.map(buildQuestionBlock)
.filter((b): b is Record<string, unknown> => b !== null)
const groupContent =
children.length > 0
? children
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
return {
type: "groupBlock",
attrs: { title, instruction },
content: groupContent,
}
}
// 构建 sectionBlock(分卷),内含多个 groupBlock
const volumes = Array.isArray(data.volumes) ? data.volumes : []
const topGroups = Array.isArray(data.groups) ? data.groups : []
for (const volume of volumes) {
if (!isRecord(volume)) continue
const title = typeof volume.title === "string" ? volume.title : ""
const innerGroups = Array.isArray(volume.groups) ? volume.groups : []
const groupBlocks = innerGroups
.map(buildGroupBlock)
.filter((b): b is Record<string, unknown> => b !== null)
const sectionContent =
groupBlocks.length > 0
? groupBlocks
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
content.push({
type: "sectionBlock",
attrs: { title, level: 1 },
content: sectionContent,
})
}
// 顶层 groups(无分卷时)
for (const g of topGroups) {
const block = buildGroupBlock(g)
if (block) content.push(block)
}
// 顶层 questions(无分卷无大题时)
if (volumes.length === 0 && topGroups.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)
}
}

View File

@@ -12,9 +12,50 @@ import {
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { Plus, Sparkles } from "lucide-react"
import type { Question } from "@/modules/questions/types"
import { createId } from "@paralleldrive/cuid2"
import type { Question, QuestionType } from "@/modules/questions/types"
import { QuestionTypeEnum } from "@/modules/questions/schema"
import { AiQuestionVariantGenerator } from "@/modules/ai/components/ai-question-variant-generator"
import { useAiClientOptional } from "@/modules/ai/context/ai-client-provider"
import type { QuestionVariantResult } from "@/modules/ai/types"
/** 合法题型集合,用于运行时校验 AI 返回的 type 字符串 */
const QUESTION_TYPES = new Set<string>(QuestionTypeEnum.options)
/**
* 类型守卫:校验字符串是否为合法 QuestionType
*
* AI 返回的 type 是 string需收窄为 QuestionType 联合类型,
* 避免直接使用 as 断言。
*/
function isQuestionType(value: string): value is QuestionType {
return QUESTION_TYPES.has(value)
}
/**
* 将 AI 生成的题目变体转换为 Question 对象
*
* 变体在客户端生成临时 IDcuid2加入试卷结构后随试卷保存时持久化。
* content 结构与题库一致:{ text, options, answer, explanation }。
*/
function variantToQuestion(variant: QuestionVariantResult): Question {
const type: QuestionType = isQuestionType(variant.type) ? variant.type : "single_choice"
return {
id: createId(),
content: {
text: variant.text,
options: variant.options,
answer: variant.answer,
explanation: variant.explanation,
},
type,
difficulty: variant.difficulty,
createdAt: new Date(),
updatedAt: new Date(),
author: null,
knowledgePoints: [],
}
}
type QuestionBankListProps = {
questions: Question[]
@@ -46,7 +87,7 @@ function extractQuestionText(content: unknown): string {
}
}
export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMore, isLoading, subject }: QuestionBankListProps) {
export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMore, isLoading, subject }: QuestionBankListProps): React.ReactNode {
const aiClient = useAiClientOptional()
if (questions.length === 0 && !isLoading) {
@@ -104,6 +145,7 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
questionType={q.type}
difficulty={q.difficulty}
subject={subject}
onAdd={onAdd}
/>
) : null}
<Button
@@ -149,20 +191,28 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
* AI 题目变体生成对话框
*
* 独立组件,仅在用户点击时挂载,避免不必要的渲染。
* 采纳变体后自动关闭对话框。
*/
function AiVariantDialog({
questionText,
questionType,
difficulty,
subject,
onAdd,
}: {
questionText: string
questionType: string
difficulty: number
subject?: string
}) {
onAdd: (question: Question) => void
}): React.ReactNode {
const [open, setOpen] = useState(false)
const handleAddVariant = (variant: QuestionVariantResult): void => {
onAdd(variantToQuestion(variant))
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
@@ -189,6 +239,7 @@ function AiVariantDialog({
difficulty,
}}
subject={subject}
onAddVariant={handleAddVariant}
/>
</DialogContent>
</Dialog>

View File

@@ -1,5 +1,6 @@
"use client"
import { useTranslations } from "next-intl"
import type { Control } from "react-hook-form"
import {
Card,
@@ -20,14 +21,6 @@ type ExamBasicInfoFormProps = {
loadingGrades: boolean
}
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)" },
]
export function ExamBasicInfoForm({
control,
subjects,
@@ -35,36 +28,46 @@ export function ExamBasicInfoForm({
loadingSubjects,
loadingGrades,
}: ExamBasicInfoFormProps) {
const t = useTranslations("examHomework")
const DIFFICULTY_OPTIONS = [
{ value: "1", label: t("exam.form.difficultyLevel1") },
{ value: "2", label: t("exam.form.difficultyLevel2") },
{ value: "3", label: t("exam.form.difficultyLevel3") },
{ value: "4", label: t("exam.form.difficultyLevel4") },
{ value: "5", label: t("exam.form.difficultyLevel5") },
]
return (
<Card>
<CardHeader>
<CardTitle>Exam Details</CardTitle>
<CardTitle>{t("exam.form.detailsTitle")}</CardTitle>
<CardDescription>
Define the core information for your exam.
{t("exam.form.detailsDesc")}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6">
<TextField
control={control}
name="title"
label="Title"
placeholder="e.g. Midterm Mathematics Exam"
label={t("exam.form.title")}
placeholder={t("exam.form.titlePlaceholder")}
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<SelectField
control={control}
name="subject"
label="Subject"
placeholder={loadingSubjects ? "Loading subjects..." : "Select subject"}
label={t("exam.form.subject")}
placeholder={loadingSubjects ? t("exam.form.loadingSubjects") : t("exam.form.subjectPlaceholder")}
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
disabled={loadingSubjects}
/>
<SelectField
control={control}
name="grade"
label="Grade Level"
placeholder={loadingGrades ? "Loading grades..." : "Select grade level"}
label={t("exam.form.grade")}
placeholder={loadingGrades ? t("exam.form.loadingGrades") : t("exam.form.gradePlaceholder")}
options={grades.map((g) => ({ value: g.id, label: g.name }))}
disabled={loadingGrades}
/>
@@ -74,20 +77,20 @@ export function ExamBasicInfoForm({
<SelectField
control={control}
name="difficulty"
label="Difficulty"
placeholder="Select level"
label={t("exam.form.difficulty")}
placeholder={t("exam.form.difficultyPlaceholder")}
options={DIFFICULTY_OPTIONS}
/>
<TextField
control={control}
name="totalScore"
label="Total Score"
label={t("exam.form.totalScore")}
type="number"
/>
<TextField
control={control}
name="durationMin"
label="Duration (min)"
label={t("exam.form.durationMin")}
type="number"
/>
</div>
@@ -95,9 +98,9 @@ export function ExamBasicInfoForm({
<TextField
control={control}
name="scheduledAt"
label="Schedule Start Time (Optional)"
label={t("exam.form.scheduledAt")}
type="datetime-local"
description="If set, this exam will be scheduled for a specific time."
description={t("exam.form.scheduledAtDesc")}
/>
</CardContent>
</Card>

View File

@@ -11,7 +11,7 @@ export const formSchema = z.object({
totalScore: z.coerce.number().min(1, "Total score must be at least 1.").optional(),
durationMin: z.coerce.number().min(10, "Duration must be at least 10 minutes.").optional(),
scheduledAt: z.string().optional(),
mode: z.enum(["manual", "ai"]),
mode: z.enum(["manual", "ai", "rich"]),
aiSourceText: z.string().optional(),
aiQuestionCount: z.coerce.number().min(1).max(200).optional(),
aiProviderId: z.string().optional(),
@@ -23,6 +23,9 @@ export const formSchema = z.object({
lateStartGraceMinutes: z.coerce.number().int().min(0).default(0),
antiCheatEnabled: z.boolean().default(false),
}).superRefine((data, ctx) => {
// 富文本模式不需要表单校验,直接跳转到 /teacher/exams/new
if (data.mode === "rich") return
// 监考模式必须设置考试时长
if (data.examMode === "proctored" && (data.durationMinutes === null || data.durationMinutes === undefined || data.durationMinutes < 1)) {
ctx.addIssue({

View File

@@ -2,6 +2,7 @@
import { useTransition, useEffect, useState, type FormEvent } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { useForm, type Resolver } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
@@ -24,6 +25,7 @@ export type { ExamFormValues } from "./exam-form-types"
export function ExamForm() {
const router = useRouter()
const t = useTranslations("examHomework")
const [isPending, startTransition] = useTransition()
const [subjects, setSubjects] = useState<{ id: string; name: string }[]>([])
const [loadingSubjects, setLoadingSubjects] = useState(true)
@@ -56,7 +58,7 @@ export function ExamForm() {
form.setValue("subject", subjectsResult.data[0].id)
}
} else {
toast.error("Failed to load subjects")
toast.error(t("exam.form.loadSubjectsFailed"))
}
if (gradesResult.success && gradesResult.data) {
@@ -65,7 +67,7 @@ export function ExamForm() {
form.setValue("grade", gradesResult.data[0].id)
}
} else {
toast.error("Failed to load grades")
toast.error(t("exam.form.loadGradesFailed"))
}
if (aiProvidersResult.success && aiProvidersResult.data) {
setAiProviders(aiProvidersResult.data)
@@ -79,7 +81,7 @@ export function ExamForm() {
}
} catch (error) {
console.error(error)
toast.error("Failed to load form data")
toast.error(t("exam.form.loadFormFailed"))
} finally {
setLoadingSubjects(false)
setLoadingGrades(false)
@@ -87,10 +89,11 @@ export function ExamForm() {
}
}
void fetchMetadata()
}, [form])
}, [form, t])
const mode = form.watch("mode")
const isAiMode = mode === "ai"
const isRichMode = mode === "rich"
const previewSubject = form.watch("subject")
const previewGrade = form.watch("grade")
const previewDuration = form.watch("durationMin") ?? 90
@@ -98,6 +101,12 @@ export function ExamForm() {
const previewTitleValue = form.watch("title")
function onSubmit(data: ExamFormValues) {
// 富文本模式不通过表单提交,直接跳转
if (data.mode === "rich") {
router.push("/teacher/exams/new")
return
}
const resolvedTitle = data.mode === "ai" && !data.title?.trim() ? "AI Exam" : data.title?.trim() ?? ""
const resolvedSubject = data.subject?.trim() || subjects[0]?.id || ""
const resolvedGrade = data.grade?.trim() || grades[0]?.id || ""
@@ -106,13 +115,13 @@ export function ExamForm() {
const resolvedDurationMin = typeof data.durationMin === "number" && data.durationMin > 0 ? data.durationMin : 90
if (data.mode === "ai" && (!resolvedSubject || !resolvedGrade)) {
toast.error("Missing subject or grade configuration")
toast.error(t("exam.form.missingSubjectOrGrade"))
return
}
if (data.mode === "ai") {
const signature = preview.buildPreviewSignature(data)
if (!preview.previewSignature || signature !== preview.previewSignature || preview.previewNodes.length === 0) {
toast.error("Please preview and confirm before creating")
toast.error(t("exam.form.previewBeforeCreate"))
return
}
}
@@ -153,17 +162,21 @@ export function ExamForm() {
: await createExamAction(null, formData)
if (result.success && result.data) {
toast.success("Exam draft created", {
description: "Redirecting to exam builder...",
toast.success(t("exam.form.createSuccess"), {
description: t("exam.form.redirecting"),
})
router.push(`/teacher/exams/${result.data}/build`)
} else {
toast.error(result.message || "Failed to create exam")
toast.error(result.message || t("exam.form.createFailed"))
}
})
}
const handleCreateClick = () => {
if (isRichMode) {
router.push("/teacher/exams/new")
return
}
if (isAiMode) {
preview.handleBackgroundPreview()
return
@@ -198,10 +211,20 @@ export function ExamForm() {
}
return (
<div className="grid w-full grid-cols-3 gap-8">
<Form {...form}>
<form onSubmit={handleFormSubmit} className="col-span-2 space-y-6">
{!isAiMode && (
<div className="space-y-6">
{/* 顶部: 创建方式选择 */}
<ExamModeSelector
mode={mode}
setMode={(value) => form.setValue("mode", value)}
isPending={isPending}
handleCreateClick={handleCreateClick}
/>
{/* 富文本模式: 不显示下方表单 */}
{isRichMode ? null : (
<Form {...form}>
<form onSubmit={handleFormSubmit} className="space-y-6">
{/* 基本信息(始终可见) */}
<ExamBasicInfoForm
control={form.control}
subjects={subjects}
@@ -209,27 +232,31 @@ export function ExamForm() {
loadingSubjects={loadingSubjects}
loadingGrades={loadingGrades}
/>
)}
{isAiMode && (
<ExamAiGenerator
form={form}
control={form.control}
aiProviders={aiProviders}
setAiProviders={setAiProviders}
loadingAiProviders={loadingAiProviders}
handlePreview={preview.handlePreview}
handleBackgroundPreview={preview.handleBackgroundPreview}
previewLoading={preview.previewLoading}
previewTasks={preview.previewTasks}
handleOpenPreviewTask={preview.handleOpenPreviewTask}
activePreviewTaskCount={activePreviewTaskCount}
runningPreviewTaskCount={runningPreviewTaskCount}
queuedPreviewTaskCount={queuedPreviewTaskCount}
/>
)}
<ExamModeConfig />
</form>
</Form>
{/* AI 模式: 显示 AI 生成器 */}
{isAiMode && (
<ExamAiGenerator
form={form}
control={form.control}
aiProviders={aiProviders}
setAiProviders={setAiProviders}
loadingAiProviders={loadingAiProviders}
handlePreview={preview.handlePreview}
handleBackgroundPreview={preview.handleBackgroundPreview}
previewLoading={preview.previewLoading}
previewTasks={preview.previewTasks}
handleOpenPreviewTask={preview.handleOpenPreviewTask}
activePreviewTaskCount={activePreviewTaskCount}
runningPreviewTaskCount={runningPreviewTaskCount}
queuedPreviewTaskCount={queuedPreviewTaskCount}
/>
)}
{/* 考试模式配置(始终可见) */}
<ExamModeConfig />
</form>
</Form>
)}
<ExamPreviewDialog
previewOpen={preview.previewOpen}
@@ -254,15 +281,6 @@ export function ExamForm() {
handleConfirmCreate={handleConfirmCreate}
previewTitleValue={previewTitleValue}
/>
<div className="col-span-1 space-y-6">
<ExamModeSelector
mode={mode}
setMode={(value) => form.setValue("mode", value)}
isPending={isPending}
handleCreateClick={handleCreateClick}
/>
</div>
</div>
)
}

View File

@@ -1,20 +1,16 @@
"use client"
import { Loader2, Sparkles, BookOpen } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { Loader2, Sparkles, BookOpen, FileText, ArrowRight } from "lucide-react"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
export type ExamCreationMode = "manual" | "ai" | "rich"
type ExamModeSelectorProps = {
mode: "manual" | "ai"
setMode: (mode: "manual" | "ai") => void
mode: ExamCreationMode
setMode: (mode: ExamCreationMode) => void
isPending: boolean
handleCreateClick: () => void
}
@@ -25,61 +21,112 @@ export function ExamModeSelector({
isPending,
handleCreateClick,
}: ExamModeSelectorProps) {
return (
<Card>
<CardHeader>
<CardTitle>Assembly Mode</CardTitle>
<CardDescription>
Choose how to build the exam structure.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col space-y-3">
<button
type="button"
className={cn(
"relative flex cursor-pointer flex-col rounded-lg border p-4 shadow-sm outline-none transition-all hover:bg-accent hover:text-accent-foreground text-left",
mode === "manual" ? "border-primary ring-1 ring-primary" : "border-border"
)}
onClick={() => setMode("manual")}
>
<div className="flex items-center gap-2">
<BookOpen className="h-4 w-4 text-primary" />
<span className="font-medium">Manual Assembly</span>
</div>
<span className="mt-1 text-xs text-muted-foreground">
Manually select questions from the bank and organize structure.
</span>
</button>
const router = useRouter()
const t = useTranslations("examHomework")
<button
const modes: Array<{
key: ExamCreationMode
icon: typeof BookOpen
title: string
description: string
}> = [
{
key: "manual",
icon: BookOpen,
title: t("exam.form.modeManual"),
description: t("exam.form.modeManualDesc"),
},
{
key: "ai",
icon: Sparkles,
title: t("exam.form.modeAi"),
description: t("exam.form.modeAiDesc"),
},
{
key: "rich",
icon: FileText,
title: t("exam.form.modeRich"),
description: t("exam.form.modeRichDesc"),
},
]
return (
<div className="space-y-4">
{/* 顶部3个大卡片 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{modes.map((m) => {
const Icon = m.icon
const isActive = mode === m.key
return (
<button
key={m.key}
type="button"
className={cn(
"relative flex cursor-pointer flex-col rounded-lg border-2 p-5 shadow-sm outline-none transition-all text-left",
isActive
? "border-primary bg-primary/5 ring-2 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-accent/50"
)}
onClick={() => setMode(m.key)}
>
<div className="flex items-center gap-2">
<div className={cn(
"flex h-10 w-10 items-center justify-center rounded-lg",
isActive ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
)}>
<Icon className="h-5 w-5" />
</div>
<span className="font-semibold">{m.title}</span>
</div>
<span className="mt-2 text-sm text-muted-foreground leading-relaxed">
{m.description}
</span>
{isActive && (
<div className="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary" />
)}
</button>
)
})}
</div>
{/* 富文本模式: 显示跳转按钮 */}
{mode === "rich" && (
<div className="rounded-lg border bg-muted/30 p-6 text-center">
<p className="text-sm text-muted-foreground mb-4">
{t("exam.form.modeRichHint")}
</p>
<Button
type="button"
className={cn(
"relative flex cursor-pointer flex-col rounded-lg border p-4 shadow-sm outline-none transition-all hover:bg-accent hover:text-accent-foreground text-left",
mode === "ai" ? "border-primary ring-1 ring-primary" : "border-border"
)}
onClick={() => setMode("ai")}
size="lg"
onClick={() => router.push("/teacher/exams/new")}
className="gap-2"
>
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
<span className="font-medium">AI Generation</span>
</div>
<span className="mt-1 text-xs text-muted-foreground">
Automatically generate a draft exam based on your input.
</span>
</button>
<FileText className="h-4 w-4" />
{t("exam.form.modeRichAction")}
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</CardContent>
<CardFooter>
<Button type="button" className="w-full" disabled={isPending} onClick={handleCreateClick}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isPending
? "Creating Draft..."
: mode === "ai"
? "后台生成试卷"
: "Create & Start Building"}
</Button>
</CardFooter>
</Card>
)}
{/* 手动/AI 模式: 显示创建按钮 */}
{mode !== "rich" && (
<div className="flex justify-end">
<Button
type="button"
size="lg"
disabled={isPending}
onClick={handleCreateClick}
className="gap-2 min-w-[200px]"
>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{isPending
? t("exam.form.creating")
: mode === "ai"
? t("exam.form.modeAiAction")
: t("exam.form.modeManualAction")}
</Button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,478 @@
"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 { Input } from "@/shared/components/ui/input"
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: "1级 (简单)" },
{ value: "2", label: "2级" },
{ value: "3", label: "3级 (中等)" },
{ value: "4", label: "4级" },
{ value: "5", label: "5级 (困难)" },
]
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 [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])
/**
* AI 自动标记 —— 从编辑器当前内容获取文本,交给 AI 解析后重新载入。
* 用户可直接在编辑器中粘贴试卷文本,然后点击此按钮让 AI 自动标记题目结构。
*/
const handleAutoMark = () => {
const currentText = editorRef.current?.getText() ?? ""
if (!currentText.trim()) {
toast.error(t("exam.richEditor.emptyEditor"))
return
}
startMarking(async () => {
const formData = new FormData()
formData.append("sourceText", currentText)
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-4">
{/* 顶部工具栏:基本信息(单行) + AI 自动标记 + 保存 */}
<div className="flex flex-wrap items-center gap-3 rounded-md border bg-card p-3">
<Input
placeholder={t("exam.richEditor.titlePlaceholder")}
value={values.title}
onChange={(e) => setValues((v) => ({ ...v, title: e.target.value }))}
className="h-9 min-w-[200px] flex-1"
/>
<Select
value={values.subject}
onValueChange={(val) => setValues((v) => ({ ...v, subject: val }))}
disabled={loadingSubjects}
>
<SelectTrigger className="h-9 w-[120px]">
<SelectValue placeholder={loadingSubjects ? "..." : t("exam.form.subject")} />
</SelectTrigger>
<SelectContent>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={values.grade}
onValueChange={(val) => setValues((v) => ({ ...v, grade: val }))}
disabled={loadingGrades}
>
<SelectTrigger className="h-9 w-[120px]">
<SelectValue placeholder={loadingGrades ? "..." : t("exam.form.grade")} />
</SelectTrigger>
<SelectContent>
{grades.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={values.difficulty}
onValueChange={(val) => setValues((v) => ({ ...v, difficulty: val }))}
>
<SelectTrigger className="h-9 w-[100px]">
<SelectValue placeholder={t("exam.form.difficulty")} />
</SelectTrigger>
<SelectContent>
{DIFFICULTY_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="number"
min={1}
value={String(values.totalScore)}
onChange={(e) => setValues((v) => ({ ...v, totalScore: Number(e.target.value) || 0 }))}
className="h-9 w-[90px]"
title={t("exam.form.totalScore")}
/>
<Input
type="number"
min={10}
value={String(values.durationMin)}
onChange={(e) => setValues((v) => ({ ...v, durationMin: Number(e.target.value) || 0 }))}
className="h-9 w-[90px]"
title={t("exam.form.durationMin")}
/>
<div className="ml-auto flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAutoMark}
disabled={isMarking}
className="gap-2"
title={t("exam.richEditor.aiMarkHint")}
>
<Sparkles className="h-4 w-4" />
{isMarking ? t("exam.richEditor.aiMarking") : t("exam.richEditor.aiAutoMark")}
</Button>
<Button type="submit" disabled={isPending} size="sm" className="gap-2">
<Save className="h-4 w-4" />
{isPending ? t("exam.richEditor.saving") : t("exam.richEditor.saveDraft")}
</Button>
</div>
</div>
{/* 编辑器 + 预览(可拖拽分栏) */}
<div className="h-[calc(100vh-180px)] 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>
</form>
)
}
function ExamPreview({ structure }: { structure: EditorDoc }) {
const counter = { value: 0 }
const renderNode = (
node: EditorDoc["structure"][number],
depth: number = 0
): React.ReactNode => {
// 分卷(第Ⅰ卷)—— 顶层结构,显示标题 + 自动统计
if (node.type === "section") {
const level = node.level ?? 1
return (
<div key={node.id} className="mb-6">
<div className="mb-3 flex items-baseline gap-3 border-b-2 border-foreground pb-2">
<h2 className={cn("font-bold", level === 1 ? "text-lg" : "text-base")}>
{node.title || (level === 1 ? "分卷" : "部分")}
</h2>
{node.questionCount !== undefined && node.totalScore !== undefined && (
<span className="text-xs text-muted-foreground">
( {node.questionCount} ,{node.totalScore} )
</span>
)}
</div>
<div className="space-y-4">
{(node.children ?? []).map((child) => renderNode(child, depth + 1))}
</div>
</div>
)
}
// 大题分组(一、选择题)—— 显示标题 + 说明 + 自动统计
if (node.type === "group") {
return (
<div key={node.id} className="mb-5">
<div className="mb-2 flex items-baseline gap-2 border-b border-foreground/30 pb-1">
<h3 className="text-base font-bold text-foreground">
{node.title || "大题"}
</h3>
{node.questionCount !== undefined && node.totalScore !== undefined && (
<span className="text-xs text-muted-foreground">
( {node.questionCount} ,{node.totalScore} )
</span>
)}
</div>
{node.instruction && (
<p className="mb-2 text-xs text-muted-foreground italic">
{node.instruction}
</p>
)}
<div className="space-y-3 pl-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
const qNum = counter.value
const isComposite = question.type === "composite"
const typeLabel = question.type === "single_choice" ? "单选"
: question.type === "multiple_choice" ? "多选"
: question.type === "judgment" ? "判断"
: question.type === "composite" ? "复合"
: "简答"
return (
<div key={node.id} className="rounded border border-border/60 bg-background p-3">
{/* 题目头部:题号 + 题型标签 + 分值 */}
<div className="flex items-center gap-2 mb-2">
<span className="font-bold text-foreground min-w-[32px]">{qNum}.</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{typeLabel}
</span>
<span className="ml-auto text-xs text-muted-foreground">
{question.score}
</span>
</div>
{/* 题干文本 */}
{question.content.text && (
<div className="mb-2 text-sm text-foreground/90 leading-relaxed whitespace-pre-wrap pl-8">
{question.content.text}
</div>
)}
{/* 选项 */}
{question.content.options && question.content.options.length > 0 && (
<div className="space-y-1.5 pl-8 mb-2">
{question.content.options.map((opt) => (
<div
key={`${question.id}-${opt.id}`}
className="text-sm text-foreground/80 flex gap-2"
>
<span className="font-medium min-w-[20px]">{opt.id}.</span>
<span>{opt.text}</span>
</div>
))}
</div>
)}
{/* 复合题子题(阅读理解小题) */}
{isComposite && question.content.subQuestions && question.content.subQuestions.length > 0 && (
<div className="mt-3 ml-4 space-y-3 border-l-2 border-primary/30 pl-4">
{question.content.subQuestions.map((sub, idx) => (
<div key={`${question.id}-sub-${sub.id}`} className="text-sm">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-foreground/80">
({idx + 1})
</span>
{sub.score !== undefined && (
<span className="text-xs text-muted-foreground">
{sub.score}
</span>
)}
</div>
<div className="text-foreground/80 whitespace-pre-wrap pl-6">
{sub.text}
</div>
</div>
))}
</div>
)}
{/* 图片 */}
{question.content.images && question.content.images.length > 0 && (
<div className="mt-2 pl-8 flex flex-wrap gap-2">
{question.content.images.map((img) => (
<img
key={img.fileId}
src={img.url}
alt={img.alt || ""}
className="max-h-32 rounded border"
/>
))}
</div>
)}
</div>
)
}
return (
<div className="space-y-4">
{structure.title && (
<h2 className="text-center text-lg font-bold border-b pb-2">
{structure.title}
</h2>
)}
{structure.structure.length === 0 ? (
<p className="text-center text-sm text-muted-foreground py-8">
,
</p>
) : (
structure.structure.map((node) => renderNode(node))
)}
</div>
)
}

View File

@@ -0,0 +1,241 @@
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 CHOICE_TYPES = new Set(["single_choice", "multiple_choice", "judgment"])
const parseOptions = (
nodes: JSONContent[],
questionType: RichQuestionType
): Array<{ id: string; text: string; isCorrect?: boolean }> => {
// 只有选择题才解析选项,填空/简答/复合题的列表保持为普通文本
if (!CHOICE_TYPES.has(questionType)) return []
const options: Array<{ id: string; text: string; isCorrect?: boolean }> = []
const seenIds = new Set<string>()
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*(.+)$/)
const id = match ? match[1]! : String.fromCharCode(65 + idx)
// 同一题目内选项 id 去重,避免多个列表合并后出现重复 "A"
if (seenIds.has(id)) return
seenIds.add(id)
options.push({ id, text: match ? match[2]! : 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 ?? []
// 分离出嵌套的 questionBlock(复合题的子题)、选项列表、图片、普通文本
const subQuestions: Array<{ id: string; text: string; answer?: string; score?: number }> = []
const nonQuestionBlocks = inner.filter((n) => {
if (n.type === "questionBlock") {
const sub = buildQuestion(n)
const subText = sub.content.text
const subOptions = sub.content.options
?.map((o) => `${o.id}. ${o.text}`)
.join("\n")
const fullText = [subText, subOptions].filter(Boolean).join("\n")
subQuestions.push({
id: sub.id,
text: fullText || "",
score: sub.score,
})
return false
}
return true
})
// 选择题:过滤掉列表(列表作为选项单独处理)
// 非选择题:保留列表内容在文本中(列表是题干的一部分)
const isChoice = CHOICE_TYPES.has(type)
const textNodes = isChoice
? nonQuestionBlocks.filter(
(n) =>
n.type !== "orderedList" &&
n.type !== "bulletList" &&
n.type !== "image"
)
: nonQuestionBlocks.filter((n) => n.type !== "image")
const text = extractText({ type: "doc", content: textNodes })
const options = parseOptions(nonQuestionBlocks, type)
const blanks = collectBlanks(nonQuestionBlocks)
const images = collectImages(nonQuestionBlocks)
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
if (subQuestions.length > 0) content.subQuestions = subQuestions
return { id, type, score, content }
}
/** 统计结构节点的题目数和总分(递归) */
const computeStats = (node: EditorStructureNode): { count: number; score: number } => {
if (node.type === "question") {
return { count: 1, score: node.score ?? 0 }
}
let count = 0
let score = 0
for (const child of node.children ?? []) {
const stats = computeStats(child)
count += stats.count
score += stats.score
}
return { count, score }
}
/**
* 递归构建结构树:支持 sectionBlock(分卷) / groupBlock(大题) / questionBlock(题目) 嵌套。
* 返回结构节点和提取出的所有题目(扁平列表)。
*/
const buildStructureNode = (
block: JSONContent,
questions: EditorQuestion[]
): EditorStructureNode | null => {
if (block.type === "sectionBlock") {
const title =
typeof block.attrs?.title === "string" ? block.attrs.title : ""
const level =
typeof block.attrs?.level === "number" ? block.attrs.level : 1
const children: EditorStructureNode[] = []
for (const inner of block.content ?? []) {
const child = buildStructureNode(inner, questions)
if (child) children.push(child)
}
const node: EditorStructureNode = {
id: createId(),
type: "section",
title,
level,
children,
}
const stats = computeStats(node)
node.questionCount = stats.count
node.totalScore = stats.score
return node
}
if (block.type === "groupBlock") {
const title =
typeof block.attrs?.title === "string" ? block.attrs.title : ""
const instruction =
typeof block.attrs?.instruction === "string"
? block.attrs.instruction
: ""
const children: EditorStructureNode[] = []
for (const inner of block.content ?? []) {
const child = buildStructureNode(inner, questions)
if (child) children.push(child)
}
const node: EditorStructureNode = {
id: createId(),
type: "group",
title,
instruction,
children,
}
const stats = computeStats(node)
node.questionCount = stats.count
node.totalScore = stats.score
return node
}
if (block.type === "questionBlock") {
const q = buildQuestion(block)
questions.push(q)
return {
id: createId(),
type: "question",
questionId: q.id,
score: q.score,
}
}
return null
}
/**
* 将 Tiptap 编辑器文档(JSONContent)转换为试卷结构(EditorDoc)。
* 支持 sectionBlock(分卷) / 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) {
const node = buildStructureNode(block, questions)
if (node) structure.push(node)
}
return { title, questions, structure }
}

View File

@@ -0,0 +1,53 @@
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
}
/** 结构节点类型:section=分卷, group=大题, question=题目 */
export type EditorStructureNodeType = "section" | "group" | "question"
export interface EditorStructureNode {
id: string
type: EditorStructureNodeType
title?: string
/** 大题说明(如"每小题3分") */
instruction?: string
/** 分卷层级(1=卷, 2=部分) */
level?: number
questionId?: string
score?: number
/** 自动统计:题目数(不含结构节点) */
questionCount?: number
/** 自动统计:总分(子题累加) */
totalScore?: number
children?: EditorStructureNode[]
}
export interface EditorDoc {
title: string
questions: EditorQuestion[]
structure: EditorStructureNode[]
}
/** 用于编辑器与试卷结构互转的 JSONContent 别名 */
export type EditorJSONContent = JSONContent

View File

@@ -0,0 +1,331 @@
"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,
SectionBlock,
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
/** 将选区包裹为题目块 */
wrapInQuestion: (type: QuestionBlockType, score?: number) => void
/** 插入大题分组 */
insertGroup: (title?: string, instruction?: string) => void
/** 将选区包裹为大题分组 */
wrapInGroup: (title?: string, instruction?: string) => void
/** 插入试卷分卷 */
insertSection: (title?: string, level?: number) => void
/** 将选区包裹为试卷分卷 */
wrapInSection: (title?: string, level?: number) => 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,
SectionBlock,
],
[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()
},
wrapInQuestion: (type, score = 2) => {
editor?.chain().focus().wrapInQuestion({ type, score }).run()
},
insertGroup: (title, instruction) => {
editor?.chain().focus().insertGroup(title, instruction).run()
},
wrapInGroup: (title, instruction) => {
editor?.chain().focus().wrapInGroup(title, instruction).run()
},
insertSection: (title, level) => {
editor?.chain().focus().insertSection(title, level).run()
},
wrapInSection: (title, level) => {
editor?.chain().focus().wrapInSection(title, level).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>
)
}
)

View 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" }),
}
},
})

View 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),
}
},
})

View File

@@ -0,0 +1,123 @@
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, instruction?: string) => ReturnType
/** 将当前选区内容包裹为大题分组 */
wrapInGroup: (title?: string, instruction?: string) => ReturnType
}
}
}
/** 递归统计节点内的题目数和总分(含嵌套 questionBlock 的子题) */
const countQuestionsAndScore = (node: { content?: Array<{ type?: string; attrs?: Record<string, unknown>; content?: unknown[] }> }): { count: number; score: number } => {
let count = 0
let score = 0
const blocks = node.content ?? []
for (const block of blocks) {
if (block.type === "questionBlock") {
const inner = Array.isArray(block.content) ? block.content : []
const hasSubQuestions = inner.some((n) => typeof n === "object" && n !== null && "type" in n && (n as { type?: string }).type === "questionBlock")
if (hasSubQuestions) {
const subStats = countQuestionsAndScore(block as never)
count += subStats.count
score += subStats.score
} else {
count += 1
const s = typeof block.attrs?.score === "number" ? block.attrs.score : 0
score += s
}
} else if (block.type === "sectionBlock" || block.type === "groupBlock") {
const subStats = countQuestionsAndScore(block as never)
count += subStats.count
score += subStats.score
}
}
return { count, score }
}
const GroupView = ({ node, updateAttributes }: NodeViewProps) => {
const title = (node.attrs.title as string) || ""
const instruction = (node.attrs.instruction as string) || ""
const stats = countQuestionsAndScore(node.toJSON())
return (
<NodeViewWrapper className="my-4 rounded-md border-l-4 border-l-primary bg-primary/5 p-3">
<div className="mb-1 flex flex-wrap items-center gap-2">
<input
type="text"
value={title}
onChange={(e) => updateAttributes({ title: e.target.value })}
placeholder="大题标题(如:一、选择题)"
className="flex-1 bg-transparent text-base font-semibold focus:outline-none"
/>
<span className="rounded bg-primary/10 px-2 py-0.5 text-xs text-primary">
{stats.count} · {stats.score}
</span>
</div>
<input
type="text"
value={instruction}
onChange={(e) => updateAttributes({ instruction: e.target.value })}
placeholder="说明(如:每小题3分,共24分)—— 可留空,总分自动统计"
className="mb-2 w-full bg-transparent text-xs text-muted-foreground focus:outline-none"
/>
<NodeViewContent className="mt-1 block" />
</NodeViewWrapper>
)
}
/**
* 大题分组节点 —— 如"一、选择题""二、填空题"。
* 不是题目,题数和总分由内部子题自动累加。
* instruction 字段用于"每小题3分"等说明,不影响统计。
*/
export const GroupBlock = Node.create({
name: "groupBlock",
group: "block",
content: "block+",
defining: true,
isolating: true,
addAttributes() {
return {
title: { default: "" },
instruction: { 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, instruction) =>
({ commands }) =>
commands.insertContent({
type: "groupBlock",
attrs: { title: title || "", instruction: instruction || "" },
content: [
{
type: "paragraph",
content: [{ type: "text", text: " " }],
},
],
}),
wrapInGroup:
(title, instruction) =>
({ commands }) =>
commands.wrapIn("groupBlock", {
title: title || "",
instruction: instruction || "",
}),
}
},
})

View 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 }),
}
},
})

View File

@@ -0,0 +1,6 @@
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"
export { SectionBlock } from "./section-block"

View File

@@ -0,0 +1,113 @@
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
/** 将当前选区内容包裹为题目块 */
wrapInQuestion: (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 ?? "",
},
content: [
{
type: "paragraph",
content: [{ type: "text", text: " " }],
},
],
}),
wrapInQuestion:
(attrs) =>
({ commands }) =>
commands.wrapIn("questionBlock", {
type: attrs?.type ?? "single_choice",
score: attrs?.score ?? 0,
questionId: attrs?.questionId ?? "",
}),
}
},
})

View File

@@ -0,0 +1,129 @@
import { Node, mergeAttributes } from "@tiptap/core"
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent, type NodeViewProps } from "@tiptap/react"
declare module "@tiptap/core" {
interface Commands<ReturnType> {
sectionBlock: {
/** 插入试卷分卷(如"第Ⅰ卷 选择题") */
insertSection: (title?: string, level?: number) => ReturnType
/** 将当前选区内容包裹为试卷分卷 */
wrapInSection: (title?: string, level?: number) => ReturnType
}
}
}
/** 递归统计节点内的题目数和总分(含嵌套 section/group/question) */
const countQuestionsAndScore = (node: { content?: Array<{ type?: string; attrs?: Record<string, unknown>; content?: unknown[] }> }): { count: number; score: number } => {
let count = 0
let score = 0
const blocks = node.content ?? []
for (const block of blocks) {
if (block.type === "questionBlock") {
// 复合题:统计嵌套子题;否则计为 1 题
const inner = Array.isArray(block.content) ? block.content : []
const hasSubQuestions = inner.some((n) => typeof n === "object" && n !== null && "type" in n && (n as { type?: string }).type === "questionBlock")
if (hasSubQuestions) {
const subStats = countQuestionsAndScore(block as never)
count += subStats.count
score += subStats.score
} else {
count += 1
const s = typeof block.attrs?.score === "number" ? block.attrs.score : 0
score += s
}
} else if (block.type === "sectionBlock" || block.type === "groupBlock") {
const subStats = countQuestionsAndScore(block as never)
count += subStats.count
score += subStats.score
}
}
return { count, score }
}
const SectionView = ({ node, updateAttributes }: NodeViewProps) => {
const title = (node.attrs.title as string) || ""
const level = typeof node.attrs.level === "number" ? node.attrs.level : 1
const stats = countQuestionsAndScore(node.toJSON())
const titleSize = level === 1 ? "text-lg" : "text-base"
return (
<NodeViewWrapper className="my-4 rounded-md border-2 border-primary/40 bg-primary/5 p-4">
<div className="mb-2 flex flex-wrap items-center gap-2 border-b border-primary/20 pb-2">
<input
type="text"
value={title}
onChange={(e) => updateAttributes({ title: e.target.value })}
placeholder={`分卷标题(如:第Ⅰ卷 选择题)`}
className={`flex-1 bg-transparent font-bold focus:outline-none ${titleSize}`}
/>
<select
value={String(level)}
onChange={(e) => updateAttributes({ level: Number(e.target.value) })}
className="rounded border bg-background px-1.5 py-0.5 text-xs"
title="层级"
>
<option value="1"></option>
<option value="2"></option>
<option value="3"></option>
</select>
<span className="rounded bg-primary/10 px-2 py-0.5 text-xs text-primary">
{stats.count} · {stats.score}
</span>
</div>
<NodeViewContent className="mt-2 block" />
</NodeViewWrapper>
)
}
/**
* 试卷分卷节点 —— 顶层结构,如"第Ⅰ卷 选择题(共24分)"。
* 不是题目,题数和总分由内部子题自动累加。
* 可嵌套 groupBlock(大题)和 questionBlock(题目)。
*/
export const SectionBlock = Node.create({
name: "sectionBlock",
group: "block",
content: "block+",
defining: true,
isolating: true,
addAttributes() {
return {
title: { default: "" },
level: { default: 1 },
}
},
parseHTML: () => [{ tag: "div[data-section-block]" }],
renderHTML: ({ HTMLAttributes }) => [
"div",
mergeAttributes(HTMLAttributes, { "data-section-block": "true" }),
0,
],
addNodeView() {
return ReactNodeViewRenderer(SectionView)
},
addCommands() {
return {
insertSection:
(title, level) =>
({ commands }) =>
commands.insertContent({
type: "sectionBlock",
attrs: { title: title || "", level: level ?? 1 },
content: [
{
type: "paragraph",
content: [{ type: "text", text: " " }],
},
],
}),
wrapInSection:
(title, level) =>
({ commands }) =>
commands.wrapIn("sectionBlock", {
title: title || "",
level: level ?? 1,
}),
}
},
})

View 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"

View File

@@ -0,0 +1,362 @@
"use client"
import { useEffect, useState } from "react"
import type { Editor, JSONContent } from "@tiptap/react"
import {
Box,
Brackets,
CircleSlash,
FileText,
Heading,
Layers,
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>
)
}
/**
* 把 orderedList/bulletList 转为普通段落,去掉列表标号。
* 用于把选中文本转为题目块时,避免列表被错误解析为选项(填空/简答题场景)。
* 列表的每个 listItem 的子块(通常是 paragraph)会被提取为独立段落。
*/
const flattenListToParagraphs = (nodes: JSONContent[]): JSONContent[] => {
const result: JSONContent[] = []
for (const node of nodes) {
if (node.type === "orderedList" || node.type === "bulletList") {
if (Array.isArray(node.content)) {
for (const item of node.content) {
if (Array.isArray(item.content)) {
result.push(...item.content)
}
}
}
} else {
result.push(node)
}
}
// 确保非空(questionBlock 要求 content: "block+")
if (result.length === 0) {
result.push({ type: "paragraph", content: [{ type: "text", text: " " }] })
}
return result
}
/**
* 选区标记浮层 —— 选中文本时浮现,提供"标记为题目/分组/加点字/填空"等快捷操作。
* 跟随选区位置定位,使用 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
// 使用浏览器原生选区的可见矩形(相对视口),避免长文本选区起点
// 在视口外时 coordsAtPos 返回负坐标导致工具栏跑到页面顶部
const domSelection = window.getSelection()
let top: number
let left: number
if (domSelection && domSelection.rangeCount > 0) {
const rect = domSelection.getRangeAt(0).getBoundingClientRect()
// 选区不可见(滚动出视口)时 rect 为空矩形,此时不显示工具栏
if (rect.width === 0 && rect.height === 0) {
setCoords(null)
setHasSelection(false)
return
}
top = rect.top - 44 // 浮在选区上方
left = rect.left + rect.width / 2
} else {
// 降级:使用 ProseMirror 的 coordsAtPos
const view = editor.view
const start = view.coordsAtPos(from)
const end = view.coordsAtPos(to)
top = Math.min(start.top, end.top) - 44
left = (start.left + end.right) / 2
}
// 限制在视口内,避免超出顶部或左右边界
const clampedTop = Math.max(8, Math.min(top, window.innerHeight - 60))
const clampedLeft = Math.max(120, Math.min(left, window.innerWidth - 120))
setCoords({ top: clampedTop, left: clampedLeft })
setHasSelection(true)
}
editor.on("selectionUpdate", updatePosition)
// 鼠标释放后立即更新位置(选区拖动结束时)
const handlePointerUp = () => {
// 微延迟确保选区已更新
requestAnimationFrame(updatePosition)
}
document.addEventListener("pointerup", handlePointerUp)
editor.on("blur", () => {
// 延迟以允许点击工具栏按钮
setTimeout(() => {
if (!editor.isFocused) {
setCoords(null)
setHasSelection(false)
}
}, 200)
})
return () => {
editor.off("selectionUpdate", updatePosition)
document.removeEventListener("pointerup", handlePointerUp)
}
}, [editor])
if (!editor || !hasSelection || !coords) return null
const { from, to, empty } = editor.state.selection
const hasTextSelection = !empty && from !== to
const insertQuestion = (type: QuestionBlockType) => {
const score = type === "composite" ? 5 : 2
if (!hasTextSelection) {
// 未选中文本时:插入空题目块
editor.chain().focus().insertQuestion({ type, score }).run()
return
}
const isChoice =
type === "single_choice" ||
type === "multiple_choice" ||
type === "judgment"
// 选择题:先尝试 wrapIn(保留列表结构,以便解析为 A/B/C 选项)
if (isChoice && editor.commands.wrapInQuestion({ type, score })) {
return
}
// 非选择题,或 wrapIn 失败(如在 isolating 节点内):
// 用 slice 获取选区完整节点结构,把列表转为普通段落(去掉有序序列标号),
// 然后插入新的 questionBlock。避免列表导致填空/简答题在预览中不显示。
const { from, to } = editor.state.selection
const slice = editor.state.doc.slice(from, to)
const sliceContent = slice.content.toJSON
? (slice.content.toJSON() as JSONContent[])
: Array.isArray(slice.content.content)
? slice.content.content.map((n) => n.toJSON() as JSONContent)
: []
const content = isChoice
? sliceContent.length > 0
? sliceContent
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
: flattenListToParagraphs(sliceContent)
editor.chain()
.focus()
.deleteSelection()
.insertContent({
type: "questionBlock",
attrs: { type, score, questionId: "" },
content,
})
.run()
}
/** 通用降级包裹:wrapIn 失败时,把选中文本转为指定节点 */
const wrapOrInsert = (
wrapFn: () => boolean,
insertFn: () => void,
nodeType: "groupBlock" | "sectionBlock",
attrs: Record<string, unknown>,
defaultTitle: string
) => {
if (!hasTextSelection) {
insertFn()
return
}
const wrapped = wrapFn()
if (!wrapped) {
// wrapIn 失败:获取选中文本,删除选区,插入包含选中文本的节点
const { from, to } = editor.state.selection
const selectedText = editor.state.doc.textBetween(from, to, "\n")
const lines = selectedText.split("\n").filter((l) => l.trim().length > 0)
const content = lines.length > 0
? lines.map((line) => ({
type: "paragraph",
content: [{ type: "text", text: line }],
}))
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
editor.chain()
.focus()
.deleteSelection()
.insertContent({
type: nodeType,
attrs: { ...attrs, title: defaultTitle },
content,
})
.run()
}
}
const insertGroup = () => {
wrapOrInsert(
() => editor.commands.wrapInGroup("一、选择题", ""),
() => editor.chain().focus().insertGroup("一、选择题", "").run(),
"groupBlock",
{ instruction: "" },
"一、选择题"
)
}
const insertSection = () => {
wrapOrInsert(
() => editor.commands.wrapInSection("第Ⅰ卷 选择题", 1),
() => editor.chain().focus().insertSection("第Ⅰ卷 选择题", 1).run(),
"sectionBlock",
{ level: 1 },
"第Ⅰ卷 选择题"
)
}
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={insertSection} icon={Layers} label="分卷" />
<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>
)
}

View 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 }
}

View File

@@ -1,5 +1,5 @@
// 文件关联的目标资源类型(多态关联)
export type FileTargetType = "exam" | "textbook" | "question" | "announcement"
export type FileTargetType = "exam" | "textbook" | "question" | "announcement" | "homework"
// 文件附件记录DB 行的 TypeScript 表示)
export interface FileAttachment {

View File

@@ -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)
}
}

View File

@@ -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>
)

View 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>
)
}

View File

@@ -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>

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -32,7 +32,32 @@
"createFailed": "Failed to create exam",
"loadFormFailed": "Failed to load form data",
"loadSubjectsFailed": "Failed to load subjects",
"loadGradesFailed": "Failed to load grades"
"loadGradesFailed": "Failed to load grades",
"creating": "Creating...",
"detailsTitle": "Exam Details",
"detailsDesc": "Define the core information for your exam.",
"titlePlaceholder": "e.g. Midterm Mathematics Exam",
"subjectPlaceholder": "Select subject",
"gradePlaceholder": "Select grade level",
"difficultyPlaceholder": "Select level",
"difficultyLevel1": "Level 1 (Easy)",
"difficultyLevel2": "Level 2",
"difficultyLevel3": "Level 3 (Medium)",
"difficultyLevel4": "Level 4",
"difficultyLevel5": "Level 5 (Hard)",
"loadingSubjects": "Loading subjects...",
"loadingGrades": "Loading grades...",
"scheduledAtDesc": "If set, this exam will be scheduled for a specific time.",
"modeManual": "Manual Assembly",
"modeManualDesc": "Manually select questions from the bank and organize structure.",
"modeManualAction": "Create & Start Building",
"modeAi": "AI Generation",
"modeAiDesc": "Paste exam text and AI will auto-parse questions into structured preview.",
"modeAiAction": "Generate in Background",
"modeRich": "Rich Text Editor",
"modeRichDesc": "Use rich text editor to paste exam text, manually or AI-assisted marking of questions, groups, blanks, etc.",
"modeRichHint": "The rich text editor supports selecting text to mark as questions/groups/dotted chars/blanks, with live preview on the right.",
"modeRichAction": "Open Rich Text Editor"
},
"status": {
"draft": "Draft",
@@ -123,6 +148,59 @@
"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 in the editor, click this button to let AI auto-detect question structure",
"titlePlaceholder": "Exam title (e.g. 2024-2025 Final Academic Quality Assessment)",
"editorArea": "Editor (paste exam text, select text to mark questions/groups/dotted/blanks)",
"previewArea": "Preview",
"emptyEditor": "Enter or paste exam content in the editor on the left",
"emptyPreview": "Preview will appear here",
"markQuestion": "Mark Question",
"markGroup": "Mark Section",
"markSection": "Mark Volume",
"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 +316,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 +375,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",

View File

@@ -32,7 +32,32 @@
"createFailed": "创建考试失败",
"loadFormFailed": "加载表单数据失败",
"loadSubjectsFailed": "加载科目失败",
"loadGradesFailed": "加载年级失败"
"loadGradesFailed": "加载年级失败",
"creating": "创建中...",
"detailsTitle": "基本信息",
"detailsDesc": "设置考试的核心信息。",
"titlePlaceholder": "例如:期中数学考试",
"subjectPlaceholder": "选择科目",
"gradePlaceholder": "选择年级",
"difficultyPlaceholder": "选择难度",
"difficultyLevel1": "1级 (简单)",
"difficultyLevel2": "2级",
"difficultyLevel3": "3级 (中等)",
"difficultyLevel4": "4级",
"difficultyLevel5": "5级 (困难)",
"loadingSubjects": "加载科目中...",
"loadingGrades": "加载年级中...",
"scheduledAtDesc": "设置后,考试将在指定时间开放。",
"modeManual": "手动组卷",
"modeManualDesc": "从题库手动选择题目并组织试卷结构。",
"modeManualAction": "创建并开始组卷",
"modeAi": "AI 生成",
"modeAiDesc": "粘贴试卷文本,AI 自动解析题目并生成结构化预览。",
"modeAiAction": "后台生成试卷",
"modeRich": "富文本编辑器",
"modeRichDesc": "使用富文本编辑器粘贴试卷文本,手动或 AI 辅助标记题目、分组、填空等。",
"modeRichHint": "富文本编辑器支持选中文本标记题目/分组/加点字/填空,右侧实时预览试卷效果。",
"modeRichAction": "打开富文本编辑器"
},
"status": {
"draft": "草稿",
@@ -123,6 +148,59 @@
"highErrorWarningDesc": "以下题目错误率超过 70%,建议重点讲解",
"noData": "暂无分析数据,需等待学生提交并批改后生成",
"viewAnalytics": "查看分析"
},
"richEditor": {
"title": "富文本组卷",
"description": "粘贴试卷文本,使用工具栏标记题目/分组/填空/加点字,右侧实时预览",
"basicInfo": "基本信息",
"sourceText": "试卷原文",
"sourceTextPlaceholder": "在此粘贴完整试卷文本...",
"aiAutoMark": "AI 自动标记",
"aiMarking": "AI 标记中...",
"aiMarkSuccess": "AI 自动标记完成",
"aiMarkFailed": "AI 自动标记失败",
"aiMarkHint": "在编辑器中粘贴试卷文本后,点击此按钮让 AI 自动识别题目结构",
"titlePlaceholder": "试卷标题(如:2024-2025学年度下期期末学业质量监测)",
"editorArea": "编辑区(粘贴试卷文本,选中文本标记题目/分组/加点字/填空)",
"previewArea": "预览区",
"emptyEditor": "请在左侧编辑区输入或粘贴试卷内容",
"emptyPreview": "预览将在此处显示",
"markQuestion": "标记题目",
"markGroup": "标记大题",
"markSection": "标记分卷",
"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 +316,18 @@
"autoSaveSaved": "已自动保存",
"autoSaveError": "自动保存失败,将在网络恢复后重试",
"autoSaveRestored": "已从离线缓存恢复未提交的答案",
"autoSaveCacheError": "离线缓存恢复失败"
"autoSaveCacheError": "离线缓存恢复失败",
"scanTitle": "答题拍照上传",
"scanDescription": "在纸上作答后,拍照上传整卷答题图片",
"selectImageFiles": "请选择图片文件",
"uploadFailed": "上传失败",
"uploadSuccess": "已上传 {{count}} 张图片",
"deleteScan": "删除",
"moveUp": "上移",
"moveDown": "下移",
"dragDropHint": "拖拽图片到此处,或点击选择文件",
"pageLabel": "第 {{page}} 页",
"scanDisabled": "已提交,无法修改答题图片"
},
"grade": {
"title": "批改",
@@ -286,7 +375,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": "复习",