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

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

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

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

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

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

View File

@@ -0,0 +1,360 @@
/**
* Lesson Plans 数据变换工具单测ARCHITECTURE.md §11.3 DoD
*
* 关联ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
*/
import { describe, expect, it } from "vitest";
import type { LessonPlanDetail } from "@/lib/api";
import {
LESSON_PLAN_STATUS_LABEL,
OUTLINE_NODE_TYPE_LABEL,
RESOURCE_TYPE_LABEL,
calcHeatmapIntensity,
formatCalendarDayCount,
formatDownloadCount,
formatDuration,
formatLessonPlanDate,
formatLessonPlanStatus,
formatMonthLabel,
formatOutlineNodeType,
formatRating,
formatResourceType,
intensityToColorClass,
isLessonPlanArchived,
isLessonPlanEditable,
isLessonPlanPublished,
isValidLessonPlanStatus,
lessonPlanStatusToBadgeClass,
ratingToColorClass,
toLessonPlanListItem,
} from "../transformations";
describe("formatLessonPlanStatus", () => {
it("maps known statuses to Chinese labels", () => {
expect(formatLessonPlanStatus("DRAFT")).toBe("草稿");
expect(formatLessonPlanStatus("PUBLISHED")).toBe("已发布");
expect(formatLessonPlanStatus("ARCHIVED")).toBe("已归档");
});
it("returns original value for unknown status", () => {
expect(formatLessonPlanStatus("UNKNOWN")).toBe("UNKNOWN");
expect(formatLessonPlanStatus("")).toBe("");
});
it("LESSON_PLAN_STATUS_LABEL covers all standard statuses", () => {
expect(Object.keys(LESSON_PLAN_STATUS_LABEL)).toHaveLength(3);
});
});
describe("formatOutlineNodeType", () => {
it("maps known types to Chinese labels", () => {
expect(formatOutlineNodeType("section")).toBe("章节");
expect(formatOutlineNodeType("topic")).toBe("主题");
expect(formatOutlineNodeType("activity")).toBe("活动");
expect(formatOutlineNodeType("assessment")).toBe("评估");
});
it("returns original value for unknown type", () => {
expect(formatOutlineNodeType("unknown")).toBe("unknown");
});
it("OUTLINE_NODE_TYPE_LABEL covers all standard types", () => {
expect(Object.keys(OUTLINE_NODE_TYPE_LABEL)).toHaveLength(4);
});
});
describe("formatResourceType", () => {
it("maps known types to Chinese labels", () => {
expect(formatResourceType("link")).toBe("链接");
expect(formatResourceType("file")).toBe("文件");
expect(formatResourceType("video")).toBe("视频");
expect(formatResourceType("image")).toBe("图片");
});
it("returns original value for unknown type", () => {
expect(formatResourceType("audio")).toBe("audio");
});
it("RESOURCE_TYPE_LABEL covers all standard types", () => {
expect(Object.keys(RESOURCE_TYPE_LABEL)).toHaveLength(4);
});
});
describe("formatLessonPlanDate", () => {
it("formats valid ISO date string", () => {
const result = formatLessonPlanDate("2026-07-25T23:59:59Z");
expect(result).toContain("2026");
expect(result).toContain("07");
});
it("returns placeholder for null/undefined/empty", () => {
expect(formatLessonPlanDate(null)).toBe("--");
expect(formatLessonPlanDate(undefined)).toBe("--");
expect(formatLessonPlanDate("")).toBe("--");
});
it("returns placeholder for invalid date", () => {
expect(formatLessonPlanDate("not-a-date")).toBe("--");
});
});
describe("formatDuration", () => {
it("formats minutes under 60", () => {
expect(formatDuration(30)).toBe("30 分钟");
expect(formatDuration(1)).toBe("1 分钟");
expect(formatDuration(45)).toBe("45 分钟");
});
it("formats exact hours", () => {
expect(formatDuration(60)).toBe("1 小时");
expect(formatDuration(120)).toBe("2 小时");
});
it("formats hours with remainder minutes", () => {
expect(formatDuration(90)).toBe("1 小时 30 分钟");
expect(formatDuration(75)).toBe("1 小时 15 分钟");
});
it("returns placeholder for invalid or zero input", () => {
expect(formatDuration(0)).toBe("--");
expect(formatDuration(-1)).toBe("--");
expect(formatDuration(Number.NaN)).toBe("--");
});
});
describe("isLessonPlanEditable / isLessonPlanPublished / isLessonPlanArchived", () => {
it("DRAFT is editable but not published/archived", () => {
expect(isLessonPlanEditable("DRAFT")).toBe(true);
expect(isLessonPlanPublished("DRAFT")).toBe(false);
expect(isLessonPlanArchived("DRAFT")).toBe(false);
});
it("PUBLISHED is published but not editable/archived", () => {
expect(isLessonPlanEditable("PUBLISHED")).toBe(false);
expect(isLessonPlanPublished("PUBLISHED")).toBe(true);
expect(isLessonPlanArchived("PUBLISHED")).toBe(false);
});
it("ARCHIVED is archived but not editable/published", () => {
expect(isLessonPlanEditable("ARCHIVED")).toBe(false);
expect(isLessonPlanPublished("ARCHIVED")).toBe(false);
expect(isLessonPlanArchived("ARCHIVED")).toBe(true);
});
it("unknown status is neither editable nor published nor archived", () => {
expect(isLessonPlanEditable("UNKNOWN")).toBe(false);
expect(isLessonPlanPublished("UNKNOWN")).toBe(false);
expect(isLessonPlanArchived("UNKNOWN")).toBe(false);
});
});
describe("toLessonPlanListItem", () => {
it("extracts list fields from full detail", () => {
const detail: LessonPlanDetail = {
id: "lp-001",
title: "集合的概念",
gradeId: "grade-12",
subjectId: "sub-math",
objectives: "理解集合的定义",
content: "<p>集合是数学中最基本的概念...</p>",
attachments: [],
duration: 45,
status: "DRAFT",
outline: [
{
id: "node-1",
title: "导入",
type: "section",
order: 1,
},
],
resources: [
{
id: "res-1",
name: "教材P1-10",
type: "file",
url: "https://example.com/textbook.pdf",
},
],
createdAt: "2026-07-20T00:00:00Z",
updatedAt: "2026-07-20T00:00:00Z",
};
const item = toLessonPlanListItem(detail);
expect(item.id).toBe("lp-001");
expect(item.title).toBe("集合的概念");
expect(item.status).toBe("DRAFT");
expect(item.duration).toBe(45);
expect(item).not.toHaveProperty("content");
expect(item).not.toHaveProperty("outline");
expect(item).not.toHaveProperty("resources");
expect(item).not.toHaveProperty("attachments");
});
});
describe("lessonPlanStatusToBadgeClass", () => {
it("returns correct badge class for each status", () => {
expect(lessonPlanStatusToBadgeClass("DRAFT")).toBe(
"bg-muted text-muted-foreground",
);
expect(lessonPlanStatusToBadgeClass("PUBLISHED")).toContain("primary");
expect(lessonPlanStatusToBadgeClass("ARCHIVED")).toBe(
"bg-muted text-muted-foreground",
);
});
it("returns muted for unknown status", () => {
expect(lessonPlanStatusToBadgeClass("UNKNOWN")).toBe(
"bg-muted text-muted-foreground",
);
});
});
describe("formatRating", () => {
it("formats valid ratings with 1 decimal place", () => {
expect(formatRating(4.5)).toBe("4.5");
expect(formatRating(5)).toBe("5.0");
expect(formatRating(0)).toBe("0.0");
});
it("returns placeholder for out-of-range or non-finite input", () => {
expect(formatRating(-0.1)).toBe("--");
expect(formatRating(5.1)).toBe("--");
expect(formatRating(Number.NaN)).toBe("--");
});
});
describe("ratingToColorClass", () => {
it("returns emerald for high rating", () => {
expect(ratingToColorClass(4.0)).toBe("text-emerald-600");
expect(ratingToColorClass(5.0)).toBe("text-emerald-600");
});
it("returns amber for medium rating", () => {
expect(ratingToColorClass(3.0)).toBe("text-amber-600");
expect(ratingToColorClass(3.99)).toBe("text-amber-600");
});
it("returns destructive for low rating", () => {
expect(ratingToColorClass(2.99)).toBe("text-destructive");
expect(ratingToColorClass(0)).toBe("text-destructive");
});
it("returns muted for non-finite input", () => {
expect(ratingToColorClass(Number.NaN)).toBe("text-muted-foreground");
});
});
describe("formatDownloadCount", () => {
it("formats counts under 1000 as plain numbers", () => {
expect(formatDownloadCount(0)).toBe("0");
expect(formatDownloadCount(999)).toBe("999");
});
it("formats counts >= 1000 with k suffix", () => {
expect(formatDownloadCount(1000)).toBe("1.0k");
expect(formatDownloadCount(1234)).toBe("1.2k");
expect(formatDownloadCount(9999)).toBe("10.0k");
});
it("returns placeholder for invalid input", () => {
expect(formatDownloadCount(-1)).toBe("--");
expect(formatDownloadCount(Number.NaN)).toBe("--");
});
});
describe("intensityToColorClass", () => {
it("returns correct color class for each intensity level", () => {
expect(intensityToColorClass(0)).toBe("bg-muted");
expect(intensityToColorClass(1)).toContain("zinc-200");
expect(intensityToColorClass(2)).toContain("zinc-300");
expect(intensityToColorClass(3)).toContain("zinc-400");
expect(intensityToColorClass(4)).toContain("zinc-500");
});
it("returns muted for unknown intensity", () => {
expect(intensityToColorClass(5)).toBe("bg-muted");
expect(intensityToColorClass(-1)).toBe("bg-muted");
});
});
describe("calcHeatmapIntensity", () => {
it("returns 0 for zero count", () => {
expect(calcHeatmapIntensity(0, 10)).toBe(0);
});
it("returns 1 for low ratio (< 0.25)", () => {
expect(calcHeatmapIntensity(1, 10)).toBe(1);
expect(calcHeatmapIntensity(2, 10)).toBe(1);
});
it("returns 2 for medium ratio (>= 0.25)", () => {
expect(calcHeatmapIntensity(3, 10)).toBe(2);
expect(calcHeatmapIntensity(4, 10)).toBe(2);
});
it("returns 3 for high ratio (>= 0.5)", () => {
expect(calcHeatmapIntensity(5, 10)).toBe(3);
expect(calcHeatmapIntensity(7, 10)).toBe(3);
});
it("returns 4 for very high ratio (>= 0.75)", () => {
expect(calcHeatmapIntensity(8, 10)).toBe(4);
expect(calcHeatmapIntensity(10, 10)).toBe(4);
});
it("returns 0 for zero or invalid maxCount", () => {
expect(calcHeatmapIntensity(5, 0)).toBe(0);
expect(calcHeatmapIntensity(5, -1)).toBe(0);
expect(calcHeatmapIntensity(5, Number.NaN)).toBe(0);
});
it("returns 0 for negative count", () => {
expect(calcHeatmapIntensity(-5, 10)).toBe(0);
});
});
describe("formatCalendarDayCount", () => {
it("returns string for positive count", () => {
expect(formatCalendarDayCount(1)).toBe("1");
expect(formatCalendarDayCount(5)).toBe("5");
});
it("returns empty string for zero or invalid", () => {
expect(formatCalendarDayCount(0)).toBe("");
expect(formatCalendarDayCount(-1)).toBe("");
expect(formatCalendarDayCount(Number.NaN)).toBe("");
});
});
describe("formatMonthLabel", () => {
it("formats YYYY-MM to Chinese label", () => {
expect(formatMonthLabel("2026-07")).toBe("2026年7月");
expect(formatMonthLabel("2026-01")).toBe("2026年1月");
expect(formatMonthLabel("2026-12")).toBe("2026年12月");
});
it("returns placeholder for empty input", () => {
expect(formatMonthLabel("")).toBe("--");
});
it("returns original for invalid format", () => {
expect(formatMonthLabel("invalid")).toBe("invalid");
expect(formatMonthLabel("2026")).toBe("2026");
});
});
describe("isValidLessonPlanStatus", () => {
it("returns true for valid statuses", () => {
expect(isValidLessonPlanStatus("DRAFT")).toBe(true);
expect(isValidLessonPlanStatus("PUBLISHED")).toBe(true);
expect(isValidLessonPlanStatus("ARCHIVED")).toBe(true);
});
it("returns false for invalid statuses", () => {
expect(isValidLessonPlanStatus("UNKNOWN")).toBe(false);
expect(isValidLessonPlanStatus("")).toBe(false);
});
});

