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 }>;
|
||||
}
|
||||
|
||||
// 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.
|
||||
const BASELINE: Baseline = {
|
||||
total: 48,
|
||||
total: 53,
|
||||
categories: {
|
||||
dashboards: {
|
||||
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 "./students";
|
||||
export * from "./student";
|
||||
export * from "./course-plans";
|
||||
export * from "./elective";
|
||||
export * from "./parent";
|
||||
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 "./students.graphql";
|
||||
export * from "./student.graphql";
|
||||
export * from "./course-plans.graphql";
|
||||
export * from "./elective.graphql";
|
||||
export * from "./parent.graphql";
|
||||
export * from "./admin.graphql";
|
||||
|
||||
@@ -1030,7 +1030,59 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"title": "Diagnostic"
|
||||
@@ -1042,7 +1094,72 @@
|
||||
"title": "Practice"
|
||||
},
|
||||
"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": {
|
||||
"title": "Leave Requests"
|
||||
|
||||
@@ -1030,7 +1030,59 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"title": "诊断报告"
|
||||
@@ -1042,7 +1094,72 @@
|
||||
"title": "练习分析"
|
||||
},
|
||||
"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": {
|
||||
"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 ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -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":
|
||||
return {
|
||||
|
||||
@@ -168,6 +168,23 @@ export const EXACT_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
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 专属 ──────────────────────────────────────────
|
||||
"/shell/student/error-book": {
|
||||
@@ -312,6 +329,29 @@ export const PREFIX_ROUTE_PERMISSIONS: Array<{
|
||||
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/",
|
||||
|
||||
Reference in New Issue
Block a user