diff --git a/apps/portal-shell/scripts/check-page-count.ts b/apps/portal-shell/scripts/check-page-count.ts index 3b50ddc..8a4630e 100644 --- a/apps/portal-shell/scripts/check-page-count.ts +++ b/apps/portal-shell/scripts/check-page-count.ts @@ -19,9 +19,9 @@ interface Baseline { categories: Record; } -// Baseline as of P2 (2026-07-22, grades module added). Update when adding pages. +// Baseline as of P2 (2026-07-22, lesson-plans module added). Update when adding pages. const BASELINE: Baseline = { - total: 31, + total: 37, categories: { dashboards: { pattern: "shell/{admin,teacher,student,parent}/page.tsx", diff --git a/apps/portal-shell/src/app/shell/teacher/lesson-plans/[planId]/edit/page.tsx b/apps/portal-shell/src/app/shell/teacher/lesson-plans/[planId]/edit/page.tsx new file mode 100644 index 0000000..741b421 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/lesson-plans/[planId]/edit/page.tsx @@ -0,0 +1,27 @@ +import { Suspense } from "react"; + +import { LessonPlanEditClient } from "@/features/teacher/lesson-plans/lesson-plan-edit-client"; +import { WorkbenchPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 教案编辑工作台页(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹(useParams 要求)。 + * + * 数据契约(@contract-pending 全 MSW): + * - lessonPlan(id) ❌ → MSW 兜底 + * - updateLessonPlan(input) mutation ❌ → MSW 兜底 + * + * 契约工单: + * - docs/architecture/issues/contracts/core-edu_contract.md#lesson-plan-detail + * - docs/architecture/issues/contracts/core-edu_contract.md#update-lesson-plan + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function LessonPlanEditPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/lesson-plans/calendar/page.tsx b/apps/portal-shell/src/app/shell/teacher/lesson-plans/calendar/page.tsx new file mode 100644 index 0000000..adbceec --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/lesson-plans/calendar/page.tsx @@ -0,0 +1,22 @@ +import { Suspense } from "react"; + +import { LessonPlanCalendarClient } from "@/features/teacher/lesson-plans/lesson-plan-calendar-client"; +import { DetailPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 教案日历视图页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。 + * + * 数据契约:查询 lessonPlanCalendar(month) ❌ schema 无此字段 → MSW 兜底(@contract-pending) + * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#lesson-plan-calendar + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function LessonPlanCalendarPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/lesson-plans/error.tsx b/apps/portal-shell/src/app/shell/teacher/lesson-plans/error.tsx new file mode 100644 index 0000000..915739c --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/lesson-plans/error.tsx @@ -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 LessonPlansError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}): React.ReactElement { + const t = useTranslations("lessonPlans"); + + useEffect(() => { + console.error("[portal-shell] lesson-plans route error:", error); + }, [error]); + + return ( +
+

+ {t("error.title")} +

+

+ {error.message || t("error.unknown")} +

