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:
20
src/app/(dashboard)/teacher/lesson-plans/heatmap/error.tsx
Normal file
20
src/app/(dashboard)/teacher/lesson-plans/heatmap/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/app/(dashboard)/teacher/lesson-plans/heatmap/loading.tsx
Normal file
22
src/app/(dashboard)/teacher/lesson-plans/heatmap/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/app/(dashboard)/teacher/lesson-plans/heatmap/page.tsx
Normal file
124
src/app/(dashboard)/teacher/lesson-plans/heatmap/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
src/app/(dashboard)/teacher/lesson-plans/library/error.tsx
Normal file
20
src/app/(dashboard)/teacher/lesson-plans/library/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/app/(dashboard)/teacher/lesson-plans/library/loading.tsx
Normal file
17
src/app/(dashboard)/teacher/lesson-plans/library/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
src/app/(dashboard)/teacher/lesson-plans/library/page.tsx
Normal file
106
src/app/(dashboard)/teacher/lesson-plans/library/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user