feat(portal-shell): homework 模块 7 页迁移(教师域 §9.1 B2)
§9.1 教师域 homework 模块完整迁移(继 exams 之后第二个 B2 模块): 7 页路由结构(与旧 teacher-portal 同构): - /shell/teacher/homework:列表页(?classId/status/q 筛选) - /shell/teacher/homework/new:布置作业表单页 - /shell/teacher/homework/[id]:详情 + 内联批改(含提交列表 + recordGrade 表单) - /shell/teacher/homework/submissions:跨作业提交评审列表 - /shell/teacher/homework/submissions/[submissionId]:单份提交批改 + AI 建议 + 上下份导航 - /shell/teacher/homework/submissions/[submissionId]/scan-grading:扫描批改工作台(三栏) - /shell/teacher/homework/assignments/[id]/submissions:按作业批量批改 + 统计 + AI 批量评分 数据契约(混合): - ✅ homework(id: ID!) 真实查询(schema 已就绪,详情页用) - ❌ 列表/mutation/submissions/grading/aiBatchGrading 全部 @contract-pending MSW 兜底 · 9 个 hook 走 MSW,待后端补齐 mutation 后切换真实 fetcher §11.3 DoD 11 项验收: 1. route-permissions:EXACT + PREFIX 表 /shell/teacher/homework 已配置 2. 页面模板:list/new 用 ListPageShell/FormPageShell;detail/grading 用 DetailPageShell; scan-grading 用 WorkbenchPageShell(三栏,未使用 emptyNode) 3. 三态:loading(Skeleton)/error(errorNode 或 errorSummary)/empty(emptyNode) 全实现 4. lib/api hooks:homework.ts 10 个 hooks(useHomework 真实 + 9 个 MSW) 5. @contract-pending MSW:graphql-data.ts 扩展 6 块 mock + 10 个 switch case 6. i18n:homework 节点扩展 8 个分区共 130+ keys(list/detail/new/submissions/grading/ scan/assignment/error)中英对齐 7. lint:0 errors(4 warnings 在 __generated__) 8. lint:tokens:0 errors 9. notify:mutation 反馈走 @/shared/lib/notify(非 sonner 直引) 10. vitest:transformations 纯函数单测齐全,全量 323/323 通过(新增 ~50 测试) 11. typecheck:0 errors(noUncheckedIndexedAccess 安全访问) 附带修复: - 修复 2 处遗留 broken link: · widgets/sidebar/quick-actions: /homework/new → /shell/teacher/homework/new · widgets/topbar/global-search: /homework → /shell/teacher/homework - scripts/check-page-count.ts baseline 同步 13 → 26(与 exams 6 + homework 7 一致) 剩余模块:grades(5)+lesson-plans(6)+questions(1)+textbooks(2)+attendance(4)+classes(3)+ students(1)+course-plans(2)+elective(3)+error-book(1)+diagnostic(2)+analytics(2)+ai-*(3)+ knowledge-graph(1)+practice(1)+schedule-changes(1)+leave(1) 共 39 页。
This commit is contained in:
@@ -0,0 +1,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 边界包裹。
|
||||
* 业务逻辑在 HomeworkDetailClient(client 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>
|
||||
);
|
||||
}
|
||||
@@ -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 边界包裹。
|
||||
* 业务逻辑在 AssignmentSubmissionsClient(client 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>
|
||||
);
|
||||
}
|
||||
38
apps/portal-shell/src/app/shell/teacher/homework/error.tsx
Normal file
38
apps/portal-shell/src/app/shell/teacher/homework/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
apps/portal-shell/src/app/shell/teacher/homework/loading.tsx
Normal file
12
apps/portal-shell/src/app/shell/teacher/homework/loading.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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 要求)。
|
||||
* 业务逻辑在 NewHomeworkClient(client 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>
|
||||
);
|
||||
}
|
||||
23
apps/portal-shell/src/app/shell/teacher/homework/page.tsx
Normal file
23
apps/portal-shell/src/app/shell/teacher/homework/page.tsx
Normal 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 要求)。
|
||||
* 业务逻辑在 HomeworkListClient(client 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>
|
||||
);
|
||||
}
|
||||
@@ -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 边界包裹。
|
||||
* 业务逻辑在 SubmissionGradingClient(client 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>
|
||||
);
|
||||
}
|
||||
@@ -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 边界包裹。
|
||||
* 业务逻辑在 ScanGradingClient(client 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>
|
||||
);
|
||||
}
|
||||
@@ -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 要求)。
|
||||
* 业务逻辑在 SubmissionsListClient(client 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Homework 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Homework } from "@/lib/api";
|
||||
|
||||
import {
|
||||
HOMEWORK_STATUS_LABEL,
|
||||
SUBMISSION_STATUS_LABEL,
|
||||
calcSubmissionRate,
|
||||
confidenceToColorClass,
|
||||
formatConfidence,
|
||||
formatDueDate,
|
||||
formatGradeLevel,
|
||||
formatGracePeriod,
|
||||
formatHomeworkStatus,
|
||||
formatScore,
|
||||
formatSubmissionRate,
|
||||
formatSubmissionStatus,
|
||||
gradeLevelToColorClass,
|
||||
homeworkStatusToBadgeClass,
|
||||
isGraded,
|
||||
isHomeworkEditable,
|
||||
isHomeworkPublished,
|
||||
isOverdue,
|
||||
isPendingGrading,
|
||||
submissionStatusToBadgeClass,
|
||||
toHomeworkListItem,
|
||||
} from "../transformations";
|
||||
|
||||
describe("formatHomeworkStatus", () => {
|
||||
it("maps known statuses to Chinese labels", () => {
|
||||
expect(formatHomeworkStatus("DRAFT")).toBe("草稿");
|
||||
expect(formatHomeworkStatus("PUBLISHED")).toBe("已发布");
|
||||
expect(formatHomeworkStatus("CLOSED")).toBe("已关闭");
|
||||
expect(formatHomeworkStatus("ARCHIVED")).toBe("已归档");
|
||||
});
|
||||
|
||||
it("returns original value for unknown status", () => {
|
||||
expect(formatHomeworkStatus("UNKNOWN")).toBe("UNKNOWN");
|
||||
expect(formatHomeworkStatus("")).toBe("");
|
||||
});
|
||||
|
||||
it("HOMEWORK_STATUS_LABEL covers all standard statuses", () => {
|
||||
expect(Object.keys(HOMEWORK_STATUS_LABEL)).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSubmissionStatus", () => {
|
||||
it("maps known statuses to Chinese labels", () => {
|
||||
expect(formatSubmissionStatus("SUBMITTED")).toBe("已提交");
|
||||
expect(formatSubmissionStatus("GRADING")).toBe("批改中");
|
||||
expect(formatSubmissionStatus("GRADED")).toBe("已批改");
|
||||
expect(formatSubmissionStatus("RETURNED")).toBe("已退回");
|
||||
expect(formatSubmissionStatus("LATE")).toBe("迟交");
|
||||
});
|
||||
|
||||
it("returns original value for unknown status", () => {
|
||||
expect(formatSubmissionStatus("UNKNOWN")).toBe("UNKNOWN");
|
||||
});
|
||||
|
||||
it("SUBMISSION_STATUS_LABEL covers all standard statuses", () => {
|
||||
expect(Object.keys(SUBMISSION_STATUS_LABEL)).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDueDate", () => {
|
||||
it("formats valid ISO date string", () => {
|
||||
const result = formatDueDate("2026-07-25T23:59:59Z");
|
||||
expect(result).toContain("2026");
|
||||
expect(result).toContain("07");
|
||||
});
|
||||
|
||||
it("returns placeholder for null/undefined/empty", () => {
|
||||
expect(formatDueDate(null)).toBe("--");
|
||||
expect(formatDueDate(undefined)).toBe("--");
|
||||
expect(formatDueDate("")).toBe("--");
|
||||
});
|
||||
|
||||
it("returns placeholder for invalid date", () => {
|
||||
expect(formatDueDate("not-a-date")).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatGracePeriod", () => {
|
||||
it("formats hours under 24", () => {
|
||||
expect(formatGracePeriod(12)).toBe("12 小时");
|
||||
expect(formatGracePeriod(1)).toBe("1 小时");
|
||||
});
|
||||
|
||||
it("formats exact days", () => {
|
||||
expect(formatGracePeriod(24)).toBe("1 天");
|
||||
expect(formatGracePeriod(48)).toBe("2 天");
|
||||
});
|
||||
|
||||
it("formats days with remainder hours", () => {
|
||||
expect(formatGracePeriod(36)).toBe("1 天 12 小时");
|
||||
expect(formatGracePeriod(25)).toBe("1 天 1 小时");
|
||||
});
|
||||
|
||||
it("returns placeholder for invalid or zero input", () => {
|
||||
expect(formatGracePeriod(0)).toBe("无宽限期");
|
||||
expect(formatGracePeriod(-1)).toBe("无宽限期");
|
||||
expect(formatGracePeriod(Number.NaN)).toBe("无宽限期");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOverdue", () => {
|
||||
it("returns true when dueDate is in the past", () => {
|
||||
const past = "2020-01-01T00:00:00Z";
|
||||
expect(isOverdue(past, new Date("2026-07-22T00:00:00Z"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when dueDate is in the future", () => {
|
||||
const future = "2030-01-01T00:00:00Z";
|
||||
expect(isOverdue(future, new Date("2026-07-22T00:00:00Z"))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null/undefined/invalid date", () => {
|
||||
expect(isOverdue(null)).toBe(false);
|
||||
expect(isOverdue(undefined)).toBe(false);
|
||||
expect(isOverdue("not-a-date")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when dueDate equals now (not strictly past)", () => {
|
||||
const now = new Date("2026-07-22T00:00:00Z");
|
||||
expect(isOverdue("2026-07-22T00:00:00Z", now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isHomeworkEditable / isHomeworkPublished", () => {
|
||||
it("DRAFT is editable but not published", () => {
|
||||
expect(isHomeworkEditable("DRAFT")).toBe(true);
|
||||
expect(isHomeworkPublished("DRAFT")).toBe(false);
|
||||
});
|
||||
|
||||
it("PUBLISHED is published but not editable", () => {
|
||||
expect(isHomeworkEditable("PUBLISHED")).toBe(false);
|
||||
expect(isHomeworkPublished("PUBLISHED")).toBe(true);
|
||||
});
|
||||
|
||||
it("unknown status is neither editable nor published", () => {
|
||||
expect(isHomeworkEditable("UNKNOWN")).toBe(false);
|
||||
expect(isHomeworkPublished("UNKNOWN")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toHomeworkListItem", () => {
|
||||
it("extracts list fields from full homework", () => {
|
||||
const hw: Homework = {
|
||||
id: "hw-001",
|
||||
classId: "cls-001",
|
||||
subjectId: "sub-math",
|
||||
title: "集合练习",
|
||||
description: "完成教材练习",
|
||||
dueDate: "2026-07-25T23:59:59Z",
|
||||
gracePeriod: 24,
|
||||
status: "PUBLISHED",
|
||||
schoolId: "sch-001",
|
||||
createdBy: "usr-001",
|
||||
createdAt: "2026-07-20T00:00:00Z",
|
||||
updatedAt: "2026-07-20T00:00:00Z",
|
||||
};
|
||||
|
||||
const item = toHomeworkListItem(hw);
|
||||
expect(item.id).toBe("hw-001");
|
||||
expect(item.title).toBe("集合练习");
|
||||
expect(item.status).toBe("PUBLISHED");
|
||||
expect(item).not.toHaveProperty("schoolId");
|
||||
expect(item).not.toHaveProperty("createdBy");
|
||||
expect(item).not.toHaveProperty("updatedAt");
|
||||
});
|
||||
|
||||
it("handles null description", () => {
|
||||
const hw: Homework = {
|
||||
id: "hw-002",
|
||||
classId: "cls-001",
|
||||
subjectId: "sub-math",
|
||||
title: "无描述作业",
|
||||
description: null,
|
||||
dueDate: "2026-07-25T23:59:59Z",
|
||||
gracePeriod: 12,
|
||||
status: "DRAFT",
|
||||
schoolId: "sch-001",
|
||||
createdBy: "usr-001",
|
||||
createdAt: "2026-07-20T00:00:00Z",
|
||||
updatedAt: "2026-07-20T00:00:00Z",
|
||||
};
|
||||
const item = toHomeworkListItem(hw);
|
||||
expect(item.description).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGraded / isPendingGrading", () => {
|
||||
it("GRADED and RETURNED are graded", () => {
|
||||
expect(isGraded("GRADED")).toBe(true);
|
||||
expect(isGraded("RETURNED")).toBe(true);
|
||||
});
|
||||
|
||||
it("SUBMITTED and LATE are pending grading", () => {
|
||||
expect(isPendingGrading("SUBMITTED")).toBe(true);
|
||||
expect(isPendingGrading("LATE")).toBe(true);
|
||||
});
|
||||
|
||||
it("GRADED is not pending", () => {
|
||||
expect(isPendingGrading("GRADED")).toBe(false);
|
||||
});
|
||||
|
||||
it("GRADING is neither graded nor pending", () => {
|
||||
expect(isGraded("GRADING")).toBe(false);
|
||||
expect(isPendingGrading("GRADING")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatScore", () => {
|
||||
it("formats finite numbers with 1 decimal place", () => {
|
||||
expect(formatScore(82.5)).toBe("82.5");
|
||||
expect(formatScore(98)).toBe("98.0");
|
||||
expect(formatScore(0)).toBe("0.0");
|
||||
});
|
||||
|
||||
it("returns placeholder for null/undefined", () => {
|
||||
expect(formatScore(null)).toBe("--");
|
||||
expect(formatScore(undefined)).toBe("--");
|
||||
});
|
||||
|
||||
it("returns placeholder for non-finite input", () => {
|
||||
expect(formatScore(Number.NaN)).toBe("--");
|
||||
expect(formatScore(Number.POSITIVE_INFINITY)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSubmissionRate", () => {
|
||||
it("formats rate in [0,1] as percentage", () => {
|
||||
expect(formatSubmissionRate(0.789)).toBe("79%");
|
||||
expect(formatSubmissionRate(0)).toBe("0%");
|
||||
expect(formatSubmissionRate(1)).toBe("100%");
|
||||
});
|
||||
|
||||
it("returns placeholder for out-of-range or non-finite input", () => {
|
||||
expect(formatSubmissionRate(-0.1)).toBe("--");
|
||||
expect(formatSubmissionRate(1.1)).toBe("--");
|
||||
expect(formatSubmissionRate(Number.NaN)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("calcSubmissionRate", () => {
|
||||
it("calculates rate correctly", () => {
|
||||
expect(calcSubmissionRate(30, 38)).toBeCloseTo(0.789, 2);
|
||||
expect(calcSubmissionRate(0, 38)).toBe(0);
|
||||
expect(calcSubmissionRate(38, 38)).toBe(1);
|
||||
});
|
||||
|
||||
it("caps at 1 when submitted > total", () => {
|
||||
expect(calcSubmissionRate(40, 38)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 0 for zero or invalid total", () => {
|
||||
expect(calcSubmissionRate(10, 0)).toBe(0);
|
||||
expect(calcSubmissionRate(10, -1)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for negative submitted count", () => {
|
||||
expect(calcSubmissionRate(-5, 38)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatGradeLevel", () => {
|
||||
it("maps score rate to A/B/C/D levels", () => {
|
||||
expect(formatGradeLevel(90, 100)).toBe("A");
|
||||
expect(formatGradeLevel(85, 100)).toBe("A");
|
||||
expect(formatGradeLevel(75, 100)).toBe("B");
|
||||
expect(formatGradeLevel(70, 100)).toBe("B");
|
||||
expect(formatGradeLevel(65, 100)).toBe("C");
|
||||
expect(formatGradeLevel(60, 100)).toBe("C");
|
||||
expect(formatGradeLevel(50, 100)).toBe("D");
|
||||
expect(formatGradeLevel(0, 100)).toBe("D");
|
||||
});
|
||||
|
||||
it("returns placeholder for null/undefined score", () => {
|
||||
expect(formatGradeLevel(null, 100)).toBe("--");
|
||||
expect(formatGradeLevel(undefined, 100)).toBe("--");
|
||||
});
|
||||
|
||||
it("returns placeholder for zero or invalid maxScore", () => {
|
||||
expect(formatGradeLevel(80, 0)).toBe("--");
|
||||
expect(formatGradeLevel(80, -1)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("gradeLevelToColorClass", () => {
|
||||
it("maps each level to correct color class", () => {
|
||||
expect(gradeLevelToColorClass("A")).toBe("text-emerald-600");
|
||||
expect(gradeLevelToColorClass("B")).toBe("text-blue-600");
|
||||
expect(gradeLevelToColorClass("C")).toBe("text-amber-600");
|
||||
expect(gradeLevelToColorClass("D")).toBe("text-destructive");
|
||||
});
|
||||
|
||||
it("returns muted for unknown level", () => {
|
||||
expect(gradeLevelToColorClass("X")).toBe("text-muted-foreground");
|
||||
expect(gradeLevelToColorClass("")).toBe("text-muted-foreground");
|
||||
});
|
||||
});
|
||||
|
||||
describe("submissionStatusToBadgeClass", () => {
|
||||
it("returns correct badge class for each status", () => {
|
||||
expect(submissionStatusToBadgeClass("SUBMITTED")).toContain("amber");
|
||||
expect(submissionStatusToBadgeClass("LATE")).toContain("amber");
|
||||
expect(submissionStatusToBadgeClass("GRADING")).toContain("primary");
|
||||
expect(submissionStatusToBadgeClass("GRADED")).toContain("emerald");
|
||||
expect(submissionStatusToBadgeClass("RETURNED")).toContain("blue");
|
||||
});
|
||||
|
||||
it("returns muted for unknown status", () => {
|
||||
expect(submissionStatusToBadgeClass("UNKNOWN")).toBe(
|
||||
"bg-muted text-muted-foreground",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("homeworkStatusToBadgeClass", () => {
|
||||
it("returns correct badge class for each status", () => {
|
||||
expect(homeworkStatusToBadgeClass("DRAFT")).toBe(
|
||||
"bg-muted text-muted-foreground",
|
||||
);
|
||||
expect(homeworkStatusToBadgeClass("PUBLISHED")).toContain("primary");
|
||||
expect(homeworkStatusToBadgeClass("CLOSED")).toContain("amber");
|
||||
});
|
||||
|
||||
it("returns muted for unknown status", () => {
|
||||
expect(homeworkStatusToBadgeClass("UNKNOWN")).toBe(
|
||||
"bg-muted text-muted-foreground",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("confidenceToColorClass", () => {
|
||||
it("returns emerald for high confidence", () => {
|
||||
expect(confidenceToColorClass(0.8)).toBe("text-emerald-600");
|
||||
expect(confidenceToColorClass(0.95)).toBe("text-emerald-600");
|
||||
});
|
||||
|
||||
it("returns amber for medium confidence", () => {
|
||||
expect(confidenceToColorClass(0.6)).toBe("text-amber-600");
|
||||
expect(confidenceToColorClass(0.79)).toBe("text-amber-600");
|
||||
});
|
||||
|
||||
it("returns destructive for low confidence", () => {
|
||||
expect(confidenceToColorClass(0.59)).toBe("text-destructive");
|
||||
expect(confidenceToColorClass(0)).toBe("text-destructive");
|
||||
});
|
||||
|
||||
it("returns muted for non-finite input", () => {
|
||||
expect(confidenceToColorClass(Number.NaN)).toBe("text-muted-foreground");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatConfidence", () => {
|
||||
it("formats confidence in [0,1] as percentage", () => {
|
||||
expect(formatConfidence(0.92)).toBe("92%");
|
||||
expect(formatConfidence(0)).toBe("0%");
|
||||
expect(formatConfidence(1)).toBe("100%");
|
||||
});
|
||||
|
||||
it("returns placeholder for out-of-range or non-finite input", () => {
|
||||
expect(formatConfidence(-0.1)).toBe("--");
|
||||
expect(formatConfidence(1.1)).toBe("--");
|
||||
expect(formatConfidence(Number.NaN)).toBe("--");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,437 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 按作业批量批改页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 查询 assignmentSubmissions(homeworkId):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 查询 aiBatchGrading(homeworkId):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { Layers } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import {
|
||||
useAssignmentSubmissions,
|
||||
useAiBatchGrading,
|
||||
type AiGradingSuggestion,
|
||||
} from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
import {
|
||||
calcSubmissionRate,
|
||||
confidenceToColorClass,
|
||||
formatConfidence,
|
||||
formatDueDate,
|
||||
formatScore,
|
||||
formatSubmissionRate,
|
||||
formatSubmissionStatus,
|
||||
submissionStatusToBadgeClass,
|
||||
} from "@/features/teacher/homework/transformations";
|
||||
|
||||
/**
|
||||
* 批量批改客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function AssignmentSubmissionsClient(): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const tCommon = useTranslations("common");
|
||||
const params = useParams<{ id: string }>();
|
||||
const homeworkId = params?.id ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useAssignmentSubmissions(homeworkId);
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("assignment.mswNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const homeworkTitle = data?.homework.title ?? "";
|
||||
const stats = data?.stats;
|
||||
const submissions = data?.submissions ?? [];
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
title={t("assignment.title")}
|
||||
description={
|
||||
homeworkTitle
|
||||
? t("assignment.description", { title: homeworkTitle })
|
||||
: t("assignment.descriptionLoading")
|
||||
}
|
||||
icon={<Layers className="size-6" />}
|
||||
actions={
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/shell/teacher/homework/${homeworkId}`}>
|
||||
{t("assignment.backToHomework")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<ListPageSkeleton rows={6} />}
|
||||
empty={!loading && !error && submissions.length === 0}
|
||||
errorNode={errorNode}
|
||||
pagination={
|
||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("assignment.total", { count: submissions.length })}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{data ? (
|
||||
<AssignmentSubmissionsBody
|
||||
homeworkId={homeworkId}
|
||||
homeworkTitle={homeworkTitle}
|
||||
dueDate={data.homework.dueDate}
|
||||
className={data.homework.className}
|
||||
stats={stats}
|
||||
submissions={submissions}
|
||||
/>
|
||||
) : null}
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 主体:统计卡片 + AI 建议区 + 提交表格。
|
||||
*/
|
||||
function AssignmentSubmissionsBody({
|
||||
homeworkId,
|
||||
homeworkTitle,
|
||||
dueDate,
|
||||
className,
|
||||
stats,
|
||||
submissions,
|
||||
}: {
|
||||
homeworkId: string;
|
||||
homeworkTitle: string;
|
||||
dueDate: string;
|
||||
className: string;
|
||||
stats:
|
||||
| {
|
||||
totalStudents: number;
|
||||
submittedCount: number;
|
||||
gradedCount: number;
|
||||
pendingCount: number;
|
||||
avgScore: number;
|
||||
submissionRate: number;
|
||||
}
|
||||
| undefined;
|
||||
submissions: ReadonlyArray<{
|
||||
id: string;
|
||||
homeworkId: string;
|
||||
homeworkTitle: string;
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
classId: string;
|
||||
className: string;
|
||||
status: string;
|
||||
submittedAt: string | null;
|
||||
gradedAt: string | null;
|
||||
gradedBy: string | null;
|
||||
totalScore: number | null;
|
||||
maxScore: number;
|
||||
}>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 统计卡片区 */}
|
||||
{stats ? <StatsCard stats={stats} /> : null}
|
||||
|
||||
{/* 作业信息 */}
|
||||
<section className="rounded-xl border bg-card p-4">
|
||||
<h2 className="mb-2 text-sm font-semibold">
|
||||
{t("assignment.sectionHomework")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
|
||||
<InfoItem
|
||||
label={t("assignment.homeworkTitle")}
|
||||
value={homeworkTitle}
|
||||
/>
|
||||
<InfoItem label={t("assignment.homeworkId")} value={homeworkId} />
|
||||
<InfoItem label={t("assignment.className")} value={className} />
|
||||
<InfoItem
|
||||
label={t("assignment.dueDate")}
|
||||
value={formatDueDate(dueDate)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* AI 批量评分区 */}
|
||||
<AiBatchGradingSection homeworkId={homeworkId} />
|
||||
|
||||
{/* 提交列表 */}
|
||||
<section className="rounded-xl border">
|
||||
<header className="border-b bg-muted/30 p-3">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("assignment.sectionSubmissions")}
|
||||
</h2>
|
||||
</header>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("assignment.colStudent")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("assignment.colStatus")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("assignment.colSubmittedAt")}
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
{t("assignment.colScore")}
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
{t("assignment.colActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{submissions.map((s) => (
|
||||
<tr key={s.id} className="hover:bg-muted/30">
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/shell/teacher/homework/submissions/${s.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{s.studentName}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{s.studentNo}
|
||||
</p>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${submissionStatusToBadgeClass(s.status)}`}
|
||||
>
|
||||
{formatSubmissionStatus(s.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs">
|
||||
{formatDueDate(s.submittedAt)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{formatScore(s.totalScore)} / {s.maxScore}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Link
|
||||
href={`/shell/teacher/homework/submissions/${s.id}/scan-grading`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("assignment.scanGrade")}
|
||||
</Link>
|
||||
<span className="mx-1 text-muted-foreground/40">·</span>
|
||||
<Link
|
||||
href={`/shell/teacher/homework/submissions/${s.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("assignment.detailGrade")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计卡片(4 个指标 + 提交率)。
|
||||
*/
|
||||
function StatsCard({
|
||||
stats,
|
||||
}: {
|
||||
stats: {
|
||||
totalStudents: number;
|
||||
submittedCount: number;
|
||||
gradedCount: number;
|
||||
pendingCount: number;
|
||||
avgScore: number;
|
||||
submissionRate: number;
|
||||
};
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const items = [
|
||||
{
|
||||
label: t("assignment.statsTotalStudents"),
|
||||
value: String(stats.totalStudents),
|
||||
},
|
||||
{
|
||||
label: t("assignment.statsSubmitted"),
|
||||
value: String(stats.submittedCount),
|
||||
},
|
||||
{
|
||||
label: t("assignment.statsGraded"),
|
||||
value: String(stats.gradedCount),
|
||||
},
|
||||
{
|
||||
label: t("assignment.statsPending"),
|
||||
value: String(stats.pendingCount),
|
||||
},
|
||||
{
|
||||
label: t("assignment.statsAvgScore"),
|
||||
value: formatScore(stats.avgScore),
|
||||
},
|
||||
{
|
||||
label: t("assignment.statsSubmissionRate"),
|
||||
value: formatSubmissionRate(
|
||||
calcSubmissionRate(stats.submittedCount, stats.totalStudents),
|
||||
),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{items.map((it) => (
|
||||
<div key={it.label} className="rounded-lg border bg-card p-3">
|
||||
<p className="text-xs text-muted-foreground">{it.label}</p>
|
||||
<p className="mt-1 text-lg font-semibold">{it.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 批量评分区(点击按钮触发拉取建议 + 显示建议列表)。
|
||||
*/
|
||||
function AiBatchGradingSection({
|
||||
homeworkId,
|
||||
}: {
|
||||
homeworkId: string;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const tCommon = useTranslations("common");
|
||||
const { data, loading, error, refetch } = useAiBatchGrading(homeworkId, {
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const handleTrigger = (): void => {
|
||||
void refetch();
|
||||
notify.info(t("assignment.aiTriggered"));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="rounded-xl border bg-card p-4">
|
||||
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="rounded-xl border bg-card p-4">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const suggestions = data?.suggestions ?? [];
|
||||
const summary = data?.summary;
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border bg-card p-4">
|
||||
<header className="mb-3 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("assignment.sectionAiGrading")}
|
||||
</h2>
|
||||
{summary ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("assignment.aiSummary", {
|
||||
processed: summary.processed,
|
||||
total: summary.totalSubmissions,
|
||||
avg: formatConfidence(summary.avgConfidence),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTrigger}
|
||||
>
|
||||
{t("assignment.aiBatchGrade")}
|
||||
</Button>
|
||||
</header>
|
||||
{suggestions.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{suggestions.map((s) => (
|
||||
<AiSuggestionRow key={s.submissionId} suggestion={s} />
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("assignment.aiEmpty")}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("assignment.aiContractPending")}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AiSuggestionRow({
|
||||
suggestion,
|
||||
}: {
|
||||
suggestion: AiGradingSuggestion;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
return (
|
||||
<li className="flex flex-col gap-1 rounded-md border p-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{suggestion.studentName}</p>
|
||||
<p className="text-xs text-muted-foreground">{suggestion.reasoning}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="font-mono">
|
||||
{t("assignment.suggestedScore")}:{" "}
|
||||
<span className="font-semibold">{suggestion.suggestedScore}</span>
|
||||
</span>
|
||||
<span className={confidenceToColorClass(suggestion.confidence)}>
|
||||
{t("assignment.confidence")}:{" "}
|
||||
{formatConfidence(suggestion.confidence)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoItem({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-sm">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 作业详情页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 单查 homework(id: ID!):✅ schema 真实字段(core-edu 子图)
|
||||
* - 列表查询 homeworkSubmissions(homeworkId):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - mutation recordGrade(input):❌ schema 无 Mutation → MSW 兜底(@contract-pending)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - notFound:data 为 null 时显示空态节点
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import {
|
||||
useHomework,
|
||||
useHomeworkSubmissions,
|
||||
useRecordGrade,
|
||||
type Homework,
|
||||
type HomeworkSubmissionItem,
|
||||
} from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
DetailField,
|
||||
} from "@/shared/components/page-templates";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
import {
|
||||
formatDueDate,
|
||||
formatGracePeriod,
|
||||
formatHomeworkStatus,
|
||||
formatScore,
|
||||
formatSubmissionStatus,
|
||||
homeworkStatusToBadgeClass,
|
||||
isHomeworkEditable,
|
||||
isOverdue,
|
||||
submissionStatusToBadgeClass,
|
||||
} from "@/features/teacher/homework/transformations";
|
||||
|
||||
/**
|
||||
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function HomeworkDetailClient(): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const tCommon = useTranslations("common");
|
||||
const params = useParams<{ id: string }>();
|
||||
const homeworkId = params?.id ?? "";
|
||||
|
||||
// ✅ 真实查询:homework(id: ID!),schema 已就绪
|
||||
const { data, loading, error } = useHomework(homeworkId);
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={data?.title ?? t("detail.title")}
|
||||
description={
|
||||
data
|
||||
? t("detail.createdAtPrefix", {
|
||||
date: formatDueDate(data.createdAt),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<ClipboardList className="size-6" />}
|
||||
backHref="/shell/teacher/homework"
|
||||
actions={
|
||||
data && isHomeworkEditable(data.status) ? (
|
||||
<Button variant="outline">{t("detail.edit")}</Button>
|
||||
) : null
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={
|
||||
!loading && !error && !data ? (
|
||||
<div className="rounded-xl border p-6 text-center text-muted-foreground">
|
||||
{t("detail.notFound")}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{data ? <HomeworkDetailBody homework={data} /> : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情内容区(基本信息 + 提交列表 + 内联批改表单)。
|
||||
*/
|
||||
function HomeworkDetailBody({
|
||||
homework,
|
||||
}: {
|
||||
homework: Homework;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
return (
|
||||
<>
|
||||
<DetailSection title={t("detail.sectionBasic")}>
|
||||
<DetailField label={t("detail.fieldTitle")} value={homework.title} />
|
||||
<DetailField
|
||||
label={t("detail.fieldDescription")}
|
||||
value={homework.description ?? "-"}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldDueDate")}
|
||||
value={
|
||||
<span
|
||||
className={isOverdue(homework.dueDate) ? "text-destructive" : ""}
|
||||
>
|
||||
{formatDueDate(homework.dueDate)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldGracePeriod")}
|
||||
value={formatGracePeriod(homework.gracePeriod)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldStatus")}
|
||||
value={
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${homeworkStatusToBadgeClass(homework.status)}`}
|
||||
>
|
||||
{formatHomeworkStatus(homework.status)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldClassId")}
|
||||
value={homework.classId}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldSubjectId")}
|
||||
value={homework.subjectId}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldCreatedBy")}
|
||||
value={homework.createdBy}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldUpdatedAt")}
|
||||
value={formatDueDate(homework.updatedAt)}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection
|
||||
title={t("detail.sectionSubmissions")}
|
||||
actions={
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link
|
||||
href={`/shell/teacher/homework/assignments/${homework.id}/submissions`}
|
||||
>
|
||||
{t("detail.viewAllSubmissions")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<HomeworkSubmissionsSection homeworkId={homework.id} />
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("detail.sectionInlineGrade")}>
|
||||
<InlineGradeForm homeworkId={homework.id} />
|
||||
</DetailSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交列表(按 homeworkId 拉取,@contract-pending MSW 兜底)。
|
||||
*/
|
||||
function HomeworkSubmissionsSection({
|
||||
homeworkId,
|
||||
}: {
|
||||
homeworkId: string;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const tCommon = useTranslations("common");
|
||||
const { data, loading, error } = useHomeworkSubmissions({ homeworkId });
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const items = data?.items ?? [];
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("detail.noSubmissions")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-2 text-left font-medium">
|
||||
{t("detail.colStudent")}
|
||||
</th>
|
||||
<th className="p-2 text-left font-medium">
|
||||
{t("detail.colStatus")}
|
||||
</th>
|
||||
<th className="p-2 text-left font-medium">
|
||||
{t("detail.colSubmittedAt")}
|
||||
</th>
|
||||
<th className="p-2 text-right font-medium">
|
||||
{t("detail.colScore")}
|
||||
</th>
|
||||
<th className="p-2 text-right font-medium">
|
||||
{t("detail.colActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((s) => (
|
||||
<SubmissionRow key={s.id} submission={s} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmissionRow({
|
||||
submission,
|
||||
}: {
|
||||
submission: HomeworkSubmissionItem;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
return (
|
||||
<tr className="hover:bg-muted/30">
|
||||
<td className="p-2">
|
||||
<Link
|
||||
href={`/shell/teacher/homework/submissions/${submission.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{submission.studentName}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">{submission.studentNo}</p>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${submissionStatusToBadgeClass(submission.status)}`}
|
||||
>
|
||||
{formatSubmissionStatus(submission.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2 font-mono text-xs">
|
||||
{formatDueDate(submission.submittedAt)}
|
||||
</td>
|
||||
<td className="p-2 text-right">
|
||||
{formatScore(submission.totalScore)} / {submission.maxScore}
|
||||
</td>
|
||||
<td className="p-2 text-right">
|
||||
<Link
|
||||
href={`/shell/teacher/homework/submissions/${submission.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("detail.grade")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 内联批改表单(录入单个学生单次成绩,@contract-pending MSW 兜底)。
|
||||
*/
|
||||
function InlineGradeForm({
|
||||
homeworkId,
|
||||
}: {
|
||||
homeworkId: string;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const { run: recordGrade, loading: submitting } = useRecordGrade();
|
||||
|
||||
const [studentId, setStudentId] = useState("");
|
||||
const [score, setScore] = useState("");
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
setError(null);
|
||||
if (!studentId.trim()) {
|
||||
setError(t("detail.gradeErrorStudentRequired"));
|
||||
return;
|
||||
}
|
||||
const scoreNum = Number(score);
|
||||
if (!Number.isFinite(scoreNum) || scoreNum < 0) {
|
||||
setError(t("detail.gradeErrorScoreInvalid"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await recordGrade({
|
||||
homeworkId,
|
||||
studentId: studentId.trim(),
|
||||
score: scoreNum,
|
||||
feedback: feedback.trim() || undefined,
|
||||
});
|
||||
notify.success(t("detail.gradeSuccess"));
|
||||
setStudentId("");
|
||||
setScore("");
|
||||
setFeedback("");
|
||||
} catch (err) {
|
||||
notify.error(`${t("detail.gradeError")}: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">
|
||||
{t("detail.gradeStudentId")}
|
||||
<span className="ml-1 text-destructive">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={studentId}
|
||||
onChange={(e) => setStudentId(e.target.value)}
|
||||
placeholder="stu-001"
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">
|
||||
{t("detail.gradeScore")}
|
||||
<span className="ml-1 text-destructive">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={score}
|
||||
onChange={(e) => setScore(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">
|
||||
{t("detail.gradeFeedback")}
|
||||
</label>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
placeholder={t("detail.gradeFeedbackPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? t("detail.gradeSubmitting") : t("detail.gradeSubmit")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("detail.gradeContractPending")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 作业管理列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 列表查询 homeworks(classId):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#homework-list
|
||||
*
|
||||
* URL 状态:?classId=xxx &status=xxx &q=xxx
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useMemo, useTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useHomeworkList, type HomeworkListItem } from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatDueDate,
|
||||
formatGracePeriod,
|
||||
formatHomeworkStatus,
|
||||
homeworkStatusToBadgeClass,
|
||||
isOverdue,
|
||||
} from "@/features/teacher/homework/transformations";
|
||||
|
||||
/**
|
||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
||||
*/
|
||||
export function HomeworkListClient(): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const classId = searchParams.get("classId") ?? "cls-001";
|
||||
const statusFilter = searchParams.get("status") ?? "";
|
||||
const q = searchParams.get("q") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useHomeworkList(classId, {
|
||||
status: statusFilter || undefined,
|
||||
});
|
||||
|
||||
// 客户端二次筛选(q)—— 后端补齐列表查询后改服务端筛选
|
||||
const filteredItems = useMemo<HomeworkListItem[]>(() => {
|
||||
const items = data?.items ?? [];
|
||||
return items.filter((item) => {
|
||||
if (q && !item.title.toLowerCase().includes(q.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [data, q]);
|
||||
|
||||
const updateQuery = (key: string, value: string): void => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
startTransition(() => {
|
||||
router.push(`/shell/teacher/homework?${params.toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("list.mswNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
title={t("list.title")}
|
||||
description={t("list.description")}
|
||||
icon={<ClipboardList className="size-6" />}
|
||||
actions={
|
||||
<Button asChild>
|
||||
<Link href={`/shell/teacher/homework/new?classId=${classId}`}>
|
||||
{t("list.new")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
filters={
|
||||
<>
|
||||
<FilterSearchInput
|
||||
placeholder={t("list.searchPlaceholder")}
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => updateQuery("status", e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.statusFilter")}
|
||||
>
|
||||
<option value="">{t("list.statusAll")}</option>
|
||||
<option value="DRAFT">{t("list.statusDraft")}</option>
|
||||
<option value="PUBLISHED">{t("list.statusPublished")}</option>
|
||||
<option value="CLOSED">{t("list.statusClosed")}</option>
|
||||
<option value="ARCHIVED">{t("list.statusArchived")}</option>
|
||||
</select>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<ListPageSkeleton rows={5} />}
|
||||
empty={filteredItems.length === 0 && !loading}
|
||||
errorNode={errorNode}
|
||||
pagination={
|
||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("list.total", { count: filteredItems.length })}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<HomeworkTable items={filteredItems} />
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 作业列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
||||
*/
|
||||
function HomeworkTable({
|
||||
items,
|
||||
}: {
|
||||
items: HomeworkListItem[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">{t("list.colName")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colDueDate")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colGracePeriod")}
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
{t("list.colActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((hw) => (
|
||||
<tr key={hw.id} className="hover:bg-muted/30">
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/shell/teacher/homework/${hw.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{hw.title}
|
||||
</Link>
|
||||
{hw.description ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{hw.description}
|
||||
</p>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<HomeworkStatusBadge status={hw.status} />
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs">
|
||||
<span
|
||||
className={isOverdue(hw.dueDate) ? "text-destructive" : ""}
|
||||
>
|
||||
{formatDueDate(hw.dueDate)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-xs">
|
||||
{formatGracePeriod(hw.gracePeriod)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Link
|
||||
href={`/shell/teacher/homework/${hw.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("list.viewDetail")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 作业状态徽章(按状态色阶展示)。
|
||||
*/
|
||||
function HomeworkStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: string;
|
||||
}): React.ReactElement {
|
||||
const label = formatHomeworkStatus(status);
|
||||
const cls = homeworkStatusToBadgeClass(status);
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 布置作业表单页 - 客户端组件(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - mutation assignHomework(input):❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#assign-homework-mutation
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:FormPageSkeleton(初始数据加载,由 server page Suspense 兜底)
|
||||
* - error:errorSummary 表单级错误
|
||||
* - success:notify.success + router.push 回列表
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTransition, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useAssignHomework, type AssignHomeworkInput } from "@/lib/api";
|
||||
import { FormPageShell } from "@/shared/components/page-templates";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
|
||||
/**
|
||||
* 表单客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function NewHomeworkClient(): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const presetClassId = searchParams.get("classId") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { run: assignHomework, loading: submitting } = useAssignHomework();
|
||||
|
||||
const handleSubmit = async (input: AssignHomeworkInput): Promise<void> => {
|
||||
try {
|
||||
const result = await assignHomework(input);
|
||||
notify.success(t("new.success"));
|
||||
startTransition(() => {
|
||||
router.push(`/shell/teacher/homework?classId=${input.classId}`);
|
||||
});
|
||||
void result;
|
||||
} catch (err) {
|
||||
notify.error(`${t("new.error")}: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<NewHomeworkFormInner
|
||||
presetClassId={presetClassId}
|
||||
submitting={submitting}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单主体(受控表单 + 内联校验)。
|
||||
*
|
||||
* 注:未引入 react-hook-form + zod,因当前仅一个表单,引入会增加依赖。
|
||||
* 后续表单数量增多后统一迁移到 react-hook-form(§7.3 表单页模板建议)。
|
||||
*/
|
||||
function NewHomeworkFormInner({
|
||||
presetClassId,
|
||||
submitting,
|
||||
onSubmit,
|
||||
}: {
|
||||
presetClassId: string;
|
||||
submitting: boolean;
|
||||
onSubmit: (input: AssignHomeworkInput) => Promise<void>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
const [classId, setClassId] = useState(presetClassId);
|
||||
const [subjectId, setSubjectId] = useState("sub-math");
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [dueDate, setDueDate] = useState("");
|
||||
const [gracePeriod, setGracePeriod] = useState("24");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleFormSubmit = (): void => {
|
||||
setError(null);
|
||||
|
||||
if (!classId.trim()) {
|
||||
setError(t("new.errorClassRequired"));
|
||||
return;
|
||||
}
|
||||
if (!title.trim()) {
|
||||
setError(t("new.errorTitleRequired"));
|
||||
return;
|
||||
}
|
||||
if (!dueDate) {
|
||||
setError(t("new.errorDateRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
const input: AssignHomeworkInput = {
|
||||
classId: classId.trim(),
|
||||
subjectId: subjectId.trim(),
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
dueDate: new Date(dueDate).toISOString(),
|
||||
gracePeriod: Number(gracePeriod) || 0,
|
||||
};
|
||||
|
||||
void onSubmit(input);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormPageShell
|
||||
title={t("new.title")}
|
||||
description={t("new.description")}
|
||||
icon={<ClipboardList className="size-6" />}
|
||||
backHref={`/shell/teacher/homework?classId=${classId}`}
|
||||
onSubmit={handleFormSubmit}
|
||||
submitting={submitting}
|
||||
submitLabel={t("new.submit")}
|
||||
cancelLabel={tCommon("button.cancel")}
|
||||
errorSummary={
|
||||
error ? <p className="text-sm text-destructive">{error}</p> : undefined
|
||||
}
|
||||
>
|
||||
{/* 班级 ID */}
|
||||
<FormField label={t("new.classId")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={classId}
|
||||
onChange={(e) => setClassId(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder="cls-001"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 科目 ID */}
|
||||
<FormField label={t("new.subjectId")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={subjectId}
|
||||
onChange={(e) => setSubjectId(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder="sub-math"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 标题 */}
|
||||
<FormField label={t("new.titleLabel")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder={t("new.titlePlaceholder")}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 描述 */}
|
||||
<FormField label={t("new.descriptionLabel")}>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
placeholder={t("new.descriptionPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 截止日期 */}
|
||||
<FormField label={t("new.dueDate")} required>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 宽限期(小时) */}
|
||||
<FormField label={t("new.gracePeriod")}>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={gracePeriod}
|
||||
onChange={(e) => setGracePeriod(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("new.gracePeriodHint")}
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
{/* @contract-pending 提示 */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("new.contractPending")}
|
||||
</p>
|
||||
</FormPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单字段容器(label + children)。
|
||||
* 简化版,后续可提取到 shared/components/ui/form-field.tsx 复用。
|
||||
*/
|
||||
function FormField({
|
||||
label,
|
||||
required,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{label}
|
||||
{required ? <span className="ml-1 text-destructive">*</span> : null}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 扫描批改页 - 客户端组件(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 单查 submissionDetail(submissionId):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - mutation saveScanGrading(input):❌ schema 无 Mutation → MSW 兜底(@contract-pending)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:WorkbenchPageSkeleton(loading=true)
|
||||
* - error:errorNode 局部降级
|
||||
* - 局部加载:左/中/右各自支持独立 loading(本页用整体 loading)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*
|
||||
* 注:WorkbenchPageShell 没有 emptyNode 属性;notFound 由 errorNode 节点呈现。
|
||||
*/
|
||||
import { ScanLine } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import {
|
||||
useSubmissionDetail,
|
||||
useSaveScanGrading,
|
||||
type SubmissionAnswer,
|
||||
} from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
WorkbenchPageShell,
|
||||
WorkbenchPanel,
|
||||
WorkbenchPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
import {
|
||||
confidenceToColorClass,
|
||||
formatConfidence,
|
||||
} from "@/features/teacher/homework/transformations";
|
||||
|
||||
/**
|
||||
* 扫描批改客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function ScanGradingClient(): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const tCommon = useTranslations("common");
|
||||
const params = useParams<{ submissionId: string }>();
|
||||
const submissionId = params?.submissionId ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useSubmissionDetail(submissionId);
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const notFoundNode =
|
||||
!loading && !error && !data ? (
|
||||
<div className="rounded-xl border p-6 text-center text-muted-foreground">
|
||||
{t("scan.notFound")}
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<WorkbenchPageShell
|
||||
title={
|
||||
data
|
||||
? t("scan.title", { name: data.submission.studentName })
|
||||
: t("scan.titleLoading")
|
||||
}
|
||||
description={
|
||||
data
|
||||
? t("scan.subtitle", {
|
||||
homework: data.submission.homeworkTitle,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<ScanLine className="size-6" />}
|
||||
loading={loading}
|
||||
loadingNode={<WorkbenchPageSkeleton />}
|
||||
errorNode={errorNode ?? notFoundNode}
|
||||
left={
|
||||
data ? (
|
||||
<WorkbenchPanel title={t("scan.scanPreview")}>
|
||||
<ScanPreviewPanel submissionId={data.submission.id} />
|
||||
</WorkbenchPanel>
|
||||
) : null
|
||||
}
|
||||
center={
|
||||
data ? (
|
||||
<WorkbenchPanel title={t("scan.recognizedAnswers")}>
|
||||
<RecognizedAnswersPanel answers={data.answers} />
|
||||
</WorkbenchPanel>
|
||||
) : null
|
||||
}
|
||||
right={
|
||||
data ? (
|
||||
<WorkbenchPanel title={t("scan.gradingForm")}>
|
||||
<ScanGradingForm
|
||||
submissionId={data.submission.id}
|
||||
answers={data.answers}
|
||||
maxScore={data.submission.maxScore}
|
||||
/>
|
||||
</WorkbenchPanel>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描图片预览面板(左栏)。
|
||||
* 注:当前无真实扫描图片,使用占位符 + 提示。
|
||||
*/
|
||||
function ScanPreviewPanel({
|
||||
submissionId,
|
||||
}: {
|
||||
submissionId: string;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3">
|
||||
<div className="flex flex-1 items-center justify-center rounded-md border bg-muted/30 p-4">
|
||||
<div className="text-center">
|
||||
<ScanLine className="mx-auto size-12 text-muted-foreground" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("scan.imagePlaceholder", { id: submissionId })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("scan.imageContractPending")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 已识别答案面板(中栏)。
|
||||
*/
|
||||
function RecognizedAnswersPanel({
|
||||
answers,
|
||||
}: {
|
||||
answers: SubmissionAnswer[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{answers.map((a, idx) => (
|
||||
<div key={a.id} className="rounded-md border p-3">
|
||||
<div className="mb-1 flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium">
|
||||
{idx + 1}. {a.questionTitle}
|
||||
</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{a.maxScore} {t("scan.unitScore")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("scan.questionType")}: {a.questionType}
|
||||
</p>
|
||||
<div className="mt-2 rounded-md bg-muted/30 p-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("scan.recognizedAnswer")}
|
||||
</p>
|
||||
<p className="mt-1 text-sm">{a.answer}</p>
|
||||
</div>
|
||||
{a.aiSuggestion ? (
|
||||
<p className="mt-2 text-xs">
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{t("scan.aiSuggestionLabel")}
|
||||
</span>
|
||||
<span className="ml-1">{a.aiSuggestion}</span>
|
||||
</p>
|
||||
) : null}
|
||||
{a.isCorrect !== null ? (
|
||||
<p
|
||||
className={`mt-1 text-xs ${a.isCorrect ? "text-emerald-600" : "text-destructive"}`}
|
||||
>
|
||||
{a.isCorrect
|
||||
? t("scan.recognizedCorrect")
|
||||
: t("scan.recognizedIncorrect")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{answers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("scan.noAnswers")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描批改表单(右栏):每题分数 + 评语 + 总反馈 + 保存。
|
||||
*/
|
||||
function ScanGradingForm({
|
||||
submissionId,
|
||||
answers,
|
||||
maxScore,
|
||||
}: {
|
||||
submissionId: string;
|
||||
answers: SubmissionAnswer[];
|
||||
maxScore: number;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const { run: saveScanGrading, loading: submitting } = useSaveScanGrading();
|
||||
|
||||
const initialScores = useMemo<Record<string, string>>(() => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const a of answers) {
|
||||
m[a.questionId] = a.score === null ? "" : String(a.score);
|
||||
}
|
||||
return m;
|
||||
}, [answers]);
|
||||
|
||||
const [scores, setScores] = useState<Record<string, string>>(initialScores);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const applyAllAi = (): void => {
|
||||
const next: Record<string, string> = { ...scores };
|
||||
let applied = 0;
|
||||
for (const a of answers) {
|
||||
if (!a.aiSuggestion) continue;
|
||||
if (a.aiSuggestion.includes("满分")) {
|
||||
next[a.questionId] = String(a.maxScore);
|
||||
applied += 1;
|
||||
}
|
||||
}
|
||||
setScores(next);
|
||||
notify.info(t("scan.aiAppliedCount", { count: applied }));
|
||||
};
|
||||
|
||||
const totalScore = answers.reduce((sum, a) => {
|
||||
const raw = scores[a.questionId] ?? "";
|
||||
const num = Number(raw);
|
||||
return Number.isFinite(num) ? sum + num : sum;
|
||||
}, 0);
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
setError(null);
|
||||
const payload = answers.map((a) => {
|
||||
const raw = scores[a.questionId] ?? "";
|
||||
const num = Number(raw);
|
||||
if (raw === "" || !Number.isFinite(num)) {
|
||||
throw new Error(t("scan.errorScoreInvalid", { qid: a.questionId }));
|
||||
}
|
||||
return {
|
||||
questionId: a.questionId,
|
||||
score: num,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await saveScanGrading({
|
||||
submissionId,
|
||||
answers: payload,
|
||||
feedback: feedback.trim() || undefined,
|
||||
});
|
||||
notify.success(t("scan.saveSuccess"));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
notify.error(`${t("scan.saveError")}: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("scan.totalScore")}: {totalScore} / {maxScore}
|
||||
</span>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={applyAllAi}>
|
||||
{t("scan.applyAllAi")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 overflow-y-auto">
|
||||
{answers.map((a, idx) => {
|
||||
const raw = scores[a.questionId] ?? "";
|
||||
const aiConf = a.aiSuggestion
|
||||
? a.aiSuggestion.includes("满分")
|
||||
? 0.95
|
||||
: 0.7
|
||||
: null;
|
||||
return (
|
||||
<div key={a.id} className="space-y-1 rounded-md border p-2">
|
||||
<p className="text-xs font-medium">
|
||||
{idx + 1}. {a.questionTitle}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={a.maxScore}
|
||||
value={raw}
|
||||
onChange={(e) =>
|
||||
setScores((prev) => ({
|
||||
...prev,
|
||||
[a.questionId]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-8 w-20 rounded-md border border-input bg-background px-2 text-sm"
|
||||
aria-label={t("scan.scoreLabel", { qid: a.questionId })}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
/ {a.maxScore}
|
||||
</span>
|
||||
{aiConf !== null ? (
|
||||
<span
|
||||
className={`ml-auto text-xs ${confidenceToColorClass(aiConf)}`}
|
||||
>
|
||||
{t("scan.confidence")}: {formatConfidence(aiConf)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
{t("scan.overallFeedback")}
|
||||
</label>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-2 py-1 text-sm"
|
||||
placeholder={t("scan.overallFeedbackPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? t("scan.saving") : t("scan.save")}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("scan.contractPending")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 单份提交批改页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 单查 submissionDetail(submissionId):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - mutation gradeSubmission(input):❌ schema 无 Mutation → MSW 兜底(@contract-pending)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - notFound:data 为 null 时显示空态节点
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { ClipboardCheck } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import {
|
||||
useSubmissionDetail,
|
||||
useGradeSubmission,
|
||||
type SubmissionAnswer,
|
||||
type SubmissionDetail,
|
||||
} from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
DetailField,
|
||||
} from "@/shared/components/page-templates";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
import {
|
||||
formatDueDate,
|
||||
formatSubmissionStatus,
|
||||
submissionStatusToBadgeClass,
|
||||
} from "@/features/teacher/homework/transformations";
|
||||
|
||||
/**
|
||||
* 批改客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function SubmissionGradingClient(): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const tCommon = useTranslations("common");
|
||||
const params = useParams<{ submissionId: string }>();
|
||||
const submissionId = params?.submissionId ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useSubmissionDetail(submissionId);
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={
|
||||
data
|
||||
? t("grading.title", { name: data.submission.studentName })
|
||||
: t("grading.titleLoading")
|
||||
}
|
||||
description={
|
||||
data
|
||||
? t("grading.subtitle", {
|
||||
homework: data.submission.homeworkTitle,
|
||||
no: data.submission.studentNo,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<ClipboardCheck className="size-6" />}
|
||||
backHref="/shell/teacher/homework/submissions"
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={
|
||||
!loading && !error && !data ? (
|
||||
<div className="rounded-xl border p-6 text-center text-muted-foreground">
|
||||
{t("grading.notFound")}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{data ? <SubmissionGradingBody detail={data} /> : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批改主体:学生信息 + 题目作答 + 批改表单 + 上下导航。
|
||||
*/
|
||||
function SubmissionGradingBody({
|
||||
detail,
|
||||
}: {
|
||||
detail: SubmissionDetail;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const { submission, navigation } = detail;
|
||||
const router = useRouter();
|
||||
|
||||
const handleNav = (target: string | null): void => {
|
||||
if (!target) return;
|
||||
router.push(`/shell/teacher/homework/submissions/${target}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DetailSection title={t("grading.sectionStudent")}>
|
||||
<DetailField
|
||||
label={t("grading.fieldStudentName")}
|
||||
value={submission.studentName}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("grading.fieldStudentNo")}
|
||||
value={submission.studentNo}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("grading.fieldClass")}
|
||||
value={submission.className}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("grading.fieldHomework")}
|
||||
value={
|
||||
<Link
|
||||
href={`/shell/teacher/homework/${submission.homeworkId}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{submission.homeworkTitle}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("grading.fieldStatus")}
|
||||
value={
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${submissionStatusToBadgeClass(submission.status)}`}
|
||||
>
|
||||
{formatSubmissionStatus(submission.status)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("grading.fieldSubmittedAt")}
|
||||
value={formatDueDate(submission.submittedAt)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("grading.fieldFeedback")}
|
||||
value={submission.feedback ?? "-"}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("grading.sectionAnswers")}>
|
||||
<AnswersGradingForm
|
||||
submissionId={submission.id}
|
||||
answers={detail.answers}
|
||||
maxScore={submission.maxScore}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("grading.sectionNavigation")}>
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!navigation.prevId}
|
||||
onClick={() => handleNav(navigation.prevId)}
|
||||
>
|
||||
{t("grading.prev")}
|
||||
</Button>
|
||||
<span className="text-muted-foreground">
|
||||
{t("grading.position", {
|
||||
current: navigation.currentIndex + 1,
|
||||
total: navigation.totalCount,
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!navigation.nextId}
|
||||
onClick={() => handleNav(navigation.nextId)}
|
||||
>
|
||||
{t("grading.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</DetailSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 题目作答 + 批改表单。
|
||||
*/
|
||||
function AnswersGradingForm({
|
||||
submissionId,
|
||||
answers,
|
||||
maxScore,
|
||||
}: {
|
||||
submissionId: string;
|
||||
answers: SubmissionAnswer[];
|
||||
maxScore: number;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const { run: gradeSubmission, loading: submitting } = useGradeSubmission();
|
||||
|
||||
// 每题分数与教师评语,初始化为已有值
|
||||
const initialScores = useMemo<Record<string, string>>(() => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const a of answers) {
|
||||
m[a.questionId] = a.score === null ? "" : String(a.score);
|
||||
}
|
||||
return m;
|
||||
}, [answers]);
|
||||
|
||||
const initialComments = useMemo<Record<string, string>>(() => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const a of answers) {
|
||||
m[a.questionId] = a.teacherComment ?? "";
|
||||
}
|
||||
return m;
|
||||
}, [answers]);
|
||||
|
||||
const [scores, setScores] = useState<Record<string, string>>(initialScores);
|
||||
const [comments, setComments] =
|
||||
useState<Record<string, string>>(initialComments);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const applyAiSuggestion = (answer: SubmissionAnswer): void => {
|
||||
if (!answer.aiSuggestion) return;
|
||||
// 解析 AI 建议(如 "答案正确,可直接给满分。" → 直接给满分)
|
||||
if (answer.aiSuggestion.includes("满分")) {
|
||||
setScores((prev) => ({
|
||||
...prev,
|
||||
[answer.questionId]: String(answer.maxScore),
|
||||
}));
|
||||
}
|
||||
setComments((prev) => ({
|
||||
...prev,
|
||||
[answer.questionId]: answer.aiSuggestion ?? "",
|
||||
}));
|
||||
notify.info(t("grading.aiSuggestionApplied"));
|
||||
};
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
setError(null);
|
||||
const payload = answers.map((a) => {
|
||||
const raw = scores[a.questionId] ?? "";
|
||||
const num = Number(raw);
|
||||
if (raw === "" || !Number.isFinite(num)) {
|
||||
throw new Error(t("grading.errorScoreInvalid", { qid: a.questionId }));
|
||||
}
|
||||
return {
|
||||
questionId: a.questionId,
|
||||
score: num,
|
||||
teacherComment: (comments[a.questionId] ?? "").trim() || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await gradeSubmission({
|
||||
submissionId,
|
||||
answers: payload,
|
||||
feedback: feedback.trim() || undefined,
|
||||
});
|
||||
notify.success(t("grading.success"));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
notify.error(`${t("grading.error")}: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{answers.map((a, idx) => (
|
||||
<div key={a.id} className="rounded-md border p-3">
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{idx + 1}. {a.questionTitle}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("grading.questionType")}: {a.questionType} ·{" "}
|
||||
{t("grading.maxScore")}: {a.maxScore}
|
||||
</p>
|
||||
</div>
|
||||
{a.aiSuggestion ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => applyAiSuggestion(a)}
|
||||
>
|
||||
{t("grading.applyAi")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mb-2 rounded-md bg-muted/30 p-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("grading.studentAnswer")}
|
||||
</p>
|
||||
<p className="mt-1 text-sm">{a.answer}</p>
|
||||
</div>
|
||||
{a.aiSuggestion ? (
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium">
|
||||
{t("grading.aiSuggestionLabel")}:
|
||||
</span>{" "}
|
||||
{a.aiSuggestion}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
{t("grading.score")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={a.maxScore}
|
||||
value={scores[a.questionId] ?? ""}
|
||||
onChange={(e) =>
|
||||
setScores((prev) => ({
|
||||
...prev,
|
||||
[a.questionId]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 sm:col-span-2">
|
||||
<label className="text-xs font-medium">
|
||||
{t("grading.teacherComment")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={comments[a.questionId] ?? ""}
|
||||
onChange={(e) =>
|
||||
setComments((prev) => ({
|
||||
...prev,
|
||||
[a.questionId]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">
|
||||
{t("grading.overallFeedback")}
|
||||
</label>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
placeholder={t("grading.overallFeedbackPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("grading.maxScoreTotal")}: {maxScore}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? t("grading.submitting") : t("grading.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("grading.contractPending")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 提交批改列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 列表查询 homeworkSubmissions(filter):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#homework-submissions
|
||||
*
|
||||
* URL 状态:?classId=xxx &status=xxx &q=xxx
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { ClipboardCheck } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useMemo, useTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useHomeworkSubmissions, type HomeworkSubmissionItem } from "@/lib/api";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatDueDate,
|
||||
formatScore,
|
||||
formatSubmissionStatus,
|
||||
submissionStatusToBadgeClass,
|
||||
} from "@/features/teacher/homework/transformations";
|
||||
|
||||
/**
|
||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function SubmissionsListClient(): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const classId = searchParams.get("classId") ?? "";
|
||||
const statusFilter = searchParams.get("status") ?? "";
|
||||
const q = searchParams.get("q") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useHomeworkSubmissions({
|
||||
classId: classId || undefined,
|
||||
status: statusFilter || undefined,
|
||||
});
|
||||
|
||||
const filteredItems = useMemo<HomeworkSubmissionItem[]>(() => {
|
||||
const items = data?.items ?? [];
|
||||
if (!q) return items;
|
||||
return items.filter(
|
||||
(item) =>
|
||||
item.studentName.toLowerCase().includes(q.toLowerCase()) ||
|
||||
item.studentNo.toLowerCase().includes(q.toLowerCase()) ||
|
||||
item.homeworkTitle.toLowerCase().includes(q.toLowerCase()),
|
||||
);
|
||||
}, [data, q]);
|
||||
|
||||
const updateQuery = (key: string, value: string): void => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
startTransition(() => {
|
||||
router.push(`/shell/teacher/homework/submissions?${params.toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("submissions.mswNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
title={t("submissions.title")}
|
||||
description={t("submissions.description")}
|
||||
icon={<ClipboardCheck className="size-6" />}
|
||||
filters={
|
||||
<>
|
||||
<FilterSearchInput
|
||||
placeholder={t("submissions.searchPlaceholder")}
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={classId}
|
||||
onChange={(e) => updateQuery("classId", e.target.value)}
|
||||
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder={t("submissions.classIdPlaceholder")}
|
||||
aria-label={t("submissions.classIdFilter")}
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => updateQuery("status", e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("submissions.statusFilter")}
|
||||
>
|
||||
<option value="">{t("submissions.statusAll")}</option>
|
||||
<option value="SUBMITTED">
|
||||
{t("submissions.statusSubmitted")}
|
||||
</option>
|
||||
<option value="GRADING">{t("submissions.statusGrading")}</option>
|
||||
<option value="GRADED">{t("submissions.statusGraded")}</option>
|
||||
<option value="RETURNED">{t("submissions.statusReturned")}</option>
|
||||
<option value="LATE">{t("submissions.statusLate")}</option>
|
||||
</select>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<ListPageSkeleton rows={8} />}
|
||||
empty={filteredItems.length === 0 && !loading}
|
||||
errorNode={errorNode}
|
||||
pagination={
|
||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("submissions.total", { count: filteredItems.length })}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SubmissionsTable items={filteredItems} />
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交列表表格(纯展示组件)。
|
||||
*/
|
||||
function SubmissionsTable({
|
||||
items,
|
||||
}: {
|
||||
items: HomeworkSubmissionItem[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("submissions.colStudent")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("submissions.colHomework")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("submissions.colClass")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("submissions.colStatus")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("submissions.colSubmittedAt")}
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
{t("submissions.colScore")}
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
{t("submissions.colActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((s) => (
|
||||
<tr key={s.id} className="hover:bg-muted/30">
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/shell/teacher/homework/submissions/${s.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{s.studentName}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">{s.studentNo}</p>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/shell/teacher/homework/${s.homeworkId}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{s.homeworkTitle}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3 text-xs">{s.className}</td>
|
||||
<td className="p-3">
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${submissionStatusToBadgeClass(s.status)}`}
|
||||
>
|
||||
{formatSubmissionStatus(s.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs">
|
||||
{formatDueDate(s.submittedAt)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{formatScore(s.totalScore)} / {s.maxScore}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Link
|
||||
href={`/shell/teacher/homework/submissions/${s.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("submissions.toGrade")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Homework 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
|
||||
*
|
||||
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
|
||||
* 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
|
||||
import type { Homework, HomeworkListItem } from "@/lib/api";
|
||||
|
||||
/** 作业状态中文标签映射(对齐旧 teacher-portal HOMEWORK_STATUS_LABEL) */
|
||||
export const HOMEWORK_STATUS_LABEL: Record<string, string> = {
|
||||
DRAFT: "草稿",
|
||||
PUBLISHED: "已发布",
|
||||
CLOSED: "已关闭",
|
||||
ARCHIVED: "已归档",
|
||||
};
|
||||
|
||||
/** 提交状态中文标签映射 */
|
||||
export const SUBMISSION_STATUS_LABEL: Record<string, string> = {
|
||||
SUBMITTED: "已提交",
|
||||
GRADING: "批改中",
|
||||
GRADED: "已批改",
|
||||
RETURNED: "已退回",
|
||||
LATE: "迟交",
|
||||
};
|
||||
|
||||
/**
|
||||
* 将作业状态枚举值映射为中文标签。
|
||||
* 未知状态回退为原始值。
|
||||
*/
|
||||
export function formatHomeworkStatus(status: string): string {
|
||||
return HOMEWORK_STATUS_LABEL[status] ?? status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将提交状态枚举值映射为中文标签。
|
||||
* 未知状态回退为原始值。
|
||||
*/
|
||||
export function formatSubmissionStatus(status: string): string {
|
||||
return SUBMISSION_STATUS_LABEL[status] ?? status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 ISO 日期字符串为本地化展示(zh-CN,含年月日时分)。
|
||||
* 输入无效时返回占位符。
|
||||
*/
|
||||
export function formatDueDate(isoDate: string | null | undefined): string {
|
||||
if (!isoDate) return "--";
|
||||
const d = new Date(isoDate);
|
||||
if (Number.isNaN(d.getTime())) return "--";
|
||||
return d.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化宽限期(小时)为友好展示。
|
||||
* - 0 小时:返回 "无宽限期"
|
||||
* - < 24 小时:返回 "N 小时"
|
||||
* - >= 24 小时:返回 "X 天 Y 小时"
|
||||
*/
|
||||
export function formatGracePeriod(hours: number): string {
|
||||
if (!Number.isFinite(hours) || hours <= 0) return "无宽限期";
|
||||
if (hours < 24) return `${hours} 小时`;
|
||||
const days = Math.floor(hours / 24);
|
||||
const rest = hours % 24;
|
||||
return rest === 0 ? `${days} 天` : `${days} 天 ${rest} 小时`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断作业是否已过截止时间(基于 dueDate)。
|
||||
* 输入无效返回 false。
|
||||
*/
|
||||
export function isOverdue(
|
||||
dueDate: string | null | undefined,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
if (!dueDate) return false;
|
||||
const d = new Date(dueDate);
|
||||
if (Number.isNaN(d.getTime())) return false;
|
||||
return d.getTime() < now.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断作业是否处于可编辑状态(DRAFT)。
|
||||
*/
|
||||
export function isHomeworkEditable(status: string): boolean {
|
||||
return status === "DRAFT";
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断作业是否已发布(PUBLISHED)。
|
||||
*/
|
||||
export function isHomeworkPublished(status: string): boolean {
|
||||
return status === "PUBLISHED";
|
||||
}
|
||||
|
||||
/**
|
||||
* 从作业详情中提取列表项视图模型(裁剪字段)。
|
||||
*/
|
||||
export function toHomeworkListItem(homework: Homework): HomeworkListItem {
|
||||
return {
|
||||
id: homework.id,
|
||||
classId: homework.classId,
|
||||
subjectId: homework.subjectId,
|
||||
title: homework.title,
|
||||
description: homework.description,
|
||||
dueDate: homework.dueDate,
|
||||
gracePeriod: homework.gracePeriod,
|
||||
status: homework.status,
|
||||
createdAt: homework.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断提交是否已完成批改(GRADED / RETURNED)。
|
||||
*/
|
||||
export function isGraded(status: string): boolean {
|
||||
return status === "GRADED" || status === "RETURNED";
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断提交是否处于待批改状态(SUBMITTED / LATE)。
|
||||
*/
|
||||
export function isPendingGrading(status: string): boolean {
|
||||
return status === "SUBMITTED" || status === "LATE";
|
||||
}
|
||||
|
||||
/**
|
||||
* 将分数(数值)格式化为展示字符串,保留 1 位小数。
|
||||
* 输入无效(null/NaN/Infinity)返回 "--"。
|
||||
*/
|
||||
export function formatScore(score: number | null | undefined): string {
|
||||
if (score === null || score === undefined) return "--";
|
||||
if (!Number.isFinite(score)) return "--";
|
||||
return score.toFixed(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 0-1 的小数格式化为百分比字符串(如 0.789 → "79%")。
|
||||
* 输入无效返回 "--"。
|
||||
*/
|
||||
export function formatSubmissionRate(rate: number): string {
|
||||
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
|
||||
return `${(rate * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算提交率(已提交数 / 总人数)。
|
||||
* 总人数为 0 时返回 0。
|
||||
*/
|
||||
export function calcSubmissionRate(
|
||||
submittedCount: number,
|
||||
totalStudents: number,
|
||||
): number {
|
||||
if (!Number.isFinite(totalStudents) || totalStudents <= 0) return 0;
|
||||
if (!Number.isFinite(submittedCount) || submittedCount < 0) return 0;
|
||||
return Math.min(submittedCount / totalStudents, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据得分率(score/maxScore)返回等级标签(A/B/C/D)。
|
||||
* - rate >= 0.85 → A
|
||||
* - rate >= 0.7 → B
|
||||
* - rate >= 0.6 → C
|
||||
* - 其他 → D
|
||||
*
|
||||
* maxScore 为 0 或输入无效时返回 "--"。
|
||||
*/
|
||||
export function formatGradeLevel(
|
||||
score: number | null | undefined,
|
||||
maxScore: number,
|
||||
): string {
|
||||
if (score === null || score === undefined) return "--";
|
||||
if (!Number.isFinite(score) || !Number.isFinite(maxScore) || maxScore <= 0) {
|
||||
return "--";
|
||||
}
|
||||
const rate = score / maxScore;
|
||||
if (rate >= 0.85) return "A";
|
||||
if (rate >= 0.7) return "B";
|
||||
if (rate >= 0.6) return "C";
|
||||
return "D";
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据等级(A/B/C/D)返回 Tailwind 文本语义类名。
|
||||
*/
|
||||
export function gradeLevelToColorClass(level: string): string {
|
||||
switch (level) {
|
||||
case "A":
|
||||
return "text-emerald-600";
|
||||
case "B":
|
||||
return "text-blue-600";
|
||||
case "C":
|
||||
return "text-amber-600";
|
||||
case "D":
|
||||
return "text-destructive";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据提交状态返回 Tailwind 徽章语义类名。
|
||||
*/
|
||||
export function submissionStatusToBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case "SUBMITTED":
|
||||
case "LATE":
|
||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
|
||||
case "GRADING":
|
||||
return "bg-primary/10 text-primary";
|
||||
case "GRADED":
|
||||
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
|
||||
case "RETURNED":
|
||||
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据作业状态返回 Tailwind 徽章语义类名。
|
||||
*/
|
||||
export function homeworkStatusToBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case "DRAFT":
|
||||
return "bg-muted text-muted-foreground";
|
||||
case "PUBLISHED":
|
||||
return "bg-primary/10 text-primary";
|
||||
case "CLOSED":
|
||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
|
||||
case "ARCHIVED":
|
||||
return "bg-muted text-muted-foreground";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 AI 评分置信度返回 Tailwind 文本语义类名。
|
||||
* - >= 0.8 → text-emerald-600(高置信)
|
||||
* - >= 0.6 → text-amber-600(中置信)
|
||||
* - 其他 → text-destructive(低置信)
|
||||
*/
|
||||
export function confidenceToColorClass(confidence: number): string {
|
||||
if (!Number.isFinite(confidence)) return "text-muted-foreground";
|
||||
if (confidence >= 0.8) return "text-emerald-600";
|
||||
if (confidence >= 0.6) return "text-amber-600";
|
||||
return "text-destructive";
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化置信度(0-1)为百分比字符串(如 0.92 → "92%")。
|
||||
* 输入无效返回 "--"。
|
||||
*/
|
||||
export function formatConfidence(confidence: number): string {
|
||||
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) {
|
||||
return "--";
|
||||
}
|
||||
return `${(confidence * 100).toFixed(0)}%`;
|
||||
}
|
||||
612
apps/portal-shell/src/lib/api/homework.ts
Normal file
612
apps/portal-shell/src/lib/api/homework.ts
Normal file
@@ -0,0 +1,612 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Homework domain API(ARCHITECTURE.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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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 };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
261
apps/portal-shell/src/lib/api/operations/homework.graphql.ts
Normal file
261
apps/portal-shell/src/lib/api/operations/homework.graphql.ts
Normal 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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "成绩查询"
|
||||
|
||||
@@ -575,6 +575,287 @@ const mockExamRichEditor = {
|
||||
],
|
||||
};
|
||||
|
||||
// ── Homework 域(@contract-pending,schema 无 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 } };
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user