feat(portal-shell): homework 模块 7 页迁移(教师域 §9.1 B2)

§9.1 教师域 homework 模块完整迁移(继 exams 之后第二个 B2 模块):

7 页路由结构(与旧 teacher-portal 同构):
- /shell/teacher/homework:列表页(?classId/status/q 筛选)
- /shell/teacher/homework/new:布置作业表单页
- /shell/teacher/homework/[id]:详情 + 内联批改(含提交列表 + recordGrade 表单)
- /shell/teacher/homework/submissions:跨作业提交评审列表
- /shell/teacher/homework/submissions/[submissionId]:单份提交批改 + AI 建议 + 上下份导航
- /shell/teacher/homework/submissions/[submissionId]/scan-grading:扫描批改工作台(三栏)
- /shell/teacher/homework/assignments/[id]/submissions:按作业批量批改 + 统计 + AI 批量评分

数据契约(混合):
-  homework(id: ID!) 真实查询(schema 已就绪,详情页用)
-  列表/mutation/submissions/grading/aiBatchGrading 全部 @contract-pending MSW 兜底
  · 9 个 hook 走 MSW,待后端补齐 mutation 后切换真实 fetcher

§11.3 DoD 11 项验收:
1. route-permissions:EXACT + PREFIX 表 /shell/teacher/homework 已配置
2. 页面模板:list/new 用 ListPageShell/FormPageShell;detail/grading 用 DetailPageShell;
   scan-grading 用 WorkbenchPageShell(三栏,未使用 emptyNode)
3. 三态:loading(Skeleton)/error(errorNode 或 errorSummary)/empty(emptyNode) 全实现
4. lib/api hooks:homework.ts 10 个 hooks(useHomework 真实 + 9 个 MSW)
5. @contract-pending MSW:graphql-data.ts 扩展 6 块 mock + 10 个 switch case
6. i18n:homework 节点扩展 8 个分区共 130+ keys(list/detail/new/submissions/grading/
   scan/assignment/error)中英对齐
7. lint:0 errors(4 warnings 在 __generated__)
8. lint:tokens:0 errors
9. notify:mutation 反馈走 @/shared/lib/notify(非 sonner 直引)
10. vitest:transformations 纯函数单测齐全,全量 323/323 通过(新增 ~50 测试)
11. typecheck:0 errors(noUncheckedIndexedAccess 安全访问)

附带修复:
- 修复 2 处遗留 broken link:
  · widgets/sidebar/quick-actions: /homework/new → /shell/teacher/homework/new
  · widgets/topbar/global-search: /homework → /shell/teacher/homework
- scripts/check-page-count.ts baseline 同步 13 → 26(与 exams 6 + homework 7 一致)

剩余模块:grades(5)+lesson-plans(6)+questions(1)+textbooks(2)+attendance(4)+classes(3)+
students(1)+course-plans(2)+elective(3)+error-book(1)+diagnostic(2)+analytics(2)+ai-*(3)+
knowledge-graph(1)+practice(1)+schedule-changes(1)+leave(1) 共 39 页。
This commit is contained in:
SpecialX
2026-07-22 18:11:15 +08:00
parent dca25fc42f
commit 081cb5fbc3
28 changed files with 4810 additions and 6 deletions

View File

