feat(lesson-preparation): major update with AI features, schedules, and new components

- Add actions-schedules.ts and data-access-schedules.ts for schedule management

- Add AI differentiation, AI feedback, consistency check dialogs

- Add attachment-picker, curriculum-heatmap, print-view, version-diff-view

- Add lesson-plan-mobile-view and schedule-dialog components

- Add lib: ai-differentiation, ai-feedback, auto-layout, consistency-check,

  curriculum-coverage, export, version-diff

- Add history-slice hook for version history

- Update existing components, hooks, providers, services, types

- Add teacher lesson-plans heatmap and library pages
This commit is contained in:
SpecialX
2026-07-04 10:22:10 +08:00
parent 41fe8d8903
commit 25dca843be
45 changed files with 5295 additions and 210 deletions

View File

@@ -0,0 +1,20 @@
"use client";
import { useTranslations } from "next-intl";
import { BookOpen } from "lucide-react";
import { EmptyState } from "@/shared/components/ui/empty-state";
export default function HeatmapError() {
const t = useTranslations("lessonPreparation");
return (
<div className="p-8">
<EmptyState
icon={BookOpen}
title={t("heatmap.title")}
description={t("error.loadFailedDesc")}
action={{ label: t("error.retry"), onClick: () => window.location.reload() }}
className="border-none shadow-none"
/>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { Skeleton } from "@/shared/components/ui/skeleton";
export default function HeatmapLoading() {
return (
<div className="p-6 space-y-4">
<div className="space-y-2">
<Skeleton className="h-8 w-[200px]" />
<Skeleton className="h-4 w-[320px]" />
</div>
<div className="grid grid-cols-3 gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-[80px] w-full" />
))}
</div>
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-[200px] w-full" />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
import type { JSX } from "react";
import { BookOpen } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { requirePermission } from "@/shared/lib/auth-guard";
import { Permissions } from "@/shared/types/permissions";
import { getLessonPlans } from "@/modules/lesson-preparation/data-access";
import { getLessonPlanById } from "@/modules/lesson-preparation/data-access";
import { getKnowledgePointsByTextbookId, getTextbooks } from "@/modules/textbooks/data-access";
import { CurriculumHeatmap } from "@/modules/lesson-preparation/components/curriculum-heatmap";
import type { PlanKpLink } from "@/modules/lesson-preparation/lib/curriculum-coverage";
import type { LessonPlanDocument } from "@/modules/lesson-preparation/types";
import { isRecord } from "@/shared/lib/type-guards";
import { EmptyState } from "@/shared/components/ui/empty-state";
export const dynamic = "force-dynamic";
/**
* V5-20 T4教师端课标热力图页面。
*
* 展示教师所有课案对所选教材知识点的覆盖情况。
* 从教师所有课案中提取 knowledgePointIds统计覆盖率。
*/
export default async function HeatmapPage(): Promise<JSX.Element> {
const t = await getTranslations("lessonPreparation");
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
// 教师所有课案(含草稿和已发布)
const [plans, textbooks] = await Promise.all([
getLessonPlans({}, ctx.dataScope, ctx.userId),
getTextbooks(),
]);
// 仅保留有教材的课案
const plansWithTextbook = plans.filter((p) => p.textbookId);
// 默认展示教师第一本教材(按使用频率)
const textbookCounts = new Map<string, number>();
for (const p of plansWithTextbook) {
if (p.textbookId) {
textbookCounts.set(p.textbookId, (textbookCounts.get(p.textbookId) ?? 0) + 1);
}
}
const defaultTextbookId =
[...textbookCounts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? textbooks[0]?.id ?? "";
if (!defaultTextbookId || textbooks.length === 0) {
return (
<div className="p-6">
<EmptyState
icon={BookOpen}
title={t("heatmap.title")}
description={t("heatmap.selectTextbook")}
className="border-none shadow-none"
/>
</div>
);
}
// 获取该教材的所有知识点
const allKps = await getKnowledgePointsByTextbookId(defaultTextbookId);
// 提取该教材下所有课案的知识点关联
const planLinks: PlanKpLink[] = [];
const chapterNames: Record<string, string> = {};
for (const plan of plansWithTextbook.filter((p) => p.textbookId === defaultTextbookId)) {
try {
const fullPlan = await getLessonPlanById(plan.id, ctx.userId);
if (!fullPlan) continue;
const doc = fullPlan.content as LessonPlanDocument;
const kpSet = new Set<string>();
for (const node of doc.nodes) {
if (!isRecord(node.data)) continue;
const dataKps = (node.data as { knowledgePointIds?: unknown }).knowledgePointIds;
if (Array.isArray(dataKps)) {
for (const kp of dataKps) {
if (typeof kp === "string") kpSet.add(kp);
}
}
}
if (kpSet.size > 0) {
planLinks.push({ planId: plan.id, knowledgePointIds: [...kpSet] });
}
} catch {
// 单个课案加载失败不阻塞整体
}
}
// 章节名映射(从 textbooks 模块获取)
// 此处简化:使用知识点自身的 chapterId章节名从 textbooks data-access 获取
// 为避免 N+1 查询,此处仅用 chapterId 作为 key章节名由前端未知章节兜底
// 完整实现可在 textbooks data-access 添加批量查询函数
for (const kp of allKps) {
if (kp.chapterId) {
chapterNames[kp.chapterId] = kp.chapterId; // 占位,实际应查询章节名
}
}
return (
<div className="p-6 space-y-4">
<div>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<BookOpen className="h-6 w-6" aria-hidden="true" />
{t("heatmap.title")}
</h1>
<p className="text-muted-foreground">{t("heatmap.description")}</p>
</div>
{allKps.length === 0 ? (
<EmptyState
icon={BookOpen}
title={t("heatmap.noData")}
description={t("heatmap.description")}
className="border-none shadow-none"
/>
) : (
<CurriculumHeatmap
allKps={allKps}
planLinks={planLinks}
chapterNames={chapterNames}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
import { useTranslations } from "next-intl";
import { BookOpen } from "lucide-react";
import { EmptyState } from "@/shared/components/ui/empty-state";
export default function LibraryError() {
const t = useTranslations("lessonPreparation");
return (
<div className="p-8">
<EmptyState
icon={BookOpen}
title={t("library.title")}
description={t("error.loadFailedDesc")}
action={{ label: t("error.retry"), onClick: () => window.location.reload() }}
className="border-none shadow-none"
/>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { Skeleton } from "@/shared/components/ui/skeleton";
export default function LibraryLoading() {
return (
<div className="p-6 space-y-4">
<div className="space-y-2">
<Skeleton className="h-8 w-[200px]" />
<Skeleton className="h-4 w-[320px]" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-[160px] w-full" />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
import type { JSX } from "react";
import Link from "next/link";
import { BookOpen, Copy } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { requirePermission } from "@/shared/lib/auth-guard";
import { Permissions } from "@/shared/types/permissions";
import { getLessonPlans } from "@/modules/lesson-preparation/data-access";
import { duplicateLessonPlanFormAction } from "@/modules/lesson-preparation/actions";
import { formatDateTime } from "@/shared/lib/utils";
export const dynamic = "force-dynamic";
/**
* V5-12 R4校内课案库。
*
* 展示本校其他教师分享的已发布课案,支持一键复制为自己的课案。
* 复用现有 getLessonPlans 数据访问scope 已限定可见范围),
* 在页面层过滤掉当前用户自己创建的课案。
*/
export default async function LibraryPage(): Promise<JSX.Element> {
const t = await getTranslations("lessonPreparation");
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
const allItems = await getLessonPlans(
{ status: "published" },
ctx.dataScope,
ctx.userId,
);
// 过滤掉当前用户自己创建的课案,仅展示他人分享的
const libraryItems = allItems.filter((item) => item.creatorId !== ctx.userId);
return (
<div className="p-6 space-y-4">
<div>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<BookOpen className="h-6 w-6" aria-hidden="true" />
{t("library.title")}
</h1>
<p className="text-muted-foreground">{t("library.description")}</p>
</div>
{libraryItems.length === 0 ? (
<EmptyState
icon={BookOpen}
title={t("library.empty")}
description={t("library.description")}
className="border-none shadow-none"
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{libraryItems.map((item) => (
<article
key={item.id}
className="border border-outline-variant rounded-lg p-4 bg-surface flex flex-col gap-2"
>
<div className="flex-1">
<h3 className="font-medium text-base line-clamp-2">
<Link
href={`/teacher/lesson-plans/${item.id}/edit`}
className="hover:text-primary"
>
{item.title}
</Link>
</h3>
<div className="flex flex-wrap gap-1.5 mt-1 text-xs text-on-surface-variant">
{item.subjectName && (
<span className="px-1.5 py-0.5 rounded bg-surface-container-highest">
{item.subjectName}
</span>
)}
{item.gradeName && (
<span className="px-1.5 py-0.5 rounded bg-surface-container-highest">
{item.gradeName}
</span>
)}
{item.textbookTitle && (
<span className="px-1.5 py-0.5 rounded bg-surface-container-highest truncate max-w-[120px]">
{item.textbookTitle}
</span>
)}
</div>
<p className="text-xs text-on-surface-variant mt-2">
{t("library.byCreator", {
creator: item.creatorName ?? t("library.noCreator"),
})}
{" · "}
{formatDateTime(item.updatedAt)}
</p>
</div>
<form action={duplicateLessonPlanFormAction}>
<input type="hidden" name="planId" value={item.id} />
<Button type="submit" variant="outline" size="sm" className="w-full">
<Copy className="h-3.5 w-3.5 mr-1" aria-hidden="true" />
{t("library.fork")}
</Button>
</form>
</article>
))}
</div>
)}
</div>
);
}