Files
Edu/apps/portal-shell/src/features/teacher/homework/scan-grading-client.tsx
SpecialX 081cb5fbc3 feat(portal-shell): homework 模块 7 页迁移(教师域 §9.1 B2)
§9.1 教师域 homework 模块完整迁移(继 exams 之后第二个 B2 模块):

7 页路由结构(与旧 teacher-portal 同构):
- /shell/teacher/homework:列表页(?classId/status/q 筛选)
- /shell/teacher/homework/new:布置作业表单页
- /shell/teacher/homework/[id]:详情 + 内联批改(含提交列表 + recordGrade 表单)
- /shell/teacher/homework/submissions:跨作业提交评审列表
- /shell/teacher/homework/submissions/[submissionId]:单份提交批改 + AI 建议 + 上下份导航
- /shell/teacher/homework/submissions/[submissionId]/scan-grading:扫描批改工作台(三栏)
- /shell/teacher/homework/assignments/[id]/submissions:按作业批量批改 + 统计 + AI 批量评分

数据契约(混合):
-  homework(id: ID!) 真实查询(schema 已就绪,详情页用)
-  列表/mutation/submissions/grading/aiBatchGrading 全部 @contract-pending MSW 兜底
  · 9 个 hook 走 MSW,待后端补齐 mutation 后切换真实 fetcher

§11.3 DoD 11 项验收:
1. route-permissions:EXACT + PREFIX 表 /shell/teacher/homework 已配置
2. 页面模板:list/new 用 ListPageShell/FormPageShell;detail/grading 用 DetailPageShell;
   scan-grading 用 WorkbenchPageShell(三栏,未使用 emptyNode)
3. 三态:loading(Skeleton)/error(errorNode 或 errorSummary)/empty(emptyNode) 全实现
4. lib/api hooks:homework.ts 10 个 hooks(useHomework 真实 + 9 个 MSW)
5. @contract-pending MSW:graphql-data.ts 扩展 6 块 mock + 10 个 switch case
6. i18n:homework 节点扩展 8 个分区共 130+ keys(list/detail/new/submissions/grading/
   scan/assignment/error)中英对齐
7. lint:0 errors(4 warnings 在 __generated__)
8. lint:tokens:0 errors
9. notify:mutation 反馈走 @/shared/lib/notify(非 sonner 直引)
10. vitest:transformations 纯函数单测齐全,全量 323/323 通过(新增 ~50 测试)
11. typecheck:0 errors(noUncheckedIndexedAccess 安全访问)

附带修复:
- 修复 2 处遗留 broken link:
  · widgets/sidebar/quick-actions: /homework/new → /shell/teacher/homework/new
  · widgets/topbar/global-search: /homework → /shell/teacher/homework
- scripts/check-page-count.ts baseline 同步 13 → 26(与 exams 6 + homework 7 一致)

剩余模块:grades(5)+lesson-plans(6)+questions(1)+textbooks(2)+attendance(4)+classes(3)+
students(1)+course-plans(2)+elective(3)+error-book(1)+diagnostic(2)+analytics(2)+ai-*(3)+
knowledge-graph(1)+practice(1)+schedule-changes(1)+leave(1) 共 39 页。
2026-07-22 18:11:15 +08:00

