feat(portal-shell): lesson-plans 模块 6 页迁移(教师域 §9.1 B2)

§9.1 line 629 教师域 lesson-plans 行:
- /lesson-plans (列表) / /new (表单) / /library (教案库)
- /calendar (日历) / /heatmap (热力图) / /[planId]/edit (工作台)
契约:全  schema 无 → MSW 兜底 + @contract-pending

新增文件:
- src/lib/api/lesson-plans.ts (8 hooks)
- src/lib/api/operations/lesson-plans.graphql.ts (8 documents)
- src/features/teacher/lesson-plans/ (clients + transformations + tests)
- src/app/shell/teacher/lesson-plans/ (6 page.tsx + loading.tsx + error.tsx)

修改文件:
- src/mocks/graphql-data.ts (mock 数据 + handler cases)
- src/messages/{zh-CN,en}.json (lessonPlans i18n 命名空间)
- src/lib/api/{index,operations/index}.ts (导出 lesson-plans)
- src/shared/lib/route-permissions.ts (lesson-plans 路由权限声明)
- scripts/check-page-count.ts (baseline 31 → 37)

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

关联:ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §10 P2 / §11.3 / §11.4
契约工单:docs/architecture/issues/contracts/core-edu_contract.md
This commit is contained in:
SpecialX
2026-07-22 19:59:22 +08:00
parent 2f8f3f3855
commit 8ab4fae9d5
26 changed files with 3746 additions and 4 deletions

View File

@@ -19,9 +19,9 @@ interface Baseline {
categories: Record<string, { pattern: string; min: number; label: string }>;
}
// 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",

View File

@@ -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 (
<Suspense fallback={<WorkbenchPageSkeleton />}>
<LessonPlanEditClient />
</Suspense>
);
}

View File

@@ -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 (
<Suspense fallback={<DetailPageSkeleton />}>
<LessonPlanCalendarClient />
</Suspense>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
/**
* 教案路由错误边界ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment error.tsx捕获子树未处理异常。
*/
import { useEffect } from "react";
import { Button } from "@/shared/components/ui/button";
import { useTranslations } from "next-intl";
export default function 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 (
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
<h2 className="text-lg font-semibold text-destructive">
{t("error.title")}
</h2>
<p className="text-sm text-muted-foreground">
{error.message || t("error.unknown")}
</p>
<Button onClick={reset} variant="outline">
{t("error.retry")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,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 (
<Suspense fallback={<DetailPageSkeleton />}>
<LessonPlanHeatmapClient />
</Suspense>
);
}

View File

@@ -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 (
<Suspense fallback={<ListPageSkeleton rows={5} />}>
<LessonPlanLibraryClient />
</Suspense>
);
}

View File

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

View File

@@ -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 (
<Suspense fallback={<FormPageSkeleton />}>
<NewLessonPlanClient />
</Suspense>
);
}

View File

@@ -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 要求)。
* 业务逻辑在 LessonPlanListClientclient 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 (
<Suspense fallback={<ListPageSkeleton rows={5} />}>
<LessonPlanListClient />
</Suspense>
);
}

View File

@@ -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: "<p>集合是数学中最基本的概念...</p>",
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);
});
});

View File

@@ -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 DoDloading骨架/ 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 包裹在 <Suspense> 中。
*/
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-pendingMSW 兜底
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 ? (
<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("calendar.mswNotice")}
</p>
</div>
) : undefined;
return (
<DetailPageShell
title={t("calendar.title")}
description={formatMonthLabel(month)}
icon={<CalendarDays className="size-6" />}
actions={
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => goToMonth(-1)}
type="button"
>
{t("calendar.prevMonth")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => goToMonth(1)}
type="button"
>
{t("calendar.nextMonth")}
</Button>
</div>
}
backHref="/shell/teacher/lesson-plans"
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
>
{data ? <CalendarGrid days={data.days} /> : null}
</DetailPageShell>
);
}
/**
* 日历网格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<string, { date: string; lessonPlans: unknown[] }>();
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 (
<div className="overflow-x-auto rounded-xl border">
<div className="grid grid-cols-7 border-b bg-muted/30">
{weekdays.map((day) => (
<div
key={day}
className="p-2 text-center text-xs font-medium text-muted-foreground"
>
{day}
</div>
))}
</div>
<div className="grid grid-cols-7">
{/* 月首空白填充 */}
{Array.from({ length: firstWeekday }, (_, i) => (
<div
key={`blank-${i}`}
className="min-h-24 border-b border-r bg-muted/20 p-2"
/>
))}
{days.map((day) => (
<CalendarDayCell key={day.date} day={day} />
))}
</div>
</div>
);
}
/**
* 日历单日单元格。
*/
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 (
<div className="min-h-24 border-b border-r p-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium">{dayNum}</span>
{count > 0 ? (
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-primary/10 px-1 text-xs font-medium text-primary">
{formatCalendarDayCount(count)}
</span>
) : null}
</div>
<div className="mt-1 space-y-1">
{day.lessonPlans.slice(0, 3).map((lp, idx) => {
const plan = lp as {
id: string;
title: string;
status: string;
};
return (
<div
key={plan.id ?? idx}
className={`truncate rounded px-1 py-0.5 text-xs ${lessonPlanStatusToBadgeClass(plan.status)}`}
title={plan.title}
>
{plan.title}
</div>
);
})}
{count > 3 ? (
<p className="text-xs text-muted-foreground">
{t("calendar.moreCount", { count: count - 3 })}
</p>
) : null}
</div>
</div>
);
}

View File

@@ -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
* - loadingWorkbenchPageSkeleton
* - errorerrorNode 局部降级(合并 empty 降级,因 WorkbenchPageShell 无 emptyNode
* - successnotify.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 包裹在 <Suspense> 中。
*/
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<string | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | undefined>();
const editorRef = useRef<HTMLDivElement>(null);
// 首次拿到数据时初始化 HTML 内容
useEffect(() => {
if (data && html === null) {
setHtml(data.content || "");
}
}, [data, html]);
// WorkbenchPageShell 的 errorNode 同时承担 error + empty 两种降级场景
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
</div>
) : !loading && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("edit.notFound")}
</div>
) : undefined;
const handleSave = async (): Promise<void> => {
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 (
<WorkbenchPageShell
title={data?.title ?? t("edit.title")}
description={
data
? t("edit.subtitle", {
duration: formatDuration(data.duration),
status: formatLessonPlanStatus(data.status),
})
: undefined
}
icon={<BookOpen className="size-6" />}
actions={
<Button
type="button"
disabled={updateMutation.loading || !data}
onClick={handleSave}
>
{updateMutation.loading ? tCommon("status.loading") : t("edit.save")}
</Button>
}
loading={loading}
loadingNode={<WorkbenchPageSkeleton />}
errorNode={errorNode}
left={
data ? (
<WorkbenchPanel title={t("edit.outlineTitle")}>
<LessonPlanOutlineTree
nodes={data.outline}
selectedId={selectedNodeId}
onSelect={handleOutlineSelect}
/>
</WorkbenchPanel>
) : null
}
center={
data && html !== null ? (
<RichEditor
ref={editorRef}
html={html}
onChange={setHtml}
onExec={exec}
/>
) : null
}
right={
data ? (
<WorkbenchPanel title={t("edit.propertiesTitle")}>
<div className="space-y-4 text-sm">
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propPlanId")}
</p>
<p className="mt-1 font-mono text-xs">{data.id}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propGrade")}
</p>
<p className="mt-1 font-semibold">{data.gradeId}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propSubject")}
</p>
<p className="mt-1 font-semibold">{data.subjectId}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propDuration")}
</p>
<p className="mt-1 font-semibold">
{formatDuration(data.duration)}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propStatus")}
</p>
<span
className={`mt-1 inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${lessonPlanStatusToBadgeClass(data.status)}`}
>
{formatLessonPlanStatus(data.status)}
</span>
</div>
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propUpdatedAt")}
</p>
<p className="mt-1 text-xs">
{formatLessonPlanDate(data.updatedAt)}
</p>
</div>
{/* 资源列表 */}
{data.resources.length > 0 ? (
<div>
<p className="text-xs text-muted-foreground">
{t("edit.resourcesTitle")}
</p>
<ul className="mt-1 space-y-1">
{data.resources.map((res) => (
<li key={res.id} className="text-xs">
<a
href={res.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{res.name}
</a>
<span className="ml-1 text-muted-foreground">
({formatResourceType(res.type)})
</span>
</li>
))}
</ul>
</div>
) : null}
<p className="text-xs text-muted-foreground">
{t("edit.contractPending")}
</p>
</div>
</WorkbenchPanel>
) : null
}
/>
);
}
/**
* 富文本编辑器contentEditable + 工具栏)。
*
* 简化实现:使用 document.execCommanddeprecated 但仍可用)。
* 后续可升级到 tiptap/lexical@contract-pending 富文本库契约)。
*/
interface RichEditorProps {
html: string;
onChange: (html: string) => void;
onExec: (command: string, value?: string) => void;
}
const RichEditor = forwardRef<HTMLDivElement, RichEditorProps>(
function RichEditor({ html, onChange, onExec }, ref) {
const t = useTranslations("lessonPlans");
return (
<WorkbenchPanel title={t("edit.editorTitle")}>
<div className="flex h-full flex-col gap-3">
<div className="flex flex-wrap gap-1 border-b pb-3">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("bold")}
aria-label={t("edit.toolbarBold")}
>
<strong>B</strong>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("italic")}
aria-label={t("edit.toolbarItalic")}
>
<em>I</em>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("underline")}
aria-label={t("edit.toolbarUnderline")}
>
<u>U</u>
</Button>
<span className="mx-1 border-l" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("formatBlock", "<h1>")}
>
H1
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("formatBlock", "<h2>")}
>
H2
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("formatBlock", "<h3>")}
>
H3
</Button>
<span className="mx-1 border-l" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("insertUnorderedList")}
aria-label={t("edit.toolbarBulletList")}
>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("insertOrderedList")}
aria-label={t("edit.toolbarOrderedList")}
>
1.
</Button>
<span className="mx-1 border-l" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("formatBlock", "<p>")}
>
P
</Button>
</div>
<div
ref={ref}
contentEditable
suppressContentEditableWarning
className="flex-1 overflow-y-auto rounded-md border bg-background p-4 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
dangerouslySetInnerHTML={{ __html: html }}
onInput={(e) => onChange((e.target as HTMLDivElement).innerHTML)}
style={{ minHeight: "400px" }}
/>
</div>
</WorkbenchPanel>
);
},
);

