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,373 @@
/**
* Homework 数据变换工具单测ARCHITECTURE.md §11.3 DoD
*
* 关联ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
*/
import { describe, expect, it } from "vitest";
import type { Homework } from "@/lib/api";
import {
HOMEWORK_STATUS_LABEL,
SUBMISSION_STATUS_LABEL,
calcSubmissionRate,
confidenceToColorClass,
formatConfidence,
formatDueDate,
formatGradeLevel,
formatGracePeriod,
formatHomeworkStatus,
formatScore,
formatSubmissionRate,
formatSubmissionStatus,
gradeLevelToColorClass,
homeworkStatusToBadgeClass,
isGraded,
isHomeworkEditable,
isHomeworkPublished,
isOverdue,
isPendingGrading,
submissionStatusToBadgeClass,
toHomeworkListItem,
} from "../transformations";
describe("formatHomeworkStatus", () => {
it("maps known statuses to Chinese labels", () => {
expect(formatHomeworkStatus("DRAFT")).toBe("草稿");
expect(formatHomeworkStatus("PUBLISHED")).toBe("已发布");
expect(formatHomeworkStatus("CLOSED")).toBe("已关闭");
expect(formatHomeworkStatus("ARCHIVED")).toBe("已归档");
});
it("returns original value for unknown status", () => {
expect(formatHomeworkStatus("UNKNOWN")).toBe("UNKNOWN");
expect(formatHomeworkStatus("")).toBe("");
});
it("HOMEWORK_STATUS_LABEL covers all standard statuses", () => {
expect(Object.keys(HOMEWORK_STATUS_LABEL)).toHaveLength(4);
});
});
describe("formatSubmissionStatus", () => {
it("maps known statuses to Chinese labels", () => {
expect(formatSubmissionStatus("SUBMITTED")).toBe("已提交");
expect(formatSubmissionStatus("GRADING")).toBe("批改中");
expect(formatSubmissionStatus("GRADED")).toBe("已批改");
expect(formatSubmissionStatus("RETURNED")).toBe("已退回");
expect(formatSubmissionStatus("LATE")).toBe("迟交");
});
it("returns original value for unknown status", () => {
expect(formatSubmissionStatus("UNKNOWN")).toBe("UNKNOWN");
});
it("SUBMISSION_STATUS_LABEL covers all standard statuses", () => {
expect(Object.keys(SUBMISSION_STATUS_LABEL)).toHaveLength(5);
});
});
describe("formatDueDate", () => {
it("formats valid ISO date string", () => {
const result = formatDueDate("2026-07-25T23:59:59Z");
expect(result).toContain("2026");
expect(result).toContain("07");
});
it("returns placeholder for null/undefined/empty", () => {
expect(formatDueDate(null)).toBe("--");
expect(formatDueDate(undefined)).toBe("--");
expect(formatDueDate("")).toBe("--");
});
it("returns placeholder for invalid date", () => {
expect(formatDueDate("not-a-date")).toBe("--");
});
});
describe("formatGracePeriod", () => {
it("formats hours under 24", () => {
expect(formatGracePeriod(12)).toBe("12 小时");
expect(formatGracePeriod(1)).toBe("1 小时");
});
it("formats exact days", () => {
expect(formatGracePeriod(24)).toBe("1 天");
expect(formatGracePeriod(48)).toBe("2 天");
});
it("formats days with remainder hours", () => {
expect(formatGracePeriod(36)).toBe("1 天 12 小时");
expect(formatGracePeriod(25)).toBe("1 天 1 小时");
});
it("returns placeholder for invalid or zero input", () => {
expect(formatGracePeriod(0)).toBe("无宽限期");
expect(formatGracePeriod(-1)).toBe("无宽限期");
expect(formatGracePeriod(Number.NaN)).toBe("无宽限期");
});
});
describe("isOverdue", () => {
it("returns true when dueDate is in the past", () => {
const past = "2020-01-01T00:00:00Z";
expect(isOverdue(past, new Date("2026-07-22T00:00:00Z"))).toBe(true);
});
it("returns false when dueDate is in the future", () => {
const future = "2030-01-01T00:00:00Z";
expect(isOverdue(future, new Date("2026-07-22T00:00:00Z"))).toBe(false);
});
it("returns false for null/undefined/invalid date", () => {
expect(isOverdue(null)).toBe(false);
expect(isOverdue(undefined)).toBe(false);
expect(isOverdue("not-a-date")).toBe(false);
});
it("returns false when dueDate equals now (not strictly past)", () => {
const now = new Date("2026-07-22T00:00:00Z");
expect(isOverdue("2026-07-22T00:00:00Z", now)).toBe(false);
});
});
describe("isHomeworkEditable / isHomeworkPublished", () => {
it("DRAFT is editable but not published", () => {
expect(isHomeworkEditable("DRAFT")).toBe(true);
expect(isHomeworkPublished("DRAFT")).toBe(false);
});
it("PUBLISHED is published but not editable", () => {
expect(isHomeworkEditable("PUBLISHED")).toBe(false);
expect(isHomeworkPublished("PUBLISHED")).toBe(true);
});
it("unknown status is neither editable nor published", () => {
expect(isHomeworkEditable("UNKNOWN")).toBe(false);
expect(isHomeworkPublished("UNKNOWN")).toBe(false);
});
});
describe("toHomeworkListItem", () => {
it("extracts list fields from full homework", () => {
const hw: Homework = {
id: "hw-001",
classId: "cls-001",
subjectId: "sub-math",
title: "集合练习",
description: "完成教材练习",
dueDate: "2026-07-25T23:59:59Z",
gracePeriod: 24,
status: "PUBLISHED",
schoolId: "sch-001",
createdBy: "usr-001",
createdAt: "2026-07-20T00:00:00Z",
updatedAt: "2026-07-20T00:00:00Z",
};
const item = toHomeworkListItem(hw);
expect(item.id).toBe("hw-001");
expect(item.title).toBe("集合练习");
expect(item.status).toBe("PUBLISHED");
expect(item).not.toHaveProperty("schoolId");
expect(item).not.toHaveProperty("createdBy");
expect(item).not.toHaveProperty("updatedAt");
});
it("handles null description", () => {
const hw: Homework = {
id: "hw-002",
classId: "cls-001",
subjectId: "sub-math",
title: "无描述作业",
description: null,
dueDate: "2026-07-25T23:59:59Z",
gracePeriod: 12,
status: "DRAFT",
schoolId: "sch-001",
createdBy: "usr-001",
createdAt: "2026-07-20T00:00:00Z",
updatedAt: "2026-07-20T00:00:00Z",
};
const item = toHomeworkListItem(hw);
expect(item.description).toBeNull();
});
});
describe("isGraded / isPendingGrading", () => {
it("GRADED and RETURNED are graded", () => {
expect(isGraded("GRADED")).toBe(true);
expect(isGraded("RETURNED")).toBe(true);
});
it("SUBMITTED and LATE are pending grading", () => {
expect(isPendingGrading("SUBMITTED")).toBe(true);
expect(isPendingGrading("LATE")).toBe(true);
});
it("GRADED is not pending", () => {
expect(isPendingGrading("GRADED")).toBe(false);
});
it("GRADING is neither graded nor pending", () => {
expect(isGraded("GRADING")).toBe(false);
expect(isPendingGrading("GRADING")).toBe(false);
});
});
describe("formatScore", () => {
it("formats finite numbers with 1 decimal place", () => {
expect(formatScore(82.5)).toBe("82.5");
expect(formatScore(98)).toBe("98.0");
expect(formatScore(0)).toBe("0.0");
});
it("returns placeholder for null/undefined", () => {
expect(formatScore(null)).toBe("--");
expect(formatScore(undefined)).toBe("--");
});
it("returns placeholder for non-finite input", () => {
expect(formatScore(Number.NaN)).toBe("--");
expect(formatScore(Number.POSITIVE_INFINITY)).toBe("--");
});
});
describe("formatSubmissionRate", () => {
it("formats rate in [0,1] as percentage", () => {
expect(formatSubmissionRate(0.789)).toBe("79%");
expect(formatSubmissionRate(0)).toBe("0%");
expect(formatSubmissionRate(1)).toBe("100%");
});
it("returns placeholder for out-of-range or non-finite input", () => {
expect(formatSubmissionRate(-0.1)).toBe("--");
expect(formatSubmissionRate(1.1)).toBe("--");
expect(formatSubmissionRate(Number.NaN)).toBe("--");
});
});
describe("calcSubmissionRate", () => {
it("calculates rate correctly", () => {
expect(calcSubmissionRate(30, 38)).toBeCloseTo(0.789, 2);
expect(calcSubmissionRate(0, 38)).toBe(0);
expect(calcSubmissionRate(38, 38)).toBe(1);
});
it("caps at 1 when submitted > total", () => {
expect(calcSubmissionRate(40, 38)).toBe(1);
});
it("returns 0 for zero or invalid total", () => {
expect(calcSubmissionRate(10, 0)).toBe(0);
expect(calcSubmissionRate(10, -1)).toBe(0);
});
it("returns 0 for negative submitted count", () => {
expect(calcSubmissionRate(-5, 38)).toBe(0);
});
});
describe("formatGradeLevel", () => {
it("maps score rate to A/B/C/D levels", () => {
expect(formatGradeLevel(90, 100)).toBe("A");
expect(formatGradeLevel(85, 100)).toBe("A");
expect(formatGradeLevel(75, 100)).toBe("B");
expect(formatGradeLevel(70, 100)).toBe("B");
expect(formatGradeLevel(65, 100)).toBe("C");
expect(formatGradeLevel(60, 100)).toBe("C");
expect(formatGradeLevel(50, 100)).toBe("D");
expect(formatGradeLevel(0, 100)).toBe("D");
});
it("returns placeholder for null/undefined score", () => {
expect(formatGradeLevel(null, 100)).toBe("--");
expect(formatGradeLevel(undefined, 100)).toBe("--");
});
it("returns placeholder for zero or invalid maxScore", () => {
expect(formatGradeLevel(80, 0)).toBe("--");
expect(formatGradeLevel(80, -1)).toBe("--");
});
});
describe("gradeLevelToColorClass", () => {
it("maps each level to correct color class", () => {
expect(gradeLevelToColorClass("A")).toBe("text-emerald-600");
expect(gradeLevelToColorClass("B")).toBe("text-blue-600");
expect(gradeLevelToColorClass("C")).toBe("text-amber-600");
expect(gradeLevelToColorClass("D")).toBe("text-destructive");
});
it("returns muted for unknown level", () => {
expect(gradeLevelToColorClass("X")).toBe("text-muted-foreground");
expect(gradeLevelToColorClass("")).toBe("text-muted-foreground");
});
});
describe("submissionStatusToBadgeClass", () => {
it("returns correct badge class for each status", () => {
expect(submissionStatusToBadgeClass("SUBMITTED")).toContain("amber");
expect(submissionStatusToBadgeClass("LATE")).toContain("amber");
expect(submissionStatusToBadgeClass("GRADING")).toContain("primary");
expect(submissionStatusToBadgeClass("GRADED")).toContain("emerald");
expect(submissionStatusToBadgeClass("RETURNED")).toContain("blue");
});
it("returns muted for unknown status", () => {
expect(submissionStatusToBadgeClass("UNKNOWN")).toBe(
"bg-muted text-muted-foreground",
);
});
});
describe("homeworkStatusToBadgeClass", () => {
it("returns correct badge class for each status", () => {
expect(homeworkStatusToBadgeClass("DRAFT")).toBe(
"bg-muted text-muted-foreground",
);
expect(homeworkStatusToBadgeClass("PUBLISHED")).toContain("primary");
expect(homeworkStatusToBadgeClass("CLOSED")).toContain("amber");
});
it("returns muted for unknown status", () => {
expect(homeworkStatusToBadgeClass("UNKNOWN")).toBe(
"bg-muted text-muted-foreground",
);
});
});
describe("confidenceToColorClass", () => {
it("returns emerald for high confidence", () => {
expect(confidenceToColorClass(0.8)).toBe("text-emerald-600");
expect(confidenceToColorClass(0.95)).toBe("text-emerald-600");
});
it("returns amber for medium confidence", () => {
expect(confidenceToColorClass(0.6)).toBe("text-amber-600");
expect(confidenceToColorClass(0.79)).toBe("text-amber-600");
});
it("returns destructive for low confidence", () => {
expect(confidenceToColorClass(0.59)).toBe("text-destructive");
expect(confidenceToColorClass(0)).toBe("text-destructive");
});
it("returns muted for non-finite input", () => {
expect(confidenceToColorClass(Number.NaN)).toBe("text-muted-foreground");
});
});
describe("formatConfidence", () => {
it("formats confidence in [0,1] as percentage", () => {
expect(formatConfidence(0.92)).toBe("92%");
expect(formatConfidence(0)).toBe("0%");
expect(formatConfidence(1)).toBe("100%");
});
it("returns placeholder for out-of-range or non-finite input", () => {
expect(formatConfidence(-0.1)).toBe("--");
expect(formatConfidence(1.1)).toBe("--");
expect(formatConfidence(Number.NaN)).toBe("--");
});
});

