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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { LessonPlanListClient } from "@/features/teacher/lesson-plans/lesson-plan-list-client";
|
||||
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 教案管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
|
||||
* 业务逻辑在 LessonPlanListClient(client component)中。
|
||||
*
|
||||
* 数据契约:列表查询 lessonPlans(gradeId, subjectId, status) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#lesson-plans-list
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
export default function LessonPlansListPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<ListPageSkeleton rows={5} />}>
|
||||
<LessonPlanListClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 教案日历视图 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 查询 lessonPlanCalendar(month):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#lesson-plan-calendar
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(空日历)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { CalendarDays } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useLessonPlanCalendar } from "@/lib/api";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
formatCalendarDayCount,
|
||||
formatMonthLabel,
|
||||
lessonPlanStatusToBadgeClass,
|
||||
} from "@/features/teacher/lesson-plans/transformations";
|
||||
|
||||
/**
|
||||
* 日历客户端主体。需由 server page 包裹在 <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-pending:MSW 兜底
|
||||
const { data, loading, error } = useLessonPlanCalendar(month);
|
||||
|
||||
const goToMonth = (delta: number): void => {
|
||||
const [year, monthNum] = month.split("-").map(Number);
|
||||
if (!year || !monthNum) return;
|
||||
const d = new Date(year, monthNum - 1 + delta, 1);
|
||||
const newMonth = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
startTransition(() => {
|
||||
router.push(`/shell/teacher/lesson-plans/calendar?month=${newMonth}`);
|
||||
});
|
||||
};
|
||||
|
||||
const errorNode = error ? (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 教案编辑工作台页 - 客户端组件(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约(@contract-pending 全 MSW):
|
||||
* - lessonPlan(id) ❌ → MSW 兜底
|
||||
* - updateLessonPlan(input) mutation ❌ → MSW 兜底
|
||||
*
|
||||
* 布局(WorkbenchPageShell 三栏):
|
||||
* - left:大纲树(LessonPlanOutlineTree)
|
||||
* - center:富文本编辑器(contentEditable + 工具栏)
|
||||
* - right:属性面板(基本信息 + 资源列表 + 状态操作)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:WorkbenchPageSkeleton
|
||||
* - error:errorNode 局部降级(合并 empty 降级,因 WorkbenchPageShell 无 emptyNode)
|
||||
* - success:notify.success
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { BookOpen } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { forwardRef, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
useLessonPlan,
|
||||
useUpdateLessonPlan,
|
||||
type LessonPlanOutlineNode,
|
||||
} from "@/lib/api";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
WorkbenchPageShell,
|
||||
WorkbenchPageSkeleton,
|
||||
WorkbenchPanel,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatDuration,
|
||||
formatLessonPlanDate,
|
||||
formatLessonPlanStatus,
|
||||
formatResourceType,
|
||||
lessonPlanStatusToBadgeClass,
|
||||
} from "@/features/teacher/lesson-plans/transformations";
|
||||
import { LessonPlanOutlineTree } from "@/features/teacher/lesson-plans/lesson-plan-outline-tree";
|
||||
|
||||
/**
|
||||
* 教案编辑工作台客户端主体。需由 server page 包裹在 <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.execCommand(deprecated 但仍可用)。
|
||||
* 后续可升级到 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 教案热力图视图 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 查询 lessonPlanHeatmap(startDate, endDate):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#lesson-plan-heatmap
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(空热力图)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { Activity } from "lucide-react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useLessonPlanHeatmap } from "@/lib/api";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { intensityToColorClass } from "@/features/teacher/lesson-plans/transformations";
|
||||
|
||||
/** 热力图默认范围:过去 12 周 */
|
||||
function getDefaultRange(): { startDate: string; endDate: string } {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() - 7 * 12);
|
||||
const fmt = (d: Date): string =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
return { startDate: fmt(start), endDate: fmt(end) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 热力图客户端主体。需由 server page 包裹在 <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-pending:MSW 兜底
|
||||
const { data, loading, error } = useLessonPlanHeatmap({
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
|
||||
const shiftRange = (weeks: number): void => {
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
start.setDate(start.getDate() + weeks * 7);
|
||||
end.setDate(end.getDate() + weeks * 7);
|
||||
const fmt = (d: Date): string =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
startTransition(() => {
|
||||
router.push(
|
||||
`/shell/teacher/lesson-plans/heatmap?startDate=${fmt(start)}&endDate=${fmt(end)}`,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const errorNode = error ? (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 教案库列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 查询 lessonPlanLibrary(subjectId, grade):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#lesson-plan-library
|
||||
*
|
||||
* URL 状态:?subjectId=xxx &grade=xxx &q=xxx
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { Library } from "lucide-react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useMemo, useTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useLessonPlanLibrary, type LessonPlanLibraryItem } from "@/lib/api";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatDownloadCount,
|
||||
formatLessonPlanDate,
|
||||
formatRating,
|
||||
ratingToColorClass,
|
||||
} from "@/features/teacher/lesson-plans/transformations";
|
||||
|
||||
/**
|
||||
* 教案库客户端主体。需由 server page 包裹在 <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-pending:MSW 兜底
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 教案管理列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 列表查询 lessonPlans(gradeId, subjectId, status):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#lesson-plans-list
|
||||
*
|
||||
* URL 状态:?gradeId=xxx &subjectId=xxx &status=xxx &q=xxx
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { BookOpen } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useMemo, useTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useLessonPlansList, type LessonPlanListItem } from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatDuration,
|
||||
formatLessonPlanDate,
|
||||
formatLessonPlanStatus,
|
||||
lessonPlanStatusToBadgeClass,
|
||||
} from "@/features/teacher/lesson-plans/transformations";
|
||||
|
||||
/**
|
||||
* 列表客户端主体。需由 server page 包裹在 <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-pending:MSW 兜底
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 新建教案表单页 - 客户端组件(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - mutation createLessonPlan(input):❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#create-lesson-plan
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:FormPageSkeleton(初始数据加载,由 server page Suspense 兜底)
|
||||
* - error:errorSummary 表单级错误
|
||||
* - success:notify.success + router.push 回列表
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { BookOpen } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTransition, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useCreateLessonPlan, type CreateLessonPlanInput } from "@/lib/api";
|
||||
import { FormPageShell } from "@/shared/components/page-templates";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
|
||||
/**
|
||||
* 表单客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function NewLessonPlanClient(): React.ReactElement {
|
||||
const t = useTranslations("lessonPlans");
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { run: createLessonPlan, loading: submitting } = useCreateLessonPlan();
|
||||
|
||||
const handleSubmit = async (input: CreateLessonPlanInput): Promise<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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
517
apps/portal-shell/src/lib/api/lesson-plans.ts
Normal file
517
apps/portal-shell/src/lib/api/lesson-plans.ts
Normal file
@@ -0,0 +1,517 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Lesson Plans domain API(ARCHITECTURE.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-pending,MSW 返回此结构) */
|
||||
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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
207
apps/portal-shell/src/lib/api/operations/lesson-plans.graphql.ts
Normal file
207
apps/portal-shell/src/lib/api/operations/lesson-plans.graphql.ts
Normal 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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "课程计划"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/",
|
||||
|
||||
Reference in New Issue
Block a user