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:
SpecialX
2026-06-24 13:16:33 +08:00
parent 0c64219cb8
commit 6114607c1e
30 changed files with 3548 additions and 26 deletions

View File

@@ -433,3 +433,122 @@ export async function batchAutoGradeSubmissionsAction(
return handleActionError(e)
}
}
// ---------------------------------------------------------------------------
// 答题拍照上传:扫描图管理(基于 fileAttachments 表)
// ---------------------------------------------------------------------------
export interface ScanAttachment {
fileId: string
url: string
filename: string
originalName: string
/** 页码(按创建时间排序,从 1 开始) */
page: number
}
/**
* 获取某次提交的所有答题扫描图。
* 扫描图存储在 fileAttachments 表中,targetType="homework", targetId=submissionId。
*
* 支持两类访问者:
* - 学生(HOMEWORK_SUBMIT):仅可访问自己的提交
* - 教师(HOMEWORK_GRADE):仅可访问自己创建的作业的提交
*/
export async function getScansAction(
submissionId: string
): Promise<ActionState<ScanAttachment[]>> {
try {
// 优先尝试教师批改权限
let isAuthorizedAsTeacher = false
try {
const gradeCtx = await requirePermission(Permissions.HOMEWORK_GRADE)
const submissionForGrading = await getHomeworkSubmissionForGrading(submissionId)
if (!submissionForGrading) {
return { success: false, message: "提交记录不存在" }
}
// 管理员(dataScope.type === "all")或作业创建者可访问
if (gradeCtx.dataScope.type === "all" || submissionForGrading.creatorId === gradeCtx.userId) {
isAuthorizedAsTeacher = true
}
} catch {
// 教师权限不足,继续尝试学生权限
}
if (!isAuthorizedAsTeacher) {
// 回退到学生权限:仅允许提交者本人访问
const submitCtx = await requirePermission(Permissions.HOMEWORK_SUBMIT)
const submission = await getHomeworkSubmissionForPermission(submissionId)
if (!submission) {
return { success: false, message: "提交记录不存在" }
}
if (submission.studentId !== submitCtx.userId) {
return { success: false, message: "无权访问此提交" }
}
}
const { db } = await import("@/shared/db")
const { fileAttachments } = await import("@/shared/db/schema")
const { eq, and, asc } = await import("drizzle-orm")
const rows = await db
.select({
id: fileAttachments.id,
url: fileAttachments.url,
filename: fileAttachments.filename,
originalName: fileAttachments.originalName,
createdAt: fileAttachments.createdAt,
})
.from(fileAttachments)
.where(
and(
eq(fileAttachments.targetType, "homework"),
eq(fileAttachments.targetId, submissionId)
)
)
.orderBy(asc(fileAttachments.createdAt))
const scans: ScanAttachment[] = rows.map((row, idx) => ({
fileId: row.id,
url: row.url ?? "",
filename: row.filename,
originalName: row.originalName,
page: idx + 1,
}))
return { success: true, message: "OK", data: scans }
} catch (e) {
return handleActionError(e)
}
}
/**
* 删除某张答题扫描图。
* 仅允许提交者本人删除,且仅在提交状态为 started 时允许。
*/
export async function deleteScanAction(
submissionId: string,
fileId: string
): Promise<ActionState<null>> {
try {
const ctx = await requirePermission(Permissions.HOMEWORK_SUBMIT)
const submission = await getHomeworkSubmissionForPermission(submissionId)
if (!submission) {
return { success: false, message: "提交记录不存在" }
}
if (submission.studentId !== ctx.userId) {
return { success: false, message: "无权操作此提交" }
}
if (submission.status !== "started") {
return { success: false, message: "提交已锁定,无法修改" }
}
const { deleteFileAttachment } = await import("@/modules/files/data-access")
await deleteFileAttachment(fileId)
return { success: true, message: "已删除", data: null }
} catch (e) {
return handleActionError(e)
}
}

View File

