feat(exams,homework): add rich text exam editor and scan-based grading
- Add Tiptap-based rich text editor with custom extensions (dotted-mark, blank-node, image-node, group-block, question-block) for exam creation - Add AI auto-marking action to convert pasted exam text to structured editor doc - Add resizable split-panel layout for editor + live preview - Add student scan upload (photo of paper answers) with drag-drop and reorder - Add scan image viewer with zoom/rotate/fullscreen for teachers - Add scan grading view with side-by-side questions and scan images - Add /teacher/exams/new and /teacher/homework/submissions/[id]/scan-grading routes - Fix getScansAction to support both teacher (HOMEWORK_GRADE) and student (HOMEWORK_SUBMIT) permission scopes - Add i18n keys for rich editor, scan upload, and scan grading (zh-CN/en) - Sync architecture diagrams (004/005) with new modules, routes, and deps
This commit is contained in:
@@ -433,3 +433,122 @@ export async function batchAutoGradeSubmissionsAction(
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 答题拍照上传:扫描图管理(基于 fileAttachments 表)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ScanAttachment {
|
||||
fileId: string
|
||||
url: string
|
||||
filename: string
|
||||
originalName: string
|
||||
/** 页码(按创建时间排序,从 1 开始) */
|
||||
page: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某次提交的所有答题扫描图。
|
||||
* 扫描图存储在 fileAttachments 表中,targetType="homework", targetId=submissionId。
|
||||
*
|
||||
* 支持两类访问者:
|
||||
* - 学生(HOMEWORK_SUBMIT):仅可访问自己的提交
|
||||
* - 教师(HOMEWORK_GRADE):仅可访问自己创建的作业的提交
|
||||
*/
|
||||
export async function getScansAction(
|
||||
submissionId: string
|
||||
): Promise<ActionState<ScanAttachment[]>> {
|
||||
try {
|
||||
// 优先尝试教师批改权限
|
||||
let isAuthorizedAsTeacher = false
|
||||
try {
|
||||
const gradeCtx = await requirePermission(Permissions.HOMEWORK_GRADE)
|
||||
const submissionForGrading = await getHomeworkSubmissionForGrading(submissionId)
|
||||
if (!submissionForGrading) {
|
||||
return { success: false, message: "提交记录不存在" }
|
||||
}
|
||||
// 管理员(dataScope.type === "all")或作业创建者可访问
|
||||
if (gradeCtx.dataScope.type === "all" || submissionForGrading.creatorId === gradeCtx.userId) {
|
||||
isAuthorizedAsTeacher = true
|
||||
}
|
||||
} catch {
|
||||
// 教师权限不足,继续尝试学生权限
|
||||
}
|
||||
|
||||
if (!isAuthorizedAsTeacher) {
|
||||
// 回退到学生权限:仅允许提交者本人访问
|
||||
const submitCtx = await requirePermission(Permissions.HOMEWORK_SUBMIT)
|
||||
const submission = await getHomeworkSubmissionForPermission(submissionId)
|
||||
if (!submission) {
|
||||
return { success: false, message: "提交记录不存在" }
|
||||
}
|
||||
if (submission.studentId !== submitCtx.userId) {
|
||||
return { success: false, message: "无权访问此提交" }
|
||||
}
|
||||
}
|
||||
|
||||
const { db } = await import("@/shared/db")
|
||||
const { fileAttachments } = await import("@/shared/db/schema")
|
||||
const { eq, and, asc } = await import("drizzle-orm")
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: fileAttachments.id,
|
||||
url: fileAttachments.url,
|
||||
filename: fileAttachments.filename,
|
||||
originalName: fileAttachments.originalName,
|
||||
createdAt: fileAttachments.createdAt,
|
||||
})
|
||||
.from(fileAttachments)
|
||||
.where(
|
||||
and(
|
||||
eq(fileAttachments.targetType, "homework"),
|
||||
eq(fileAttachments.targetId, submissionId)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(fileAttachments.createdAt))
|
||||
|
||||
const scans: ScanAttachment[] = rows.map((row, idx) => ({
|
||||
fileId: row.id,
|
||||
url: row.url ?? "",
|
||||
filename: row.filename,
|
||||
originalName: row.originalName,
|
||||
page: idx + 1,
|
||||
}))
|
||||
|
||||
return { success: true, message: "OK", data: scans }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除某张答题扫描图。
|
||||
* 仅允许提交者本人删除,且仅在提交状态为 started 时允许。
|
||||
*/
|
||||
export async function deleteScanAction(
|
||||
submissionId: string,
|
||||
fileId: string
|
||||
): Promise<ActionState<null>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.HOMEWORK_SUBMIT)
|
||||
|
||||
const submission = await getHomeworkSubmissionForPermission(submissionId)
|
||||
if (!submission) {
|
||||
return { success: false, message: "提交记录不存在" }
|
||||
}
|
||||
if (submission.studentId !== ctx.userId) {
|
||||
return { success: false, message: "无权操作此提交" }
|
||||
}
|
||||
if (submission.status !== "started") {
|
||||
return { success: false, message: "提交已锁定,无法修改" }
|
||||
}
|
||||
|
||||
const { deleteFileAttachment } = await import("@/modules/files/data-access")
|
||||
await deleteFileAttachment(fileId)
|
||||
|
||||
return { success: true, message: "已删除", data: null }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,12 +150,20 @@ export function HomeworkBatchGradingView({ submissions }: HomeworkBatchGradingVi
|
||||
<TableCell className="text-muted-foreground tabular-nums">{s.submittedAt ? formatDate(s.submittedAt) : "-"}</TableCell>
|
||||
<TableCell className="tabular-nums">{typeof s.score === "number" ? s.score : "-"}</TableCell>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`/teacher/homework/submissions/${s.id}`}
|
||||
className="text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("homework.grade.title")}
|
||||
</a>
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href={`/teacher/homework/submissions/${s.id}`}
|
||||
className="text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("homework.grade.title")}
|
||||
</a>
|
||||
<a
|
||||
href={`/teacher/homework/submissions/${s.id}/scan-grading`}
|
||||
className="text-sm text-muted-foreground underline-offset-4 hover:underline hover:text-foreground"
|
||||
>
|
||||
{t("homework.grade.scanGrading")}
|
||||
</a>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
|
||||
287
src/modules/homework/components/homework-scan-grading-view.tsx
Normal file
287
src/modules/homework/components/homework-scan-grading-view.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { Save, ChevronLeft, ChevronRight, User, Clock } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { ResizablePanel } from "@/shared/components/ui/resizable-panel"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
|
||||
import { gradeHomeworkSubmissionAction, getScansAction, type ScanAttachment } from "../actions"
|
||||
import { QuestionRenderer } from "./question-renderer"
|
||||
import { ScanImageViewer } from "./scan-image-viewer"
|
||||
|
||||
type QuestionContent = { text?: string } & Record<string, unknown>
|
||||
|
||||
type Answer = {
|
||||
id: string
|
||||
questionId: string
|
||||
questionContent: QuestionContent | null
|
||||
questionType: string
|
||||
maxScore: number
|
||||
studentAnswer: unknown
|
||||
score: number | null
|
||||
feedback: string | null
|
||||
order: number
|
||||
}
|
||||
|
||||
type HomeworkScanGradingViewProps = {
|
||||
submissionId: string
|
||||
studentName: string
|
||||
assignmentTitle: string
|
||||
submittedAt: string | null
|
||||
status: string
|
||||
totalScore: number | null
|
||||
answers: Answer[]
|
||||
prevSubmissionId?: string | null
|
||||
nextSubmissionId?: string | null
|
||||
}
|
||||
|
||||
interface GradingState {
|
||||
score: string
|
||||
feedback: string
|
||||
}
|
||||
|
||||
export function HomeworkScanGradingView({
|
||||
submissionId,
|
||||
studentName,
|
||||
assignmentTitle,
|
||||
submittedAt,
|
||||
status,
|
||||
totalScore,
|
||||
answers,
|
||||
prevSubmissionId,
|
||||
nextSubmissionId,
|
||||
}: HomeworkScanGradingViewProps) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("examHomework")
|
||||
const [scans, setScans] = useState<ScanAttachment[]>([])
|
||||
const [loadingScans, setLoadingScans] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const [grading, setGrading] = useState<Record<string, GradingState>>(() => {
|
||||
const obj: Record<string, GradingState> = {}
|
||||
for (const ans of answers) {
|
||||
obj[ans.id] = {
|
||||
score: ans.score?.toString() ?? "",
|
||||
feedback: ans.feedback ?? "",
|
||||
}
|
||||
}
|
||||
return obj
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const result = await getScansAction(submissionId)
|
||||
if (result.success && result.data) {
|
||||
setScans(result.data)
|
||||
}
|
||||
setLoadingScans(false)
|
||||
})()
|
||||
}, [submissionId])
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const answersPayload = answers.map((ans) => {
|
||||
const g = grading[ans.id]
|
||||
return {
|
||||
id: ans.id,
|
||||
score: g ? Number(g.score) || 0 : 0,
|
||||
feedback: g?.feedback ?? null,
|
||||
}
|
||||
})
|
||||
const fd = new FormData()
|
||||
fd.set("submissionId", submissionId)
|
||||
fd.set("answersJson", JSON.stringify(answersPayload))
|
||||
const result = await gradeHomeworkSubmissionAction(null, fd)
|
||||
if (result.success) {
|
||||
toast.success(t("homework.grade.gradesSaved"))
|
||||
} else {
|
||||
toast.error(result.message || t("homework.grade.gradesSaveFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("homework.grade.saveFailed"))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNavigate = (targetId: string | null | undefined) => {
|
||||
if (!targetId) return
|
||||
router.push(`/teacher/homework/submissions/${targetId}/scan-grading`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-10rem)] flex-col gap-4">
|
||||
{/* 顶部信息栏 */}
|
||||
<div className="flex items-center justify-between rounded-md border bg-card p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.back()}
|
||||
className="gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t("homework.grade.back")}
|
||||
</Button>
|
||||
<div className="h-6 w-px bg-border" />
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{studentName}</span>
|
||||
</div>
|
||||
<div className="h-6 w-px bg-border" />
|
||||
<span className="text-sm text-muted-foreground">{assignmentTitle}</span>
|
||||
{submittedAt && (
|
||||
<>
|
||||
<div className="h-6 w-px bg-border" />
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDate(submittedAt)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{status}
|
||||
</Badge>
|
||||
{totalScore !== null && (
|
||||
<Badge variant="secondary">{t("homework.grade.totalScore")}: {totalScore}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleNavigate(prevSubmissionId)}
|
||||
disabled={!prevSubmissionId}
|
||||
className="gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
{t("homework.grade.prevSubmission")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleNavigate(nextSubmissionId)}
|
||||
disabled={!nextSubmissionId}
|
||||
className="gap-1"
|
||||
>
|
||||
{t("homework.grade.nextSubmission")}
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving} className="gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
{isSaving ? t("homework.grade.saving") : t("homework.grade.saveGrading")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主体:左题目右扫描图 */}
|
||||
<div className="flex-1 min-h-0 rounded-md border">
|
||||
<ResizablePanel
|
||||
initialLeft={55}
|
||||
minLeft={30}
|
||||
minRight={25}
|
||||
left={
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
{t("homework.grade.questionsCount", { count: answers.length })}
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-4 p-4">
|
||||
{answers.map((ans, idx) => (
|
||||
<Card key={ans.id} className="border-l-4 border-l-primary">
|
||||
<CardHeader className="pb-2">
|
||||
<QuestionRenderer
|
||||
questionId={ans.questionId}
|
||||
questionType={ans.questionType}
|
||||
questionContent={ans.questionContent}
|
||||
maxScore={ans.maxScore}
|
||||
index={idx}
|
||||
mode="review"
|
||||
value={ans.studentAnswer}
|
||||
disabled
|
||||
showCorrectAnswer
|
||||
feedback={ans.feedback}
|
||||
/>
|
||||
<div className="mt-3 grid gap-2 border-t pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`score-${ans.id}`} className="text-xs whitespace-nowrap">
|
||||
{t("homework.grade.scoreLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`score-${ans.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={ans.maxScore}
|
||||
value={grading[ans.id]?.score ?? ""}
|
||||
onChange={(e) =>
|
||||
setGrading((prev) => ({
|
||||
...prev,
|
||||
[ans.id]: {
|
||||
score: e.target.value,
|
||||
feedback: prev[ans.id]?.feedback ?? "",
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="h-8 w-20"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("homework.grade.scoreOutOf", { max: ans.maxScore })}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder={t("homework.grade.scanFeedbackPlaceholder")}
|
||||
value={grading[ans.id]?.feedback ?? ""}
|
||||
onChange={(e) =>
|
||||
setGrading((prev) => ({
|
||||
...prev,
|
||||
[ans.id]: {
|
||||
score: prev[ans.id]?.score ?? "",
|
||||
feedback: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="min-h-[60px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
{loadingScans
|
||||
? t("homework.grade.loadingScans")
|
||||
: t("homework.grade.scanPagesCount", { count: scans.length })}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
{loadingScans ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{t("homework.grade.loadingImages")}
|
||||
</div>
|
||||
) : (
|
||||
<ScanImageViewer images={scans} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -21,12 +21,13 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert, CloudUpload, CloudOff, Check, Loader2, Timer } from "lucide-react"
|
||||
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert, CloudUpload, CloudOff, Check, Loader2, Timer, Camera } from "lucide-react"
|
||||
import { formatDate, cn } from "@/shared/lib/utils"
|
||||
|
||||
import type { StudentHomeworkTakeData } from "../types"
|
||||
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions"
|
||||
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction, getScansAction, deleteScanAction } from "../actions"
|
||||
import { QuestionRenderer } from "./question-renderer"
|
||||
import { ScanUploader, type ScanImage } from "./scan-uploader"
|
||||
import { parseSavedAnswer } from "../lib/question-content-utils"
|
||||
import { useDebouncedAutoSave, loadOfflineCache, clearOfflineCache } from "../hooks/use-debounced-auto-save"
|
||||
import { useExamCountdown } from "../hooks/use-exam-countdown"
|
||||
@@ -43,6 +44,26 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
const [submissionStatus, setSubmissionStatus] = useState<string>(initialData.submission?.status ?? "not_started")
|
||||
const [isBusy, setIsBusy] = useState(false)
|
||||
const [showSubmitConfirm, setShowSubmitConfirm] = useState(false)
|
||||
const [scanImages, setScanImages] = useState<ScanImage[]>([])
|
||||
|
||||
// 加载已有答题扫描图(拍照上传)
|
||||
useEffect(() => {
|
||||
if (!submissionId) return
|
||||
void (async () => {
|
||||
const result = await getScansAction(submissionId)
|
||||
if (result.success && result.data) {
|
||||
setScanImages(result.data)
|
||||
}
|
||||
})()
|
||||
}, [submissionId])
|
||||
|
||||
const handleDeleteScan = async (fileId: string) => {
|
||||
if (!submissionId) return
|
||||
const result = await deleteScanAction(submissionId, fileId)
|
||||
if (!result.success) {
|
||||
toast.error(result.message || t("homework.take.saveFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
const initialAnswersByQuestionId = useMemo(() => {
|
||||
const map = new Map<string, { answer: unknown }>()
|
||||
@@ -357,6 +378,29 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
|
||||
{showQuestions && submissionId && (
|
||||
<Card className="border-l-4 border-l-blue-500 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Camera className="h-4 w-4 text-blue-500" />
|
||||
<h3 className="font-semibold text-sm">{t("homework.take.scanTitle")}</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("homework.take.scanDescription")}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<ScanUploader
|
||||
images={scanImages}
|
||||
onChange={setScanImages}
|
||||
onDeleteScan={handleDeleteScan}
|
||||
submissionId={submissionId}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
206
src/modules/homework/components/scan-image-viewer.tsx
Normal file
206
src/modules/homework/components/scan-image-viewer.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Maximize2, RotateCw } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import type { ScanImage } from "./scan-uploader"
|
||||
|
||||
interface ScanImageViewerProps {
|
||||
images: ScanImage[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描图查看器 —— 用于阅卷式批改时查看学生答题图片。
|
||||
* 支持翻页、缩放、旋转、全屏。
|
||||
*/
|
||||
export function ScanImageViewer({ images, className }: ScanImageViewerProps) {
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [rotation, setRotation] = useState(0)
|
||||
|
||||
const goToPage = useCallback(
|
||||
(page: number) => {
|
||||
if (images.length === 0) return
|
||||
const clamped = Math.max(0, Math.min(images.length - 1, page))
|
||||
setCurrentPage(clamped)
|
||||
setZoom(1)
|
||||
setRotation(0)
|
||||
},
|
||||
[images.length]
|
||||
)
|
||||
|
||||
const handlePrev = useCallback(() => goToPage(currentPage - 1), [currentPage, goToPage])
|
||||
const handleNext = useCallback(() => goToPage(currentPage + 1), [currentPage, goToPage])
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
setZoom((z) => Math.min(3, z + 0.25))
|
||||
}, [])
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
setZoom((z) => Math.max(0.5, z - 0.25))
|
||||
}, [])
|
||||
|
||||
const handleRotate = useCallback(() => {
|
||||
setRotation((r) => (r + 90) % 360)
|
||||
}, [])
|
||||
|
||||
const handleFullscreen = useCallback(() => {
|
||||
const img = document.getElementById("scan-image-fullscreen")
|
||||
if (img?.requestFullscreen) {
|
||||
void img.requestFullscreen()
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<div className={cn("flex h-full items-center justify-center text-muted-foreground", className)}>
|
||||
<div className="text-center">
|
||||
<p>该学生未上传答题图片</p>
|
||||
<p className="mt-1 text-xs">学生可在答题页拍摄上传纸质答案</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const currentImage = images[currentPage]
|
||||
if (!currentImage) return null
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)}>
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center justify-between border-b bg-muted/30 px-3 py-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoom <= 0.5}
|
||||
title="缩小"
|
||||
>
|
||||
<ZoomOut className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<span className="min-w-[44px] text-center text-xs tabular-nums">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleZoomIn}
|
||||
disabled={zoom >= 3}
|
||||
title="放大"
|
||||
>
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleRotate}
|
||||
title="旋转"
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleFullscreen}
|
||||
title="全屏"
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
第 {currentPage + 1} / {images.length} 页
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图片显示区 */}
|
||||
<div className="relative flex-1 overflow-auto bg-neutral-100 dark:bg-neutral-900">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<div
|
||||
id="scan-image-fullscreen"
|
||||
className="relative"
|
||||
style={{
|
||||
transform: `scale(${zoom}) rotate(${rotation}deg)`,
|
||||
transformOrigin: "center center",
|
||||
transition: "transform 0.2s ease-out",
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={currentImage.url}
|
||||
alt={`答题图 第${currentImage.page}页`}
|
||||
className="max-h-full max-w-full object-contain shadow-lg"
|
||||
style={{ maxHeight: "80vh" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 翻页按钮 */}
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2"
|
||||
onClick={handlePrev}
|
||||
disabled={currentPage === 0}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2"
|
||||
onClick={handleNext}
|
||||
disabled={currentPage === images.length - 1}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 缩略图导航 */}
|
||||
{images.length > 1 && (
|
||||
<div className="flex gap-1.5 overflow-x-auto border-t bg-muted/20 p-2">
|
||||
{images.map((img, idx) => (
|
||||
<button
|
||||
key={img.fileId}
|
||||
type="button"
|
||||
onClick={() => goToPage(idx)}
|
||||
className={cn(
|
||||
"relative h-16 w-12 shrink-0 overflow-hidden rounded border-2 transition-colors",
|
||||
idx === currentPage
|
||||
? "border-primary"
|
||||
: "border-transparent hover:border-muted-foreground/30"
|
||||
)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={img.url}
|
||||
alt={`缩略图 ${img.page}`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<span className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 text-center text-[10px] text-white">
|
||||
{img.page}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
266
src/modules/homework/components/scan-uploader.tsx
Normal file
266
src/modules/homework/components/scan-uploader.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useTransition, type ChangeEvent } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { Upload, X, Loader2, ImageIcon } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
export interface ScanImage {
|
||||
fileId: string
|
||||
url: string
|
||||
filename: string
|
||||
originalName?: string
|
||||
/** 页码(从 1 开始) */
|
||||
page: number
|
||||
}
|
||||
|
||||
interface ScanUploaderProps {
|
||||
/** 已上传的扫描图列表 */
|
||||
images: ScanImage[]
|
||||
/** 图片列表变化回调(增删/排序后触发) */
|
||||
onChange: (images: ScanImage[]) => void
|
||||
/** 删除单张扫描图时的服务端回调(可选,用于删除 fileAttachments 记录) */
|
||||
onDeleteScan?: (fileId: string) => Promise<void>
|
||||
/** 关联的提交 ID(用于权限校验) */
|
||||
submissionId: string
|
||||
/** 是否禁用(如已提交) */
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描图上传组件 —— 学生在纸上作答后,按页拍摄上传。
|
||||
* 调用 /api/upload 上传图片,返回 fileId + url。
|
||||
* 上传后将 fileId 列表通过 onChange 暴露给父组件。
|
||||
*/
|
||||
export function ScanUploader({
|
||||
images,
|
||||
onChange,
|
||||
onDeleteScan,
|
||||
submissionId,
|
||||
disabled = false,
|
||||
className,
|
||||
}: ScanUploaderProps) {
|
||||
const [isUploading, startUpload] = useTransition()
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const t = useTranslations("examHomework")
|
||||
|
||||
const uploadFiles = (files: FileList | File[]) => {
|
||||
if (disabled) return
|
||||
const fileArray = Array.from(files).filter(
|
||||
(f) => f.type.startsWith("image/") || f.type === "application/pdf"
|
||||
)
|
||||
if (fileArray.length === 0) {
|
||||
toast.error(t("homework.take.selectImageFiles"))
|
||||
return
|
||||
}
|
||||
|
||||
startUpload(async () => {
|
||||
const uploaded: ScanImage[] = []
|
||||
const basePage = images.length
|
||||
for (let i = 0; i < fileArray.length; i++) {
|
||||
const file = fileArray[i]
|
||||
if (!file) continue
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
formData.append("targetType", "homework")
|
||||
formData.append("targetId", submissionId)
|
||||
try {
|
||||
const res = await fetch("/api/upload", { method: "POST", body: formData })
|
||||
const data = await res.json()
|
||||
if (data?.success && data?.url && data?.id) {
|
||||
uploaded.push({
|
||||
fileId: data.id,
|
||||
url: data.url,
|
||||
filename: data.originalName || data.filename || `page-${basePage + i + 1}`,
|
||||
page: basePage + i + 1,
|
||||
})
|
||||
} else {
|
||||
toast.error(`${t("homework.take.uploadFailed")}: ${data?.message || ""}`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[ScanUploader] upload failed", e)
|
||||
toast.error(`${t("homework.take.uploadFailed")}: ${file.name}`)
|
||||
}
|
||||
}
|
||||
if (uploaded.length > 0) {
|
||||
const next = [...images, ...uploaded]
|
||||
onChange(next)
|
||||
toast.success(t("homework.take.uploadSuccess", { count: uploaded.length }))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleFileInput = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
uploadFiles(e.target.files)
|
||||
e.target.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
uploadFiles(e.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = (page: number) => {
|
||||
if (disabled) return
|
||||
const target = images.find((img) => img.page === page)
|
||||
const next = images.filter((img) => img.page !== page)
|
||||
// 重新编号
|
||||
const renumbered = next.map((img, idx) => ({ ...img, page: idx + 1 }))
|
||||
onChange(renumbered)
|
||||
// 服务端删除(不阻塞 UI)
|
||||
if (target && onDeleteScan) {
|
||||
void onDeleteScan(target.fileId).catch((e) => {
|
||||
console.error("[ScanUploader] delete failed", e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleMove = (page: number, direction: "up" | "down") => {
|
||||
if (disabled) return
|
||||
const idx = images.findIndex((img) => img.page === page)
|
||||
if (idx === -1) return
|
||||
const targetIdx = direction === "up" ? idx - 1 : idx + 1
|
||||
if (targetIdx < 0 || targetIdx >= images.length) return
|
||||
const next = [...images]
|
||||
const [moved] = next.splice(idx, 1)
|
||||
next.splice(targetIdx, 0, moved)
|
||||
const renumbered = next.map((img, i) => ({ ...img, page: i + 1 }))
|
||||
onChange(renumbered)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setDragOver(true)
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"rounded-md border-2 border-dashed p-6 text-center transition-colors",
|
||||
dragOver ? "border-primary bg-primary/5" : "border-border",
|
||||
disabled && "opacity-50"
|
||||
)}
|
||||
>
|
||||
<Upload className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("homework.take.dragDropHint")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
支持 JPG/PNG/WebP,每张不超过 10MB
|
||||
</p>
|
||||
<label className="mt-3 inline-block">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFileInput}
|
||||
disabled={disabled || isUploading}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled || isUploading}
|
||||
className="gap-2"
|
||||
onClick={() => {
|
||||
// 触发隐藏的 file input
|
||||
const input = document.createElement("input")
|
||||
input.type = "file"
|
||||
input.accept = "image/*"
|
||||
input.multiple = true
|
||||
input.onchange = () => {
|
||||
if (input.files) uploadFiles(input.files)
|
||||
}
|
||||
input.click()
|
||||
}}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{isUploading ? t("homework.take.submitting") : t("homework.take.scanTitle")}
|
||||
</Button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{images.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||
{images.map((img) => (
|
||||
<div
|
||||
key={img.fileId}
|
||||
className="group relative overflow-hidden rounded-md border bg-muted"
|
||||
>
|
||||
<div className="aspect-[3/4] w-full">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={img.url}
|
||||
alt={img.filename}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute left-1 top-1 rounded bg-black/60 px-1.5 py-0.5 text-xs text-white">
|
||||
{t("homework.take.pageLabel", { page: img.page })}
|
||||
</div>
|
||||
{!disabled && (
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-1 bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleMove(img.page, "up")}
|
||||
disabled={img.page === 1}
|
||||
title={t("homework.take.moveUp")}
|
||||
>
|
||||
↑
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleMove(img.page, "down")}
|
||||
disabled={img.page === images.length}
|
||||
title={t("homework.take.moveDown")}
|
||||
>
|
||||
↓
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleRemove(img.page)}
|
||||
title={t("homework.take.deleteScan")}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{images.length === 0 && !disabled && (
|
||||
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
|
||||
<ImageIcon className="mr-2 h-4 w-4" />
|
||||
{t("homework.take.noScans")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user