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 页。
This commit is contained in:
SpecialX
2026-07-22 18:11:15 +08:00
parent dca25fc42f
commit 081cb5fbc3
28 changed files with 4810 additions and 6 deletions

View File

@@ -0,0 +1,356 @@
"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>
);
}