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,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>
);
}