§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 页。
357 lines
11 KiB
TypeScript
357 lines
11 KiB
TypeScript
"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 包裹在 <Suspense> 中。
|
||
*/
|
||
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 ? (
|
||
<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>
|
||
);
|
||
}
|