- 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
125 lines
4.5 KiB
TypeScript
125 lines
4.5 KiB
TypeScript
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>
|
||
);
|
||
}
|