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:
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user