"use client"; /** * 扫描批改页 - 客户端组件(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2) * * 数据契约: * - 单查 submissionDetail(submissionId):❌ schema 无此字段 → MSW 兜底(@contract-pending) * - mutation saveScanGrading(input):❌ schema 无 Mutation → MSW 兜底(@contract-pending) * * 三态规范(§11.3 DoD): * - loading:WorkbenchPageSkeleton(loading=true) * - error:errorNode 局部降级 * - 局部加载:左/中/右各自支持独立 loading(本页用整体 loading) * * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 * * 注:WorkbenchPageShell 没有 emptyNode 属性;notFound 由 errorNode 节点呈现。 */ import { ScanLine } from "lucide-react"; import { useParams } from "next/navigation"; import { useMemo, useState } from "react"; import { useTranslations } from "next-intl"; import { useSubmissionDetail, useSaveScanGrading, type SubmissionAnswer, } from "@/lib/api"; import { Button } from "@/shared/components/ui/button"; import { WorkbenchPageShell, WorkbenchPanel, WorkbenchPageSkeleton, } from "@/shared/components/page-templates"; import { notify } from "@/shared/lib/notify"; import { confidenceToColorClass, formatConfidence, } from "@/features/teacher/homework/transformations"; /** * 扫描批改客户端主体。需由 server page 包裹在 中。 */ export function ScanGradingClient(): React.ReactElement { const t = useTranslations("homework"); const tCommon = useTranslations("common"); const params = useParams<{ submissionId: string }>(); const submissionId = params?.submissionId ?? ""; // @contract-pending:MSW 兜底 const { data, loading, error } = useSubmissionDetail(submissionId); const errorNode = error ? (

{tCommon("error.loadFailed", { message: String(error) })}

) : undefined; const notFoundNode = !loading && !error && !data ? (
{t("scan.notFound")}
) : undefined; return ( } loading={loading} loadingNode={} errorNode={errorNode ?? notFoundNode} left={ data ? ( ) : null } center={ data ? ( ) : null } right={ data ? ( ) : null } /> ); } /** * 扫描图片预览面板(左栏)。 * 注:当前无真实扫描图片,使用占位符 + 提示。 */ function ScanPreviewPanel({ submissionId, }: { submissionId: string; }): React.ReactElement { const t = useTranslations("homework"); return (

{t("scan.imagePlaceholder", { id: submissionId })}

{t("scan.imageContractPending")}

); } /** * 已识别答案面板(中栏)。 */ function RecognizedAnswersPanel({ answers, }: { answers: SubmissionAnswer[]; }): React.ReactElement { const t = useTranslations("homework"); return (
{answers.map((a, idx) => (

{idx + 1}. {a.questionTitle}

{a.maxScore} {t("scan.unitScore")}

{t("scan.questionType")}: {a.questionType}

{t("scan.recognizedAnswer")}

{a.answer}

{a.aiSuggestion ? (

{t("scan.aiSuggestionLabel")} {a.aiSuggestion}

) : null} {a.isCorrect !== null ? (

{a.isCorrect ? t("scan.recognizedCorrect") : t("scan.recognizedIncorrect")}

) : null}
))} {answers.length === 0 ? (

{t("scan.noAnswers")}

) : null}
); } /** * 扫描批改表单(右栏):每题分数 + 评语 + 总反馈 + 保存。 */ function ScanGradingForm({ submissionId, answers, maxScore, }: { submissionId: string; answers: SubmissionAnswer[]; maxScore: number; }): React.ReactElement { const t = useTranslations("homework"); const { run: saveScanGrading, loading: submitting } = useSaveScanGrading(); const initialScores = useMemo>(() => { const m: Record = {}; for (const a of answers) { m[a.questionId] = a.score === null ? "" : String(a.score); } return m; }, [answers]); const [scores, setScores] = useState>(initialScores); const [feedback, setFeedback] = useState(""); const [error, setError] = useState(null); const applyAllAi = (): void => { const next: Record = { ...scores }; let applied = 0; for (const a of answers) { if (!a.aiSuggestion) continue; if (a.aiSuggestion.includes("满分")) { next[a.questionId] = String(a.maxScore); applied += 1; } } setScores(next); notify.info(t("scan.aiAppliedCount", { count: applied })); }; const totalScore = answers.reduce((sum, a) => { const raw = scores[a.questionId] ?? ""; const num = Number(raw); return Number.isFinite(num) ? sum + num : sum; }, 0); const handleSubmit = async (): Promise => { setError(null); const payload = answers.map((a) => { const raw = scores[a.questionId] ?? ""; const num = Number(raw); if (raw === "" || !Number.isFinite(num)) { throw new Error(t("scan.errorScoreInvalid", { qid: a.questionId })); } return { questionId: a.questionId, score: num, }; }); try { await saveScanGrading({ submissionId, answers: payload, feedback: feedback.trim() || undefined, }); notify.success(t("scan.saveSuccess")); } catch (err) { const msg = err instanceof Error ? err.message : String(err); setError(msg); notify.error(`${t("scan.saveError")}: ${msg}`); } }; return (
{t("scan.totalScore")}: {totalScore} / {maxScore}
{answers.map((a, idx) => { const raw = scores[a.questionId] ?? ""; const aiConf = a.aiSuggestion ? a.aiSuggestion.includes("满分") ? 0.95 : 0.7 : null; return (

{idx + 1}. {a.questionTitle}

setScores((prev) => ({ ...prev, [a.questionId]: e.target.value, })) } className="h-8 w-20 rounded-md border border-input bg-background px-2 text-sm" aria-label={t("scan.scoreLabel", { qid: a.questionId })} /> / {a.maxScore} {aiConf !== null ? ( {t("scan.confidence")}: {formatConfidence(aiConf)} ) : null}
); })}