@@ -150,12 +150,20 @@ export function HomeworkBatchGradingView({ submissions }: HomeworkBatchGradingVi
<TableCell className="text-muted-foreground tabular-nums">{s.submittedAt ? formatDate(s.submittedAt) : "-"}</TableCell>
<TableCell className="tabular-nums">{typeof s.score === "number" ? s.score : "-"}</TableCell>
<TableCell>
<a
href={`/teacher/homework/submissions/${s.id}`}
className="text-sm underline-offset-4 hover:underline"
>
{t("homework.grade.title")}
</a>
<div className="flex items-center gap-3">
<a
href={`/teacher/homework/submissions/${s.id}`}
className="text-sm underline-offset-4 hover:underline"
>
{t("homework.grade.title")}
</a>
<a
href={`/teacher/homework/submissions/${s.id}/scan-grading`}
className="text-sm text-muted-foreground underline-offset-4 hover:underline hover:text-foreground"
>
{t("homework.grade.scanGrading")}
</a>
</div>
</TableCell>
</TableRow>
)

View File

@@ -0,0 +1,287 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Save, ChevronLeft, ChevronRight, User, Clock } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Card, CardHeader } from "@/shared/components/ui/card"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { Badge } from "@/shared/components/ui/badge"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { ResizablePanel } from "@/shared/components/ui/resizable-panel"
import { formatDate } from "@/shared/lib/utils"
import { gradeHomeworkSubmissionAction, getScansAction, type ScanAttachment } from "../actions"
import { QuestionRenderer } from "./question-renderer"
import { ScanImageViewer } from "./scan-image-viewer"
type QuestionContent = { text?: string } & Record<string, unknown>
type Answer = {
id: string
questionId: string
questionContent: QuestionContent | null
questionType: string
maxScore: number
studentAnswer: unknown
score: number | null
feedback: string | null
order: number
}
type HomeworkScanGradingViewProps = {
submissionId: string
studentName: string
assignmentTitle: string
submittedAt: string | null
status: string
totalScore: number | null
answers: Answer[]
prevSubmissionId?: string | null
nextSubmissionId?: string | null
}
interface GradingState {
score: string
feedback: string
}
export function HomeworkScanGradingView({
submissionId,
studentName,
assignmentTitle,
submittedAt,
status,
totalScore,
answers,
prevSubmissionId,
nextSubmissionId,
}: HomeworkScanGradingViewProps) {
const router = useRouter()
const t = useTranslations("examHomework")
const [scans, setScans] = useState<ScanAttachment[]>([])
const [loadingScans, setLoadingScans] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [grading, setGrading] = useState<Record<string, GradingState>>(() => {
const obj: Record<string, GradingState> = {}
for (const ans of answers) {
obj[ans.id] = {
score: ans.score?.toString() ?? "",
feedback: ans.feedback ?? "",
}
}
return obj
})
useEffect(() => {
void (async () => {
const result = await getScansAction(submissionId)
if (result.success && result.data) {
setScans(result.data)
}
setLoadingScans(false)
})()
}, [submissionId])
const handleSave = async () => {
setIsSaving(true)
try {
const answersPayload = answers.map((ans) => {
const g = grading[ans.id]
return {
id: ans.id,
score: g ? Number(g.score) || 0 : 0,
feedback: g?.feedback ?? null,
}
})
const fd = new FormData()
fd.set("submissionId", submissionId)
fd.set("answersJson", JSON.stringify(answersPayload))
const result = await gradeHomeworkSubmissionAction(null, fd)
if (result.success) {
toast.success(t("homework.grade.gradesSaved"))
} else {
toast.error(result.message || t("homework.grade.gradesSaveFailed"))
}
} catch {
toast.error(t("homework.grade.saveFailed"))
} finally {
setIsSaving(false)
}
}
const handleNavigate = (targetId: string | null | undefined) => {
if (!targetId) return
router.push(`/teacher/homework/submissions/${targetId}/scan-grading`)
}
return (
<div className="flex h-[calc(100vh-10rem)] flex-col gap-4">
{/* 顶部信息栏 */}
<div className="flex items-center justify-between rounded-md border bg-card p-3">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => router.back()}
className="gap-1"
>
<ChevronLeft className="h-4 w-4" />
{t("homework.grade.back")}
</Button>
<div className="h-6 w-px bg-border" />
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{studentName}</span>
</div>
<div className="h-6 w-px bg-border" />
<span className="text-sm text-muted-foreground">{assignmentTitle}</span>
{submittedAt && (
<>
<div className="h-6 w-px bg-border" />
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{formatDate(submittedAt)}
</div>
</>
)}
<Badge variant="outline" className="capitalize">
{status}
</Badge>
{totalScore !== null && (
<Badge variant="secondary">{t("homework.grade.totalScore")}: {totalScore}</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleNavigate(prevSubmissionId)}
disabled={!prevSubmissionId}
className="gap-1"
>
<ChevronLeft className="h-3 w-3" />
{t("homework.grade.prevSubmission")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleNavigate(nextSubmissionId)}
disabled={!nextSubmissionId}
className="gap-1"
>
{t("homework.grade.nextSubmission")}
<ChevronRight className="h-3 w-3" />
</Button>
<Button onClick={handleSave} disabled={isSaving} className="gap-2">
<Save className="h-4 w-4" />
{isSaving ? t("homework.grade.saving") : t("homework.grade.saveGrading")}
</Button>
</div>
</div>
{/* 主体:左题目右扫描图 */}
<div className="flex-1 min-h-0 rounded-md border">
<ResizablePanel
initialLeft={55}
minLeft={30}
minRight={25}
left={
<div className="flex h-full flex-col">
<div className="border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
{t("homework.grade.questionsCount", { count: answers.length })}
</div>
<ScrollArea className="flex-1">
<div className="space-y-4 p-4">
{answers.map((ans, idx) => (
<Card key={ans.id} className="border-l-4 border-l-primary">
<CardHeader className="pb-2">
<QuestionRenderer
questionId={ans.questionId}
questionType={ans.questionType}
questionContent={ans.questionContent}
maxScore={ans.maxScore}
index={idx}
mode="review"
value={ans.studentAnswer}
disabled
showCorrectAnswer
feedback={ans.feedback}
/>
<div className="mt-3 grid gap-2 border-t pt-3">
<div className="flex items-center gap-2">
<Label htmlFor={`score-${ans.id}`} className="text-xs whitespace-nowrap">
{t("homework.grade.scoreLabel")}
</Label>
<Input
id={`score-${ans.id}`}
type="number"
min={0}
max={ans.maxScore}
value={grading[ans.id]?.score ?? ""}
onChange={(e) =>
setGrading((prev) => ({
...prev,
[ans.id]: {
score: e.target.value,
feedback: prev[ans.id]?.feedback ?? "",
},
}))
}
className="h-8 w-20"
/>
<span className="text-xs text-muted-foreground">
{t("homework.grade.scoreOutOf", { max: ans.maxScore })}
</span>
</div>
<Textarea
placeholder={t("homework.grade.scanFeedbackPlaceholder")}
value={grading[ans.id]?.feedback ?? ""}
onChange={(e) =>
setGrading((prev) => ({
...prev,
[ans.id]: {
score: prev[ans.id]?.score ?? "",
feedback: e.target.value,
},
}))
}
className="min-h-[60px] text-sm"
/>
</div>
</CardHeader>
</Card>
))}
</div>
</ScrollArea>
</div>
}
right={
<div className="flex h-full flex-col">
<div className="border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
{loadingScans
? t("homework.grade.loadingScans")
: t("homework.grade.scanPagesCount", { count: scans.length })}
</div>
<div className="flex-1 min-h-0">
{loadingScans ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{t("homework.grade.loadingImages")}
</div>
) : (
<ScanImageViewer images={scans} />
)}
</div>
</div>
}
/>
</div>
</div>
)
}