View File

@@ -0,0 +1,210 @@
"use client";
/**
* 教案日历视图 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* 数据契约:
* - 查询 lessonPlanCalendar(month):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#lesson-plan-calendar
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ empty空日历
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { CalendarDays } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTransition } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlanCalendar } from "@/lib/api";
import {
DetailPageShell,
DetailPageSkeleton,
} from "@/shared/components/page-templates";
import { Button } from "@/shared/components/ui/button";
import {
formatCalendarDayCount,
formatMonthLabel,
lessonPlanStatusToBadgeClass,
} from "@/features/teacher/lesson-plans/transformations";
/**
* 日历客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function LessonPlanCalendarClient(): React.ReactElement {
const t = useTranslations("lessonPlans");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
// 默认当前月YYYY-MM
const now = new Date();
const defaultMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
const month = searchParams.get("month") ?? defaultMonth;
// @contract-pendingMSW 兜底
const { data, loading, error } = useLessonPlanCalendar(month);
const goToMonth = (delta: number): void => {
const [year, monthNum] = month.split("-").map(Number);
if (!year || !monthNum) return;
const d = new Date(year, monthNum - 1 + delta, 1);
const newMonth = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
startTransition(() => {
router.push(`/shell/teacher/lesson-plans/calendar?month=${newMonth}`);
});
};
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{t("calendar.mswNotice")}
</p>
</div>
) : undefined;
return (
<DetailPageShell
title={t("calendar.title")}
description={formatMonthLabel(month)}
icon={<CalendarDays className="size-6" />}
actions={
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => goToMonth(-1)}
type="button"
>
{t("calendar.prevMonth")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => goToMonth(1)}
type="button"
>
{t("calendar.nextMonth")}
</Button>
</div>
}
backHref="/shell/teacher/lesson-plans"
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
>
{data ? <CalendarGrid days={data.days} /> : null}
</DetailPageShell>
);
}
/**
* 日历网格7 列 × N 行)。
*/
function CalendarGrid({
days,
}: {
days: { date: string; lessonPlans: unknown[] }[];
}): React.ReactElement {
const t = useTranslations("lessonPlans");
const weekdays = [
t("calendar.weekSun"),
t("calendar.weekMon"),
t("calendar.weekTue"),
t("calendar.weekWed"),
t("calendar.weekThu"),
t("calendar.weekFri"),
t("calendar.weekSat"),
];
// 按日期索引映射
const dayMap = new Map<string, { date: string; lessonPlans: unknown[] }>();
for (const d of days) {
dayMap.set(d.date, d);
}
// 计算月首日是星期几,生成空白填充
const firstDate = days.length > 0 ? new Date(days[0]!.date) : new Date();
const firstWeekday = firstDate.getDay();
return (
<div className="overflow-x-auto rounded-xl border">
<div className="grid grid-cols-7 border-b bg-muted/30">
{weekdays.map((day) => (
<div
key={day}
className="p-2 text-center text-xs font-medium text-muted-foreground"
>
{day}
</div>
))}
</div>
<div className="grid grid-cols-7">
{/* 月首空白填充 */}
{Array.from({ length: firstWeekday }, (_, i) => (
<div
key={`blank-${i}`}
className="min-h-24 border-b border-r bg-muted/20 p-2"
/>
))}
{days.map((day) => (
<CalendarDayCell key={day.date} day={day} />
))}
</div>
</div>
);
}
/**
* 日历单日单元格。
*/
function CalendarDayCell({
day,
}: {
day: { date: string; lessonPlans: unknown[] };
}): React.ReactElement {
const t = useTranslations("lessonPlans");
const date = new Date(day.date);
const dayNum = date.getDate();
const count = day.lessonPlans.length;
return (
<div className="min-h-24 border-b border-r p-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium">{dayNum}</span>
{count > 0 ? (
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-primary/10 px-1 text-xs font-medium text-primary">
{formatCalendarDayCount(count)}
</span>
) : null}
</div>
<div className="mt-1 space-y-1">
{day.lessonPlans.slice(0, 3).map((lp, idx) => {
const plan = lp as {
id: string;
title: string;
status: string;
};
return (
<div
key={plan.id ?? idx}
className={`truncate rounded px-1 py-0.5 text-xs ${lessonPlanStatusToBadgeClass(plan.status)}`}
title={plan.title}
>
{plan.title}
</div>
);
})}
{count > 3 ? (
<p className="text-xs text-muted-foreground">
{t("calendar.moreCount", { count: count - 3 })}
</p>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,355 @@
"use client";
/**
* 教案编辑工作台页 - 客户端组件ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2
*
* 数据契约(@contract-pending 全 MSW
* - lessonPlan(id) ❌ → MSW 兜底
* - updateLessonPlan(input) mutation ❌ → MSW 兜底
*
* 布局WorkbenchPageShell 三栏):
* - left大纲树LessonPlanOutlineTree
* - center富文本编辑器contentEditable + 工具栏)
* - right属性面板基本信息 + 资源列表 + 状态操作)
*
* 三态规范§11.3 DoD
* - loadingWorkbenchPageSkeleton
* - errorerrorNode 局部降级(合并 empty 降级,因 WorkbenchPageShell 无 emptyNode
* - successnotify.success
*
* 关联ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { BookOpen } from "lucide-react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { forwardRef, useEffect, useRef, useState } from "react";
import {
useLessonPlan,
useUpdateLessonPlan,
type LessonPlanOutlineNode,
} from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button";
import {
WorkbenchPageShell,
WorkbenchPageSkeleton,
WorkbenchPanel,
} from "@/shared/components/page-templates";
import {
formatDuration,
formatLessonPlanDate,
formatLessonPlanStatus,
formatResourceType,
lessonPlanStatusToBadgeClass,
} from "@/features/teacher/lesson-plans/transformations";
import { LessonPlanOutlineTree } from "@/features/teacher/lesson-plans/lesson-plan-outline-tree";
/**
* 教案编辑工作台客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function LessonPlanEditClient(): React.ReactElement {
const t = useTranslations("lessonPlans");
const tCommon = useTranslations("common");
const params = useParams<{ planId: string }>();
const planId = params?.planId ?? "";
// @contract-pending MSW 兜底
const { data, loading, error } = useLessonPlan(planId);
const updateMutation = useUpdateLessonPlan();
const [html, setHtml] = useState<string | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | undefined>();
const editorRef = useRef<HTMLDivElement>(null);
// 首次拿到数据时初始化 HTML 内容
useEffect(() => {
if (data && html === null) {
setHtml(data.content || "");
}
}, [data, html]);
// WorkbenchPageShell 的 errorNode 同时承担 error + empty 两种降级场景
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
</div>
) : !loading && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("edit.notFound")}
</div>
) : undefined;
const handleSave = async (): Promise<void> => {
if (!data) return;
const currentHtml = editorRef.current?.innerHTML ?? html ?? "";
try {
await updateMutation.run({
id: planId,
content: currentHtml,
});
notify.success(t("edit.saveSuccess"));
} catch (err) {
notify.error(t("edit.saveFailed", { message: String(err) }));
}
};
const handleOutlineSelect = (node: LessonPlanOutlineNode): void => {
setSelectedNodeId(node.id);
};
const exec = (command: string, value?: string): void => {
if (!editorRef.current) return;
editorRef.current.focus();
document.execCommand(command, false, value);
setHtml(editorRef.current.innerHTML);
};
return (
<WorkbenchPageShell
title={data?.title ?? t("edit.title")}
description={
data
? t("edit.subtitle", {
duration: formatDuration(data.duration),
status: formatLessonPlanStatus(data.status),
})
: undefined
}
icon={<BookOpen className="size-6" />}
actions={
<Button
type="button"
disabled={updateMutation.loading || !data}
onClick={handleSave}
>
{updateMutation.loading ? tCommon("status.loading") : t("edit.save")}
</Button>
}
loading={loading}
loadingNode={<WorkbenchPageSkeleton />}
errorNode={errorNode}
left={
data ? (
<WorkbenchPanel title={t("edit.outlineTitle")}>
<LessonPlanOutlineTree
nodes={data.outline}
selectedId={selectedNodeId}
onSelect={handleOutlineSelect}
/>
</WorkbenchPanel>
) : null
}
center={
data && html !== null ? (
<RichEditor
ref={editorRef}
html={html}
onChange={setHtml}
onExec={exec}
/>
) : null
}
right={
data ? (
<WorkbenchPanel title={t("edit.propertiesTitle")}>
<div className="space-y-4 text-sm">
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propPlanId")}
</p>
<p className="mt-1 font-mono text-xs">{data.id}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propGrade")}
</p>
<p className="mt-1 font-semibold">{data.gradeId}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propSubject")}
</p>
<p className="mt-1 font-semibold">{data.subjectId}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propDuration")}
</p>
<p className="mt-1 font-semibold">
{formatDuration(data.duration)}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propStatus")}
</p>
<span
className={`mt-1 inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${lessonPlanStatusToBadgeClass(data.status)}`}
>
{formatLessonPlanStatus(data.status)}
</span>
</div>
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propUpdatedAt")}
</p>
<p className="mt-1 text-xs">
{formatLessonPlanDate(data.updatedAt)}
</p>
</div>
{/* 资源列表 */}
{data.resources.length > 0 ? (
<div>
<p className="text-xs text-muted-foreground">
{t("edit.resourcesTitle")}
</p>
<ul className="mt-1 space-y-1">
{data.resources.map((res) => (
<li key={res.id} className="text-xs">
<a
href={res.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{res.name}
</a>
<span className="ml-1 text-muted-foreground">
({formatResourceType(res.type)})
</span>
</li>
))}
</ul>
</div>
) : null}
<p className="text-xs text-muted-foreground">
{t("edit.contractPending")}
</p>
</div>
</WorkbenchPanel>
) : null
}
/>
);
}
/**
* 富文本编辑器contentEditable + 工具栏)。
*
* 简化实现:使用 document.execCommanddeprecated 但仍可用)。
* 后续可升级到 tiptap/lexical@contract-pending 富文本库契约)。
*/
interface RichEditorProps {
html: string;
onChange: (html: string) => void;
onExec: (command: string, value?: string) => void;
}
const RichEditor = forwardRef<HTMLDivElement, RichEditorProps>(
function RichEditor({ html, onChange, onExec }, ref) {
const t = useTranslations("lessonPlans");
return (
<WorkbenchPanel title={t("edit.editorTitle")}>
<div className="flex h-full flex-col gap-3">
<div className="flex flex-wrap gap-1 border-b pb-3">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("bold")}
aria-label={t("edit.toolbarBold")}
>
<strong>B</strong>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("italic")}
aria-label={t("edit.toolbarItalic")}
>
<em>I</em>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("underline")}
aria-label={t("edit.toolbarUnderline")}
>
<u>U</u>
</Button>
<span className="mx-1 border-l" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("formatBlock", "<h1>")}
>
H1
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("formatBlock", "<h2>")}
>
H2
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("formatBlock", "<h3>")}
>
H3
</Button>
<span className="mx-1 border-l" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("insertUnorderedList")}
aria-label={t("edit.toolbarBulletList")}
>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("insertOrderedList")}
aria-label={t("edit.toolbarOrderedList")}
>
1.
</Button>
<span className="mx-1 border-l" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("formatBlock", "<p>")}
>
P
</Button>
</div>
<div
ref={ref}
contentEditable
suppressContentEditableWarning
className="flex-1 overflow-y-auto rounded-md border bg-background p-4 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
dangerouslySetInnerHTML={{ __html: html }}
onInput={(e) => onChange((e.target as HTMLDivElement).innerHTML)}
style={{ minHeight: "400px" }}
/>
</div>
</WorkbenchPanel>
);
},
);