View File

@@ -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 DoDloading骨架/ 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 包裹在 <Suspense> 中。
*/
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-pendingMSW 兜底
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 ? (
<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("heatmap.mswNotice")}
</p>
</div>
) : undefined;
return (
<DetailPageShell
title={t("heatmap.title")}
description={t("heatmap.range", { startDate, endDate })}
icon={<Activity className="size-6" />}
actions={
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => shiftRange(-4)}
type="button"
>
{t("heatmap.prevRange")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => shiftRange(4)}
type="button"
>
{t("heatmap.nextRange")}
</Button>
</div>
}
backHref="/shell/teacher/lesson-plans"
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
>
{data ? (
<HeatmapGrid cells={data.cells} maxCount={data.maxCount} />
) : null}
</DetailPageShell>
);
}
/**
* 热力图网格(按周列 × 星期行布局)。
*/
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 (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("heatmap.empty")}
</div>
);
}
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 (
<div className="space-y-4">
<div className="overflow-x-auto rounded-xl border p-4">
<div className="flex gap-1">
{/* 星期标签列 */}
<div className="flex flex-col gap-1 pr-2">
{weekdays.map((day, i) => (
<div
key={day}
className={`flex h-4 items-center justify-end text-xs text-muted-foreground ${
i % 2 === 0 ? "opacity-0" : ""
}`}
>
{day}
</div>
))}
</div>
{/* 热力图单元格 */}
{weeks.map((week, weekIdx) => (
<div key={weekIdx} className="flex flex-col gap-1">
{week.map((cell) => (
<div
key={cell.date}
className={`size-4 rounded-sm ${intensityToColorClass(cell.intensity)}`}
title={`${cell.date}: ${cell.count} ${t("heatmap.unit")}`}
/>
))}
</div>
))}
</div>
</div>
{/* 图例 */}
<div className="flex items-center justify-end gap-2 text-xs text-muted-foreground">
<span>{t("heatmap.less")}</span>
{[0, 1, 2, 3, 4].map((level) => (
<div
key={level}
className={`size-4 rounded-sm ${intensityToColorClass(level)}`}
/>
))}
<span>{t("heatmap.more")}</span>
<span className="ml-4">
{t("heatmap.maxCount", { count: maxCount })}
</span>
</div>
</div>
);
}

View File

@@ -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 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联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 包裹在 <Suspense> 中。
*/
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-pendingMSW 兜底
const { data, loading, error } = useLessonPlanLibrary({
subjectId: subjectId || undefined,
grade: grade || undefined,
});
const filteredItems = useMemo<LessonPlanLibraryItem[]>(() => {
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 ? (
<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("library.mswNotice")}
</p>
</div>
) : undefined;
return (
<ListPageShell
title={t("library.title")}
description={t("library.description")}
icon={<Library className="size-6" />}
filters={
<>
<FilterSearchInput
placeholder={t("library.searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
/>
<input
type="text"
value={subjectId}
onChange={(e) => 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")}
/>
<input
type="text"
value={grade}
onChange={(e) => 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={<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("library.total", { count: filteredItems.length })}</span>
</div>
}
>
<LibraryGrid items={filteredItems} />
</ListPageShell>
);
}
/**
* 教案库卡片网格(纯展示组件)。
*/
function LibraryGrid({
items,
}: {
items: LessonPlanLibraryItem[];
}): React.ReactElement {
const t = useTranslations("lessonPlans");
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item) => (
<div
key={item.id}
className="flex flex-col gap-3 rounded-xl border bg-card p-4"
>
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold">{item.title}</h3>
<span
className={`text-sm font-bold ${ratingToColorClass(item.rating)}`}
>
{formatRating(item.rating)}
</span>
</div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="rounded bg-muted px-2 py-0.5">
{item.subjectName}
</span>
<span className="rounded bg-muted px-2 py-0.5">{item.grade}</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{t("library.author", { name: item.authorName })}</span>
<span>{formatLessonPlanDate(item.updatedAt)}</span>
</div>
<div className="flex items-center justify-between border-t pt-2 text-xs">
<span className="text-muted-foreground">
{t("library.downloads", {
count: formatDownloadCount(item.downloadCount),
})}
</span>
<span className="text-muted-foreground">
{t("library.duration", { minutes: item.duration })}
</span>
</div>
</div>
))}
</div>
);
}

View File