View File

@@ -21,12 +21,13 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert, CloudUpload, CloudOff, Check, Loader2, Timer } from "lucide-react"
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert, CloudUpload, CloudOff, Check, Loader2, Timer, Camera } from "lucide-react"
import { formatDate, cn } from "@/shared/lib/utils"
import type { StudentHomeworkTakeData } from "../types"
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions"
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction, getScansAction, deleteScanAction } from "../actions"
import { QuestionRenderer } from "./question-renderer"
import { ScanUploader, type ScanImage } from "./scan-uploader"
import { parseSavedAnswer } from "../lib/question-content-utils"
import { useDebouncedAutoSave, loadOfflineCache, clearOfflineCache } from "../hooks/use-debounced-auto-save"
import { useExamCountdown } from "../hooks/use-exam-countdown"
@@ -43,6 +44,26 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
const [submissionStatus, setSubmissionStatus] = useState<string>(initialData.submission?.status ?? "not_started")
const [isBusy, setIsBusy] = useState(false)
const [showSubmitConfirm, setShowSubmitConfirm] = useState(false)
const [scanImages, setScanImages] = useState<ScanImage[]>([])
// 加载已有答题扫描图(拍照上传)
useEffect(() => {
if (!submissionId) return
void (async () => {
const result = await getScansAction(submissionId)
if (result.success && result.data) {
setScanImages(result.data)
}
})()
}, [submissionId])
const handleDeleteScan = async (fileId: string) => {
if (!submissionId) return
const result = await deleteScanAction(submissionId, fileId)
if (!result.success) {
toast.error(result.message || t("homework.take.saveFailed"))
}
}
const initialAnswersByQuestionId = useMemo(() => {
const map = new Map<string, { answer: unknown }>()
@@ -357,6 +378,29 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
</Card>
)
})}
{showQuestions && submissionId && (
<Card className="border-l-4 border-l-blue-500 shadow-sm">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<Camera className="h-4 w-4 text-blue-500" />
<h3 className="font-semibold text-sm">{t("homework.take.scanTitle")}</h3>
</div>
<p className="text-xs text-muted-foreground mt-1">
{t("homework.take.scanDescription")}
</p>
<div className="mt-3">
<ScanUploader
images={scanImages}
onChange={setScanImages}
onDeleteScan={handleDeleteScan}
submissionId={submissionId}
disabled={!canEdit}
/>
</div>
</CardHeader>
</Card>
)}
</div>
</ScrollArea>
</div>