+ +
+ ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/lesson-plans/heatmap/page.tsx b/apps/portal-shell/src/app/shell/teacher/lesson-plans/heatmap/page.tsx new file mode 100644 index 0000000..81ce7b7 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/lesson-plans/heatmap/page.tsx @@ -0,0 +1,22 @@ +import { Suspense } from "react"; + +import { LessonPlanHeatmapClient } from "@/features/teacher/lesson-plans/lesson-plan-heatmap-client"; +import { DetailPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 教案热力图视图页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。 + * + * 数据契约:查询 lessonPlanHeatmap(startDate, endDate) ❌ schema 无此字段 → MSW 兜底(@contract-pending) + * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#lesson-plan-heatmap + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function LessonPlanHeatmapPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/lesson-plans/library/page.tsx b/apps/portal-shell/src/app/shell/teacher/lesson-plans/library/page.tsx new file mode 100644 index 0000000..4e83e10 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/lesson-plans/library/page.tsx @@ -0,0 +1,22 @@ +import { Suspense } from "react"; + +import { LessonPlanLibraryClient } from "@/features/teacher/lesson-plans/lesson-plan-library-client"; +import { ListPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 教案库列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。 + * + * 数据契约:查询 lessonPlanLibrary(subjectId, grade) ❌ schema 无此字段 → MSW 兜底(@contract-pending) + * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#lesson-plan-library + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function LessonPlanLibraryPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/lesson-plans/loading.tsx b/apps/portal-shell/src/app/shell/teacher/lesson-plans/loading.tsx new file mode 100644 index 0000000..8b3d5c5 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/lesson-plans/loading.tsx @@ -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 的 兜底, + * 本文件仅在 /shell/teacher/lesson-plans 列表/重定向期间显示。 + */ +export default function LessonPlansLoading(): React.ReactElement { + return ; +} diff --git a/apps/portal-shell/src/app/shell/teacher/lesson-plans/new/page.tsx b/apps/portal-shell/src/app/shell/teacher/lesson-plans/new/page.tsx new file mode 100644 index 0000000..a01421a --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/lesson-plans/new/page.tsx @@ -0,0 +1,22 @@ +import { Suspense } from "react"; + +import { NewLessonPlanClient } from "@/features/teacher/lesson-plans/new-lesson-plan-client"; +import { FormPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 新建教案表单页(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹。 + * + * 数据契约:mutation createLessonPlan(input) ❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending) + * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#create-lesson-plan + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function NewLessonPlanPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/lesson-plans/page.tsx b/apps/portal-shell/src/app/shell/teacher/lesson-plans/page.tsx new file mode 100644 index 0000000..c35848f --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/lesson-plans/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from "react"; + +import { LessonPlanListClient } from "@/features/teacher/lesson-plans/lesson-plan-list-client"; +import { ListPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 教案管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。 + * 业务逻辑在 LessonPlanListClient(client component)中。 + * + * 数据契约:列表查询 lessonPlans(gradeId, subjectId, status) ❌ schema 无此字段 → MSW 兜底(@contract-pending) + * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#lesson-plans-list + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function LessonPlansListPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/features/teacher/lesson-plans/__tests__/transformations.test.ts b/apps/portal-shell/src/features/teacher/lesson-plans/__tests__/transformations.test.ts new file mode 100644 index 0000000..6227832 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/lesson-plans/__tests__/transformations.test.ts @@ -0,0 +1,360 @@ +/** + * Lesson Plans 数据变换工具单测(ARCHITECTURE.md §11.3 DoD) + * + * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测" + */ +import { describe, expect, it } from "vitest"; + +import type { LessonPlanDetail } from "@/lib/api"; + +import { + LESSON_PLAN_STATUS_LABEL, + OUTLINE_NODE_TYPE_LABEL, + RESOURCE_TYPE_LABEL, + calcHeatmapIntensity, + formatCalendarDayCount, + formatDownloadCount, + formatDuration, + formatLessonPlanDate, + formatLessonPlanStatus, + formatMonthLabel, + formatOutlineNodeType, + formatRating, + formatResourceType, + intensityToColorClass, + isLessonPlanArchived, + isLessonPlanEditable, + isLessonPlanPublished, + isValidLessonPlanStatus, + lessonPlanStatusToBadgeClass, + ratingToColorClass, + toLessonPlanListItem, +} from "../transformations"; + +describe("formatLessonPlanStatus", () => { + it("maps known statuses to Chinese labels", () => { + expect(formatLessonPlanStatus("DRAFT")).toBe("草稿"); + expect(formatLessonPlanStatus("PUBLISHED")).toBe("已发布"); + expect(formatLessonPlanStatus("ARCHIVED")).toBe("已归档"); + }); + + it("returns original value for unknown status", () => { + expect(formatLessonPlanStatus("UNKNOWN")).toBe("UNKNOWN"); + expect(formatLessonPlanStatus("")).toBe(""); + }); + + it("LESSON_PLAN_STATUS_LABEL covers all standard statuses", () => { + expect(Object.keys(LESSON_PLAN_STATUS_LABEL)).toHaveLength(3); + }); +}); + +describe("formatOutlineNodeType", () => { + it("maps known types to Chinese labels", () => { + expect(formatOutlineNodeType("section")).toBe("章节"); + expect(formatOutlineNodeType("topic")).toBe("主题"); + expect(formatOutlineNodeType("activity")).toBe("活动"); + expect(formatOutlineNodeType("assessment")).toBe("评估"); + }); + + it("returns original value for unknown type", () => { + expect(formatOutlineNodeType("unknown")).toBe("unknown"); + }); + + it("OUTLINE_NODE_TYPE_LABEL covers all standard types", () => { + expect(Object.keys(OUTLINE_NODE_TYPE_LABEL)).toHaveLength(4); + }); +}); + +describe("formatResourceType", () => { + it("maps known types to Chinese labels", () => { + expect(formatResourceType("link")).toBe("链接"); + expect(formatResourceType("file")).toBe("文件"); + expect(formatResourceType("video")).toBe("视频"); + expect(formatResourceType("image")).toBe("图片"); + }); + + it("returns original value for unknown type", () => { + expect(formatResourceType("audio")).toBe("audio"); + }); + + it("RESOURCE_TYPE_LABEL covers all standard types", () => { + expect(Object.keys(RESOURCE_TYPE_LABEL)).toHaveLength(4); + }); +}); + +describe("formatLessonPlanDate", () => { + it("formats valid ISO date string", () => { + const result = formatLessonPlanDate("2026-07-25T23:59:59Z"); + expect(result).toContain("2026"); + expect(result).toContain("07"); + }); + + it("returns placeholder for null/undefined/empty", () => { + expect(formatLessonPlanDate(null)).toBe("--"); + expect(formatLessonPlanDate(undefined)).toBe("--"); + expect(formatLessonPlanDate("")).toBe("--"); + }); + + it("returns placeholder for invalid date", () => { + expect(formatLessonPlanDate("not-a-date")).toBe("--"); + }); +}); + +describe("formatDuration", () => { + it("formats minutes under 60", () => { + expect(formatDuration(30)).toBe("30 分钟"); + expect(formatDuration(1)).toBe("1 分钟"); + expect(formatDuration(45)).toBe("45 分钟"); + }); + + it("formats exact hours", () => { + expect(formatDuration(60)).toBe("1 小时"); + expect(formatDuration(120)).toBe("2 小时"); + }); + + it("formats hours with remainder minutes", () => { + expect(formatDuration(90)).toBe("1 小时 30 分钟"); + expect(formatDuration(75)).toBe("1 小时 15 分钟"); + }); + + it("returns placeholder for invalid or zero input", () => { + expect(formatDuration(0)).toBe("--"); + expect(formatDuration(-1)).toBe("--"); + expect(formatDuration(Number.NaN)).toBe("--"); + }); +}); + +describe("isLessonPlanEditable / isLessonPlanPublished / isLessonPlanArchived", () => { + it("DRAFT is editable but not published/archived", () => { + expect(isLessonPlanEditable("DRAFT")).toBe(true); + expect(isLessonPlanPublished("DRAFT")).toBe(false); + expect(isLessonPlanArchived("DRAFT")).toBe(false); + }); + + it("PUBLISHED is published but not editable/archived", () => { + expect(isLessonPlanEditable("PUBLISHED")).toBe(false); + expect(isLessonPlanPublished("PUBLISHED")).toBe(true); + expect(isLessonPlanArchived("PUBLISHED")).toBe(false); + }); + + it("ARCHIVED is archived but not editable/published", () => { + expect(isLessonPlanEditable("ARCHIVED")).toBe(false); + expect(isLessonPlanPublished("ARCHIVED")).toBe(false); + expect(isLessonPlanArchived("ARCHIVED")).toBe(true); + }); + + it("unknown status is neither editable nor published nor archived", () => { + expect(isLessonPlanEditable("UNKNOWN")).toBe(false); + expect(isLessonPlanPublished("UNKNOWN")).toBe(false); + expect(isLessonPlanArchived("UNKNOWN")).toBe(false); + }); +}); + +describe("toLessonPlanListItem", () => { + it("extracts list fields from full detail", () => { + const detail: LessonPlanDetail = { + id: "lp-001", + title: "集合的概念", + gradeId: "grade-12", + subjectId: "sub-math", + objectives: "理解集合的定义", + content: "

集合是数学中最基本的概念...

", + attachments: [], + duration: 45, + status: "DRAFT", + outline: [ + { + id: "node-1", + title: "导入", + type: "section", + order: 1, + }, + ], + resources: [ + { + id: "res-1", + name: "教材P1-10", + type: "file", + url: "https://example.com/textbook.pdf", + }, + ], + createdAt: "2026-07-20T00:00:00Z", + updatedAt: "2026-07-20T00:00:00Z", + }; + + const item = toLessonPlanListItem(detail); + expect(item.id).toBe("lp-001"); + expect(item.title).toBe("集合的概念"); + expect(item.status).toBe("DRAFT"); + expect(item.duration).toBe(45); + expect(item).not.toHaveProperty("content"); + expect(item).not.toHaveProperty("outline"); + expect(item).not.toHaveProperty("resources"); + expect(item).not.toHaveProperty("attachments"); + }); +}); + +describe("lessonPlanStatusToBadgeClass", () => { + it("returns correct badge class for each status", () => { + expect(lessonPlanStatusToBadgeClass("DRAFT")).toBe( + "bg-muted text-muted-foreground", + ); + expect(lessonPlanStatusToBadgeClass("PUBLISHED")).toContain("primary"); + expect(lessonPlanStatusToBadgeClass("ARCHIVED")).toBe( + "bg-muted text-muted-foreground", + ); + }); + + it("returns muted for unknown status", () => { + expect(lessonPlanStatusToBadgeClass("UNKNOWN")).toBe( + "bg-muted text-muted-foreground", + ); + }); +}); + +describe("formatRating", () => { + it("formats valid ratings with 1 decimal place", () => { + expect(formatRating(4.5)).toBe("4.5"); + expect(formatRating(5)).toBe("5.0"); + expect(formatRating(0)).toBe("0.0"); + }); + + it("returns placeholder for out-of-range or non-finite input", () => { + expect(formatRating(-0.1)).toBe("--"); + expect(formatRating(5.1)).toBe("--"); + expect(formatRating(Number.NaN)).toBe("--"); + }); +}); + +describe("ratingToColorClass", () => { + it("returns emerald for high rating", () => { + expect(ratingToColorClass(4.0)).toBe("text-emerald-600"); + expect(ratingToColorClass(5.0)).toBe("text-emerald-600"); + }); + + it("returns amber for medium rating", () => { + expect(ratingToColorClass(3.0)).toBe("text-amber-600"); + expect(ratingToColorClass(3.99)).toBe("text-amber-600"); + }); + + it("returns destructive for low rating", () => { + expect(ratingToColorClass(2.99)).toBe("text-destructive"); + expect(ratingToColorClass(0)).toBe("text-destructive"); + }); + + it("returns muted for non-finite input", () => { + expect(ratingToColorClass(Number.NaN)).toBe("text-muted-foreground"); + }); +}); + +describe("formatDownloadCount", () => { + it("formats counts under 1000 as plain numbers", () => { + expect(formatDownloadCount(0)).toBe("0"); + expect(formatDownloadCount(999)).toBe("999"); + }); + + it("formats counts >= 1000 with k suffix", () => { + expect(formatDownloadCount(1000)).toBe("1.0k"); + expect(formatDownloadCount(1234)).toBe("1.2k"); + expect(formatDownloadCount(9999)).toBe("10.0k"); + }); + + it("returns placeholder for invalid input", () => { + expect(formatDownloadCount(-1)).toBe("--"); + expect(formatDownloadCount(Number.NaN)).toBe("--"); + }); +}); + +describe("intensityToColorClass", () => { + it("returns correct color class for each intensity level", () => { + expect(intensityToColorClass(0)).toBe("bg-muted"); + expect(intensityToColorClass(1)).toContain("zinc-200"); + expect(intensityToColorClass(2)).toContain("zinc-300"); + expect(intensityToColorClass(3)).toContain("zinc-400"); + expect(intensityToColorClass(4)).toContain("zinc-500"); + }); + + it("returns muted for unknown intensity", () => { + expect(intensityToColorClass(5)).toBe("bg-muted"); + expect(intensityToColorClass(-1)).toBe("bg-muted"); + }); +}); + +describe("calcHeatmapIntensity", () => { + it("returns 0 for zero count", () => { + expect(calcHeatmapIntensity(0, 10)).toBe(0); + }); + + it("returns 1 for low ratio (< 0.25)", () => { + expect(calcHeatmapIntensity(1, 10)).toBe(1); + expect(calcHeatmapIntensity(2, 10)).toBe(1); + }); + + it("returns 2 for medium ratio (>= 0.25)", () => { + expect(calcHeatmapIntensity(3, 10)).toBe(2); + expect(calcHeatmapIntensity(4, 10)).toBe(2); + }); + + it("returns 3 for high ratio (>= 0.5)", () => { + expect(calcHeatmapIntensity(5, 10)).toBe(3); + expect(calcHeatmapIntensity(7, 10)).toBe(3); + }); + + it("returns 4 for very high ratio (>= 0.75)", () => { + expect(calcHeatmapIntensity(8, 10)).toBe(4); + expect(calcHeatmapIntensity(10, 10)).toBe(4); + }); + + it("returns 0 for zero or invalid maxCount", () => { + expect(calcHeatmapIntensity(5, 0)).toBe(0); + expect(calcHeatmapIntensity(5, -1)).toBe(0); + expect(calcHeatmapIntensity(5, Number.NaN)).toBe(0); + }); + + it("returns 0 for negative count", () => { + expect(calcHeatmapIntensity(-5, 10)).toBe(0); + }); +}); + +describe("formatCalendarDayCount", () => { + it("returns string for positive count", () => { + expect(formatCalendarDayCount(1)).toBe("1"); + expect(formatCalendarDayCount(5)).toBe("5"); + }); + + it("returns empty string for zero or invalid", () => { + expect(formatCalendarDayCount(0)).toBe(""); + expect(formatCalendarDayCount(-1)).toBe(""); + expect(formatCalendarDayCount(Number.NaN)).toBe(""); + }); +}); + +describe("formatMonthLabel", () => { + it("formats YYYY-MM to Chinese label", () => { + expect(formatMonthLabel("2026-07")).toBe("2026年7月"); + expect(formatMonthLabel("2026-01")).toBe("2026年1月"); + expect(formatMonthLabel("2026-12")).toBe("2026年12月"); + }); + + it("returns placeholder for empty input", () => { + expect(formatMonthLabel("")).toBe("--"); + }); + + it("returns original for invalid format", () => { + expect(formatMonthLabel("invalid")).toBe("invalid"); + expect(formatMonthLabel("2026")).toBe("2026"); + }); +}); + +describe("isValidLessonPlanStatus", () => { + it("returns true for valid statuses", () => { + expect(isValidLessonPlanStatus("DRAFT")).toBe(true); + expect(isValidLessonPlanStatus("PUBLISHED")).toBe(true); + expect(isValidLessonPlanStatus("ARCHIVED")).toBe(true); + }); + + it("returns false for invalid statuses", () => { + expect(isValidLessonPlanStatus("UNKNOWN")).toBe(false); + expect(isValidLessonPlanStatus("")).toBe(false); + }); +}); diff --git a/apps/portal-shell/src/features/teacher/lesson-plans/lesson-plan-calendar-client.tsx b/apps/portal-shell/src/features/teacher/lesson-plans/lesson-plan-calendar-client.tsx new file mode 100644 index 0000000..ab4e72d --- /dev/null +++ b/apps/portal-shell/src/features/teacher/lesson-plans/lesson-plan-calendar-client.tsx @@ -0,0 +1,210 @@ +"use client"; + +/** + * 教案日历视图 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2) + * + * 数据契约: + * - 查询 lessonPlanCalendar(month):❌ schema 无此字段 → MSW 兜底(@contract-pending) + * - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#lesson-plan-calendar + * + * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(空日历) + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +import { CalendarDays } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useTransition } from "react"; +import { useTranslations } from "next-intl"; + +import { useLessonPlanCalendar } from "@/lib/api"; +import { + DetailPageShell, + DetailPageSkeleton, +} from "@/shared/components/page-templates"; +import { Button } from "@/shared/components/ui/button"; +import { + formatCalendarDayCount, + formatMonthLabel, + lessonPlanStatusToBadgeClass, +} from "@/features/teacher/lesson-plans/transformations"; + +/** + * 日历客户端主体。需由 server page 包裹在 中。 + */ +export function LessonPlanCalendarClient(): React.ReactElement { + const t = useTranslations("lessonPlans"); + const tCommon = useTranslations("common"); + const router = useRouter(); + const searchParams = useSearchParams(); + const [, startTransition] = useTransition(); + + // 默认当前月(YYYY-MM) + const now = new Date(); + const defaultMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; + const month = searchParams.get("month") ?? defaultMonth; + + // @contract-pending:MSW 兜底 + const { data, loading, error } = useLessonPlanCalendar(month); + + const goToMonth = (delta: number): void => { + const [year, monthNum] = month.split("-").map(Number); + if (!year || !monthNum) return; + const d = new Date(year, monthNum - 1 + delta, 1); + const newMonth = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; + startTransition(() => { + router.push(`/shell/teacher/lesson-plans/calendar?month=${newMonth}`); + }); + }; + + const errorNode = error ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+

+ {t("calendar.mswNotice")} +

+
+ ) : undefined; + + return ( + } + actions={ +
+ + +
+ } + backHref="/shell/teacher/lesson-plans" + loading={loading} + loadingNode={} + errorNode={errorNode} + > + {data ? : null} +
+ ); +} + +/** + * 日历网格(7 列 × N 行)。 + */ +function CalendarGrid({ + days, +}: { + days: { date: string; lessonPlans: unknown[] }[]; +}): React.ReactElement { + const t = useTranslations("lessonPlans"); + const weekdays = [ + t("calendar.weekSun"), + t("calendar.weekMon"), + t("calendar.weekTue"), + t("calendar.weekWed"), + t("calendar.weekThu"), + t("calendar.weekFri"), + t("calendar.weekSat"), + ]; + + // 按日期索引映射 + const dayMap = new Map(); + for (const d of days) { + dayMap.set(d.date, d); + } + + // 计算月首日是星期几,生成空白填充 + const firstDate = days.length > 0 ? new Date(days[0]!.date) : new Date(); + const firstWeekday = firstDate.getDay(); + + return ( +
+
+ {weekdays.map((day) => ( +
+ {day} +
+ ))} +
+
+ {/* 月首空白填充 */} + {Array.from({ length: firstWeekday }, (_, i) => ( +
+ ))} + {days.map((day) => ( + + ))} +
+
+ ); +} + +/** + * 日历单日单元格。 + */ +function CalendarDayCell({ + day, +}: { + day: { date: string; lessonPlans: unknown[] }; +}): React.ReactElement { + const t = useTranslations("lessonPlans"); + const date = new Date(day.date); + const dayNum = date.getDate(); + const count = day.lessonPlans.length; + + return ( +
+
+ {dayNum} + {count > 0 ? ( + + {formatCalendarDayCount(count)} + + ) : null} +
+
+ {day.lessonPlans.slice(0, 3).map((lp, idx) => { + const plan = lp as { + id: string; + title: string; + status: string; + }; + return ( +
+ {plan.title} +
+ ); + })} + {count > 3 ? ( +

+ {t("calendar.moreCount", { count: count - 3 })} +

+ ) : null} +
+
+ ); +} diff --git a/apps/portal-shell/src/features/teacher/lesson-plans/lesson-plan-edit-client.tsx b/apps/portal-shell/src/features/teacher/lesson-plans/lesson-plan-edit-client.tsx new file mode 100644 index 0000000..7ed875c --- /dev/null +++ b/apps/portal-shell/src/features/teacher/lesson-plans/lesson-plan-edit-client.tsx @@ -0,0 +1,355 @@ +"use client"; + +/** + * 教案编辑工作台页 - 客户端组件(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2) + * + * 数据契约(@contract-pending 全 MSW): + * - lessonPlan(id) ❌ → MSW 兜底 + * - updateLessonPlan(input) mutation ❌ → MSW 兜底 + * + * 布局(WorkbenchPageShell 三栏): + * - left:大纲树(LessonPlanOutlineTree) + * - center:富文本编辑器(contentEditable + 工具栏) + * - right:属性面板(基本信息 + 资源列表 + 状态操作) + * + * 三态规范(§11.3 DoD): + * - loading:WorkbenchPageSkeleton + * - error:errorNode 局部降级(合并 empty 降级,因 WorkbenchPageShell 无 emptyNode) + * - success:notify.success + * + * 关联:ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +import { BookOpen } from "lucide-react"; +import { useParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { forwardRef, useEffect, useRef, useState } from "react"; + +import { + useLessonPlan, + useUpdateLessonPlan, + type LessonPlanOutlineNode, +} from "@/lib/api"; +import { notify } from "@/shared/lib/notify"; +import { Button } from "@/shared/components/ui/button"; +import { + WorkbenchPageShell, + WorkbenchPageSkeleton, + WorkbenchPanel, +} from "@/shared/components/page-templates"; +import { + formatDuration, + formatLessonPlanDate, + formatLessonPlanStatus, + formatResourceType, + lessonPlanStatusToBadgeClass, +} from "@/features/teacher/lesson-plans/transformations"; +import { LessonPlanOutlineTree } from "@/features/teacher/lesson-plans/lesson-plan-outline-tree"; + +/** + * 教案编辑工作台客户端主体。需由 server page 包裹在 中。 + */ +export function LessonPlanEditClient(): React.ReactElement { + const t = useTranslations("lessonPlans"); + const tCommon = useTranslations("common"); + const params = useParams<{ planId: string }>(); + const planId = params?.planId ?? ""; + + // @contract-pending MSW 兜底 + const { data, loading, error } = useLessonPlan(planId); + const updateMutation = useUpdateLessonPlan(); + + const [html, setHtml] = useState(null); + const [selectedNodeId, setSelectedNodeId] = useState(); + const editorRef = useRef(null); + + // 首次拿到数据时初始化 HTML 内容 + useEffect(() => { + if (data && html === null) { + setHtml(data.content || ""); + } + }, [data, html]); + + // WorkbenchPageShell 的 errorNode 同时承担 error + empty 两种降级场景 + const errorNode = error ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+
+ ) : !loading && !data ? ( +
+ {t("edit.notFound")} +
+ ) : undefined; + + const handleSave = async (): Promise => { + if (!data) return; + const currentHtml = editorRef.current?.innerHTML ?? html ?? ""; + try { + await updateMutation.run({ + id: planId, + content: currentHtml, + }); + notify.success(t("edit.saveSuccess")); + } catch (err) { + notify.error(t("edit.saveFailed", { message: String(err) })); + } + }; + + const handleOutlineSelect = (node: LessonPlanOutlineNode): void => { + setSelectedNodeId(node.id); + }; + + const exec = (command: string, value?: string): void => { + if (!editorRef.current) return; + editorRef.current.focus(); + document.execCommand(command, false, value); + setHtml(editorRef.current.innerHTML); + }; + + return ( + } + actions={ + + } + loading={loading} + loadingNode={} + errorNode={errorNode} + left={ + data ? ( + + + + ) : null + } + center={ + data && html !== null ? ( + + ) : null + } + right={ + data ? ( + +
+
+

+ {t("edit.propPlanId")} +

+

{data.id}

+
+
+

+ {t("edit.propGrade")} +

+

{data.gradeId}

+
+
+

+ {t("edit.propSubject")} +

+

{data.subjectId}

+
+
+

+ {t("edit.propDuration")} +

+

+ {formatDuration(data.duration)} +

+
+
+

+ {t("edit.propStatus")} +

+ + {formatLessonPlanStatus(data.status)} + +
+
+

+ {t("edit.propUpdatedAt")} +

+

+ {formatLessonPlanDate(data.updatedAt)} +

+
+ + {/* 资源列表 */} + {data.resources.length > 0 ? ( +
+

+ {t("edit.resourcesTitle")} +

+
    + {data.resources.map((res) => ( +
  • + + {res.name} + + + ({formatResourceType(res.type)}) + +
  • + ))} +
+
+ ) : null} + +

+ {t("edit.contractPending")} +

+
+
+ ) : null + } + /> + ); +} + +/** + * 富文本编辑器(contentEditable + 工具栏)。 + * + * 简化实现:使用 document.execCommand(deprecated 但仍可用)。 + * 后续可升级到 tiptap/lexical(@contract-pending 富文本库契约)。 + */ +interface RichEditorProps { + html: string; + onChange: (html: string) => void; + onExec: (command: string, value?: string) => void; +} + +const RichEditor = forwardRef( + function RichEditor({ html, onChange, onExec }, ref) { + const t = useTranslations("lessonPlans"); + return ( + +
+
+ + + + + + + + + + + + +
+ +
onChange((e.target as HTMLDivElement).innerHTML)} + style={{ minHeight: "400px" }} + /> +
+ + ); + }, +); diff --git a/apps/portal-shell/src/features/teacher/lesson-plans/lesson-plan-heatmap-client.tsx b/apps/portal-shell/src/features/teacher/lesson-plans/lesson-plan-heatmap-client.tsx new file mode 100644 index 0000000..658c78b --- /dev/null +++ b/apps/portal-shell/src/features/teacher/lesson-plans/lesson-plan-heatmap-client.tsx @@ -0,0 +1,235 @@ +"use client"; + +/** + * 教案热力图视图 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2) + * + * 数据契约: + * - 查询 lessonPlanHeatmap(startDate, endDate):❌ schema 无此字段 → MSW 兜底(@contract-pending) + * - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#lesson-plan-heatmap + * + * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(空热力图) + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +import { Activity } from "lucide-react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { useTransition } from "react"; +import { useTranslations } from "next-intl"; + +import { useLessonPlanHeatmap } from "@/lib/api"; +import { + DetailPageShell, + DetailPageSkeleton, +} from "@/shared/components/page-templates"; +import { Button } from "@/shared/components/ui/button"; +import { intensityToColorClass } from "@/features/teacher/lesson-plans/transformations"; + +/** 热力图默认范围:过去 12 周 */ +function getDefaultRange(): { startDate: string; endDate: string } { + const end = new Date(); + const start = new Date(); + start.setDate(start.getDate() - 7 * 12); + const fmt = (d: Date): string => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + return { startDate: fmt(start), endDate: fmt(end) }; +} + +/** + * 热力图客户端主体。需由 server page 包裹在 中。 + */ +export function LessonPlanHeatmapClient(): React.ReactElement { + const t = useTranslations("lessonPlans"); + const tCommon = useTranslations("common"); + const router = useRouter(); + const searchParams = useSearchParams(); + const [, startTransition] = useTransition(); + + const defaultRange = getDefaultRange(); + const startDate = searchParams.get("startDate") ?? defaultRange.startDate; + const endDate = searchParams.get("endDate") ?? defaultRange.endDate; + + // @contract-pending:MSW 兜底 + const { data, loading, error } = useLessonPlanHeatmap({ + startDate, + endDate, + }); + + const shiftRange = (weeks: number): void => { + const start = new Date(startDate); + const end = new Date(endDate); + start.setDate(start.getDate() + weeks * 7); + end.setDate(end.getDate() + weeks * 7); + const fmt = (d: Date): string => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + startTransition(() => { + router.push( + `/shell/teacher/lesson-plans/heatmap?startDate=${fmt(start)}&endDate=${fmt(end)}`, + ); + }); + }; + + const errorNode = error ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+

+ {t("heatmap.mswNotice")} +

+
+ ) : undefined; + + return ( + } + actions={ +
+ + +
+ } + backHref="/shell/teacher/lesson-plans" + loading={loading} + loadingNode={} + errorNode={errorNode} + > + {data ? ( + + ) : null} +
+ ); +} + +/** + * 热力图网格(按周列 × 星期行布局)。 + */ +function HeatmapGrid({ + cells, + maxCount, +}: { + cells: { date: string; count: number; intensity: number }[]; + maxCount: number; +}): React.ReactElement { + const t = useTranslations("lessonPlans"); + + // 按日期索引 + const cellMap = new Map< + string, + { date: string; count: number; intensity: number } + >(); + for (const c of cells) { + cellMap.set(c.date, c); + } + + // 计算周列 + if (cells.length === 0) { + return ( +
+ {t("heatmap.empty")} +
+ ); + } + + const firstDate = new Date(cells[0]!.date); + const lastDate = new Date(cells[cells.length - 1]!.date); + + // 按周分组 + const weeks: { date: string; count: number; intensity: number }[][] = []; + let currentWeek: { date: string; count: number; intensity: number }[] = []; + const cursor = new Date(firstDate); + // 调整到周日开始 + cursor.setDate(cursor.getDate() - cursor.getDay()); + + while (cursor <= lastDate) { + const dateStr = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, "0")}-${String(cursor.getDate()).padStart(2, "0")}`; + const cell = cellMap.get(dateStr); + if (cell) { + currentWeek.push(cell); + } else { + currentWeek.push({ date: dateStr, count: 0, intensity: 0 }); + } + if (currentWeek.length === 7) { + weeks.push(currentWeek); + currentWeek = []; + } + cursor.setDate(cursor.getDate() + 1); + } + if (currentWeek.length > 0) { + weeks.push(currentWeek); + } + + const weekdays = [ + t("heatmap.weekSun"), + t("heatmap.weekMon"), + t("heatmap.weekTue"), + t("heatmap.weekWed"), + t("heatmap.weekThu"), + t("heatmap.weekFri"), + t("heatmap.weekSat"), + ]; + + return ( +
+
+
+ {/* 星期标签列 */} +
+ {weekdays.map((day, i) => ( +
+ {day} +
+ ))} +
+ {/* 热力图单元格 */} + {weeks.map((week, weekIdx) => ( +
+ {week.map((cell) => ( +
+ ))} +
+ ))} +
+
+ + {/* 图例 */} +
+ {t("heatmap.less")} + {[0, 1, 2, 3, 4].map((level) => ( +
+ ))} + {t("heatmap.more")} + + {t("heatmap.maxCount", { count: maxCount })} + +
+
+ ); +} diff --git a/apps/portal-shell/src/features/teacher/lesson-plans/lesson-plan-library-client.tsx b/apps/portal-shell/src/features/teacher/lesson-plans/lesson-plan-library-client.tsx new file mode 100644 index 0000000..a8b0e01 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/lesson-plans/lesson-plan-library-client.tsx @@ -0,0 +1,180 @@ +"use client"; + +/** + * 教案库列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2) + * + * 数据契约: + * - 查询 lessonPlanLibrary(subjectId, grade):❌ schema 无此字段 → MSW 兜底(@contract-pending) + * - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#lesson-plan-library + * + * URL 状态:?subjectId=xxx &grade=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 { Library } from "lucide-react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { useMemo, useTransition } from "react"; +import { useTranslations } from "next-intl"; + +import { useLessonPlanLibrary, type LessonPlanLibraryItem } from "@/lib/api"; +import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; +import { + ListPageShell, + ListPageSkeleton, +} from "@/shared/components/page-templates"; +import { + formatDownloadCount, + formatLessonPlanDate, + formatRating, + ratingToColorClass, +} from "@/features/teacher/lesson-plans/transformations"; + +/** + * 教案库客户端主体。需由 server page 包裹在 中。 + */ +export function LessonPlanLibraryClient(): React.ReactElement { + const t = useTranslations("lessonPlans"); + const tCommon = useTranslations("common"); + const router = useRouter(); + const searchParams = useSearchParams(); + const [, startTransition] = useTransition(); + + const subjectId = searchParams.get("subjectId") ?? ""; + const grade = searchParams.get("grade") ?? ""; + const q = searchParams.get("q") ?? ""; + + // @contract-pending:MSW 兜底 + const { data, loading, error } = useLessonPlanLibrary({ + subjectId: subjectId || undefined, + grade: grade || undefined, + }); + + const filteredItems = useMemo(() => { + const items = data?.items ?? []; + return items.filter((item) => { + if (q && !item.title.toLowerCase().includes(q.toLowerCase())) { + return false; + } + return true; + }); + }, [data, q]); + + const updateQuery = (key: string, value: string): void => { + const params = new URLSearchParams(searchParams.toString()); + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + startTransition(() => { + router.push(`/shell/teacher/lesson-plans/library?${params.toString()}`); + }); + }; + + const errorNode = error ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+

+ {t("library.mswNotice")} +

+
+ ) : undefined; + + return ( + } + filters={ + <> + updateQuery("q", v)} + /> + updateQuery("subjectId", e.target.value)} + placeholder={t("library.subjectPlaceholder")} + className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm" + aria-label={t("library.subjectPlaceholder")} + /> + updateQuery("grade", e.target.value)} + placeholder={t("library.gradePlaceholder")} + className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm" + aria-label={t("library.gradePlaceholder")} + /> + + } + loading={loading} + loadingNode={} + empty={filteredItems.length === 0 && !loading} + errorNode={errorNode} + pagination={ +
+ {t("library.total", { count: filteredItems.length })} +
+ } + > + +
+ ); +} + +/** + * 教案库卡片网格(纯展示组件)。 + */ +function LibraryGrid({ + items, +}: { + items: LessonPlanLibraryItem[]; +}): React.ReactElement { + const t = useTranslations("lessonPlans"); + return ( +
+ {items.map((item) => ( +
+
+

{item.title}

+ + {formatRating(item.rating)} + +
+
+ + {item.subjectName} + + {item.grade} +
+
+ {t("library.author", { name: item.authorName })} + {formatLessonPlanDate(item.updatedAt)} +
+
+ + {t("library.downloads", { + count: formatDownloadCount(item.downloadCount), + })} + + + {t("library.duration", { minutes: item.duration })} + +
+
+ ))} +
+ ); +} diff --git a/apps/portal-shell/src/features/teacher/lesson-plans/lesson-plan-list-client.tsx b/apps/portal-shell/src/features/teacher/lesson-plans/lesson-plan-list-client.tsx new file mode 100644 index 0000000..9482c3a --- /dev/null +++ b/apps/portal-shell/src/features/teacher/lesson-plans/lesson-plan-list-client.tsx @@ -0,0 +1,237 @@ +"use client"; + +/** + * 教案管理列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2) + * + * 数据契约: + * - 列表查询 lessonPlans(gradeId, subjectId, status):❌ schema 无此字段 → MSW 兜底(@contract-pending) + * - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#lesson-plans-list + * + * URL 状态:?gradeId=xxx &subjectId=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 { BookOpen } 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 { useLessonPlansList, type LessonPlanListItem } 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 { + formatDuration, + formatLessonPlanDate, + formatLessonPlanStatus, + lessonPlanStatusToBadgeClass, +} from "@/features/teacher/lesson-plans/transformations"; + +/** + * 列表客户端主体。需由 server page 包裹在 中 + * (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。 + */ +export function LessonPlanListClient(): React.ReactElement { + const t = useTranslations("lessonPlans"); + const tCommon = useTranslations("common"); + const router = useRouter(); + const searchParams = useSearchParams(); + const [, startTransition] = useTransition(); + + const gradeId = searchParams.get("gradeId") ?? ""; + const subjectId = searchParams.get("subjectId") ?? ""; + const statusFilter = searchParams.get("status") ?? ""; + const q = searchParams.get("q") ?? ""; + + // @contract-pending:MSW 兜底 + const { data, loading, error } = useLessonPlansList({ + gradeId: gradeId || undefined, + subjectId: subjectId || undefined, + status: statusFilter || undefined, + }); + + // 客户端二次筛选(q)—— 后端补齐列表查询后改服务端筛选 + const filteredItems = useMemo(() => { + const items = data?.items ?? []; + return items.filter((item) => { + if (q && !item.title.toLowerCase().includes(q.toLowerCase())) { + return false; + } + return true; + }); + }, [data, q]); + + const updateQuery = (key: string, value: string): void => { + const params = new URLSearchParams(searchParams.toString()); + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + startTransition(() => { + router.push(`/shell/teacher/lesson-plans?${params.toString()}`); + }); + }; + + const errorNode = error ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+

+ {t("list.mswNotice")} +

+
+ ) : undefined; + + return ( + } + actions={ + + } + filters={ + <> + updateQuery("q", v)} + /> + updateQuery("gradeId", 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")} + /> + updateQuery("subjectId", 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")} + /> + + + } + loading={loading} + loadingNode={} + empty={filteredItems.length === 0 && !loading} + errorNode={errorNode} + pagination={ +
+ {t("list.total", { count: filteredItems.length })} +
+ } + > + +
+ ); +} + +/** + * 教案列表表格(纯展示组件,对齐 §8.2 排版规范)。 + */ +function LessonPlanTable({ + items, +}: { + items: LessonPlanListItem[]; +}): React.ReactElement { + const t = useTranslations("lessonPlans"); + return ( +
+ + + + + + + + + + + + {items.map((plan) => ( + + + + + + + + ))} + +
{t("list.colTitle")}{t("list.colStatus")} + {t("list.colDuration")} + + {t("list.colUpdatedAt")} + + {t("list.colActions")} +
+ + {plan.title} + + {plan.objectives ? ( +

+ {plan.objectives} +

+ ) : null} +
+ + {formatDuration(plan.duration)} + {formatLessonPlanDate(plan.updatedAt)} + + + {t("list.edit")} + +
+
+ ); +} + +/** + * 教案状态徽章(按状态色阶展示)。 + */ +function LessonPlanStatusBadge({ + status, +}: { + status: string; +}): React.ReactElement { + const label = formatLessonPlanStatus(status); + const cls = lessonPlanStatusToBadgeClass(status); + return ( + + {label} + + ); +} diff --git a/apps/portal-shell/src/features/teacher/lesson-plans/lesson-plan-outline-tree.tsx b/apps/portal-shell/src/features/teacher/lesson-plans/lesson-plan-outline-tree.tsx new file mode 100644 index 0000000..d905d37 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/lesson-plans/lesson-plan-outline-tree.tsx @@ -0,0 +1,151 @@ +"use client"; + +/** + * 教案大纲树组件 - 客户端组件(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2) + * + * 用于 lesson-plan-edit-client.tsx 左栏。展示教案大纲的树形结构, + * 支持节点选中(高亮当前选中项)。 + * + * 数据契约:大纲数据来自 LessonPlanDetail.outline(@contract-pending 全 MSW) + * + * 关联:ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +import { ChevronRight, FileText, ListChecks, Target, Zap } from "lucide-react"; +import { useState } from "react"; + +import type { LessonPlanOutlineNode } from "@/lib/api"; +import { cn } from "@/shared/lib/utils"; +import { formatOutlineNodeType } from "@/features/teacher/lesson-plans/transformations"; + +/** + * 大纲树 props。 + */ +export interface LessonPlanOutlineTreeProps { + /** 大纲根节点列表 */ + nodes: LessonPlanOutlineNode[]; + /** 当前选中节点 id */ + selectedId?: string; + /** 节点选中回调 */ + onSelect: (node: LessonPlanOutlineNode) => void; +} + +/** + * 大纲树主体。 + */ +export function LessonPlanOutlineTree({ + nodes, + selectedId, + onSelect, +}: LessonPlanOutlineTreeProps): React.ReactElement { + return ( +
+ {nodes.map((node) => ( + + ))} +
+ ); +} + +/** + * 大纲节点项(递归渲染子节点)。 + */ +function OutlineNodeItem({ + node, + depth, + selectedId, + onSelect, +}: { + node: LessonPlanOutlineNode; + depth: number; + selectedId?: string; + onSelect: (node: LessonPlanOutlineNode) => void; +}): React.ReactElement { + const [expanded, setExpanded] = useState(depth < 1); + const hasChildren = (node.children?.length ?? 0) > 0; + const isSelected = selectedId === node.id; + const icon = getNodeTypeIcon(node.type); + + return ( +
+
onSelect(node)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(node); + } + }} + role="button" + tabIndex={0} + > + {hasChildren ? ( + + ) : ( + + )} + {icon} + {node.title} + + {formatOutlineNodeType(node.type)} + +
+ {hasChildren && expanded ? ( +
+ {node.children!.map((child) => ( + + ))} +
+ ) : null} +
+ ); +} + +/** + * 按节点类型返回图标。 + */ +function getNodeTypeIcon(type: string): React.ReactElement { + switch (type) { + case "section": + return ; + case "topic": + return ; + case "activity": + return ; + case "assessment": + return ; + default: + return ; + } +} diff --git a/apps/portal-shell/src/features/teacher/lesson-plans/new-lesson-plan-client.tsx b/apps/portal-shell/src/features/teacher/lesson-plans/new-lesson-plan-client.tsx new file mode 100644 index 0000000..ee42bb5 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/lesson-plans/new-lesson-plan-client.tsx @@ -0,0 +1,209 @@ +"use client"; + +/** + * 新建教案表单页 - 客户端组件(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2) + * + * 数据契约: + * - mutation createLessonPlan(input):❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending) + * - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#create-lesson-plan + * + * 三态规范(§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 { BookOpen } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useTransition, useState } from "react"; +import { useTranslations } from "next-intl"; + +import { useCreateLessonPlan, type CreateLessonPlanInput } from "@/lib/api"; +import { FormPageShell } from "@/shared/components/page-templates"; +import { notify } from "@/shared/lib/notify"; + +/** + * 表单客户端主体。需由 server page 包裹在 中。 + */ +export function NewLessonPlanClient(): React.ReactElement { + const t = useTranslations("lessonPlans"); + const router = useRouter(); + const [, startTransition] = useTransition(); + + // @contract-pending:MSW 兜底 + const { run: createLessonPlan, loading: submitting } = useCreateLessonPlan(); + + const handleSubmit = async (input: CreateLessonPlanInput): Promise => { + try { + const result = await createLessonPlan(input); + notify.success(t("new.success")); + startTransition(() => { + router.push(`/shell/teacher/lesson-plans/${result.id}/edit`); + }); + } catch (err) { + notify.error(`${t("new.error")}: ${String(err)}`); + } + }; + + return ( + + ); +} + +/** + * 表单主体(受控表单 + 内联校验)。 + */ +function NewLessonPlanFormInner({ + submitting, + onSubmit, +}: { + submitting: boolean; + onSubmit: (input: CreateLessonPlanInput) => Promise; +}): React.ReactElement { + const t = useTranslations("lessonPlans"); + const tCommon = useTranslations("common"); + + const [title, setTitle] = useState(""); + const [gradeId, setGradeId] = useState("grade-12"); + const [subjectId, setSubjectId] = useState("sub-math"); + const [objectives, setObjectives] = useState(""); + const [content, setContent] = useState(""); + const [duration, setDuration] = useState("45"); + const [error, setError] = useState(null); + + const handleFormSubmit = (): void => { + setError(null); + + if (!title.trim()) { + setError(t("new.errorTitleRequired")); + return; + } + if (!gradeId.trim()) { + setError(t("new.errorGradeRequired")); + return; + } + if (!subjectId.trim()) { + setError(t("new.errorSubjectRequired")); + return; + } + + const input: CreateLessonPlanInput = { + title: title.trim(), + gradeId: gradeId.trim(), + subjectId: subjectId.trim(), + objectives: objectives.trim() || undefined, + content: content.trim() || undefined, + duration: Number(duration) || 45, + }; + + void onSubmit(input); + }; + + return ( + } + backHref="/shell/teacher/lesson-plans" + onSubmit={handleFormSubmit} + submitting={submitting} + submitLabel={t("new.submit")} + cancelLabel={tCommon("button.cancel")} + errorSummary={ + error ?

{error}

: undefined + } + > + + setTitle(e.target.value)} + className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" + placeholder={t("new.titlePlaceholder")} + required + /> + + + + setGradeId(e.target.value)} + className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" + placeholder="grade-12" + required + /> + + + + setSubjectId(e.target.value)} + className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" + placeholder="sub-math" + required + /> + + + +