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,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("--");
|
||||
});
|
||||
});
|
||||
@@ -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 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联: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-pending:MSW 兜底
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - notFound:data 为 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>
|
||||
);
|
||||
}
|
||||
@@ -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 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联: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-pending:MSW 兜底
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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):
|
||||
* - loading:FormPageSkeleton(初始数据加载,由 server page Suspense 兜底)
|
||||
* - error:errorSummary 表单级错误
|
||||
* - success:notify.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-pending:MSW 兜底
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - notFound:data 为 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-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;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联: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-pending:MSW 兜底
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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)}%`;
|
||||
}
|
||||
Reference in New Issue
Block a user