2131 lines
67 KiB
Markdown
2131 lines
67 KiB
Markdown
# 试卷富文本编辑器与拍照阅卷重构 实施计划
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** 用 Tiptap 富文本编辑器替换现有"粘贴文本+AI解析+弹窗预览"的试卷创建流程,支持选区标记题目/分组/填空/加点字 + AI自动标记 + 可拖拽分栏预览;答题侧支持客观题在线作答 + 主观题整卷分页拍照上传,批改侧改为高考阅卷式(左题目右答案图片)。
|
|
|
|
**Architecture:**
|
|
- 创建侧:基于 Tiptap 3.15.3 扩展自定义节点(`questionBlock`/`groupBlock`/`blankNode`/`dottedMark`/`imageNode`),编辑器文档模型与试卷 structure 双向同步;左右分栏可拖拽(自实现 ResizablePanel);AI 自动标记复用现有 `previewAiExamAction` 但输出标记到编辑器而非弹窗。
|
|
- 答题侧:`homeworkAnswers.answerContent` 扩展为 `{ answer, imageFileIds }`;整卷图片复用 `fileAttachments` 表(`targetType="homework_submission"`);客观题保留在线作答与自动判分,主观题拍照。
|
|
- 批改侧:新 `HomeworkScanGradingView` 组件,左题目+评分面板,右答案图片滚动/缩放/翻页。
|
|
- 不新增 DB 题型枚举:填空用 `text` + `content.blanks`,排序/复合用 `composite` + `content.subQuestions`,看图用 `content.images`。`questions.content` 是 json,无需 migration。
|
|
|
|
**Tech Stack:** Tiptap 3.15.3、@dnd-kit/sortable(已安装)、next-intl、Drizzle JSON、现有 files 模块上传接口。
|
|
|
|
---
|
|
|
|
## 文件结构
|
|
|
|
### 创建侧(富文本编辑器)
|
|
- `src/modules/exams/editor/extensions/question-block.ts` — 题目块节点(题号/题型/分值/题干)
|
|
- `src/modules/exams/editor/extensions/group-block.ts` — 分组块节点(大题标题)
|
|
- `src/modules/exams/editor/extensions/blank-node.ts` — 填空占位节点
|
|
- `src/modules/exams/editor/extensions/dotted-mark.ts` — 加点字(下加点)mark
|
|
- `src/modules/exams/editor/extensions/image-node.ts` — 图片节点(复用 Tiptap Image,绑定 fileAttachments)
|
|
- `src/modules/exams/editor/extensions/index.ts` — 聚合导出
|
|
- `src/modules/exams/editor/exam-rich-editor.tsx` — 富文本编辑器主组件(工具栏+选区标记浮层)
|
|
- `src/modules/exams/editor/selection-toolbar.tsx` — 选区标记浮层(选中文字→标记为题目/分组/填空/加点字)
|
|
- `src/modules/exams/editor/editor-to-structure.ts` — 编辑器文档 → 试卷 structure + questions 纯函数
|
|
- `src/modules/exams/editor/structure-to-editor.ts` — 试卷 structure → 编辑器文档 纯函数
|
|
- `src/modules/exams/editor/exam-rich-editor-types.ts` — 类型定义
|
|
- `src/shared/components/ui/resizable-panel.tsx` — 可拖拽分栏容器(自实现,无新依赖)
|
|
|
|
### 创建侧(新创建页)
|
|
- `src/modules/exams/components/exam-rich-form.tsx` — 新创建页主组件(替换 ExamForm 的 AI 模式)
|
|
- `src/modules/exams/components/exam-rich-preview-pane.tsx` — 右侧实时预览面板(复用 ExamViewer)
|
|
|
|
### 答题侧(拍照上传)
|
|
- `src/modules/homework/components/scan-uploader.tsx` — 整卷分页拍照上传组件(多图,复用 /api/upload)
|
|
- `src/modules/homework/components/homework-take-view.tsx` — 修改:客观题在线作答 + 主观题显示"请在纸上作答并拍照上传"
|
|
- `src/modules/homework/lib/question-content-utils.ts` — 修改:扩展 answerContent 支持 imageFileIds;填空多空判分
|
|
|
|
### 批改侧(阅卷式)
|
|
- `src/modules/homework/components/homework-scan-grading-view.tsx` — 新阅卷式批改视图(左题目右图片)
|
|
- `src/modules/homework/components/scan-image-viewer.tsx` — 答案图片查看器(滚动/缩放/翻页)
|
|
- `src/app/(dashboard)/teacher/homework/submissions/[id]/scan/page.tsx` — 阅卷式批改页路由
|
|
|
|
### i18n
|
|
- `src/shared/i18n/messages/zh-CN/exam-homework.json` — 修改:补齐题型标签、编辑器工具栏、阅卷式批改文案
|
|
- `src/shared/i18n/messages/en/exam-homework.json` — 修改:同步英文
|
|
|
|
### 架构图同步
|
|
- `docs/architecture/004_architecture_impact_map.md` — 修改:exams/homework 模块章节
|
|
- `docs/architecture/005_architecture_data.json` — 修改:对应节点
|
|
|
|
---
|
|
|
|
## 阶段一:富文本编辑器扩展(Tiptap 自定义节点)
|
|
|
|
### Task 1: 可拖拽分栏容器
|
|
|
|
**Files:**
|
|
- Create: `src/shared/components/ui/resizable-panel.tsx`
|
|
|
|
- [ ] **Step 1: 实现 ResizablePanel 组件**
|
|
|
|
```tsx
|
|
"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 hover:bg-primary/40 transition-colors"
|
|
/>
|
|
<div style={{ width: `${100 - leftPct}%` }} className="h-full min-w-0 overflow-hidden">
|
|
{right}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: 验证类型**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: 无新错误
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/shared/components/ui/resizable-panel.tsx
|
|
git commit -m "feat(shared): add ResizablePanel component for draggable split layout"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: 加点字 mark 扩展
|
|
|
|
**Files:**
|
|
- Create: `src/modules/exams/editor/extensions/dotted-mark.ts`
|
|
|
|
- [ ] **Step 1: 实现 dottedMark**
|
|
|
|
```ts
|
|
import { Mark, mergeAttributes } from "@tiptap/core"
|
|
|
|
declare module "@tiptap/core" {
|
|
interface Commands<ReturnType> {
|
|
dotted: {
|
|
toggleDotted: () => ReturnType
|
|
}
|
|
}
|
|
}
|
|
|
|
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),
|
|
}
|
|
},
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/modules/exams/editor/extensions/dotted-mark.ts
|
|
git commit -m "feat(exams): add DottedMark tiptap extension for underdotted chars"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: 填空占位节点
|
|
|
|
**Files:**
|
|
- Create: `src/modules/exams/editor/extensions/blank-node.ts`
|
|
|
|
- [ ] **Step 1: 实现 blankNode(原子节点,渲染为下划线空)**
|
|
|
|
```ts
|
|
import { Node, mergeAttributes } from "@tiptap/core"
|
|
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"
|
|
import { css } from "@emotion/react"
|
|
|
|
declare module "@tiptap/core" {
|
|
interface Commands<ReturnType> {
|
|
blank: {
|
|
insertBlank: () => ReturnType
|
|
}
|
|
}
|
|
}
|
|
|
|
const BlankView = () => (
|
|
<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" }}
|
|
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" }),
|
|
}
|
|
},
|
|
})
|
|
```
|
|
|
|
注意:若项目未装 `@emotion/react`,移除该 import(此处未使用)。检查后删除未用 import。
|
|
|
|
- [ ] **Step 2: 移除未用 import 并验证**
|
|
|
|
检查 `@emotion/react` 是否在 package.json。若没有,删除该行。
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: 无新错误
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/modules/exams/editor/extensions/blank-node.ts
|
|
git commit -m "feat(exams): add BlankNode tiptap extension for fill-in-the-blank placeholder"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: 图片节点(绑定 fileAttachments)
|
|
|
|
**Files:**
|
|
- Create: `src/modules/exams/editor/extensions/image-node.ts`
|
|
|
|
- [ ] **Step 1: 实现 imageNode(存储 fileId + url)**
|
|
|
|
```ts
|
|
import Image from "@tiptap/extension-image"
|
|
import { mergeAttributes } from "@tiptap/core"
|
|
|
|
export interface ImageNodeOptions {
|
|
/** 上传回调,返回 { url, fileId } */
|
|
uploadFn?: (file: File) => Promise<{ url: string; fileId: string }>
|
|
}
|
|
|
|
export const ImageNode = Image.extend({
|
|
addAttributes() {
|
|
return {
|
|
...this.parent?.(),
|
|
fileId: {
|
|
default: null,
|
|
parseHTML: (el) => el.getAttribute("data-file-id"),
|
|
renderHTML: (attrs) => (attrs.fileId ? { "data-file-id": attrs.fileId } : {}),
|
|
},
|
|
}
|
|
},
|
|
renderHTML({ HTMLAttributes }) {
|
|
return ["img", mergeAttributes(HTMLAttributes)]
|
|
},
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/modules/exams/editor/extensions/image-node.ts
|
|
git commit -m "feat(exams): add ImageNode tiptap extension with fileId binding"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: 题目块与分组块节点
|
|
|
|
**Files:**
|
|
- Create: `src/modules/exams/editor/extensions/question-block.ts`
|
|
- Create: `src/modules/exams/editor/extensions/group-block.ts`
|
|
|
|
- [ ] **Step 1: 实现 groupBlock(大题分组,如"一、选择题")**
|
|
|
|
```ts
|
|
import { Node, mergeAttributes } from "@tiptap/core"
|
|
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent } from "@tiptap/react"
|
|
|
|
declare module "@tiptap/core" {
|
|
interface Commands<ReturnType> {
|
|
groupBlock: {
|
|
insertGroup: (title?: string) => ReturnType
|
|
}
|
|
}
|
|
}
|
|
|
|
const GroupView = ({ node, updateAttributes }: any) => (
|
|
<NodeViewWrapper className="my-4 rounded-md border-l-4 border-l-primary bg-primary/5 p-3">
|
|
<input
|
|
type="text"
|
|
value={node.attrs.title || ""}
|
|
onChange={(e) => updateAttributes({ title: e.target.value })}
|
|
placeholder="大题标题(如:一、选择题)"
|
|
className="w-full bg-transparent text-base font-semibold focus:outline-none"
|
|
/>
|
|
<NodeViewContent className="mt-2 block" />
|
|
</NodeViewWrapper>
|
|
)
|
|
|
|
export const GroupBlock = Node.create({
|
|
name: "groupBlock",
|
|
group: "block",
|
|
content: "block+",
|
|
defining: true,
|
|
isolating: true,
|
|
addAttributes() {
|
|
return { title: { default: "" } }
|
|
},
|
|
parseHTML: () => [{ tag: "div[data-group-block]" }],
|
|
renderHTML: ({ HTMLAttributes }) => ["div", mergeAttributes(HTMLAttributes, { "data-group-block": "true" }), 0],
|
|
addNodeView() {
|
|
return ReactNodeViewRenderer(GroupView)
|
|
},
|
|
addCommands() {
|
|
return {
|
|
insertGroup:
|
|
(title) =>
|
|
({ commands }) =>
|
|
commands.insertContent({ type: "groupBlock", attrs: { title: title || "" } }),
|
|
}
|
|
},
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2: 实现 questionBlock(单道题,含题型/分值/题干)**
|
|
|
|
```ts
|
|
import { Node, mergeAttributes } from "@tiptap/core"
|
|
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent } from "@tiptap/react"
|
|
|
|
export type QuestionBlockAttrs = {
|
|
questionId: string
|
|
type: "single_choice" | "multiple_choice" | "judgment" | "text" | "composite"
|
|
score: number
|
|
}
|
|
|
|
declare module "@tiptap/core" {
|
|
interface Commands<ReturnType> {
|
|
questionBlock: {
|
|
insertQuestion: (attrs?: Partial<QuestionBlockAttrs>) => ReturnType
|
|
}
|
|
}
|
|
}
|
|
|
|
const QuestionView = ({ node, updateAttributes }: any) => {
|
|
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>
|
|
)
|
|
}
|
|
|
|
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 ?? "" },
|
|
}),
|
|
}
|
|
},
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 3: 验证类型**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: 无新错误(可能有 `any` 警告,后续 Task 替换为 NodeProps 类型)
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/modules/exams/editor/extensions/question-block.ts src/modules/exams/editor/extensions/group-block.ts
|
|
git commit -m "feat(exams): add QuestionBlock and GroupBlock tiptap extensions"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: 扩展聚合导出
|
|
|
|
**Files:**
|
|
- Create: `src/modules/exams/editor/extensions/index.ts`
|
|
|
|
- [ ] **Step 1: 聚合导出**
|
|
|
|
```ts
|
|
export { DottedMark } from "./dotted-mark"
|
|
export { BlankNode } from "./blank-node"
|
|
export { ImageNode } from "./image-node"
|
|
export { QuestionBlock } from "./question-block"
|
|
export { GroupBlock } from "./group-block"
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/modules/exams/editor/extensions/index.ts
|
|
git commit -m "feat(exams): aggregate tiptap extensions export"
|
|
```
|
|
|
|
---
|
|
|
|
## 阶段二:编辑器主组件与选区标记
|
|
|
|
### Task 7: 编辑器文档与试卷 structure 双向转换
|
|
|
|
**Files:**
|
|
- Create: `src/modules/exams/editor/exam-rich-editor-types.ts`
|
|
- Create: `src/modules/exams/editor/editor-to-structure.ts`
|
|
- Create: `src/modules/exams/editor/structure-to-editor.ts`
|
|
|
|
- [ ] **Step 1: 定义类型**
|
|
|
|
```ts
|
|
// exam-rich-editor-types.ts
|
|
import type { JSONContent } from "@tiptap/react"
|
|
|
|
export type RichQuestionType = "single_choice" | "multiple_choice" | "judgment" | "text" | "composite"
|
|
|
|
export interface RichQuestionContent {
|
|
text: string
|
|
options?: Array<{ id: string; text: string; isCorrect?: boolean }>
|
|
blanks?: Array<{ id: string; answer?: string; score?: number }>
|
|
images?: Array<{ fileId: string; url: string; alt?: string }>
|
|
subQuestions?: Array<{ id: string; text: string; answer?: string; score?: number }>
|
|
correctAnswer?: unknown
|
|
}
|
|
|
|
export interface EditorQuestion {
|
|
id: string
|
|
type: RichQuestionType
|
|
score: number
|
|
content: RichQuestionContent
|
|
}
|
|
|
|
export interface EditorStructureNode {
|
|
id: string
|
|
type: "group" | "question"
|
|
title?: string
|
|
questionId?: string
|
|
score?: number
|
|
children?: EditorStructureNode[]
|
|
}
|
|
|
|
export interface EditorDoc {
|
|
title: string
|
|
questions: EditorQuestion[]
|
|
structure: EditorStructureNode[]
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: 实现 editor-to-structure(遍历 Tiptap JSONContent)**
|
|
|
|
```ts
|
|
// editor-to-structure.ts
|
|
import { createId } from "@paralleldrive/cuid2"
|
|
import type { JSONContent } from "@tiptap/react"
|
|
import type { EditorDoc, EditorQuestion, EditorStructureNode, RichQuestionContent, RichQuestionType } from "./exam-rich-editor-types"
|
|
|
|
const extractText = (node: JSONContent | undefined): string => {
|
|
if (!node) return ""
|
|
if (node.type === "text") return node.text ?? ""
|
|
if (Array.isArray(node.content)) return node.content.map(extractText).join("")
|
|
return ""
|
|
}
|
|
|
|
const collectBlanks = (nodes: JSONContent[]): Array<{ id: string }> => {
|
|
const blanks: Array<{ id: string }> = []
|
|
let i = 0
|
|
const walk = (n: JSONContent) => {
|
|
if (n.type === "blank") {
|
|
i += 1
|
|
blanks.push({ id: String(i) })
|
|
}
|
|
if (Array.isArray(n.content)) n.content.forEach(walk)
|
|
}
|
|
nodes.forEach(walk)
|
|
return blanks
|
|
}
|
|
|
|
const collectImages = (nodes: JSONContent[]): Array<{ fileId: string; url: string; alt?: string }> => {
|
|
const imgs: Array<{ fileId: string; url: string; alt?: string }> = []
|
|
const walk = (n: JSONContent) => {
|
|
if (n.type === "image" && n.attrs) {
|
|
const fileId = typeof n.attrs.fileId === "string" ? n.attrs.fileId : ""
|
|
const url = typeof n.attrs.src === "string" ? n.attrs.src : ""
|
|
if (fileId && url) imgs.push({ fileId, url, alt: typeof n.attrs.alt === "string" ? n.attrs.alt : undefined })
|
|
}
|
|
if (Array.isArray(n.content)) n.content.forEach(walk)
|
|
}
|
|
nodes.forEach(walk)
|
|
return imgs
|
|
}
|
|
|
|
const parseOptions = (nodes: JSONContent[]): Array<{ id: string; text: string; isCorrect?: boolean }> => {
|
|
// 约定:题干后紧跟的 orderedList/bulletList 项为选项,以 "A." "B." 开头
|
|
const options: Array<{ id: string; text: string; isCorrect?: boolean }> = []
|
|
for (const n of nodes) {
|
|
if (n.type === "orderedList" || n.type === "bulletList") {
|
|
if (Array.isArray(n.content)) {
|
|
n.content.forEach((item, idx) => {
|
|
const text = extractText(item).trim()
|
|
const match = text.match(/^([A-Z])[.、)]\s*(.+)$/)
|
|
if (match) {
|
|
options.push({ id: match[1], text: match[2] })
|
|
} else {
|
|
options.push({ id: String.fromCharCode(65 + idx), text })
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return options
|
|
}
|
|
|
|
export const editorDocToStructure = (doc: JSONContent, title: string): EditorDoc => {
|
|
const questions: EditorQuestion[] = []
|
|
const structure: EditorStructureNode[] = []
|
|
const topBlocks = doc.content ?? []
|
|
|
|
for (const block of topBlocks) {
|
|
if (block.type === "groupBlock") {
|
|
const groupTitle = typeof block.attrs?.title === "string" ? block.attrs.title : ""
|
|
const children: EditorStructureNode[] = []
|
|
const innerBlocks = block.content ?? []
|
|
for (const qb of innerBlocks) {
|
|
if (qb.type === "questionBlock") {
|
|
const q = buildQuestion(qb)
|
|
questions.push(q)
|
|
children.push({ id: createId(), type: "question", questionId: q.id, score: q.score })
|
|
}
|
|
}
|
|
structure.push({ id: createId(), type: "group", title: groupTitle, children })
|
|
} else if (block.type === "questionBlock") {
|
|
const q = buildQuestion(block)
|
|
questions.push(q)
|
|
structure.push({ id: createId(), type: "question", questionId: q.id, score: q.score })
|
|
}
|
|
}
|
|
|
|
return { title, questions, structure }
|
|
}
|
|
|
|
const buildQuestion = (qb: JSONContent): EditorQuestion => {
|
|
const attrs = qb.attrs ?? {}
|
|
const id = typeof attrs.questionId === "string" && attrs.questionId ? attrs.questionId : createId()
|
|
const type = (typeof attrs.type === "string" ? attrs.type : "single_choice") as RichQuestionType
|
|
const score = typeof attrs.score === "number" ? attrs.score : 0
|
|
const inner = qb.content ?? []
|
|
const text = extractText({ type: "doc", content: inner.filter((n) => n.type !== "orderedList" && n.type !== "bulletList" && n.type !== "image") })
|
|
const options = parseOptions(inner)
|
|
const blanks = collectBlanks(inner)
|
|
const images = collectImages(inner)
|
|
const content: RichQuestionContent = { text: text.trim() }
|
|
if (options.length > 0) content.options = options
|
|
if (blanks.length > 0) content.blanks = blanks
|
|
if (images.length > 0) content.images = images
|
|
return { id, type, score, content }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: 实现 structure-to-editor(试卷 structure → Tiptap JSONContent,用于回填)**
|
|
|
|
```ts
|
|
// structure-to-editor.ts
|
|
import type { JSONContent } from "@tiptap/react"
|
|
import type { EditorDoc, EditorQuestion, EditorStructureNode } 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,
|
|
}
|
|
}
|
|
|
|
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 }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: 验证类型**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: 无新错误
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/modules/exams/editor/exam-rich-editor-types.ts src/modules/exams/editor/editor-to-structure.ts src/modules/exams/editor/structure-to-editor.ts
|
|
git commit -m "feat(exams): add editor doc <-> exam structure bidirectional conversion"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: 选区标记浮层
|
|
|
|
**Files:**
|
|
- Create: `src/modules/exams/editor/selection-toolbar.tsx`
|
|
|
|
- [ ] **Step 1: 实现选区浮层(选中文字时显示,提供标记按钮)**
|
|
|
|
```tsx
|
|
"use client"
|
|
|
|
import { useEffect, useState } from "react"
|
|
import type { Editor } from "@tiptap/react"
|
|
import { Button } from "@/shared/components/ui/button"
|
|
import { cn } from "@/shared/lib/utils"
|
|
|
|
interface SelectionToolbarProps {
|
|
editor: Editor
|
|
}
|
|
|
|
export function SelectionToolbar({ editor }: SelectionToolbarProps) {
|
|
const [visible, setVisible] = useState(false)
|
|
const [pos, setPos] = useState({ top: 0, left: 0 })
|
|
|
|
useEffect(() => {
|
|
const handler = () => {
|
|
const sel = window.getSelection()
|
|
if (!sel || sel.isCollapsed || !editor.isFocused) {
|
|
setVisible(false)
|
|
return
|
|
}
|
|
const range = sel.getRangeAt(0)
|
|
const rect = range.getBoundingClientRect()
|
|
if (rect.width === 0 && rect.height === 0) {
|
|
setVisible(false)
|
|
return
|
|
}
|
|
setPos({ top: rect.top - 48, left: rect.left + rect.width / 2 })
|
|
setVisible(true)
|
|
}
|
|
document.addEventListener("selectionchange", handler)
|
|
return () => document.removeEventListener("selectionchange", handler)
|
|
}, [editor])
|
|
|
|
if (!visible) return null
|
|
|
|
const wrap = (type: "questionBlock" | "groupBlock") => {
|
|
// 将选中的块转换为题目块/分组块
|
|
if (type === "questionBlock") editor.chain().focus().insertQuestion().run()
|
|
if (type === "groupBlock") editor.chain().focus().insertGroup().run()
|
|
setVisible(false)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="fixed z-50 flex items-center gap-1 rounded-md border bg-background p-1 shadow-md"
|
|
style={{ top: pos.top, left: pos.left, transform: "translateX(-50%)" }}
|
|
role="toolbar"
|
|
aria-label="选区标记"
|
|
>
|
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs" onClick={() => wrap("questionBlock")}>
|
|
标为题目
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs" onClick={() => wrap("groupBlock")}>
|
|
标为大题
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-7 px-2 text-xs"
|
|
onClick={() => {
|
|
editor.chain().focus().toggleDotted().run()
|
|
setVisible(false)
|
|
}}
|
|
>
|
|
加点字
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-7 px-2 text-xs"
|
|
onClick={() => {
|
|
editor.chain().focus().insertBlank().run()
|
|
setVisible(false)
|
|
}}
|
|
>
|
|
填空
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/modules/exams/editor/selection-toolbar.tsx
|
|
git commit -m "feat(exams): add SelectionToolbar for marking questions/groups/blanks/dotted"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: 富文本编辑器主组件
|
|
|
|
**Files:**
|
|
- Create: `src/modules/exams/editor/exam-rich-editor.tsx`
|
|
|
|
- [ ] **Step 1: 实现主编辑器(工具栏 + 编辑区 + 选区浮层 + 图片上传)**
|
|
|
|
```tsx
|
|
"use client"
|
|
|
|
import { useCallback, useRef } from "react"
|
|
import { useEditor, EditorContent } from "@tiptap/react"
|
|
import StarterKit from "@tiptap/starter-kit"
|
|
import Placeholder from "@tiptap/extension-placeholder"
|
|
import { Button } from "@/shared/components/ui/button"
|
|
import { Separator } from "@/shared/components/ui/separator"
|
|
import { cn } from "@/shared/lib/utils"
|
|
import { Bold, Italic, List, ListOrdered, Image as ImageIcon, Plus, FileText, Heading } from "lucide-react"
|
|
|
|
import { DottedMark, BlankNode, ImageNode, QuestionBlock, GroupBlock } from "./extensions"
|
|
import { SelectionToolbar } from "./selection-toolbar"
|
|
import type { EditorDoc } from "./exam-rich-editor-types"
|
|
import { editorDocToStructure } from "./editor-to-structure"
|
|
import { structureToEditorDoc } from "./structure-to-editor"
|
|
|
|
interface ExamRichEditorProps {
|
|
value?: EditorDoc | null
|
|
title: string
|
|
onChange: (doc: EditorDoc) => void
|
|
className?: string
|
|
}
|
|
|
|
const uploadImage = async (file: File): Promise<{ url: string; fileId: string }> => {
|
|
const fd = new FormData()
|
|
fd.append("file", file)
|
|
const res = await fetch("/api/upload", { method: "POST", body: fd })
|
|
if (!res.ok) throw new Error("upload failed")
|
|
const data = (await res.json()) as { url?: string; id?: string; fileId?: string }
|
|
return { url: data.url ?? "", fileId: data.id ?? data.fileId ?? "" }
|
|
}
|
|
|
|
export function ExamRichEditor({ value, title, onChange, className }: ExamRichEditorProps) {
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const lastEmitRef = useRef<string>("")
|
|
|
|
const editor = useEditor({
|
|
immediatelyRender: false,
|
|
extensions: [
|
|
StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
|
|
Placeholder.configure({ placeholder: "粘贴试卷文本,选中内容标记为题目/大题,或点击工具栏插入..." }),
|
|
DottedMark,
|
|
BlankNode,
|
|
ImageNode,
|
|
QuestionBlock,
|
|
GroupBlock,
|
|
],
|
|
editorProps: {
|
|
attributes: { class: "prose prose-sm dark:prose-invert max-w-none min-h-[400px] p-4 focus:outline-none" },
|
|
handleDrop: (view, event) => {
|
|
const files = event.dataTransfer?.files
|
|
if (!files || files.length === 0) return false
|
|
event.preventDefault()
|
|
for (const file of Array.from(files)) {
|
|
if (!file.type.startsWith("image/")) continue
|
|
void uploadImage(file).then(({ url, fileId }) => {
|
|
view.dispatch(view.state.tr.replaceSelectionWith(
|
|
view.state.schema.nodes.image.create({ src: url, "data-file-id": fileId })
|
|
))
|
|
})
|
|
}
|
|
return true
|
|
},
|
|
},
|
|
content: value ? structureToEditorDoc(value) : { type: "doc", content: [] },
|
|
onUpdate: ({ editor }) => {
|
|
const doc = editorDocToStructure(editor.getJSON(), title)
|
|
const sig = JSON.stringify(doc)
|
|
if (sig !== lastEmitRef.current) {
|
|
lastEmitRef.current = sig
|
|
onChange(doc)
|
|
}
|
|
},
|
|
})
|
|
|
|
const handleImagePick = useCallback(() => {
|
|
fileInputRef.current?.click()
|
|
}, [])
|
|
|
|
const handleFileChange = useCallback(
|
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
if (!file || !editor) return
|
|
try {
|
|
const { url, fileId } = await uploadImage(file)
|
|
editor.chain().focus().setImage({ src: url, "data-file-id": fileId }).run()
|
|
} catch {
|
|
// toast error
|
|
}
|
|
e.target.value = ""
|
|
},
|
|
[editor]
|
|
)
|
|
|
|
if (!editor) return null
|
|
|
|
return (
|
|
<div className={cn("flex flex-col rounded-md border bg-background overflow-hidden", className)}>
|
|
<div className="flex flex-wrap items-center gap-1 border-b bg-background p-1">
|
|
<Button size="sm" variant="ghost" className="h-8" onClick={() => editor.chain().focus().toggleBold().run()} title="加粗">
|
|
<Bold className="h-4 w-4" />
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="h-8" onClick={() => editor.chain().focus().toggleItalic().run()} title="斜体">
|
|
<Italic className="h-4 w-4" />
|
|
</Button>
|
|
<Separator orientation="vertical" className="mx-1 h-6" />
|
|
<Button size="sm" variant="ghost" className="h-8" onClick={() => editor.chain().focus().insertQuestion().run()} title="插入题目">
|
|
<FileText className="h-4 w-4" /> <span className="ml-1 text-xs">题目</span>
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="h-8" onClick={() => editor.chain().focus().insertGroup().run()} title="插入大题">
|
|
<Heading className="h-4 w-4" /> <span className="ml-1 text-xs">大题</span>
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="h-8" onClick={() => editor.chain().focus().insertBlank().run()} title="插入填空">
|
|
<Plus className="h-4 w-4" /> <span className="ml-1 text-xs">填空</span>
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="h-8" onClick={() => editor.chain().focus().toggleDotted().run()} title="加点字">
|
|
<span className="text-xs underline decoration-dotted underline-offset-4">加点字</span>
|
|
</Button>
|
|
<Separator orientation="vertical" className="mx-1 h-6" />
|
|
<Button size="sm" variant="ghost" className="h-8" onClick={() => editor.chain().focus().toggleBulletList().run()} title="无序列表">
|
|
<List className="h-4 w-4" />
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="h-8" onClick={() => editor.chain().focus().toggleOrderedList().run()} title="选项列表">
|
|
<ListOrdered className="h-4 w-4" />
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="h-8" onClick={handleImagePick} title="插入图片">
|
|
<ImageIcon className="h-4 w-4" />
|
|
</Button>
|
|
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileChange} />
|
|
</div>
|
|
<EditorContent editor={editor} className="flex-1 overflow-y-auto" />
|
|
<SelectionToolbar editor={editor} />
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: 验证类型**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: 无新错误
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/modules/exams/editor/exam-rich-editor.tsx
|
|
git commit -m "feat(exams): add ExamRichEditor main component with toolbar and image upload"
|
|
```
|
|
|
|
---
|
|
|
|
## 阶段三:新创建页(可拖拽分栏 + AI自动标记)
|
|
|
|
### Task 10: 新创建页主组件
|
|
|
|
**Files:**
|
|
- Create: `src/modules/exams/components/exam-rich-form.tsx`
|
|
|
|
- [ ] **Step 1: 实现新创建页(左编辑器 + 右预览,可拖拽)**
|
|
|
|
```tsx
|
|
"use client"
|
|
|
|
import { useState, useTransition } from "react"
|
|
import { useRouter } from "next/navigation"
|
|
import { toast } from "sonner"
|
|
import { Button } from "@/shared/components/ui/button"
|
|
import { Input } from "@/shared/components/ui/input"
|
|
import { Label } from "@/shared/components/ui/label"
|
|
import { ResizablePanel } from "@/shared/components/ui/resizable-panel"
|
|
import { ExamRichEditor } from "../editor/exam-rich-editor"
|
|
import { ExamViewer } from "./exam-viewer"
|
|
import { createExamAction } from "../actions"
|
|
import type { EditorDoc } from "../editor/exam-rich-editor-types"
|
|
|
|
export function ExamRichForm() {
|
|
const router = useRouter()
|
|
const [isPending, startTransition] = useTransition()
|
|
const [title, setTitle] = useState("")
|
|
const [doc, setDoc] = useState<EditorDoc>({ title: "", questions: [], structure: [] })
|
|
|
|
const handleCreate = () => {
|
|
if (doc.questions.length === 0) {
|
|
toast.error("请先在编辑器中标记题目")
|
|
return
|
|
}
|
|
const fd = new FormData()
|
|
fd.append("title", title.trim() || "未命名试卷")
|
|
fd.append("subject", "")
|
|
fd.append("grade", "")
|
|
fd.append("difficulty", "3")
|
|
fd.append("totalScore", String(doc.questions.reduce((s, q) => s + q.score, 0)))
|
|
fd.append("durationMin", "90")
|
|
fd.append("examMode", "homework")
|
|
fd.append("aiQuestionsJson", JSON.stringify(doc.questions))
|
|
fd.append("structureJson", JSON.stringify(doc.structure))
|
|
|
|
startTransition(async () => {
|
|
const result = await createExamAction(null, fd)
|
|
if (result.success && result.data) {
|
|
toast.success("试卷草稿已创建")
|
|
router.push(`/teacher/exams/${result.data}/build`)
|
|
} else {
|
|
toast.error(result.message || "创建失败")
|
|
}
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-[calc(100vh-8rem)] flex-col gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<Label className="text-sm">试卷标题</Label>
|
|
<Input value={title} onChange={(e) => setTitle(e.target.value)} className="max-w-xs" placeholder="未命名试卷" />
|
|
<Button onClick={handleCreate} disabled={isPending} className="ml-auto">
|
|
{isPending ? "创建中..." : "创建试卷"}
|
|
</Button>
|
|
</div>
|
|
<ResizablePanel
|
|
initialLeft={55}
|
|
left={<ExamRichEditor title={title} onChange={setDoc} className="h-full" />}
|
|
right={
|
|
<div className="h-full overflow-y-auto p-4">
|
|
<h3 className="mb-3 text-sm font-semibold text-muted-foreground">实时预览</h3>
|
|
<ExamViewer structure={doc.structure} questions={doc.questions.map((q) => ({
|
|
questionId: q.id,
|
|
questionType: q.type,
|
|
questionContent: q.content,
|
|
maxScore: q.score,
|
|
}))} />
|
|
</div>
|
|
}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: 验证类型**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: 无新错误
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/modules/exams/components/exam-rich-form.tsx
|
|
git commit -m "feat(exams): add ExamRichForm with resizable editor+preview split layout"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: 新创建页路由
|
|
|
|
**Files:**
|
|
- Create: `src/app/(dashboard)/teacher/exams/new/page.tsx`
|
|
|
|
- [ ] **Step 1: 实现路由页(替换或并存现有创建页)**
|
|
|
|
先查看现有创建页路由位置。假设现有为 `/teacher/exams` 下的弹窗创建,新增独立路由 `/teacher/exams/new`。
|
|
|
|
```tsx
|
|
import { getTranslations } from "next-intl/server"
|
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
|
import { ExamRichForm } from "@/modules/exams/components/exam-rich-form"
|
|
|
|
export const dynamic = "force-dynamic"
|
|
|
|
export default async function NewExamPage() {
|
|
await requirePermission("exam.create")
|
|
const t = await getTranslations("examHomework")
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight">{t("exam.form.createTitle")}</h1>
|
|
<p className="text-sm text-muted-foreground">粘贴试卷文本,选区标记题目,实时预览。</p>
|
|
</div>
|
|
<ExamRichForm />
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: 确认权限点存在**
|
|
|
|
Run: `grep -n "exam.create" src/shared/types/permissions.ts src/shared/lib/permissions.ts`
|
|
若不存在,使用现有 exam 创建权限点(查 `Permissions` 常量)。
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/app/(dashboard)/teacher/exams/new/page.tsx
|
|
git commit -m "feat(exams): add /teacher/exams/new route for rich editor exam creation"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: AI 自动标记 Action
|
|
|
|
**Files:**
|
|
- Modify: `src/modules/exams/actions.ts` — 新增 `autoMarkExamAction`
|
|
|
|
- [ ] **Step 1: 新增 AI 自动标记 action(调用 AI 返回题目边界,前端应用到编辑器)**
|
|
|
|
在 actions.ts 末尾添加:
|
|
|
|
```ts
|
|
"use server"
|
|
|
|
import { z } from "zod"
|
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
|
import { createAiChatCompletion } from "@/shared/lib/ai"
|
|
import { env } from "@/env.mjs"
|
|
import { ActionState } from "@/shared/types/action-state"
|
|
|
|
const AutoMarkSchema = z.object({
|
|
sourceText: z.string().min(1),
|
|
})
|
|
|
|
const AUTO_MARK_PROMPT = [
|
|
"你是试卷结构识别引擎。将给定试卷文本切分为大题分组和题目。",
|
|
'输出 JSON:{"groups":[{"title":"一、选择题","questions":[{"text":"题目文本","type":"single_choice","score":2}]}]}',
|
|
"type 取值:single_choice/multiple_choice/judgment/text/composite。",
|
|
"仅输出 JSON,不要 markdown。",
|
|
].join("\n")
|
|
|
|
export async function autoMarkExamAction(
|
|
_prev: ActionState<{ groups: Array<{ title: string; questions: Array<{ text: string; type: string; score: number }> }> }> | null,
|
|
formData: FormData
|
|
) {
|
|
await requirePermission("exam.create")
|
|
const parsed = AutoMarkSchema.safeParse({ sourceText: formData.get("sourceText") })
|
|
if (!parsed.success) return { success: false, message: "文本不能为空", data: null }
|
|
|
|
try {
|
|
const result = await createAiChatCompletion({
|
|
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
|
|
messages: [
|
|
{ role: "system", content: AUTO_MARK_PROMPT },
|
|
{ role: "user", content: parsed.data.sourceText },
|
|
],
|
|
temperature: 0,
|
|
maxTokens: 8000,
|
|
})
|
|
const json = JSON.parse(result.content)
|
|
return { success: true, message: "", data: json }
|
|
} catch (error) {
|
|
return { success: false, message: "AI 标记失败", data: null }
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: 验证类型**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: 无新错误
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/modules/exams/actions.ts
|
|
git commit -m "feat(exams): add autoMarkExamAction for AI auto-marking question boundaries"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 13: 编辑器接入 AI 自动标记按钮
|
|
|
|
**Files:**
|
|
- Modify: `src/modules/exams/components/exam-rich-form.tsx`
|
|
|
|
- [ ] **Step 1: 在 ExamRichForm 增加"AI自动标记"按钮**
|
|
|
|
在标题栏增加按钮,调用 `autoMarkExamAction`,将返回的 groups 通过 `structureToEditorDoc` 转换后填入编辑器。
|
|
|
|
需在 ExamRichEditor 暴露 `setDoc` 方法(通过 ref 或 key 重渲染)。简单方案:用 `key` 强制重建编辑器并传入新 value。
|
|
|
|
修改 ExamRichForm:
|
|
|
|
```tsx
|
|
// 在 handleCreate 之前增加
|
|
const [aiLoading, setAiLoading] = useState(false)
|
|
const [editorKey, setEditorKey] = useState(0)
|
|
|
|
const handleAutoMark = async () => {
|
|
const sourceText = doc.questions.length > 0
|
|
? doc.questions.map((q) => q.content.text).join("\n\n")
|
|
: ""
|
|
if (!sourceText) {
|
|
toast.error("请先粘贴试卷文本")
|
|
return
|
|
}
|
|
setAiLoading(true)
|
|
const fd = new FormData()
|
|
fd.append("sourceText", sourceText)
|
|
try {
|
|
const res = await autoMarkExamAction(null, fd)
|
|
if (res.success && res.data) {
|
|
const newDoc: EditorDoc = {
|
|
title,
|
|
questions: [],
|
|
structure: res.data.groups.map((g) => ({
|
|
id: createId(),
|
|
type: "group" as const,
|
|
title: g.title,
|
|
children: g.questions.map((q) => ({ id: createId(), type: "question" as const, questionId: "", score: q.score })),
|
|
})),
|
|
}
|
|
// 重建编辑器以应用新文档
|
|
setDoc(newDoc)
|
|
setEditorKey((k) => k + 1)
|
|
toast.success("AI 标记完成,请检查并调整")
|
|
} else {
|
|
toast.error(res.message || "AI 标记失败")
|
|
}
|
|
} finally {
|
|
setAiLoading(false)
|
|
}
|
|
}
|
|
```
|
|
|
|
并在 `<ExamRichEditor key={editorKey} ... />` 加 key。
|
|
|
|
注意:需 import `createId` from `@paralleldrive/cuid2` 和 `autoMarkExamAction`。
|
|
|
|
- [ ] **Step 2: 验证类型**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: 无新错误
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/modules/exams/components/exam-rich-form.tsx
|
|
git commit -m "feat(exams): integrate AI auto-marking button into rich form"
|
|
```
|
|
|
|
---
|
|
|
|
## 阶段四:答题侧拍照上传
|
|
|
|
### Task 14: 扫描上传组件
|
|
|
|
**Files:**
|
|
- Create: `src/modules/homework/components/scan-uploader.tsx`
|
|
|
|
- [ ] **Step 1: 实现整卷分页拍照上传(多图,复用 /api/upload)**
|
|
|
|
```tsx
|
|
"use client"
|
|
|
|
import { useState, useTransition } from "react"
|
|
import Image from "next/image"
|
|
import { Button } from "@/shared/components/ui/button"
|
|
import { Input } from "@/shared/components/ui/input"
|
|
import { Upload, X, Loader2 } from "lucide-react"
|
|
import { cn } from "@/shared/lib/utils"
|
|
|
|
export interface UploadedScan {
|
|
fileId: string
|
|
url: string
|
|
filename: string
|
|
}
|
|
|
|
interface ScanUploaderProps {
|
|
value: UploadedScan[]
|
|
onChange: (scans: UploadedScan[]) => void
|
|
disabled?: boolean
|
|
}
|
|
|
|
export function ScanUploader({ value, onChange, disabled }: ScanUploaderProps) {
|
|
const [uploading, setUploading] = useState(false)
|
|
|
|
const handleUpload = async (files: FileList) => {
|
|
setUploading(true)
|
|
try {
|
|
const uploaded: UploadedScan[] = []
|
|
for (const file of Array.from(files)) {
|
|
if (!file.type.startsWith("image/")) continue
|
|
const fd = new FormData()
|
|
fd.append("file", file)
|
|
const res = await fetch("/api/upload", { method: "POST", body: fd })
|
|
if (!res.ok) continue
|
|
const data = (await res.json()) as { url?: string; id?: string }
|
|
uploaded.push({ fileId: data.id ?? "", url: data.url ?? "", filename: file.name })
|
|
}
|
|
onChange([...value, ...uploaded])
|
|
} finally {
|
|
setUploading(false)
|
|
}
|
|
}
|
|
|
|
const remove = (idx: number) => {
|
|
onChange(value.filter((_, i) => i !== idx))
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<label className="flex cursor-pointer flex-col items-center justify-center rounded-md border border-dashed p-6 text-center hover:bg-muted/30">
|
|
{uploading ? (
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
) : (
|
|
<Upload className="h-6 w-6 text-muted-foreground" />
|
|
)}
|
|
<span className="mt-2 text-sm text-muted-foreground">点击或拖拽上传答题图片(可多张分页)</span>
|
|
<Input
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
className="hidden"
|
|
disabled={disabled || uploading}
|
|
onChange={(e) => e.target.files && handleUpload(e.target.files)}
|
|
/>
|
|
</label>
|
|
{value.length > 0 && (
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{value.map((scan, idx) => (
|
|
<div key={scan.fileId} className="group relative aspect-[3/4] overflow-hidden rounded-md border">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img src={scan.url} alt={scan.filename} className="h-full w-full object-cover" />
|
|
<div className="absolute left-1 top-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] text-white">第{idx + 1}页</div>
|
|
{!disabled && (
|
|
<button
|
|
type="button"
|
|
onClick={() => remove(idx)}
|
|
className="absolute right-1 top-1 rounded bg-black/60 p-1 text-white opacity-0 transition-opacity group-hover:opacity-100"
|
|
aria-label="删除"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/modules/homework/components/scan-uploader.tsx
|
|
git commit -m "feat(homework): add ScanUploader for paginated answer sheet photo upload"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 15: 扩展 answerContent 支持图片
|
|
|
|
**Files:**
|
|
- Modify: `src/modules/homework/lib/question-content-utils.ts`
|
|
|
|
- [ ] **Step 1: 扩展 AnswerShape 与解析函数支持 imageFileIds**
|
|
|
|
在 `toAnswerShape` 和 `parseSavedAnswer` 中增加对 `{ answer, imageFileIds }` 的兼容:
|
|
|
|
```ts
|
|
// 修改 AnswerShape 类型
|
|
export type AnswerShape =
|
|
| { answer: string }
|
|
| { answer: boolean }
|
|
| { answer: string[] }
|
|
| { answer: unknown; imageFileIds?: string[] }
|
|
|
|
// 修改 toAnswerShape:若传入对象含 imageFileIds,保留
|
|
export const toAnswerShape = (questionType: QuestionType, v: unknown): AnswerShape => {
|
|
if (isRecord(v) && "imageFileIds" in v && Array.isArray(v.imageFileIds)) {
|
|
const inner = "answer" in v ? v.answer : ""
|
|
const base = toAnswerShape(questionType, inner)
|
|
return { ...base, imageFileIds: v.imageFileIds.filter((x): x is string => typeof x === "string") }
|
|
}
|
|
if (questionType === "text") return { answer: typeof v === "string" ? v : "" }
|
|
if (questionType === "judgment") return { answer: typeof v === "boolean" ? v : false }
|
|
if (questionType === "single_choice") return { answer: typeof v === "string" ? v : "" }
|
|
if (questionType === "multiple_choice") {
|
|
return { answer: Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [] }
|
|
}
|
|
return { answer: v }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: 新增 getImageFileIds 工具函数**
|
|
|
|
```ts
|
|
export const getImageFileIds = (studentAnswer: unknown): string[] => {
|
|
if (!isRecord(studentAnswer)) return []
|
|
const raw = studentAnswer.imageFileIds
|
|
if (!Array.isArray(raw)) return []
|
|
return raw.filter((x): x is string => typeof x === "string")
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: 验证类型**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: 无新错误
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/modules/homework/lib/question-content-utils.ts
|
|
git commit -m "feat(homework): extend answerContent to support imageFileIds for scanned answers"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 16: 答题页接入拍照上传
|
|
|
|
**Files:**
|
|
- Modify: `src/modules/homework/components/homework-take-view.tsx`
|
|
|
|
- [ ] **Step 1: 在答题页底部增加整卷拍照上传区**
|
|
|
|
在 `HomeworkTakeView` 的 `showQuestions` 区块末尾、提交按钮前,增加整卷扫描上传区。主观题(text/composite)在题目卡片内提示"请在纸上作答"。
|
|
|
|
在 `initialData.questions.map` 之后、`</ScrollArea>` 之前增加:
|
|
|
|
```tsx
|
|
{showQuestions && (
|
|
<Card className="border-dashed">
|
|
<CardHeader>
|
|
<h3 className="text-base font-semibold">主观题答题纸上传(可选)</h3>
|
|
<p className="text-sm text-muted-foreground">若主观题在纸上作答,请拍照上传整卷,教师将阅卷批改。</p>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ScanUploader
|
|
value={submissionScans}
|
|
onChange={setSubmissionScans}
|
|
disabled={!canEdit}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
```
|
|
|
|
并在组件顶部增加状态:
|
|
|
|
```tsx
|
|
const [submissionScans, setSubmissionScans] = useState<UploadedScan[]>([])
|
|
```
|
|
|
|
在 `handleSubmit` 中,将 scans 关联到 submission(通过 fileAttachments 的 targetType/targetId):
|
|
|
|
```tsx
|
|
// 在 submitHomeworkAction 调用前,先关联图片
|
|
if (submissionScans.length > 0 && submissionId) {
|
|
const fd = new FormData()
|
|
fd.append("submissionId", submissionId)
|
|
fd.append("scansJson", JSON.stringify(submissionScans))
|
|
await fetch("/api/homework/attach-scans", { method: "POST", body: fd })
|
|
}
|
|
```
|
|
|
|
注意:需新增 `/api/homework/attach-scans` 路由(Task 17)。
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/modules/homework/components/homework-take-view.tsx
|
|
git commit -m "feat(homework): integrate ScanUploader into take view for subjective answers"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 17: 关联扫描图到 submission 的 API
|
|
|
|
**Files:**
|
|
- Create: `src/app/api/homework/attach-scans/route.ts`
|
|
|
|
- [ ] **Step 1: 实现 API(更新 fileAttachments 的 targetType/targetId)**
|
|
|
|
```ts
|
|
import { NextRequest, NextResponse } from "next/server"
|
|
import { z } from "zod"
|
|
import { getServerSession } from "next-auth"
|
|
import { authOptions } from "@/auth"
|
|
import { db } from "@/shared/db"
|
|
import { fileAttachments } from "@/shared/db/schema"
|
|
import { eq, inArray } from "drizzle-orm"
|
|
|
|
const Schema = z.object({
|
|
submissionId: z.string().min(1),
|
|
scansJson: z.string(),
|
|
})
|
|
|
|
export async function POST(req: NextRequest) {
|
|
const session = await getServerSession(authOptions)
|
|
if (!session?.user?.id) return NextResponse.json({ error: "unauthorized" }, { status: 401 })
|
|
|
|
const body = await req.json()
|
|
const parsed = Schema.safeParse(body)
|
|
if (!parsed.success) return NextResponse.json({ error: "invalid" }, { status: 400 })
|
|
|
|
const scans = JSON.parse(parsed.data.scansJson) as Array<{ fileId: string }>
|
|
const fileIds = scans.map((s) => s.fileId).filter(Boolean)
|
|
if (fileIds.length === 0) return NextResponse.json({ ok: true })
|
|
|
|
await db.update(fileAttachments)
|
|
.set({ targetType: "homework_submission", targetId: parsed.data.submissionId })
|
|
.where(inArray(fileAttachments.id, fileIds))
|
|
|
|
return NextResponse.json({ ok: true })
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: 验证类型**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: 无新错误
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/app/api/homework/attach-scans/route.ts
|
|
git commit -m "feat(homework): add attach-scans API to link answer images to submission"
|
|
```
|
|
|
|
---
|
|
|
|
## 阶段五:阅卷式批改视图
|
|
|
|
### Task 18: 答案图片查看器
|
|
|
|
**Files:**
|
|
- Create: `src/modules/homework/components/scan-image-viewer.tsx`
|
|
|
|
- [ ] **Step 1: 实现图片查看器(滚动/缩放/翻页)**
|
|
|
|
```tsx
|
|
"use client"
|
|
|
|
import { useState } from "react"
|
|
import { Button } from "@/shared/components/ui/button"
|
|
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut } from "lucide-react"
|
|
import { cn } from "@/shared/lib/utils"
|
|
|
|
interface ScanImageViewerProps {
|
|
images: Array<{ url: string; filename: string }>
|
|
className?: string
|
|
}
|
|
|
|
export function ScanImageViewer({ images, className }: ScanImageViewerProps) {
|
|
const [idx, setIdx] = useState(0)
|
|
const [zoom, setZoom] = useState(1)
|
|
|
|
if (images.length === 0) {
|
|
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">无答题图片</div>
|
|
}
|
|
|
|
const current = images[idx]
|
|
|
|
return (
|
|
<div className={cn("flex h-full flex-col", className)}>
|
|
<div className="flex items-center justify-between border-b p-2">
|
|
<span className="text-xs text-muted-foreground">{current.filename} ({idx + 1}/{images.length})</span>
|
|
<div className="flex items-center gap-1">
|
|
<Button size="sm" variant="ghost" className="h-7 w-7 p-0" onClick={() => setZoom((z) => Math.max(0.5, z - 0.2))}>
|
|
<ZoomOut className="h-4 w-4" />
|
|
</Button>
|
|
<span className="text-xs tabular-nums">{Math.round(zoom * 100)}%</span>
|
|
<Button size="sm" variant="ghost" className="h-7 w-7 p-0" onClick={() => setZoom((z) => Math.min(3, z + 0.2))}>
|
|
<ZoomIn className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="relative flex-1 overflow-auto bg-muted/30">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={current.url}
|
|
alt={current.filename}
|
|
className="mx-auto block"
|
|
style={{ transform: `scale(${zoom})`, transformOrigin: "top center" }}
|
|
/>
|
|
</div>
|
|
{images.length > 1 && (
|
|
<div className="flex items-center justify-between border-t p-2">
|
|
<Button size="sm" variant="outline" disabled={idx === 0} onClick={() => setIdx((i) => Math.max(0, i - 1))}>
|
|
<ChevronLeft className="h-4 w-4" /> 上一页
|
|
</Button>
|
|
<Button size="sm" variant="outline" disabled={idx === images.length - 1} onClick={() => setIdx((i) => Math.min(images.length - 1, i + 1))}>
|
|
下一页 <ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/modules/homework/components/scan-image-viewer.tsx
|
|
git commit -m "feat(homework): add ScanImageViewer with zoom and pagination"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 19: 阅卷式批改视图
|
|
|
|
**Files:**
|
|
- Create: `src/modules/homework/components/homework-scan-grading-view.tsx`
|
|
|
|
- [ ] **Step 1: 实现阅卷式批改(左题目+评分,右答案图片)**
|
|
|
|
```tsx
|
|
"use client"
|
|
|
|
import { useState } from "react"
|
|
import { useRouter } from "next/navigation"
|
|
import { useTranslations } from "next-intl"
|
|
import { toast } from "sonner"
|
|
import { Button } from "@/shared/components/ui/button"
|
|
import { Card, CardContent, 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 { ScrollArea } from "@/shared/components/ui/scroll-area"
|
|
import { ResizablePanel } from "@/shared/components/ui/resizable-panel"
|
|
import { ScanImageViewer } from "./scan-image-viewer"
|
|
import { QuestionRenderer } from "./question-renderer"
|
|
import { gradeHomeworkSubmissionAction } from "../actions"
|
|
import { getFileAttachmentsByTarget } from "@/modules/files/data-access"
|
|
import type { UploadedScan } from "./scan-uploader"
|
|
|
|
type Answer = {
|
|
id: string
|
|
questionId: string
|
|
questionContent: unknown
|
|
questionType: string
|
|
maxScore: number
|
|
studentAnswer: unknown
|
|
score: number | null
|
|
feedback: string | null
|
|
order: number
|
|
}
|
|
|
|
interface HomeworkScanGradingViewProps {
|
|
submissionId: string
|
|
studentName: string
|
|
assignmentTitle: string
|
|
answers: Answer[]
|
|
scans: Array<{ url: string; filename: string }>
|
|
}
|
|
|
|
export function HomeworkScanGradingView({
|
|
submissionId,
|
|
studentName,
|
|
assignmentTitle,
|
|
answers,
|
|
scans,
|
|
}: HomeworkScanGradingViewProps) {
|
|
const router = useRouter()
|
|
const t = useTranslations("examHomework")
|
|
const [answerStates, setAnswerStates] = useState(() => answers)
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
|
|
const handleScoreChange = (id: string, val: string) => {
|
|
const num = val === "" ? 0 : Number(val)
|
|
setAnswerStates((prev) => prev.map((a) => (a.id === id ? { ...a, score: Number.isFinite(num) ? num : 0 } : a)))
|
|
}
|
|
|
|
const handleFeedbackChange = (id: string, val: string) => {
|
|
setAnswerStates((prev) => prev.map((a) => (a.id === id ? { ...a, feedback: val } : a)))
|
|
}
|
|
|
|
const handleSubmit = async () => {
|
|
setIsSubmitting(true)
|
|
const payload = answerStates.map((a) => ({
|
|
id: a.id,
|
|
score: a.score ?? 0,
|
|
feedback: a.feedback?.trim() || undefined,
|
|
}))
|
|
const fd = new FormData()
|
|
fd.set("submissionId", submissionId)
|
|
fd.set("answersJson", JSON.stringify(payload))
|
|
try {
|
|
const res = await gradeHomeworkSubmissionAction(null, fd)
|
|
if (res.success) {
|
|
toast.success(t("homework.grade.gradesSaved"))
|
|
router.refresh()
|
|
} else {
|
|
toast.error(res.message || t("homework.grade.gradesSaveFailed"))
|
|
}
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}
|
|
|
|
const totalScore = answerStates.reduce((s, a) => s + (a.score ?? 0), 0)
|
|
const maxTotal = answerStates.reduce((s, a) => s + a.maxScore, 0)
|
|
|
|
return (
|
|
<div className="flex h-[calc(100vh-8rem)] flex-col gap-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-lg font-semibold">{assignmentTitle}</h2>
|
|
<p className="text-sm text-muted-foreground">{studentName} · 总分 {totalScore}/{maxTotal}</p>
|
|
</div>
|
|
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
|
{isSubmitting ? "保存中..." : "保存批改"}
|
|
</Button>
|
|
</div>
|
|
<ResizablePanel
|
|
initialLeft={50}
|
|
left={
|
|
<ScrollArea className="h-full">
|
|
<div className="space-y-4 p-4">
|
|
{answerStates.map((ans, idx) => (
|
|
<Card key={ans.id} id={`grade-${ans.id}`}>
|
|
<CardHeader className="pb-2">
|
|
<QuestionRenderer
|
|
questionId={ans.id}
|
|
questionType={ans.questionType}
|
|
questionContent={ans.questionContent}
|
|
maxScore={ans.maxScore}
|
|
index={idx}
|
|
mode="grade"
|
|
value={ans.studentAnswer}
|
|
showCorrectAnswer
|
|
/>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<Label className="text-sm">评分</Label>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
max={ans.maxScore}
|
|
value={ans.score ?? ""}
|
|
onChange={(e) => handleScoreChange(ans.id, e.target.value)}
|
|
className="w-20"
|
|
/>
|
|
<span className="text-sm text-muted-foreground">/ {ans.maxScore}</span>
|
|
</div>
|
|
<Textarea
|
|
placeholder="批改反馈(可选)"
|
|
value={ans.feedback ?? ""}
|
|
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
|
|
className="min-h-[60px]"
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
}
|
|
right={
|
|
<div className="h-full rounded-md border">
|
|
<ScanImageViewer images={scans} />
|
|
</div>
|
|
}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: 验证类型**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: 无新错误
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/modules/homework/components/homework-scan-grading-view.tsx
|
|
git commit -m "feat(homework): add HomeworkScanGradingView with split question+scan layout"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 20: 阅卷式批改页路由
|
|
|
|
**Files:**
|
|
- Create: `src/app/(dashboard)/teacher/homework/submissions/[id]/scan/page.tsx`
|
|
|
|
- [ ] **Step 1: 实现路由页(读取 submission + scans)**
|
|
|
|
```tsx
|
|
import { notFound } from "next/navigation"
|
|
import { getTranslations } from "next-intl/server"
|
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
|
import { getHomeworkSubmissionForGrading } from "@/modules/homework/data-access"
|
|
import { getFileAttachmentsByTarget } from "@/modules/files/data-access"
|
|
import { HomeworkScanGradingView } from "@/modules/homework/components/homework-scan-grading-view"
|
|
|
|
export const dynamic = "force-dynamic"
|
|
|
|
export default async function ScanGradingPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ id: string }>
|
|
}) {
|
|
await requirePermission("homework.grade")
|
|
const { id } = await params
|
|
const t = await getTranslations("examHomework")
|
|
|
|
const submission = await getHomeworkSubmissionForGrading(id)
|
|
if (!submission) return notFound()
|
|
|
|
const scans = await getFileAttachmentsByTarget("homework_submission", id)
|
|
|
|
return (
|
|
<HomeworkScanGradingView
|
|
submissionId={id}
|
|
studentName={submission.studentName}
|
|
assignmentTitle={submission.assignmentTitle}
|
|
answers={submission.answers}
|
|
scans={scans.map((s) => ({ url: s.url, filename: s.originalName }))}
|
|
/>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: 确认 getHomeworkSubmissionForGrading 存在**
|
|
|
|
Run: `grep -n "getHomeworkSubmissionForGrading" src/modules/homework/data-access.ts`
|
|
若不存在,在 data-access.ts 中添加该函数(读取 submission + answers + studentName + assignmentTitle)。
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/app/(dashboard)/teacher/homework/submissions/[id]/scan/page.tsx
|
|
git commit -m "feat(homework): add scan grading route page"
|
|
```
|
|
|
|
---
|
|
|
|
## 阶段六:i18n 与架构图同步
|
|
|
|
### Task 21: 补齐题型标签 i18n
|
|
|
|
**Files:**
|
|
- Modify: `src/shared/i18n/messages/zh-CN/exam-homework.json`
|
|
- Modify: `src/shared/i18n/messages/en/exam-homework.json`
|
|
- Modify: `src/modules/homework/components/question-renderer.tsx`
|
|
|
|
- [ ] **Step 1: 在 zh-CN/exam-homework.json 的 homework.take 下增加 questionType 节点**
|
|
|
|
```json
|
|
"questionType": {
|
|
"single_choice": "单选题",
|
|
"multiple_choice": "多选题",
|
|
"judgment": "判断题",
|
|
"text": "填空/简答",
|
|
"composite": "复合题"
|
|
}
|
|
```
|
|
|
|
在 en/exam-homework.json 同步:
|
|
|
|
```json
|
|
"questionType": {
|
|
"single_choice": "Single Choice",
|
|
"multiple_choice": "Multiple Choice",
|
|
"judgment": "True/False",
|
|
"text": "Short Answer",
|
|
"composite": "Composite"
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: 修改 question-renderer.tsx 使用 i18n 题型标签**
|
|
|
|
将第 97 行:
|
|
```tsx
|
|
{questionType.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase())} • {maxScore} {t("homework.take.points")}
|
|
```
|
|
改为:
|
|
```tsx
|
|
{t(`homework.take.questionType.${questionType}`)} • {maxScore} {t("homework.take.points")}
|
|
```
|
|
|
|
- [ ] **Step 3: 验证类型**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: 无新错误
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/shared/i18n/messages/zh-CN/exam-homework.json src/shared/i18n/messages/en/exam-homework.json src/modules/homework/components/question-renderer.tsx
|
|
git commit -m "feat(i18n): add question type labels and replace hardcoded English in QuestionRenderer"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 22: 补齐编辑器与阅卷文案 i18n
|
|
|
|
**Files:**
|
|
- Modify: `src/shared/i18n/messages/zh-CN/exam-homework.json`
|
|
- Modify: `src/shared/i18n/messages/en/exam-homework.json`
|
|
|
|
- [ ] **Step 1: 增加 exam.editor 与 homework.scanGrading 文案节点**
|
|
|
|
zh-CN:
|
|
```json
|
|
"editor": {
|
|
"toolbar": {
|
|
"bold": "加粗",
|
|
"italic": "斜体",
|
|
"question": "题目",
|
|
"group": "大题",
|
|
"blank": "填空",
|
|
"dotted": "加点字",
|
|
"image": "图片",
|
|
"bulletList": "无序列表",
|
|
"orderedList": "选项列表"
|
|
},
|
|
"placeholder": "粘贴试卷文本,选中内容标记为题目/大题,或点击工具栏插入...",
|
|
"selection": {
|
|
"markQuestion": "标为题目",
|
|
"markGroup": "标为大题",
|
|
"dotted": "加点字",
|
|
"blank": "填空"
|
|
},
|
|
"autoMark": "AI 自动标记",
|
|
"autoMarking": "AI 标记中...",
|
|
"preview": "实时预览",
|
|
"create": "创建试卷"
|
|
},
|
|
"scanGrading": {
|
|
"title": "阅卷批改",
|
|
"saveGrades": "保存批改",
|
|
"saving": "保存中...",
|
|
"noScans": "无答题图片",
|
|
"prevPage": "上一页",
|
|
"nextPage": "下一页"
|
|
}
|
|
```
|
|
|
|
en 同步英文。
|
|
|
|
- [ ] **Step 2: 将编辑器/阅卷组件中的硬编码中文替换为 t() 调用**
|
|
|
|
修改 `exam-rich-editor.tsx`、`selection-toolbar.tsx`、`exam-rich-form.tsx`、`scan-uploader.tsx`、`scan-image-viewer.tsx`、`homework-scan-grading-view.tsx` 中的硬编码文案为 `useTranslations("examHomework")` 调用。
|
|
|
|
- [ ] **Step 3: 验证类型**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: 无新错误
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/shared/i18n/messages/zh-CN/exam-homework.json src/shared/i18n/messages/en/exam-homework.json src/modules/exams/editor/ src/modules/exams/components/exam-rich-form.tsx src/modules/homework/components/
|
|
git commit -m "feat(i18n): add editor and scan grading i18n messages, replace hardcoded strings"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 23: 同步架构图
|
|
|
|
**Files:**
|
|
- Modify: `docs/architecture/004_architecture_impact_map.md`
|
|
- Modify: `docs/architecture/005_architecture_data.json`
|
|
|
|
- [ ] **Step 1: 在 004 文档的 exams 模块章节增加 editor 子目录说明**
|
|
|
|
在 exams 模块章节增加:
|
|
```
|
|
#### exams/editor (新增)
|
|
- ExamRichEditor: 富文本试卷编辑器(Tiptap 扩展)
|
|
- extensions/: 自定义节点(questionBlock/groupBlock/blankNode/dottedMark/imageNode)
|
|
- editor-to-structure / structure-to-editor: 文档模型与试卷 structure 双向转换
|
|
- selection-toolbar: 选区标记浮层
|
|
```
|
|
|
|
- [ ] **Step 2: 在 homework 模块章节增加 scan 组件说明**
|
|
|
|
```
|
|
#### homework/components (新增)
|
|
- ScanUploader: 整卷分页拍照上传
|
|
- ScanImageViewer: 答案图片查看器(缩放/翻页)
|
|
- HomeworkScanGradingView: 阅卷式批改视图(左题目右图片)
|
|
```
|
|
|
|
- [ ] **Step 3: 在 005 JSON 的 modules.exams.exports 与 modules.homework.exports 增加新导出**
|
|
|
|
```json
|
|
"exams": {
|
|
"exports": [
|
|
"ExamRichEditor",
|
|
"autoMarkExamAction",
|
|
"editorDocToStructure",
|
|
"structureToEditorDoc"
|
|
]
|
|
},
|
|
"homework": {
|
|
"exports": [
|
|
"ScanUploader",
|
|
"ScanImageViewer",
|
|
"HomeworkScanGradingView"
|
|
]
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add docs/architecture/004_architecture_impact_map.md docs/architecture/005_architecture_data.json
|
|
git commit -m "docs(architecture): sync exams editor and homework scan grading modules"
|
|
```
|
|
|
|
---
|
|
|
|
## 阶段七:验证与收尾
|
|
|
|
### Task 24: Lint 与类型检查
|
|
|
|
- [ ] **Step 1: 运行 lint**
|
|
|
|
Run: `npm run lint`
|
|
Expected: 零错误(警告可接受)
|
|
|
|
- [ ] **Step 2: 运行类型检查**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: 零错误
|
|
|
|
- [ ] **Step 3: 修复发现的问题**
|
|
|
|
逐一修复 lint/tsc 报错。
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "chore: fix lint and type errors from exam editor refactor"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 25: 手动验证关键流程
|
|
|
|
- [ ] **Step 1: 验证创建流程**
|
|
|
|
1. 访问 `/teacher/exams/new`
|
|
2. 粘贴试卷文本
|
|
3. 选中文字 → 点击"标为题目"/"标为大题"/"加点字"/"填空"
|
|
4. 点击"AI 自动标记"
|
|
5. 拖拽分栏分隔条,确认可调整
|
|
6. 点击"创建试卷",确认跳转到 build 页
|
|
|
|
- [ ] **Step 2: 验证答题流程**
|
|
|
|
1. 学生访问作业
|
|
2. 客观题在线作答
|
|
3. 底部上传答题图片(多张)
|
|
4. 提交
|
|
|
|
- [ ] **Step 3: 验证阅卷流程**
|
|
|
|
1. 教师访问 `/teacher/homework/submissions/[id]/scan`
|
|
2. 左侧题目评分,右侧图片滚动/缩放/翻页
|
|
3. 保存批改
|
|
|
|
- [ ] **Step 4: 修复发现的问题并 Commit**
|
|
|
|
---
|
|
|
|
## Self-Review
|
|
|
|
**1. Spec coverage:**
|
|
- 富文本编辑器选区标记题目/分组/填空/加点字 → Task 2-9 ✓
|
|
- AI 自动标记 → Task 12-13 ✓
|
|
- 可拖拽分栏预览 → Task 1, 10 ✓
|
|
- 整卷分页拍照上传 → Task 14-17 ✓
|
|
- 阅卷式批改(学生答案图片侧边展示) → Task 18-20 ✓
|
|
- i18n 题型标签 → Task 21-22 ✓
|
|
- 架构图同步 → Task 23 ✓
|
|
- 图片附件(书签、宣传照) → Task 4, 9 图片上传 ✓
|
|
- 加点字(拼音注音题) → Task 2 ✓
|
|
- 多空填空 → Task 3, 7 blanks 结构 ✓
|
|
|
|
**2. Placeholder scan:** 无 TBD/TODO,每个步骤有具体代码。
|
|
|
|
**3. Type consistency:**
|
|
- `EditorDoc` / `EditorQuestion` / `EditorStructureNode` 在 Task 7 定义,Task 10/13 使用一致
|
|
- `UploadedScan` 在 Task 14 定义,Task 16 使用一致
|
|
- `AnswerShape` 在 Task 15 扩展,与现有 `parseSavedAnswer` 兼容
|
|
|
|
**注意点:**
|
|
- Task 5 的 NodeView 用了 `any`,后续可替换为 `NodeProps`(不阻塞功能)
|
|
- Task 12 的 AI 返回结构需与 Task 13 的消费端一致(`groups[].questions[]`)
|
|
- Task 17 的 API 需确认 `/api/upload` 返回字段(`id` vs `fileId`),需在 Task 9/14 验证
|