@@ -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 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联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 包裹在 <Suspense> 中
* 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-pendingMSW 兜底
const { data, loading, error } = useLessonPlansList({
gradeId: gradeId || undefined,
subjectId: subjectId || undefined,
status: statusFilter || undefined,
});
// 客户端二次筛选q—— 后端补齐列表查询后改服务端筛选
const filteredItems = useMemo<LessonPlanListItem[]>(() => {
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 ? (
<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={<BookOpen className="size-6" />}
actions={
<Button asChild>
<Link href="/shell/teacher/lesson-plans/new">{t("list.new")}</Link>
</Button>
}
filters={
<>
<FilterSearchInput
placeholder={t("list.searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
/>
<input
type="text"
value={gradeId}
onChange={(e) => 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")}
/>
<input
type="text"
value={subjectId}
onChange={(e) => 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")}
/>
<select
value={statusFilter}
onChange={(e) => updateQuery("status", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.statusFilter")}
>
<option value="">{t("list.statusAll")}</option>
<option value="DRAFT">{t("list.statusDraft")}</option>
<option value="PUBLISHED">{t("list.statusPublished")}</option>
<option value="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>
}
>
<LessonPlanTable items={filteredItems} />
</ListPageShell>
);
}
/**
* 教案列表表格(纯展示组件,对齐 §8.2 排版规范)。
*/
function LessonPlanTable({
items,
}: {
items: LessonPlanListItem[];
}): React.ReactElement {
const t = useTranslations("lessonPlans");
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.colTitle")}</th>
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
<th className="p-3 text-left font-medium">
{t("list.colDuration")}
</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/lesson-plans/${plan.id}/edit`}
className="font-medium hover:underline"
>
{plan.title}
</Link>
{plan.objectives ? (
<p className="mt-1 text-xs text-muted-foreground">
{plan.objectives}
</p>
) : null}
</td>
<td className="p-3">
<LessonPlanStatusBadge status={plan.status} />
</td>
<td className="p-3 text-xs">{formatDuration(plan.duration)}</td>
<td className="p-3 font-mono text-xs">
{formatLessonPlanDate(plan.updatedAt)}
</td>
<td className="p-3 text-right">
<Link
href={`/shell/teacher/lesson-plans/${plan.id}/edit`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("list.edit")}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* 教案状态徽章(按状态色阶展示)。
*/
function LessonPlanStatusBadge({
status,
}: {
status: string;
}): React.ReactElement {
const label = formatLessonPlanStatus(status);
const cls = lessonPlanStatusToBadgeClass(status);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,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 (
<div className="space-y-1">
{nodes.map((node) => (
<OutlineNodeItem
key={node.id}
node={node}
depth={0}
selectedId={selectedId}
onSelect={onSelect}
/>
))}
</div>
);
}
/**
* 大纲节点项(递归渲染子节点)。
*/
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 (
<div>
<div
className={cn(
"flex cursor-pointer items-center gap-1 rounded-md px-2 py-1.5 text-sm hover:bg-muted/50",
isSelected && "bg-primary/10 text-primary",
)}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
onClick={() => onSelect(node)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(node);
}
}}
role="button"
tabIndex={0}
>
{hasChildren ? (
<button
type="button"
className="flex size-4 shrink-0 items-center justify-center"
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
aria-label={expanded ? "Collapse" : "Expand"}
>
<ChevronRight
className={cn(
"size-3 transition-transform",
expanded && "rotate-90",
)}
/>
</button>
) : (
<span className="size-4 shrink-0" />
)}
<span className="shrink-0">{icon}</span>
<span className="flex-1 truncate">{node.title}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{formatOutlineNodeType(node.type)}
</span>
</div>
{hasChildren && expanded ? (
<div>
{node.children!.map((child) => (
<OutlineNodeItem
key={child.id}
node={child}
depth={depth + 1}
selectedId={selectedId}
onSelect={onSelect}
/>
))}
</div>
) : null}
</div>
);
}
/**
* 按节点类型返回图标。
*/
function getNodeTypeIcon(type: string): React.ReactElement {
switch (type) {
case "section":
return <FileText className="size-4 text-muted-foreground" />;
case "topic":
return <Target className="size-4 text-muted-foreground" />;
case "activity":
return <Zap className="size-4 text-muted-foreground" />;
case "assessment":
return <ListChecks className="size-4 text-muted-foreground" />;
default:
return <FileText className="size-4 text-muted-foreground" />;
}
}

View File

@@ -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
* - loadingFormPageSkeleton初始数据加载由 server page Suspense 兜底)
* - errorerrorSummary 表单级错误
* - successnotify.success + router.push 回列表
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { 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 包裹在 <Suspense> 中。
*/
export function NewLessonPlanClient(): React.ReactElement {
const t = useTranslations("lessonPlans");
const router = useRouter();
const [, startTransition] = useTransition();
// @contract-pendingMSW 兜底
const { run: createLessonPlan, loading: submitting } = useCreateLessonPlan();
const handleSubmit = async (input: CreateLessonPlanInput): Promise<void> => {
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 (
<NewLessonPlanFormInner submitting={submitting} onSubmit={handleSubmit} />
);
}
/**
* 表单主体(受控表单 + 内联校验)。
*/
function NewLessonPlanFormInner({
submitting,
onSubmit,
}: {
submitting: boolean;
onSubmit: (input: CreateLessonPlanInput) => Promise<void>;
}): 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<string | null>(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 (
<FormPageShell
title={t("new.title")}
description={t("new.description")}
icon={<BookOpen className="size-6" />}
backHref="/shell/teacher/lesson-plans"
onSubmit={handleFormSubmit}
submitting={submitting}
submitLabel={t("new.submit")}
cancelLabel={tCommon("button.cancel")}
errorSummary={
error ? <p className="text-sm text-destructive">{error}</p> : undefined
}
>
<FormField label={t("new.titleLabel")} required>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder={t("new.titlePlaceholder")}
required
/>
</FormField>
<FormField label={t("new.gradeId")} required>
<input
type="text"
value={gradeId}
onChange={(e) => setGradeId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="grade-12"
required
/>
</FormField>
<FormField label={t("new.subjectId")} required>
<input
type="text"
value={subjectId}
onChange={(e) => setSubjectId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="sub-math"
required
/>
</FormField>
<FormField label={t("new.objectivesLabel")}>
<textarea
value={objectives}
onChange={(e) => setObjectives(e.target.value)}
rows={2}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder={t("new.objectivesPlaceholder")}
/>
</FormField>
<FormField label={t("new.contentLabel")}>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={4}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder={t("new.contentPlaceholder")}
/>
</FormField>
<FormField label={t("new.duration")}>
<input
type="number"
min={1}
value={duration}
onChange={(e) => setDuration(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
<p className="text-xs text-muted-foreground">{t("new.durationHint")}</p>
</FormField>
<p className="text-xs text-muted-foreground">
{t("new.contractPending")}
</p>
</FormPageShell>
);
}
/**
* 表单字段容器label + children
*/
function FormField({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}): React.ReactElement {
return (
<div className="space-y-2">
<label className="text-sm font-medium">
{label}
{required ? <span className="ml-1 text-destructive">*</span> : null}
</label>
{children}
</div>
);
}

View File

@@ -0,0 +1,261 @@
/**
* Lesson Plans 数据变换工具ARCHITECTURE.md §11.3 DoD - 纯函数单测)
*
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
* 关联ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
*/
import type {
LessonPlanDetail,
LessonPlanListItem,
LessonPlanStatus,
} from "@/lib/api";
/** 教案状态中文标签映射 */
export const LESSON_PLAN_STATUS_LABEL: Record<string, string> = {
DRAFT: "草稿",
PUBLISHED: "已发布",
ARCHIVED: "已归档",
};
/** 教案大纲节点类型中文标签映射 */
export const OUTLINE_NODE_TYPE_LABEL: Record<string, string> = {
section: "章节",
topic: "主题",
activity: "活动",
assessment: "评估",
};
/** 教案资源类型中文标签映射 */
export const RESOURCE_TYPE_LABEL: Record<string, string> = {
link: "链接",
file: "文件",
video: "视频",
image: "图片",
};
/**
* 将教案状态枚举值映射为中文标签。
* 未知状态回退为原始值。
*/
export function formatLessonPlanStatus(status: string): string {
return LESSON_PLAN_STATUS_LABEL[status] ?? status;
}
/**
* 将大纲节点类型枚举值映射为中文标签。
* 未知类型回退为原始值。
*/
export function formatOutlineNodeType(type: string): string {
return OUTLINE_NODE_TYPE_LABEL[type] ?? type;
}
/**
* 将资源类型枚举值映射为中文标签。
* 未知类型回退为原始值。
*/
export function formatResourceType(type: string): string {
return RESOURCE_TYPE_LABEL[type] ?? type;
}
/**
* 格式化 ISO 日期字符串为本地化展示zh-CN含年月日时分
* 输入无效时返回占位符。
*/
export function formatLessonPlanDate(
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",
});
}
/**
* 格式化时长(分钟)为友好展示。
* - < 60 分钟:返回 "N 分钟"
* - >= 60 分钟:返回 "X 小时 Y 分钟"Y 为 0 时省略)
*/
export function formatDuration(minutes: number): string {
if (!Number.isFinite(minutes) || minutes <= 0) return "--";
if (minutes < 60) return `${minutes} 分钟`;
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return rest === 0 ? `${hours} 小时` : `${hours} 小时 ${rest} 分钟`;
}
/**
* 判断教案是否处于可编辑状态DRAFT
*/
export function isLessonPlanEditable(status: string): boolean {
return status === "DRAFT";
}
/**
* 判断教案是否已发布PUBLISHED
*/
export function isLessonPlanPublished(status: string): boolean {
return status === "PUBLISHED";
}
/**
* 判断教案是否已归档ARCHIVED
*/
export function isLessonPlanArchived(status: string): boolean {
return status === "ARCHIVED";
}
/**
* 从教案详情中提取列表项视图模型(裁剪字段)。
*/
export function toLessonPlanListItem(
detail: LessonPlanDetail,
): LessonPlanListItem {
return {
id: detail.id,
title: detail.title,
gradeId: detail.gradeId,
subjectId: detail.subjectId,
objectives: detail.objectives,
duration: detail.duration,
status: detail.status,
createdAt: detail.createdAt,
updatedAt: detail.updatedAt,
};
}
/**
* 根据教案状态返回 Tailwind 徽章语义类名。
*/
export function lessonPlanStatusToBadgeClass(status: string): string {
switch (status) {
case "DRAFT":
return "bg-muted text-muted-foreground";
case "PUBLISHED":
return "bg-primary/10 text-primary";
case "ARCHIVED":
return "bg-muted text-muted-foreground";
default:
return "bg-muted text-muted-foreground";
}
}
/**
* 格式化评分0-5为 1 位小数字符串。
* 输入无效返回 "--"。
*/
export function formatRating(rating: number): string {
if (!Number.isFinite(rating) || rating < 0 || rating > 5) return "--";
return rating.toFixed(1);
}
/**
* 根据评分0-5返回 Tailwind 文本语义类名。
* - >= 4.0 → text-emerald-600高评分
* - >= 3.0 → text-amber-600中评分
* - 其他 → text-destructive低评分
*/
export function ratingToColorClass(rating: number): string {
if (!Number.isFinite(rating) || rating < 0 || rating > 5) {
return "text-muted-foreground";
}
if (rating >= 4.0) return "text-emerald-600";
if (rating >= 3.0) return "text-amber-600";
return "text-destructive";
}
/**
* 格式化下载量,超过 1000 时用 k 单位。
* - 0 → "0"
* - 1234 → "1.2k"
*/
export function formatDownloadCount(count: number): string {
if (!Number.isFinite(count) || count < 0) return "--";
if (count < 1000) return String(count);
return `${(count / 1000).toFixed(1)}k`;
}
/**
* 根据热力图强度0-4返回 Tailwind 背景色类名。
* 使用 zinc 色阶(对齐设计令牌,禁止硬编码 hex
*/
export function intensityToColorClass(intensity: number): string {
switch (intensity) {
case 0:
return "bg-muted";
case 1:
return "bg-zinc-200 dark:bg-zinc-800";
case 2:
return "bg-zinc-300 dark:bg-zinc-700";
case 3:
return "bg-zinc-400 dark:bg-zinc-600";
case 4:
return "bg-zinc-500 dark:bg-zinc-500";
default:
return "bg-muted";
}
}
/**
* 根据计数和最大值计算热力图强度0-4
* - count === 0 → 0
* - count / maxCount >= 0.75 → 4
* - >= 0.5 → 3
* - >= 0.25 → 2
* - > 0 → 1
*
* maxCount 为 0 或输入无效时返回 0。
*/
export function calcHeatmapIntensity(
count: number,
maxCount: number,
): 0 | 1 | 2 | 3 | 4 {
if (!Number.isFinite(count) || !Number.isFinite(maxCount) || maxCount <= 0) {
return 0;
}
if (count <= 0) return 0;
const ratio = count / maxCount;
if (ratio >= 0.75) return 4;
if (ratio >= 0.5) return 3;
if (ratio >= 0.25) return 2;
return 1;
}
/**
* 格式化日历单日教案数(用于日历单元格角标)。
*/
export function formatCalendarDayCount(count: number): string {
if (!Number.isFinite(count) || count <= 0) return "";
return String(count);
}
/**
* 格式化年月字符串YYYY-MM为本地化展示如 "2026年7月")。
* 输入无效返回原始值。
*/
export function formatMonthLabel(month: string): string {
if (!month) return "--";
const parts = month.split("-");
if (parts.length < 2) return month;
const year = parts[0];
const monthNum = Number(parts[1]);
if (!year || !Number.isFinite(monthNum) || monthNum < 1 || monthNum > 12) {
return month;
}
return `${year}${monthNum}`;
}
/**
* 将 LessonPlanStatus 类型守卫:判断字符串是否为合法状态。
*/
export function isValidLessonPlanStatus(
status: string,
): status is LessonPlanStatus {
return status === "DRAFT" || status === "PUBLISHED" || status === "ARCHIVED";
}

View File

@@ -17,6 +17,7 @@ export * from "./teacher";
export * from "./exams";
export * from "./homework";
export * from "./grades";
export * from "./lesson-plans";
export * from "./student";
export * from "./parent";
export * from "./admin";

View File

@@ -0,0 +1,517 @@
"use client";
/**
* Lesson Plans domain APIARCHITECTURE.md §5.1 / §5.3 / §9.1 教师域教案模块)
*
* 契约状态:全 ❌schema 无 lessonPlan(id) / lessonPlans / lessonPlanLibrary /
* lessonPlanCalendar / lessonPlanHeatmap 根字段,也无 Mutation 类型)
* → 所有查询与 mutation 走 MSW 兜底(@contract-pending
*
* 命名说明:本域 hook 命名遵循 homework.ts 模式(`useLessonPlansList` 而非
* `useLessonPlans`),以避免与 teacher.ts 中存量 `useLessonPlans(classId)`
* (用于 lesson-plan-editor widget冲突。详情类型用 `LessonPlanDetail`
* 同样为避免与 teacher.ts 的 `LessonPlan` 接口冲突。
*
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#lesson-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_LESSON_PLAN_DOC,
DELETE_LESSON_PLAN_DOC,
GET_LESSON_PLAN_CALENDAR_DOC,
GET_LESSON_PLAN_DOC,
GET_LESSON_PLAN_HEATMAP_DOC,
GET_LESSON_PLAN_LIBRARY_DOC,
GET_LESSON_PLANS_LIST_DOC,
UPDATE_LESSON_PLAN_DOC,
} from "./operations/lesson-plans.graphql";
import type { UseQueryResult } from "./types";
// ===== 数据类型(@contract-pending与 MSW mock 数据形状对齐)=====
/** 教案状态枚举 */
export type LessonPlanStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
/** 教案大纲节点(编辑器左侧树) */
export interface LessonPlanOutlineNode {
id: string;
title: string;
type: "section" | "topic" | "activity" | "assessment";
order: number;
children?: LessonPlanOutlineNode[];
}
/** 教案关联资源 */
export interface LessonPlanResource {
id: string;
name: string;
type: "link" | "file" | "video" | "image";
url: string;
}
/**
* 教案实体(完整字段,用于编辑工作台)。
*
* 命名为 `LessonPlanDetail` 以避免与 teacher.ts 的 `LessonPlan` 接口冲突
* (后者是 widget 时代的简化 stub被 lesson-plan-editor widget 使用)。
*/
export interface LessonPlanDetail {
id: string;
title: string;
gradeId: string;
subjectId: string;
objectives: string;
content: string;
attachments: string[];
duration: number;
status: LessonPlanStatus;
outline: LessonPlanOutlineNode[];
resources: LessonPlanResource[];
createdAt: string;
updatedAt: string;
}
/** 教案列表项(轻量字段集,用于列表渲染) */
export interface LessonPlanListItem {
id: string;
title: string;
gradeId: string;
subjectId: string;
objectives: string;
duration: number;
status: LessonPlanStatus;
createdAt: string;
updatedAt: string;
}
/** 列表查询响应(@contract-pending 假契约形状MSW 返回此结构) */
interface LessonPlansListResponse {
lessonPlans: {
items: LessonPlanListItem[];
total: number;
};
}
/** 单查响应(@contract-pendingMSW 返回此结构) */
interface LessonPlanResponse {
lessonPlan: LessonPlanDetail | null;
}
/** 创建教案输入 */
export interface CreateLessonPlanInput {
title: string;
gradeId: string;
subjectId: string;
objectives?: string;
content?: string;
attachments?: string[];
duration?: number;
}
/** 创建教案 mutation 响应(@contract-pending */
interface CreateLessonPlanResponse {
createLessonPlan: { id: string } | null;
}
/** 更新教案输入 */
export interface UpdateLessonPlanInput {
id: string;
title?: string;
objectives?: string;
content?: string;
attachments?: string[];
duration?: number;
status?: LessonPlanStatus;
outline?: LessonPlanOutlineNode[];
resources?: LessonPlanResource[];
}
/** 更新教案 mutation 响应(@contract-pending */
interface UpdateLessonPlanResponse {
updateLessonPlan: { id: string } | null;
}
/** 删除教案 mutation 响应(@contract-pending */
interface DeleteLessonPlanResponse {
deleteLessonPlan: { id: string } | null;
}
// ===== 教案库类型(@contract-pending 全 MSW=====
/** 教案库列表项(含共享库展示字段) */
export interface LessonPlanLibraryItem {
id: string;
title: string;
subjectId: string;
subjectName: string;
grade: string;
duration: number;
authorName: string;
downloadCount: number;
rating: number;
updatedAt: string;
}
/** 教案库查询响应 */
interface LessonPlanLibraryResponse {
lessonPlanLibrary: {
items: LessonPlanLibraryItem[];
total: number;
};
}
// ===== 日历类型(@contract-pending 全 MSW=====
/** 日历单条教案排期 */
export interface CalendarLessonPlan {
id: string;
title: string;
className: string;
subjectName: string;
startTime: string;
endTime: string;
status: LessonPlanStatus;
}
/** 日历单日条目 */
export interface CalendarDay {
date: string;
lessonPlans: CalendarLessonPlan[];
}
/** 日历完整数据 */
export interface LessonPlanCalendar {
month: string;
days: CalendarDay[];
}
/** 日历查询响应 */
interface LessonPlanCalendarResponse {
lessonPlanCalendar: LessonPlanCalendar | null;
}
// ===== 热力图类型(@contract-pending 全 MSW=====
/** 热力图单元格 */
export interface HeatmapCell {
date: string;
count: number;
intensity: 0 | 1 | 2 | 3 | 4;
}
/** 热力图完整数据 */
export interface LessonPlanHeatmap {
startDate: string;
endDate: string;
cells: HeatmapCell[];
maxCount: number;
}
/** 热力图查询响应 */
interface LessonPlanHeatmapResponse {
lessonPlanHeatmap: LessonPlanHeatmap | null;
}
// ===== 查询选项 =====
export interface LessonPlanQueryOptions {
enabled?: boolean;
pollInterval?: number;
fetchPolicy?: FetchPolicy;
}
// ===== Hooks =====
/**
* 查询教案列表(@contract-pendingMSW 兜底)。
*
* schema 无 lessonPlans 根字段,由 MSW handlers 返回 mock 数据。
* 后端补齐列表查询后切换到真实 fetcher页面无需改动。
*
* 命名为 `useLessonPlansList` 以避免与 teacher.ts 的 `useLessonPlans` 冲突
* (对齐 homework.ts 的 `useHomeworkList` 模式)。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 列表页 / §11.4 契约工单
*/
export function useLessonPlansList(
filter: {
gradeId?: string;
subjectId?: string;
status?: string;
limit?: number;
offset?: number;
},
options?: LessonPlanQueryOptions,
): UseQueryResult<{ items: LessonPlanListItem[]; total: number }> {
const result = useWidgetQuery<
LessonPlansListResponse,
{
gradeId?: string;
subjectId?: string;
status?: string;
limit?: number;
offset?: number;
}
>(
GET_LESSON_PLANS_LIST_DOC,
{
gradeId: filter.gradeId,
subjectId: filter.subjectId,
status: filter.status,
limit: filter.limit,
offset: filter.offset,
},
{
enabled: options?.enabled ?? true,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
},
);
return {
data: result.data?.lessonPlans,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 按 id 查询教案详情(@contract-pendingMSW 兜底)。
*
* schema 无 lessonPlan(id) 根字段,由 MSW handlers 返回 mock 数据。
* 用于 /shell/teacher/lesson-plans/[planId]/edit 工作台页。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 工作台页 / §11.4 契约工单
*/
export function useLessonPlan(
id: string,
options?: LessonPlanQueryOptions,
): UseQueryResult<LessonPlanDetail | null> {
const result = useWidgetQuery<LessonPlanResponse, { id: string }>(
GET_LESSON_PLAN_DOC,
{ id },
{
...options,
enabled: options?.enabled ?? id.length > 0,
},
);
return {
data: result.data?.lessonPlan ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 创建教案 mutation@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
* 用于 /shell/teacher/lesson-plans/new 表单页。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 表单页 / §11.4 契约工单
*/
export function useCreateLessonPlan(): {
run: (input: CreateLessonPlanInput) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
CreateLessonPlanResponse,
{ input: CreateLessonPlanInput }
>(CREATE_LESSON_PLAN_DOC);
const run = async (input: CreateLessonPlanInput): Promise<{ id: string }> => {
const data = await rawRun({ input });
if (!data?.createLessonPlan) {
throw new ApiError("Failed to create lesson plan", "INTERNAL_ERROR");
}
return data.createLessonPlan;
};
return { run, loading, error };
}
/**
* 更新教案 mutation@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
* 用于 /shell/teacher/lesson-plans/[planId]/edit 工作台页保存。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 工作台页 / §11.4 契约工单
*/
export function useUpdateLessonPlan(): {
run: (input: UpdateLessonPlanInput) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
UpdateLessonPlanResponse,
{ input: UpdateLessonPlanInput }
>(UPDATE_LESSON_PLAN_DOC);
const run = async (input: UpdateLessonPlanInput): Promise<{ id: string }> => {
const data = await rawRun({ input });
if (!data?.updateLessonPlan) {
throw new ApiError("Failed to update lesson plan", "INTERNAL_ERROR");
}
return data.updateLessonPlan;
};
return { run, loading, error };
}
/**
* 删除教案 mutation@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
* 用于列表/编辑页删除按钮。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 列表页 / §11.4 契约工单
*/
export function useDeleteLessonPlan(): {
run: (id: string) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<DeleteLessonPlanResponse, { id: string }>(
DELETE_LESSON_PLAN_DOC,
);
const run = async (id: string): Promise<{ id: string }> => {
const data = await rawRun({ id });
if (!data?.deleteLessonPlan) {
throw new ApiError("Failed to delete lesson plan", "INTERNAL_ERROR");
}
return data.deleteLessonPlan;
};
return { run, loading, error };
}
/**
* 查询教案库列表(@contract-pendingMSW 兜底)。
*
* schema 无 lessonPlanLibrary 根字段,由 MSW handlers 返回 mock 数据。
* 用于 /shell/teacher/lesson-plans/library 教案库列表页。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 教案库页 / §11.4 契约工单
*/
export function useLessonPlanLibrary(
filter: {
subjectId?: string;
grade?: string;
limit?: number;
offset?: number;
},
options?: LessonPlanQueryOptions,
): UseQueryResult<{ items: LessonPlanLibraryItem[]; total: number }> {
const result = useWidgetQuery<
LessonPlanLibraryResponse,
{
subjectId?: string;
grade?: string;
limit?: number;
offset?: number;
}
>(
GET_LESSON_PLAN_LIBRARY_DOC,
{
subjectId: filter.subjectId,
grade: filter.grade,
limit: filter.limit,
offset: filter.offset,
},
{
enabled: options?.enabled ?? true,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
},
);
return {
data: result.data?.lessonPlanLibrary,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询教案日历数据(@contract-pendingMSW 兜底)。
*
* schema 无 lessonPlanCalendar 根字段,由 MSW handlers 返回 mock 数据。
* 用于 /shell/teacher/lesson-plans/calendar 日历视图。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 日历视图 / §11.4 契约工单
*/
export function useLessonPlanCalendar(
month: string,
options?: LessonPlanQueryOptions,
): UseQueryResult<LessonPlanCalendar | null> {
const result = useWidgetQuery<LessonPlanCalendarResponse, { month: string }>(
GET_LESSON_PLAN_CALENDAR_DOC,
{ month },
{
...options,
enabled: options?.enabled ?? month.length > 0,
},
);
return {
data: result.data?.lessonPlanCalendar ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询教案热力图数据(@contract-pendingMSW 兜底)。
*
* schema 无 lessonPlanHeatmap 根字段,由 MSW handlers 返回 mock 数据。
* 用于 /shell/teacher/lesson-plans/heatmap 热力图视图。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 热力图视图 / §11.4 契约工单
*/
export function useLessonPlanHeatmap(
range: { startDate: string; endDate: string },
options?: LessonPlanQueryOptions,
): UseQueryResult<LessonPlanHeatmap | null> {
const result = useWidgetQuery<
LessonPlanHeatmapResponse,
{ startDate: string; endDate: string }
>(
GET_LESSON_PLAN_HEATMAP_DOC,
{ startDate: range.startDate, endDate: range.endDate },
{
...options,
enabled:
options?.enabled ??
(range.startDate.length > 0 && range.endDate.length > 0),
},
);
return {
data: result.data?.lessonPlanHeatmap ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}

View File

@@ -7,6 +7,7 @@ export * from "./teacher.graphql";
export * from "./exams.graphql";
export * from "./homework.graphql";
export * from "./grades.graphql";
export * from "./lesson-plans.graphql";
export * from "./student.graphql";
export * from "./parent.graphql";
export * from "./admin.graphql";

View File

@@ -0,0 +1,207 @@
// Lesson Plans domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
//
// 拆分原则:
// - 全部 8 个查询/mutation❌ schema 无对应字段/Mutation 类型
// → 走 MSW 兜底(@contract-pending等待后端补齐契约
//
// 注combined-schema.graphql 中 lessonPlanStatus 仅在 ai 子图存在(状态轮询用),
// core-edu 子图无 lessonPlan(id) / lessonPlans / lessonPlanLibrary / lessonPlanCalendar /
// lessonPlanHeatmap 根字段,也无 Mutation 类型 → 全部走 MSW 兜底。
//
// 契约工单docs/architecture/issues/contracts/core-edu_contract.md#lesson-plans
// 关联ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §11.4
import { gql } from "@apollo/client";
// ── 假契约查询(@contract-pending─────────────────────────────
// 列表查询schema 无 lessonPlans 根字段
// 页面通过 MSW 兜底获取列表数据,后端补齐后切换 fetcher 指向真实查询
//
// 命名说明operation name 与常量名使用 `GetLessonPlansList` /
// `GET_LESSON_PLANS_LIST_DOC`,以避免与 teacher.graphql.ts 中存量
// `GetLessonPlans` / `GET_LESSON_PLANS_DOC`widget lesson-plan-editor 用)
// 冲突。MSW handlers 也通过不同 operationName 区分两种响应形状。
//
// 契约工单core-edu_contract.md#lesson-plans-list
export const GET_LESSON_PLANS_LIST_DOC = gql`
query GetLessonPlansList(
$gradeId: ID
$subjectId: ID
$status: String
$limit: Int
$offset: Int
) {
lessonPlans(
gradeId: $gradeId
subjectId: $subjectId
status: $status
limit: $limit
offset: $offset
) {
items {
id
title
gradeId
subjectId
objectives
duration
status
createdAt
updatedAt
}
total
}
}
`;
// ── 单查(@contract-pending────────────────────────────────────
// schema 无 lessonPlan(id) 根字段 → MSW 兜底
// 用于 /shell/teacher/lesson-plans/[planId]/edit 工作台页
// 契约工单core-edu_contract.md#lesson-plan-detail
export const GET_LESSON_PLAN_DOC = gql`
query GetLessonPlan($id: ID!) {
lessonPlan(id: $id) {
id
title
gradeId
subjectId
objectives
content
attachments
duration
status
outline {
id
title
type
order
children {
id
title
type
order
}
}
resources {
id
name
type
url
}
createdAt
updatedAt
}
}
`;
// ── 假契约变更(@contract-pending─────────────────────────────
// 创建教案schema 无 Mutation 类型
// 页面通过 MSW 兜底提交,后端补齐 mutation 后切换 fetcher
// 契约工单core-edu_contract.md#create-lesson-plan
export const CREATE_LESSON_PLAN_DOC = gql`
mutation CreateLessonPlan($input: CreateLessonPlanInput!) {
createLessonPlan(input: $input) {
id
}
}
`;
// ── 更新教案 mutation@contract-pending──────────────────────
// schema 无 Mutation 类型 → MSW 兜底
// 用于 /shell/teacher/lesson-plans/[planId]/edit 工作台页保存
// 契约工单core-edu_contract.md#update-lesson-plan
export const UPDATE_LESSON_PLAN_DOC = gql`
mutation UpdateLessonPlan($input: UpdateLessonPlanInput!) {
updateLessonPlan(input: $input) {
id
}
}
`;
// ── 删除教案 mutation@contract-pending──────────────────────
// schema 无 Mutation 类型 → MSW 兜底
// 用于列表/编辑页删除按钮
// 契约工单core-edu_contract.md#delete-lesson-plan
export const DELETE_LESSON_PLAN_DOC = gql`
mutation DeleteLessonPlan($id: ID!) {
deleteLessonPlan(id: $id) {
id
}
}
`;
// ── 教案库(@contract-pending 全 MSW──────────────────────────
// schema 无 lessonPlanLibrary 根字段 → MSW 兜底
// 用于 /shell/teacher/lesson-plans/library 教案库列表页
// 契约工单core-edu_contract.md#lesson-plan-library
export const GET_LESSON_PLAN_LIBRARY_DOC = gql`
query GetLessonPlanLibrary(
$subjectId: ID
$grade: String
$limit: Int
$offset: Int
) {
lessonPlanLibrary(
subjectId: $subjectId
grade: $grade
limit: $limit
offset: $offset
) {
items {
id
title
subjectId
subjectName
grade
duration
authorName
downloadCount
rating
updatedAt
}
total
}
}
`;
// ── 教案日历(@contract-pending 全 MSW────────────────────────
// schema 无 lessonPlanCalendar 根字段 → MSW 兜底
// 用于 /shell/teacher/lesson-plans/calendar 日历视图
// 契约工单core-edu_contract.md#lesson-plan-calendar
export const GET_LESSON_PLAN_CALENDAR_DOC = gql`
query GetLessonPlanCalendar($month: String!) {
lessonPlanCalendar(month: $month) {
month
days {
date
lessonPlans {
id
title
className
subjectName
startTime
endTime
status
}
}
}
}
`;
// ── 教案热力图(@contract-pending 全 MSW──────────────────────
// schema 无 lessonPlanHeatmap 根字段 → MSW 兜底
// 用于 /shell/teacher/lesson-plans/heatmap 热力图视图
// 契约工单core-edu_contract.md#lesson-plan-heatmap
export const GET_LESSON_PLAN_HEATMAP_DOC = gql`
query GetLessonPlanHeatmap($startDate: String!, $endDate: String!) {
lessonPlanHeatmap(startDate: $startDate, endDate: $endDate) {
startDate
endDate
cells {
date
count
intensity
}
maxCount
}
}
`;

View File

@@ -661,7 +661,123 @@
"title": "Textbooks"
},
"lessonPlans": {
"title": "Lesson Plans"
"title": "Lesson Plans",
"list": {
"title": "Lesson Plans",
"description": "View and manage all lesson plans",
"new": "New Lesson Plan",
"searchPlaceholder": "Search lesson plan name...",
"gradePlaceholder": "Grade ID",
"subjectPlaceholder": "Subject ID",
"statusFilter": "Filter by status",
"statusAll": "All statuses",
"statusDraft": "Draft",
"statusPublished": "Published",
"statusArchived": "Archived",
"total": "{count} total",
"colTitle": "Title",
"colStatus": "Status",
"colDuration": "Duration",
"colUpdatedAt": "Updated At",
"colActions": "Actions",
"edit": "Edit",
"mswNotice": "List query contract is pending, please ensure NEXT_PUBLIC_MSW=1 is enabled."
},
"new": {
"title": "New Lesson Plan",
"description": "Fill in lesson plan basic information",
"submit": "Create Lesson Plan",
"success": "Lesson plan created successfully",
"error": "Creation failed",
"titleLabel": "Title",
"titlePlaceholder": "e.g. Sets and Their Representations",
"gradeId": "Grade ID",
"subjectId": "Subject ID",
"objectivesLabel": "Objectives",
"objectivesPlaceholder": "Teaching objectives for this lesson...",
"contentLabel": "Content",
"contentPlaceholder": "Lesson plan content...",
"duration": "Duration (minutes)",
"durationHint": "Lesson duration, default 45 minutes",
"errorTitleRequired": "Please fill in lesson plan title",
"errorGradeRequired": "Please fill in grade ID",
"errorSubjectRequired": "Please fill in subject ID",
"contractPending": "Create lesson plan contract is @contract-pending, currently backed by MSW. Will switch to real submission once backend mutation is ready."
},
"library": {
"title": "Lesson Plan Library",
"description": "Browse and download shared lesson plans",
"searchPlaceholder": "Search lesson plan name...",
"subjectPlaceholder": "Subject ID",
"gradePlaceholder": "Grade",
"total": "{count} total",
"author": "Author: {name}",
"downloads": "{count} downloads",
"duration": "{minutes} min",
"mswNotice": "Library query contract is pending, please ensure NEXT_PUBLIC_MSW=1 is enabled."
},
"calendar": {
"title": "Lesson Plan Calendar",
"prevMonth": "Previous",
"nextMonth": "Next",
"weekSun": "Sun",
"weekMon": "Mon",
"weekTue": "Tue",
"weekWed": "Wed",
"weekThu": "Thu",
"weekFri": "Fri",
"weekSat": "Sat",
"moreCount": "{count} more",
"mswNotice": "Calendar query contract is pending, please ensure NEXT_PUBLIC_MSW=1 is enabled."
},
"heatmap": {
"title": "Lesson Plan Heatmap",
"range": "Range: {startDate} to {endDate}",
"prevRange": "Previous 4 weeks",
"nextRange": "Next 4 weeks",
"weekSun": "Sun",
"weekMon": "Mon",
"weekTue": "Tue",
"weekWed": "Wed",
"weekThu": "Thu",
"weekFri": "Fri",
"weekSat": "Sat",
"less": "Less",
"more": "More",
"unit": "plans",
"maxCount": "Max {count} plans",
"empty": "No heatmap data",
"mswNotice": "Heatmap query contract is pending, please ensure NEXT_PUBLIC_MSW=1 is enabled."
},
"edit": {
"title": "Edit Lesson Plan",
"subtitle": "Duration: {duration} · Status: {status}",
"save": "Save",
"saveSuccess": "Lesson plan saved successfully",
"saveFailed": "Save failed: {message}",
"notFound": "Lesson plan not found, may have been deleted",
"outlineTitle": "Outline",
"propertiesTitle": "Properties",
"propPlanId": "Plan ID",
"propGrade": "Grade",
"propSubject": "Subject",
"propDuration": "Duration",
"propStatus": "Status",
"propUpdatedAt": "Updated At",
"resourcesTitle": "Resources",
"editorTitle": "Content Editor",
"toolbarBold": "Bold",
"toolbarItalic": "Italic",
"toolbarUnderline": "Underline",
"toolbarBulletList": "Bullet list",
"toolbarOrderedList": "Ordered list",
"contractPending": "Rich text editor contract is @contract-pending, currently backed by MSW. Will switch to real data once backend is ready."
},
"error": {
"title": "Lesson plan page error",
"unknown": "An unknown error occurred",
"retry": "Retry"
}
},
"coursePlans": {
"title": "Course Plans"

View File

@@ -661,7 +661,123 @@
"title": "教材"
},
"lessonPlans": {
"title": "备课"
"title": "备课",
"list": {
"title": "教案管理",
"description": "查看和管理所有教案",
"new": "新建教案",
"searchPlaceholder": "搜索教案名称...",
"gradePlaceholder": "年级 ID",
"subjectPlaceholder": "科目 ID",
"statusFilter": "按状态筛选",
"statusAll": "全部状态",
"statusDraft": "草稿",
"statusPublished": "已发布",
"statusArchived": "已归档",
"total": "共 {count} 条",
"colTitle": "标题",
"colStatus": "状态",
"colDuration": "时长",
"colUpdatedAt": "更新时间",
"colActions": "操作",
"edit": "编辑",
"mswNotice": "列表查询契约待补齐,请确认 NEXT_PUBLIC_MSW=1 已启用。"
},
"new": {
"title": "新建教案",
"description": "填写教案基本信息",
"submit": "创建教案",
"success": "教案创建成功",
"error": "创建失败",
"titleLabel": "标题",
"titlePlaceholder": "例如:集合的概念与表示",
"gradeId": "年级 ID",
"subjectId": "科目 ID",
"objectivesLabel": "教学目标",
"objectivesPlaceholder": "本节课要达成的教学目标...",
"contentLabel": "教案内容",
"contentPlaceholder": "教案正文内容...",
"duration": "时长(分钟)",
"durationHint": "本节课时长,默认 45 分钟",
"errorTitleRequired": "请填写教案标题",
"errorGradeRequired": "请填写年级 ID",
"errorSubjectRequired": "请填写科目 ID",
"contractPending": "创建教案契约为 @contract-pending当前通过 MSW 兜底。后端补齐 mutation 后将切换为真实提交。"
},
"library": {
"title": "教案库",
"description": "浏览和下载共享教案",
"searchPlaceholder": "搜索教案名称...",
"subjectPlaceholder": "科目 ID",
"gradePlaceholder": "年级",
"total": "共 {count} 条",
"author": "作者:{name}",
"downloads": "下载 {count} 次",
"duration": "{minutes} 分钟",
"mswNotice": "教案库查询契约待补齐,请确认 NEXT_PUBLIC_MSW=1 已启用。"
},
"calendar": {
"title": "教案日历",
"prevMonth": "上月",
"nextMonth": "下月",
"weekSun": "日",
"weekMon": "一",
"weekTue": "二",
"weekWed": "三",
"weekThu": "四",
"weekFri": "五",
"weekSat": "六",
"moreCount": "还有 {count} 条",
"mswNotice": "日历查询契约待补齐,请确认 NEXT_PUBLIC_MSW=1 已启用。"
},
"heatmap": {
"title": "教案热力图",
"range": "范围:{startDate} 至 {endDate}",
"prevRange": "前 4 周",
"nextRange": "后 4 周",
"weekSun": "日",
"weekMon": "一",
"weekTue": "二",
"weekWed": "三",
"weekThu": "四",
"weekFri": "五",
"weekSat": "六",
"less": "少",
"more": "多",
"unit": "篇",
"maxCount": "最多 {count} 篇",
"empty": "暂无热力图数据",
"mswNotice": "热力图查询契约待补齐,请确认 NEXT_PUBLIC_MSW=1 已启用。"
},
"edit": {
"title": "编辑教案",
"subtitle": "时长:{duration} · 状态:{status}",
"save": "保存",
"saveSuccess": "教案保存成功",
"saveFailed": "保存失败:{message}",
"notFound": "未找到教案,可能已被删除",
"outlineTitle": "大纲",
"propertiesTitle": "属性",
"propPlanId": "教案 ID",
"propGrade": "年级",
"propSubject": "科目",
"propDuration": "时长",
"propStatus": "状态",
"propUpdatedAt": "更新时间",
"resourcesTitle": "资源",
"editorTitle": "内容编辑",
"toolbarBold": "加粗",
"toolbarItalic": "斜体",
"toolbarUnderline": "下划线",
"toolbarBulletList": "无序列表",
"toolbarOrderedList": "有序列表",
"contractPending": "富文本编辑器契约为 @contract-pending当前通过 MSW 兜底。后端补齐后切换为真实数据。"
},
"error": {
"title": "教案页面出错了",
"unknown": "发生未知错误",
"retry": "重试"
}
},
"coursePlans": {
"title": "课程计划"

View File

@@ -1133,6 +1133,297 @@ const mockReportCard = {
totalStudents: 38,
};
// ── Lesson Plans 域(教师域 P2 迁移,@contract-pending 全 MSW─────────
// schema 无 lessonPlan(id) / lessonPlans(列表) / lessonPlanLibrary /
// lessonPlanCalendar / lessonPlanHeatmap 根字段,也无 Mutation 类型
// → 全部走 MSW 兜底
// 契约工单docs/architecture/issues/contracts/core-edu_contract.md#lesson-plans
const mockLessonPlansList = [
{
id: "lp-001",
title: "集合的概念与表示",
gradeId: "grade-12",
subjectId: "sub-math",
objectives: "理解集合的定义,掌握集合的两种表示方法",
duration: 45,
status: "PUBLISHED",
createdAt: "2026-07-15T08:00:00Z",
updatedAt: "2026-07-18T10:00:00Z",
},
{
id: "lp-002",
title: "函数的单调性",
gradeId: "grade-12",
subjectId: "sub-math",
objectives: "理解函数单调性的概念,能判断简单函数的单调性",
duration: 45,
status: "DRAFT",
createdAt: "2026-07-19T08:00:00Z",
updatedAt: "2026-07-20T12:00:00Z",
},
{
id: "lp-003",
title: "文言文阅读:岳阳楼记",
gradeId: "grade-12",
subjectId: "sub-chinese",
objectives: "理解文章主旨,分析写作手法",
duration: 90,
status: "PUBLISHED",
createdAt: "2026-07-10T08:00:00Z",
updatedAt: "2026-07-12T15:00:00Z",
},
{
id: "lp-004",
title: "牛顿第二定律应用",
gradeId: "grade-11",
subjectId: "sub-physics",
objectives: "掌握牛顿第二定律的应用方法",
duration: 45,
status: "ARCHIVED",
createdAt: "2026-06-01T08:00:00Z",
updatedAt: "2026-06-05T10:00:00Z",
},
];
const mockLessonPlanDetail = {
id: "lp-001",
title: "集合的概念与表示",
gradeId: "grade-12",
subjectId: "sub-math",
objectives: "理解集合的定义,掌握集合的两种表示方法",
content:
"<h2>导入</h2><p>在日常生活中,我们经常把一些对象分组...</p><h2>新授</h2><p>集合的定义:把一些能够确定的不同的对象看成一个整体,就说这个整体是由这些对象的全体构成的集合。</p>",
attachments: [],
duration: 45,
status: "PUBLISHED",
outline: [
{
id: "node-1",
title: "导入",
type: "section",
order: 1,
children: [
{
id: "node-1-1",
title: "生活实例引入",
type: "activity",
order: 1,
},
],
},
{
id: "node-2",
title: "新授",
type: "section",
order: 2,
children: [
{
id: "node-2-1",
title: "集合的定义",
type: "topic",
order: 1,
},
{
id: "node-2-2",
title: "集合的表示方法",
type: "topic",
order: 2,
},
],
},
{
id: "node-3",
title: "课堂练习",
type: "assessment",
order: 3,
},
],
resources: [
{
id: "res-1",
name: "教材P1-10",
type: "file",
url: "https://example.com/textbook.pdf",
},
{
id: "res-2",
name: "集合概念微课",
type: "video",
url: "https://example.com/video.mp4",
},
],
createdAt: "2026-07-15T08:00:00Z",
updatedAt: "2026-07-18T10:00:00Z",
};
const mockLessonPlanLibrary = [
{
id: "lib-001",
title: "高中数学必修一全册教案",
subjectId: "sub-math",
subjectName: "数学",
grade: "高一",
duration: 45,
authorName: "张老师",
downloadCount: 1234,
rating: 4.5,
updatedAt: "2026-07-10T08:00:00Z",
},
{
id: "lib-002",
title: "高中语文古诗文教案集",
subjectId: "sub-chinese",
subjectName: "语文",
grade: "高二",
duration: 90,
authorName: "李老师",
downloadCount: 856,
rating: 4.8,
updatedAt: "2026-07-12T08:00:00Z",
},
{
id: "lib-003",
title: "物理力学实验教案",
subjectId: "sub-physics",
subjectName: "物理",
grade: "高二",
duration: 45,
authorName: "王老师",
downloadCount: 432,
rating: 3.5,
updatedAt: "2026-07-08T08:00:00Z",
},
{
id: "lib-004",
title: "化学有机反应教案",
subjectId: "sub-chemistry",
subjectName: "化学",
grade: "高三",
duration: 45,
authorName: "赵老师",
downloadCount: 99,
rating: 2.5,
updatedAt: "2026-07-05T08:00:00Z",
},
];
const mockLessonPlanCalendar = {
month: "2026-07",
days: Array.from({ length: 31 }, (_, i) => {
const day = i + 1;
const dateStr = `2026-07-${String(day).padStart(2, "0")}`;
// 随机给一些天添加教案
if (
day === 1 ||
day === 5 ||
day === 8 ||
day === 12 ||
day === 15 ||
day === 20
) {
return {
date: dateStr,
lessonPlans: [
{
id: `cal-lp-${day}`,
title: `数学教案 ${day}`,
className: "高三(1)班",
subjectName: "数学",
startTime: `2026-07-${String(day).padStart(2, "0")}T08:00:00Z`,
endTime: `2026-07-${String(day).padStart(2, "0")}T08:45:00Z`,
status: "PUBLISHED",
},
],
};
}
if (day === 10 || day === 18) {
return {
date: dateStr,
lessonPlans: [
{
id: `cal-lp-${day}-1`,
title: `语文教案 ${day}`,
className: "高三(2)班",
subjectName: "语文",
startTime: `2026-07-${String(day).padStart(2, "0")}T10:00:00Z`,
endTime: `2026-07-${String(day).padStart(2, "0")}T11:30:00Z`,
status: "PUBLISHED",
},
{
id: `cal-lp-${day}-2`,
title: `物理教案 ${day}`,
className: "高三(1)班",
subjectName: "物理",
startTime: `2026-07-${String(day).padStart(2, "0")}T14:00:00Z`,
endTime: `2026-07-${String(day).padStart(2, "0")}T14:45:00Z`,
status: "DRAFT",
},
{
id: `cal-lp-${day}-3`,
title: `化学教案 ${day}`,
className: "高三(3)班",
subjectName: "化学",
startTime: `2026-07-${String(day).padStart(2, "0")}T16:00:00Z`,
endTime: `2026-07-${String(day).padStart(2, "0")}T16:45:00Z`,
status: "PUBLISHED",
},
{
id: `cal-lp-${day}-4`,
title: `英语教案 ${day}`,
className: "高三(1)班",
subjectName: "英语",
startTime: `2026-07-${String(day).padStart(2, "0")}T17:00:00Z`,
endTime: `2026-07-${String(day).padStart(2, "0")}T17:45:00Z`,
status: "DRAFT",
},
],
};
}
return { date: dateStr, lessonPlans: [] };
}),
};
const mockLessonPlanHeatmap = {
startDate: "2026-05-06",
endDate: "2026-07-22",
maxCount: 5,
cells: (() => {
const cells: { date: string; count: number; intensity: number }[] = [];
const start = new Date("2026-05-06");
const end = new Date("2026-07-22");
const cursor = new Date(start);
const maxCount = 5;
while (cursor <= end) {
const dateStr = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, "0")}-${String(cursor.getDate()).padStart(2, "0")}`;
// 工作日多数有教案,周末较少
const dayOfWeek = cursor.getDay();
let count = 0;
if (dayOfWeek === 0 || dayOfWeek === 6) {
count = Math.random() < 0.2 ? 1 : 0;
} else {
const rand = Math.random();
if (rand < 0.15) count = 0;
else if (rand < 0.4) count = 1;
else if (rand < 0.65) count = 2;
else if (rand < 0.85) count = 3;
else if (rand < 0.95) count = 4;
else count = 5;
}
const ratio = count / maxCount;
let intensity: number = 0;
if (count === 0) intensity = 0;
else if (ratio >= 0.75) intensity = 4;
else if (ratio >= 0.5) intensity = 3;
else if (ratio >= 0.25) intensity = 2;
else intensity = 1;
cells.push({ date: dateStr, count, intensity });
cursor.setDate(cursor.getDate() + 1);
}
return cells;
})(),
};
// ── GraphQL Response ───────────────────────────────────────────
/**
@@ -1614,6 +1905,101 @@ export function graphqlResponse(
case "GetReportCard":
return { data: { reportCard: mockReportCard } };
// ── Lesson Plans 域(教师域 P2 迁移,@contract-pending 全 MSW──
// GetLessonPlansList(gradeId, subjectId, status):列表查询
case "GetLessonPlansList": {
const gradeId = variables?.gradeId as string | undefined;
const subjectId = variables?.subjectId as string | undefined;
const status = variables?.status as string | undefined;
const filtered = mockLessonPlansList.filter(
(lp) =>
(!gradeId || lp.gradeId === gradeId) &&
(!subjectId || lp.subjectId === subjectId) &&
(!status || lp.status === status),
);
return {
data: {
lessonPlans: {
items: filtered,
total: filtered.length,
},
},
};
}
// GetLessonPlan($id):按 id 单查,任意 id 都返回同一条dev 兜底)
case "GetLessonPlan": {
const planId = (variables?.id as string | undefined) ?? "";
const found =
mockLessonPlansList.find((lp) => lp.id === planId) ??
mockLessonPlanDetail;
return {
data: {
lessonPlan:
found === mockLessonPlanDetail
? mockLessonPlanDetail
: {
...found,
content: "",
attachments: [],
outline: [],
resources: [],
},
},
};
}
// CreateLessonPlan($input)mutation 兜底,返回基于时间戳的新 id
case "CreateLessonPlan": {
const input = (variables?.input ?? {}) as Record<string, unknown>;
const newId = `lp-${Date.now()}`;
mockLessonPlansList.push({
id: newId,
title: (input.title as string) ?? "未命名教案",
gradeId: (input.gradeId as string) ?? "grade-12",
subjectId: (input.subjectId as string) ?? "sub-math",
objectives: (input.objectives as string) ?? "",
duration: (input.duration as number) ?? 45,
status: "DRAFT",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
return { data: { createLessonPlan: { id: newId } } };
}
// UpdateLessonPlan($input)mutation 兜底
case "UpdateLessonPlan": {
const input = (variables?.input ?? {}) as Record<string, unknown>;
const planId = (input.id as string) ?? "lp-001";
return { data: { updateLessonPlan: { id: planId } } };
}
// DeleteLessonPlan($id)mutation 兜底
case "DeleteLessonPlan": {
const planId = (variables?.id as string) ?? "lp-001";
return { data: { deleteLessonPlan: { id: planId } } };
}
// GetLessonPlanLibrary(subjectId, grade):教案库列表
case "GetLessonPlanLibrary": {
const subjectId = variables?.subjectId as string | undefined;
const grade = variables?.grade as string | undefined;
const filtered = mockLessonPlanLibrary.filter(
(lp) =>
(!subjectId || lp.subjectId === subjectId) &&
(!grade || lp.grade === grade),
);
return {
data: {
lessonPlanLibrary: {
items: filtered,
total: filtered.length,
},
},
};
}
// GetLessonPlanCalendar($month):日历数据
case "GetLessonPlanCalendar":
return { data: { lessonPlanCalendar: mockLessonPlanCalendar } };
// GetLessonPlanHeatmap($startDate, $endDate):热力图数据
case "GetLessonPlanHeatmap":
return { data: { lessonPlanHeatmap: mockLessonPlanHeatmap } };
// ── 通用 ──
case "GetNotificationsList":
return {

View File

@@ -220,6 +220,18 @@ export const PREFIX_ROUTE_PERMISSIONS: Array<{
anyOfPermissions: ["HOMEWORK_READ", "HOMEWORK_CREATE", "HOMEWORK_GRADE"],
},
},
// 教案管理P2 迁移)
{
prefix: "/shell/teacher/lesson-plans/",
config: {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: [
"LESSON_PLAN_READ",
"LESSON_PLAN_CREATE",
"LESSON_PLAN_UPDATE",
],
},
},
// 成绩录入
{
prefix: "/shell/teacher/grades/",