feat(portal-shell): course-plans + elective 模块 5 页迁移(教师域 §9.1 B2)

§9.1 line 635-636 教师域:
- /shell/teacher/course-plans (列表) + /[id] (详情) — 2 页
- /shell/teacher/elective (列表) + /create (表单) + /[id]/edit (编辑表单) — 3 页
契约:全  schema 无 → MSW 兜底 + @contract-pending

新增文件:
- src/lib/api/{course-plans,elective}.ts (8 hooks 合计)
- src/lib/api/operations/{course-plans,elective}.graphql.ts (8 documents)
- src/features/teacher/{course-plans,elective}/ (clients + transformations + tests)
- src/app/shell/teacher/{course-plans,elective}/ (5 page.tsx + 2 loading + 2 error)

修改文件:
- src/mocks/graphql-data.ts (3 mock 数据 + 8 handler cases)
- src/messages/{zh-CN,en}.json (coursePlans/elective i18n 命名空间)
- src/lib/api/{index,operations/index}.ts (导出 course-plans/elective)
- src/shared/lib/route-permissions.ts (2 EXACT + 2 PREFIX 条目)
- scripts/check-page-count.ts (baseline 48 → 53)

DoD 验收(§11.3 11 项):
- typecheck 0 errors
- lint 0 errors
- vitest 645 tests passed
- lint:tokens 0 errors
- check:pages 53 PASS
- route-permissions 已声明
- 三态齐备
- @contract-pending + MSW 兜底
- i18n zh-CN + en 同步

设计决策:
- COURSE_PLAN_* 权限点不存在于 PERMISSION_BITMAP_ORDER,复用 LESSON_PLAN_READ/CREATE/UPDATE(同备课域语义对齐)
- elective 使用已存在的 ELECTIVE_READ/ELECTIVE_MANAGE
- 编辑表单用 useEffect + initialized state guard 预填数据

关联:ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §10 P2 / §11.3 / §11.4
契约工单:docs/architecture/issues/contracts/core-edu_contract.md
This commit is contained in:
SpecialX
2026-07-22 23:15:45 +08:00
parent 73e09ca29a
commit 80cc1d2461
29 changed files with 3835 additions and 6 deletions

View File