357 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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
* - loadingWorkbenchPageSkeletonloading=true
* - errorerrorNode 局部降级
* - 局部加载:左/中/右各自支持独立 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 包裹在 <Suspense> 中。
*/
export function ScanGradingClient(): React.ReactElement {
const t = useTranslations("homework");
const tCommon = useTranslations("common");
const params = useParams<{ submissionId: string }>();
const submissionId = params?.submissionId ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useSubmissionDetail(submissionId);
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
</div>
) : undefined;
const notFoundNode =
!loading && !error && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("scan.notFound")}
</div>
) : undefined;
return (
<WorkbenchPageShell
title={
data
? t("scan.title", { name: data.submission.studentName })
: t("scan.titleLoading")
}
description={
data
? t("scan.subtitle", {
homework: data.submission.homeworkTitle,
})
: undefined
}
icon={<ScanLine className="size-6" />}
loading={loading}
loadingNode={<WorkbenchPageSkeleton />}
errorNode={errorNode ?? notFoundNode}
left={
data ? (
<WorkbenchPanel title={t("scan.scanPreview")}>
<ScanPreviewPanel submissionId={data.submission.id} />
</WorkbenchPanel>
) : null
}
center={
data ? (
<WorkbenchPanel title={t("scan.recognizedAnswers")}>
<RecognizedAnswersPanel answers={data.answers} />
</WorkbenchPanel>
) : null
}
right={
data ? (
<WorkbenchPanel title={t("scan.gradingForm")}>
<ScanGradingForm
submissionId={data.submission.id}
answers={data.answers}
maxScore={data.submission.maxScore}
/>
</WorkbenchPanel>
) : null
}
/>
);
}
/**
* 扫描图片预览面板(左栏)。
* 注:当前无真实扫描图片,使用占位符 + 提示。
*/
function ScanPreviewPanel({
submissionId,
}: {
submissionId: string;
}): React.ReactElement {
const t = useTranslations("homework");
return (
<div className="flex h-full flex-col gap-3">
<div className="flex flex-1 items-center justify-center rounded-md border bg-muted/30 p-4">
<div className="text-center">
<ScanLine className="mx-auto size-12 text-muted-foreground" />
<p className="mt-2 text-xs text-muted-foreground">
{t("scan.imagePlaceholder", { id: submissionId })}
</p>
</div>
</div>
<p className="text-xs text-muted-foreground">
{t("scan.imageContractPending")}
</p>
</div>
);
}
/**
* 已识别答案面板(中栏)。
*/
function RecognizedAnswersPanel({
answers,
}: {
answers: SubmissionAnswer[];
}): React.ReactElement {
const t = useTranslations("homework");
return (
<div className="space-y-3">
{answers.map((a, idx) => (
<div key={a.id} className="rounded-md border p-3">
<div className="mb-1 flex items-start justify-between gap-2">
<p className="text-sm font-medium">
{idx + 1}. {a.questionTitle}
</p>
<span className="text-xs text-muted-foreground">
{a.maxScore} {t("scan.unitScore")}
</span>
</div>
<p className="text-xs text-muted-foreground">
{t("scan.questionType")}: {a.questionType}
</p>
<div className="mt-2 rounded-md bg-muted/30 p-2">
<p className="text-xs text-muted-foreground">
{t("scan.recognizedAnswer")}
</p>
<p className="mt-1 text-sm">{a.answer}</p>
</div>
{a.aiSuggestion ? (
<p className="mt-2 text-xs">
<span className="font-medium text-muted-foreground">
{t("scan.aiSuggestionLabel")}
</span>
<span className="ml-1">{a.aiSuggestion}</span>
</p>
) : null}
{a.isCorrect !== null ? (
<p
className={`mt-1 text-xs ${a.isCorrect ? "text-emerald-600" : "text-destructive"}`}
>
{a.isCorrect
? t("scan.recognizedCorrect")
: t("scan.recognizedIncorrect")}
</p>
) : null}
</div>
))}
{answers.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("scan.noAnswers")}</p>
) : null}
</div>
);
}
/**
* 扫描批改表单(右栏):每题分数 + 评语 + 总反馈 + 保存。
*/
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<Record<string, string>>(() => {
const m: Record<string, string> = {};
for (const a of answers) {
m[a.questionId] = a.score === null ? "" : String(a.score);
}
return m;
}, [answers]);
const [scores, setScores] = useState<Record<string, string>>(initialScores);
const [feedback, setFeedback] = useState("");
const [error, setError] = useState<string | null>(null);
const applyAllAi = (): void => {
const next: Record<string, string> = { ...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<void> => {
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 (
<div className="flex h-full flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{t("scan.totalScore")}: {totalScore} / {maxScore}
</span>
<Button type="button" variant="ghost" size="sm" onClick={applyAllAi}>
{t("scan.applyAllAi")}
</Button>
</div>
<div className="space-y-3 overflow-y-auto">
{answers.map((a, idx) => {
const raw = scores[a.questionId] ?? "";
const aiConf = a.aiSuggestion
? a.aiSuggestion.includes("满分")
? 0.95
: 0.7
: null;
return (
<div key={a.id} className="space-y-1 rounded-md border p-2">
<p className="text-xs font-medium">
{idx + 1}. {a.questionTitle}
</p>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={a.maxScore}
value={raw}
onChange={(e) =>
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 })}
/>
<span className="text-xs text-muted-foreground">
/ {a.maxScore}
</span>
{aiConf !== null ? (
<span
className={`ml-auto text-xs ${confidenceToColorClass(aiConf)}`}
>
{t("scan.confidence")}: {formatConfidence(aiConf)}
</span>
) : null}
</div>
</div>
);
})}
</div>
<div className="space-y-1">
<label className="text-xs font-medium">
{t("scan.overallFeedback")}
</label>
<textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
rows={3}
className="w-full rounded-md border border-input bg-background px-2 py-1 text-sm"
placeholder={t("scan.overallFeedbackPlaceholder")}
/>
</div>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
<Button
type="button"
onClick={() => void handleSubmit()}
disabled={submitting}
>
{submitting ? t("scan.saving") : t("scan.save")}
</Button>
<p className="text-xs text-muted-foreground">
{t("scan.contractPending")}
</p>
</div>
);
}