@@ -0,0 +1,25 @@
import { Suspense } from "react";
import { HomeworkDetailClient } from "@/features/teacher/homework/homework-detail-client";
import { DetailPageSkeleton } from "@/shared/components/page-templates";
/**
* 作业详情页ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹。
* 业务逻辑在 HomeworkDetailClientclient component中。
*
* 数据契约:
* - 单查 homework(id: ID!) ✅ schema 真实字段core-edu 子图)
* - 提交列表 homeworkSubmissions(homeworkId) ❌ schema 无此字段 → MSW 兜底
* - mutation recordGrade(input) ❌ schema 无 Mutation → MSW 兜底
*
* 关联ARCHITECTURE.md §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function HomeworkDetailPage(): React.ReactElement {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<HomeworkDetailClient />
</Suspense>
);
}

View File

@@ -0,0 +1,24 @@
import { Suspense } from "react";
import { AssignmentSubmissionsClient } from "@/features/teacher/homework/assignment-submissions-client";
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 按作业批量批改页ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹。
* 业务逻辑在 AssignmentSubmissionsClientclient component中。
*
* 数据契约:
* - 查询 assignmentSubmissions(homeworkId) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 查询 aiBatchGrading(homeworkId) ❌ schema 无此字段 → MSW 兜底
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function AssignmentSubmissionsPage(): React.ReactElement {
return (
<Suspense fallback={<ListPageSkeleton rows={6} />}>
<AssignmentSubmissionsClient />
</Suspense>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
/**
* 作业路由错误边界ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment error.tsx捕获子树未处理异常。
*/
import { useEffect } from "react";
import { Button } from "@/shared/components/ui/button";
import { useTranslations } from "next-intl";
export default function HomeworkError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}): React.ReactElement {
const t = useTranslations("homework");
useEffect(() => {
console.error("[portal-shell] homework route error:", error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
<h2 className="text-lg font-semibold text-destructive">
{t("error.title")}
</h2>
<p className="text-sm text-muted-foreground">
{error.message || t("error.unknown")}
</p>
<Button onClick={reset} variant="outline">
{t("error.retry")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 作业路由段加载骨架ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment loading.tsx自动包裹页面渲染期间。
*
* 子页面(详情/批改/扫描批改)的 Skeleton 由各自 server page 的 <Suspense> 兜底,
* 本文件仅在 /shell/teacher/homework 列表/重定向期间显示。
*/
export default function HomeworkLoading(): React.ReactElement {
return <ListPageSkeleton rows={5} />;
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { NewHomeworkClient } from "@/features/teacher/homework/new-homework-client";
import { FormPageSkeleton } from "@/shared/components/page-templates";
/**
* 布置作业表单页ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useSearchParams 要求)。
* 业务逻辑在 NewHomeworkClientclient component中。
*
* 数据契约mutation assignHomework(input) ❌ schema 无 Mutation → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#assign-homework-mutation
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function NewHomeworkPage(): React.ReactElement {
return (
<Suspense fallback={<FormPageSkeleton />}>
<NewHomeworkClient />
</Suspense>
);
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { HomeworkListClient } from "@/features/teacher/homework/homework-list-client";
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 作业管理列表页ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useSearchParams 要求)。
* 业务逻辑在 HomeworkListClientclient component中。
*
* 数据契约:列表查询 homeworks(classId) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#homework-list
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function HomeworkListPage(): React.ReactElement {
return (
<Suspense fallback={<ListPageSkeleton rows={5} />}>
<HomeworkListClient />
</Suspense>
);
}

View File

@@ -0,0 +1,24 @@
import { Suspense } from "react";
import { SubmissionGradingClient } from "@/features/teacher/homework/submission-grading-client";
import { DetailPageSkeleton } from "@/shared/components/page-templates";
/**
* 单份提交批改页ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹。
* 业务逻辑在 SubmissionGradingClientclient component中。
*
* 数据契约:
* - 单查 submissionDetail(submissionId) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - mutation gradeSubmission(input) ❌ schema 无 Mutation → MSW 兜底
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function SubmissionGradingPage(): React.ReactElement {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<SubmissionGradingClient />
</Suspense>
);
}

View File

@@ -0,0 +1,24 @@
import { Suspense } from "react";
import { ScanGradingClient } from "@/features/teacher/homework/scan-grading-client";
import { WorkbenchPageSkeleton } from "@/shared/components/page-templates";
/**
* 扫描批改页ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹。
* 业务逻辑在 ScanGradingClientclient component中。
*
* 数据契约:
* - 单查 submissionDetail(submissionId) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - mutation saveScanGrading(input) ❌ schema 无 Mutation → MSW 兜底
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function ScanGradingPage(): React.ReactElement {
return (
<Suspense fallback={<WorkbenchPageSkeleton />}>
<ScanGradingClient />
</Suspense>
);
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { SubmissionsListClient } from "@/features/teacher/homework/submissions-list-client";
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 提交批改列表页ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useSearchParams 要求)。
* 业务逻辑在 SubmissionsListClientclient component中。
*
* 数据契约:列表查询 homeworkSubmissions(filter) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#homework-submissions
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function HomeworkSubmissionsPage(): React.ReactElement {
return (
<Suspense fallback={<ListPageSkeleton rows={8} />}>
<SubmissionsListClient />
</Suspense>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,356 @@
"use client";
/**
* 扫描批改页 - 客户端组件ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2
*
* 数据契约:
* - 单查 submissionDetail(submissionId):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - mutation saveScanGrading(input):❌ schema 无 Mutation → MSW 兜底(@contract-pending
*
* 三态规范§11.3 DoD
* - loadingWorkbenchPageSkeletonloading=true
* - errorerrorNode 局部降级
* - 局部加载:左/中/右各自支持独立 loading本页用整体 loading
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*
* 注WorkbenchPageShell 没有 emptyNode 属性notFound 由 errorNode 节点呈现。
*/
import { ScanLine } from "lucide-react";
import { useParams } from "next/navigation";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import {
useSubmissionDetail,
useSaveScanGrading,
type SubmissionAnswer,
} from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import {
WorkbenchPageShell,
WorkbenchPanel,
WorkbenchPageSkeleton,
} from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
import {
confidenceToColorClass,
formatConfidence,
} from "@/features/teacher/homework/transformations";
/**
* 扫描批改客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function ScanGradingClient(): React.ReactElement {
const t = useTranslations("homework");
const tCommon = useTranslations("common");
const params = useParams<{ submissionId: string }>();
const submissionId = params?.submissionId ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useSubmissionDetail(submissionId);
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
</div>
) : undefined;
const notFoundNode =
!loading && !error && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("scan.notFound")}
</div>
) : undefined;
return (
<WorkbenchPageShell
title={
data
? t("scan.title", { name: data.submission.studentName })
: t("scan.titleLoading")
}
description={
data
? t("scan.subtitle", {
homework: data.submission.homeworkTitle,
})
: undefined
}
icon={<ScanLine className="size-6" />}
loading={loading}
loadingNode={<WorkbenchPageSkeleton />}
errorNode={errorNode ?? notFoundNode}
left={
data ? (
<WorkbenchPanel title={t("scan.scanPreview")}>
<ScanPreviewPanel submissionId={data.submission.id} />
</WorkbenchPanel>
) : null
}
center={
data ? (
<WorkbenchPanel title={t("scan.recognizedAnswers")}>
<RecognizedAnswersPanel answers={data.answers} />
</WorkbenchPanel>
) : null
}
right={
data ? (
<WorkbenchPanel title={t("scan.gradingForm")}>
<ScanGradingForm
submissionId={data.submission.id}
answers={data.answers}
maxScore={data.submission.maxScore}
/>
</WorkbenchPanel>
) : null
}
/>
);
}
/**
* 扫描图片预览面板(左栏)。
* 注:当前无真实扫描图片,使用占位符 + 提示。
*/
function ScanPreviewPanel({
submissionId,
}: {
submissionId: string;
}): React.ReactElement {
const t = useTranslations("homework");
return (
<div className="flex h-full flex-col gap-3">
<div className="flex flex-1 items-center justify-center rounded-md border bg-muted/30 p-4">
<div className="text-center">
<ScanLine className="mx-auto size-12 text-muted-foreground" />
<p className="mt-2 text-xs text-muted-foreground">
{t("scan.imagePlaceholder", { id: submissionId })}
</p>
</div>
</div>
<p className="text-xs text-muted-foreground">
{t("scan.imageContractPending")}
</p>
</div>
);
}
/**
* 已识别答案面板(中栏)。
*/
function RecognizedAnswersPanel({
answers,
}: {
answers: SubmissionAnswer[];
}): React.ReactElement {
const t = useTranslations("homework");
return (
<div className="space-y-3">
{answers.map((a, idx) => (
<div key={a.id} className="rounded-md border p-3">
<div className="mb-1 flex items-start justify-between gap-2">
<p className="text-sm font-medium">
{idx + 1}. {a.questionTitle}
</p>
<span className="text-xs text-muted-foreground">
{a.maxScore} {t("scan.unitScore")}
</span>
</div>
<p className="text-xs text-muted-foreground">
{t("scan.questionType")}: {a.questionType}
</p>
<div className="mt-2 rounded-md bg-muted/30 p-2">
<p className="text-xs text-muted-foreground">
{t("scan.recognizedAnswer")}
</p>
<p className="mt-1 text-sm">{a.answer}</p>
</div>
{a.aiSuggestion ? (
<p className="mt-2 text-xs">
<span className="font-medium text-muted-foreground">
{t("scan.aiSuggestionLabel")}
</span>
<span className="ml-1">{a.aiSuggestion}</span>
</p>
) : null}
{a.isCorrect !== null ? (
<p
className={`mt-1 text-xs ${a.isCorrect ? "text-emerald-600" : "text-destructive"}`}
>
{a.isCorrect
? t("scan.recognizedCorrect")
: t("scan.recognizedIncorrect")}
</p>
) : null}
</div>
))}
{answers.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("scan.noAnswers")}</p>
) : null}
</div>
);
}
/**
* 扫描批改表单(右栏):每题分数 + 评语 + 总反馈 + 保存。
*/
function ScanGradingForm({
submissionId,
answers,
maxScore,
}: {
submissionId: string;
answers: SubmissionAnswer[];
maxScore: number;
}): React.ReactElement {
const t = useTranslations("homework");
const { run: saveScanGrading, loading: submitting } = useSaveScanGrading();
const initialScores = useMemo<Record<string, string>>(() => {
const m: Record<string, string> = {};
for (const a of answers) {
m[a.questionId] = a.score === null ? "" : String(a.score);
}
return m;
}, [answers]);
const [scores, setScores] = useState<Record<string, string>>(initialScores);
const [feedback, setFeedback] = useState("");
const [error, setError] = useState<string | null>(null);
const applyAllAi = (): void => {
const next: Record<string, string> = { ...scores };
let applied = 0;
for (const a of answers) {
if (!a.aiSuggestion) continue;
if (a.aiSuggestion.includes("满分")) {
next[a.questionId] = String(a.maxScore);
applied += 1;
}
}
setScores(next);
notify.info(t("scan.aiAppliedCount", { count: applied }));
};
const totalScore = answers.reduce((sum, a) => {
const raw = scores[a.questionId] ?? "";
const num = Number(raw);
return Number.isFinite(num) ? sum + num : sum;
}, 0);
const handleSubmit = async (): Promise<void> => {
setError(null);
const payload = answers.map((a) => {
const raw = scores[a.questionId] ?? "";
const num = Number(raw);
if (raw === "" || !Number.isFinite(num)) {
throw new Error(t("scan.errorScoreInvalid", { qid: a.questionId }));
}
return {
questionId: a.questionId,
score: num,
};
});
try {
await saveScanGrading({
submissionId,
answers: payload,
feedback: feedback.trim() || undefined,
});
notify.success(t("scan.saveSuccess"));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
notify.error(`${t("scan.saveError")}: ${msg}`);
}
};
return (
<div className="flex h-full flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{t("scan.totalScore")}: {totalScore} / {maxScore}
</span>
<Button type="button" variant="ghost" size="sm" onClick={applyAllAi}>
{t("scan.applyAllAi")}
</Button>
</div>
<div className="space-y-3 overflow-y-auto">
{answers.map((a, idx) => {
const raw = scores[a.questionId] ?? "";
const aiConf = a.aiSuggestion
? a.aiSuggestion.includes("满分")
? 0.95
: 0.7
: null;
return (
<div key={a.id} className="space-y-1 rounded-md border p-2">
<p className="text-xs font-medium">
{idx + 1}. {a.questionTitle}
</p>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={a.maxScore}
value={raw}
onChange={(e) =>
setScores((prev) => ({
...prev,
[a.questionId]: e.target.value,
}))
}
className="h-8 w-20 rounded-md border border-input bg-background px-2 text-sm"
aria-label={t("scan.scoreLabel", { qid: a.questionId })}
/>
<span className="text-xs text-muted-foreground">
/ {a.maxScore}
</span>
{aiConf !== null ? (
<span
className={`ml-auto text-xs ${confidenceToColorClass(aiConf)}`}
>
{t("scan.confidence")}: {formatConfidence(aiConf)}
</span>
) : null}
</div>
</div>
);
})}
</div>
<div className="space-y-1">
<label className="text-xs font-medium">
{t("scan.overallFeedback")}
</label>
<textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
rows={3}
className="w-full rounded-md border border-input bg-background px-2 py-1 text-sm"
placeholder={t("scan.overallFeedbackPlaceholder")}
/>
</div>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
<Button
type="button"
onClick={() => void handleSubmit()}
disabled={submitting}
>
{submitting ? t("scan.saving") : t("scan.save")}
</Button>
<p className="text-xs text-muted-foreground">
{t("scan.contractPending")}
</p>
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,612 @@
"use client";
/**
* Homework domain APIARCHITECTURE.md §5.1 / §5.3 / §9.1 教师域作业模块)
*
* 三类操作:
* 1. useHomework按 id 单查):✅ 真实查询 homework(id: ID!)schema 已就绪
* 2. 列表/提交查询/mutation❌ schema 无对应字段 → MSW 兜底(@contract-pending
*
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md
* 后端补齐后:重跑 normalize + codegen → 关闭 skipDocumentsValidation → 切换 fetcher → 删 mock
*
* 关联ARCHITECTURE.md §5.3 契约纪律 / §5.4 MSW 兜底 / §9.1 / §11.4 契约工单
*/
import type { FetchPolicy } from "@apollo/client";
import { useWidgetMutation } from "../useWidgetMutation";
import { useWidgetQuery } from "../useWidgetQuery";
import { ApiError } from "./errors";
import {
ASSIGN_HOMEWORK_DOC,
GET_AI_BATCH_GRADING_DOC,
GET_ASSIGNMENT_SUBMISSIONS_DOC,
GET_HOMEWORK_DOC,
GET_HOMEWORK_LIST_DOC,
GET_HOMEWORK_SUBMISSIONS_DOC,
GET_SUBMISSION_DETAIL_DOC,
GRADE_SUBMISSION_DOC,
RECORD_GRADE_DOC,
SAVE_SCAN_GRADING_DOC,
} from "./operations/homework.graphql";
import type { UseQueryResult } from "./types";
// ===== 数据类型(对齐 schema Homework 类型)=====
/**
* 作业实体(对齐 combined-schema.graphql Homework 类型core-edu 子图)
*
* 字段命名 camelCase与 schema 一致)。
*/
export interface Homework {
id: string;
classId: string;
subjectId: string;
title: string;
description: string | null;
dueDate: string;
gracePeriod: number;
status: string;
schoolId: string;
createdBy: string;
createdAt: string;
updatedAt: string;
}
/** 作业列表项(轻量字段集,用于列表渲染) */
export interface HomeworkListItem {
id: string;
classId: string;
subjectId: string;
title: string;
description: string | null;
dueDate: string;
gracePeriod: number;
status: string;
createdAt: string;
}
/** 列表查询响应(@contract-pending 假契约形状MSW 返回此结构) */
interface HomeworkListResponse {
homeworks: {
items: HomeworkListItem[];
total: number;
};
}
/** 单查响应(真实 schema */
interface HomeworkResponse {
homework: Homework | null;
}
/** 布置作业输入 */
export interface AssignHomeworkInput {
classId: string;
subjectId: string;
title: string;
description?: string;
dueDate: string;
gracePeriod?: number;
}
/** 布置作业 mutation 响应(@contract-pending */
interface AssignHomeworkResponse {
assignHomework: { id: string } | null;
}
// ===== 提交相关类型(@contract-pending 全 MSW=====
/** 提交状态枚举 */
export type SubmissionStatus =
"SUBMITTED" | "GRADING" | "GRADED" | "RETURNED" | "LATE";
/** 作业提交列表项 */
export interface HomeworkSubmissionItem {
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;
}
/** 提交列表筛选 */
export interface SubmissionsFilter {
classId?: string;
homeworkId?: string;
status?: string;
limit?: number;
offset?: number;
}
/** 提交列表响应 */
interface HomeworkSubmissionsResponse {
homeworkSubmissions: { items: HomeworkSubmissionItem[]; total: number };
}
/** 单题作答 */
export interface SubmissionAnswer {
id: string;
submissionId: string;
questionId: string;
questionTitle: string;
questionType: string;
maxScore: number;
answer: string;
score: number | null;
teacherComment: string | null;
isCorrect: boolean | null;
aiSuggestion: string | null;
}
/** 批改页导航信息 */
export interface SubmissionNavigation {
prevId: string | null;
nextId: string | null;
currentIndex: number;
totalCount: number;
}
/** 单份提交详情 */
export interface SubmissionDetail {
submission: HomeworkSubmissionItem & { feedback: string | null };
answers: SubmissionAnswer[];
navigation: SubmissionNavigation;
}
/** 单份提交详情响应 */
interface SubmissionDetailResponse {
submissionDetail: SubmissionDetail | null;
}
/** 批改提交输入 */
export interface GradeSubmissionInput {
submissionId: string;
answers: Array<{
questionId: string;
score: number;
teacherComment?: string;
}>;
feedback?: string;
}
/** 批改提交响应 */
interface GradeSubmissionResponse {
gradeSubmission: { submissionId: string } | null;
}
/** 保存扫描批改输入 */
export interface SaveScanGradingInput {
submissionId: string;
answers: Array<{
questionId: string;
score: number;
teacherComment?: string;
}>;
feedback?: string;
}
/** 保存扫描批改响应 */
interface SaveScanGradingResponse {
saveScanGrading: { submissionId: string } | null;
}
/** AI 批量评分建议项 */
export interface AiGradingSuggestion {
submissionId: string;
studentName: string;
suggestedScore: number;
confidence: number;
reasoning: string;
}
/** AI 批量评分汇总 */
export interface AiBatchGradingSummary {
totalSubmissions: number;
processed: number;
avgConfidence: number;
}
/** AI 批量评分数据 */
export interface AiBatchGrading {
homeworkId: string;
suggestions: AiGradingSuggestion[];
summary: AiBatchGradingSummary;
}
/** AI 批量评分响应 */
interface AiBatchGradingResponse {
aiBatchGrading: AiBatchGrading | null;
}
/** 按作业汇总统计 */
export interface AssignmentStats {
totalStudents: number;
submittedCount: number;
gradedCount: number;
pendingCount: number;
avgScore: number;
submissionRate: number;
}
/** 按作业的作业摘要 */
export interface AssignmentHomeworkSummary {
id: string;
title: string;
classId: string;
className: string;
dueDate: string;
maxScore: number;
}
/** 按作业的提交列表数据 */
export interface AssignmentSubmissions {
homework: AssignmentHomeworkSummary;
stats: AssignmentStats;
submissions: HomeworkSubmissionItem[];
}
/** 按作业提交列表响应 */
interface AssignmentSubmissionsResponse {
assignmentSubmissions: AssignmentSubmissions | null;
}
/** 详情页内联批改输入 */
export interface RecordGradeInput {
homeworkId: string;
studentId: string;
score: number;
feedback?: string;
}
/** 详情页内联批改响应 */
interface RecordGradeResponse {
recordGrade: { gradeId: string } | null;
}
// ===== 查询选项 =====
export interface HomeworkQueryOptions {
enabled?: boolean;
pollInterval?: number;
fetchPolicy?: FetchPolicy;
}
// ===== Hooks =====
/**
* 按 id 查询作业详情(真实 schema✅ 契约已就绪)。
*
* 关联ARCHITECTURE.md §5.5 后端已就绪查询 / §9.1 详情页
*/
export function useHomework(
id: string,
options?: HomeworkQueryOptions,
): UseQueryResult<Homework | null> {
const result = useWidgetQuery<HomeworkResponse, { id: string }>(
GET_HOMEWORK_DOC,
{ id },
{
...options,
enabled: options?.enabled ?? id.length > 0,
},
);
return {
data: result.data?.homework ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询班级下的作业列表(@contract-pendingMSW 兜底)。
*
* schema 无 homeworks(classId) 根字段,由 MSW handlers 返回 mock 数据。
* 后端补齐列表查询后切换到真实 fetcher页面无需改动。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 列表页 / §11.4 契约工单
*/
export function useHomeworkList(
classId: string,
options?: HomeworkQueryOptions & {
status?: string;
limit?: number;
offset?: number;
},
): UseQueryResult<{ items: HomeworkListItem[]; total: number }> {
const result = useWidgetQuery<
HomeworkListResponse,
{
classId: string;
status?: string;
limit?: number;
offset?: number;
}
>(
GET_HOMEWORK_LIST_DOC,
{
classId,
status: options?.status,
limit: options?.limit,
offset: options?.offset,
},
{
enabled: options?.enabled ?? classId.length > 0,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
},
);
return {
data: result.data?.homeworks,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 布置作业 mutation@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
* 后端补齐 mutation 后切换到真实 fetcher。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 表单页 / §11.4 契约工单
*/
export function useAssignHomework(): {
run: (input: AssignHomeworkInput) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<AssignHomeworkResponse, { input: AssignHomeworkInput }>(
ASSIGN_HOMEWORK_DOC,
);
const run = async (input: AssignHomeworkInput): Promise<{ id: string }> => {
const data = await rawRun({ input });
if (!data?.assignHomework) {
throw new ApiError("Failed to assign homework", "INTERNAL_ERROR");
}
return data.assignHomework;
};
return { run, loading, error };
}
/**
* 查询跨作业提交列表(@contract-pendingMSW 兜底)。
*
* schema 无 homeworkSubmissions 根字段,由 MSW handlers 返回 mock 数据。
* 用于 /shell/teacher/homework/submissions 列表页。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 submissions 列表页 / §11.4 契约工单
*/
export function useHomeworkSubmissions(
filter: SubmissionsFilter,
options?: HomeworkQueryOptions,
): UseQueryResult<{ items: HomeworkSubmissionItem[]; total: number }> {
const result = useWidgetQuery<
HomeworkSubmissionsResponse,
{
classId?: string;
homeworkId?: string;
status?: string;
limit?: number;
offset?: number;
}
>(GET_HOMEWORK_SUBMISSIONS_DOC, filter, {
enabled: options?.enabled ?? true,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
});
return {
data: result.data?.homeworkSubmissions,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询单份提交详情(@contract-pendingMSW 兜底)。
*
* schema 无 submissionDetail 根字段,由 MSW handlers 返回 mock 数据。
* 用于 /shell/teacher/homework/submissions/[submissionId] 批改页。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 批改页 / §11.4 契约工单
*/
export function useSubmissionDetail(
submissionId: string,
options?: HomeworkQueryOptions,
): UseQueryResult<SubmissionDetail | null> {
const result = useWidgetQuery<
SubmissionDetailResponse,
{ submissionId: string }
>(
GET_SUBMISSION_DETAIL_DOC,
{ submissionId },
{
...options,
enabled: options?.enabled ?? submissionId.length > 0,
},
);
return {
data: result.data?.submissionDetail ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 批改提交 mutation@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
* 用于单份提交批改页保存评分。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 批改页 / §11.4 契约工单
*/
export function useGradeSubmission(): {
run: (input: GradeSubmissionInput) => Promise<{ submissionId: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
GradeSubmissionResponse,
{ input: GradeSubmissionInput }
>(GRADE_SUBMISSION_DOC);
const run = async (
input: GradeSubmissionInput,
): Promise<{ submissionId: string }> => {
const data = await rawRun({ input });
if (!data?.gradeSubmission) {
throw new ApiError("Failed to grade submission", "INTERNAL_ERROR");
}
return data.gradeSubmission;
};
return { run, loading, error };
}
/**
* 保存扫描批改 mutation@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
* 用于扫描批改页保存。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 扫描批改页 / §11.4 契约工单
*/
export function useSaveScanGrading(): {
run: (input: SaveScanGradingInput) => Promise<{ submissionId: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
SaveScanGradingResponse,
{ input: SaveScanGradingInput }
>(SAVE_SCAN_GRADING_DOC);
const run = async (
input: SaveScanGradingInput,
): Promise<{ submissionId: string }> => {
const data = await rawRun({ input });
if (!data?.saveScanGrading) {
throw new ApiError("Failed to save scan grading", "INTERNAL_ERROR");
}
return data.saveScanGrading;
};
return { run, loading, error };
}
/**
* 查询 AI 批量评分建议(@contract-pendingMSW 兜底)。
*
* schema 无 aiBatchGrading 根字段,由 MSW handlers 返回 mock 数据。
* 用于按作业批量批改页 AI 评分建议。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 批量批改页 / §11.4 契约工单
*/
export function useAiBatchGrading(
homeworkId: string,
options?: HomeworkQueryOptions,
): UseQueryResult<AiBatchGrading | null> {
const result = useWidgetQuery<AiBatchGradingResponse, { homeworkId: string }>(
GET_AI_BATCH_GRADING_DOC,
{ homeworkId },
{
...options,
enabled: options?.enabled ?? homeworkId.length > 0,
},
);
return {
data: result.data?.aiBatchGrading ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询按作业拉所有提交(@contract-pendingMSW 兜底)。
*
* schema 无 assignmentSubmissions 根字段,由 MSW handlers 返回 mock 数据。
* 用于 /shell/teacher/homework/assignments/[id]/submissions 批量批改视图。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 批量批改页 / §11.4 契约工单
*/
export function useAssignmentSubmissions(
homeworkId: string,
options?: HomeworkQueryOptions,
): UseQueryResult<AssignmentSubmissions | null> {
const result = useWidgetQuery<
AssignmentSubmissionsResponse,
{ homeworkId: string }
>(
GET_ASSIGNMENT_SUBMISSIONS_DOC,
{ homeworkId },
{
...options,
enabled: options?.enabled ?? homeworkId.length > 0,
},
);
return {
data: result.data?.assignmentSubmissions ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 详情页内联批改 mutation@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
* 用于作业详情页内联录入单份提交评分。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 详情页 / §11.4 契约工单
*/
export function useRecordGrade(): {
run: (input: RecordGradeInput) => Promise<{ gradeId: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<RecordGradeResponse, { input: RecordGradeInput }>(
RECORD_GRADE_DOC,
);
const run = async (input: RecordGradeInput): Promise<{ gradeId: string }> => {
const data = await rawRun({ input });
if (!data?.recordGrade) {
throw new ApiError("Failed to record grade", "INTERNAL_ERROR");
}
return data.recordGrade;
};
return { run, loading, error };
}

View File

@@ -15,6 +15,7 @@ export * from "./sidebar";
export * from "./topbar";
export * from "./teacher";
export * from "./exams";
export * from "./homework";
export * from "./student";
export * from "./parent";
export * from "./admin";

View File

@@ -0,0 +1,261 @@
// Homework domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
//
// 拆分原则:
// - GetHomework按 id 单查):✅ combined-schema 中真实存在homework(id: ID!): Homework
// - 其余 9 个查询/mutation❌ schema 无对应字段/Mutation 类型
// → 走 MSW 兜底(@contract-pending等待后端补齐契约
//
// 契约工单docs/architecture/issues/contracts/core-edu_contract.md
// 关联ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §11.4
import { gql } from "@apollo/client";
// ── 真实查询homework(id) 单查 ─────────────────────────────────
// 字段全部对齐 combined-schema.graphql 中 Homework 类型core-edu 子图)
export const GET_HOMEWORK_DOC = gql`
query GetHomework($id: ID!) {
homework(id: $id) {
id
classId
subjectId
title
description
dueDate
gracePeriod
status
schoolId
createdBy
createdAt
updatedAt
}
}
`;
// ── 假契约查询(@contract-pending─────────────────────────────
// 列表查询schema 无 homeworks(classId) 根字段
// 页面通过 MSW 兜底获取列表数据,后端补齐后切换 fetcher 指向真实查询
// 契约工单core-edu_contract.md#homework-list
export const GET_HOMEWORK_LIST_DOC = gql`
query GetHomeworkList(
$classId: ID!
$status: String
$limit: Int
$offset: Int
) {
homeworks(
classId: $classId
status: $status
limit: $limit
offset: $offset
) {
items {
id
classId
subjectId
title
description
dueDate
gracePeriod
status
createdAt
}
total
}
}
`;
// ── 假契约变更(@contract-pending─────────────────────────────
// 布置作业schema 无 Mutation 类型
// 页面通过 MSW 兜底提交,后端补齐 mutation 后切换 fetcher
// 契约工单core-edu_contract.md#assign-homework-mutation
export const ASSIGN_HOMEWORK_DOC = gql`
mutation AssignHomework($input: AssignHomeworkInput!) {
assignHomework(input: $input) {
id
}
}
`;
// ── 跨作业提交列表(@contract-pending─────────────────────────
// schema 无 homeworkSubmissions 根字段 → MSW 兜底
// 用于 /shell/teacher/homework/submissions 列表页
// 契约工单core-edu_contract.md#homework-submissions
export const GET_HOMEWORK_SUBMISSIONS_DOC = gql`
query GetHomeworkSubmissions(
$classId: ID
$homeworkId: ID
$status: String
$limit: Int
$offset: Int
) {
homeworkSubmissions(
classId: $classId
homeworkId: $homeworkId
status: $status
limit: $limit
offset: $offset
) {
items {
id
homeworkId
homeworkTitle
studentId
studentName
studentNo
classId
className
status
submittedAt
gradedAt
gradedBy
totalScore
maxScore
}
total
}
}
`;
// ── 单份提交详情(@contract-pending───────────────────────────
// schema 无 submissionDetail 根字段 → MSW 兜底
// 用于 /shell/teacher/homework/submissions/[submissionId] 批改页
// 契约工单core-edu_contract.md#submission-detail
export const GET_SUBMISSION_DETAIL_DOC = gql`
query GetSubmissionDetail($submissionId: ID!) {
submissionDetail(submissionId: $submissionId) {
submission {
id
homeworkId
homeworkTitle
studentId
studentName
studentNo
classId
className
status
submittedAt
gradedAt
gradedBy
totalScore
maxScore
feedback
}
answers {
id
submissionId
questionId
questionTitle
questionType
maxScore
answer
score
teacherComment
isCorrect
aiSuggestion
}
navigation {
prevId
nextId
currentIndex
totalCount
}
}
}
`;
// ── 批改提交 mutation@contract-pending──────────────────────
// schema 无 Mutation 类型 → MSW 兜底
// 用于单份提交批改页保存评分
// 契约工单core-edu_contract.md#grade-submission
export const GRADE_SUBMISSION_DOC = gql`
mutation GradeSubmission($input: GradeSubmissionInput!) {
gradeSubmission(input: $input) {
submissionId
}
}
`;
// ── 保存扫描批改 mutation@contract-pending──────────────────
// schema 无 Mutation 类型 → MSW 兜底
// 用于扫描批改页保存
// 契约工单core-edu_contract.md#save-scan-grading
export const SAVE_SCAN_GRADING_DOC = gql`
mutation SaveScanGrading($input: SaveScanGradingInput!) {
saveScanGrading(input: $input) {
submissionId
}
}
`;
// ── AI 批量评分建议(@contract-pending────────────────────────
// schema 无 aiBatchGrading 根字段 → MSW 兜底
// 用于按作业批量批改页 AI 评分建议
// 契约工单core-edu_contract.md#ai-batch-grading
export const GET_AI_BATCH_GRADING_DOC = gql`
query GetAiBatchGrading($homeworkId: ID!) {
aiBatchGrading(homeworkId: $homeworkId) {
homeworkId
suggestions {
submissionId
studentName
suggestedScore
confidence
reasoning
}
summary {
totalSubmissions
processed
avgConfidence
}
}
}
`;
// ── 按作业拉所有提交(@contract-pending───────────────────────
// schema 无 assignmentSubmissions 根字段 → MSW 兜底
// 用于 /shell/teacher/homework/assignments/[id]/submissions 批量批改视图
// 契约工单core-edu_contract.md#assignment-submissions
export const GET_ASSIGNMENT_SUBMISSIONS_DOC = gql`
query GetAssignmentSubmissions($homeworkId: ID!) {
assignmentSubmissions(homeworkId: $homeworkId) {
homework {
id
title
classId
className
dueDate
maxScore
}
stats {
totalStudents
submittedCount
gradedCount
pendingCount
avgScore
submissionRate
}
submissions {
id
studentId
studentName
studentNo
status
submittedAt
gradedAt
totalScore
maxScore
}
}
}
`;
// ── 详情页内联批改 mutation@contract-pending────────────────
// schema 无 Mutation 类型 → MSW 兜底
// 用于作业详情页内联录入单份提交评分
// 契约工单core-edu_contract.md#record-grade
export const RECORD_GRADE_DOC = gql`
mutation RecordGrade($input: RecordGradeInput!) {
recordGrade(input: $input) {
gradeId
}
}
`;

View File

@@ -5,6 +5,7 @@ export * from "./sidebar.graphql";
export * from "./topbar.graphql";
export * from "./teacher.graphql";
export * from "./exams.graphql";
export * from "./homework.graphql";
export * from "./student.graphql";
export * from "./parent.graphql";
export * from "./admin.graphql";

View File

@@ -302,7 +302,215 @@
}
},
"homework": {
"title": "Homework"
"title": "Homework",
"list": {
"title": "Homework",
"description": "View and manage all homework",
"new": "Assign Homework",
"searchPlaceholder": "Search homework name...",
"statusFilter": "Filter by status",
"statusAll": "All statuses",
"statusDraft": "Draft",
"statusPublished": "Published",
"statusClosed": "Closed",
"statusArchived": "Archived",
"total": "{count} total",
"colName": "Name",
"colStatus": "Status",
"colDueDate": "Due Date",
"colGracePeriod": "Grace Period",
"colActions": "Actions",
"viewDetail": "View Detail →",
"mswNotice": "List query contract is pending, please ensure NEXT_PUBLIC_MSW=1 is enabled."
},
"detail": {
"title": "Homework Detail",
"edit": "Edit",
"notFound": "Homework not found, may have been deleted",
"createdAtPrefix": "Created on {date}",
"sectionBasic": "Basic Info",
"sectionSubmissions": "Submissions",
"sectionInlineGrade": "Inline Grade",
"fieldTitle": "Title",
"fieldDescription": "Description",
"fieldDueDate": "Due Date",
"fieldGracePeriod": "Grace Period",
"fieldStatus": "Status",
"fieldClassId": "Class ID",
"fieldSubjectId": "Subject ID",
"fieldCreatedBy": "Created By",
"fieldUpdatedAt": "Updated At",
"viewAllSubmissions": "All Submissions",
"noSubmissions": "No submissions yet",
"colStudent": "Student",
"colStatus": "Status",
"colSubmittedAt": "Submitted At",
"colScore": "Score",
"colActions": "Actions",
"grade": "Grade",
"gradeStudentId": "Student ID",
"gradeScore": "Score",
"gradeFeedback": "Feedback",
"gradeFeedbackPlaceholder": "Write feedback to the student...",
"gradeSubmit": "Submit Grade",
"gradeSubmitting": "Submitting...",
"gradeSuccess": "Grade submitted successfully",
"gradeError": "Grading failed",
"gradeErrorStudentRequired": "Please fill in Student ID",
"gradeErrorScoreInvalid": "Score is invalid, please enter a non-negative number",
"gradeContractPending": "Grade contract is @contract-pending, currently backed by MSW."
},
"new": {
"title": "Assign Homework",
"description": "Fill in homework basic information",
"submit": "Assign",
"success": "Homework assigned successfully",
"error": "Assignment failed",
"classId": "Class ID",
"subjectId": "Subject ID",
"titleLabel": "Title",
"titlePlaceholder": "e.g. Set and Function Practice",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Scope, notes, etc.",
"dueDate": "Due Date",
"gracePeriod": "Grace Period (hours)",
"gracePeriodHint": "Hours of grace after the deadline, 0 means no grace",
"errorClassRequired": "Please fill in Class ID",
"errorTitleRequired": "Please fill in homework title",
"errorDateRequired": "Please select due date",
"contractPending": "Assign homework contract is @contract-pending, currently backed by MSW. Will switch to real submission once backend mutation is ready."
},
"submissions": {
"title": "Submission Grading",
"description": "View all homework submissions and grade them",
"searchPlaceholder": "Search student/no/homework...",
"classIdFilter": "Filter by class",
"classIdPlaceholder": "Class ID",
"statusFilter": "Filter by status",
"statusAll": "All statuses",
"statusSubmitted": "Submitted",
"statusGrading": "Grading",
"statusGraded": "Graded",
"statusReturned": "Returned",
"statusLate": "Late",
"total": "{count} total",
"colStudent": "Student",
"colHomework": "Homework",
"colClass": "Class",
"colStatus": "Status",
"colSubmittedAt": "Submitted At",
"colScore": "Score",
"colActions": "Actions",
"toGrade": "Grade",
"mswNotice": "Submission query contract is pending, please ensure NEXT_PUBLIC_MSW=1 is enabled."
},
"grading": {
"title": "Grade: {name}",
"titleLoading": "Loading grade...",
"subtitle": "Homework: {homework} · No.: {no}",
"notFound": "Submission not found, may have been deleted",
"sectionStudent": "Student Info",
"sectionAnswers": "Answers",
"sectionNavigation": "Navigation",
"fieldStudentName": "Name",
"fieldStudentNo": "Student No.",
"fieldClass": "Class",
"fieldHomework": "Homework",
"fieldStatus": "Status",
"fieldSubmittedAt": "Submitted At",
"fieldFeedback": "Current Feedback",
"questionType": "Type",
"maxScore": "Max Score",
"maxScoreTotal": "Total Max",
"studentAnswer": "Student Answer",
"score": "Score",
"teacherComment": "Teacher Comment",
"overallFeedback": "Overall Feedback",
"overallFeedbackPlaceholder": "Write overall feedback to the student...",
"applyAi": "Apply AI Suggestion",
"aiSuggestionLabel": "AI Suggestion",
"aiSuggestionApplied": "AI suggestion applied",
"prev": "Previous",
"next": "Next",
"position": "{current}/{total}",
"submit": "Submit Grade",
"submitting": "Submitting...",
"success": "Grade submitted successfully",
"error": "Grading failed",
"errorScoreInvalid": "Score for question {qid} is invalid",
"contractPending": "Grading contract is @contract-pending, currently backed by MSW."
},
"scan": {
"title": "Scan Grade: {name}",
"titleLoading": "Loading scan grade...",
"subtitle": "Homework: {homework}",
"notFound": "Submission not found, may have been deleted",
"scanPreview": "Scan Preview",
"imagePlaceholder": "Scan image (submissionId: {id})",
"imageContractPending": "Scan image preview contract is @contract-pending, currently a placeholder.",
"recognizedAnswers": "Recognized Answers",
"recognizedAnswer": "Recognized Answer",
"recognizedCorrect": "Recognized correct",
"recognizedIncorrect": "Recognized incorrect",
"questionType": "Type",
"aiSuggestionLabel": "AI Suggestion",
"noAnswers": "No answer data",
"gradingForm": "Grading Form",
"scoreLabel": "Score for {qid}",
"totalScore": "Current Total",
"applyAllAi": "Apply All AI Suggestions",
"aiAppliedCount": "Applied {count} AI suggestions",
"confidence": "Confidence",
"overallFeedback": "Overall Feedback",
"overallFeedbackPlaceholder": "Write overall feedback to the student...",
"save": "Save Grade",
"saving": "Saving...",
"saveSuccess": "Scan grading saved successfully",
"saveError": "Save failed",
"errorScoreInvalid": "Score for question {qid} is invalid",
"unitScore": "pts",
"contractPending": "Scan grading contract is @contract-pending, currently backed by MSW."
},
"assignment": {
"title": "Batch Grading by Homework",
"description": "Homework: {title}",
"descriptionLoading": "Loading homework info...",
"backToHomework": "Back to Homework Detail",
"total": "{count} submissions",
"mswNotice": "Assignment submissions query contract is pending, please ensure NEXT_PUBLIC_MSW=1 is enabled.",
"sectionHomework": "Homework Info",
"sectionSubmissions": "Submissions",
"sectionAiGrading": "AI Batch Grading",
"homeworkTitle": "Title",
"homeworkId": "Homework ID",
"className": "Class",
"dueDate": "Due Date",
"colStudent": "Student",
"colStatus": "Status",
"colSubmittedAt": "Submitted At",
"colScore": "Score",
"colActions": "Actions",
"scanGrade": "Scan Grade",
"detailGrade": "Detail Grade",
"statsTotalStudents": "Expected",
"statsSubmitted": "Submitted",
"statsGraded": "Graded",
"statsPending": "Pending",
"statsAvgScore": "Avg",
"statsSubmissionRate": "Submission Rate",
"aiBatchGrade": "Batch Auto Grade",
"aiTriggered": "AI grading request triggered",
"aiSummary": "Processed {processed}/{total}, avg confidence {avg}",
"aiEmpty": "No AI suggestions yet, click the button to trigger",
"aiContractPending": "AI grading contract is @contract-pending, currently backed by MSW.",
"suggestedScore": "Suggested",
"confidence": "Confidence"
},
"error": {
"title": "Homework page error",
"unknown": "Unknown error",
"retry": "Retry"
}
},
"grades": {
"title": "Grades"

View File

@@ -302,7 +302,215 @@
}
},
"homework": {
"title": "作业管理"
"title": "作业管理",
"list": {
"title": "作业管理",
"description": "查看和管理所有作业",
"new": "布置作业",
"searchPlaceholder": "搜索作业名称...",
"statusFilter": "按状态筛选",
"statusAll": "全部状态",
"statusDraft": "草稿",
"statusPublished": "已发布",
"statusClosed": "已关闭",
"statusArchived": "已归档",
"total": "共 {count} 条",
"colName": "名称",
"colStatus": "状态",
"colDueDate": "截止时间",
"colGracePeriod": "宽限期",
"colActions": "操作",
"viewDetail": "查看详情 →",
"mswNotice": "列表查询契约待补齐,请确认 NEXT_PUBLIC_MSW=1 已启用。"
},
"detail": {
"title": "作业详情",
"edit": "编辑",
"notFound": "未找到作业,可能已被删除",
"createdAtPrefix": "创建于 {date}",
"sectionBasic": "基本信息",
"sectionSubmissions": "提交列表",
"sectionInlineGrade": "内联批改",
"fieldTitle": "标题",
"fieldDescription": "描述",
"fieldDueDate": "截止时间",
"fieldGracePeriod": "宽限期",
"fieldStatus": "状态",
"fieldClassId": "班级 ID",
"fieldSubjectId": "科目 ID",
"fieldCreatedBy": "创建人",
"fieldUpdatedAt": "更新时间",
"viewAllSubmissions": "全部提交",
"noSubmissions": "暂无提交",
"colStudent": "学生",
"colStatus": "状态",
"colSubmittedAt": "提交时间",
"colScore": "分数",
"colActions": "操作",
"grade": "去批改",
"gradeStudentId": "学生 ID",
"gradeScore": "分数",
"gradeFeedback": "反馈",
"gradeFeedbackPlaceholder": "给学生写下评语...",
"gradeSubmit": "提交批改",
"gradeSubmitting": "提交中...",
"gradeSuccess": "批改成功",
"gradeError": "批改失败",
"gradeErrorStudentRequired": "请填写学生 ID",
"gradeErrorScoreInvalid": "分数无效,请输入非负数字",
"gradeContractPending": "批改契约为 @contract-pending当前通过 MSW 兜底。"
},
"new": {
"title": "布置作业",
"description": "填写作业基本信息",
"submit": "布置作业",
"success": "作业布置成功",
"error": "布置失败",
"classId": "班级 ID",
"subjectId": "科目 ID",
"titleLabel": "标题",
"titlePlaceholder": "例如:集合与函数练习",
"descriptionLabel": "描述",
"descriptionPlaceholder": "作业范围、注意事项等",
"dueDate": "截止时间",
"gracePeriod": "宽限期(小时)",
"gracePeriodHint": "超过截止时间后的宽限小时数0 表示无宽限期",
"errorClassRequired": "请填写班级 ID",
"errorTitleRequired": "请填写作业标题",
"errorDateRequired": "请选择截止时间",
"contractPending": "布置作业契约为 @contract-pending当前通过 MSW 兜底。后端补齐 mutation 后将切换为真实提交。"
},
"submissions": {
"title": "提交批改",
"description": "查看所有作业的提交记录并批改",
"searchPlaceholder": "搜索学生姓名/学号/作业...",
"classIdFilter": "按班级筛选",
"classIdPlaceholder": "班级 ID",
"statusFilter": "按状态筛选",
"statusAll": "全部状态",
"statusSubmitted": "已提交",
"statusGrading": "批改中",
"statusGraded": "已批改",
"statusReturned": "已退回",
"statusLate": "迟交",
"total": "共 {count} 条",
"colStudent": "学生",
"colHomework": "作业",
"colClass": "班级",
"colStatus": "状态",
"colSubmittedAt": "提交时间",
"colScore": "分数",
"colActions": "操作",
"toGrade": "去批改",
"mswNotice": "提交查询契约待补齐,请确认 NEXT_PUBLIC_MSW=1 已启用。"
},
"grading": {
"title": "批改:{name}",
"titleLoading": "批改加载中...",
"subtitle": "作业:{homework} · 学号:{no}",
"notFound": "未找到提交,可能已被删除",
"sectionStudent": "学生信息",
"sectionAnswers": "题目作答",
"sectionNavigation": "翻页",
"fieldStudentName": "姓名",
"fieldStudentNo": "学号",
"fieldClass": "班级",
"fieldHomework": "作业",
"fieldStatus": "状态",
"fieldSubmittedAt": "提交时间",
"fieldFeedback": "当前反馈",
"questionType": "题型",
"maxScore": "满分",
"maxScoreTotal": "总分",
"studentAnswer": "学生作答",
"score": "分数",
"teacherComment": "教师评语",
"overallFeedback": "整体反馈",
"overallFeedbackPlaceholder": "给学生写下整体评语...",
"applyAi": "采用 AI 建议",
"aiSuggestionLabel": "AI 建议",
"aiSuggestionApplied": "已应用 AI 建议",
"prev": "上一份",
"next": "下一份",
"position": "第 {current}/{total} 份",
"submit": "提交批改",
"submitting": "提交中...",
"success": "批改成功",
"error": "批改失败",
"errorScoreInvalid": "题目 {qid} 的分数无效",
"contractPending": "批改契约为 @contract-pending当前通过 MSW 兜底。"
},
"scan": {
"title": "扫描批改:{name}",
"titleLoading": "扫描批改加载中...",
"subtitle": "作业:{homework}",
"notFound": "未找到提交,可能已被删除",
"scanPreview": "扫描预览",
"imagePlaceholder": "扫描图submissionId: {id}",
"imageContractPending": "扫描图片预览契约为 @contract-pending当前用占位符。",
"recognizedAnswers": "已识别答案",
"recognizedAnswer": "已识别答案",
"recognizedCorrect": "识别正确",
"recognizedIncorrect": "识别错误",
"questionType": "题型",
"aiSuggestionLabel": "AI 建议",
"noAnswers": "暂无作答数据",
"gradingForm": "批改表单",
"scoreLabel": "题目 {qid} 分数",
"totalScore": "当前总分",
"applyAllAi": "采用全部 AI 建议",
"aiAppliedCount": "已应用 {count} 条 AI 建议",
"confidence": "置信度",
"overallFeedback": "整体反馈",
"overallFeedbackPlaceholder": "给学生写下整体评语...",
"save": "保存批改",
"saving": "保存中...",
"saveSuccess": "扫描批改保存成功",
"saveError": "保存失败",
"errorScoreInvalid": "题目 {qid} 的分数无效",
"unitScore": "分",
"contractPending": "扫描批改契约为 @contract-pending当前通过 MSW 兜底。"
},
"assignment": {
"title": "按作业批量批改",
"description": "作业:{title}",
"descriptionLoading": "加载作业信息...",
"backToHomework": "返回作业详情",
"total": "共 {count} 份提交",
"mswNotice": "按作业提交查询契约待补齐,请确认 NEXT_PUBLIC_MSW=1 已启用。",
"sectionHomework": "作业信息",
"sectionSubmissions": "提交列表",
"sectionAiGrading": "AI 批量评分",
"homeworkTitle": "作业标题",
"homeworkId": "作业 ID",
"className": "班级",
"dueDate": "截止时间",
"colStudent": "学生",
"colStatus": "状态",
"colSubmittedAt": "提交时间",
"colScore": "分数",
"colActions": "操作",
"scanGrade": "扫描批改",
"detailGrade": "详情批改",
"statsTotalStudents": "应交人数",
"statsSubmitted": "已交人数",
"statsGraded": "已批改",
"statsPending": "待批改",
"statsAvgScore": "平均分",
"statsSubmissionRate": "提交率",
"aiBatchGrade": "批量自动评分",
"aiTriggered": "AI 评分请求已触发",
"aiSummary": "已处理 {processed}/{total} 份,平均置信度 {avg}",
"aiEmpty": "暂无 AI 评分建议,点击右侧按钮触发",
"aiContractPending": "AI 评分契约为 @contract-pending当前通过 MSW 兜底。",
"suggestedScore": "建议分数",
"confidence": "置信度"
},
"error": {
"title": "作业页面出错了",
"unknown": "未知错误",
"retry": "重试"
}
},
"grades": {
"title": "成绩查询"

View File

@@ -575,6 +575,287 @@ const mockExamRichEditor = {
],
};
// ── Homework 域(@contract-pendingschema 无 homeworks 列表/assignHomework mutation
// 用于 /shell/teacher/homework 列表页 + /new 表单页 MSW 兜底
// 关联ARCHITECTURE.md §9.1 / §11.4 契约工单
const mockHomeworkList = [
{
id: "hw-001",
classId: "cls-001",
subjectId: "sub-math",
title: "集合与函数练习",
description: "完成教材 P10-15 练习题",
dueDate: "2026-07-25T23:59:59Z",
gracePeriod: 24,
status: "PUBLISHED",
createdAt: "2026-07-20T08:00:00Z",
},
{
id: "hw-002",
classId: "cls-001",
subjectId: "sub-math",
title: "二次函数图像作业",
description: "绘制 y=ax²+bx+c 图像并分析性质",
dueDate: "2026-07-28T23:59:59Z",
gracePeriod: 12,
status: "PUBLISHED",
createdAt: "2026-07-22T08:00:00Z",
},
{
id: "hw-003",
classId: "cls-001",
subjectId: "sub-math",
title: "三角函数综合练习",
description: null,
dueDate: "2026-08-01T23:59:59Z",
gracePeriod: 24,
status: "DRAFT",
createdAt: "2026-07-22T10:00:00Z",
},
];
// ── Homework 单查 mock与 combined-schema Homework 类型字段对齐)
// 用于 /shell/teacher/homework/[id] 详情页真实查询可用MSW 也兜底)
const mockHomeworkDetail = {
id: "hw-001",
classId: "cls-001",
subjectId: "sub-math",
title: "集合与函数练习",
description: "完成教材 P10-15 练习题",
dueDate: "2026-07-25T23:59:59Z",
gracePeriod: 24,
status: "PUBLISHED",
schoolId: "sch-001",
createdBy: "usr-teacher-001",
createdAt: "2026-07-20T08:00:00Z",
updatedAt: "2026-07-20T08:00:00Z",
};
// ── Homework Submissions mock@contract-pending 全 MSW──
// 跨作业提交列表,用于 /shell/teacher/homework/submissions
const mockHomeworkSubmissions = [
{
id: "sub-001",
homeworkId: "hw-001",
homeworkTitle: "集合与函数练习",
studentId: "stu-001",
studentName: "张明",
studentNo: "2026001",
classId: "cls-001",
className: "高三(1)班",
status: "SUBMITTED",
submittedAt: "2026-07-24T20:30:00Z",
gradedAt: null,
gradedBy: null,
totalScore: null,
maxScore: 100,
},
{
id: "sub-002",
homeworkId: "hw-001",
homeworkTitle: "集合与函数练习",
studentId: "stu-002",
studentName: "李华",
studentNo: "2026002",
classId: "cls-001",
className: "高三(1)班",
status: "GRADED",
submittedAt: "2026-07-24T18:00:00Z",
gradedAt: "2026-07-25T09:00:00Z",
gradedBy: "usr-teacher-001",
totalScore: 92,
maxScore: 100,
},
{
id: "sub-003",
homeworkId: "hw-002",
homeworkTitle: "二次函数图像作业",
studentId: "stu-001",
studentName: "张明",
studentNo: "2026001",
classId: "cls-001",
className: "高三(1)班",
status: "GRADING",
submittedAt: "2026-07-27T22:00:00Z",
gradedAt: null,
gradedBy: null,
totalScore: null,
maxScore: 100,
},
{
id: "sub-004",
homeworkId: "hw-001",
homeworkTitle: "集合与函数练习",
studentId: "stu-003",
studentName: "王芳",
studentNo: "2026003",
classId: "cls-001",
className: "高三(1)班",
status: "RETURNED",
submittedAt: "2026-07-23T19:00:00Z",
gradedAt: "2026-07-25T10:00:00Z",
gradedBy: "usr-teacher-001",
totalScore: 85,
maxScore: 100,
},
];
// ── Submission Detail mock@contract-pending 全 MSW──
// 单份提交详情,用于 /shell/teacher/homework/submissions/[submissionId] 批改页
function buildMockSubmissionDetail(
submissionId: string,
): Record<string, unknown> {
const idx = mockHomeworkSubmissions.findIndex((s) => s.id === submissionId);
const submission =
idx >= 0 ? mockHomeworkSubmissions[idx] : mockHomeworkSubmissions[0];
if (!submission) {
return { submissionDetail: null };
}
const currentIndex = idx >= 0 ? idx : 0;
const prevId =
currentIndex > 0
? (mockHomeworkSubmissions[currentIndex - 1]?.id ?? null)
: null;
const nextId =
currentIndex < mockHomeworkSubmissions.length - 1
? (mockHomeworkSubmissions[currentIndex + 1]?.id ?? null)
: null;
return {
submissionDetail: {
submission: {
...submission,
feedback:
submission.status === "GRADED" || submission.status === "RETURNED"
? "整体掌握良好,注意第 3 题定义域求解。"
: null,
},
answers: [
{
id: `ans-${submission.id}-q1`,
submissionId: submission.id,
questionId: "q-001",
questionTitle: "已知集合 A={1,2,3},求 A 的子集个数",
questionType: "single_choice",
maxScore: 20,
answer: "8 个",
score:
submission.status === "GRADED" || submission.status === "RETURNED"
? 20
: null,
teacherComment:
submission.status === "GRADED" || submission.status === "RETURNED"
? "正确"
: null,
isCorrect:
submission.status === "GRADED" || submission.status === "RETURNED"
? true
: null,
aiSuggestion: "答案正确,可直接给满分。",
},
{
id: `ans-${submission.id}-q2`,
submissionId: submission.id,
questionId: "q-002",
questionTitle: "函数 f(x)=x²+2x+1 的最小值",
questionType: "fill_blank",
maxScore: 30,
answer: "0",
score:
submission.status === "GRADED" || submission.status === "RETURNED"
? 30
: null,
teacherComment:
submission.status === "GRADED" || submission.status === "RETURNED"
? "正确,配方后 (x+1)²≥0"
: null,
isCorrect:
submission.status === "GRADED" || submission.status === "RETURNED"
? true
: null,
aiSuggestion: "答案正确,配方过程完整。",
},
{
id: `ans-${submission.id}-q3`,
submissionId: submission.id,
questionId: "q-003",
questionTitle: "求函数 f(x)=√(x-1) 的定义域",
questionType: "essay",
maxScore: 50,
answer: "x≥1",
score:
submission.status === "GRADED" || submission.status === "RETURNED"
? 42
: null,
teacherComment:
submission.status === "GRADED" || submission.status === "RETURNED"
? "结论正确,过程略显简略,建议写明 x-1≥0 的推导。"
: null,
isCorrect:
submission.status === "GRADED" || submission.status === "RETURNED"
? true
: null,
aiSuggestion: "结论正确但推导步骤较少,建议给 42/50。",
},
],
navigation: {
prevId,
nextId,
currentIndex,
totalCount: mockHomeworkSubmissions.length,
},
},
};
}
// ── Assignment Submissions mock@contract-pending 全 MSW──
// 按作业拉所有提交,用于 /shell/teacher/homework/assignments/[id]/submissions
const mockAssignmentSubmissions = {
homework: {
id: "hw-001",
title: "集合与函数练习",
classId: "cls-001",
className: "高三(1)班",
dueDate: "2026-07-25T23:59:59Z",
maxScore: 100,
},
stats: {
totalStudents: 38,
submittedCount: 30,
gradedCount: 18,
pendingCount: 12,
avgScore: 82.5,
submissionRate: 0.789,
},
submissions: mockHomeworkSubmissions.filter((s) => s.homeworkId === "hw-001"),
};
// ── AI Batch Grading mock@contract-pending 全 MSW──
// AI 批量评分建议,用于批量批改页
const mockAiBatchGrading = {
homeworkId: "hw-001",
suggestions: [
{
submissionId: "sub-001",
studentName: "张明",
suggestedScore: 88,
confidence: 0.92,
reasoning: "答案整体正确,第 3 题推导略有瑕疵,建议 88/100。",
},
{
submissionId: "sub-004",
studentName: "王芳",
suggestedScore: 85,
confidence: 0.88,
reasoning: "答案正确,第 3 题推导简略,建议 85/100。",
},
],
summary: {
totalSubmissions: 30,
processed: 30,
avgConfidence: 0.9,
},
};
// ── GraphQL Response ───────────────────────────────────────────
/**
@@ -854,6 +1135,119 @@ export function graphqlResponse(
return { data: { saveExamRichContent: { examId } } };
}
// ── Homework 域(教师域 P2 迁移,@contract-pending──
// GetHomework($id):按 id 单查(真实 schema 可用MSW 也兜底)
case "GetHomework": {
const hwId = (variables?.id as string | undefined) ?? "";
const found =
mockHomeworkList.find((h) => h.id === hwId) ?? mockHomeworkDetail;
return {
data: {
homework: {
...found,
schoolId: "sch-001",
createdBy: "usr-teacher-001",
updatedAt: "2026-07-20T08:00:00Z",
},
},
};
}
// GetHomeworkList($classId):列表查询,按 classId 过滤
case "GetHomeworkList": {
const classId = variables?.classId as string | undefined;
const status = variables?.status as string | undefined;
const filtered = mockHomeworkList.filter(
(h) =>
(!classId || h.classId === classId) &&
(!status || h.status === status),
);
return {
data: {
homeworks: {
items: filtered,
total: filtered.length,
},
},
};
}
// AssignHomework($input):布置作业 mutation 兜底
case "AssignHomework": {
const input = (variables?.input ?? {}) as Record<string, unknown>;
const newId = `hw-${Date.now()}`;
mockHomeworkList.push({
id: newId,
classId: (input.classId as string) ?? "cls-001",
subjectId: (input.subjectId as string) ?? "sub-math",
title: (input.title as string) ?? "未命名作业",
description: (input.description as string | null) ?? null,
dueDate: (input.dueDate as string) ?? new Date().toISOString(),
gracePeriod: (input.gracePeriod as number) ?? 24,
status: "DRAFT",
createdAt: new Date().toISOString(),
});
return { data: { assignHomework: { id: newId } } };
}
// ── Homework Submissions@contract-pending 全 MSW──
// 跨作业提交列表,支持 classId/homeworkId/status 筛选
case "GetHomeworkSubmissions": {
const classId = variables?.classId as string | undefined;
const homeworkId = variables?.homeworkId as string | undefined;
const status = variables?.status as string | undefined;
const filtered = mockHomeworkSubmissions.filter(
(s) =>
(!classId || s.classId === classId) &&
(!homeworkId || s.homeworkId === homeworkId) &&
(!status || s.status === status),
);
return {
data: {
homeworkSubmissions: {
items: filtered,
total: filtered.length,
},
},
};
}
// ── Submission Detail@contract-pending 全 MSW──
// 单份提交详情,含题目作答 + 导航
case "GetSubmissionDetail": {
const submissionId =
(variables?.submissionId as string | undefined) ?? "";
return { data: buildMockSubmissionDetail(submissionId) };
}
// ── GradeSubmission@contract-pending mutation──
case "GradeSubmission": {
const input = (variables?.input ?? {}) as Record<string, unknown>;
const submissionId = (input.submissionId as string) ?? "sub-001";
return { data: { gradeSubmission: { submissionId } } };
}
// ── SaveScanGrading@contract-pending mutation──
case "SaveScanGrading": {
const input = (variables?.input ?? {}) as Record<string, unknown>;
const submissionId = (input.submissionId as string) ?? "sub-001";
return { data: { saveScanGrading: { submissionId } } };
}
// ── AiBatchGrading@contract-pending 全 MSW──
case "GetAiBatchGrading":
return { data: { aiBatchGrading: mockAiBatchGrading } };
// ── AssignmentSubmissions@contract-pending 全 MSW──
case "GetAssignmentSubmissions":
return { data: { assignmentSubmissions: mockAssignmentSubmissions } };
// ── RecordGrade@contract-pending mutation──
case "RecordGrade": {
const input = (variables?.input ?? {}) as Record<string, unknown>;
const gradeId = `grade-${Date.now()}`;
void input;
return { data: { recordGrade: { gradeId } } };
}
// ── Grades 域(预留) ──
case "GetGrades":
return { data: { grades: mockGrades } };

View File

@@ -18,7 +18,7 @@ interface ActionItem {
const ACTIONS_BY_ROLE: Record<Role, ActionItem[]> = {
teacher: [
{ label: "布置作业", path: "/homework/new" },
{ label: "布置作业", path: "/shell/teacher/homework/new" },
{ label: "创建考试", path: "/exam/new" },
{ label: "查看课表", path: "/schedule" },
],

View File

@@ -17,7 +17,7 @@ const TYPE_PATH_MAP: Record<string, string> = {
student: "/students",
class: "/classes",
exam: "/exams",
homework: "/homework",
homework: "/shell/teacher/homework",
announcement: "/announcements",
};