@@ -19,10 +19,10 @@ interface Baseline {
categories: Record<string, { pattern: string; min: number; label: string }>; categories: Record<string, { pattern: string; min: number; label: string }>;
} }
// Baseline as of P2 B2 (2026-07-22, attendance + classes + students modules added). // Baseline as of P2 B2 (2026-07-22, course-plans + elective modules added).
// Update when adding pages. // Update when adding pages.
const BASELINE: Baseline = { const BASELINE: Baseline = {
total: 48, total: 53,
categories: { categories: {
dashboards: { dashboards: {
pattern: "shell/{admin,teacher,student,parent}/page.tsx", pattern: "shell/{admin,teacher,student,parent}/page.tsx",

View File

@@ -0,0 +1,22 @@
import { Suspense } from "react";
import { CoursePlanDetailClient } from "@/features/teacher/course-plans/course-plan-detail-client";
import { DetailPageSkeleton } from "@/shared/components/page-templates";
/**
* 课程计划详情页ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹。
*
* 数据契约:单查 coursePlan(id) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#course-plan-detail
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function CoursePlanDetailPage(): React.ReactElement {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<CoursePlanDetailClient />
</Suspense>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
import { Suspense } from "react";
import { ElectiveEditClient } from "@/features/teacher/elective/elective-edit-client";
import { FormPageSkeleton } from "@/shared/components/page-templates";
/**
* 编辑选修课表单页ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹。
*
* 数据契约:
* - 单查 elective(id) ❌ schema 无此字段 → MSW 兜底(@contract-pending用于预填
* - mutation updateElective(input) ❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#update-elective
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function ElectiveEditPage(): React.ReactElement {
return (
<Suspense fallback={<FormPageSkeleton />}>
<ElectiveEditClient />
</Suspense>
);
}

View File

@@ -0,0 +1,22 @@
import { Suspense } from "react";
import { ElectiveCreateClient } from "@/features/teacher/elective/elective-create-client";
import { FormPageSkeleton } from "@/shared/components/page-templates";
/**
* 新建选修课表单页ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹。
*
* 数据契约mutation createElective(input) ❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#create-elective
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function ElectiveCreatePage(): React.ReactElement {
return (
<Suspense fallback={<FormPageSkeleton />}>
<ElectiveCreateClient />
</Suspense>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,354 @@
/**
* Course Plans 数据变换工具单测ARCHITECTURE.md §11.3 DoD
*
* 关联ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
*/
import { describe, expect, it } from "vitest";
import type { CoursePlanDetail } from "@/lib/api";
import {
COURSE_PLAN_STATUS_LABEL,
COURSE_PLAN_UNIT_STATUS_LABEL,
calcUnitProgress,
coursePlanStatusToBadgeClass,
coursePlanUnitStatusToBadgeClass,
formatCoursePlanDate,
formatCoursePlanStatus,
formatCoursePlanUnitStatus,
formatProgress,
hasDescription,
hasObjectives,
isCoursePlanArchived,
isCoursePlanEditable,
isCoursePlanInProgress,
isValidCoursePlanStatus,
isValidCoursePlanUnitStatus,
progressToColorClass,
toCoursePlanListItem,
} from "../transformations";
const sampleDetail: CoursePlanDetail = {
id: "cp-001",
name: "高一数学第一学期课程计划",
gradeId: "grade-10",
subjectId: "sub-math",
semester: "2026-fall",
status: "IN_PROGRESS",
description: "覆盖集合、函数、三角函数等核心章节",
objectives: "掌握函数概念与基本性质",
units: [
{
id: "unit-1",
title: "集合与逻辑",
order: 1,
lessonCount: 8,
completedLessonCount: 8,
status: "COMPLETED",
},
{
id: "unit-2",
title: "函数概念",
order: 2,
lessonCount: 10,
completedLessonCount: 6,
status: "IN_PROGRESS",
},
{
id: "unit-3",
title: "三角函数",
order: 3,
lessonCount: 12,
completedLessonCount: 0,
status: "NOT_STARTED",
},
],
progress: 50,
createdAt: "2026-08-15T00:00:00Z",
updatedAt: "2026-10-01T00:00:00Z",
};
describe("formatCoursePlanStatus", () => {
it("maps known statuses to Chinese labels", () => {
expect(formatCoursePlanStatus("DRAFT")).toBe("草稿");
expect(formatCoursePlanStatus("IN_PROGRESS")).toBe("进行中");
expect(formatCoursePlanStatus("COMPLETED")).toBe("已完成");
expect(formatCoursePlanStatus("ARCHIVED")).toBe("已归档");
});
it("returns original value for unknown status", () => {
expect(formatCoursePlanStatus("other")).toBe("other");
expect(formatCoursePlanStatus("")).toBe("");
});
it("COURSE_PLAN_STATUS_LABEL covers 4 standard statuses", () => {
expect(Object.keys(COURSE_PLAN_STATUS_LABEL)).toHaveLength(4);
});
});
describe("formatCoursePlanUnitStatus", () => {
it("maps known statuses to Chinese labels", () => {
expect(formatCoursePlanUnitStatus("NOT_STARTED")).toBe("未开始");
expect(formatCoursePlanUnitStatus("IN_PROGRESS")).toBe("进行中");
expect(formatCoursePlanUnitStatus("COMPLETED")).toBe("已完成");
});
it("returns original value for unknown status", () => {
expect(formatCoursePlanUnitStatus("other")).toBe("other");
});
it("COURSE_PLAN_UNIT_STATUS_LABEL covers 3 standard statuses", () => {
expect(Object.keys(COURSE_PLAN_UNIT_STATUS_LABEL)).toHaveLength(3);
});
});
describe("formatCoursePlanDate", () => {
it("formats valid ISO date string with time", () => {
const result = formatCoursePlanDate("2026-07-22T10:30:00Z");
expect(result).toContain("2026");
expect(result).toContain("07");
});
it("returns placeholder for null/undefined/empty", () => {
expect(formatCoursePlanDate(null)).toBe("--");
expect(formatCoursePlanDate(undefined)).toBe("--");
expect(formatCoursePlanDate("")).toBe("--");
});
it("returns placeholder for invalid date", () => {
expect(formatCoursePlanDate("not-a-date")).toBe("--");
});
});
describe("coursePlanStatusToBadgeClass", () => {
it("returns primary class for IN_PROGRESS", () => {
expect(coursePlanStatusToBadgeClass("IN_PROGRESS")).toContain("primary");
});
it("returns emerald class for COMPLETED", () => {
expect(coursePlanStatusToBadgeClass("COMPLETED")).toContain("emerald");
});
it("returns muted for DRAFT/ARCHIVED/unknown", () => {
expect(coursePlanStatusToBadgeClass("DRAFT")).toBe(
"bg-muted text-muted-foreground",
);
expect(coursePlanStatusToBadgeClass("ARCHIVED")).toBe(
"bg-muted text-muted-foreground",
);
expect(coursePlanStatusToBadgeClass("unknown")).toBe(
"bg-muted text-muted-foreground",
);
});
});
describe("coursePlanUnitStatusToBadgeClass", () => {
it("returns primary class for IN_PROGRESS", () => {
expect(coursePlanUnitStatusToBadgeClass("IN_PROGRESS")).toContain(
"primary",
);
});
it("returns emerald class for COMPLETED", () => {
expect(coursePlanUnitStatusToBadgeClass("COMPLETED")).toContain("emerald");
});
it("returns muted for NOT_STARTED/unknown", () => {
expect(coursePlanUnitStatusToBadgeClass("NOT_STARTED")).toBe(
"bg-muted text-muted-foreground",
);
expect(coursePlanUnitStatusToBadgeClass("unknown")).toBe(
"bg-muted text-muted-foreground",
);
});
});
describe("formatProgress", () => {
it("formats valid progress as percentage", () => {
expect(formatProgress(0)).toBe("0%");
expect(formatProgress(50)).toBe("50%");
expect(formatProgress(100)).toBe("100%");
expect(formatProgress(33.7)).toBe("34%");
});
it("returns placeholder for invalid input", () => {
expect(formatProgress(-1)).toBe("--");
expect(formatProgress(101)).toBe("--");
expect(formatProgress(Number.NaN)).toBe("--");
});
});
describe("progressToColorClass", () => {
it("returns emerald for >= 75", () => {
expect(progressToColorClass(75)).toContain("emerald");
expect(progressToColorClass(100)).toContain("emerald");
});
it("returns amber for 50-74", () => {
expect(progressToColorClass(50)).toContain("amber");
expect(progressToColorClass(74)).toContain("amber");
});
it("returns primary for 25-49", () => {
expect(progressToColorClass(25)).toBe("text-primary");
expect(progressToColorClass(49)).toBe("text-primary");
});
it("returns destructive for < 25", () => {
expect(progressToColorClass(0)).toContain("destructive");
expect(progressToColorClass(24)).toContain("destructive");
});
it("returns muted for invalid input", () => {
expect(progressToColorClass(-1)).toBe("text-muted-foreground");
expect(progressToColorClass(101)).toBe("text-muted-foreground");
});
});
describe("toCoursePlanListItem", () => {
it("extracts list fields from detail", () => {
const item = toCoursePlanListItem(sampleDetail);
expect(item.id).toBe("cp-001");
expect(item.name).toBe("高一数学第一学期课程计划");
expect(item.gradeId).toBe("grade-10");
expect(item.subjectId).toBe("sub-math");
expect(item.semester).toBe("2026-fall");
expect(item.status).toBe("IN_PROGRESS");
expect(item.description).toBe("覆盖集合、函数、三角函数等核心章节");
expect(item.createdAt).toBe("2026-08-15T00:00:00Z");
expect(item.updatedAt).toBe("2026-10-01T00:00:00Z");
});
it("does not include detail-only fields (units/objectives/progress)", () => {
const item = toCoursePlanListItem(sampleDetail);
expect(item).not.toHaveProperty("units");
expect(item).not.toHaveProperty("objectives");
expect(item).not.toHaveProperty("progress");
});
});
describe("calcUnitProgress", () => {
it("calculates percentage for valid input", () => {
expect(calcUnitProgress({ lessonCount: 8, completedLessonCount: 8 })).toBe(
100,
);
expect(calcUnitProgress({ lessonCount: 10, completedLessonCount: 6 })).toBe(
60,
);
expect(calcUnitProgress({ lessonCount: 12, completedLessonCount: 0 })).toBe(
0,
);
});
it("returns 0 for zero/negative lessonCount", () => {
expect(calcUnitProgress({ lessonCount: 0, completedLessonCount: 0 })).toBe(
0,
);
expect(calcUnitProgress({ lessonCount: -1, completedLessonCount: 0 })).toBe(
0,
);
});
it("clamps to 100 when completedLessonCount > lessonCount", () => {
expect(
calcUnitProgress({ lessonCount: 10, completedLessonCount: 15 }),
).toBe(100);
});
it("clamps to 0 for negative completedLessonCount", () => {
expect(
calcUnitProgress({ lessonCount: 10, completedLessonCount: -5 }),
).toBe(0);
});
it("returns 0 for invalid input (NaN/Infinity)", () => {
expect(
calcUnitProgress({
lessonCount: Number.NaN,
completedLessonCount: 1,
}),
).toBe(0);
expect(
calcUnitProgress({
lessonCount: Number.POSITIVE_INFINITY,
completedLessonCount: 1,
}),
).toBe(0);
});
});
describe("isCoursePlanEditable", () => {
it("returns true only for DRAFT", () => {
expect(isCoursePlanEditable("DRAFT")).toBe(true);
expect(isCoursePlanEditable("IN_PROGRESS")).toBe(false);
expect(isCoursePlanEditable("COMPLETED")).toBe(false);
expect(isCoursePlanEditable("ARCHIVED")).toBe(false);
});
});
describe("isCoursePlanArchived", () => {
it("returns true only for ARCHIVED", () => {
expect(isCoursePlanArchived("ARCHIVED")).toBe(true);
expect(isCoursePlanArchived("DRAFT")).toBe(false);
});
});
describe("isCoursePlanInProgress", () => {
it("returns true only for IN_PROGRESS", () => {
expect(isCoursePlanInProgress("IN_PROGRESS")).toBe(true);
expect(isCoursePlanInProgress("DRAFT")).toBe(false);
});
});
describe("isValidCoursePlanStatus", () => {
it("returns true for valid statuses", () => {
expect(isValidCoursePlanStatus("DRAFT")).toBe(true);
expect(isValidCoursePlanStatus("IN_PROGRESS")).toBe(true);
expect(isValidCoursePlanStatus("COMPLETED")).toBe(true);
expect(isValidCoursePlanStatus("ARCHIVED")).toBe(true);
});
it("returns false for invalid statuses", () => {
expect(isValidCoursePlanStatus("other")).toBe(false);
expect(isValidCoursePlanStatus("")).toBe(false);
});
});
describe("isValidCoursePlanUnitStatus", () => {
it("returns true for valid unit statuses", () => {
expect(isValidCoursePlanUnitStatus("NOT_STARTED")).toBe(true);
expect(isValidCoursePlanUnitStatus("IN_PROGRESS")).toBe(true);
expect(isValidCoursePlanUnitStatus("COMPLETED")).toBe(true);
});
it("returns false for invalid statuses", () => {
expect(isValidCoursePlanUnitStatus("ARCHIVED")).toBe(false);
expect(isValidCoursePlanUnitStatus("")).toBe(false);
});
});
describe("hasDescription", () => {
it("returns true for non-empty string", () => {
expect(hasDescription("课程简介")).toBe(true);
});
it("returns false for null/undefined/empty/whitespace", () => {
expect(hasDescription(null)).toBe(false);
expect(hasDescription(undefined)).toBe(false);
expect(hasDescription("")).toBe(false);
expect(hasDescription(" ")).toBe(false);
});
});
describe("hasObjectives", () => {
it("returns true for non-empty string", () => {
expect(hasObjectives("教学目标")).toBe(true);
});
it("returns false for null/undefined/empty/whitespace", () => {
expect(hasObjectives(null)).toBe(false);
expect(hasObjectives(undefined)).toBe(false);
expect(hasObjectives("")).toBe(false);
expect(hasObjectives(" ")).toBe(false);
});
});

View File

@@ -0,0 +1,242 @@
"use client";
/**
* 课程计划详情页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* 数据契约(@contract-pending 全 MSW
* - 单查 coursePlan(id):❌ schema 无此字段 → MSW 兜底
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#course-plan-detail
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - notFounddata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { ClipboardList } from "lucide-react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCoursePlan } from "@/lib/api";
import { EmptyState } from "@/shared/components/ui/empty-state";
import {
DetailPageShell,
DetailPageSkeleton,
DetailSection,
DetailField,
} from "@/shared/components/page-templates";
import {
calcUnitProgress,
coursePlanUnitStatusToBadgeClass,
formatCoursePlanDate,
formatCoursePlanStatus,
formatCoursePlanUnitStatus,
formatProgress,
hasDescription,
hasObjectives,
progressToColorClass,
} from "@/features/teacher/course-plans/transformations";
/**
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function CoursePlanDetailClient(): React.ReactElement {
const t = useTranslations("coursePlans");
const tCommon = useTranslations("common");
const params = useParams<{ id: string }>();
const planId = params?.id ?? "";
// @contract-pending MSW 兜底
const { data, loading, error } = useCoursePlan(planId);
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("detail.mswNotice")}
</p>
</div>
) : undefined;
const emptyNode =
!loading && !error && !data ? (
<EmptyState
icon={ClipboardList}
title={t("detail.notFound")}
action={{
label: t("detail.backToList"),
href: "/shell/teacher/course-plans",
}}
/>
) : undefined;
return (
<DetailPageShell
title={data?.name ?? t("detail.title")}
description={
data
? t("detail.createdAtPrefix", {
date: formatCoursePlanDate(data.createdAt),
})
: undefined
}
icon={<ClipboardList className="size-6" />}
backHref="/shell/teacher/course-plans"
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={emptyNode}
>
{data ? <CoursePlanDetailBody detail={data} /> : null}
{data ? <UnitsSection units={data.units} /> : null}
</DetailPageShell>
);
}
/**
* 详情基本信息区(对齐 §7.3 详情页模板)。
*/
function CoursePlanDetailBody({
detail,
}: {
detail: NonNullable<ReturnType<typeof useCoursePlan>["data"]>;
}): React.ReactElement {
const t = useTranslations("coursePlans");
return (
<DetailSection title={t("detail.sectionBasic")}>
<DetailField label={t("detail.fieldName")} value={detail.name} />
<DetailField label={t("detail.fieldGradeId")} value={detail.gradeId} />
<DetailField
label={t("detail.fieldSubjectId")}
value={detail.subjectId}
/>
<DetailField label={t("detail.fieldSemester")} value={detail.semester} />
<DetailField
label={t("detail.fieldStatus")}
value={formatCoursePlanStatus(detail.status)}
/>
<DetailField
label={t("detail.fieldProgress")}
value={
<span className={progressToColorClass(detail.progress)}>
{formatProgress(detail.progress)}
</span>
}
/>
<DetailField
label={t("detail.fieldDescription")}
value={
hasDescription(detail.description)
? (detail.description ?? "-")
: t("detail.noDescription")
}
/>
<DetailField
label={t("detail.fieldObjectives")}
value={
hasObjectives(detail.objectives)
? (detail.objectives ?? "-")
: t("detail.noObjectives")
}
/>
<DetailField
label={t("detail.fieldCreatedAt")}
value={formatCoursePlanDate(detail.createdAt)}
/>
<DetailField
label={t("detail.fieldUpdatedAt")}
value={formatCoursePlanDate(detail.updatedAt)}
/>
</DetailSection>
);
}
/**
* 单元列表区(展示各单元进度)。
*/
function UnitsSection({
units,
}: {
units: NonNullable<ReturnType<typeof useCoursePlan>["data"]>["units"];
}): React.ReactElement {
const t = useTranslations("coursePlans");
return (
<DetailSection title={t("detail.sectionUnits")}>
{units.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("detail.noUnits")}</p>
) : (
<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("detail.colUnitOrder")}
</th>
<th className="p-3 text-left font-medium">
{t("detail.colUnitTitle")}
</th>
<th className="p-3 text-left font-medium">
{t("detail.colUnitProgress")}
</th>
<th className="p-3 text-left font-medium">
{t("detail.colUnitStatus")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{units.map((unit) => {
const progress = calcUnitProgress(unit);
return (
<tr key={unit.id} className="hover:bg-muted/30">
<td className="p-3 font-mono text-xs text-muted-foreground">
{unit.order}
</td>
<td className="p-3 font-medium">{unit.title}</td>
<td className="p-3">
<div className="flex items-center gap-2">
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${progress}%` }}
aria-hidden="true"
/>
</div>
<span
className={`text-xs ${progressToColorClass(progress)}`}
>
{progress}% ({unit.completedLessonCount}/
{unit.lessonCount})
</span>
</div>
</td>
<td className="p-3">
<UnitStatusBadge status={unit.status} />
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</DetailSection>
);
}
/**
* 单元状态徽章。
*/
function UnitStatusBadge({ status }: { status: string }): React.ReactElement {
const label = formatCoursePlanUnitStatus(status);
const cls = coursePlanUnitStatusToBadgeClass(status);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,232 @@
"use client";
/**
* 课程计划列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* 数据契约:
* - 列表查询 coursePlans(gradeId, subjectId, status, q):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#course-plans-list
*
* URL 状态:?grade=xxx &subject=xxx &status=xxx &q=xxx
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { ClipboardList } from "lucide-react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useCoursePlans, type CoursePlanListItem } from "@/lib/api";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
coursePlanStatusToBadgeClass,
formatCoursePlanDate,
formatCoursePlanStatus,
} from "@/features/teacher/course-plans/transformations";
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。
*/
export function CoursePlanListClient(): React.ReactElement {
const t = useTranslations("coursePlans");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const gradeId = searchParams.get("grade") ?? "";
const subjectId = searchParams.get("subject") ?? "";
const statusFilter = searchParams.get("status") ?? "";
const q = searchParams.get("q") ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useCoursePlans({
gradeId: gradeId || undefined,
subjectId: subjectId || undefined,
status: statusFilter || undefined,
q: q || undefined,
});
// 客户端二次筛选q—— 后端补齐列表查询后改服务端筛选
const filteredItems = useMemo<CoursePlanListItem[]>(() => {
const items = data?.items ?? [];
return items.filter((item) => {
if (q && !item.name.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/course-plans?${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" />}
filters={
<>
<FilterSearchInput
placeholder={t("list.searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
/>
<input
type="text"
value={gradeId}
onChange={(e) => updateQuery("grade", e.target.value)}
placeholder={t("list.gradePlaceholder")}
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.gradePlaceholder")}
/>
<input
type="text"
value={subjectId}
onChange={(e) => updateQuery("subject", e.target.value)}
placeholder={t("list.subjectPlaceholder")}
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.subjectPlaceholder")}
/>
<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="IN_PROGRESS">{t("list.statusInProgress")}</option>
<option value="COMPLETED">{t("list.statusCompleted")}</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>
}
>
<CoursePlanTable items={filteredItems} />
</ListPageShell>
);
}
/**
* 课程计划列表表格(纯展示组件,对齐 §8.2 排版规范)。
*/
function CoursePlanTable({
items,
}: {
items: CoursePlanListItem[];
}): React.ReactElement {
const t = useTranslations("coursePlans");
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.colSemester")}
</th>
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
<th className="p-3 text-left font-medium">
{t("list.colUpdatedAt")}
</th>
<th className="p-3 text-right font-medium">
{t("list.colActions")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((plan) => (
<tr key={plan.id} className="hover:bg-muted/30">
<td className="p-3">
<Link
href={`/shell/teacher/course-plans/${plan.id}`}
className="font-medium hover:underline"
>
{plan.name}
</Link>
{plan.description ? (
<p className="mt-1 text-xs text-muted-foreground">
{plan.description}
</p>
) : null}
</td>
<td className="p-3 font-mono text-xs">{plan.semester}</td>
<td className="p-3">
<CoursePlanStatusBadge status={plan.status} />
</td>
<td className="p-3 font-mono text-xs">
{formatCoursePlanDate(plan.updatedAt)}
</td>
<td className="p-3 text-right">
<Link
href={`/shell/teacher/course-plans/${plan.id}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("list.viewDetail")}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* 课程计划状态徽章(按状态色阶展示)。
*/
function CoursePlanStatusBadge({
status,
}: {
status: string;
}): React.ReactElement {
const label = formatCoursePlanStatus(status);
const cls = coursePlanStatusToBadgeClass(status);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,227 @@
/**
* Course Plans 数据变换工具ARCHITECTURE.md §11.3 DoD - 纯函数单测)
*
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
* 关联ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
*/
import type {
CoursePlanDetail,
CoursePlanListItem,
CoursePlanStatus,
CoursePlanUnitStatus,
} from "@/lib/api";
/** 课程计划状态中文标签映射 */
export const COURSE_PLAN_STATUS_LABEL: Record<string, string> = {
DRAFT: "草稿",
IN_PROGRESS: "进行中",
COMPLETED: "已完成",
ARCHIVED: "已归档",
};
/** 单元状态中文标签映射 */
export const COURSE_PLAN_UNIT_STATUS_LABEL: Record<string, string> = {
NOT_STARTED: "未开始",
IN_PROGRESS: "进行中",
COMPLETED: "已完成",
};
/**
* 将课程计划状态枚举值映射为中文标签。
* 未知状态回退为原始值。
*/
export function formatCoursePlanStatus(status: string): string {
return COURSE_PLAN_STATUS_LABEL[status] ?? status;
}
/**
* 将单元状态枚举值映射为中文标签。
* 未知状态回退为原始值。
*/
export function formatCoursePlanUnitStatus(status: string): string {
return COURSE_PLAN_UNIT_STATUS_LABEL[status] ?? status;
}
/**
* 格式化 ISO 日期字符串为本地化展示zh-CN含年月日时分
* 输入无效时返回占位符。
*/
export function formatCoursePlanDate(
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",
});
}
/**
* 根据课程计划状态返回 Tailwind 徽章语义类名。
*/
export function coursePlanStatusToBadgeClass(status: string): string {
switch (status) {
case "DRAFT":
return "bg-muted text-muted-foreground";
case "IN_PROGRESS":
return "bg-primary/10 text-primary";
case "COMPLETED":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
case "ARCHIVED":
return "bg-muted text-muted-foreground";
default:
return "bg-muted text-muted-foreground";
}
}
/**
* 根据单元状态返回 Tailwind 徽章语义类名。
*/
export function coursePlanUnitStatusToBadgeClass(status: string): string {
switch (status) {
case "NOT_STARTED":
return "bg-muted text-muted-foreground";
case "IN_PROGRESS":
return "bg-primary/10 text-primary";
case "COMPLETED":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
default:
return "bg-muted text-muted-foreground";
}
}
/**
* 格式化进度0-100为百分比字符串。
* 输入无效返回 "--"。
*/
export function formatProgress(progress: number): string {
if (!Number.isFinite(progress) || progress < 0 || progress > 100) return "--";
return `${Math.round(progress)}%`;
}
/**
* 根据进度0-100返回 Tailwind 文本语义类名。
* - >= 75 → emerald高进度
* - >= 50 → amber中进度
* - >= 25 → primary
* - 其他 → destructive
*/
export function progressToColorClass(progress: number): string {
if (!Number.isFinite(progress) || progress < 0 || progress > 100) {
return "text-muted-foreground";
}
if (progress >= 75) return "text-emerald-600 dark:text-emerald-400";
if (progress >= 50) return "text-amber-600 dark:text-amber-400";
if (progress >= 25) return "text-primary";
return "text-destructive";
}
/**
* 从课程计划详情中提取列表项视图模型(裁剪字段)。
*/
export function toCoursePlanListItem(
detail: CoursePlanDetail,
): CoursePlanListItem {
return {
id: detail.id,
name: detail.name,
gradeId: detail.gradeId,
subjectId: detail.subjectId,
semester: detail.semester,
status: detail.status,
description: detail.description,
createdAt: detail.createdAt,
updatedAt: detail.updatedAt,
};
}
/**
* 计算单元完成进度completedLessonCount / lessonCount * 100
* lessonCount 为 0 或输入无效时返回 0。
*/
export function calcUnitProgress(unit: {
lessonCount: number;
completedLessonCount: number;
}): number {
if (
!Number.isFinite(unit.lessonCount) ||
!Number.isFinite(unit.completedLessonCount) ||
unit.lessonCount <= 0
) {
return 0;
}
const ratio = unit.completedLessonCount / unit.lessonCount;
if (ratio < 0) return 0;
if (ratio > 1) return 100;
return Math.round(ratio * 100);
}
/**
* 判断课程计划是否可编辑DRAFT 状态)。
*/
export function isCoursePlanEditable(status: string): boolean {
return status === "DRAFT";
}
/**
* 判断课程计划是否已归档ARCHIVED
*/
export function isCoursePlanArchived(status: string): boolean {
return status === "ARCHIVED";
}
/**
* 判断课程计划是否进行中IN_PROGRESS
*/
export function isCoursePlanInProgress(status: string): boolean {
return status === "IN_PROGRESS";
}
/**
* 将 CoursePlanStatus 类型守卫:判断字符串是否为合法状态。
*/
export function isValidCoursePlanStatus(
status: string,
): status is CoursePlanStatus {
return (
status === "DRAFT" ||
status === "IN_PROGRESS" ||
status === "COMPLETED" ||
status === "ARCHIVED"
);
}
/**
* 将 CoursePlanUnitStatus 类型守卫:判断字符串是否为合法单元状态。
*/
export function isValidCoursePlanUnitStatus(
status: string,
): status is CoursePlanUnitStatus {
return (
status === "NOT_STARTED" ||
status === "IN_PROGRESS" ||
status === "COMPLETED"
);
}
/**
* 判断课程计划是否有描述。
*/
export function hasDescription(
description: string | null | undefined,
): boolean {
return Boolean(description && description.trim().length > 0);
}
/**
* 判断课程计划是否有教学目标。
*/
export function hasObjectives(objectives: string | null | undefined): boolean {
return Boolean(objectives && objectives.trim().length > 0);
}

View File

@@ -0,0 +1,303 @@
/**
* Elective 数据变换工具单测ARCHITECTURE.md §11.3 DoD
*
* 关联ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
*/
import { describe, expect, it } from "vitest";
import type { Elective } from "@/lib/api";
import {
ELECTIVE_STATUS_LABEL,
calcAvailableCount,
calcEnrollmentRate,
electiveStatusToBadgeClass,
enrollmentRateToColorClass,
formatElectiveDate,
formatElectiveStatus,
formatEnrollmentCount,
hasAvailableSpot,
hasDescription,
isElectiveArchived,
isElectiveClosed,
isElectiveEditable,
isElectiveOpen,
isValidElectiveStatus,
toElectiveListItem,
} from "../transformations";
const sampleElective: Elective = {
id: "ele-001",
name: "高等数学拓展",
description: "为有兴趣深造的学生提供进阶数学内容",
capacity: 30,
enrolledCount: 20,
semester: "2026-fall",
gradeLevel: "grade-11",
subject: "数学",
teacherId: "tch-001",
teacherName: "李老师",
status: "OPEN",
createdAt: "2026-08-01T00:00:00Z",
updatedAt: "2026-09-15T00:00:00Z",
};
describe("formatElectiveStatus", () => {
it("maps known statuses to Chinese labels", () => {
expect(formatElectiveStatus("DRAFT")).toBe("草稿");
expect(formatElectiveStatus("OPEN")).toBe("报名中");
expect(formatElectiveStatus("CLOSED")).toBe("已关闭");
expect(formatElectiveStatus("ARCHIVED")).toBe("已归档");
});
it("returns original value for unknown status", () => {
expect(formatElectiveStatus("other")).toBe("other");
expect(formatElectiveStatus("")).toBe("");
});
it("ELECTIVE_STATUS_LABEL covers 4 standard statuses", () => {
expect(Object.keys(ELECTIVE_STATUS_LABEL)).toHaveLength(4);
});
});
describe("formatElectiveDate", () => {
it("formats valid ISO date string with time", () => {
const result = formatElectiveDate("2026-07-22T10:30:00Z");
expect(result).toContain("2026");
expect(result).toContain("07");
});
it("returns placeholder for null/undefined/empty", () => {
expect(formatElectiveDate(null)).toBe("--");
expect(formatElectiveDate(undefined)).toBe("--");
expect(formatElectiveDate("")).toBe("--");
});
it("returns placeholder for invalid date", () => {
expect(formatElectiveDate("not-a-date")).toBe("--");
});
});
describe("electiveStatusToBadgeClass", () => {
it("returns primary class for OPEN", () => {
expect(electiveStatusToBadgeClass("OPEN")).toContain("primary");
});
it("returns amber class for CLOSED", () => {
expect(electiveStatusToBadgeClass("CLOSED")).toContain("amber");
});
it("returns muted for DRAFT/ARCHIVED/unknown", () => {
expect(electiveStatusToBadgeClass("DRAFT")).toBe(
"bg-muted text-muted-foreground",
);
expect(electiveStatusToBadgeClass("ARCHIVED")).toBe(
"bg-muted text-muted-foreground",
);
expect(electiveStatusToBadgeClass("unknown")).toBe(
"bg-muted text-muted-foreground",
);
});
});
describe("calcEnrollmentRate", () => {
it("calculates percentage for valid input", () => {
expect(calcEnrollmentRate({ capacity: 30, enrolledCount: 30 })).toBe(100);
expect(calcEnrollmentRate({ capacity: 30, enrolledCount: 20 })).toBe(67);
expect(calcEnrollmentRate({ capacity: 30, enrolledCount: 0 })).toBe(0);
});
it("returns 0 for zero/negative capacity", () => {
expect(calcEnrollmentRate({ capacity: 0, enrolledCount: 0 })).toBe(0);
expect(calcEnrollmentRate({ capacity: -1, enrolledCount: 0 })).toBe(0);
});
it("clamps to 100 when enrolledCount > capacity", () => {
expect(calcEnrollmentRate({ capacity: 30, enrolledCount: 35 })).toBe(100);
});
it("clamps to 0 for negative enrolledCount", () => {
expect(calcEnrollmentRate({ capacity: 30, enrolledCount: -5 })).toBe(0);
});
it("returns 0 for invalid input (NaN/Infinity)", () => {
expect(
calcEnrollmentRate({
capacity: Number.NaN,
enrolledCount: 1,
}),
).toBe(0);
expect(
calcEnrollmentRate({
capacity: Number.POSITIVE_INFINITY,
enrolledCount: 1,
}),
).toBe(0);
});
});
describe("enrollmentRateToColorClass", () => {
it("returns destructive for >= 90", () => {
expect(enrollmentRateToColorClass(90)).toContain("destructive");
expect(enrollmentRateToColorClass(100)).toContain("destructive");
});
it("returns amber for 50-89", () => {
expect(enrollmentRateToColorClass(50)).toContain("amber");
expect(enrollmentRateToColorClass(89)).toContain("amber");
});
it("returns primary for 1-49", () => {
expect(enrollmentRateToColorClass(1)).toBe("text-primary");
expect(enrollmentRateToColorClass(49)).toBe("text-primary");
});
it("returns muted for 0", () => {
expect(enrollmentRateToColorClass(0)).toBe("text-muted-foreground");
});
it("returns muted for invalid input", () => {
expect(enrollmentRateToColorClass(-1)).toBe("text-muted-foreground");
expect(enrollmentRateToColorClass(101)).toBe("text-muted-foreground");
});
});
describe("formatEnrollmentCount", () => {
it("formats count as enrolled/capacity", () => {
expect(formatEnrollmentCount({ capacity: 30, enrolledCount: 20 })).toBe(
"20/30",
);
expect(formatEnrollmentCount({ capacity: 0, enrolledCount: 0 })).toBe(
"0/0",
);
});
it("returns placeholder for invalid input", () => {
expect(
formatEnrollmentCount({
capacity: Number.NaN,
enrolledCount: 1,
}),
).toBe("--");
});
});
describe("isElectiveEditable", () => {
it("returns true only for DRAFT", () => {
expect(isElectiveEditable("DRAFT")).toBe(true);
expect(isElectiveEditable("OPEN")).toBe(false);
expect(isElectiveEditable("CLOSED")).toBe(false);
expect(isElectiveEditable("ARCHIVED")).toBe(false);
});
});
describe("isElectiveOpen", () => {
it("returns true only for OPEN", () => {
expect(isElectiveOpen("OPEN")).toBe(true);
expect(isElectiveOpen("DRAFT")).toBe(false);
});
});
describe("isElectiveClosed", () => {
it("returns true only for CLOSED", () => {
expect(isElectiveClosed("CLOSED")).toBe(true);
expect(isElectiveClosed("OPEN")).toBe(false);
});
});
describe("isElectiveArchived", () => {
it("returns true only for ARCHIVED", () => {
expect(isElectiveArchived("ARCHIVED")).toBe(true);
expect(isElectiveArchived("OPEN")).toBe(false);
});
});
describe("isValidElectiveStatus", () => {
it("returns true for valid statuses", () => {
expect(isValidElectiveStatus("DRAFT")).toBe(true);
expect(isValidElectiveStatus("OPEN")).toBe(true);
expect(isValidElectiveStatus("CLOSED")).toBe(true);
expect(isValidElectiveStatus("ARCHIVED")).toBe(true);
});
it("returns false for invalid statuses", () => {
expect(isValidElectiveStatus("other")).toBe(false);
expect(isValidElectiveStatus("")).toBe(false);
});
});
describe("toElectiveListItem", () => {
it("returns isomorphic shape with all fields", () => {
const item = toElectiveListItem(sampleElective);
expect(item).toEqual(sampleElective);
});
it("returns new object (not same reference)", () => {
const item = toElectiveListItem(sampleElective);
expect(item).not.toBe(sampleElective);
});
});
describe("hasDescription", () => {
it("returns true for non-empty string", () => {
expect(hasDescription("选修课简介")).toBe(true);
});
it("returns false for null/undefined/empty/whitespace", () => {
expect(hasDescription(null)).toBe(false);
expect(hasDescription(undefined)).toBe(false);
expect(hasDescription("")).toBe(false);
expect(hasDescription(" ")).toBe(false);
});
});
describe("hasAvailableSpot", () => {
it("returns true when enrolledCount < capacity", () => {
expect(hasAvailableSpot({ capacity: 30, enrolledCount: 20 })).toBe(true);
expect(hasAvailableSpot({ capacity: 30, enrolledCount: 29 })).toBe(true);
});
it("returns false when enrolledCount >= capacity", () => {
expect(hasAvailableSpot({ capacity: 30, enrolledCount: 30 })).toBe(false);
expect(hasAvailableSpot({ capacity: 30, enrolledCount: 35 })).toBe(false);
});
it("returns false for zero/negative capacity", () => {
expect(hasAvailableSpot({ capacity: 0, enrolledCount: 0 })).toBe(false);
expect(hasAvailableSpot({ capacity: -1, enrolledCount: 0 })).toBe(false);
});
it("returns false for invalid input", () => {
expect(
hasAvailableSpot({
capacity: Number.NaN,
enrolledCount: 1,
}),
).toBe(false);
});
});
describe("calcAvailableCount", () => {
it("calculates remaining spots for valid input", () => {
expect(calcAvailableCount({ capacity: 30, enrolledCount: 20 })).toBe(10);
expect(calcAvailableCount({ capacity: 30, enrolledCount: 30 })).toBe(0);
});
it("returns 0 when over capacity", () => {
expect(calcAvailableCount({ capacity: 30, enrolledCount: 35 })).toBe(0);
});
it("returns 0 for zero/negative capacity", () => {
expect(calcAvailableCount({ capacity: 0, enrolledCount: 0 })).toBe(0);
expect(calcAvailableCount({ capacity: -1, enrolledCount: 0 })).toBe(0);
});
it("returns 0 for invalid input", () => {
expect(
calcAvailableCount({
capacity: Number.NaN,
enrolledCount: 1,
}),
).toBe(0);
});
});

View File

@@ -0,0 +1,235 @@
"use client";
/**
* 新建选修课表单页 - 客户端组件ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2
*
* 数据契约:
* - mutation createElective(input):❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#create-elective
*
* 三态规范§11.3 DoD
* - loadingFormPageSkeleton初始数据加载由 server page Suspense 兜底)
* - errorerrorSummary 表单级错误
* - successnotify.success + router.push 回列表
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { BookMarked } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTransition, useState } from "react";
import { useTranslations } from "next-intl";
import { useCreateElective, type CreateElectiveInput } from "@/lib/api";
import { FormPageShell } from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
/**
* 表单客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function ElectiveCreateClient(): React.ReactElement {
const t = useTranslations("elective");
const router = useRouter();
const [, startTransition] = useTransition();
// @contract-pendingMSW 兜底
const { run: createElective, loading: submitting } = useCreateElective();
const handleSubmit = async (input: CreateElectiveInput): Promise<void> => {
try {
await createElective(input);
notify.success(t("create.success"));
startTransition(() => {
router.push("/shell/teacher/elective");
});
} catch (err) {
notify.error(`${t("create.error")}: ${String(err)}`);
}
};
return <ElectiveFormInner submitting={submitting} onSubmit={handleSubmit} />;
}
/**
* 表单主体(受控表单 + 内联校验)。
*/
function ElectiveFormInner({
submitting,
onSubmit,
initial,
}: {
submitting: boolean;
onSubmit: (input: CreateElectiveInput) => Promise<void>;
initial?: Partial<CreateElectiveInput>;
}): React.ReactElement {
const t = useTranslations("elective");
const tCommon = useTranslations("common");
const [name, setName] = useState(initial?.name ?? "");
const [description, setDescription] = useState(initial?.description ?? "");
const [capacity, setCapacity] = useState(String(initial?.capacity ?? "30"));
const [semester, setSemester] = useState(initial?.semester ?? "2026-fall");
const [gradeLevel, setGradeLevel] = useState(initial?.gradeLevel ?? "");
const [subject, setSubject] = useState(initial?.subject ?? "");
const [teacherId, setTeacherId] = useState(initial?.teacherId ?? "");
const [error, setError] = useState<string | null>(null);
const handleFormSubmit = (): void => {
setError(null);
if (!name.trim()) {
setError(t("create.errorNameRequired"));
return;
}
if (!gradeLevel.trim()) {
setError(t("create.errorGradeLevelRequired"));
return;
}
if (!subject.trim()) {
setError(t("create.errorSubjectRequired"));
return;
}
if (!semester.trim()) {
setError(t("create.errorSemesterRequired"));
return;
}
const capacityNum = Number(capacity);
if (!Number.isFinite(capacityNum) || capacityNum <= 0) {
setError(t("create.errorCapacityInvalid"));
return;
}
const input: CreateElectiveInput = {
name: name.trim(),
description: description.trim() || undefined,
capacity: capacityNum,
semester: semester.trim(),
gradeLevel: gradeLevel.trim(),
subject: subject.trim(),
teacherId: teacherId.trim() || undefined,
};
void onSubmit(input);
};
return (
<FormPageShell
title={t("create.title")}
description={t("create.description")}
icon={<BookMarked className="size-6" />}
backHref="/shell/teacher/elective"
onSubmit={handleFormSubmit}
submitting={submitting}
submitLabel={t("create.submit")}
cancelLabel={tCommon("button.cancel")}
errorSummary={
error ? <p className="text-sm text-destructive">{error}</p> : undefined
}
>
<FormField label={t("create.nameLabel")} required>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder={t("create.namePlaceholder")}
required
/>
</FormField>
<FormField label={t("create.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("create.descriptionPlaceholder")}
/>
</FormField>
<FormField label={t("create.subjectLabel")} required>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder={t("create.subjectPlaceholder")}
required
/>
</FormField>
<FormField label={t("create.gradeLevelLabel")} required>
<input
type="text"
value={gradeLevel}
onChange={(e) => setGradeLevel(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder={t("create.gradeLevelPlaceholder")}
required
/>
</FormField>
<FormField label={t("create.semesterLabel")} required>
<input
type="text"
value={semester}
onChange={(e) => setSemester(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder={t("create.semesterPlaceholder")}
required
/>
</FormField>
<FormField label={t("create.capacityLabel")} required>
<input
type="number"
min={1}
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required
/>
<p className="text-xs text-muted-foreground">
{t("create.capacityHint")}
</p>
</FormField>
<FormField label={t("create.teacherIdLabel")}>
<input
type="text"
value={teacherId}
onChange={(e) => setTeacherId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder={t("create.teacherIdPlaceholder")}
/>
</FormField>
<p className="text-xs text-muted-foreground">
{t("create.contractPending")}
</p>
</FormPageShell>
);
}
/**
* 表单字段容器label + children
*/
function FormField({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}): React.ReactElement {
return (
<div className="space-y-2">
<label className="text-sm font-medium">
{label}
{required ? <span className="ml-1 text-destructive">*</span> : null}
</label>
{children}
</div>
);
}

View File

@@ -0,0 +1,273 @@
"use client";
/**
* 编辑选修课表单页 - 客户端组件ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2
*
* 数据契约(@contract-pending 全 MSW
* - 单查 elective(id):❌ schema 无此字段 → MSW 兜底(用于表单预填)
* - mutation updateElective(input):❌ schema 无 Mutation 类型 → MSW 兜底
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#update-elective
*
* 三态规范§11.3 DoD
* - loadingFormPageSkeleton加载预填数据
* - errorerrorSummary 表单级错误
* - successnotify.success + router.push 回列表
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { BookMarked } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useTransition, useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import {
useElective,
useUpdateElective,
type UpdateElectiveInput,
} from "@/lib/api";
import {
FormPageShell,
FormPageSkeleton,
} from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
/**
* 编辑表单客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function ElectiveEditClient(): React.ReactElement {
const t = useTranslations("elective");
const tCommon = useTranslations("common");
const router = useRouter();
const params = useParams<{ id: string }>();
const electiveId = params?.id ?? "";
const [, startTransition] = useTransition();
// @contract-pending MSW 兜底(预填数据)
const { data, loading, error } = useElective(electiveId);
// @contract-pending MSW 兜底
const { run: updateElective, loading: submitting } = useUpdateElective();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [capacity, setCapacity] = useState("30");
const [semester, setSemester] = useState("");
const [gradeLevel, setGradeLevel] = useState("");
const [subject, setSubject] = useState("");
const [teacherId, setTeacherId] = useState("");
const [status, setStatus] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const [initialized, setInitialized] = useState(false);
// 首次拿到数据时初始化表单
useEffect(() => {
if (data && !initialized) {
setName(data.name);
setDescription(data.description);
setCapacity(String(data.capacity));
setSemester(data.semester);
setGradeLevel(data.gradeLevel);
setSubject(data.subject);
setTeacherId(data.teacherId);
setStatus(data.status);
setInitialized(true);
}
}, [data, initialized]);
const handleFormSubmit = (): void => {
setFormError(null);
if (!name.trim()) {
setFormError(t("create.errorNameRequired"));
return;
}
if (!gradeLevel.trim()) {
setFormError(t("create.errorGradeLevelRequired"));
return;
}
if (!subject.trim()) {
setFormError(t("create.errorSubjectRequired"));
return;
}
const capacityNum = Number(capacity);
if (!Number.isFinite(capacityNum) || capacityNum <= 0) {
setFormError(t("create.errorCapacityInvalid"));
return;
}
const input: UpdateElectiveInput = {
id: electiveId,
name: name.trim(),
description: description.trim() || undefined,
capacity: capacityNum,
semester: semester.trim() || undefined,
gradeLevel: gradeLevel.trim(),
subject: subject.trim(),
teacherId: teacherId.trim() || undefined,
status: (status || undefined) as UpdateElectiveInput["status"],
};
void (async (): Promise<void> => {
try {
await updateElective(input);
notify.success(t("edit.saveSuccess"));
startTransition(() => {
router.push("/shell/teacher/elective");
});
} catch (err) {
setFormError(`${t("edit.saveFailed")}: ${String(err)}`);
}
})();
};
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("edit.mswNotice")}
</p>
</div>
) : undefined;
const emptyNode =
!loading && !error && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("edit.notFound")}
</div>
) : undefined;
return (
<FormPageShell
title={t("edit.title")}
description={t("edit.description")}
icon={<BookMarked className="size-6" />}
backHref="/shell/teacher/elective"
onSubmit={handleFormSubmit}
submitting={submitting}
submitLabel={t("edit.save")}
cancelLabel={tCommon("button.cancel")}
errorSummary={
formError ? (
<p className="text-sm text-destructive">{formError}</p>
) : undefined
}
loading={loading}
loadingNode={errorNode ?? emptyNode ?? <FormPageSkeleton />}
>
{data && initialized ? (
<>
<FormField label={t("create.nameLabel")} required>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required
/>
</FormField>
<FormField label={t("create.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"
/>
</FormField>
<FormField label={t("create.subjectLabel")} required>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required
/>
</FormField>
<FormField label={t("create.gradeLevelLabel")} required>
<input
type="text"
value={gradeLevel}
onChange={(e) => setGradeLevel(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required
/>
</FormField>
<FormField label={t("create.semesterLabel")}>
<input
type="text"
value={semester}
onChange={(e) => setSemester(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
</FormField>
<FormField label={t("create.capacityLabel")} required>
<input
type="number"
min={1}
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required
/>
</FormField>
<FormField label={t("create.teacherIdLabel")}>
<input
type="text"
value={teacherId}
onChange={(e) => setTeacherId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
</FormField>
<FormField label={t("edit.statusLabel")}>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
>
<option value="DRAFT">{t("list.statusDraft")}</option>
<option value="OPEN">{t("list.statusOpen")}</option>
<option value="CLOSED">{t("list.statusClosed")}</option>
<option value="ARCHIVED">{t("list.statusArchived")}</option>
</select>
</FormField>
<p className="text-xs text-muted-foreground">
{t("edit.contractPending")}
</p>
</>
) : null}
</FormPageShell>
);
}
/**
* 表单字段容器label + children
*/
function FormField({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}): React.ReactElement {
return (
<div className="space-y-2">
<label className="text-sm font-medium">
{label}
{required ? <span className="ml-1 text-destructive">*</span> : null}
</label>
{children}
</div>
);
}

View File

@@ -0,0 +1,242 @@
"use client";
/**
* 选修课列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* 数据契约:
* - 列表查询 electives(status, q):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#electives-list
*
* URL 状态:?status=xxx &q=xxx
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { BookMarked } 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 { useElectives, type ElectiveListItem } 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 {
calcEnrollmentRate,
electiveStatusToBadgeClass,
enrollmentRateToColorClass,
formatElectiveDate,
formatElectiveStatus,
formatEnrollmentCount,
} from "@/features/teacher/elective/transformations";
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。
*/
export function ElectiveListClient(): React.ReactElement {
const t = useTranslations("elective");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const statusFilter = searchParams.get("status") ?? "";
const q = searchParams.get("q") ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useElectives({
status: statusFilter || undefined,
q: q || undefined,
});
// 客户端二次筛选q—— 后端补齐列表查询后改服务端筛选
const filteredItems = useMemo<ElectiveListItem[]>(() => {
const items = data?.items ?? [];
return items.filter((item) => {
if (q && !item.name.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/elective?${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={<BookMarked className="size-6" />}
actions={
<Button asChild>
<Link href="/shell/teacher/elective/create">{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="OPEN">{t("list.statusOpen")}</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>
}
>
<ElectiveTable items={filteredItems} />
</ListPageShell>
);
}
/**
* 选修课列表表格(纯展示组件,对齐 §8.2 排版规范)。
*/
function ElectiveTable({
items,
}: {
items: ElectiveListItem[];
}): React.ReactElement {
const t = useTranslations("elective");
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.colSubject")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colTeacher")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colEnrolled")}
</th>
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
<th className="p-3 text-left font-medium">
{t("list.colUpdatedAt")}
</th>
<th className="p-3 text-right font-medium">
{t("list.colActions")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item) => {
const rate = calcEnrollmentRate(item);
return (
<tr key={item.id} className="hover:bg-muted/30">
<td className="p-3">
<Link
href={`/shell/teacher/elective/${item.id}/edit`}
className="font-medium hover:underline"
>
{item.name}
</Link>
{item.description ? (
<p className="mt-1 text-xs text-muted-foreground">
{item.description}
</p>
) : null}
</td>
<td className="p-3 text-muted-foreground">{item.subject}</td>
<td className="p-3 text-muted-foreground">
{item.teacherName}
</td>
<td className="p-3">
<div className="flex items-center gap-2">
<span
className={`text-xs ${enrollmentRateToColorClass(rate)}`}
>
{formatEnrollmentCount(item)}
</span>
</div>
</td>
<td className="p-3">
<ElectiveStatusBadge status={item.status} />
</td>
<td className="p-3 font-mono text-xs">
{formatElectiveDate(item.updatedAt)}
</td>
<td className="p-3 text-right">
<Link
href={`/shell/teacher/elective/${item.id}/edit`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("list.edit")}
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
/**
* 选修课状态徽章(按状态色阶展示)。
*/
function ElectiveStatusBadge({
status,
}: {
status: string;
}): React.ReactElement {
const label = formatElectiveStatus(status);
const cls = electiveStatusToBadgeClass(status);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,207 @@
/**
* Elective 数据变换工具ARCHITECTURE.md §11.3 DoD - 纯函数单测)
*
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
* 关联ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
*/
import type { Elective, ElectiveListItem, ElectiveStatus } from "@/lib/api";
/** 选修课状态中文标签映射 */
export const ELECTIVE_STATUS_LABEL: Record<string, string> = {
DRAFT: "草稿",
OPEN: "报名中",
CLOSED: "已关闭",
ARCHIVED: "已归档",
};
/**
* 将选修课状态枚举值映射为中文标签。
* 未知状态回退为原始值。
*/
export function formatElectiveStatus(status: string): string {
return ELECTIVE_STATUS_LABEL[status] ?? status;
}
/**
* 格式化 ISO 日期字符串为本地化展示zh-CN含年月日时分
* 输入无效时返回占位符。
*/
export function formatElectiveDate(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",
});
}
/**
* 根据选修课状态返回 Tailwind 徽章语义类名。
*/
export function electiveStatusToBadgeClass(status: string): string {
switch (status) {
case "DRAFT":
return "bg-muted text-muted-foreground";
case "OPEN":
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";
}
}
/**
* 计算报名率enrolledCount / capacity * 100
* capacity 为 0 或输入无效时返回 0。
*/
export function calcEnrollmentRate(item: {
capacity: number;
enrolledCount: number;
}): number {
if (
!Number.isFinite(item.capacity) ||
!Number.isFinite(item.enrolledCount) ||
item.capacity <= 0
) {
return 0;
}
const ratio = item.enrolledCount / item.capacity;
if (ratio < 0) return 0;
if (ratio > 1) return 100;
return Math.round(ratio * 100);
}
/**
* 根据报名率0-100返回 Tailwind 文本语义类名。
* - >= 90 → destructive接近满员
* - >= 50 → amber半满
* - > 0 → primary
* - == 0 → muted
*/
export function enrollmentRateToColorClass(rate: number): string {
if (!Number.isFinite(rate) || rate < 0 || rate > 100) {
return "text-muted-foreground";
}
if (rate >= 90) return "text-destructive";
if (rate >= 50) return "text-amber-600 dark:text-amber-400";
if (rate > 0) return "text-primary";
return "text-muted-foreground";
}
/**
* 格式化报名进度展示(如 "20/30")。
* 输入无效返回 "--"。
*/
export function formatEnrollmentCount(item: {
capacity: number;
enrolledCount: number;
}): string {
if (!Number.isFinite(item.capacity) || !Number.isFinite(item.enrolledCount)) {
return "--";
}
return `${item.enrolledCount}/${item.capacity}`;
}
/**
* 判断选修课是否可编辑DRAFT 状态)。
*/
export function isElectiveEditable(status: string): boolean {
return status === "DRAFT";
}
/**
* 判断选修课是否可报名OPEN 状态)。
*/
export function isElectiveOpen(status: string): boolean {
return status === "OPEN";
}
/**
* 判断选修课是否已关闭CLOSED
*/
export function isElectiveClosed(status: string): boolean {
return status === "CLOSED";
}
/**
* 判断选修课是否已归档ARCHIVED
*/
export function isElectiveArchived(status: string): boolean {
return status === "ARCHIVED";
}
/**
* 将 ElectiveStatus 类型守卫:判断字符串是否为合法状态。
*/
export function isValidElectiveStatus(
status: string,
): status is ElectiveStatus {
return (
status === "DRAFT" ||
status === "OPEN" ||
status === "CLOSED" ||
status === "ARCHIVED"
);
}
/**
* 从 Elective 实体中提取列表项ElectiveListItem 与 Elective 同构)。
* 保留为显式函数以对齐模块模式,便于未来字段裁剪。
*/
export function toElectiveListItem(item: Elective): ElectiveListItem {
return { ...item };
}
/**
* 判断选修课是否有描述。
*/
export function hasDescription(
description: string | null | undefined,
): boolean {
return Boolean(description && description.trim().length > 0);
}
/**
* 判断选修课是否还有名额enrolledCount < capacity
* 输入无效或 capacity 为 0 时返回 false。
*/
export function hasAvailableSpot(item: {
capacity: number;
enrolledCount: number;
}): boolean {
if (
!Number.isFinite(item.capacity) ||
!Number.isFinite(item.enrolledCount) ||
item.capacity <= 0
) {
return false;
}
return item.enrolledCount < item.capacity;
}
/**
* 计算剩余名额。
* 输入无效返回 0。
*/
export function calcAvailableCount(item: {
capacity: number;
enrolledCount: number;
}): number {
if (
!Number.isFinite(item.capacity) ||
!Number.isFinite(item.enrolledCount) ||
item.capacity <= 0
) {
return 0;
}
const remain = item.capacity - item.enrolledCount;
return remain > 0 ? remain : 0;
}

View File

@@ -0,0 +1,280 @@
"use client";
/**
* Course Plans domain APIARCHITECTURE.md §5.1 / §5.3 / §9.1 教师域课程计划模块)
*
* 契约状态:全 ❌schema 无 coursePlan(id) / coursePlans 根字段,也无 Mutation 类型)
* → 所有查询与 mutation 走 MSW 兜底(@contract-pending
*
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#course-plans
* 后端补齐后:重跑 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 {
CREATE_COURSE_PLAN_DOC,
GET_COURSE_PLAN_DOC,
GET_COURSE_PLANS_DOC,
UPDATE_COURSE_PLAN_DOC,
} from "./operations/course-plans.graphql";
import type { UseQueryResult } from "./types";
// ===== 数据类型(@contract-pending与 MSW mock 数据形状对齐)=====
/** 课程计划状态枚举 */
export type CoursePlanStatus =
"DRAFT" | "IN_PROGRESS" | "COMPLETED" | "ARCHIVED";
/** 单元状态枚举 */
export type CoursePlanUnitStatus = "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED";
/** 课程计划单元(详情页中的单元项) */
export interface CoursePlanUnit {
id: string;
title: string;
order: number;
lessonCount: number;
completedLessonCount: number;
status: CoursePlanUnitStatus;
}
/**
* 课程计划详情实体(用于详情页)。
*
* schema 无 CoursePlan 类型,字段形状由 MSW mock 定义。
* 后端补齐后对齐真实 schema。
*/
export interface CoursePlanDetail {
id: string;
name: string;
gradeId: string;
subjectId: string;
semester: string;
status: CoursePlanStatus;
description: string;
objectives: string;
units: CoursePlanUnit[];
progress: number;
createdAt: string;
updatedAt: string;
}
/** 课程计划列表项(轻量字段集,用于列表渲染) */
export interface CoursePlanListItem {
id: string;
name: string;
gradeId: string;
subjectId: string;
semester: string;
status: CoursePlanStatus;
description: string;
createdAt: string;
updatedAt: string;
}
/** 列表查询响应(@contract-pending 假契约形状MSW 返回此结构) */
interface CoursePlansListResponse {
coursePlans: {
items: CoursePlanListItem[];
total: number;
};
}
/** 单查响应(@contract-pendingMSW 返回此结构) */
interface CoursePlanResponse {
coursePlan: CoursePlanDetail | null;
}
/** 创建课程计划输入 */
export interface CreateCoursePlanInput {
name: string;
gradeId: string;
subjectId: string;
semester: string;
description?: string;
objectives?: string;
}
/** 创建课程计划 mutation 响应(@contract-pending */
interface CreateCoursePlanResponse {
createCoursePlan: { id: string } | null;
}
/** 更新课程计划输入 */
export interface UpdateCoursePlanInput {
id: string;
name?: string;
description?: string;
objectives?: string;
status?: CoursePlanStatus;
}
/** 更新课程计划 mutation 响应(@contract-pending */
interface UpdateCoursePlanResponse {
updateCoursePlan: { id: string } | null;
}
// ===== 筛选类型 =====
export interface CoursePlansListFilter {
gradeId?: string;
subjectId?: string;
status?: string;
q?: string;
limit?: number;
offset?: number;
}
// ===== 查询选项 =====
export interface CoursePlanQueryOptions {
enabled?: boolean;
pollInterval?: number;
fetchPolicy?: FetchPolicy;
}
// ===== Hooks =====
/**
* 查询课程计划列表(@contract-pendingMSW 兜底)。
*
* schema 无 coursePlans 根字段,由 MSW handlers 返回 mock 数据。
* 后端补齐列表查询后切换到真实 fetcher页面无需改动。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 列表页 / §11.4 契约工单
*/
export function useCoursePlans(
filter: CoursePlansListFilter,
options?: CoursePlanQueryOptions,
): UseQueryResult<{ items: CoursePlanListItem[]; total: number }> {
const result = useWidgetQuery<
CoursePlansListResponse,
{
gradeId?: string;
subjectId?: string;
status?: string;
q?: string;
limit?: number;
offset?: number;
}
>(
GET_COURSE_PLANS_DOC,
{
gradeId: filter.gradeId,
subjectId: filter.subjectId,
status: filter.status,
q: filter.q,
limit: filter.limit,
offset: filter.offset,
},
{
enabled: options?.enabled ?? true,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
},
);
return {
data: result.data?.coursePlans,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 按 id 查询课程计划详情(@contract-pendingMSW 兜底)。
*
* schema 无 coursePlan(id) 根字段,由 MSW handlers 返回 mock 数据。
* 用于 /shell/teacher/course-plans/[id] 详情页。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 详情页 / §11.4 契约工单
*/
export function useCoursePlan(
id: string,
options?: CoursePlanQueryOptions,
): UseQueryResult<CoursePlanDetail | null> {
const result = useWidgetQuery<CoursePlanResponse, { id: string }>(
GET_COURSE_PLAN_DOC,
{ id },
{
...options,
enabled: options?.enabled ?? id.length > 0,
},
);
return {
data: result.data?.coursePlan ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 创建课程计划 mutation@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
* 后端补齐 mutation 后切换到真实 fetcher。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4 契约工单
*/
export function useCreateCoursePlan(): {
run: (input: CreateCoursePlanInput) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
CreateCoursePlanResponse,
{ input: CreateCoursePlanInput }
>(CREATE_COURSE_PLAN_DOC);
const run = async (input: CreateCoursePlanInput): Promise<{ id: string }> => {
const data = await rawRun({ input });
if (!data?.createCoursePlan) {
throw new ApiError("Failed to create course plan", "INTERNAL_ERROR");
}
return data.createCoursePlan;
};
return { run, loading, error };
}
/**
* 更新课程计划 mutation@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4 契约工单
*/
export function useUpdateCoursePlan(): {
run: (input: UpdateCoursePlanInput) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
UpdateCoursePlanResponse,
{ input: UpdateCoursePlanInput }
>(UPDATE_COURSE_PLAN_DOC);
const run = async (input: UpdateCoursePlanInput): Promise<{ id: string }> => {
const data = await rawRun({ input });
if (!data?.updateCoursePlan) {
throw new ApiError("Failed to update course plan", "INTERNAL_ERROR");
}
return data.updateCoursePlan;
};
return { run, loading, error };
}

View File

@@ -0,0 +1,255 @@
"use client";
/**
* Elective domain APIARCHITECTURE.md §5.1 / §5.3 / §9.1 教师域选修课模块)
*
* 契约状态:全 ❌schema 无 electives / elective(id) 根字段,也无 Mutation 类型)
* → 所有查询与 mutation 走 MSW 兜底(@contract-pending
*
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#elective
* 后端补齐后:重跑 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 {
CREATE_ELECTIVE_DOC,
GET_ELECTIVE_DOC,
GET_ELECTIVES_DOC,
UPDATE_ELECTIVE_DOC,
} from "./operations/elective.graphql";
import type { UseQueryResult } from "./types";
// ===== 数据类型(@contract-pending与 MSW mock 数据形状对齐)=====
/** 选修课状态枚举 */
export type ElectiveStatus = "DRAFT" | "OPEN" | "CLOSED" | "ARCHIVED";
/**
* 选修课实体(列表项与单查同构,@contract-pending
*
* schema 无 Elective 类型,字段形状由 MSW mock 定义。
* 后端补齐后对齐真实 schema。
*/
export interface Elective {
id: string;
name: string;
description: string;
capacity: number;
enrolledCount: number;
semester: string;
gradeLevel: string;
subject: string;
teacherId: string;
teacherName: string;
status: ElectiveStatus;
createdAt: string;
updatedAt: string;
}
/** 列表项(与 Elective 同构) */
export type ElectiveListItem = Elective;
/** 列表查询响应(@contract-pending 假契约形状MSW 返回此结构) */
interface ElectivesListResponse {
electives: {
items: ElectiveListItem[];
total: number;
};
}
/** 单查响应(@contract-pendingMSW 返回此结构) */
interface ElectiveResponse {
elective: Elective | null;
}
/** 创建选修课输入 */
export interface CreateElectiveInput {
name: string;
description?: string;
capacity: number;
semester: string;
gradeLevel: string;
subject: string;
teacherId?: string;
}
/** 创建选修课 mutation 响应(@contract-pending */
interface CreateElectiveResponse {
createElective: { id: string } | null;
}
/** 更新选修课输入 */
export interface UpdateElectiveInput {
id: string;
name?: string;
description?: string;
capacity?: number;
semester?: string;
gradeLevel?: string;
subject?: string;
teacherId?: string;
status?: ElectiveStatus;
}
/** 更新选修课 mutation 响应(@contract-pending */
interface UpdateElectiveResponse {
updateElective: { id: string } | null;
}
// ===== 筛选类型 =====
export interface ElectivesListFilter {
status?: string;
q?: string;
limit?: number;
offset?: number;
}
// ===== 查询选项 =====
export interface ElectiveQueryOptions {
enabled?: boolean;
pollInterval?: number;
fetchPolicy?: FetchPolicy;
}
// ===== Hooks =====
/**
* 查询选修课列表(@contract-pendingMSW 兜底)。
*
* schema 无 electives 根字段,由 MSW handlers 返回 mock 数据。
* 后端补齐列表查询后切换到真实 fetcher页面无需改动。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 列表页 / §11.4 契约工单
*/
export function useElectives(
filter: ElectivesListFilter,
options?: ElectiveQueryOptions,
): UseQueryResult<{ items: ElectiveListItem[]; total: number }> {
const result = useWidgetQuery<
ElectivesListResponse,
{
status?: string;
q?: string;
limit?: number;
offset?: number;
}
>(
GET_ELECTIVES_DOC,
{
status: filter.status,
q: filter.q,
limit: filter.limit,
offset: filter.offset,
},
{
enabled: options?.enabled ?? true,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
},
);
return {
data: result.data?.electives,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 按 id 查询选修课详情(@contract-pendingMSW 兜底)。
*
* schema 无 elective(id) 根字段,由 MSW handlers 返回 mock 数据。
* 用于 /shell/teacher/elective/[id]/edit 编辑表单页预填。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 表单页 / §11.4 契约工单
*/
export function useElective(
id: string,
options?: ElectiveQueryOptions,
): UseQueryResult<Elective | null> {
const result = useWidgetQuery<ElectiveResponse, { id: string }>(
GET_ELECTIVE_DOC,
{ id },
{
...options,
enabled: options?.enabled ?? id.length > 0,
},
);
return {
data: result.data?.elective ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 创建选修课 mutation@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
* 用于 /shell/teacher/elective/create 表单页。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 表单页 / §11.4 契约工单
*/
export function useCreateElective(): {
run: (input: CreateElectiveInput) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<CreateElectiveResponse, { input: CreateElectiveInput }>(
CREATE_ELECTIVE_DOC,
);
const run = async (input: CreateElectiveInput): Promise<{ id: string }> => {
const data = await rawRun({ input });
if (!data?.createElective) {
throw new ApiError("Failed to create elective", "INTERNAL_ERROR");
}
return data.createElective;
};
return { run, loading, error };
}
/**
* 更新选修课 mutation@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
* 用于 /shell/teacher/elective/[id]/edit 编辑表单页保存。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 表单页 / §11.4 契约工单
*/
export function useUpdateElective(): {
run: (input: UpdateElectiveInput) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<UpdateElectiveResponse, { input: UpdateElectiveInput }>(
UPDATE_ELECTIVE_DOC,
);
const run = async (input: UpdateElectiveInput): Promise<{ id: string }> => {
const data = await rawRun({ input });
if (!data?.updateElective) {
throw new ApiError("Failed to update elective", "INTERNAL_ERROR");
}
return data.updateElective;
};
return { run, loading, error };
}

View File

@@ -24,5 +24,7 @@ export * from "./attendance";
export * from "./classes"; export * from "./classes";
export * from "./students"; export * from "./students";
export * from "./student"; export * from "./student";
export * from "./course-plans";
export * from "./elective";
export * from "./parent"; export * from "./parent";
export * from "./admin"; export * from "./admin";

View File

@@ -0,0 +1,100 @@
// Course Plans domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
//
// 拆分原则:
// - 全部操作:❌ schema 无 coursePlans / coursePlan(id) 根字段,也无 Mutation 类型
// → 走 MSW 兜底(@contract-pending等待后端补齐契约
//
// 契约工单docs/architecture/issues/contracts/core-edu_contract.md#course-plans
// 关联ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §11.4
import { gql } from "@apollo/client";
// ── 假契约查询(@contract-pending─────────────────────────────
// 列表查询schema 无 coursePlans(...) 根字段
// 页面通过 MSW 兜底获取列表数据,后端补齐后切换 fetcher 指向真实查询
// 契约工单core-edu_contract.md#course-plans-list
export const GET_COURSE_PLANS_DOC = gql`
query GetCoursePlans(
$gradeId: ID
$subjectId: ID
$status: String
$q: String
$limit: Int
$offset: Int
) {
coursePlans(
gradeId: $gradeId
subjectId: $subjectId
status: $status
q: $q
limit: $limit
offset: $offset
) {
items {
id
name
gradeId
subjectId
semester
status
description
createdAt
updatedAt
}
total
}
}
`;
// ── 单查(@contract-pending────────────────────────────────────
// schema 无 coursePlan(id) 根字段 → MSW 兜底
// 用于 /shell/teacher/course-plans/[id] 详情页
// 契约工单core-edu_contract.md#course-plan-detail
export const GET_COURSE_PLAN_DOC = gql`
query GetCoursePlan($id: ID!) {
coursePlan(id: $id) {
id
name
gradeId
subjectId
semester
status
description
objectives
units {
id
title
order
lessonCount
completedLessonCount
status
}
progress
createdAt
updatedAt
}
}
`;
// ── 假契约变更(@contract-pending─────────────────────────────
// 创建课程计划schema 无 Mutation 类型
// 页面通过 MSW 兜底提交,后端补齐 mutation 后切换 fetcher
// 契约工单core-edu_contract.md#create-course-plan
export const CREATE_COURSE_PLAN_DOC = gql`
mutation CreateCoursePlan($input: CreateCoursePlanInput!) {
createCoursePlan(input: $input) {
id
}
}
`;
// ── 更新课程计划 mutation@contract-pending──────────────────
// schema 无 Mutation 类型 → MSW 兜底
// 用于编辑/状态变更
// 契约工单core-edu_contract.md#update-course-plan
export const UPDATE_COURSE_PLAN_DOC = gql`
mutation UpdateCoursePlan($input: UpdateCoursePlanInput!) {
updateCoursePlan(input: $input) {
id
}
}
`;

View File

@@ -0,0 +1,84 @@
// Elective domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
//
// 拆分原则:
// - 全部操作:❌ schema 无 electives / elective(id) 根字段,也无 Mutation 类型
// → 走 MSW 兜底(@contract-pending等待后端补齐契约
//
// 契约工单docs/architecture/issues/contracts/core-edu_contract.md#elective
// 关联ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §11.4
import { gql } from "@apollo/client";
// ── 假契约查询(@contract-pending─────────────────────────────
// 列表查询schema 无 electives(...) 根字段
// 页面通过 MSW 兜底获取列表数据,后端补齐后切换 fetcher 指向真实查询
// 契约工单core-edu_contract.md#electives-list
export const GET_ELECTIVES_DOC = gql`
query GetElectives($status: String, $q: String, $limit: Int, $offset: Int) {
electives(status: $status, q: $q, limit: $limit, offset: $offset) {
items {
id
name
description
capacity
enrolledCount
semester
gradeLevel
subject
teacherId
teacherName
status
createdAt
updatedAt
}
total
}
}
`;
// ── 单查(@contract-pending────────────────────────────────────
// schema 无 elective(id) 根字段 → MSW 兜底
// 用于 /shell/teacher/elective/[id]/edit 编辑表单页预填
// 契约工单core-edu_contract.md#elective-by-id
export const GET_ELECTIVE_DOC = gql`
query GetElective($id: ID!) {
elective(id: $id) {
id
name
description
capacity
enrolledCount
semester
gradeLevel
subject
teacherId
teacherName
status
createdAt
updatedAt
}
}
`;
// ── 假契约变更(@contract-pending─────────────────────────────
// 创建选修课schema 无 Mutation 类型
// 页面通过 MSW 兜底提交,后端补齐 mutation 后切换 fetcher
// 契约工单core-edu_contract.md#create-elective
export const CREATE_ELECTIVE_DOC = gql`
mutation CreateElective($input: CreateElectiveInput!) {
createElective(input: $input) {
id
}
}
`;
// ── 更新选修课 mutation@contract-pending────────────────────
// schema 无 Mutation 类型 → MSW 兜底
// 用于 /shell/teacher/elective/[id]/edit 编辑表单页保存
// 契约工单core-edu_contract.md#update-elective
export const UPDATE_ELECTIVE_DOC = gql`
mutation UpdateElective($input: UpdateElectiveInput!) {
updateElective(input: $input) {
id
}
}
`;

View File

@@ -14,5 +14,7 @@ export * from "./attendance.graphql";
export * from "./classes.graphql"; export * from "./classes.graphql";
export * from "./students.graphql"; export * from "./students.graphql";
export * from "./student.graphql"; export * from "./student.graphql";
export * from "./course-plans.graphql";
export * from "./elective.graphql";
export * from "./parent.graphql"; export * from "./parent.graphql";
export * from "./admin.graphql"; export * from "./admin.graphql";

View File

@@ -1030,7 +1030,59 @@
} }
}, },
"coursePlans": { "coursePlans": {
"title": "Course Plans" "title": "Course Plans",
"list": {
"title": "Course Plans",
"description": "View and manage all course plans",
"searchPlaceholder": "Search plan name...",
"gradePlaceholder": "Grade ID",
"subjectPlaceholder": "Subject ID",
"statusFilter": "Filter by status",
"statusAll": "All statuses",
"statusDraft": "Draft",
"statusInProgress": "In Progress",
"statusCompleted": "Completed",
"statusArchived": "Archived",
"total": "{count} total",
"colName": "Name",
"colSemester": "Semester",
"colStatus": "Status",
"colUpdatedAt": "Updated",
"colActions": "Actions",
"viewDetail": "View Detail →",
"mswNotice": "List query contract pending; ensure NEXT_PUBLIC_MSW=1 is enabled."
},
"detail": {
"title": "Course Plan Detail",
"notFound": "Course plan not found, may have been deleted",
"backToList": "Back to list",
"createdAtPrefix": "Created on {date}",
"mswNotice": "Detail query contract pending (@contract-pending).",
"sectionBasic": "Basic Information",
"sectionUnits": "Units",
"noUnits": "No units",
"fieldName": "Name",
"fieldGradeId": "Grade ID",
"fieldSubjectId": "Subject ID",
"fieldSemester": "Semester",
"fieldStatus": "Status",
"fieldProgress": "Overall Progress",
"fieldDescription": "Description",
"noDescription": "No description",
"fieldObjectives": "Objectives",
"noObjectives": "No objectives",
"fieldCreatedAt": "Created",
"fieldUpdatedAt": "Updated",
"colUnitOrder": "#",
"colUnitTitle": "Unit Title",
"colUnitProgress": "Progress",
"colUnitStatus": "Status"
},
"error": {
"title": "Course Plans module error",
"unknown": "An unknown error occurred in the Course Plans module",
"retry": "Retry"
}
}, },
"diagnostic": { "diagnostic": {
"title": "Diagnostic" "title": "Diagnostic"
@@ -1042,7 +1094,72 @@
"title": "Practice" "title": "Practice"
}, },
"elective": { "elective": {
"title": "Elective" "title": "Elective",
"list": {
"title": "Elective Management",
"description": "View and manage all elective courses",
"searchPlaceholder": "Search elective name...",
"statusFilter": "Filter by status",
"statusAll": "All statuses",
"statusDraft": "Draft",
"statusOpen": "Open",
"statusClosed": "Closed",
"statusArchived": "Archived",
"total": "{count} total",
"new": "New Elective",
"colName": "Name",
"colSubject": "Subject",
"colTeacher": "Teacher",
"colEnrolled": "Enrolled",
"colStatus": "Status",
"colUpdatedAt": "Updated",
"colActions": "Actions",
"edit": "Edit",
"mswNotice": "List query contract pending; ensure NEXT_PUBLIC_MSW=1 is enabled."
},
"create": {
"title": "New Elective",
"description": "Fill in elective course information",
"submit": "Create Elective",
"success": "Elective created successfully",
"error": "Creation failed",
"nameLabel": "Name",
"namePlaceholder": "e.g. Advanced Mathematics",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Course summary, target audience, etc.",
"subjectLabel": "Subject",
"subjectPlaceholder": "e.g. Mathematics",
"gradeLevelLabel": "Grade Level",
"gradeLevelPlaceholder": "e.g. grade-11",
"semesterLabel": "Semester",
"semesterPlaceholder": "e.g. 2026-fall",
"capacityLabel": "Capacity",
"capacityHint": "Maximum number of enrollable students",
"teacherIdLabel": "Teacher ID",
"teacherIdPlaceholder": "e.g. tch-001; leave empty to assign later",
"errorNameRequired": "Please fill in name",
"errorGradeLevelRequired": "Please fill in grade level",
"errorSubjectRequired": "Please fill in subject",
"errorSemesterRequired": "Please fill in semester",
"errorCapacityInvalid": "Capacity must be a positive integer",
"contractPending": "Create elective contract is @contract-pending; currently using MSW fallback. Will switch to real submission once backend mutation is ready."
},
"edit": {
"title": "Edit Elective",
"description": "Modify elective course information",
"save": "Save Changes",
"saveSuccess": "Saved successfully",
"saveFailed": "Save failed",
"notFound": "Elective not found, may have been deleted",
"mswNotice": "Single query and update are @contract-pending (MSW fallback).",
"statusLabel": "Status",
"contractPending": "Update elective contract is @contract-pending; currently using MSW fallback."
},
"error": {
"title": "Elective module error",
"unknown": "An unknown error occurred in the Elective module",
"retry": "Retry"
}
}, },
"leave": { "leave": {
"title": "Leave Requests" "title": "Leave Requests"

View File

@@ -1030,7 +1030,59 @@
} }
}, },
"coursePlans": { "coursePlans": {
"title": "课程计划" "title": "课程计划",
"list": {
"title": "课程计划",
"description": "查看和管理所有课程计划",
"searchPlaceholder": "搜索计划名称...",
"gradePlaceholder": "年级 ID",
"subjectPlaceholder": "科目 ID",
"statusFilter": "按状态筛选",
"statusAll": "全部状态",
"statusDraft": "草稿",
"statusInProgress": "进行中",
"statusCompleted": "已完成",
"statusArchived": "已归档",
"total": "共 {count} 条",
"colName": "名称",
"colSemester": "学期",
"colStatus": "状态",
"colUpdatedAt": "更新时间",
"colActions": "操作",
"viewDetail": "查看详情 →",
"mswNotice": "列表查询契约待补齐,请确认 NEXT_PUBLIC_MSW=1 已启用。"
},
"detail": {
"title": "课程计划详情",
"notFound": "未找到课程计划,可能已被删除",
"backToList": "返回列表",
"createdAtPrefix": "创建于 {date}",
"mswNotice": "详情查询契约待补齐(@contract-pending。",
"sectionBasic": "基本信息",
"sectionUnits": "单元列表",
"noUnits": "暂无单元",
"fieldName": "名称",
"fieldGradeId": "年级 ID",
"fieldSubjectId": "科目 ID",
"fieldSemester": "学期",
"fieldStatus": "状态",
"fieldProgress": "总体进度",
"fieldDescription": "描述",
"noDescription": "暂无描述",
"fieldObjectives": "教学目标",
"noObjectives": "暂无目标",
"fieldCreatedAt": "创建时间",
"fieldUpdatedAt": "更新时间",
"colUnitOrder": "序号",
"colUnitTitle": "单元标题",
"colUnitProgress": "进度",
"colUnitStatus": "状态"
},
"error": {
"title": "课程计划模块出错了",
"unknown": "课程计划模块发生未知错误",
"retry": "重试"
}
}, },
"diagnostic": { "diagnostic": {
"title": "诊断报告" "title": "诊断报告"
@@ -1042,7 +1094,72 @@
"title": "练习分析" "title": "练习分析"
}, },
"elective": { "elective": {
"title": "选修课" "title": "选修课",
"list": {
"title": "选修课管理",
"description": "查看和管理所有选修课",
"searchPlaceholder": "搜索选修课名称...",
"statusFilter": "按状态筛选",
"statusAll": "全部状态",
"statusDraft": "草稿",
"statusOpen": "报名中",
"statusClosed": "已关闭",
"statusArchived": "已归档",
"total": "共 {count} 条",
"new": "新建选修课",
"colName": "名称",
"colSubject": "科目",
"colTeacher": "任课老师",
"colEnrolled": "报名情况",
"colStatus": "状态",
"colUpdatedAt": "更新时间",
"colActions": "操作",
"edit": "编辑",
"mswNotice": "列表查询契约待补齐,请确认 NEXT_PUBLIC_MSW=1 已启用。"
},
"create": {
"title": "新建选修课",
"description": "填写选修课基本信息",
"submit": "创建选修课",
"success": "选修课创建成功",
"error": "创建失败",
"nameLabel": "名称",
"namePlaceholder": "例如:高等数学拓展",
"descriptionLabel": "描述",
"descriptionPlaceholder": "课程简介、适用人群等",
"subjectLabel": "科目",
"subjectPlaceholder": "例如:数学",
"gradeLevelLabel": "年级",
"gradeLevelPlaceholder": "例如grade-11",
"semesterLabel": "学期",
"semesterPlaceholder": "例如2026-fall",
"capacityLabel": "名额上限",
"capacityHint": "可报名的学生数量上限",
"teacherIdLabel": "任课老师 ID",
"teacherIdPlaceholder": "如 tch-001留空表示待分配",
"errorNameRequired": "请填写名称",
"errorGradeLevelRequired": "请填写年级",
"errorSubjectRequired": "请填写科目",
"errorSemesterRequired": "请填写学期",
"errorCapacityInvalid": "名额上限必须为正整数",
"contractPending": "创建选修课契约为 @contract-pending当前通过 MSW 兜底。后端补齐 mutation 后将切换为真实提交。"
},
"edit": {
"title": "编辑选修课",
"description": "修改选修课基本信息",
"save": "保存修改",
"saveSuccess": "保存成功",
"saveFailed": "保存失败",
"notFound": "未找到选修课,可能已被删除",
"mswNotice": "单查与更新契约为 @contract-pendingMSW 兜底)。",
"statusLabel": "状态",
"contractPending": "更新选修课契约为 @contract-pending当前通过 MSW 兜底。"
},
"error": {
"title": "选修课模块出错了",
"unknown": "选修课模块发生未知错误",
"retry": "重试"
}
}, },
"leave": { "leave": {
"title": "请假审批" "title": "请假审批"

View File

@@ -2210,6 +2210,171 @@ const mockStudents = [
}, },
]; ];
// ── Course Plans 域(教师域 B2 迁移,@contract-pending 全 MSW──
// schema 无 coursePlans / coursePlan(id) 根字段,全部走 MSW 兜底
// 契约工单docs/architecture/issues/contracts/core-edu_contract.md#course-plans
const mockCoursePlans = [
{
id: "cp-001",
name: "高一数学第一学期课程计划",
gradeId: "grade-10",
subjectId: "sub-math",
semester: "2026-fall",
status: "IN_PROGRESS",
description: "覆盖集合、函数、三角函数等核心章节",
createdAt: "2026-08-15T00:00:00Z",
updatedAt: "2026-10-01T00:00:00Z",
},
{
id: "cp-002",
name: "高一物理第一学期课程计划",
gradeId: "grade-10",
subjectId: "sub-physics",
semester: "2026-fall",
status: "DRAFT",
description: "运动学、力学基础",
createdAt: "2026-08-20T00:00:00Z",
updatedAt: "2026-08-25T00:00:00Z",
},
{
id: "cp-003",
name: "高二数学第一学期课程计划",
gradeId: "grade-11",
subjectId: "sub-math",
semester: "2026-fall",
status: "COMPLETED",
description: "数列、不等式、立体几何",
createdAt: "2026-08-10T00:00:00Z",
updatedAt: "2026-12-30T00:00:00Z",
},
{
id: "cp-004",
name: "高一语文第一学期课程计划",
gradeId: "grade-10",
subjectId: "sub-chinese",
semester: "2026-fall",
status: "ARCHIVED",
description: "古诗文、现代文阅读",
createdAt: "2025-08-15T00:00:00Z",
updatedAt: "2026-01-20T00:00:00Z",
},
];
// 课程计划详情(含单元进度,任意 id 都返回同一条 dev 兜底)
const mockCoursePlanDetail = {
id: "cp-001",
name: "高一数学第一学期课程计划",
gradeId: "grade-10",
subjectId: "sub-math",
semester: "2026-fall",
status: "IN_PROGRESS",
description: "覆盖集合、函数、三角函数等核心章节",
objectives: "掌握函数概念与基本性质,能解决简单的函数应用题",
units: [
{
id: "unit-1",
title: "集合与逻辑",
order: 1,
lessonCount: 8,
completedLessonCount: 8,
status: "COMPLETED",
},
{
id: "unit-2",
title: "函数概念与性质",
order: 2,
lessonCount: 10,
completedLessonCount: 6,
status: "IN_PROGRESS",
},
{
id: "unit-3",
title: "基本初等函数",
order: 3,
lessonCount: 12,
completedLessonCount: 0,
status: "NOT_STARTED",
},
{
id: "unit-4",
title: "三角函数",
order: 4,
lessonCount: 14,
completedLessonCount: 0,
status: "NOT_STARTED",
},
],
progress: 50,
createdAt: "2026-08-15T00:00:00Z",
updatedAt: "2026-10-01T00:00:00Z",
};
// ── Elective 域(教师域 B2 迁移,@contract-pending 全 MSW──
// schema 无 electives / elective(id) 根字段,全部走 MSW 兜底
// 契约工单docs/architecture/issues/contracts/core-edu_contract.md#elective
const mockElectives = [
{
id: "ele-001",
name: "高等数学拓展",
description: "为有兴趣深造的学生提供进阶数学内容",
capacity: 30,
enrolledCount: 20,
semester: "2026-fall",
gradeLevel: "grade-11",
subject: "数学",
teacherId: "tch-001",
teacherName: "李老师",
status: "OPEN",
createdAt: "2026-08-01T00:00:00Z",
updatedAt: "2026-09-15T00:00:00Z",
},
{
id: "ele-002",
name: "物理实验探究",
description: "通过实验探究物理原理",
capacity: 25,
enrolledCount: 25,
semester: "2026-fall",
gradeLevel: "grade-11",
subject: "物理",
teacherId: "tch-002",
teacherName: "王老师",
status: "CLOSED",
createdAt: "2026-08-05T00:00:00Z",
updatedAt: "2026-09-20T00:00:00Z",
},
{
id: "ele-003",
name: "英语口语训练营",
description: "提升英语口语表达能力",
capacity: 20,
enrolledCount: 0,
semester: "2026-fall",
gradeLevel: "grade-10",
subject: "英语",
teacherId: "",
teacherName: "",
status: "DRAFT",
createdAt: "2026-09-25T00:00:00Z",
updatedAt: "2026-09-25T00:00:00Z",
},
{
id: "ele-004",
name: "计算机编程入门",
description: "Python 编程基础",
capacity: 40,
enrolledCount: 38,
semester: "2026-spring",
gradeLevel: "grade-12",
subject: "信息技术",
teacherId: "tch-003",
teacherName: "张老师",
status: "ARCHIVED",
createdAt: "2026-02-01T00:00:00Z",
updatedAt: "2026-06-30T00:00:00Z",
},
];
// ── GraphQL Response ─────────────────────────────────────────── // ── GraphQL Response ───────────────────────────────────────────
/** /**
@@ -3172,6 +3337,144 @@ export function graphqlResponse(
}; };
} }
// ── Course Plans 域(教师域 B2 迁移,@contract-pending 全 MSW──
// schema 无 coursePlans / coursePlan(id) 根字段,全部走 MSW 兜底
// 契约工单docs/architecture/issues/contracts/core-edu_contract.md#course-plans
//
// GetCoursePlans($gradeId, $subjectId, $status, $q):列表查询
case "GetCoursePlans": {
const gradeId = variables?.gradeId as string | undefined;
const subjectId = variables?.subjectId as string | undefined;
const status = variables?.status as string | undefined;
const q = variables?.q as string | undefined;
let filtered = [...mockCoursePlans];
if (gradeId) filtered = filtered.filter((c) => c.gradeId === gradeId);
if (subjectId)
filtered = filtered.filter((c) => c.subjectId === subjectId);
if (status) filtered = filtered.filter((c) => c.status === status);
if (q) {
const ql = q.toLowerCase();
filtered = filtered.filter(
(c) =>
c.name.toLowerCase().includes(ql) ||
c.description.toLowerCase().includes(ql),
);
}
return {
data: {
coursePlans: { items: filtered, total: filtered.length },
},
};
}
// GetCoursePlan($id):按 id 单查(@contract-pendingdev 兜底返回 mockCoursePlanDetail
case "GetCoursePlan": {
const planId = (variables?.id as string | undefined) ?? "";
const listMatch = mockCoursePlans.find((c) => c.id === planId);
return {
data: {
coursePlan: listMatch
? {
...mockCoursePlanDetail,
id: listMatch.id,
name: listMatch.name,
gradeId: listMatch.gradeId,
subjectId: listMatch.subjectId,
semester: listMatch.semester,
status: listMatch.status,
description: listMatch.description,
createdAt: listMatch.createdAt,
updatedAt: listMatch.updatedAt,
}
: { ...mockCoursePlanDetail, id: planId },
},
};
}
// CreateCoursePlan($input)mutation 兜底,返回基于时间戳的新 id
case "CreateCoursePlan": {
const input = (variables?.input ?? {}) as Record<string, unknown>;
const newId = `cp-${Date.now()}`;
mockCoursePlans.push({
id: newId,
name: (input.name as string) ?? "未命名课程计划",
gradeId: (input.gradeId as string) ?? "grade-10",
subjectId: (input.subjectId as string) ?? "sub-math",
semester: (input.semester as string) ?? "2026-fall",
status: "DRAFT",
description: (input.description as string) ?? "",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
return { data: { createCoursePlan: { id: newId } } };
}
// UpdateCoursePlan($input)mutation 兜底
case "UpdateCoursePlan": {
const input = (variables?.input ?? {}) as Record<string, unknown>;
const planId = (input.id as string) ?? "cp-001";
return { data: { updateCoursePlan: { id: planId } } };
}
// ── Elective 域(教师域 B2 迁移,@contract-pending 全 MSW──
// schema 无 electives / elective(id) 根字段,全部走 MSW 兜底
// 契约工单docs/architecture/issues/contracts/core-edu_contract.md#elective
//
// GetElectives($status, $q):列表查询
case "GetElectives": {
const status = variables?.status as string | undefined;
const q = variables?.q as string | undefined;
let filtered = [...mockElectives];
if (status) filtered = filtered.filter((e) => e.status === status);
if (q) {
const ql = q.toLowerCase();
filtered = filtered.filter(
(e) =>
e.name.toLowerCase().includes(ql) ||
e.description.toLowerCase().includes(ql),
);
}
return {
data: {
electives: { items: filtered, total: filtered.length },
},
};
}
// GetElective($id):按 id 单查(@contract-pending
case "GetElective": {
const eleId = (variables?.id as string | undefined) ?? "";
const found = mockElectives.find((e) => e.id === eleId);
return {
data: {
elective: found ?? null,
},
};
}
// CreateElective($input)mutation 兜底,返回基于时间戳的新 id
case "CreateElective": {
const input = (variables?.input ?? {}) as Record<string, unknown>;
const newId = `ele-${Date.now()}`;
mockElectives.push({
id: newId,
name: (input.name as string) ?? "未命名选修课",
description: (input.description as string) ?? "",
capacity: (input.capacity as number) ?? 30,
enrolledCount: 0,
semester: (input.semester as string) ?? "2026-fall",
gradeLevel: (input.gradeLevel as string) ?? "grade-10",
subject: (input.subject as string) ?? "通用",
teacherId: (input.teacherId as string) ?? "",
teacherName: "",
status: "DRAFT",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
return { data: { createElective: { id: newId } } };
}
// UpdateElective($input)mutation 兜底
case "UpdateElective": {
const input = (variables?.input ?? {}) as Record<string, unknown>;
const eleId = (input.id as string) ?? "ele-001";
return { data: { updateElective: { id: eleId } } };
}
// ── 通用 ── // ── 通用 ──
case "GetNotificationsList": case "GetNotificationsList":
return { return {

View File

@@ -168,6 +168,23 @@ export const EXACT_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
requiredRoles: ["teacher", "admin"], requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["DIAGNOSTIC_READ", "DIAGNOSTIC_MANAGE"], anyOfPermissions: ["DIAGNOSTIC_READ", "DIAGNOSTIC_MANAGE"],
}, },
// P2 迁移B2 教师域):课程计划列表页根路由(无尾斜杠)
// 无 COURSE_PLAN_* 权限点,复用 LESSON_PLAN_READ/CREATE/UPDATE
// (课程计划与教案同属备课域,权限语义对齐)
"/shell/teacher/course-plans": {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: [
"LESSON_PLAN_READ",
"LESSON_PLAN_CREATE",
"LESSON_PLAN_UPDATE",
],
},
// P2 迁移B2 教师域):选修课列表页根路由(无尾斜杠)
// ELECTIVE_READ/MANAGE/SELECT 权限点已存在PERMISSION_BITMAP_ORDER 116-118
"/shell/teacher/elective": {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["ELECTIVE_READ", "ELECTIVE_MANAGE"],
},
// ── student 专属 ────────────────────────────────────────── // ── student 专属 ──────────────────────────────────────────
"/shell/student/error-book": { "/shell/student/error-book": {
@@ -312,6 +329,29 @@ export const PREFIX_ROUTE_PERMISSIONS: Array<{
anyOfPermissions: ["DIAGNOSTIC_READ", "DIAGNOSTIC_MANAGE"], anyOfPermissions: ["DIAGNOSTIC_READ", "DIAGNOSTIC_MANAGE"],
}, },
}, },
// P2 迁移B2 教师域):课程计划子路由(含 /shell/teacher/course-plans/[id]
// 复用 LESSON_PLAN_READ/CREATE/UPDATE同备课域
{
prefix: "/shell/teacher/course-plans/",
config: {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: [
"LESSON_PLAN_READ",
"LESSON_PLAN_CREATE",
"LESSON_PLAN_UPDATE",
],
},
},
// P2 迁移B2 教师域):选修课子路由(含 /shell/teacher/elective/create
// 和 /shell/teacher/elective/[id]/edit
// ELECTIVE_READ/MANAGE 权限点已存在
{
prefix: "/shell/teacher/elective/",
config: {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["ELECTIVE_READ", "ELECTIVE_MANAGE"],
},
},
// 公告管理 // 公告管理
{ {
prefix: "/shell/admin/announcements/", prefix: "/shell/admin/announcements/",