View File

@@ -0,0 +1,437 @@
"use client";
/**
* 按作业批量批改页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* 数据契约:
* - 查询 assignmentSubmissions(homeworkId):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 查询 aiBatchGrading(homeworkId):❌ schema 无此字段 → MSW 兜底(@contract-pending
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { Layers } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import {
useAssignmentSubmissions,
useAiBatchGrading,
type AiGradingSuggestion,
} from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
import {
calcSubmissionRate,
confidenceToColorClass,
formatConfidence,
formatDueDate,
formatScore,
formatSubmissionRate,
formatSubmissionStatus,
submissionStatusToBadgeClass,
} from "@/features/teacher/homework/transformations";
/**
* 批量批改客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function AssignmentSubmissionsClient(): React.ReactElement {
const t = useTranslations("homework");
const tCommon = useTranslations("common");
const params = useParams<{ id: string }>();
const homeworkId = params?.id ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useAssignmentSubmissions(homeworkId);
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>
<p className="mt-2 text-xs text-muted-foreground">
{t("assignment.mswNotice")}
</p>
</div>
) : undefined;
const homeworkTitle = data?.homework.title ?? "";
const stats = data?.stats;
const submissions = data?.submissions ?? [];
return (
<ListPageShell
title={t("assignment.title")}
description={
homeworkTitle
? t("assignment.description", { title: homeworkTitle })
: t("assignment.descriptionLoading")
}
icon={<Layers className="size-6" />}
actions={
<Button asChild variant="outline">
<Link href={`/shell/teacher/homework/${homeworkId}`}>
{t("assignment.backToHomework")}
</Link>
</Button>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={6} />}
empty={!loading && !error && submissions.length === 0}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("assignment.total", { count: submissions.length })}</span>
</div>
}
>
{data ? (
<AssignmentSubmissionsBody
homeworkId={homeworkId}
homeworkTitle={homeworkTitle}
dueDate={data.homework.dueDate}
className={data.homework.className}
stats={stats}
submissions={submissions}
/>
) : null}
</ListPageShell>
);
}
/**
* 主体:统计卡片 + AI 建议区 + 提交表格。
*/
function AssignmentSubmissionsBody({
homeworkId,
homeworkTitle,
dueDate,
className,
stats,
submissions,
}: {
homeworkId: string;
homeworkTitle: string;
dueDate: string;
className: string;
stats:
| {
totalStudents: number;
submittedCount: number;
gradedCount: number;
pendingCount: number;
avgScore: number;
submissionRate: number;
}
| undefined;
submissions: ReadonlyArray<{
id: string;
homeworkId: string;
homeworkTitle: string;
studentId: string;
studentName: string;
studentNo: string;
classId: string;
className: string;
status: string;
submittedAt: string | null;
gradedAt: string | null;
gradedBy: string | null;
totalScore: number | null;
maxScore: number;
}>;
}): React.ReactElement {
const t = useTranslations("homework");
return (
<div className="space-y-6">
{/* 统计卡片区 */}
{stats ? <StatsCard stats={stats} /> : null}
{/* 作业信息 */}
<section className="rounded-xl border bg-card p-4">
<h2 className="mb-2 text-sm font-semibold">
{t("assignment.sectionHomework")}
</h2>
<div className="grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
<InfoItem
label={t("assignment.homeworkTitle")}
value={homeworkTitle}
/>
<InfoItem label={t("assignment.homeworkId")} value={homeworkId} />
<InfoItem label={t("assignment.className")} value={className} />
<InfoItem
label={t("assignment.dueDate")}
value={formatDueDate(dueDate)}
/>
</div>
</section>
{/* AI 批量评分区 */}
<AiBatchGradingSection homeworkId={homeworkId} />
{/* 提交列表 */}
<section className="rounded-xl border">
<header className="border-b bg-muted/30 p-3">
<h2 className="text-sm font-semibold">
{t("assignment.sectionSubmissions")}
</h2>
</header>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">
{t("assignment.colStudent")}
</th>
<th className="p-3 text-left font-medium">
{t("assignment.colStatus")}
</th>
<th className="p-3 text-left font-medium">
{t("assignment.colSubmittedAt")}
</th>
<th className="p-3 text-right font-medium">
{t("assignment.colScore")}
</th>
<th className="p-3 text-right font-medium">
{t("assignment.colActions")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{submissions.map((s) => (
<tr key={s.id} className="hover:bg-muted/30">
<td className="p-3">
<Link
href={`/shell/teacher/homework/submissions/${s.id}`}
className="font-medium hover:underline"
>
{s.studentName}
</Link>
<p className="text-xs text-muted-foreground">
{s.studentNo}
</p>
</td>
<td className="p-3">
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${submissionStatusToBadgeClass(s.status)}`}
>
{formatSubmissionStatus(s.status)}
</span>
</td>
<td className="p-3 font-mono text-xs">
{formatDueDate(s.submittedAt)}
</td>
<td className="p-3 text-right">
{formatScore(s.totalScore)} / {s.maxScore}
</td>
<td className="p-3 text-right">
<Link
href={`/shell/teacher/homework/submissions/${s.id}/scan-grading`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("assignment.scanGrade")}
</Link>
<span className="mx-1 text-muted-foreground/40">·</span>
<Link
href={`/shell/teacher/homework/submissions/${s.id}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("assignment.detailGrade")}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
);
}
/**
* 统计卡片4 个指标 + 提交率)。
*/
function StatsCard({
stats,
}: {
stats: {
totalStudents: number;
submittedCount: number;
gradedCount: number;
pendingCount: number;
avgScore: number;
submissionRate: number;
};
}): React.ReactElement {
const t = useTranslations("homework");
const items = [
{
label: t("assignment.statsTotalStudents"),
value: String(stats.totalStudents),
},
{
label: t("assignment.statsSubmitted"),
value: String(stats.submittedCount),
},
{
label: t("assignment.statsGraded"),
value: String(stats.gradedCount),
},
{
label: t("assignment.statsPending"),
value: String(stats.pendingCount),
},
{
label: t("assignment.statsAvgScore"),
value: formatScore(stats.avgScore),
},
{
label: t("assignment.statsSubmissionRate"),
value: formatSubmissionRate(
calcSubmissionRate(stats.submittedCount, stats.totalStudents),
),
},
];
return (
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{items.map((it) => (
<div key={it.label} className="rounded-lg border bg-card p-3">
<p className="text-xs text-muted-foreground">{it.label}</p>
<p className="mt-1 text-lg font-semibold">{it.value}</p>
</div>
))}
</section>
);
}
/**
* AI 批量评分区(点击按钮触发拉取建议 + 显示建议列表)。
*/
function AiBatchGradingSection({
homeworkId,
}: {
homeworkId: string;
}): React.ReactElement {
const t = useTranslations("homework");
const tCommon = useTranslations("common");
const { data, loading, error, refetch } = useAiBatchGrading(homeworkId, {
enabled: false,
});
const handleTrigger = (): void => {
void refetch();
notify.info(t("assignment.aiTriggered"));
};
if (loading) {
return (
<section className="rounded-xl border bg-card p-4">
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
</section>
);
}
if (error) {
return (
<section className="rounded-xl border bg-card p-4">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
</section>
);
}
const suggestions = data?.suggestions ?? [];
const summary = data?.summary;
return (
<section className="rounded-xl border bg-card p-4">
<header className="mb-3 flex items-center justify-between gap-2">
<div>
<h2 className="text-sm font-semibold">
{t("assignment.sectionAiGrading")}
</h2>
{summary ? (
<p className="mt-1 text-xs text-muted-foreground">
{t("assignment.aiSummary", {
processed: summary.processed,
total: summary.totalSubmissions,
avg: formatConfidence(summary.avgConfidence),
})}
</p>
) : null}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleTrigger}
>
{t("assignment.aiBatchGrade")}
</Button>
</header>
{suggestions.length > 0 ? (
<ul className="space-y-2">
{suggestions.map((s) => (
<AiSuggestionRow key={s.submissionId} suggestion={s} />
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">
{t("assignment.aiEmpty")}
</p>
)}
<p className="mt-2 text-xs text-muted-foreground">
{t("assignment.aiContractPending")}
</p>
</section>
);
}
function AiSuggestionRow({
suggestion,
}: {
suggestion: AiGradingSuggestion;
}): React.ReactElement {
const t = useTranslations("homework");
return (
<li className="flex flex-col gap-1 rounded-md border p-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1">
<p className="text-sm font-medium">{suggestion.studentName}</p>
<p className="text-xs text-muted-foreground">{suggestion.reasoning}</p>
</div>
<div className="flex items-center gap-3 text-xs">
<span className="font-mono">
{t("assignment.suggestedScore")}:{" "}
<span className="font-semibold">{suggestion.suggestedScore}</span>
</span>
<span className={confidenceToColorClass(suggestion.confidence)}>
{t("assignment.confidence")}:{" "}
{formatConfidence(suggestion.confidence)}
</span>
</div>
</li>
);
}
function InfoItem({
label,
value,
}: {
label: string;
value: string;
}): React.ReactElement {
return (
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-sm">{value}</p>
</div>
);
}

View File

@@ -0,0 +1,394 @@
"use client";
/**
* 作业详情页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* 数据契约:
* - 单查 homework(id: ID!):✅ schema 真实字段core-edu 子图)
* - 列表查询 homeworkSubmissions(homeworkId):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - mutation recordGrade(input):❌ schema 无 Mutation → MSW 兜底(@contract-pending
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - notFounddata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { ClipboardList } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useState } from "react";
import { useTranslations } from "next-intl";
import {
useHomework,
useHomeworkSubmissions,
useRecordGrade,
type Homework,
type HomeworkSubmissionItem,
} from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import {
DetailPageShell,
DetailPageSkeleton,
DetailSection,
DetailField,
} from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
import {
formatDueDate,
formatGracePeriod,
formatHomeworkStatus,
formatScore,
formatSubmissionStatus,
homeworkStatusToBadgeClass,
isHomeworkEditable,
isOverdue,
submissionStatusToBadgeClass,
} from "@/features/teacher/homework/transformations";
/**
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function HomeworkDetailClient(): React.ReactElement {
const t = useTranslations("homework");
const tCommon = useTranslations("common");
const params = useParams<{ id: string }>();
const homeworkId = params?.id ?? "";
// ✅ 真实查询homework(id: ID!)schema 已就绪
const { data, loading, error } = useHomework(homeworkId);
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;
return (
<DetailPageShell
title={data?.title ?? t("detail.title")}
description={
data
? t("detail.createdAtPrefix", {
date: formatDueDate(data.createdAt),
})
: undefined
}
icon={<ClipboardList className="size-6" />}
backHref="/shell/teacher/homework"
actions={
data && isHomeworkEditable(data.status) ? (
<Button variant="outline">{t("detail.edit")}</Button>
) : null
}
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={
!loading && !error && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("detail.notFound")}
</div>
) : undefined
}
>
{data ? <HomeworkDetailBody homework={data} /> : null}
</DetailPageShell>
);
}
/**
* 详情内容区(基本信息 + 提交列表 + 内联批改表单)。
*/
function HomeworkDetailBody({
homework,
}: {
homework: Homework;
}): React.ReactElement {
const t = useTranslations("homework");
return (
<>
<DetailSection title={t("detail.sectionBasic")}>
<DetailField label={t("detail.fieldTitle")} value={homework.title} />
<DetailField
label={t("detail.fieldDescription")}
value={homework.description ?? "-"}
/>
<DetailField
label={t("detail.fieldDueDate")}
value={
<span
className={isOverdue(homework.dueDate) ? "text-destructive" : ""}
>
{formatDueDate(homework.dueDate)}
</span>
}
/>
<DetailField
label={t("detail.fieldGracePeriod")}
value={formatGracePeriod(homework.gracePeriod)}
/>
<DetailField
label={t("detail.fieldStatus")}
value={
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${homeworkStatusToBadgeClass(homework.status)}`}
>
{formatHomeworkStatus(homework.status)}
</span>
}
/>
<DetailField
label={t("detail.fieldClassId")}
value={homework.classId}
/>
<DetailField
label={t("detail.fieldSubjectId")}
value={homework.subjectId}
/>
<DetailField
label={t("detail.fieldCreatedBy")}
value={homework.createdBy}
/>
<DetailField
label={t("detail.fieldUpdatedAt")}
value={formatDueDate(homework.updatedAt)}
/>
</DetailSection>
<DetailSection
title={t("detail.sectionSubmissions")}
actions={
<Button asChild variant="outline" size="sm">
<Link
href={`/shell/teacher/homework/assignments/${homework.id}/submissions`}
>
{t("detail.viewAllSubmissions")}
</Link>
</Button>
}
>
<HomeworkSubmissionsSection homeworkId={homework.id} />
</DetailSection>
<DetailSection title={t("detail.sectionInlineGrade")}>
<InlineGradeForm homeworkId={homework.id} />
</DetailSection>
</>
);
}
/**
* 提交列表(按 homeworkId 拉取,@contract-pending MSW 兜底)。
*/
function HomeworkSubmissionsSection({
homeworkId,
}: {
homeworkId: string;
}): React.ReactElement {
const t = useTranslations("homework");
const tCommon = useTranslations("common");
const { data, loading, error } = useHomeworkSubmissions({ homeworkId });
if (loading) {
return (
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
);
}
if (error) {
return (
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
);
}
const items = data?.items ?? [];
if (items.length === 0) {
return (
<p className="text-sm text-muted-foreground">
{t("detail.noSubmissions")}
</p>
);
}
return (
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-2 text-left font-medium">
{t("detail.colStudent")}
</th>
<th className="p-2 text-left font-medium">
{t("detail.colStatus")}
</th>
<th className="p-2 text-left font-medium">
{t("detail.colSubmittedAt")}
</th>
<th className="p-2 text-right font-medium">
{t("detail.colScore")}
</th>
<th className="p-2 text-right font-medium">
{t("detail.colActions")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((s) => (
<SubmissionRow key={s.id} submission={s} />
))}
</tbody>
</table>
</div>
);
}
function SubmissionRow({
submission,
}: {
submission: HomeworkSubmissionItem;
}): React.ReactElement {
const t = useTranslations("homework");
return (
<tr className="hover:bg-muted/30">
<td className="p-2">
<Link
href={`/shell/teacher/homework/submissions/${submission.id}`}
className="font-medium hover:underline"
>
{submission.studentName}
</Link>
<p className="text-xs text-muted-foreground">{submission.studentNo}</p>
</td>
<td className="p-2">
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${submissionStatusToBadgeClass(submission.status)}`}
>
{formatSubmissionStatus(submission.status)}
</span>
</td>
<td className="p-2 font-mono text-xs">
{formatDueDate(submission.submittedAt)}
</td>
<td className="p-2 text-right">
{formatScore(submission.totalScore)} / {submission.maxScore}
</td>
<td className="p-2 text-right">
<Link
href={`/shell/teacher/homework/submissions/${submission.id}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("detail.grade")}
</Link>
</td>
</tr>
);
}
/**
* 内联批改表单(录入单个学生单次成绩,@contract-pending MSW 兜底)。
*/
function InlineGradeForm({
homeworkId,
}: {
homeworkId: string;
}): React.ReactElement {
const t = useTranslations("homework");
const { run: recordGrade, loading: submitting } = useRecordGrade();
const [studentId, setStudentId] = useState("");
const [score, setScore] = useState("");
const [feedback, setFeedback] = useState("");
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (): Promise<void> => {
setError(null);
if (!studentId.trim()) {
setError(t("detail.gradeErrorStudentRequired"));
return;
}
const scoreNum = Number(score);
if (!Number.isFinite(scoreNum) || scoreNum < 0) {
setError(t("detail.gradeErrorScoreInvalid"));
return;
}
try {
await recordGrade({
homeworkId,
studentId: studentId.trim(),
score: scoreNum,
feedback: feedback.trim() || undefined,
});
notify.success(t("detail.gradeSuccess"));
setStudentId("");
setScore("");
setFeedback("");
} catch (err) {
notify.error(`${t("detail.gradeError")}: ${String(err)}`);
}
};
return (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1">
<label className="text-sm font-medium">
{t("detail.gradeStudentId")}
<span className="ml-1 text-destructive">*</span>
</label>
<input
type="text"
value={studentId}
onChange={(e) => setStudentId(e.target.value)}
placeholder="stu-001"
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">
{t("detail.gradeScore")}
<span className="ml-1 text-destructive">*</span>
</label>
<input
type="number"
min={0}
value={score}
onChange={(e) => setScore(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">
{t("detail.gradeFeedback")}
</label>
<textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder={t("detail.gradeFeedbackPlaceholder")}
/>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<div className="flex justify-end">
<Button
type="button"
onClick={() => void handleSubmit()}
disabled={submitting}
>
{submitting ? t("detail.gradeSubmitting") : t("detail.gradeSubmit")}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t("detail.gradeContractPending")}
</p>
</div>
);
}

View File

@@ -0,0 +1,228 @@
"use client";
/**
* 作业管理列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* 数据契约:
* - 列表查询 homeworks(classId):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#homework-list
*
* URL 状态:?classId=xxx &status=xxx &q=xxx
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { ClipboardList } from "lucide-react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useHomeworkList, type HomeworkListItem } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
formatDueDate,
formatGracePeriod,
formatHomeworkStatus,
homeworkStatusToBadgeClass,
isOverdue,
} from "@/features/teacher/homework/transformations";
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。
*/
export function HomeworkListClient(): React.ReactElement {
const t = useTranslations("homework");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const classId = searchParams.get("classId") ?? "cls-001";
const statusFilter = searchParams.get("status") ?? "";
const q = searchParams.get("q") ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useHomeworkList(classId, {
status: statusFilter || undefined,
});
// 客户端二次筛选q—— 后端补齐列表查询后改服务端筛选
const filteredItems = useMemo<HomeworkListItem[]>(() => {
const items = data?.items ?? [];
return items.filter((item) => {
if (q && !item.title.toLowerCase().includes(q.toLowerCase())) {
return false;
}
return true;
});
}, [data, q]);
const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
startTransition(() => {
router.push(`/shell/teacher/homework?${params.toString()}`);
});
};
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>
<p className="mt-2 text-xs text-muted-foreground">
{t("list.mswNotice")}
</p>
</div>
) : undefined;
return (
<ListPageShell
title={t("list.title")}
description={t("list.description")}
icon={<ClipboardList className="size-6" />}
actions={
<Button asChild>
<Link href={`/shell/teacher/homework/new?classId=${classId}`}>
{t("list.new")}
</Link>
</Button>
}
filters={
<>
<FilterSearchInput
placeholder={t("list.searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
/>
<select
value={statusFilter}
onChange={(e) => updateQuery("status", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.statusFilter")}
>
<option value="">{t("list.statusAll")}</option>
<option value="DRAFT">{t("list.statusDraft")}</option>
<option value="PUBLISHED">{t("list.statusPublished")}</option>
<option value="CLOSED">{t("list.statusClosed")}</option>
<option value="ARCHIVED">{t("list.statusArchived")}</option>
</select>
</>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={filteredItems.length === 0 && !loading}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("list.total", { count: filteredItems.length })}</span>
</div>
}
>
<HomeworkTable items={filteredItems} />
</ListPageShell>
);
}
/**
* 作业列表表格(纯展示组件,对齐 §8.2 排版规范)。
*/
function HomeworkTable({
items,
}: {
items: HomeworkListItem[];
}): React.ReactElement {
const t = useTranslations("homework");
return (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">{t("list.colName")}</th>
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
<th className="p-3 text-left font-medium">
{t("list.colDueDate")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colGracePeriod")}
</th>
<th className="p-3 text-right font-medium">
{t("list.colActions")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((hw) => (
<tr key={hw.id} className="hover:bg-muted/30">
<td className="p-3">
<Link
href={`/shell/teacher/homework/${hw.id}`}
className="font-medium hover:underline"
>
{hw.title}
</Link>
{hw.description ? (
<p className="mt-1 text-xs text-muted-foreground">
{hw.description}
</p>
) : null}
</td>
<td className="p-3">
<HomeworkStatusBadge status={hw.status} />
</td>
<td className="p-3 font-mono text-xs">
<span
className={isOverdue(hw.dueDate) ? "text-destructive" : ""}
>
{formatDueDate(hw.dueDate)}
</span>
</td>
<td className="p-3 text-xs">
{formatGracePeriod(hw.gracePeriod)}
</td>
<td className="p-3 text-right">
<Link
href={`/shell/teacher/homework/${hw.id}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("list.viewDetail")}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* 作业状态徽章(按状态色阶展示)。
*/
function HomeworkStatusBadge({
status,
}: {
status: string;
}): React.ReactElement {
const label = formatHomeworkStatus(status);
const cls = homeworkStatusToBadgeClass(status);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,232 @@
"use client";
/**
* 布置作业表单页 - 客户端组件ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2
*
* 数据契约:
* - mutation assignHomework(input):❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#assign-homework-mutation
*
* 三态规范§11.3 DoD
* - loadingFormPageSkeleton初始数据加载由 server page Suspense 兜底)
* - errorerrorSummary 表单级错误
* - successnotify.success + router.push 回列表
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { ClipboardList } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTransition, useState } from "react";
import { useTranslations } from "next-intl";
import { useAssignHomework, type AssignHomeworkInput } from "@/lib/api";
import { FormPageShell } from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
/**
* 表单客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function NewHomeworkClient(): React.ReactElement {
const t = useTranslations("homework");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const presetClassId = searchParams.get("classId") ?? "";
// @contract-pendingMSW 兜底
const { run: assignHomework, loading: submitting } = useAssignHomework();
const handleSubmit = async (input: AssignHomeworkInput): Promise<void> => {
try {
const result = await assignHomework(input);
notify.success(t("new.success"));
startTransition(() => {
router.push(`/shell/teacher/homework?classId=${input.classId}`);
});
void result;
} catch (err) {
notify.error(`${t("new.error")}: ${String(err)}`);
}
};
return (
<NewHomeworkFormInner
presetClassId={presetClassId}
submitting={submitting}
onSubmit={handleSubmit}
/>
);
}
/**
* 表单主体(受控表单 + 内联校验)。
*
* 注:未引入 react-hook-form + zod因当前仅一个表单引入会增加依赖。
* 后续表单数量增多后统一迁移到 react-hook-form§7.3 表单页模板建议)。
*/
function NewHomeworkFormInner({
presetClassId,
submitting,
onSubmit,
}: {
presetClassId: string;
submitting: boolean;
onSubmit: (input: AssignHomeworkInput) => Promise<void>;
}): React.ReactElement {
const t = useTranslations("homework");
const tCommon = useTranslations("common");
const [classId, setClassId] = useState(presetClassId);
const [subjectId, setSubjectId] = useState("sub-math");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [dueDate, setDueDate] = useState("");
const [gracePeriod, setGracePeriod] = useState("24");
const [error, setError] = useState<string | null>(null);
const handleFormSubmit = (): void => {
setError(null);
if (!classId.trim()) {
setError(t("new.errorClassRequired"));
return;
}
if (!title.trim()) {
setError(t("new.errorTitleRequired"));
return;
}
if (!dueDate) {
setError(t("new.errorDateRequired"));
return;
}
const input: AssignHomeworkInput = {
classId: classId.trim(),
subjectId: subjectId.trim(),
title: title.trim(),
description: description.trim() || undefined,
dueDate: new Date(dueDate).toISOString(),
gracePeriod: Number(gracePeriod) || 0,
};
void onSubmit(input);
};
return (
<FormPageShell
title={t("new.title")}
description={t("new.description")}
icon={<ClipboardList className="size-6" />}
backHref={`/shell/teacher/homework?classId=${classId}`}
onSubmit={handleFormSubmit}
submitting={submitting}
submitLabel={t("new.submit")}
cancelLabel={tCommon("button.cancel")}
errorSummary={
error ? <p className="text-sm text-destructive">{error}</p> : undefined
}
>
{/* 班级 ID */}
<FormField label={t("new.classId")} required>
<input
type="text"
value={classId}
onChange={(e) => setClassId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="cls-001"
required
/>
</FormField>
{/* 科目 ID */}
<FormField label={t("new.subjectId")} required>
<input
type="text"
value={subjectId}
onChange={(e) => setSubjectId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="sub-math"
required
/>
</FormField>
{/* 标题 */}
<FormField label={t("new.titleLabel")} required>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder={t("new.titlePlaceholder")}
required
/>
</FormField>
{/* 描述 */}
<FormField label={t("new.descriptionLabel")}>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder={t("new.descriptionPlaceholder")}
/>
</FormField>
{/* 截止日期 */}
<FormField label={t("new.dueDate")} required>
<input
type="datetime-local"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required
/>
</FormField>
{/* 宽限期(小时) */}
<FormField label={t("new.gracePeriod")}>
<input
type="number"
min={0}
value={gracePeriod}
onChange={(e) => setGracePeriod(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
<p className="text-xs text-muted-foreground">
{t("new.gracePeriodHint")}
</p>
</FormField>
{/* @contract-pending 提示 */}
<p className="text-xs text-muted-foreground">
{t("new.contractPending")}
</p>
</FormPageShell>
);
}
/**
* 表单字段容器label + children
* 简化版,后续可提取到 shared/components/ui/form-field.tsx 复用。
*/
function FormField({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}): React.ReactElement {
return (
<div className="space-y-2">
<label className="text-sm font-medium">
{label}
{required ? <span className="ml-1 text-destructive">*</span> : null}
</label>
{children}
</div>
);
}

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>
);
}

View File

@@ -0,0 +1,391 @@
"use client";
/**
* 单份提交批改页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* 数据契约:
* - 单查 submissionDetail(submissionId):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - mutation gradeSubmission(input):❌ schema 无 Mutation → MSW 兜底(@contract-pending
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - notFounddata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { ClipboardCheck } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import {
useSubmissionDetail,
useGradeSubmission,
type SubmissionAnswer,
type SubmissionDetail,
} from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import {
DetailPageShell,
DetailPageSkeleton,
DetailSection,
DetailField,
} from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
import {
formatDueDate,
formatSubmissionStatus,
submissionStatusToBadgeClass,
} from "@/features/teacher/homework/transformations";
/**
* 批改客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function SubmissionGradingClient(): 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;
return (
<DetailPageShell
title={
data
? t("grading.title", { name: data.submission.studentName })
: t("grading.titleLoading")
}
description={
data
? t("grading.subtitle", {
homework: data.submission.homeworkTitle,
no: data.submission.studentNo,
})
: undefined
}
icon={<ClipboardCheck className="size-6" />}
backHref="/shell/teacher/homework/submissions"
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={
!loading && !error && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("grading.notFound")}
</div>
) : undefined
}
>
{data ? <SubmissionGradingBody detail={data} /> : null}
</DetailPageShell>
);
}
/**
* 批改主体:学生信息 + 题目作答 + 批改表单 + 上下导航。
*/
function SubmissionGradingBody({
detail,
}: {
detail: SubmissionDetail;
}): React.ReactElement {
const t = useTranslations("homework");
const { submission, navigation } = detail;
const router = useRouter();
const handleNav = (target: string | null): void => {
if (!target) return;
router.push(`/shell/teacher/homework/submissions/${target}`);
};
return (
<>
<DetailSection title={t("grading.sectionStudent")}>
<DetailField
label={t("grading.fieldStudentName")}
value={submission.studentName}
/>
<DetailField
label={t("grading.fieldStudentNo")}
value={submission.studentNo}
/>
<DetailField
label={t("grading.fieldClass")}
value={submission.className}
/>
<DetailField
label={t("grading.fieldHomework")}
value={
<Link
href={`/shell/teacher/homework/${submission.homeworkId}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{submission.homeworkTitle}
</Link>
}
/>
<DetailField
label={t("grading.fieldStatus")}
value={
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${submissionStatusToBadgeClass(submission.status)}`}
>
{formatSubmissionStatus(submission.status)}
</span>
}
/>
<DetailField
label={t("grading.fieldSubmittedAt")}
value={formatDueDate(submission.submittedAt)}
/>
<DetailField
label={t("grading.fieldFeedback")}
value={submission.feedback ?? "-"}
/>
</DetailSection>
<DetailSection title={t("grading.sectionAnswers")}>
<AnswersGradingForm
submissionId={submission.id}
answers={detail.answers}
maxScore={submission.maxScore}
/>
</DetailSection>
<DetailSection title={t("grading.sectionNavigation")}>
<div className="flex items-center justify-between gap-2 text-sm">
<Button
variant="outline"
size="sm"
disabled={!navigation.prevId}
onClick={() => handleNav(navigation.prevId)}
>
{t("grading.prev")}
</Button>
<span className="text-muted-foreground">
{t("grading.position", {
current: navigation.currentIndex + 1,
total: navigation.totalCount,
})}
</span>
<Button
variant="outline"
size="sm"
disabled={!navigation.nextId}
onClick={() => handleNav(navigation.nextId)}
>
{t("grading.next")}
</Button>
</div>
</DetailSection>
</>
);
}
/**
* 题目作答 + 批改表单。
*/
function AnswersGradingForm({
submissionId,
answers,
maxScore,
}: {
submissionId: string;
answers: SubmissionAnswer[];
maxScore: number;
}): React.ReactElement {
const t = useTranslations("homework");
const { run: gradeSubmission, loading: submitting } = useGradeSubmission();
// 每题分数与教师评语,初始化为已有值
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 initialComments = useMemo<Record<string, string>>(() => {
const m: Record<string, string> = {};
for (const a of answers) {
m[a.questionId] = a.teacherComment ?? "";
}
return m;
}, [answers]);
const [scores, setScores] = useState<Record<string, string>>(initialScores);
const [comments, setComments] =
useState<Record<string, string>>(initialComments);
const [feedback, setFeedback] = useState("");
const [error, setError] = useState<string | null>(null);
const applyAiSuggestion = (answer: SubmissionAnswer): void => {
if (!answer.aiSuggestion) return;
// 解析 AI 建议(如 "答案正确,可直接给满分。" → 直接给满分)
if (answer.aiSuggestion.includes("满分")) {
setScores((prev) => ({
...prev,
[answer.questionId]: String(answer.maxScore),
}));
}
setComments((prev) => ({
...prev,
[answer.questionId]: answer.aiSuggestion ?? "",
}));
notify.info(t("grading.aiSuggestionApplied"));
};
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("grading.errorScoreInvalid", { qid: a.questionId }));
}
return {
questionId: a.questionId,
score: num,
teacherComment: (comments[a.questionId] ?? "").trim() || undefined,
};
});
try {
await gradeSubmission({
submissionId,
answers: payload,
feedback: feedback.trim() || undefined,
});
notify.success(t("grading.success"));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
notify.error(`${t("grading.error")}: ${msg}`);
}
};
return (
<div className="space-y-4">
{answers.map((a, idx) => (
<div key={a.id} className="rounded-md border p-3">
<div className="mb-2 flex items-start justify-between gap-2">
<div>
<p className="text-sm font-medium">
{idx + 1}. {a.questionTitle}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("grading.questionType")}: {a.questionType} ·{" "}
{t("grading.maxScore")}: {a.maxScore}
</p>
</div>
{a.aiSuggestion ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => applyAiSuggestion(a)}
>
{t("grading.applyAi")}
</Button>
) : null}
</div>
<div className="mb-2 rounded-md bg-muted/30 p-2">
<p className="text-xs text-muted-foreground">
{t("grading.studentAnswer")}
</p>
<p className="mt-1 text-sm">{a.answer}</p>
</div>
{a.aiSuggestion ? (
<p className="mb-2 text-xs text-muted-foreground">
<span className="font-medium">
{t("grading.aiSuggestionLabel")}:
</span>{" "}
{a.aiSuggestion}
</p>
) : null}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<div className="space-y-1">
<label className="text-xs font-medium">
{t("grading.score")}
</label>
<input
type="number"
min={0}
max={a.maxScore}
value={scores[a.questionId] ?? ""}
onChange={(e) =>
setScores((prev) => ({
...prev,
[a.questionId]: e.target.value,
}))
}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm"
/>
</div>
<div className="space-y-1 sm:col-span-2">
<label className="text-xs font-medium">
{t("grading.teacherComment")}
</label>
<input
type="text"
value={comments[a.questionId] ?? ""}
onChange={(e) =>
setComments((prev) => ({
...prev,
[a.questionId]: e.target.value,
}))
}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm"
/>
</div>
</div>
</div>
))}
<div className="space-y-1">
<label className="text-sm font-medium">
{t("grading.overallFeedback")}
</label>
<textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder={t("grading.overallFeedbackPlaceholder")}
/>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
{t("grading.maxScoreTotal")}: {maxScore}
</p>
<Button
type="button"
onClick={() => void handleSubmit()}
disabled={submitting}
>
{submitting ? t("grading.submitting") : t("grading.submit")}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t("grading.contractPending")}
</p>
</div>
);
}

View File

@@ -0,0 +1,226 @@
"use client";
/**
* 提交批改列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* 数据契约:
* - 列表查询 homeworkSubmissions(filter):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#homework-submissions
*
* URL 状态:?classId=xxx &status=xxx &q=xxx
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { ClipboardCheck } from "lucide-react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useHomeworkSubmissions, type HomeworkSubmissionItem } from "@/lib/api";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
formatDueDate,
formatScore,
formatSubmissionStatus,
submissionStatusToBadgeClass,
} from "@/features/teacher/homework/transformations";
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function SubmissionsListClient(): React.ReactElement {
const t = useTranslations("homework");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const classId = searchParams.get("classId") ?? "";
const statusFilter = searchParams.get("status") ?? "";
const q = searchParams.get("q") ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useHomeworkSubmissions({
classId: classId || undefined,
status: statusFilter || undefined,
});
const filteredItems = useMemo<HomeworkSubmissionItem[]>(() => {
const items = data?.items ?? [];
if (!q) return items;
return items.filter(
(item) =>
item.studentName.toLowerCase().includes(q.toLowerCase()) ||
item.studentNo.toLowerCase().includes(q.toLowerCase()) ||
item.homeworkTitle.toLowerCase().includes(q.toLowerCase()),
);
}, [data, q]);
const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
startTransition(() => {
router.push(`/shell/teacher/homework/submissions?${params.toString()}`);
});
};
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>
<p className="mt-2 text-xs text-muted-foreground">
{t("submissions.mswNotice")}
</p>
</div>
) : undefined;
return (
<ListPageShell
title={t("submissions.title")}
description={t("submissions.description")}
icon={<ClipboardCheck className="size-6" />}
filters={
<>
<FilterSearchInput
placeholder={t("submissions.searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
/>
<input
type="text"
value={classId}
onChange={(e) => updateQuery("classId", e.target.value)}
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
placeholder={t("submissions.classIdPlaceholder")}
aria-label={t("submissions.classIdFilter")}
/>
<select
value={statusFilter}
onChange={(e) => updateQuery("status", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("submissions.statusFilter")}
>
<option value="">{t("submissions.statusAll")}</option>
<option value="SUBMITTED">
{t("submissions.statusSubmitted")}
</option>
<option value="GRADING">{t("submissions.statusGrading")}</option>
<option value="GRADED">{t("submissions.statusGraded")}</option>
<option value="RETURNED">{t("submissions.statusReturned")}</option>
<option value="LATE">{t("submissions.statusLate")}</option>
</select>
</>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={8} />}
empty={filteredItems.length === 0 && !loading}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("submissions.total", { count: filteredItems.length })}</span>
</div>
}
>
<SubmissionsTable items={filteredItems} />
</ListPageShell>
);
}
/**
* 提交列表表格(纯展示组件)。
*/
function SubmissionsTable({
items,
}: {
items: HomeworkSubmissionItem[];
}): React.ReactElement {
const t = useTranslations("homework");
return (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">
{t("submissions.colStudent")}
</th>
<th className="p-3 text-left font-medium">
{t("submissions.colHomework")}
</th>
<th className="p-3 text-left font-medium">
{t("submissions.colClass")}
</th>
<th className="p-3 text-left font-medium">
{t("submissions.colStatus")}
</th>
<th className="p-3 text-left font-medium">
{t("submissions.colSubmittedAt")}
</th>
<th className="p-3 text-right font-medium">
{t("submissions.colScore")}
</th>
<th className="p-3 text-right font-medium">
{t("submissions.colActions")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((s) => (
<tr key={s.id} className="hover:bg-muted/30">
<td className="p-3">
<Link
href={`/shell/teacher/homework/submissions/${s.id}`}
className="font-medium hover:underline"
>
{s.studentName}
</Link>
<p className="text-xs text-muted-foreground">{s.studentNo}</p>
</td>
<td className="p-3">
<Link
href={`/shell/teacher/homework/${s.homeworkId}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{s.homeworkTitle}
</Link>
</td>
<td className="p-3 text-xs">{s.className}</td>
<td className="p-3">
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${submissionStatusToBadgeClass(s.status)}`}
>
{formatSubmissionStatus(s.status)}
</span>
</td>
<td className="p-3 font-mono text-xs">
{formatDueDate(s.submittedAt)}
</td>
<td className="p-3 text-right">
{formatScore(s.totalScore)} / {s.maxScore}
</td>
<td className="p-3 text-right">
<Link
href={`/shell/teacher/homework/submissions/${s.id}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("submissions.toGrade")}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,266 @@
/**
* Homework 数据变换工具ARCHITECTURE.md §11.3 DoD - 纯函数单测)
*
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
* 关联ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
*/
import type { Homework, HomeworkListItem } from "@/lib/api";
/** 作业状态中文标签映射(对齐旧 teacher-portal HOMEWORK_STATUS_LABEL */
export const HOMEWORK_STATUS_LABEL: Record<string, string> = {
DRAFT: "草稿",
PUBLISHED: "已发布",
CLOSED: "已关闭",
ARCHIVED: "已归档",
};
/** 提交状态中文标签映射 */
export const SUBMISSION_STATUS_LABEL: Record<string, string> = {
SUBMITTED: "已提交",
GRADING: "批改中",
GRADED: "已批改",
RETURNED: "已退回",
LATE: "迟交",
};
/**
* 将作业状态枚举值映射为中文标签。
* 未知状态回退为原始值。
*/
export function formatHomeworkStatus(status: string): string {
return HOMEWORK_STATUS_LABEL[status] ?? status;
}
/**
* 将提交状态枚举值映射为中文标签。
* 未知状态回退为原始值。
*/
export function formatSubmissionStatus(status: string): string {
return SUBMISSION_STATUS_LABEL[status] ?? status;
}
/**
* 格式化 ISO 日期字符串为本地化展示zh-CN含年月日时分
* 输入无效时返回占位符。
*/
export function formatDueDate(isoDate: string | null | undefined): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* 格式化宽限期(小时)为友好展示。
* - 0 小时:返回 "无宽限期"
* - < 24 小时:返回 "N 小时"
* - >= 24 小时:返回 "X 天 Y 小时"
*/
export function formatGracePeriod(hours: number): string {
if (!Number.isFinite(hours) || hours <= 0) return "无宽限期";
if (hours < 24) return `${hours} 小时`;
const days = Math.floor(hours / 24);
const rest = hours % 24;
return rest === 0 ? `${days}` : `${days}${rest} 小时`;
}
/**
* 判断作业是否已过截止时间(基于 dueDate
* 输入无效返回 false。
*/
export function isOverdue(
dueDate: string | null | undefined,
now: Date = new Date(),
): boolean {
if (!dueDate) return false;
const d = new Date(dueDate);
if (Number.isNaN(d.getTime())) return false;
return d.getTime() < now.getTime();
}
/**
* 判断作业是否处于可编辑状态DRAFT
*/
export function isHomeworkEditable(status: string): boolean {
return status === "DRAFT";
}
/**
* 判断作业是否已发布PUBLISHED
*/
export function isHomeworkPublished(status: string): boolean {
return status === "PUBLISHED";
}
/**
* 从作业详情中提取列表项视图模型(裁剪字段)。
*/
export function toHomeworkListItem(homework: Homework): HomeworkListItem {
return {
id: homework.id,
classId: homework.classId,
subjectId: homework.subjectId,
title: homework.title,
description: homework.description,
dueDate: homework.dueDate,
gracePeriod: homework.gracePeriod,
status: homework.status,
createdAt: homework.createdAt,
};
}
/**
* 判断提交是否已完成批改GRADED / RETURNED
*/
export function isGraded(status: string): boolean {
return status === "GRADED" || status === "RETURNED";
}
/**
* 判断提交是否处于待批改状态SUBMITTED / LATE
*/
export function isPendingGrading(status: string): boolean {
return status === "SUBMITTED" || status === "LATE";
}
/**
* 将分数(数值)格式化为展示字符串,保留 1 位小数。
* 输入无效null/NaN/Infinity返回 "--"。
*/
export function formatScore(score: number | null | undefined): string {
if (score === null || score === undefined) return "--";
if (!Number.isFinite(score)) return "--";
return score.toFixed(1);
}
/**
* 将 0-1 的小数格式化为百分比字符串(如 0.789 → "79%")。
* 输入无效返回 "--"。
*/
export function formatSubmissionRate(rate: number): string {
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
return `${(rate * 100).toFixed(0)}%`;
}
/**
* 计算提交率(已提交数 / 总人数)。
* 总人数为 0 时返回 0。
*/
export function calcSubmissionRate(
submittedCount: number,
totalStudents: number,
): number {
if (!Number.isFinite(totalStudents) || totalStudents <= 0) return 0;
if (!Number.isFinite(submittedCount) || submittedCount < 0) return 0;
return Math.min(submittedCount / totalStudents, 1);
}
/**
* 根据得分率score/maxScore返回等级标签A/B/C/D
* - rate >= 0.85 → A
* - rate >= 0.7 → B
* - rate >= 0.6 → C
* - 其他 → D
*
* maxScore 为 0 或输入无效时返回 "--"。
*/
export function formatGradeLevel(
score: number | null | undefined,
maxScore: number,
): string {
if (score === null || score === undefined) return "--";
if (!Number.isFinite(score) || !Number.isFinite(maxScore) || maxScore <= 0) {
return "--";
}
const rate = score / maxScore;
if (rate >= 0.85) return "A";
if (rate >= 0.7) return "B";
if (rate >= 0.6) return "C";
return "D";
}
/**
* 根据等级A/B/C/D返回 Tailwind 文本语义类名。
*/
export function gradeLevelToColorClass(level: string): string {
switch (level) {
case "A":
return "text-emerald-600";
case "B":
return "text-blue-600";
case "C":
return "text-amber-600";
case "D":
return "text-destructive";
default:
return "text-muted-foreground";
}
}
/**
* 根据提交状态返回 Tailwind 徽章语义类名。
*/
export function submissionStatusToBadgeClass(status: string): string {
switch (status) {
case "SUBMITTED":
case "LATE":
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
case "GRADING":
return "bg-primary/10 text-primary";
case "GRADED":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
case "RETURNED":
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
default:
return "bg-muted text-muted-foreground";
}
}
/**
* 根据作业状态返回 Tailwind 徽章语义类名。
*/
export function homeworkStatusToBadgeClass(status: string): string {
switch (status) {
case "DRAFT":
return "bg-muted text-muted-foreground";
case "PUBLISHED":
return "bg-primary/10 text-primary";
case "CLOSED":
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
case "ARCHIVED":
return "bg-muted text-muted-foreground";
default:
return "bg-muted text-muted-foreground";
}
}
/**
* 根据 AI 评分置信度返回 Tailwind 文本语义类名。
* - >= 0.8 → text-emerald-600高置信
* - >= 0.6 → text-amber-600中置信
* - 其他 → text-destructive低置信
*/
export function confidenceToColorClass(confidence: number): string {
if (!Number.isFinite(confidence)) return "text-muted-foreground";
if (confidence >= 0.8) return "text-emerald-600";
if (confidence >= 0.6) return "text-amber-600";
return "text-destructive";
}
/**
* 格式化置信度0-1为百分比字符串如 0.92 → "92%")。
* 输入无效返回 "--"。
*/
export function formatConfidence(confidence: number): string {
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) {
return "--";
}
return `${(confidence * 100).toFixed(0)}%`;
}