View File

@@ -0,0 +1,206 @@
"use client"
import { useState, useCallback } from "react"
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Maximize2, RotateCw } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { cn } from "@/shared/lib/utils"
import type { ScanImage } from "./scan-uploader"
interface ScanImageViewerProps {
images: ScanImage[]
className?: string
}
/**
* 扫描图查看器 —— 用于阅卷式批改时查看学生答题图片。
* 支持翻页、缩放、旋转、全屏。
*/
export function ScanImageViewer({ images, className }: ScanImageViewerProps) {
const [currentPage, setCurrentPage] = useState(0)
const [zoom, setZoom] = useState(1)
const [rotation, setRotation] = useState(0)
const goToPage = useCallback(
(page: number) => {
if (images.length === 0) return
const clamped = Math.max(0, Math.min(images.length - 1, page))
setCurrentPage(clamped)
setZoom(1)
setRotation(0)
},
[images.length]
)
const handlePrev = useCallback(() => goToPage(currentPage - 1), [currentPage, goToPage])
const handleNext = useCallback(() => goToPage(currentPage + 1), [currentPage, goToPage])
const handleZoomIn = useCallback(() => {
setZoom((z) => Math.min(3, z + 0.25))
}, [])
const handleZoomOut = useCallback(() => {
setZoom((z) => Math.max(0.5, z - 0.25))
}, [])
const handleRotate = useCallback(() => {
setRotation((r) => (r + 90) % 360)
}, [])
const handleFullscreen = useCallback(() => {
const img = document.getElementById("scan-image-fullscreen")
if (img?.requestFullscreen) {
void img.requestFullscreen()
}
}, [])
if (images.length === 0) {
return (
<div className={cn("flex h-full items-center justify-center text-muted-foreground", className)}>
<div className="text-center">
<p></p>
<p className="mt-1 text-xs"></p>
</div>
</div>
)
}
const currentImage = images[currentPage]
if (!currentImage) return null
return (
<div className={cn("flex h-full flex-col", className)}>
{/* 工具栏 */}
<div className="flex items-center justify-between border-b bg-muted/30 px-3 py-1.5">
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleZoomOut}
disabled={zoom <= 0.5}
title="缩小"
>
<ZoomOut className="h-3.5 w-3.5" />
</Button>
<span className="min-w-[44px] text-center text-xs tabular-nums">
{Math.round(zoom * 100)}%
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleZoomIn}
disabled={zoom >= 3}
title="放大"
>
<ZoomIn className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleRotate}
title="旋转"
>
<RotateCw className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleFullscreen}
title="全屏"
>
<Maximize2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="text-xs text-muted-foreground">
{currentPage + 1} / {images.length}
</div>
</div>
{/* 图片显示区 */}
<div className="relative flex-1 overflow-auto bg-neutral-100 dark:bg-neutral-900">
<div className="flex min-h-full items-center justify-center p-4">
<div
id="scan-image-fullscreen"
className="relative"
style={{
transform: `scale(${zoom}) rotate(${rotation}deg)`,
transformOrigin: "center center",
transition: "transform 0.2s ease-out",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={currentImage.url}
alt={`答题图 第${currentImage.page}`}
className="max-h-full max-w-full object-contain shadow-lg"
style={{ maxHeight: "80vh" }}
/>
</div>
</div>
{/* 翻页按钮 */}
{images.length > 1 && (
<>
<Button
type="button"
variant="secondary"
size="icon"
className="absolute left-2 top-1/2 -translate-y-1/2"
onClick={handlePrev}
disabled={currentPage === 0}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
type="button"
variant="secondary"
size="icon"
className="absolute right-2 top-1/2 -translate-y-1/2"
onClick={handleNext}
disabled={currentPage === images.length - 1}
>
<ChevronRight className="h-4 w-4" />
</Button>
</>
)}
</div>
{/* 缩略图导航 */}
{images.length > 1 && (
<div className="flex gap-1.5 overflow-x-auto border-t bg-muted/20 p-2">
{images.map((img, idx) => (
<button
key={img.fileId}
type="button"
onClick={() => goToPage(idx)}
className={cn(
"relative h-16 w-12 shrink-0 overflow-hidden rounded border-2 transition-colors",
idx === currentPage
? "border-primary"
: "border-transparent hover:border-muted-foreground/30"
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={img.url}
alt={`缩略图 ${img.page}`}
className="h-full w-full object-cover"
/>
<span className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 text-center text-[10px] text-white">
{img.page}
</span>
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,266 @@
"use client"
import { useState, useTransition, type ChangeEvent } from "react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Upload, X, Loader2, ImageIcon } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { cn } from "@/shared/lib/utils"
export interface ScanImage {
fileId: string
url: string
filename: string
originalName?: string
/** 页码(从 1 开始) */
page: number
}
interface ScanUploaderProps {
/** 已上传的扫描图列表 */
images: ScanImage[]
/** 图片列表变化回调(增删/排序后触发) */
onChange: (images: ScanImage[]) => void
/** 删除单张扫描图时的服务端回调(可选,用于删除 fileAttachments 记录) */
onDeleteScan?: (fileId: string) => Promise<void>
/** 关联的提交 ID(用于权限校验) */
submissionId: string
/** 是否禁用(如已提交) */
disabled?: boolean
className?: string
}
/**
* 扫描图上传组件 —— 学生在纸上作答后,按页拍摄上传。
* 调用 /api/upload 上传图片,返回 fileId + url。
* 上传后将 fileId 列表通过 onChange 暴露给父组件。
*/
export function ScanUploader({
images,
onChange,
onDeleteScan,
submissionId,
disabled = false,
className,
}: ScanUploaderProps) {
const [isUploading, startUpload] = useTransition()
const [dragOver, setDragOver] = useState(false)
const t = useTranslations("examHomework")
const uploadFiles = (files: FileList | File[]) => {
if (disabled) return
const fileArray = Array.from(files).filter(
(f) => f.type.startsWith("image/") || f.type === "application/pdf"
)
if (fileArray.length === 0) {
toast.error(t("homework.take.selectImageFiles"))
return
}
startUpload(async () => {
const uploaded: ScanImage[] = []
const basePage = images.length
for (let i = 0; i < fileArray.length; i++) {
const file = fileArray[i]
if (!file) continue
const formData = new FormData()
formData.append("file", file)
formData.append("targetType", "homework")
formData.append("targetId", submissionId)
try {
const res = await fetch("/api/upload", { method: "POST", body: formData })
const data = await res.json()
if (data?.success && data?.url && data?.id) {
uploaded.push({
fileId: data.id,
url: data.url,
filename: data.originalName || data.filename || `page-${basePage + i + 1}`,
page: basePage + i + 1,
})
} else {
toast.error(`${t("homework.take.uploadFailed")}: ${data?.message || ""}`)
}
} catch (e) {
console.error("[ScanUploader] upload failed", e)
toast.error(`${t("homework.take.uploadFailed")}: ${file.name}`)
}
}
if (uploaded.length > 0) {
const next = [...images, ...uploaded]
onChange(next)
toast.success(t("homework.take.uploadSuccess", { count: uploaded.length }))
}
})
}
const handleFileInput = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
uploadFiles(e.target.files)
e.target.value = ""
}
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setDragOver(false)
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
uploadFiles(e.dataTransfer.files)
}
}
const handleRemove = (page: number) => {
if (disabled) return
const target = images.find((img) => img.page === page)
const next = images.filter((img) => img.page !== page)
// 重新编号
const renumbered = next.map((img, idx) => ({ ...img, page: idx + 1 }))
onChange(renumbered)
// 服务端删除(不阻塞 UI)
if (target && onDeleteScan) {
void onDeleteScan(target.fileId).catch((e) => {
console.error("[ScanUploader] delete failed", e)
})
}
}
const handleMove = (page: number, direction: "up" | "down") => {
if (disabled) return
const idx = images.findIndex((img) => img.page === page)
if (idx === -1) return
const targetIdx = direction === "up" ? idx - 1 : idx + 1
if (targetIdx < 0 || targetIdx >= images.length) return
const next = [...images]
const [moved] = next.splice(idx, 1)
next.splice(targetIdx, 0, moved)
const renumbered = next.map((img, i) => ({ ...img, page: i + 1 }))
onChange(renumbered)
}
return (
<div className={cn("space-y-3", className)}>
<div
onDragOver={(e) => {
e.preventDefault()
setDragOver(true)
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
className={cn(
"rounded-md border-2 border-dashed p-6 text-center transition-colors",
dragOver ? "border-primary bg-primary/5" : "border-border",
disabled && "opacity-50"
)}
>
<Upload className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{t("homework.take.dragDropHint")}
</p>
<p className="mt-1 text-xs text-muted-foreground">
JPG/PNG/WebP, 10MB
</p>
<label className="mt-3 inline-block">
<input
type="file"
accept="image/*"
multiple
onChange={handleFileInput}
disabled={disabled || isUploading}
className="hidden"
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled || isUploading}
className="gap-2"
onClick={() => {
// 触发隐藏的 file input
const input = document.createElement("input")
input.type = "file"
input.accept = "image/*"
input.multiple = true
input.onchange = () => {
if (input.files) uploadFiles(input.files)
}
input.click()
}}
>
{isUploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{isUploading ? t("homework.take.submitting") : t("homework.take.scanTitle")}
</Button>
</label>
</div>
{images.length > 0 && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{images.map((img) => (
<div
key={img.fileId}
className="group relative overflow-hidden rounded-md border bg-muted"
>
<div className="aspect-[3/4] w-full">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={img.url}
alt={img.filename}
className="h-full w-full object-cover"
/>
</div>
<div className="absolute left-1 top-1 rounded bg-black/60 px-1.5 py-0.5 text-xs text-white">
{t("homework.take.pageLabel", { page: img.page })}
</div>
{!disabled && (
<div className="absolute inset-0 flex items-center justify-center gap-1 bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
<Button
type="button"
variant="secondary"
size="icon"
className="h-7 w-7"
onClick={() => handleMove(img.page, "up")}
disabled={img.page === 1}
title={t("homework.take.moveUp")}
>
</Button>
<Button
type="button"
variant="secondary"
size="icon"
className="h-7 w-7"
onClick={() => handleMove(img.page, "down")}
disabled={img.page === images.length}
title={t("homework.take.moveDown")}
>
</Button>
<Button
type="button"
variant="destructive"
size="icon"
className="h-7 w-7"
onClick={() => handleRemove(img.page)}
title={t("homework.take.deleteScan")}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
)}
</div>
))}
</div>
)}
{images.length === 0 && !disabled && (
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
<ImageIcon className="mr-2 h-4 w-4" />
{t("homework.take.noScans")}
</div>
)}
</div>
)
}