View File

@@ -0,0 +1,235 @@
"use client";
/**
* 教案热力图视图 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* 数据契约:
* - 查询 lessonPlanHeatmap(startDate, endDate):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#lesson-plan-heatmap
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ empty空热力图
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { Activity } from "lucide-react";
import { useSearchParams, useRouter } from "next/navigation";
import { useTransition } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlanHeatmap } from "@/lib/api";
import {
DetailPageShell,
DetailPageSkeleton,
} from "@/shared/components/page-templates";
import { Button } from "@/shared/components/ui/button";
import { intensityToColorClass } from "@/features/teacher/lesson-plans/transformations";
/** 热力图默认范围:过去 12 周 */
function getDefaultRange(): { startDate: string; endDate: string } {
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - 7 * 12);
const fmt = (d: Date): string =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
return { startDate: fmt(start), endDate: fmt(end) };
}
/**
* 热力图客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function LessonPlanHeatmapClient(): React.ReactElement {
const t = useTranslations("lessonPlans");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const defaultRange = getDefaultRange();
const startDate = searchParams.get("startDate") ?? defaultRange.startDate;
const endDate = searchParams.get("endDate") ?? defaultRange.endDate;
// @contract-pendingMSW 兜底
const { data, loading, error } = useLessonPlanHeatmap({
startDate,
endDate,
});
const shiftRange = (weeks: number): void => {
const start = new Date(startDate);
const end = new Date(endDate);
start.setDate(start.getDate() + weeks * 7);
end.setDate(end.getDate() + weeks * 7);
const fmt = (d: Date): string =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
startTransition(() => {
router.push(
`/shell/teacher/lesson-plans/heatmap?startDate=${fmt(start)}&endDate=${fmt(end)}`,
);
});
};
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{t("heatmap.mswNotice")}
</p>
</div>
) : undefined;
return (
<DetailPageShell
title={t("heatmap.title")}
description={t("heatmap.range", { startDate, endDate })}
icon={<Activity className="size-6" />}
actions={
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => shiftRange(-4)}
type="button"
>
{t("heatmap.prevRange")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => shiftRange(4)}
type="button"
>
{t("heatmap.nextRange")}
</Button>
</div>
}
backHref="/shell/teacher/lesson-plans"
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
>
{data ? (
<HeatmapGrid cells={data.cells} maxCount={data.maxCount} />
) : null}
</DetailPageShell>
);
}
/**
* 热力图网格(按周列 × 星期行布局)。
*/
function HeatmapGrid({
cells,
maxCount,
}: {
cells: { date: string; count: number; intensity: number }[];
maxCount: number;
}): React.ReactElement {
const t = useTranslations("lessonPlans");
// 按日期索引
const cellMap = new Map<
string,
{ date: string; count: number; intensity: number }
>();
for (const c of cells) {
cellMap.set(c.date, c);
}
// 计算周列
if (cells.length === 0) {
return (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("heatmap.empty")}
</div>
);
}
const firstDate = new Date(cells[0]!.date);
const lastDate = new Date(cells[cells.length - 1]!.date);
// 按周分组
const weeks: { date: string; count: number; intensity: number }[][] = [];
let currentWeek: { date: string; count: number; intensity: number }[] = [];
const cursor = new Date(firstDate);
// 调整到周日开始
cursor.setDate(cursor.getDate() - cursor.getDay());
while (cursor <= lastDate) {
const dateStr = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, "0")}-${String(cursor.getDate()).padStart(2, "0")}`;
const cell = cellMap.get(dateStr);
if (cell) {
currentWeek.push(cell);
} else {
currentWeek.push({ date: dateStr, count: 0, intensity: 0 });
}
if (currentWeek.length === 7) {
weeks.push(currentWeek);
currentWeek = [];
}
cursor.setDate(cursor.getDate() + 1);
}
if (currentWeek.length > 0) {
weeks.push(currentWeek);
}
const weekdays = [
t("heatmap.weekSun"),
t("heatmap.weekMon"),
t("heatmap.weekTue"),
t("heatmap.weekWed"),
t("heatmap.weekThu"),
t("heatmap.weekFri"),
t("heatmap.weekSat"),
];
return (
<div className="space-y-4">
<div className="overflow-x-auto rounded-xl border p-4">
<div className="flex gap-1">
{/* 星期标签列 */}
<div className="flex flex-col gap-1 pr-2">
{weekdays.map((day, i) => (
<div
key={day}
className={`flex h-4 items-center justify-end text-xs text-muted-foreground ${
i % 2 === 0 ? "opacity-0" : ""
}`}
>
{day}
</div>
))}
</div>
{/* 热力图单元格 */}
{weeks.map((week, weekIdx) => (
<div key={weekIdx} className="flex flex-col gap-1">
{week.map((cell) => (
<div
key={cell.date}
className={`size-4 rounded-sm ${intensityToColorClass(cell.intensity)}`}
title={`${cell.date}: ${cell.count} ${t("heatmap.unit")}`}
/>
))}
</div>
))}
</div>
</div>
{/* 图例 */}
<div className="flex items-center justify-end gap-2 text-xs text-muted-foreground">
<span>{t("heatmap.less")}</span>
{[0, 1, 2, 3, 4].map((level) => (
<div
key={level}
className={`size-4 rounded-sm ${intensityToColorClass(level)}`}
/>
))}
<span>{t("heatmap.more")}</span>
<span className="ml-4">
{t("heatmap.maxCount", { count: maxCount })}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,180 @@
"use client";
/**
* 教案库列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* 数据契约:
* - 查询 lessonPlanLibrary(subjectId, grade):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#lesson-plan-library
*
* URL 状态:?subjectId=xxx &grade=xxx &q=xxx
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { Library } from "lucide-react";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlanLibrary, type LessonPlanLibraryItem } from "@/lib/api";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
formatDownloadCount,
formatLessonPlanDate,
formatRating,
ratingToColorClass,
} from "@/features/teacher/lesson-plans/transformations";
/**
* 教案库客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function LessonPlanLibraryClient(): React.ReactElement {
const t = useTranslations("lessonPlans");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const subjectId = searchParams.get("subjectId") ?? "";
const grade = searchParams.get("grade") ?? "";
const q = searchParams.get("q") ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useLessonPlanLibrary({
subjectId: subjectId || undefined,
grade: grade || undefined,
});
const filteredItems = useMemo<LessonPlanLibraryItem[]>(() => {
const items = data?.items ?? [];
return items.filter((item) => {
if (q && !item.title.toLowerCase().includes(q.toLowerCase())) {
return false;
}
return true;
});
}, [data, q]);
const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
startTransition(() => {
router.push(`/shell/teacher/lesson-plans/library?${params.toString()}`);
});
};
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{t("library.mswNotice")}
</p>
</div>
) : undefined;
return (
<ListPageShell
title={t("library.title")}
description={t("library.description")}
icon={<Library className="size-6" />}
filters={
<>
<FilterSearchInput
placeholder={t("library.searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
/>
<input
type="text"
value={subjectId}
onChange={(e) => updateQuery("subjectId", e.target.value)}
placeholder={t("library.subjectPlaceholder")}
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("library.subjectPlaceholder")}
/>
<input
type="text"
value={grade}
onChange={(e) => updateQuery("grade", e.target.value)}
placeholder={t("library.gradePlaceholder")}
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("library.gradePlaceholder")}
/>
</>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={filteredItems.length === 0 && !loading}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("library.total", { count: filteredItems.length })}</span>
</div>
}
>
<LibraryGrid items={filteredItems} />
</ListPageShell>
);
}
/**
* 教案库卡片网格(纯展示组件)。
*/
function LibraryGrid({
items,
}: {
items: LessonPlanLibraryItem[];
}): React.ReactElement {
const t = useTranslations("lessonPlans");
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item) => (
<div
key={item.id}
className="flex flex-col gap-3 rounded-xl border bg-card p-4"
>
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold">{item.title}</h3>
<span
className={`text-sm font-bold ${ratingToColorClass(item.rating)}`}
>
{formatRating(item.rating)}
</span>
</div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="rounded bg-muted px-2 py-0.5">
{item.subjectName}
</span>
<span className="rounded bg-muted px-2 py-0.5">{item.grade}</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{t("library.author", { name: item.authorName })}</span>
<span>{formatLessonPlanDate(item.updatedAt)}</span>
</div>
<div className="flex items-center justify-between border-t pt-2 text-xs">
<span className="text-muted-foreground">
{t("library.downloads", {
count: formatDownloadCount(item.downloadCount),
})}
</span>
<span className="text-muted-foreground">
{t("library.duration", { minutes: item.duration })}
</span>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,237 @@
"use client";
/**
* 教案管理列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* 数据契约:
* - 列表查询 lessonPlans(gradeId, subjectId, status):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#lesson-plans-list
*
* URL 状态:?gradeId=xxx &subjectId=xxx &status=xxx &q=xxx
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { BookOpen } from "lucide-react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlansList, type LessonPlanListItem } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
formatDuration,
formatLessonPlanDate,
formatLessonPlanStatus,
lessonPlanStatusToBadgeClass,
} from "@/features/teacher/lesson-plans/transformations";
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。
*/
export function LessonPlanListClient(): React.ReactElement {
const t = useTranslations("lessonPlans");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const gradeId = searchParams.get("gradeId") ?? "";
const subjectId = searchParams.get("subjectId") ?? "";
const statusFilter = searchParams.get("status") ?? "";
const q = searchParams.get("q") ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useLessonPlansList({
gradeId: gradeId || undefined,
subjectId: subjectId || undefined,
status: statusFilter || undefined,
});
// 客户端二次筛选q—— 后端补齐列表查询后改服务端筛选
const filteredItems = useMemo<LessonPlanListItem[]>(() => {
const items = data?.items ?? [];
return items.filter((item) => {
if (q && !item.title.toLowerCase().includes(q.toLowerCase())) {
return false;
}
return true;
});
}, [data, q]);
const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
startTransition(() => {
router.push(`/shell/teacher/lesson-plans?${params.toString()}`);
});
};
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{t("list.mswNotice")}
</p>
</div>
) : undefined;
return (
<ListPageShell
title={t("list.title")}
description={t("list.description")}
icon={<BookOpen className="size-6" />}
actions={
<Button asChild>
<Link href="/shell/teacher/lesson-plans/new">{t("list.new")}</Link>
</Button>
}
filters={
<>
<FilterSearchInput
placeholder={t("list.searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
/>
<input
type="text"
value={gradeId}
onChange={(e) => updateQuery("gradeId", e.target.value)}
placeholder={t("list.gradePlaceholder")}
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.gradePlaceholder")}
/>
<input
type="text"
value={subjectId}
onChange={(e) => updateQuery("subjectId", e.target.value)}
placeholder={t("list.subjectPlaceholder")}
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.subjectPlaceholder")}
/>
<select
value={statusFilter}
onChange={(e) => updateQuery("status", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.statusFilter")}
>
<option value="">{t("list.statusAll")}</option>
<option value="DRAFT">{t("list.statusDraft")}</option>
<option value="PUBLISHED">{t("list.statusPublished")}</option>
<option value="ARCHIVED">{t("list.statusArchived")}</option>
</select>
</>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={filteredItems.length === 0 && !loading}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("list.total", { count: filteredItems.length })}</span>
</div>
}
>
<LessonPlanTable items={filteredItems} />
</ListPageShell>
);
}
/**
* 教案列表表格(纯展示组件,对齐 §8.2 排版规范)。
*/
function LessonPlanTable({
items,
}: {
items: LessonPlanListItem[];
}): React.ReactElement {
const t = useTranslations("lessonPlans");
return (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">{t("list.colTitle")}</th>
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
<th className="p-3 text-left font-medium">
{t("list.colDuration")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colUpdatedAt")}
</th>
<th className="p-3 text-right font-medium">
{t("list.colActions")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((plan) => (
<tr key={plan.id} className="hover:bg-muted/30">
<td className="p-3">
<Link
href={`/shell/teacher/lesson-plans/${plan.id}/edit`}
className="font-medium hover:underline"
>
{plan.title}
</Link>
{plan.objectives ? (
<p className="mt-1 text-xs text-muted-foreground">
{plan.objectives}
</p>
) : null}
</td>
<td className="p-3">
<LessonPlanStatusBadge status={plan.status} />
</td>
<td className="p-3 text-xs">{formatDuration(plan.duration)}</td>
<td className="p-3 font-mono text-xs">
{formatLessonPlanDate(plan.updatedAt)}
</td>
<td className="p-3 text-right">
<Link
href={`/shell/teacher/lesson-plans/${plan.id}/edit`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("list.edit")}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* 教案状态徽章(按状态色阶展示)。
*/
function LessonPlanStatusBadge({
status,
}: {
status: string;
}): React.ReactElement {
const label = formatLessonPlanStatus(status);
const cls = lessonPlanStatusToBadgeClass(status);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,151 @@
"use client";
/**
* 教案大纲树组件 - 客户端组件ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2
*
* 用于 lesson-plan-edit-client.tsx 左栏。展示教案大纲的树形结构,
* 支持节点选中(高亮当前选中项)。
*
* 数据契约:大纲数据来自 LessonPlanDetail.outline@contract-pending 全 MSW
*
* 关联ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { ChevronRight, FileText, ListChecks, Target, Zap } from "lucide-react";
import { useState } from "react";
import type { LessonPlanOutlineNode } from "@/lib/api";
import { cn } from "@/shared/lib/utils";
import { formatOutlineNodeType } from "@/features/teacher/lesson-plans/transformations";
/**
* 大纲树 props。
*/
export interface LessonPlanOutlineTreeProps {
/** 大纲根节点列表 */
nodes: LessonPlanOutlineNode[];
/** 当前选中节点 id */
selectedId?: string;
/** 节点选中回调 */
onSelect: (node: LessonPlanOutlineNode) => void;
}
/**
* 大纲树主体。
*/
export function LessonPlanOutlineTree({
nodes,
selectedId,
onSelect,
}: LessonPlanOutlineTreeProps): React.ReactElement {
return (
<div className="space-y-1">
{nodes.map((node) => (
<OutlineNodeItem
key={node.id}
node={node}
depth={0}
selectedId={selectedId}
onSelect={onSelect}
/>
))}
</div>
);
}
/**
* 大纲节点项(递归渲染子节点)。
*/
function OutlineNodeItem({
node,
depth,
selectedId,
onSelect,
}: {
node: LessonPlanOutlineNode;
depth: number;
selectedId?: string;
onSelect: (node: LessonPlanOutlineNode) => void;
}): React.ReactElement {
const [expanded, setExpanded] = useState(depth < 1);
const hasChildren = (node.children?.length ?? 0) > 0;
const isSelected = selectedId === node.id;
const icon = getNodeTypeIcon(node.type);
return (
<div>
<div
className={cn(
"flex cursor-pointer items-center gap-1 rounded-md px-2 py-1.5 text-sm hover:bg-muted/50",
isSelected && "bg-primary/10 text-primary",
)}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
onClick={() => onSelect(node)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(node);
}
}}
role="button"
tabIndex={0}
>
{hasChildren ? (
<button
type="button"
className="flex size-4 shrink-0 items-center justify-center"
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
aria-label={expanded ? "Collapse" : "Expand"}
>
<ChevronRight
className={cn(
"size-3 transition-transform",
expanded && "rotate-90",
)}
/>
</button>
) : (
<span className="size-4 shrink-0" />
)}
<span className="shrink-0">{icon}</span>
<span className="flex-1 truncate">{node.title}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{formatOutlineNodeType(node.type)}
</span>
</div>
{hasChildren && expanded ? (
<div>
{node.children!.map((child) => (
<OutlineNodeItem
key={child.id}
node={child}
depth={depth + 1}
selectedId={selectedId}
onSelect={onSelect}
/>
))}
</div>
) : null}
</div>
);
}
/**
* 按节点类型返回图标。
*/
function getNodeTypeIcon(type: string): React.ReactElement {
switch (type) {
case "section":
return <FileText className="size-4 text-muted-foreground" />;
case "topic":
return <Target className="size-4 text-muted-foreground" />;
case "activity":
return <Zap className="size-4 text-muted-foreground" />;
case "assessment":
return <ListChecks className="size-4 text-muted-foreground" />;
default:
return <FileText className="size-4 text-muted-foreground" />;
}
}

View File

@@ -0,0 +1,209 @@
"use client";
/**
* 新建教案表单页 - 客户端组件ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2
*
* 数据契约:
* - mutation createLessonPlan(input):❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#create-lesson-plan
*
* 三态规范§11.3 DoD
* - loadingFormPageSkeleton初始数据加载由 server page Suspense 兜底)
* - errorerrorSummary 表单级错误
* - successnotify.success + router.push 回列表
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { BookOpen } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTransition, useState } from "react";
import { useTranslations } from "next-intl";
import { useCreateLessonPlan, type CreateLessonPlanInput } from "@/lib/api";
import { FormPageShell } from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
/**
* 表单客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function NewLessonPlanClient(): React.ReactElement {
const t = useTranslations("lessonPlans");
const router = useRouter();
const [, startTransition] = useTransition();
// @contract-pendingMSW 兜底
const { run: createLessonPlan, loading: submitting } = useCreateLessonPlan();
const handleSubmit = async (input: CreateLessonPlanInput): Promise<void> => {
try {
const result = await createLessonPlan(input);
notify.success(t("new.success"));
startTransition(() => {
router.push(`/shell/teacher/lesson-plans/${result.id}/edit`);
});
} catch (err) {
notify.error(`${t("new.error")}: ${String(err)}`);
}
};
return (
<NewLessonPlanFormInner submitting={submitting} onSubmit={handleSubmit} />
);
}
/**
* 表单主体(受控表单 + 内联校验)。
*/
function NewLessonPlanFormInner({
submitting,
onSubmit,
}: {
submitting: boolean;
onSubmit: (input: CreateLessonPlanInput) => Promise<void>;
}): React.ReactElement {
const t = useTranslations("lessonPlans");
const tCommon = useTranslations("common");
const [title, setTitle] = useState("");
const [gradeId, setGradeId] = useState("grade-12");
const [subjectId, setSubjectId] = useState("sub-math");
const [objectives, setObjectives] = useState("");
const [content, setContent] = useState("");
const [duration, setDuration] = useState("45");
const [error, setError] = useState<string | null>(null);
const handleFormSubmit = (): void => {
setError(null);
if (!title.trim()) {
setError(t("new.errorTitleRequired"));
return;
}
if (!gradeId.trim()) {
setError(t("new.errorGradeRequired"));
return;
}
if (!subjectId.trim()) {
setError(t("new.errorSubjectRequired"));
return;
}
const input: CreateLessonPlanInput = {
title: title.trim(),
gradeId: gradeId.trim(),
subjectId: subjectId.trim(),
objectives: objectives.trim() || undefined,
content: content.trim() || undefined,
duration: Number(duration) || 45,
};
void onSubmit(input);
};
return (
<FormPageShell
title={t("new.title")}
description={t("new.description")}
icon={<BookOpen className="size-6" />}
backHref="/shell/teacher/lesson-plans"
onSubmit={handleFormSubmit}
submitting={submitting}
submitLabel={t("new.submit")}
cancelLabel={tCommon("button.cancel")}
errorSummary={
error ? <p className="text-sm text-destructive">{error}</p> : undefined
}
>
<FormField label={t("new.titleLabel")} required>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder={t("new.titlePlaceholder")}
required
/>
</FormField>
<FormField label={t("new.gradeId")} required>
<input
type="text"
value={gradeId}
onChange={(e) => setGradeId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="grade-12"
required
/>
</FormField>
<FormField label={t("new.subjectId")} required>
<input
type="text"
value={subjectId}
onChange={(e) => setSubjectId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="sub-math"
required
/>
</FormField>
<FormField label={t("new.objectivesLabel")}>
<textarea
value={objectives}
onChange={(e) => setObjectives(e.target.value)}
rows={2}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder={t("new.objectivesPlaceholder")}
/>
</FormField>
<FormField label={t("new.contentLabel")}>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={4}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder={t("new.contentPlaceholder")}
/>
</FormField>
<FormField label={t("new.duration")}>
<input
type="number"
min={1}
value={duration}
onChange={(e) => setDuration(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
<p className="text-xs text-muted-foreground">{t("new.durationHint")}</p>
</FormField>
<p className="text-xs text-muted-foreground">
{t("new.contractPending")}
</p>
</FormPageShell>
);
}
/**
* 表单字段容器label + children
*/
function FormField({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}): React.ReactElement {
return (
<div className="space-y-2">
<label className="text-sm font-medium">
{label}
{required ? <span className="ml-1 text-destructive">*</span> : null}
</label>
{children}
</div>
);
}

View File

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