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:
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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 要求)。
|
||||||
|
* 业务逻辑在 CoursePlanListClient(client 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
apps/portal-shell/src/app/shell/teacher/elective/error.tsx
Normal file
38
apps/portal-shell/src/app/shell/teacher/elective/error.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选修课路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||||
|
* Next.js Route Segment error.tsx,捕获子树未处理异常。
|
||||||
|
*/
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export default function 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
apps/portal-shell/src/app/shell/teacher/elective/loading.tsx
Normal file
12
apps/portal-shell/src/app/shell/teacher/elective/loading.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选修课路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||||
|
* Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
|
||||||
|
*
|
||||||
|
* 子页面(新建/编辑)的 Skeleton 由各自 server page 的 <Suspense> 兜底,
|
||||||
|
* 本文件仅在 /shell/teacher/elective 列表/重定向期间显示。
|
||||||
|
*/
|
||||||
|
export default function ElectiveLoading(): React.ReactElement {
|
||||||
|
return <ListPageSkeleton rows={5} />;
|
||||||
|
}
|
||||||
23
apps/portal-shell/src/app/shell/teacher/elective/page.tsx
Normal file
23
apps/portal-shell/src/app/shell/teacher/elective/page.tsx
Normal 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 要求)。
|
||||||
|
* 业务逻辑在 ElectiveListClient(client 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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):
|
||||||
|
* - loading:DetailPageSkeleton
|
||||||
|
* - error:errorNode 局部降级
|
||||||
|
* - notFound:data 为 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||||
|
*
|
||||||
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||||
|
*/
|
||||||
|
import { ClipboardList } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
import { useMemo, useTransition } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
import { 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-pending:MSW 兜底
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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):
|
||||||
|
* - loading:FormPageSkeleton(初始数据加载,由 server page Suspense 兜底)
|
||||||
|
* - error:errorSummary 表单级错误
|
||||||
|
* - success:notify.success + router.push 回列表
|
||||||
|
*
|
||||||
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||||
|
*/
|
||||||
|
import { 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-pending:MSW 兜底
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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):
|
||||||
|
* - loading:FormPageSkeleton(加载预填数据)
|
||||||
|
* - error:errorSummary 表单级错误
|
||||||
|
* - success:notify.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||||
|
*
|
||||||
|
* 关联: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-pending:MSW 兜底
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
280
apps/portal-shell/src/lib/api/course-plans.ts
Normal file
280
apps/portal-shell/src/lib/api/course-plans.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Course Plans domain API(ARCHITECTURE.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-pending,MSW 返回此结构) */
|
||||||
|
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-pending,MSW 兜底)。
|
||||||
|
*
|
||||||
|
* 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-pending,MSW 兜底)。
|
||||||
|
*
|
||||||
|
* 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-pending,MSW 兜底)。
|
||||||
|
*
|
||||||
|
* 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-pending,MSW 兜底)。
|
||||||
|
*
|
||||||
|
* 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 };
|
||||||
|
}
|
||||||
255
apps/portal-shell/src/lib/api/elective.ts
Normal file
255
apps/portal-shell/src/lib/api/elective.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elective domain API(ARCHITECTURE.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-pending,MSW 返回此结构) */
|
||||||
|
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-pending,MSW 兜底)。
|
||||||
|
*
|
||||||
|
* 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-pending,MSW 兜底)。
|
||||||
|
*
|
||||||
|
* 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-pending,MSW 兜底)。
|
||||||
|
*
|
||||||
|
* 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-pending,MSW 兜底)。
|
||||||
|
*
|
||||||
|
* 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 };
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
100
apps/portal-shell/src/lib/api/operations/course-plans.graphql.ts
Normal file
100
apps/portal-shell/src/lib/api/operations/course-plans.graphql.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
84
apps/portal-shell/src/lib/api/operations/elective.graphql.ts
Normal file
84
apps/portal-shell/src/lib/api/operations/elective.graphql.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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-pending(MSW 兜底)。",
|
||||||
|
"statusLabel": "状态",
|
||||||
|
"contractPending": "更新选修课契约为 @contract-pending,当前通过 MSW 兜底。"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "选修课模块出错了",
|
||||||
|
"unknown": "选修课模块发生未知错误",
|
||||||
|
"retry": "重试"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"leave": {
|
"leave": {
|
||||||
"title": "请假审批"
|
"title": "请假审批"
|
||||||
|
|||||||
@@ -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-pending,dev 兜底返回 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 {
|
||||||
|
|||||||
@@ -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/",
|
||||||
|
|||||||
Reference in New Issue
Block a user