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:
@@ -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):
|
||||
* - 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user