From 25dca843be8fc29ebcf510d850d1857e8d808e13 Mon Sep 17 00:00:00 2001
From: SpecialX <47072643+wangxiner55@users.noreply.github.com>
Date: Sat, 4 Jul 2026 10:22:10 +0800
Subject: [PATCH] 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
---
.../teacher/lesson-plans/heatmap/error.tsx | 20 +
.../teacher/lesson-plans/heatmap/loading.tsx | 22 +
.../teacher/lesson-plans/heatmap/page.tsx | 124 ++++++
.../teacher/lesson-plans/library/error.tsx | 20 +
.../teacher/lesson-plans/library/loading.tsx | 17 +
.../teacher/lesson-plans/library/page.tsx | 106 +++++
src/modules/lesson-preparation/actions-ai.ts | 107 +++++
.../lesson-preparation/actions-schedules.ts | 87 ++++
src/modules/lesson-preparation/actions.ts | 14 +
.../components/ai-differentiation-dialog.tsx | 379 ++++++++++++++++++
.../components/ai-feedback-dialog.tsx | 184 +++++++++
.../components/attachment-picker.tsx | 281 +++++++++++++
.../components/blocks/blackboard-block.tsx | 230 +++++++++--
.../components/blocks/exercise-block.tsx | 1 +
.../components/blocks/rich-text-block.tsx | 131 +++++-
.../components/consistency-check-dialog.tsx | 157 ++++++++
.../components/curriculum-heatmap.tsx | 162 ++++++++
.../components/lesson-plan-editor.tsx | 264 +++++++++++-
.../components/lesson-plan-mobile-view.tsx | 150 +++++++
.../components/lesson-plan-readonly-view.tsx | 11 +
.../components/node-edit-panel.tsx | 56 ++-
.../components/node-editor.tsx | 27 ++
.../components/print-view.tsx | 174 ++++++++
.../components/publish-homework-dialog.tsx | 296 +++++++++++---
.../components/question-bank-picker.tsx | 149 ++++++-
.../components/schedule-dialog.tsx | 273 +++++++++++++
.../components/template-picker.tsx | 150 ++++++-
.../components/version-diff-view.tsx | 124 ++++++
.../components/version-history-drawer.tsx | 141 ++++---
.../config/block-registry.tsx | 4 +
.../data-access-schedules.ts | 181 +++++++++
.../lesson-preparation/hooks/editor-slice.ts | 86 +++-
.../lesson-preparation/hooks/history-slice.ts | 79 ++++
.../hooks/use-lesson-plan-editor.ts | 8 +-
.../lesson-preparation/hooks/version-slice.ts | 19 +-
.../lib/ai-differentiation.ts | 235 +++++++++++
.../lesson-preparation/lib/ai-feedback.ts | 135 +++++++
.../lesson-preparation/lib/auto-layout.ts | 82 ++++
.../lib/consistency-check.ts | 137 +++++++
.../lib/curriculum-coverage.ts | 149 +++++++
src/modules/lesson-preparation/lib/export.ts | 267 ++++++++++++
.../lesson-preparation/lib/version-diff.ts | 127 ++++++
.../providers/lesson-plan-provider.tsx | 44 ++
.../services/default-data-service.ts | 51 +++
src/modules/lesson-preparation/types.ts | 44 ++
45 files changed, 5295 insertions(+), 210 deletions(-)
create mode 100644 src/app/(dashboard)/teacher/lesson-plans/heatmap/error.tsx
create mode 100644 src/app/(dashboard)/teacher/lesson-plans/heatmap/loading.tsx
create mode 100644 src/app/(dashboard)/teacher/lesson-plans/heatmap/page.tsx
create mode 100644 src/app/(dashboard)/teacher/lesson-plans/library/error.tsx
create mode 100644 src/app/(dashboard)/teacher/lesson-plans/library/loading.tsx
create mode 100644 src/app/(dashboard)/teacher/lesson-plans/library/page.tsx
create mode 100644 src/modules/lesson-preparation/actions-schedules.ts
create mode 100644 src/modules/lesson-preparation/components/ai-differentiation-dialog.tsx
create mode 100644 src/modules/lesson-preparation/components/ai-feedback-dialog.tsx
create mode 100644 src/modules/lesson-preparation/components/attachment-picker.tsx
create mode 100644 src/modules/lesson-preparation/components/consistency-check-dialog.tsx
create mode 100644 src/modules/lesson-preparation/components/curriculum-heatmap.tsx
create mode 100644 src/modules/lesson-preparation/components/lesson-plan-mobile-view.tsx
create mode 100644 src/modules/lesson-preparation/components/print-view.tsx
create mode 100644 src/modules/lesson-preparation/components/schedule-dialog.tsx
create mode 100644 src/modules/lesson-preparation/components/version-diff-view.tsx
create mode 100644 src/modules/lesson-preparation/data-access-schedules.ts
create mode 100644 src/modules/lesson-preparation/hooks/history-slice.ts
create mode 100644 src/modules/lesson-preparation/lib/ai-differentiation.ts
create mode 100644 src/modules/lesson-preparation/lib/ai-feedback.ts
create mode 100644 src/modules/lesson-preparation/lib/auto-layout.ts
create mode 100644 src/modules/lesson-preparation/lib/consistency-check.ts
create mode 100644 src/modules/lesson-preparation/lib/curriculum-coverage.ts
create mode 100644 src/modules/lesson-preparation/lib/export.ts
create mode 100644 src/modules/lesson-preparation/lib/version-diff.ts
diff --git a/src/app/(dashboard)/teacher/lesson-plans/heatmap/error.tsx b/src/app/(dashboard)/teacher/lesson-plans/heatmap/error.tsx
new file mode 100644
index 0000000..38ffdd9
--- /dev/null
+++ b/src/app/(dashboard)/teacher/lesson-plans/heatmap/error.tsx
@@ -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 (
+
+ window.location.reload() }}
+ className="border-none shadow-none"
+ />
+
+ );
+}
diff --git a/src/app/(dashboard)/teacher/lesson-plans/heatmap/loading.tsx b/src/app/(dashboard)/teacher/lesson-plans/heatmap/loading.tsx
new file mode 100644
index 0000000..c811d2d
--- /dev/null
+++ b/src/app/(dashboard)/teacher/lesson-plans/heatmap/loading.tsx
@@ -0,0 +1,22 @@
+import { Skeleton } from "@/shared/components/ui/skeleton";
+
+export default function HeatmapLoading() {
+ return (
+
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/(dashboard)/teacher/lesson-plans/heatmap/page.tsx b/src/app/(dashboard)/teacher/lesson-plans/heatmap/page.tsx
new file mode 100644
index 0000000..9e06262
--- /dev/null
+++ b/src/app/(dashboard)/teacher/lesson-plans/heatmap/page.tsx
@@ -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 {
+ 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();
+ 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 (
+
+
+
+ );
+ }
+
+ // 获取该教材的所有知识点
+ const allKps = await getKnowledgePointsByTextbookId(defaultTextbookId);
+
+ // 提取该教材下所有课案的知识点关联
+ const planLinks: PlanKpLink[] = [];
+ const chapterNames: Record = {};
+ 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();
+ 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 (
+
+
+
+
+ {t("heatmap.title")}
+
+
{t("heatmap.description")}
+
+
+ {allKps.length === 0 ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/app/(dashboard)/teacher/lesson-plans/library/error.tsx b/src/app/(dashboard)/teacher/lesson-plans/library/error.tsx
new file mode 100644
index 0000000..9ff6cd4
--- /dev/null
+++ b/src/app/(dashboard)/teacher/lesson-plans/library/error.tsx
@@ -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 (
+
+ window.location.reload() }}
+ className="border-none shadow-none"
+ />
+
+ );
+}
diff --git a/src/app/(dashboard)/teacher/lesson-plans/library/loading.tsx b/src/app/(dashboard)/teacher/lesson-plans/library/loading.tsx
new file mode 100644
index 0000000..77cd4a6
--- /dev/null
+++ b/src/app/(dashboard)/teacher/lesson-plans/library/loading.tsx
@@ -0,0 +1,17 @@
+import { Skeleton } from "@/shared/components/ui/skeleton";
+
+export default function LibraryLoading() {
+ return (
+
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/(dashboard)/teacher/lesson-plans/library/page.tsx b/src/app/(dashboard)/teacher/lesson-plans/library/page.tsx
new file mode 100644
index 0000000..3c2aa14
--- /dev/null
+++ b/src/app/(dashboard)/teacher/lesson-plans/library/page.tsx
@@ -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 {
+ 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 (
+
+
+
+
+ {t("library.title")}
+
+
{t("library.description")}
+
+
+ {libraryItems.length === 0 ? (
+
+ ) : (
+
+ {libraryItems.map((item) => (
+
+
+
+
+ {item.title}
+
+
+
+ {item.subjectName && (
+
+ {item.subjectName}
+
+ )}
+ {item.gradeName && (
+
+ {item.gradeName}
+
+ )}
+ {item.textbookTitle && (
+
+ {item.textbookTitle}
+
+ )}
+
+
+ {t("library.byCreator", {
+ creator: item.creatorName ?? t("library.noCreator"),
+ })}
+ {" · "}
+ {formatDateTime(item.updatedAt)}
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/modules/lesson-preparation/actions-ai.ts b/src/modules/lesson-preparation/actions-ai.ts
index 27b1504..48a6f8e 100644
--- a/src/modules/lesson-preparation/actions-ai.ts
+++ b/src/modules/lesson-preparation/actions-ai.ts
@@ -3,9 +3,20 @@
import { requirePermission } from "@/shared/lib/auth-guard";
import { handleActionError } from "@/shared/lib/action-utils";
import { Permissions } from "@/shared/types/permissions";
+import { getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access";
import { suggestKnowledgePoints } from "./ai-suggest";
import { suggestKnowledgePointsSchema } from "./schema";
import { translateFieldErrors } from "./lib/i18n-errors";
+import { generateLessonPlanFeedback, type AiFeedbackResult } from "./lib/ai-feedback";
+import {
+ generateDifferentiationSuggestions,
+ checkCurriculumAlignment,
+ generateExplainableAssessment,
+ type DifferentiationSuggestion,
+ type CurriculumCheckItem,
+ type ExplainableAssessment,
+} from "./lib/ai-differentiation";
+import type { LessonPlanDocument } from "./types";
import type { ActionState } from "@/shared/types/action-state";
export async function suggestKnowledgePointsAction(input: {
@@ -45,3 +56,99 @@ export async function suggestKnowledgePointsAction(input: {
return handleActionError(e);
}
}
+
+/**
+ * V5-17 A1/A2:AI 反馈闭环。
+ * 调用 AI 对课案文档生成结构化反馈(含解释性展示)。
+ */
+export async function generateLessonPlanFeedbackAction(
+ doc: LessonPlanDocument,
+): Promise> {
+ try {
+ await Promise.all([
+ requirePermission(Permissions.LESSON_PLAN_READ),
+ requirePermission(Permissions.AI_CHAT),
+ ]);
+ const result = await generateLessonPlanFeedback(doc);
+ return { success: true, data: result };
+ } catch (e) {
+ return handleActionError(e);
+ }
+}
+
+/**
+ * V5-21 A3:AI 差异化教学建议生成。
+ * 根据课案内容生成基础/提高/拓展三个层次的教学建议。
+ */
+export async function generateDifferentiationSuggestionsAction(
+ doc: LessonPlanDocument,
+): Promise> {
+ try {
+ await Promise.all([
+ requirePermission(Permissions.LESSON_PLAN_READ),
+ requirePermission(Permissions.AI_CHAT),
+ ]);
+ const items = await generateDifferentiationSuggestions(doc);
+ return { success: true, data: { items } };
+ } catch (e) {
+ return handleActionError(e);
+ }
+}
+
+/**
+ * V5-21 A4:AI 课标实时核对。
+ * 检查课案是否覆盖教材课标要求。
+ */
+export async function checkCurriculumAlignmentAction(
+ doc: LessonPlanDocument,
+ knowledgePoints: { id: string; name: string }[],
+): Promise> {
+ try {
+ await Promise.all([
+ requirePermission(Permissions.LESSON_PLAN_READ),
+ requirePermission(Permissions.AI_CHAT),
+ ]);
+ const items = await checkCurriculumAlignment(doc, knowledgePoints);
+ return { success: true, data: { items } };
+ } catch (e) {
+ return handleActionError(e);
+ }
+}
+
+/**
+ * V5-21 A5:AI 可解释评估。
+ * 对课案中的练习节点给出可解释的评分依据。
+ */
+export async function generateExplainableAssessmentAction(
+ doc: LessonPlanDocument,
+): Promise> {
+ try {
+ await Promise.all([
+ requirePermission(Permissions.LESSON_PLAN_READ),
+ requirePermission(Permissions.AI_CHAT),
+ ]);
+ const items = await generateExplainableAssessment(doc);
+ return { success: true, data: { items } };
+ } catch (e) {
+ return handleActionError(e);
+ }
+}
+
+/**
+ * V5-21 A4 辅助:按教材 ID 获取知识点列表(仅 id + name),
+ * 供课标核对 Tab 按需加载,避免编辑页首屏负担。
+ */
+export async function getKnowledgePointsForAlignmentAction(
+ textbookId: string,
+): Promise> {
+ try {
+ await requirePermission(Permissions.LESSON_PLAN_READ);
+ const kps = await getKnowledgePointsByTextbookId(textbookId);
+ return {
+ success: true,
+ data: { items: kps.map((kp) => ({ id: kp.id, name: kp.name })) },
+ };
+ } catch (e) {
+ return handleActionError(e);
+ }
+}
diff --git a/src/modules/lesson-preparation/actions-schedules.ts b/src/modules/lesson-preparation/actions-schedules.ts
new file mode 100644
index 0000000..d728ffd
--- /dev/null
+++ b/src/modules/lesson-preparation/actions-schedules.ts
@@ -0,0 +1,87 @@
+/**
+ * V5-7:课案-课时绑定 Server Actions
+ */
+"use server";
+
+import { getTranslations } from "next-intl/server";
+import { revalidatePath } from "next/cache";
+import { z } from "zod";
+import {
+ getSchedulesByPlanId,
+ createSchedule,
+ deleteSchedule,
+} from "./data-access-schedules";
+import type { ActionState } from "@/shared/types/action-state";
+import { getAuthContext, requirePermission } from "@/shared/lib/auth-guard";
+import { handleActionError } from "@/shared/lib/action-utils";
+import { Permissions } from "@/shared/types/permissions";
+
+const createScheduleSchema = z.object({
+ planId: z.string().min(1, "error.planIdRequired"),
+ classId: z.string().min(1, "error.classIdRequired"),
+ scheduledDate: z.string().min(1, "error.dateRequired"),
+ period: z.number().int().min(1).max(12),
+ classScheduleId: z.string().optional(),
+ durationMin: z.number().int().min(5).max(180).optional(),
+});
+
+export async function getLessonPlanSchedulesAction(
+ planId: string,
+): Promise> }>> {
+ try {
+ await requirePermission(Permissions.LESSON_PLAN_READ);
+ const items = await getSchedulesByPlanId(planId);
+ return { success: true, data: { items } };
+ } catch (e) {
+ return handleActionError(e);
+ }
+}
+
+export async function createLessonPlanScheduleAction(
+ input: Record,
+): Promise> }>> {
+ try {
+ await requirePermission(Permissions.LESSON_PLAN_UPDATE);
+ const t = await getTranslations("lessonPreparation");
+ const parseResult = createScheduleSchema.safeParse(input);
+ if (!parseResult.success) {
+ return {
+ success: false,
+ message: t("error.invalidInput"),
+ errors: Object.fromEntries(
+ Object.entries(parseResult.error.flatten().fieldErrors),
+ ),
+ };
+ }
+
+ const auth = await getAuthContext();
+ if (!auth.userId) {
+ return { success: false, message: t("error.unauthorized") };
+ }
+
+ const schedule = await createSchedule({
+ ...parseResult.data,
+ createdBy: auth.userId,
+ });
+
+ revalidatePath("/teacher/lesson-plans");
+ revalidatePath("/teacher/lesson-plans/calendar");
+ return { success: true, data: { schedule } };
+ } catch (e) {
+ return handleActionError(e);
+ }
+}
+
+export async function deleteLessonPlanScheduleAction(
+ scheduleId: string,
+): Promise> {
+ try {
+ await requirePermission(Permissions.LESSON_PLAN_UPDATE);
+ await deleteSchedule(scheduleId);
+ revalidatePath("/teacher/lesson-plans");
+ revalidatePath("/teacher/lesson-plans/calendar");
+ return { success: true, data: null };
+ } catch (e) {
+ return handleActionError(e);
+ }
+}
diff --git a/src/modules/lesson-preparation/actions.ts b/src/modules/lesson-preparation/actions.ts
index 7af0e22..46daf27 100644
--- a/src/modules/lesson-preparation/actions.ts
+++ b/src/modules/lesson-preparation/actions.ts
@@ -1,6 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { requirePermission } from "@/shared/lib/auth-guard";
import { Permissions } from "@/shared/types/permissions";
@@ -315,6 +316,19 @@ export async function duplicateLessonPlanAction(
}
}
+// V5-12 R4:表单兼容版本(从 formData 提取 planId),用于校内课案库一键复制
+export async function duplicateLessonPlanFormAction(
+ formData: FormData,
+): Promise {
+ const planId = String(formData.get("planId") ?? "");
+ if (!planId) return;
+ const result = await duplicateLessonPlanAction(planId);
+ if (result.success && result.data) {
+ revalidatePath("/teacher/lesson-plans");
+ redirect(`/teacher/lesson-plans/${result.data.newPlanId}/edit`);
+ }
+}
+
// ---- 模板列表 ----
export async function getLessonPlanTemplatesAction(): Promise<
ActionState<{
diff --git a/src/modules/lesson-preparation/components/ai-differentiation-dialog.tsx b/src/modules/lesson-preparation/components/ai-differentiation-dialog.tsx
new file mode 100644
index 0000000..823d78b
--- /dev/null
+++ b/src/modules/lesson-preparation/components/ai-differentiation-dialog.tsx
@@ -0,0 +1,379 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import {
+ X,
+ Layers,
+ Loader2,
+ AlertCircle,
+ Users,
+ CheckCircle2,
+ XCircle,
+ BookCheck,
+ ClipboardList,
+ GraduationCap,
+} from "lucide-react";
+import { FocusTrap } from "@/shared/components/a11y/focus-trap";
+import { Button } from "@/shared/components/ui/button";
+import {
+ generateDifferentiationSuggestionsAction,
+ checkCurriculumAlignmentAction,
+ generateExplainableAssessmentAction,
+ getKnowledgePointsForAlignmentAction,
+} from "../actions-ai";
+import type { LessonPlanDocument, DifferentiationLevel } from "../types";
+import type {
+ DifferentiationSuggestion,
+ CurriculumCheckItem,
+ ExplainableAssessment,
+} from "../lib/ai-differentiation";
+
+interface Props {
+ doc: LessonPlanDocument;
+ /** 课案所属教材 ID(用于 A4 课标核对按需加载知识点) */
+ textbookId?: string;
+ onClose: () => void;
+}
+
+type TabKey = "differentiation" | "curriculum" | "assessment";
+
+const TABS: TabKey[] = ["differentiation", "curriculum", "assessment"];
+
+/**
+ * V5-21 A3/A4/A5:AI 差异化对话框。
+ *
+ * 三个 Tab 分别承载:
+ * - A3 差异化教学建议(基础/提高/拓展)
+ * - A4 课标实时核对(按 textbookId 按需加载知识点)
+ * - A5 可解释评估(练习节点的评分依据)
+ */
+export function AiDifferentiationDialog({ doc, textbookId, onClose }: Props) {
+ const t = useTranslations("lessonPreparation");
+ const [activeTab, setActiveTab] = useState("differentiation");
+
+ // ESC 关闭
+ useEffect(() => {
+ function handleEsc(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+ document.addEventListener("keydown", handleEsc);
+ return () => document.removeEventListener("keydown", handleEsc);
+ }, [onClose]);
+
+ return (
+
+
+
+
+
+
+ {t("aiDifferentiation.title")}
+
+
+
+
+ {/* Tab 切换 */}
+
+ {TABS.map((tab) => (
+
+ ))}
+
+
+
+ {activeTab === "differentiation" && (
+
+ )}
+ {activeTab === "curriculum" && (
+
+ )}
+ {activeTab === "assessment" &&
}
+
+
+
+
+
+
+
+
+ );
+}
+
+/** A3:差异化教学建议 Tab */
+function DifferentiationTab({
+ doc,
+ t,
+}: {
+ doc: LessonPlanDocument;
+ t: ReturnType;
+}) {
+ const [loading, setLoading] = useState(true);
+ const [items, setItems] = useState([]);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await generateDifferentiationSuggestionsAction(doc);
+ if (cancelled) return;
+ if (res.success && res.data) {
+ setItems(res.data.items);
+ } else {
+ setError(res.message ?? t("aiDifferentiation.loadFailed"));
+ }
+ } catch {
+ if (!cancelled) setError(t("aiDifferentiation.loadFailed"));
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [doc, t]);
+
+ if (loading) return ;
+ if (error) return ;
+ if (items.length === 0) return ;
+
+ // 按层次排序:basic → intermediate → advanced
+ const order: DifferentiationLevel[] = ["basic", "intermediate", "advanced"];
+ const sorted = [...items].sort(
+ (a, b) => order.indexOf(a.level) - order.indexOf(b.level),
+ );
+
+ return (
+
+ {sorted.map((item) => (
+
+
+
+ {t(`aiDifferentiation.level.${item.level}`)}
+ — {item.targetStudents}
+
+
+ {item.suggestions.map((s, idx) => (
+ -
+ ·
+ {s}
+
+ ))}
+
+
+ ))}
+
+ );
+}
+
+/** A4:课标实时核对 Tab(按 textbookId 按需加载知识点) */
+function CurriculumTab({
+ doc,
+ textbookId,
+ t,
+}: {
+ doc: LessonPlanDocument;
+ textbookId?: string;
+ t: ReturnType;
+}) {
+ const [loading, setLoading] = useState(true);
+ const [items, setItems] = useState([]);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ // 无教材 ID 时无法获取知识点
+ if (!textbookId) {
+ setLoading(false);
+ setError(t("aiDifferentiation.noKnowledgePoints"));
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ try {
+ // 先按需加载知识点列表
+ const kpRes = await getKnowledgePointsForAlignmentAction(textbookId);
+ if (cancelled) return;
+ if (!kpRes.success || !kpRes.data || kpRes.data.items.length === 0) {
+ setError(t("aiDifferentiation.noKnowledgePoints"));
+ return;
+ }
+ // 再调用课标核对
+ const res = await checkCurriculumAlignmentAction(doc, kpRes.data.items);
+ if (cancelled) return;
+ if (res.success && res.data) {
+ setItems(res.data.items);
+ } else {
+ setError(res.message ?? t("aiDifferentiation.loadFailed"));
+ }
+ } catch {
+ if (!cancelled) setError(t("aiDifferentiation.loadFailed"));
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [doc, textbookId, t]);
+
+ if (loading) return ;
+ if (error) return ;
+ if (items.length === 0) return ;
+
+ const covered = items.filter((i) => i.covered).length;
+ const missed = items.length - covered;
+
+ return (
+
+
+
+
+ {t("aiDifferentiation.covered", { count: covered })}
+
+
+
+ {t("aiDifferentiation.missed", { count: missed })}
+
+
+
+
+ );
+}
+
+/** A5:可解释评估 Tab */
+function AssessmentTab({
+ doc,
+ t,
+}: {
+ doc: LessonPlanDocument;
+ t: ReturnType;
+}) {
+ const [loading, setLoading] = useState(true);
+ const [items, setItems] = useState([]);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await generateExplainableAssessmentAction(doc);
+ if (cancelled) return;
+ if (res.success && res.data) {
+ setItems(res.data.items);
+ } else {
+ setError(res.message ?? t("aiDifferentiation.loadFailed"));
+ }
+ } catch {
+ if (!cancelled) setError(t("aiDifferentiation.loadFailed"));
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [doc, t]);
+
+ if (loading) return ;
+ if (error) return ;
+ if (items.length === 0) return ;
+
+ return (
+
+ {items.map((item, idx) => (
+ -
+
+
+ {item.conclusion}
+
+ {item.rationale && (
+
+
+ {item.rationale}
+
+ )}
+ {item.suggestion && (
+
+
+ {item.suggestion}
+
+ )}
+
+ ))}
+
+ );
+}
+
+function LoadingBlock({ label }: { label: string }) {
+ return (
+
+ );
+}
+
+function ErrorBlock({ message }: { message: string }) {
+ return (
+
+ );
+}
+
+function EmptyBlock({ label }: { label: string }) {
+ return {label}
;
+}
diff --git a/src/modules/lesson-preparation/components/ai-feedback-dialog.tsx b/src/modules/lesson-preparation/components/ai-feedback-dialog.tsx
new file mode 100644
index 0000000..09cf89e
--- /dev/null
+++ b/src/modules/lesson-preparation/components/ai-feedback-dialog.tsx
@@ -0,0 +1,184 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import { X, Sparkles, Loader2, AlertCircle, Lightbulb, CheckCircle2, Target, Users } from "lucide-react";
+import { FocusTrap } from "@/shared/components/a11y/focus-trap";
+import { Button } from "@/shared/components/ui/button";
+import { generateLessonPlanFeedbackAction } from "../actions-ai";
+import type { AiFeedbackResult, AiFeedbackItem } from "../lib/ai-feedback";
+import type { LessonPlanDocument } from "../types";
+
+interface Props {
+ doc: LessonPlanDocument;
+ onClose: () => void;
+}
+
+/** V5-17 A1/A2:AI 反馈闭环对话框(含解释性展示) */
+export function AiFeedbackDialog({ doc, onClose }: Props) {
+ const t = useTranslations("lessonPreparation");
+ const [loading, setLoading] = useState(true);
+ const [result, setResult] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await generateLessonPlanFeedbackAction(doc);
+ if (cancelled) return;
+ if (res.success && res.data) {
+ setResult(res.data);
+ } else {
+ setError(res.message ?? t("feedback.loadFailed"));
+ }
+ } catch {
+ if (!cancelled) setError(t("feedback.loadFailed"));
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [doc, t]);
+
+ // ESC 关闭
+ useEffect(() => {
+ function handleEsc(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+ document.addEventListener("keydown", handleEsc);
+ return () => document.removeEventListener("keydown", handleEsc);
+ }, [onClose]);
+
+ return (
+
+
+
+
+
+
+ {t("feedback.title")}
+
+
+
+
+
+ {loading ? (
+
+
+
{t("feedback.loading")}
+
+ ) : error ? (
+
+ ) : result ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+ );
+}
+
+function FeedbackContent({
+ result,
+ t,
+}: {
+ result: AiFeedbackResult;
+ t: ReturnType;
+}) {
+ // 按维度分组
+ const grouped: Record = {
+ strengths: [],
+ improvements: [],
+ alignment: [],
+ differentiation: [],
+ };
+ for (const item of result.items) {
+ grouped[item.category].push(item);
+ }
+
+ return (
+
+ {/* 摘要 + 评分 */}
+
+
+
{t("feedback.summary")}
+
{result.summary}
+
+
+
{result.overallScore}
+
{t("feedback.score")}
+
+
+
+ {/* 各维度反馈 */}
+ {(Object.keys(grouped) as AiFeedbackItem["category"][]).map((cat) => {
+ const items = grouped[cat];
+ if (items.length === 0) return null;
+ return (
+
+
+
+ {t(`feedback.category.${cat}`)}
+ ({items.length})
+
+
+ {items.map((item, idx) => (
+ -
+
{item.title}
+ {/* A2:解释性展示 — reason 字段说明 AI 判断依据 */}
+ {item.reason && (
+
+
+ {item.reason}
+
+ )}
+
+ ))}
+
+
+ );
+ })}
+
+ {result.items.length === 0 && (
+
+ {t("feedback.empty")}
+
+ )}
+
+ );
+}
+
+function CategoryIcon({ category }: { category: AiFeedbackItem["category"] }) {
+ const icon = {
+ strengths: ,
+ improvements: ,
+ alignment: ,
+ differentiation: ,
+ }[category];
+ return icon;
+}
diff --git a/src/modules/lesson-preparation/components/attachment-picker.tsx b/src/modules/lesson-preparation/components/attachment-picker.tsx
new file mode 100644
index 0000000..ee0679c
--- /dev/null
+++ b/src/modules/lesson-preparation/components/attachment-picker.tsx
@@ -0,0 +1,281 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import { X, Upload, FileText, Image as ImageIcon, Music, Video, Trash2 } from "lucide-react";
+import { Button } from "@/shared/components/ui/button";
+import { FocusTrap } from "@/shared/components/a11y/focus-trap";
+import { useFileUpload } from "@/modules/files/hooks/use-file-upload";
+import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider";
+import type { LessonPlanAttachmentOption } from "../providers/lesson-plan-provider";
+import type { RichTextAttachment } from "../types";
+import { toast } from "sonner";
+
+interface Props {
+ planId: string;
+ blockId?: string;
+ /** 当前节点已嵌入的附件(用于高亮已选) */
+ selectedIds?: string[];
+ /** 选择附件后的回调(嵌入到富文本) */
+ onSelect: (attachment: RichTextAttachment) => void;
+ onClose: () => void;
+}
+
+/** V5-5:根据 MIME 类型推断渲染种类(供调用方使用) */
+export function inferAttachmentKind(
+ mimeType: string | undefined,
+): RichTextAttachment["kind"] {
+ if (!mimeType) return "file";
+ if (mimeType.startsWith("image/")) return "image";
+ if (mimeType.startsWith("audio/")) return "audio";
+ if (mimeType.startsWith("video/")) return "video";
+ return "file";
+}
+
+function kindIcon(kind: RichTextAttachment["kind"]) {
+ switch (kind) {
+ case "image":
+ return ;
+ case "audio":
+ return ;
+ case "video":
+ return ;
+ default:
+ return ;
+ }
+}
+
+/**
+ * V5-5:素材库 Picker
+ *
+ * 功能:
+ * 1. 上传新文件(复用 use-file-upload hook,调用 /api/upload)
+ * 2. 上传成功后调用 service.createLessonPlanAttachment 落库到 lessonPlanAttachments 表
+ * 3. 列出当前课案已上传的所有附件(service.getLessonPlanAttachments)
+ * 4. 点击附件 → onSelect 回调,由调用方决定如何嵌入
+ *
+ * 不直接 import actions,通过 LessonPlanContext 注入的 service 调用。
+ */
+export function AttachmentPicker({
+ planId,
+ blockId,
+ selectedIds = [],
+ onSelect,
+ onClose,
+}: Props) {
+ const t = useTranslations("lessonPreparation");
+ const ctx = useLessonPlanContextSafe();
+ const service = ctx?.service ?? null;
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ // V5-5:复用项目已有 use-file-upload hook
+ const { inputRef, handleFiles, tasks } = useFileUpload({
+ targetType: "lesson_plan",
+ targetId: planId,
+ multiple: true,
+ onUploaded: async (result) => {
+ if (!service) return;
+ // 上传成功后创建附件记录
+ const res = await service.createLessonPlanAttachment({
+ planId,
+ blockId,
+ fileId: result.id,
+ displayName: result.originalName,
+ attachmentType: "material",
+ });
+ if (res.success) {
+ toast.success(t("attachment.createSuccess"));
+ // 刷新附件列表
+ void loadAttachments();
+ } else {
+ toast.error(res.message ?? t("error.save"));
+ }
+ },
+ });
+
+ // V5-5:加载附件列表
+ const loadAttachments = useCallback(async () => {
+ if (!service) return;
+ setLoading(true);
+ try {
+ const res = await service.getLessonPlanAttachments(planId);
+ if (res.success && res.data) {
+ setItems(res.data.items);
+ }
+ } catch (e) {
+ console.error("[AttachmentPicker] load failed", e);
+ toast.error(t("attachment.empty"));
+ } finally {
+ setLoading(false);
+ }
+ }, [service, planId, t]);
+
+ useEffect(() => {
+ void loadAttachments();
+ }, [loadAttachments]);
+
+ // V5-5:ESC 关闭
+ useEffect(() => {
+ function handleEsc(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+ document.addEventListener("keydown", handleEsc);
+ return () => document.removeEventListener("keydown", handleEsc);
+ }, [onClose]);
+
+ // V5-5:删除附件
+ async function handleDelete(attachmentId: string) {
+ if (!service) return;
+ try {
+ const res = await service.deleteLessonPlanAttachment(attachmentId);
+ if (res.success) {
+ toast.success(t("attachment.deleteSuccess"));
+ void loadAttachments();
+ } else {
+ toast.error(res.message ?? t("error.save"));
+ }
+ } catch (e) {
+ console.error("[AttachmentPicker] delete failed", e);
+ toast.error(t("error.save"));
+ }
+ }
+
+ // V5-5:选择附件嵌入富文本
+ function handleSelect(item: LessonPlanAttachmentOption) {
+ const attachment: RichTextAttachment = {
+ attachmentId: item.id,
+ fileId: item.fileId,
+ displayName: item.displayName,
+ kind: inferAttachmentKind(item.mimeType),
+ url: item.url ?? `/api/files/${item.fileId}`,
+ mimeType: item.mimeType,
+ };
+ onSelect(attachment);
+ onClose();
+ }
+
+ return (
+
+
+
+
+
{t("attachment.title")}
+
+
+
+
+ {/* 上传区域 */}
+
+
handleFiles(e.target.files)}
+ />
+
+ {/* 上传任务进度 */}
+ {tasks.length > 0 && (
+
+ {tasks.map((task) => (
+
+ {task.file.name}
+
+ {task.status === "error"
+ ? t("attachment.uploadFailed")
+ : task.status === "success"
+ ? t("attachment.uploadSuccess")
+ : `${task.progress}%`}
+
+
+ ))}
+
+ )}
+
+
+ {/* 已上传附件列表 */}
+
+
+ {loading ? (
+
+ {t("version.loading")}
+
+ ) : items.length === 0 ? (
+
+ {t("attachment.empty")}
+
+ ) : (
+
+ {items.map((item) => {
+ const isSelected = selectedIds.includes(item.id);
+ return (
+ -
+
+ {kindIcon(inferAttachmentKind(item.mimeType))}
+
+
+
+ {t(`attachment.type.${item.attachmentType}`)}
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/modules/lesson-preparation/components/blocks/blackboard-block.tsx b/src/modules/lesson-preparation/components/blocks/blackboard-block.tsx
index 02d2a5b..5a7c02b 100644
--- a/src/modules/lesson-preparation/components/blocks/blackboard-block.tsx
+++ b/src/modules/lesson-preparation/components/blocks/blackboard-block.tsx
@@ -1,8 +1,8 @@
"use client";
-import { useState } from "react";
+import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
-import { Tag } from "lucide-react";
+import { Tag, Eye, Pencil } from "lucide-react";
import type { BlackboardBlockData } from "../../types";
import { isBlackboardLayout } from "../../lib/type-guards";
import { KnowledgePointPicker } from "../knowledge-point-picker";
@@ -17,47 +17,98 @@ interface Props {
const LAYOUTS: BlackboardBlockData["layout"][] = ["structure", "mindmap", "text"];
+/**
+ * V5-14 F4:板书可视化工具。
+ *
+ * 在原有纯文本编辑基础上增加轻量级可视化预览(不引入新库):
+ * - structure(结构式):按行解析缩进,渲染为带连接线的层级树
+ * - mindmap(思维导图):第一行为中心,其余为分支节点
+ * - text(文字式):等宽字体直接展示
+ *
+ * 编辑/预览模式切换,避免双栏占满侧边面板。
+ */
export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props) {
const t = useTranslations("lessonPreparation");
const [showKpPicker, setShowKpPicker] = useState(false);
+ const [previewMode, setPreviewMode] = useState(false);
return (
{t("blackboard.hint")}
-
-
-
-
-
-
-
);
}
diff --git a/src/modules/lesson-preparation/components/consistency-check-dialog.tsx b/src/modules/lesson-preparation/components/consistency-check-dialog.tsx
new file mode 100644
index 0000000..9f3fb1f
--- /dev/null
+++ b/src/modules/lesson-preparation/components/consistency-check-dialog.tsx
@@ -0,0 +1,157 @@
+"use client";
+
+import { useEffect, useMemo } from "react";
+import { useTranslations } from "next-intl";
+import { X, AlertTriangle, CheckCircle2 } from "lucide-react";
+import { FocusTrap } from "@/shared/components/a11y/focus-trap";
+import type { LessonPlanDocument } from "../types";
+import { checkConsistency, hasConsistencyWarnings, type ConsistencyIssue } from "../lib/consistency-check";
+import { cn } from "@/shared/lib/utils";
+
+interface Props {
+ doc: LessonPlanDocument;
+ onClose: () => void;
+}
+
+/**
+ * V5-19 T3:教学评一致性校验对话框。
+ *
+ * 以纯函数 `checkConsistency` 计算结果,仅做 UI 展示。
+ * 不修改文档,不阻断保存。
+ */
+export function ConsistencyCheckDialog({ doc, onClose }: Props) {
+ const t = useTranslations("lessonPreparation");
+
+ const result = useMemo(() => checkConsistency(doc), [doc]);
+ const hasWarnings = hasConsistencyWarnings(result);
+
+ // ESC 关闭
+ useEffect(() => {
+ function handleEsc(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+ document.addEventListener("keydown", handleEsc);
+ return () => document.removeEventListener("keydown", handleEsc);
+ }, [onClose]);
+
+ return (
+
+
+
+
+
+
+ {t("consistency.title")}
+
+
+
+
+
+ {/* 顶部统计 */}
+
+
+
+ {t("editor.consistencyObjectiveCount", { count: result.objectiveCount })}
+
+
+
+
+ {t("editor.consistencyExerciseCount", { count: result.exerciseCount })}
+
+
+
+
+ {t("consistency.coverage", {
+ covered: result.coveredObjectiveCount,
+ total: result.objectiveCount,
+ })}
+
+
+
+
+ {/* 一致性分数 */}
+
+ {t("consistency.score", { score: result.score })}
+ = 80
+ ? "bg-primary-container text-on-primary-container"
+ : result.score >= 50
+ ? "bg-secondary-container text-on-secondary-container"
+ : "bg-error-container text-on-error-container",
+ )}
+ >
+ {result.score}
+
+
+
+ {/* 问题列表 */}
+
+
+ {hasWarnings ? t("consistency.title") : t("consistency.noIssues")}
+
+ {hasWarnings ? (
+
+ {result.issues.map((issue, idx) => (
+
+ ))}
+
+ ) : (
+
+
+ {t("consistency.noIssues")}
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function IssueItem({
+ issue,
+ t,
+}: {
+ issue: ConsistencyIssue;
+ t: ReturnType
;
+}) {
+ const isWarning = issue.severity === "warning";
+ const message = t(`consistency.code.${issue.code}`, {
+ title: issue.params?.title ?? issue.nodeTitle ?? "",
+ });
+ return (
+
+
+ {message}
+
+ );
+}
diff --git a/src/modules/lesson-preparation/components/curriculum-heatmap.tsx b/src/modules/lesson-preparation/components/curriculum-heatmap.tsx
new file mode 100644
index 0000000..38bbb00
--- /dev/null
+++ b/src/modules/lesson-preparation/components/curriculum-heatmap.tsx
@@ -0,0 +1,162 @@
+"use client";
+
+import { useMemo } from "react";
+import { useTranslations } from "next-intl";
+import { AlertTriangle } from "lucide-react";
+import {
+ computeCurriculumCoverage,
+ getHeatLevel,
+ type PlanKpLink,
+} from "../lib/curriculum-coverage";
+import type { KnowledgePoint } from "@/modules/textbooks/types";
+import { cn } from "@/shared/lib/utils";
+
+interface Props {
+ allKps: KnowledgePoint[];
+ planLinks: PlanKpLink[];
+ /** 章节 ID → 章节名映射 */
+ chapterNames: Record;
+}
+
+/** V5-20 T4:课标覆盖度热力图组件 */
+export function CurriculumHeatmap({ allKps, planLinks, chapterNames }: Props) {
+ const t = useTranslations("lessonPreparation");
+
+ const result = useMemo(
+ () => computeCurriculumCoverage(allKps, planLinks),
+ [allKps, planLinks],
+ );
+
+ return (
+
+ {/* 顶部统计条 */}
+
+
+
+
+
+
+ {/* 教学盲点警告 */}
+ {result.blindSpots.length > 0 && (
+
+
+
+
+ {t("heatmap.blindSpotTitle", { count: result.blindSpots.length })}
+
+
+ {result.blindSpots.slice(0, 5).map((b) => b.kpName).join("、")}
+ {result.blindSpots.length > 5 && t("heatmap.andMore")}
+
+
+
+ )}
+
+ {/* 按章节展开的热力图 */}
+
+ {result.chapters.map((ch) => {
+ const level = getHeatLevel(ch.coverageRate);
+ const chapterName = chapterNames[ch.chapterId] ?? t("heatmap.unknownChapter");
+ return (
+
+ {/* 章节头部带热力色块 */}
+
+
+
+ {chapterName}
+
+
+ {t("heatmap.chapterCoverage", {
+ covered: ch.coveredKps,
+ total: ch.totalKps,
+ rate: ch.coverageRate,
+ })}
+
+
+ {/* 知识点列表 */}
+
+ {ch.kps.map((kp) => {
+ const kpLevel = getHeatLevel(kp.planCount === 0 ? 0 : Math.min(100, kp.planCount * 33));
+ return (
+ -
+
+
+ {kp.kpName}
+
+
+ {kp.isBlindSpot
+ ? t("heatmap.notCovered")
+ : t("heatmap.planCount", { count: kp.planCount })}
+
+
+ );
+ })}
+
+
+ );
+ })}
+
+
+ );
+}
+
+function StatCard({
+ label,
+ value,
+ highlight,
+}: {
+ label: string;
+ value: string | number;
+ highlight?: boolean;
+}) {
+ return (
+
+ );
+}
+
+/** 热力等级 → Tailwind 颜色类 */
+function heatColorClass(level: 0 | 1 | 2 | 3 | 4): string {
+ return [
+ "bg-error/60",
+ "bg-tertiary/40",
+ "bg-tertiary/70",
+ "bg-primary/60",
+ "bg-primary",
+ ][level];
+}
diff --git a/src/modules/lesson-preparation/components/lesson-plan-editor.tsx b/src/modules/lesson-preparation/components/lesson-plan-editor.tsx
index 03e9393..04472ff 100644
--- a/src/modules/lesson-preparation/components/lesson-plan-editor.tsx
+++ b/src/modules/lesson-preparation/components/lesson-plan-editor.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { NodeEditor } from "./node-editor";
@@ -24,8 +24,14 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/components/ui/alert-dialog";
-import { Plus, Save, History, Book, FileText, Send, Undo2 } from "lucide-react";
+import { Plus, Save, History, Book, FileText, Send, Undo2, RotateCw, WifiOff, Printer, Undo, Redo, CalendarClock, ClipboardCheck, Sparkles, Layers } from "lucide-react";
import { toast } from "sonner";
+import { PrintView } from "./print-view";
+import { ScheduleDialog } from "./schedule-dialog";
+import { ConsistencyCheckDialog } from "./consistency-check-dialog";
+import { AiFeedbackDialog } from "./ai-feedback-dialog";
+import { AiDifferentiationDialog } from "./ai-differentiation-dialog";
+import type { LessonPlan, LessonPlanDocument } from "../types";
interface Props {
planId: string;
@@ -75,6 +81,11 @@ export function LessonPlanEditor({
const service = ctx?.service ?? null;
const [showVersions, setShowVersions] = useState(false);
const [showAddMenu, setShowAddMenu] = useState(false);
+ const [showPrint, setShowPrint] = useState(false); // V5-4:打印视图
+ const [showSchedule, setShowSchedule] = useState(false); // V5-7:安排课时对话框
+ const [showConsistency, setShowConsistency] = useState(false); // V5-19:一致性校验对话框
+ const [showAiFeedback, setShowAiFeedback] = useState(false); // V5-17:AI 反馈对话框
+ const [showAiDifferentiation, setShowAiDifferentiation] = useState(false); // V5-21:AI 差异化对话框
const [planStatus, setPlanStatus] = useState(initialStatus);
const [publishing, setPublishing] = useState(false);
const autoSaveTimer = useRef | null>(null);
@@ -90,9 +101,11 @@ export function LessonPlanEditor({
// 自动保存(debounce 3s)- 用 getState() 获取最新值(修复 P1-4)
// V3 修复:完全通过 service 调用,不直接 import actions
+ // V5-1 修复:保存失败显示 toast + 设置 saveError;断网时不触发保存
useEffect(() => {
if (!editor.isDirty) return;
if (!service) return;
+ if (!editor.isOnline) return; // V5-1:断网期间不触发保存请求
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
autoSaveTimer.current = setTimeout(async () => {
const state = useLessonPlanEditor.getState();
@@ -103,9 +116,18 @@ export function LessonPlanEditor({
title: state.title,
content: state.doc,
});
- if (res.success) state.markSaved();
+ if (res.success) {
+ // V5-1:从失败恢复到成功时提示
+ if (state.saveError) toast.success(t("status.recovered"));
+ state.markSaved();
+ } else {
+ state.setSaveError(true);
+ toast.error(t("status.saveFailed"), { description: t("status.saveFailedHint") });
+ }
} catch (e) {
console.error("[LessonPlanEditor] auto-save failed", e);
+ state.setSaveError(true);
+ toast.error(t("status.saveFailed"), { description: t("status.saveFailedHint") });
} finally {
state.setSaving(false);
}
@@ -113,7 +135,98 @@ export function LessonPlanEditor({
return () => {
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
};
- }, [editor.isDirty, editor.doc, planId, service]);
+ }, [editor.isDirty, editor.doc, planId, service, editor.isOnline, t]);
+
+ // V5-1:监听网络在线/离线状态
+ useEffect(() => {
+ function handleOnline() {
+ useLessonPlanEditor.getState().setOnline(true);
+ toast.success(t("status.backOnline"));
+ }
+ function handleOffline() {
+ useLessonPlanEditor.getState().setOnline(false);
+ toast.error(t("status.offline"), { description: t("status.offlineHint") });
+ }
+ window.addEventListener("online", handleOnline);
+ window.addEventListener("offline", handleOffline);
+ return () => {
+ window.removeEventListener("online", handleOnline);
+ window.removeEventListener("offline", handleOffline);
+ };
+ }, [t]);
+
+ // V5-1:手动重试保存(saveError 时显示按钮)
+ const handleRetrySave = useCallback(async () => {
+ if (!service) return;
+ const state = useLessonPlanEditor.getState();
+ state.setSaving(true);
+ try {
+ const res = await service.updateLessonPlan({
+ planId: state.planId,
+ title: state.title,
+ content: state.doc,
+ });
+ if (res.success) {
+ toast.success(t("status.recovered"));
+ state.markSaved();
+ } else {
+ toast.error(t("status.saveFailed"));
+ }
+ } catch (e) {
+ console.error("[LessonPlanEditor] retry save failed", e);
+ toast.error(t("status.saveFailed"));
+ } finally {
+ state.setSaving(false);
+ }
+ }, [service, t]);
+
+ // V5-2:撤销/重做快捷键(Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z 或 Cmd/Ctrl+Y)
+ useEffect(() => {
+ function handleKeyDown(e: KeyboardEvent) {
+ const isMod = e.metaKey || e.ctrlKey;
+ if (!isMod) return;
+ const key = e.key.toLowerCase();
+ if (key === "z" && !e.shiftKey) {
+ e.preventDefault();
+ const state = useLessonPlanEditor.getState();
+ if (state.canUndo()) state.undo();
+ } else if ((key === "z" && e.shiftKey) || key === "y") {
+ e.preventDefault();
+ const state = useLessonPlanEditor.getState();
+ if (state.canRedo()) state.redo();
+ }
+ }
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, []);
+
+ // V5-2:撤销/重做按钮处理(追踪 canUndo/canRedo 以驱动 disabled 状态)
+ const canUndo = editor.canUndo();
+ const canRedo = editor.canRedo();
+ const handleUndo = useCallback(() => useLessonPlanEditor.getState().undo(), []);
+ const handleRedo = useCallback(() => useLessonPlanEditor.getState().redo(), []);
+
+ // V5-4:构造打印用的 LessonPlan 对象(编辑器仅持有 planId/title/doc,其余字段填默认值)
+ const printablePlan: LessonPlan = useMemo(() => {
+ const doc: LessonPlanDocument = editor.doc;
+ return {
+ id: planId,
+ title: editor.title,
+ textbookId: textbookId ?? null,
+ chapterId: chapterId ?? null,
+ coursePlanItemId: null,
+ subjectId: null,
+ gradeId: null,
+ templateId: null,
+ templateName: null,
+ content: doc,
+ status: planStatus,
+ creatorId: "",
+ lastSavedAt: editor.lastSavedAt ? new Date(editor.lastSavedAt).toISOString() : null,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ };
+ }, [planId, editor.title, editor.doc, editor.lastSavedAt, textbookId, chapterId, planStatus]);
// 定时自动版本(30min)
useEffect(() => {
@@ -266,6 +379,25 @@ export function LessonPlanEditor({
? t("status.unsaved")
: t("status.saved")}
+ {/* V5-1:保存失败/离线提示与重试按钮 */}
+ {editor.saveError && (
+
+ )}
+ {!editor.isOnline && (
+
+
+ {t("status.offlineBadge")}
+
+ )}
+ {/* V5-2:撤销/重做按钮 */}
+
+
+ {/* V5-4:导出/打印按钮 */}
+
+ {/* V5-7:安排课时按钮 */}
+ {classes && classes.length > 0 && (
+
+ )}
+ {/* V5-19 T3:一致性校验按钮 */}
+
+ {/* V5-17 A1/A2:AI 反馈按钮 */}
+
+ {/* V5-21 A3/A4/A5:AI 差异化与课标核对按钮 */}
+
{/* 发布/撤回发布按钮(P0-1 修复)*/}
{planStatus === "published" ? (
@@ -362,6 +562,7 @@ export function LessonPlanEditor({
{editor.selectedNodeId && (
setShowVersions(false)}
planId={planId}
onReverted={handleReverted}
+ currentDoc={editor.doc}
/>
+
+ {/* V5-4:导出/打印视图 */}
+ {showPrint && (
+
+ setShowPrint(false)}
+ />
+
+ )}
+
+ {/* V5-7:安排课时对话框 */}
+ {showSchedule && classes && classes.length > 0 && (
+
+ setShowSchedule(false)}
+ />
+
+ )}
+
+ {/* V5-19 T3:一致性校验对话框 */}
+ {showConsistency && (
+
+ setShowConsistency(false)}
+ />
+
+ )}
+
+ {/* V5-17 A1/A2:AI 反馈对话框 */}
+ {showAiFeedback && (
+
+ setShowAiFeedback(false)}
+ />
+
+ )}
+
+ {/* V5-21 A3/A4/A5:AI 差异化与课标核对对话框 */}
+ {showAiDifferentiation && (
+
+ setShowAiDifferentiation(false)}
+ />
+
+ )}
);
}
diff --git a/src/modules/lesson-preparation/components/lesson-plan-mobile-view.tsx b/src/modules/lesson-preparation/components/lesson-plan-mobile-view.tsx
new file mode 100644
index 0000000..30eab3b
--- /dev/null
+++ b/src/modules/lesson-preparation/components/lesson-plan-mobile-view.tsx
@@ -0,0 +1,150 @@
+"use client";
+
+import { useMemo } from "react";
+import { useTranslations } from "next-intl";
+import { Book } from "lucide-react";
+import type {
+ LessonPlanDocument,
+ LessonPlanNode,
+ TeachingStage,
+} from "../types";
+import { TEACHING_STAGE_KEYS } from "../types";
+import { getNodeColor, getNodeSummary } from "../lib/node-summary";
+import { cn } from "@/shared/lib/utils";
+
+interface Props {
+ doc: LessonPlanDocument;
+ textbookTitle?: string;
+ chapterTitle?: string;
+}
+
+/**
+ * V5-13 P3:移动端只读视图。
+ *
+ * 在小屏设备上以线性卡片列表替代 React Flow 画布,
+ * 按教学阶段(V5-15)分组展示节点,提升可读性。
+ * 仅展示,无编辑交互。
+ */
+export function LessonPlanMobileView({ doc, textbookTitle, chapterTitle }: Props) {
+ const t = useTranslations("lessonPreparation");
+
+ // 教学节点按 stage 分组(未归类的归入"其他")
+ const grouped = useMemo(() => {
+ const teachingNodes = doc.nodes.filter(
+ (n): n is LessonPlanNode => n.type !== "textbook_content",
+ );
+ const groups: Record = {};
+ const unstaged: LessonPlanNode[] = [];
+ for (const n of teachingNodes) {
+ if (n.stage) {
+ const key = n.stage as TeachingStage;
+ if (!groups[key]) groups[key] = [];
+ groups[key].push(n);
+ } else {
+ unstaged.push(n);
+ }
+ }
+ return { groups, unstaged };
+ }, [doc.nodes]);
+
+ return (
+
+ {/* 顶部信息条 */}
+ {(textbookTitle || chapterTitle) && (
+
+ {textbookTitle && (
+
+
+ {textbookTitle}
+
+ )}
+ {chapterTitle && (
+
{chapterTitle}
+ )}
+
+ )}
+
+
+ {/* 按阶段分组渲染 */}
+ {TEACHING_STAGE_KEYS.map((stage) => {
+ const nodes = grouped.groups[stage];
+ if (!nodes || nodes.length === 0) return null;
+ return (
+
+
+ {t(`editor.stage.${stage}`)}
+
+ {nodes.map((n) => (
+
+ ))}
+
+ );
+ })}
+
+ {/* 未归类节点 */}
+ {grouped.unstaged.length > 0 && (
+
+ {TEACHING_STAGE_KEYS.some((s) => grouped.groups[s]?.length > 0) && (
+
+ {t("editor.stageNone")}
+
+ )}
+ {grouped.unstaged.map((n) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+function MobileNodeCard({
+ node,
+ t,
+}: {
+ node: LessonPlanNode;
+ t: ReturnType;
+}) {
+ const color = getNodeColor(node.type);
+ const summary = getNodeSummary(node, (key, values) => t(key, values));
+ const diff = node.differentiation;
+
+ return (
+
+
+
+
+
+
+ {node.title || node.type}
+
+ {diff && (
+
+ {t(`editor.differentiation.${diff}`)}
+
+ )}
+
+ {summary && (
+
+ {summary}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/modules/lesson-preparation/components/lesson-plan-readonly-view.tsx b/src/modules/lesson-preparation/components/lesson-plan-readonly-view.tsx
index 770b270..677577f 100644
--- a/src/modules/lesson-preparation/components/lesson-plan-readonly-view.tsx
+++ b/src/modules/lesson-preparation/components/lesson-plan-readonly-view.tsx
@@ -15,6 +15,8 @@ import { LessonNode } from "./nodes/lesson-node";
import { TextbookContentNode as TextbookContentNodeComponent } from "./nodes/textbook-content-node";
import { toRfNodes, toRfEdges } from "../lib/rf-mappers";
import { getNodeColor } from "../lib/node-summary";
+import { useMediaQuery } from "@/shared/hooks/use-media-query";
+import { LessonPlanMobileView } from "./lesson-plan-mobile-view";
import type { LessonPlanDocument } from "../types";
const nodeTypes = {
@@ -39,6 +41,8 @@ interface Props {
export function LessonPlanReadonlyView({ doc, textbookTitle, chapterTitle }: Props) {
const t = useTranslations("lessonPreparation");
const [selectedNodeId, setSelectedNodeId] = useState(null);
+ // V5-13 P3:小屏设备使用线性移动视图替代画布
+ const isMobile = useMediaQuery("(max-width: 768px)");
const rfNodes = useMemo(() => toRfNodes(doc.nodes, selectedNodeId), [doc.nodes, selectedNodeId]);
const rfEdges = useMemo(
@@ -65,6 +69,13 @@ export function LessonPlanReadonlyView({ doc, textbookTitle, chapterTitle }: Pro
});
}, [rfNodes, doc.nodes, doc.anchors, selectedNodeId]);
+ // V5-13 P3:移动端渲染线性视图
+ if (isMobile) {
+ return (
+
+ );
+ }
+
return (
{/* 顶部信息条 */}
diff --git a/src/modules/lesson-preparation/components/node-edit-panel.tsx b/src/modules/lesson-preparation/components/node-edit-panel.tsx
index e33eea4..1475b03 100644
--- a/src/modules/lesson-preparation/components/node-edit-panel.tsx
+++ b/src/modules/lesson-preparation/components/node-edit-panel.tsx
@@ -9,6 +9,8 @@ import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary";
import { Button } from "@/shared/components/ui/button";
import { Trash2, X } from "lucide-react";
import { getNodeColor } from "../lib/node-summary";
+import type { TeachingStage, DifferentiationLevel } from "../types";
+import { TEACHING_STAGE_KEYS, DIFFERENTIATION_LEVEL_KEYS } from "../types";
/**
* P0-11 修复:AI 内容生成器 slot 类型。
@@ -27,11 +29,13 @@ interface Props {
textbookId?: string;
chapterId?: string;
classes?: { id: string; name: string }[];
+ /** V5-5:当前课案 ID(透传给 RichTextBlock 用于素材库 picker) */
+ planId?: string;
/** AI 内容生成器(可选,通过 props 注入避免模块耦合)*/
aiContentGenerator?: AiContentGeneratorSlot;
}
-export function NodeEditPanel({ textbookId, chapterId, classes, aiContentGenerator }: Props) {
+export function NodeEditPanel({ textbookId, chapterId, classes, planId, aiContentGenerator }: Props) {
const t = useTranslations("lessonPreparation");
const tAi = useTranslations("ai");
const { doc, selectedNodeId, updateNode, removeNode, selectNode, removeAnchor } =
@@ -156,6 +160,55 @@ export function NodeEditPanel({ textbookId, chapterId, classes, aiContentGenerat
+ {/* V5-15 T1 + V5-18 W6:节点属性条(教学阶段 + 差异化标记) */}
+
+ {/* 教学阶段 */}
+
+
+
+ {/* 差异化标记 */}
+
+
+
+
{/* 内容编辑区 - 使用 Error Boundary 包裹 + BlockRenderer 配置驱动渲染 */}
@@ -166,6 +219,7 @@ export function NodeEditPanel({ textbookId, chapterId, classes, aiContentGenerat
textbookId={textbookId}
chapterId={chapterId}
classes={classes}
+ planId={planId}
onUpdate={(d) => updateNode(lessonNode.id, { data: d })}
/>
{/* BlockRenderer 返回 null 时显示未知类型提示 */}
diff --git a/src/modules/lesson-preparation/components/node-editor.tsx b/src/modules/lesson-preparation/components/node-editor.tsx
index 8d2f6d8..0719616 100644
--- a/src/modules/lesson-preparation/components/node-editor.tsx
+++ b/src/modules/lesson-preparation/components/node-editor.tsx
@@ -14,8 +14,11 @@ import {
type Connection,
applyEdgeChanges,
BackgroundVariant,
+ Panel,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
+import { LayoutGrid } from "lucide-react";
+import { Button } from "@/shared/components/ui/button";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { LessonNode } from "./nodes/lesson-node";
import { TextbookContentNode as TextbookContentNodeComponent } from "./nodes/textbook-content-node";
@@ -43,8 +46,14 @@ export function NodeEditor({}: Props) {
setEdges,
addAnchor,
addNode,
+ autoLayout,
} = useLessonPlanEditor();
+ // V5-8:自动布局按钮
+ const handleAutoLayout = useCallback(() => {
+ autoLayout("TB");
+ }, [autoLayout]);
+
// P1-1:构建可锚定的教学节点列表(排除正文节点)
const anchorableNodes = useMemo(
() =>
@@ -233,6 +242,10 @@ export function NodeEditor({}: Props) {
}}
proOptions={{ hideAttribution: true }}
className="bg-surface-container-low"
+ onlyRenderVisibleElements
+ minZoom={0.2}
+ maxZoom={2.5}
+ elevateNodesOnSelect={false}
>
+ {/* V5-8:自动布局按钮 */}
+
+
+
{
diff --git a/src/modules/lesson-preparation/components/print-view.tsx b/src/modules/lesson-preparation/components/print-view.tsx
new file mode 100644
index 0000000..15bbb4b
--- /dev/null
+++ b/src/modules/lesson-preparation/components/print-view.tsx
@@ -0,0 +1,174 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { useTranslations } from "next-intl";
+import { Button } from "@/shared/components/ui/button";
+import { X, Printer } from "lucide-react";
+import { FocusTrap } from "@/shared/components/a11y/focus-trap";
+import { flattenLessonPlanForPrint, type ExportVariant, type PrintableLessonPlan } from "../lib/export";
+import type { LessonPlan } from "../types";
+
+interface Props {
+ plan: LessonPlan;
+ textbookTitle?: string;
+ chapterTitle?: string;
+ teacherName?: string;
+ className?: string;
+ onClose: () => void;
+}
+
+/**
+ * V5-4:打印视图组件
+ *
+ * 渲染扁平化后的 PrintableLessonPlan,提供"详细版/简洁版"切换。
+ * 点击"打印"按钮调用 window.print(),通过浏览器原生能力保存为 PDF。
+ * 使用 print: CSS 媒体查询隐藏工具栏,仅打印教学环节内容。
+ */
+export function PrintView({
+ plan,
+ textbookTitle,
+ chapterTitle,
+ teacherName,
+ className,
+ onClose,
+}: Props) {
+ const t = useTranslations("lessonPreparation");
+ const [variant, setVariant] = useState("detailed");
+
+ // V5-4:esc 关闭
+ useEffect(() => {
+ function handleEsc(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+ document.addEventListener("keydown", handleEsc);
+ return () => document.removeEventListener("keydown", handleEsc);
+ }, [onClose]);
+
+ const printable = useMemo(
+ () =>
+ flattenLessonPlanForPrint(
+ plan,
+ { textbookTitle, chapterTitle, teacherName, className },
+ variant,
+ ),
+ [plan, textbookTitle, chapterTitle, teacherName, className, variant],
+ );
+
+ function handlePrint() {
+ window.print();
+ }
+
+ return (
+
+
+
+ {/* 工具栏:print 时隐藏 */}
+
+
+
{t("export.title")}
+
+
+
+
+
+
+
+
+
+
+
+ {/* 打印内容主体 */}
+
+ {/* 页眉 */}
+
+
+ {printable.meta.planTitle}
+
+
+
+ {printable.meta.textbookTitle && `${printable.meta.textbookTitle}`}
+ {printable.meta.chapterTitle && ` · ${printable.meta.chapterTitle}`}
+
+
+ {printable.meta.teacherName && `${t("export.teacher")}: ${printable.meta.teacherName}`}
+ {printable.meta.className && ` · ${t("export.class")}: ${printable.meta.className}`}
+
+
+
+ {t("export.totalDuration", { count: printable.meta.totalDurationMin })}
+ {printable.meta.lastSavedAt &&
+ ` · ${t("export.lastSavedAt")}: ${new Date(printable.meta.lastSavedAt).toLocaleString()}`}
+
+
+
+ {/* 课文正文 */}
+ {printable.textbookContent && (
+
+
+ {t("editor.textbookContent")}
+
+
+ {printable.textbookContent}
+
+
+ )}
+
+ {/* 教学环节列表 */}
+ {printable.sections.length === 0 ? (
+
+ {t("export.empty")}
+
+ ) : (
+ printable.sections.map((section, idx) => (
+
+
+ {idx + 1}. {section.title}
+
+
+ {section.lines.length === 0 ? (
+
+ {t("export.emptySection")}
+
+ ) : (
+ section.lines.map((line, i) => (
+
+ {line}
+
+ ))
+ )}
+
+
+ ))
+ )}
+
+ {/* 页脚 */}
+
+ {t("export.footerHint", { variant: t(`export.variant${variant === "detailed" ? "Detailed" : "Concise"}`) })}
+
+
+
+
+
+ );
+}
diff --git a/src/modules/lesson-preparation/components/publish-homework-dialog.tsx b/src/modules/lesson-preparation/components/publish-homework-dialog.tsx
index 58ce6d9..a960447 100644
--- a/src/modules/lesson-preparation/components/publish-homework-dialog.tsx
+++ b/src/modules/lesson-preparation/components/publish-homework-dialog.tsx
@@ -1,24 +1,47 @@
"use client";
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
import { Button } from "@/shared/components/ui/button";
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
-import { X } from "lucide-react";
+import { X, ChevronRight, ChevronLeft, CheckCircle2 } from "lucide-react";
+import type { ExerciseItem } from "../types";
+import { isRecord } from "@/shared/lib/type-guards";
interface Props {
planId: string;
blockId: string;
classes: { id: string; name: string }[];
+ /** V5-3:题目列表(从 exercise-block 传入,用于预览步骤)*/
+ items: ExerciseItem[];
onClose: () => void;
onPublished: () => void;
}
+type Step = "select" | "preview" | "confirm";
+
+/** V5-3:从 inline 题目内容中安全提取题干预览文本 */
+function extractStemPreview(content: unknown): string {
+ if (!isRecord(content)) return "";
+ const stem = content.stem;
+ if (typeof stem === "string") return stem.slice(0, 80);
+ const text = content.text;
+ if (typeof text === "string") return text.slice(0, 80);
+ return "";
+}
+
+/** V5-3:题型 i18n 键映射 */
+function questionTypeKey(type: string): string {
+ const known = ["single_choice", "multiple_choice", "true_false", "short_answer", "essay", "text", "judgment"];
+ return known.includes(type) ? `questionBank.type.${type}` : "questionBank.type.single_choice";
+}
+
export function PublishHomeworkDialog({
planId,
blockId,
classes,
+ items,
onClose,
onPublished,
}: Props) {
@@ -26,6 +49,7 @@ export function PublishHomeworkDialog({
const ctx = useLessonPlanContextSafe();
const service = ctx?.service ?? null;
const tracker = useLessonPlanTrackerSafe();
+ const [step, setStep] = useState("select");
const [selectedClasses, setSelectedClasses] = useState([]);
const [availableAt, setAvailableAt] = useState("");
const [dueAt, setDueAt] = useState("");
@@ -41,12 +65,44 @@ export function PublishHomeworkDialog({
return () => document.removeEventListener("keydown", handleEsc);
}, [onClose]);
- async function handlePublish() {
- if (!service) return;
+ // V5-3:计算总分
+ const totalScore = useMemo(
+ () => items.reduce((sum, it) => sum + (it.score ?? 0), 0),
+ [items],
+ );
+
+ // V5-3:选中的班级数
+ const selectedClassCount = selectedClasses.length;
+
+ function handleSelectClass(classId: string) {
+ setSelectedClasses((prev) =>
+ prev.includes(classId) ? prev.filter((x) => x !== classId) : [...prev, classId],
+ );
+ }
+
+ function handleNextFromSelect() {
if (selectedClasses.length === 0) {
setError(t("publish.selectClass"));
return;
}
+ setError(null);
+ setStep("preview");
+ }
+
+ function handleNextFromPreview() {
+ setStep("confirm");
+ }
+
+ function handleBackToSelect() {
+ setStep("select");
+ }
+
+ function handleBackToPreview() {
+ setStep("preview");
+ }
+
+ async function handlePublish() {
+ if (!service) return;
setLoading(true);
setError(null);
try {
@@ -76,78 +132,190 @@ export function PublishHomeworkDialog({
}
}
+ // V5-3:步骤指示器文案
+ const stepLabel =
+ step === "select"
+ ? t("publish.step1")
+ : step === "preview"
+ ? t("publish.step2")
+ : t("publish.step3");
+ const stepNumber = step === "select" ? 1 : step === "preview" ? 2 : 3;
+
return (
-
-
{t("publish.title")}
-
-
-
-
-
-
- {classes.map((c) => (
-
- ))}
+
+
+
{t("publish.title")}
+
+ {t("publish.stepIndicator", { current: stepNumber, total: 3, label: stepLabel })}
+
+
-
-
-
setAvailableAt(e.target.value)}
- className="w-full border rounded px-2 py-1 mt-1"
- />
+
+
+ {/* 步骤 1:选班级 + 时间 */}
+ {step === "select" && (
+ <>
+
+
+
+ {classes.map((c) => (
+
+ ))}
+
+
+
+
+ setAvailableAt(e.target.value)}
+ className="w-full border rounded px-2 py-1 mt-1"
+ />
+
+
+
+ setDueAt(e.target.value)}
+ className="w-full border rounded px-2 py-1 mt-1"
+ />
+
+ {error &&
{error}
}
+ >
+ )}
+
+ {/* 步骤 2:预览题目 + 总分 + 班级 */}
+ {step === "preview" && (
+ <>
+
+
+ {t("publish.previewClassCount")}: {selectedClassCount}
+
+
+ {t("publish.previewQuestionCount")}: {items.length}
+
+
+ {t("publish.previewTotalScore")}: {totalScore}
+
+
+
+
+
+ {items.map((item, idx) => (
+ -
+
+
+
+ {t(questionTypeKey(item.inlineContent?.type ?? "single_choice"))} · {t("publish.previewSource", { source: t(`questionBank.source.${item.source}`) })}
+
+ {item.source === "inline" && item.inlineContent ? (
+
+ {extractStemPreview(item.inlineContent.content) || t("publish.previewNoStem")}
+
+ ) : (
+
+ ID: {item.questionId}
+
+ )}
+
+
+ {t("publish.previewScore", { score: item.score })}
+
+
+
+ ))}
+
+
+ >
+ )}
+
+ {/* 步骤 3:确认发布 */}
+ {step === "confirm" && (
+
+
+
+ {t("publish.confirmTitle")}
+
+
+
{t("publish.confirmClassCount", { count: selectedClassCount })}
+
{t("publish.confirmQuestionCount", { count: items.length })}
+
{t("publish.confirmTotalScore", { score: totalScore })}
+ {availableAt && (
+
{t("publish.confirmAvailableAt", { time: availableAt })}
+ )}
+ {dueAt && (
+
{t("publish.confirmDueAt", { time: dueAt })}
+ )}
+
+
+ {t("publish.confirmWarning")}
+
+ {error &&
{error}
}
+
+ )}
-
-
-
setDueAt(e.target.value)}
- className="w-full border rounded px-2 py-1 mt-1"
- />
+
+ {/* 步骤导航按钮 */}
+
+ {step === "select" && (
+ <>
+
+
+ >
+ )}
+ {step === "preview" && (
+ <>
+
+
+ >
+ )}
+ {step === "confirm" && (
+ <>
+
+
+ >
+ )}
- {error &&
{error}
}
-
-
-
-
-
diff --git a/src/modules/lesson-preparation/components/question-bank-picker.tsx b/src/modules/lesson-preparation/components/question-bank-picker.tsx
index 2297ff5..73d2d07 100644
--- a/src/modules/lesson-preparation/components/question-bank-picker.tsx
+++ b/src/modules/lesson-preparation/components/question-bank-picker.tsx
@@ -8,10 +8,11 @@ import { Button } from "@/shared/components/ui/button"
import { FocusTrap } from "@/shared/components/a11y/focus-trap"
import { QuestionBankSkeleton } from "./lesson-plan-skeleton"
import { useDebounce } from "@/shared/hooks/use-debounce"
-import { X } from "lucide-react"
+import { X, ChevronDown, ChevronRight } from "lucide-react"
import { QuestionBankFilters } from "@/shared/components/question/question-bank-filters"
import type { ExerciseItem } from "../types"
import type { QuestionType } from "@/modules/questions/types"
+import { isRecord } from "@/shared/lib/type-guards"
// 类型守卫:验证字符串是否为有效的 QuestionType(避免 as 断言)
function isQuestionType(v: string): v is QuestionType {
@@ -118,6 +119,54 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
}
}
+ // V5-6:展开题目详情(题干/选项/答案)
+ const [expandedId, setExpandedId] = useState
(null)
+
+ function toggleExpand(id: string) {
+ setExpandedId((prev) => (prev === id ? null : id))
+ }
+
+ // V5-6:从 content 中安全提取题干
+ function extractStem(content: unknown): string {
+ if (typeof content === "string") return content
+ if (isRecord(content)) {
+ const stem = content.stem
+ if (typeof stem === "string") return stem
+ const text = content.text
+ if (typeof text === "string") return text
+ }
+ return ""
+ }
+
+ // V5-6:从 content 中安全提取选项列表
+ function extractOptions(content: unknown): { label: string; text: string; isCorrect?: boolean }[] {
+ if (!isRecord(content)) return []
+ const options = content.options
+ if (!Array.isArray(options)) return []
+ return options
+ .filter((o): o is Record => isRecord(o))
+ .map((o, i) => ({
+ label: typeof o.label === "string" ? o.label : String.fromCharCode(65 + i),
+ text: typeof o.text === "string" ? o.text : "",
+ isCorrect: typeof o.isCorrect === "boolean" ? o.isCorrect : undefined,
+ }))
+ }
+
+ // V5-6:从 content 中安全提取答案
+ function extractAnswer(content: unknown): string {
+ if (!isRecord(content)) return ""
+ const answer = content.answer
+ if (typeof answer === "string") return answer
+ if (typeof answer === "number") return String(answer)
+ if (Array.isArray(answer)) {
+ return answer
+ .filter((a): a is string | number => typeof a === "string" || typeof a === "number")
+ .map((a) => String(a))
+ .join(", ")
+ }
+ return ""
+ }
+
return (
) : (
- {questions.map((q) => (
-
- {previewText(q.content)}
-
- {t(`questionBank.type.${q.type}`)} · {t("questionBank.difficulty", { level: q.difficulty })}
-
-
-
- ))}
+ {questions.map((q) => {
+ const isExpanded = expandedId === q.id
+ const stem = extractStem(q.content)
+ const options = extractOptions(q.content)
+ const answer = extractAnswer(q.content)
+ return (
+
+
+
+
+ {t(`questionBank.type.${q.type}`)} · {t("questionBank.difficulty", { level: q.difficulty })}
+
+
+
+ {isExpanded && (
+
+ {/* V5-6:题干 */}
+ {stem && (
+
+
+ {t("questionBank.stemLabel")}
+
+
{stem}
+
+ )}
+ {/* V5-6:选项 */}
+ {options.length > 0 && (
+
+
+ {t("questionBank.optionsLabel")}
+
+
+ {options.map((opt, i) => (
+ -
+ {opt.label}.
+ {opt.text}
+ {opt.isCorrect && (
+ ✓
+ )}
+
+ ))}
+
+
+ )}
+ {/* V5-6:答案 */}
+ {answer && (
+
+
+ {t("questionBank.correctAnswer")}
+
+
{answer}
+
+ )}
+ {!stem && options.length === 0 && !answer && (
+
+ {t("questionBank.noDetail")}
+
+ )}
+
+ )}
+
+ )
+ })}
)}
diff --git a/src/modules/lesson-preparation/components/schedule-dialog.tsx b/src/modules/lesson-preparation/components/schedule-dialog.tsx
new file mode 100644
index 0000000..868ffb0
--- /dev/null
+++ b/src/modules/lesson-preparation/components/schedule-dialog.tsx
@@ -0,0 +1,273 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import { X, Calendar, Trash2, Plus } from "lucide-react";
+import { Button } from "@/shared/components/ui/button";
+import { FocusTrap } from "@/shared/components/a11y/focus-trap";
+import { toast } from "sonner";
+import {
+ getLessonPlanSchedulesAction,
+ createLessonPlanScheduleAction,
+ deleteLessonPlanScheduleAction,
+} from "../actions-schedules";
+import type { LessonPlanScheduleRecord } from "../data-access-schedules";
+
+interface ScheduleOption {
+ id: string;
+ name: string;
+}
+
+interface Props {
+ planId: string;
+ classes: ScheduleOption[];
+ onClose: () => void;
+ onScheduled?: () => void;
+}
+
+/**
+ * V5-7:课时绑定对话框
+ *
+ * 功能:
+ * 1. 列出课案已绑定的课时
+ * 2. 添加新课时绑定(选班级 + 日期 + 节次 + 时长)
+ * 3. 删除课时绑定
+ *
+ * 不通过 service 注入,直接调用 actions(因为是新增功能,且仅教师使用)。
+ */
+export function ScheduleDialog({ planId, classes, onClose, onScheduled }: Props) {
+ const t = useTranslations("lessonPreparation");
+ const [schedules, setSchedules] = useState
([]);
+ const [loading, setLoading] = useState(true);
+ const [submitting, setSubmitting] = useState(false);
+
+ // 表单状态
+ const [classId, setClassId] = useState("");
+ const [scheduledDate, setScheduledDate] = useState("");
+ const [period, setPeriod] = useState(1);
+ const [durationMin, setDurationMin] = useState(40);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ void loadSchedules();
+ }, [planId]);
+
+ useEffect(() => {
+ function handleEsc(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+ document.addEventListener("keydown", handleEsc);
+ return () => document.removeEventListener("keydown", handleEsc);
+ }, [onClose]);
+
+ async function loadSchedules() {
+ setLoading(true);
+ try {
+ const res = await getLessonPlanSchedulesAction(planId);
+ if (res.success && res.data) {
+ setSchedules(res.data.items);
+ }
+ } catch (e) {
+ console.error("[ScheduleDialog] load failed", e);
+ toast.error(t("error.getOne"));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleAdd() {
+ if (!classId) {
+ setError(t("schedule.selectClass"));
+ return;
+ }
+ if (!scheduledDate) {
+ setError(t("schedule.selectDate"));
+ return;
+ }
+ setSubmitting(true);
+ setError(null);
+ try {
+ const res = await createLessonPlanScheduleAction({
+ planId,
+ classId,
+ scheduledDate,
+ period,
+ durationMin,
+ });
+ if (res.success) {
+ toast.success(t("schedule.addSuccess"));
+ void loadSchedules();
+ onScheduled?.();
+ } else {
+ setError(res.message ?? t("error.save"));
+ }
+ } catch (e) {
+ console.error("[ScheduleDialog] add failed", e);
+ setError(t("error.save"));
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ async function handleDelete(id: string) {
+ try {
+ const res = await deleteLessonPlanScheduleAction(id);
+ if (res.success) {
+ toast.success(t("schedule.deleteSuccess"));
+ void loadSchedules();
+ onScheduled?.();
+ } else {
+ toast.error(res.message ?? t("error.save"));
+ }
+ } catch (e) {
+ console.error("[ScheduleDialog] delete failed", e);
+ toast.error(t("error.save"));
+ }
+ }
+
+ return (
+
+
+
+
+
+
+ {t("schedule.title")}
+
+
+
+
+
+ {/* 已绑定课时列表 */}
+
+
+ {loading ? (
+
+ {t("version.loading")}
+
+ ) : schedules.length === 0 ? (
+
+ {t("schedule.empty")}
+
+ ) : (
+
+ {schedules.map((s) => (
+ -
+
+ {s.className}
+
+ {s.scheduledDate} · {t("schedule.period", { n: s.period })} · {t("schedule.duration", { n: s.durationMin })}
+
+
+
+
+ ))}
+
+ )}
+
+
+ {/* 添加新绑定 */}
+
+
+
+
+
+
+
+
+
+ setScheduledDate(e.target.value)}
+ className="w-full border rounded px-2 py-1 mt-0.5 text-sm"
+ />
+
+
+
+
+
+
+
+ setDurationMin(Number(e.target.value))}
+ className="w-full border rounded px-2 py-1 mt-0.5 text-sm"
+ />
+
+
+ {error &&
{error}
}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/modules/lesson-preparation/components/template-picker.tsx b/src/modules/lesson-preparation/components/template-picker.tsx
index 4f61b2f..2b567af 100644
--- a/src/modules/lesson-preparation/components/template-picker.tsx
+++ b/src/modules/lesson-preparation/components/template-picker.tsx
@@ -9,9 +9,39 @@ import type { TextbookPickerOption, ChapterPickerOption } from "../providers/les
import { Button } from "@/shared/components/ui/button";
import { cn } from "@/shared/lib/utils";
import { SYSTEM_TEMPLATES } from "../constants";
-import { Book, ChevronRight, FileText, Loader2 } from "lucide-react";
+import { Book, ChevronRight, FileText, Loader2, Search, Clock } from "lucide-react";
import type { LessonPlanTemplate } from "../types";
+/** V5-9:localStorage 中最近使用教材的存储键 */
+const RECENT_TEXTBOOKS_KEY = "lesson-prep:recent-textbooks";
+const MAX_RECENT = 5;
+
+/** 读取最近使用教材 ID 列表 */
+function readRecentTextbookIds(): string[] {
+ if (typeof window === "undefined") return [];
+ try {
+ const raw = window.localStorage.getItem(RECENT_TEXTBOOKS_KEY);
+ if (!raw) return [];
+ const arr = JSON.parse(raw);
+ if (!Array.isArray(arr)) return [];
+ return arr.filter((x): x is string => typeof x === "string").slice(0, MAX_RECENT);
+ } catch {
+ return [];
+ }
+}
+
+/** 写入最近使用教材 ID 列表(新选择放在最前,去重,最多 MAX_RECENT 条) */
+function writeRecentTextbookId(id: string): void {
+ if (typeof window === "undefined") return;
+ try {
+ const prev = readRecentTextbookIds();
+ const next = [id, ...prev.filter((x) => x !== id)].slice(0, MAX_RECENT);
+ window.localStorage.setItem(RECENT_TEXTBOOKS_KEY, JSON.stringify(next));
+ } catch {
+ // localStorage 不可用时静默失败
+ }
+}
+
export function TemplatePicker() {
const t = useTranslations("lessonPreparation");
const router = useRouter();
@@ -33,6 +63,42 @@ export function TemplatePicker() {
const [loadingTextbooks, setLoadingTextbooks] = useState(true);
// P1-6:个人模板
const [personalTemplates, setPersonalTemplates] = useState([]);
+ // V5-9:教材搜索 + 最近使用
+ const [searchQuery, setSearchQuery] = useState("");
+ const [recentIds, setRecentIds] = useState([]);
+
+ // V5-9:客户端挂载后读取最近使用教材
+ useEffect(() => {
+ setRecentIds(readRecentTextbookIds());
+ }, []);
+
+ // V5-9:客户端模糊搜索过滤教材(标题/学科/年级/出版社)
+ const filteredTextbooks = useMemo(() => {
+ if (!searchQuery.trim()) return textbooks;
+ const q = searchQuery.trim().toLowerCase();
+ return textbooks.filter((tb) => {
+ const title = (tb.title ?? "").toLowerCase();
+ const subject = (tb.subject ?? "").toLowerCase();
+ const grade = (tb.grade ?? "").toLowerCase();
+ return title.includes(q) || subject.includes(q) || grade.includes(q);
+ });
+ }, [textbooks, searchQuery]);
+
+ // V5-9:最近使用的教材(按 recentIds 顺序,且仍存在于 textbooks 列表中)
+ const recentTextbooks = useMemo(() => {
+ if (recentIds.length === 0) return [];
+ return recentIds
+ .map((id) => textbooks.find((tb) => tb.id === id))
+ .filter((tb): tb is TextbookPickerOption => tb !== undefined);
+ }, [recentIds, textbooks]);
+
+ // V5-9:选择教材时记录到 localStorage
+ const handleTextbookSelect = useCallback((id: string) => {
+ setTextbookId(id);
+ setChapterId("");
+ if (id) writeRecentTextbookId(id);
+ setRecentIds(readRecentTextbookIds());
+ }, []);
// 派生:当前教材的章节是否正在加载
const loadingChapters = !!textbookId && textbookId !== loadedTextbookId;
@@ -176,24 +242,70 @@ export function TemplatePicker() {
) : textbooks.length === 0 ? (
{t("picker.noTextbooks")}
) : (
-
+
+ {/* V5-9:搜索框 */}
+
+
+ setSearchQuery(e.target.value)}
+ placeholder={t("picker.searchTextbookPlaceholder")}
+ className="w-full border border-outline-variant rounded-lg pl-8 pr-3 py-2 bg-surface text-sm"
+ aria-label={t("picker.searchTextbookLabel")}
+ />
+
+
+ {/* V5-9:最近使用教材(无搜索词时显示) */}
+ {!searchQuery.trim() && recentTextbooks.length > 0 && (
+
+
+
+ {t("picker.recentSection")}
+
+
+ {recentTextbooks.map((tb) => (
+
+ ))}
+
+
+ )}
+
+ {/* 教材下拉框(过滤后) */}
+
+ {searchQuery.trim() && filteredTextbooks.length === 0 && (
+
+ {t("picker.searchEmpty")}
+
+ )}
+
)}
diff --git a/src/modules/lesson-preparation/components/version-diff-view.tsx b/src/modules/lesson-preparation/components/version-diff-view.tsx
new file mode 100644
index 0000000..f903258
--- /dev/null
+++ b/src/modules/lesson-preparation/components/version-diff-view.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+import { useMemo } from "react";
+import { useTranslations } from "next-intl";
+import { Plus, Minus, Pencil, Check } from "lucide-react";
+import type { LessonPlanDocument } from "../types";
+import { diffDocuments, hasChanges, type NodeDiff } from "../lib/version-diff";
+import { getNodeColor } from "../lib/node-summary";
+import { cn } from "@/shared/lib/utils";
+
+interface Props {
+ oldDoc: LessonPlanDocument;
+ newDoc: LessonPlanDocument;
+ /** 旧版本号(用于标题展示) */
+ oldVersionNo?: number;
+}
+
+/**
+ * V5-16 T2:版本对比视图。
+ *
+ * 以列表形式展示两个版本文档的节点级差异,
+ * 支持 added/removed/modified/unchanged 四种状态标记。
+ */
+export function VersionDiffView({ oldDoc, newDoc, oldVersionNo }: Props) {
+ const t = useTranslations("lessonPreparation");
+
+ const result = useMemo(() => diffDocuments(oldDoc, newDoc), [oldDoc, newDoc]);
+ const changed = hasChanges(result);
+
+ return (
+
+ {/* 摘要条 */}
+
+
+ {t("diff.summary", {
+ added: result.summary.added,
+ removed: result.summary.removed,
+ modified: result.summary.modified,
+ })}
+
+ {oldVersionNo !== undefined && (
+
+ {t("diff.comparing", { version: oldVersionNo })}
+
+ )}
+
+
+ {!changed ? (
+
+ {t("diff.noChanges")}
+
+ ) : (
+
+ {result.diffs
+ .filter((d) => d.type !== "unchanged")
+ .map((d, idx) => (
+
+ ))}
+
+ )}
+
+ {/* unchanged 计数(折叠) */}
+ {result.summary.unchanged > 0 && (
+
+
+ {t("diff.unchangedCount", { count: result.summary.unchanged })}
+
+ )}
+
+ );
+}
+
+function DiffItem({
+ diff,
+ t,
+}: {
+ diff: NodeDiff;
+ t: ReturnType;
+}) {
+ const node = diff.newNode ?? diff.oldNode;
+ const color = node ? getNodeColor(node.type) : "#999";
+ const title = node?.title || node?.type || "";
+
+ const icon = {
+ added: ,
+ removed: ,
+ modified: ,
+ unchanged: ,
+ }[diff.type];
+
+ const label = t(`diff.${diff.type}`);
+
+ return (
+
+
+ {icon}
+
+
+
+ {label}
+
+ {title}
+
+ {diff.type === "modified" && diff.changedFields && diff.changedFields.length > 0 && (
+
+ {t("diff.changedFields")}:{" "}
+ {diff.changedFields.map((f) => t(`diff.field.${f}`)).join(", ")}
+
+ )}
+
+
+ );
+}
diff --git a/src/modules/lesson-preparation/components/version-history-drawer.tsx b/src/modules/lesson-preparation/components/version-history-drawer.tsx
index b1b1f4f..6ad4e2b 100644
--- a/src/modules/lesson-preparation/components/version-history-drawer.tsx
+++ b/src/modules/lesson-preparation/components/version-history-drawer.tsx
@@ -19,13 +19,16 @@ import {
AlertDialogTrigger,
} from "@/shared/components/ui/alert-dialog";
import { formatDateTime } from "@/shared/lib/utils";
-import type { LessonPlanVersion } from "../types";
+import { VersionDiffView } from "./version-diff-view";
+import type { LessonPlanDocument, LessonPlanVersion } from "../types";
interface Props {
open: boolean;
onClose: () => void;
planId: string;
onReverted: () => void;
+ /** V5-16:当前文档(用于版本对比) */
+ currentDoc?: LessonPlanDocument;
}
export function VersionHistoryDrawer({
@@ -33,6 +36,7 @@ export function VersionHistoryDrawer({
onClose,
planId,
onReverted,
+ currentDoc,
}: Props) {
const t = useTranslations("lessonPreparation");
const ctx = useLessonPlanContextSafe();
@@ -40,6 +44,8 @@ export function VersionHistoryDrawer({
const tracker = useLessonPlanTrackerSafe();
const [versions, setVersions] = useState([]);
const [loading, setLoading] = useState(false);
+ /** V5-16:当前正在对比的版本(null 表示显示列表) */
+ const [comparingVersion, setComparingVersion] = useState(null);
// P1-1 修复:ESC 键关闭抽屉(open 时才监听)
useEffect(() => {
@@ -97,56 +103,93 @@ export function VersionHistoryDrawer({
- {t("version.title")}
- {loading ? (
-
- ) : versions.length === 0 ? (
- {t("version.empty")}
- ) : (
-
- {versions.map((v) => (
-
+
+
+ {t("diff.comparing", { version: comparingVersion.versionNo })}
+
+
- ))}
+ {t("diff.back")}
+
+
+
+ ) : (
+ <>
+ {t("version.title")}
+ {loading ? (
+
+ ) : versions.length === 0 ? (
+ {t("version.empty")}
+ ) : (
+
+ {versions.map((v) => (
+
+
+ v{v.versionNo}
+ {v.isAuto && (
+
+ {t("version.auto")}
+
+ )}
+
+
+ {v.label ?? t("version.manual")}
+
+
+ {formatDateTime(v.createdAt)}
+
+
+ {/* V5-16 T2:对比当前按钮 */}
+ {currentDoc && (
+
setComparingVersion(v)}
+ >
+ {t("diff.compareWithCurrent")}
+
+ )}
+
+
+
+ {t("version.revert")}
+
+
+
+
+ {t("version.revertTitle")}
+
+ {t("version.revertConfirm", { versionNo: v.versionNo })}
+
+
+
+ {t("action.cancel")}
+ handleRevert(v.versionNo)}>
+ {t("action.confirm")}
+
+
+
+
+
+
+ ))}
+
+ )}
+ >
)}
diff --git a/src/modules/lesson-preparation/config/block-registry.tsx b/src/modules/lesson-preparation/config/block-registry.tsx
index 7cc8ed1..77cec5b 100644
--- a/src/modules/lesson-preparation/config/block-registry.tsx
+++ b/src/modules/lesson-preparation/config/block-registry.tsx
@@ -41,6 +41,8 @@ export interface BlockRenderProps {
textbookId?: string;
chapterId?: string;
classes?: { id: string; name: string }[];
+ /** V5-5:当前课案 ID(仅 RichTextBlock 用于素材库 picker 关联) */
+ planId?: string;
onUpdate: (data: BlockData) => void;
}
@@ -190,6 +192,8 @@ export function BlockRenderer(props: BlockRenderProps & { type: BlockType }): Re
data={rest.data}
textbookId={rest.textbookId}
chapterId={rest.chapterId}
+ planId={rest.planId}
+ blockId={rest.blockId}
onUpdate={(d) => rest.onUpdate(d)}
/>
);
diff --git a/src/modules/lesson-preparation/data-access-schedules.ts b/src/modules/lesson-preparation/data-access-schedules.ts
new file mode 100644
index 0000000..c3394b5
--- /dev/null
+++ b/src/modules/lesson-preparation/data-access-schedules.ts
@@ -0,0 +1,181 @@
+/**
+ * V5-7:课案-课时绑定数据访问层
+ *
+ * 将课案绑定到具体班级的某个日期/节次,支持 calendar-view 安排课时。
+ */
+import "server-only";
+import { db } from "@/shared/db";
+import { lessonPlanSchedules, classes } from "@/shared/db/schema";
+import { and, eq, gte, lte, desc } from "drizzle-orm";
+import { createId } from "@paralleldrive/cuid2";
+
+/** 课时绑定记录 */
+export interface LessonPlanScheduleRecord {
+ id: string;
+ planId: string;
+ classId: string;
+ className: string;
+ scheduledDate: string; // YYYY-MM-DD
+ period: number;
+ classScheduleId?: string | null;
+ durationMin: number;
+ createdBy: string;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+/** 将 Date 转换为 YYYY-MM-DD 字符串(用于接口输出) */
+function toDateStr(d: Date): string {
+ const y = d.getFullYear();
+ const m = String(d.getMonth() + 1).padStart(2, "0");
+ const day = String(d.getDate()).padStart(2, "0");
+ return `${y}-${m}-${day}`;
+}
+
+/** 查询课案的所有课时绑定 */
+export async function getSchedulesByPlanId(
+ planId: string,
+): Promise {
+ const rows = await db
+ .select({
+ id: lessonPlanSchedules.id,
+ planId: lessonPlanSchedules.planId,
+ classId: lessonPlanSchedules.classId,
+ className: classes.name,
+ scheduledDate: lessonPlanSchedules.scheduledDate,
+ period: lessonPlanSchedules.period,
+ classScheduleId: lessonPlanSchedules.classScheduleId,
+ durationMin: lessonPlanSchedules.durationMin,
+ createdBy: lessonPlanSchedules.createdBy,
+ createdAt: lessonPlanSchedules.createdAt,
+ updatedAt: lessonPlanSchedules.updatedAt,
+ })
+ .from(lessonPlanSchedules)
+ .leftJoin(classes, eq(lessonPlanSchedules.classId, classes.id))
+ .where(eq(lessonPlanSchedules.planId, planId))
+ .orderBy(desc(lessonPlanSchedules.scheduledDate));
+
+ return rows.map((r) => ({
+ id: r.id,
+ planId: r.planId,
+ classId: r.classId,
+ className: r.className ?? "",
+ scheduledDate: toDateStr(r.scheduledDate),
+ period: r.period,
+ classScheduleId: r.classScheduleId,
+ durationMin: r.durationMin,
+ createdBy: r.createdBy,
+ createdAt: r.createdAt,
+ updatedAt: r.updatedAt,
+ }));
+}
+
+/** 查询教师在某日期范围内的课时绑定 */
+export async function getSchedulesByDateRange(
+ teacherPlanIds: string[],
+ startDate: string,
+ endDate: string,
+): Promise {
+ if (teacherPlanIds.length === 0) return [];
+ const rows = await db
+ .select({
+ id: lessonPlanSchedules.id,
+ planId: lessonPlanSchedules.planId,
+ classId: lessonPlanSchedules.classId,
+ className: classes.name,
+ scheduledDate: lessonPlanSchedules.scheduledDate,
+ period: lessonPlanSchedules.period,
+ classScheduleId: lessonPlanSchedules.classScheduleId,
+ durationMin: lessonPlanSchedules.durationMin,
+ createdBy: lessonPlanSchedules.createdBy,
+ createdAt: lessonPlanSchedules.createdAt,
+ updatedAt: lessonPlanSchedules.updatedAt,
+ })
+ .from(lessonPlanSchedules)
+ .leftJoin(classes, eq(lessonPlanSchedules.classId, classes.id))
+ .where(
+ and(
+ // 简化:仅按日期范围过滤(planId 过滤由调用方处理或 join)
+ gte(lessonPlanSchedules.scheduledDate, new Date(startDate)),
+ lte(lessonPlanSchedules.scheduledDate, new Date(endDate)),
+ ),
+ )
+ .orderBy(desc(lessonPlanSchedules.scheduledDate));
+
+ return rows
+ .filter((r) => teacherPlanIds.includes(r.planId))
+ .map((r) => ({
+ id: r.id,
+ planId: r.planId,
+ classId: r.classId,
+ className: r.className ?? "",
+ scheduledDate: toDateStr(r.scheduledDate),
+ period: r.period,
+ classScheduleId: r.classScheduleId,
+ durationMin: r.durationMin,
+ createdBy: r.createdBy,
+ createdAt: r.createdAt,
+ updatedAt: r.updatedAt,
+ }));
+}
+
+/** 创建课时绑定 */
+export async function createSchedule(input: {
+ planId: string;
+ classId: string;
+ scheduledDate: string;
+ period: number;
+ classScheduleId?: string;
+ durationMin?: number;
+ createdBy: string;
+}): Promise {
+ const newId = createId();
+ await db.insert(lessonPlanSchedules).values({
+ id: newId,
+ planId: input.planId,
+ classId: input.classId,
+ scheduledDate: new Date(input.scheduledDate),
+ period: input.period,
+ classScheduleId: input.classScheduleId,
+ durationMin: input.durationMin ?? 40,
+ createdBy: input.createdBy,
+ });
+
+ const created = await db
+ .select({
+ id: lessonPlanSchedules.id,
+ planId: lessonPlanSchedules.planId,
+ classId: lessonPlanSchedules.classId,
+ className: classes.name,
+ scheduledDate: lessonPlanSchedules.scheduledDate,
+ period: lessonPlanSchedules.period,
+ classScheduleId: lessonPlanSchedules.classScheduleId,
+ durationMin: lessonPlanSchedules.durationMin,
+ createdBy: lessonPlanSchedules.createdBy,
+ createdAt: lessonPlanSchedules.createdAt,
+ updatedAt: lessonPlanSchedules.updatedAt,
+ })
+ .from(lessonPlanSchedules)
+ .leftJoin(classes, eq(lessonPlanSchedules.classId, classes.id))
+ .where(eq(lessonPlanSchedules.id, newId));
+
+ const r = created[0]!;
+ return {
+ id: r.id,
+ planId: r.planId,
+ classId: r.classId,
+ className: r.className ?? "",
+ scheduledDate: toDateStr(r.scheduledDate),
+ period: r.period,
+ classScheduleId: r.classScheduleId,
+ durationMin: r.durationMin,
+ createdBy: r.createdBy,
+ createdAt: r.createdAt,
+ updatedAt: r.updatedAt,
+ };
+}
+
+/** 删除课时绑定 */
+export async function deleteSchedule(id: string): Promise {
+ await db.delete(lessonPlanSchedules).where(eq(lessonPlanSchedules.id, id));
+}
diff --git a/src/modules/lesson-preparation/hooks/editor-slice.ts b/src/modules/lesson-preparation/hooks/editor-slice.ts
index 51542de..70a10e3 100644
--- a/src/modules/lesson-preparation/hooks/editor-slice.ts
+++ b/src/modules/lesson-preparation/hooks/editor-slice.ts
@@ -14,6 +14,7 @@ import type {
TextbookContentNodeData,
} from "../types";
import { defaultDataForType } from "../lib/document-migration";
+import { computeAutoLayout } from "../lib/auto-layout";
import type { EditorState } from "./use-lesson-plan-editor";
export interface EditorSlice {
@@ -40,6 +41,8 @@ export interface EditorSlice {
connect: (source: string, target: string) => void;
disconnect: (edgeId: string) => void;
setEdges: (edges: AnyLessonPlanEdge[]) => void;
+ /** V5-8:自动布局,使用 dagre 计算并应用新位置 */
+ autoLayout: (direction?: "TB" | "LR") => void;
}
function reindex(nodes: LessonPlanNode[]): LessonPlanNode[] {
@@ -62,12 +65,16 @@ export const createEditorSlice: StateCreator<
anchors: [],
},
- setTitle: (title) => set({ title, isDirty: true }),
+ setTitle: (title) => {
+ get().pushHistory();
+ set({ title, isDirty: true });
+ },
setPlanId: (planId) => set({ planId }),
addNode: (type, position, title) => {
const id = createId();
const state = get();
+ state.pushHistory(); // V5-2:撤销/重做
const teachingNodes = state.doc.nodes.filter(
(n): n is LessonPlanNode => n.type !== "textbook_content",
);
@@ -91,7 +98,8 @@ export const createEditorSlice: StateCreator<
return id;
},
- updateNode: (id, patch) =>
+ updateNode: (id, patch) => {
+ get().pushHistory(); // V5-2:撤销/重做
set((s) => ({
doc: {
...s.doc,
@@ -104,9 +112,12 @@ export const createEditorSlice: StateCreator<
),
},
isDirty: true,
- })),
+ }));
+ },
- updateNodePosition: (id, position) =>
+ updateNodePosition: (id, position) => {
+ // V5-2 注:拖拽过程中会产生大量 position 更新,仅在拖拽开始时推一次 history。
+ // 调用方应在 onDragStart 时调用 pushHistory,此处不重复推入。
set((s) => ({
doc: {
...s.doc,
@@ -119,9 +130,11 @@ export const createEditorSlice: StateCreator<
),
},
isDirty: true,
- })),
+ }));
+ },
- removeNode: (id) =>
+ removeNode: (id) => {
+ get().pushHistory(); // V5-2:撤销/重做
set((s) => {
const remainingTeachingNodes = reindex(
s.doc.nodes.filter(
@@ -146,9 +159,11 @@ export const createEditorSlice: StateCreator<
isDirty: true,
selectedNodeId: s.selectedNodeId === id ? null : s.selectedNodeId,
};
- }),
+ });
+ },
- updateTextbookContent: (data) =>
+ updateTextbookContent: (data) => {
+ get().pushHistory(); // V5-2:撤销/重做
set((s) => ({
doc: {
...s.doc,
@@ -159,7 +174,8 @@ export const createEditorSlice: StateCreator<
),
},
isDirty: true,
- })),
+ }));
+ },
getTextbookContentNode: () => {
const state = get();
@@ -171,6 +187,7 @@ export const createEditorSlice: StateCreator<
addAnchor: ({ nodeId, type, start, end, textPreview }) => {
const anchorId = createId();
const state = get();
+ state.pushHistory(); // V5-2:撤销/重做
const textbookNodeId = state.doc.textbookContentNodeId;
const anchor: NodeAnchor = {
@@ -202,7 +219,8 @@ export const createEditorSlice: StateCreator<
return anchorId;
},
- removeAnchor: (anchorId) =>
+ removeAnchor: (anchorId) => {
+ get().pushHistory(); // V5-2:撤销/重做
set((s) => ({
doc: {
...s.doc,
@@ -212,9 +230,11 @@ export const createEditorSlice: StateCreator<
),
},
isDirty: true,
- })),
+ }));
+ },
- updateAnchor: (anchorId, patch) =>
+ updateAnchor: (anchorId, patch) => {
+ get().pushHistory(); // V5-2:撤销/重做
set((s) => ({
doc: {
...s.doc,
@@ -223,9 +243,11 @@ export const createEditorSlice: StateCreator<
),
},
isDirty: true,
- })),
+ }));
+ },
- connect: (source, target) =>
+ connect: (source, target) => {
+ get().pushHistory(); // V5-2:撤销/重做
set((s) => {
if (
s.doc.edges.some((e) => e.source === source && e.target === target)
@@ -241,17 +263,43 @@ export const createEditorSlice: StateCreator<
doc: { ...s.doc, edges: [...s.doc.edges, edge] },
isDirty: true,
};
- }),
+ });
+ },
- disconnect: (edgeId) =>
+ disconnect: (edgeId) => {
+ get().pushHistory(); // V5-2:撤销/重做
set((s) => ({
doc: {
...s.doc,
edges: s.doc.edges.filter((e) => e.id !== edgeId),
},
isDirty: true,
- })),
+ }));
+ },
- setEdges: (edges) =>
- set((s) => ({ doc: { ...s.doc, edges }, isDirty: true })),
+ setEdges: (edges) => {
+ get().pushHistory(); // V5-2:撤销/重做
+ set((s) => ({ doc: { ...s.doc, edges }, isDirty: true }));
+ },
+
+ autoLayout: (direction = "TB") => {
+ const state = get();
+ const positions = computeAutoLayout(state.doc.nodes, state.doc.edges, { direction });
+ if (positions.size === 0) return;
+ state.pushHistory(); // V5-2:撤销/重做
+ set((s) => ({
+ doc: {
+ ...s.doc,
+ nodes: s.doc.nodes.map((n) => {
+ const p = positions.get(n.id);
+ return p
+ ? n.type === "textbook_content"
+ ? ({ ...n, position: p } as TextbookContentNode)
+ : ({ ...n, position: p } as LessonPlanNode)
+ : n;
+ }),
+ },
+ isDirty: true,
+ }));
+ },
});
diff --git a/src/modules/lesson-preparation/hooks/history-slice.ts b/src/modules/lesson-preparation/hooks/history-slice.ts
new file mode 100644
index 0000000..1a0d8e8
--- /dev/null
+++ b/src/modules/lesson-preparation/hooks/history-slice.ts
@@ -0,0 +1,79 @@
+import type { StateCreator } from "zustand";
+import type { LessonPlanDocument } from "../types";
+import type { EditorState } from "./use-lesson-plan-editor";
+
+/** V5-2:撤销/重做栈上限(用户要求至少 50 步) */
+export const MAX_HISTORY = 50;
+
+/** 历史快照(仅记录 doc + title,避免完整 state 序列化) */
+export interface HistorySnapshot {
+ doc: LessonPlanDocument;
+ title: string;
+}
+
+export interface HistorySlice {
+ past: HistorySnapshot[];
+ future: HistorySnapshot[];
+ canUndo: () => boolean;
+ canRedo: () => boolean;
+ /** 在 mutation 之前调用:把当前 doc+title 推入 past,清空 future */
+ pushHistory: () => void;
+ undo: () => void;
+ redo: () => void;
+ /** hydrate/replaceDoc 时清空历史,避免跨课案污染 */
+ clearHistory: () => void;
+}
+
+export const createHistorySlice: StateCreator<
+ EditorState,
+ [],
+ [],
+ HistorySlice
+> = (set, get) => ({
+ past: [],
+ future: [],
+
+ canUndo: () => get().past.length > 0,
+ canRedo: () => get().future.length > 0,
+
+ pushHistory: () => {
+ const state = get();
+ const snapshot: HistorySnapshot = { doc: state.doc, title: state.title };
+ set((s) => ({
+ past: [...s.past, snapshot].slice(-MAX_HISTORY),
+ future: [], // 新动作清空 redo 栈
+ }));
+ },
+
+ undo: () => {
+ const state = get();
+ if (state.past.length === 0) return;
+ const previous = state.past[state.past.length - 1]!;
+ const current: HistorySnapshot = { doc: state.doc, title: state.title };
+ set((s) => ({
+ doc: previous.doc,
+ title: previous.title,
+ past: s.past.slice(0, -1),
+ future: [current, ...s.future].slice(0, MAX_HISTORY),
+ isDirty: true,
+ selectedNodeId: null, // 撤销后重置选中节点,避免悬空引用
+ }));
+ },
+
+ redo: () => {
+ const state = get();
+ if (state.future.length === 0) return;
+ const next = state.future[0]!;
+ const current: HistorySnapshot = { doc: state.doc, title: state.title };
+ set((s) => ({
+ doc: next.doc,
+ title: next.title,
+ past: [...s.past, current].slice(-MAX_HISTORY),
+ future: s.future.slice(1),
+ isDirty: true,
+ selectedNodeId: null,
+ }));
+ },
+
+ clearHistory: () => set({ past: [], future: [] }),
+});
diff --git a/src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts b/src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts
index 7769b8c..5c15cc2 100644
--- a/src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts
+++ b/src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts
@@ -4,22 +4,26 @@ import { create } from "zustand";
import { createEditorSlice, type EditorSlice } from "./editor-slice";
import { createSelectionSlice, type SelectionSlice } from "./selection-slice";
import { createVersionSlice, type VersionSlice } from "./version-slice";
+import { createHistorySlice, type HistorySlice } from "./history-slice";
/**
* V4 P2-6 修复:将单体 Zustand store(原 303 行)拆分为 3 个独立 slice。
+ * V5-2 新增:history-slice 撤销/重做栈(50 步上限)。
*
* - editor-slice: 文档结构(planId/title/doc)及所有文档操作方法
* - selection-slice: 节点选中状态(selectedNodeId / selectNode)
- * - version-slice: 草稿版本与保存状态(isDirty/isSaving/lastSavedAt/hydrate/markSaved/replaceDoc)
+ * - version-slice: 草稿版本与保存状态(isDirty/isSaving/lastSavedAt/saveError/isOnline)
+ * - history-slice: 撤销/重做栈(past/future/canUndo/canRedo/undo/redo)
*
* 主文件仅负责组合 slice 并导出统一的 EditorState 类型,方便测试与维护。
* 各 slice 通过 `import type { EditorState }` 引用合并后的类型,TypeScript
* 编译后该类型导入会被完全移除,运行时无循环依赖。
*/
-export type EditorState = EditorSlice & SelectionSlice & VersionSlice;
+export type EditorState = EditorSlice & SelectionSlice & VersionSlice & HistorySlice;
export const useLessonPlanEditor = create()((...a) => ({
...createEditorSlice(...a),
...createSelectionSlice(...a),
...createVersionSlice(...a),
+ ...createHistorySlice(...a),
}));
diff --git a/src/modules/lesson-preparation/hooks/version-slice.ts b/src/modules/lesson-preparation/hooks/version-slice.ts
index 6fb2708..f9ff05e 100644
--- a/src/modules/lesson-preparation/hooks/version-slice.ts
+++ b/src/modules/lesson-preparation/hooks/version-slice.ts
@@ -6,10 +6,18 @@ export interface VersionSlice {
isDirty: boolean;
isSaving: boolean;
lastSavedAt: number | null;
+ /** V5-1:自动保存失败标记,供 UI 显示兜底提示与重试按钮 */
+ saveError: boolean;
+ /** V5-1:网络在线状态,供 UI 显示断网提示 */
+ isOnline: boolean;
hydrate: (planId: string, title: string, doc: LessonPlanDocument) => void;
markSaved: () => void;
setSaving: (saving: boolean) => void;
replaceDoc: (doc: LessonPlanDocument) => void;
+ /** V5-1:标记保存失败/成功 */
+ setSaveError: (hasError: boolean) => void;
+ /** V5-1:更新在线状态(监听 window online/offline 事件) */
+ setOnline: (online: boolean) => void;
}
export const createVersionSlice: StateCreator<
@@ -21,6 +29,8 @@ export const createVersionSlice: StateCreator<
isDirty: false,
isSaving: false,
lastSavedAt: null,
+ saveError: false,
+ isOnline: true,
hydrate: (planId, title, doc) =>
set({
@@ -30,9 +40,14 @@ export const createVersionSlice: StateCreator<
isDirty: false,
lastSavedAt: Date.now(),
selectedNodeId: null,
+ saveError: false,
+ past: [],
+ future: [], // V5-2:hydrate 时清空历史,避免跨课案污染
}),
- markSaved: () => set({ isDirty: false, lastSavedAt: Date.now() }),
+ markSaved: () => set({ isDirty: false, lastSavedAt: Date.now(), saveError: false }),
setSaving: (saving) => set({ isSaving: saving }),
- replaceDoc: (doc) => set({ doc, isDirty: false }),
+ replaceDoc: (doc) => set({ doc, isDirty: false, saveError: false, past: [], future: [] }),
+ setSaveError: (hasError) => set({ saveError: hasError }),
+ setOnline: (online) => set({ isOnline: online }),
});
diff --git a/src/modules/lesson-preparation/lib/ai-differentiation.ts b/src/modules/lesson-preparation/lib/ai-differentiation.ts
new file mode 100644
index 0000000..0867033
--- /dev/null
+++ b/src/modules/lesson-preparation/lib/ai-differentiation.ts
@@ -0,0 +1,235 @@
+/**
+ * V5-21 A3/A4/A5:AI 差异化生成 + 课标实时核对 + 评估可解释。
+ *
+ * - A3:根据课案内容生成基础/提高/拓展三个层次的差异化教学建议
+ * - A4:课标实时核对(检查课案是否覆盖教材课标要求)
+ * - A5:评估可解释(对 exercise 节点给出评分依据说明)
+ *
+ * 复用 ai-feedback.ts 的 AI 调用模式,纯服务端模块。
+ */
+import "server-only";
+import { env } from "@/env.mjs";
+import { createAiChatCompletion } from "@/shared/lib/ai";
+import { isRecord } from "@/shared/lib/type-guards";
+import { z } from "zod";
+import type { LessonPlanDocument, LessonPlanNode, DifferentiationLevel } from "../types";
+
+/** A3:差异化教学建议 */
+export interface DifferentiationSuggestion {
+ level: DifferentiationLevel;
+ /** 该层次的具体教学建议 */
+ suggestions: string[];
+ /** 适用学生描述 */
+ targetStudents: string;
+}
+
+/** A4:课标核对结果 */
+export interface CurriculumCheckItem {
+ /** 课标要求(知识点名称) */
+ requirement: string;
+ /** 是否已覆盖 */
+ covered: boolean;
+ /** 覆盖方式说明(如已覆盖,说明在哪个节点覆盖) */
+ explanation: string;
+}
+
+/** A5:可解释评估 */
+export interface ExplainableAssessment {
+ /** 节点 ID */
+ nodeId: string;
+ /** 评估结论 */
+ conclusion: string;
+ /** 评分依据(可解释性) */
+ rationale: string;
+ /** 改进建议 */
+ suggestion: string;
+}
+
+// Zod schemas
+const DifferentiationSuggestionSchema = z.object({
+ level: z.enum(["basic", "intermediate", "advanced"]),
+ suggestions: z.array(z.string()),
+ targetStudents: z.string(),
+});
+
+const CurriculumCheckItemSchema = z.object({
+ requirement: z.string(),
+ covered: z.boolean(),
+ explanation: z.string(),
+});
+
+const ExplainableAssessmentSchema = z.object({
+ nodeId: z.string(),
+ conclusion: z.string(),
+ rationale: z.string(),
+ suggestion: z.string(),
+});
+
+const DifferentiationResultSchema = z.object({
+ items: z.array(DifferentiationSuggestionSchema),
+});
+
+const CurriculumCheckResultSchema = z.object({
+ items: z.array(CurriculumCheckItemSchema),
+});
+
+const ExplainableAssessmentResultSchema = z.object({
+ items: z.array(ExplainableAssessmentSchema),
+});
+
+const AI_DIFFERENTIATION_PROMPT = `你是教学设计专家。请基于以下课案内容,为三个层次的学生生成差异化教学建议:
+- basic(基础生):需要夯实基础
+- intermediate(中等生):需要巩固提升
+- advanced(学优生):需要拓展延伸
+
+课案内容:
+---
+{doc}
+---
+
+返回 JSON 对象:{ items: [{ level, suggestions: [建议1, 建议2], targetStudents: "适用学生描述" }] }`;
+
+const AI_CURRICULUM_CHECK_PROMPT = `你是教学设计专家。请核对以下课案是否覆盖教材课标要求。
+
+课案内容:
+---
+{doc}
+---
+
+教材知识点列表:{kpList}
+
+返回 JSON 对象:{ items: [{ requirement: "知识点名称", covered: true/false, explanation: "覆盖方式说明" }] }`;
+
+const AI_EXPLAINABLE_PROMPT = `你是教学评价专家。请对以下课案中的练习节点给出可解释的评估。
+
+课案内容:
+---
+{doc}
+---
+
+返回 JSON 对象:{ items: [{ nodeId: "节点ID", conclusion: "评估结论", rationale: "评分依据", suggestion: "改进建议" }] }`;
+
+/** 安全提取节点文本 */
+function extractNodeText(node: LessonPlanNode): string {
+ const data = node.data as unknown;
+ if (!isRecord(data)) return "";
+ const html = typeof data.html === "string" ? data.html : "";
+ const sourceText = typeof data.sourceText === "string" ? data.sourceText : "";
+ return html || sourceText || "";
+}
+
+/** 构建课案摘要供 AI 分析 */
+function buildDocSummary(doc: LessonPlanDocument): string {
+ const teachingNodes = doc.nodes.filter(
+ (n): n is LessonPlanNode => n.type !== "textbook_content",
+ );
+ return JSON.stringify(
+ teachingNodes.slice(0, 20).map((n) => ({
+ id: n.id,
+ type: n.type,
+ title: n.title,
+ stage: n.stage,
+ differentiation: n.differentiation,
+ text: extractNodeText(n).slice(0, 200),
+ })),
+ );
+}
+
+/**
+ * A3:AI 差异化教学建议生成。
+ */
+export async function generateDifferentiationSuggestions(
+ doc: LessonPlanDocument,
+): Promise {
+ const teachingNodes = doc.nodes.filter(
+ (n): n is LessonPlanNode => n.type !== "textbook_content",
+ );
+ if (teachingNodes.length === 0) return [];
+
+ const prompt = AI_DIFFERENTIATION_PROMPT.replace("{doc}", buildDocSummary(doc));
+
+ try {
+ const { content } = await createAiChatCompletion({
+ messages: [{ role: "user", content: prompt }],
+ model: env.AI_MODEL ?? "gpt-4o-mini",
+ temperature: 0.5,
+ });
+
+ const jsonMatch = content.match(/\{[\s\S]*\}/);
+ if (!jsonMatch) return [];
+
+ const parsed: unknown = JSON.parse(jsonMatch[0]);
+ const validated = DifferentiationResultSchema.safeParse(parsed);
+ if (!validated.success) return [];
+
+ return validated.data.items;
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * A4:AI 课标实时核对。
+ */
+export async function checkCurriculumAlignment(
+ doc: LessonPlanDocument,
+ knowledgePoints: { id: string; name: string }[],
+): Promise {
+ if (knowledgePoints.length === 0) return [];
+
+ const prompt = AI_CURRICULUM_CHECK_PROMPT
+ .replace("{doc}", buildDocSummary(doc))
+ .replace("{kpList}", JSON.stringify(knowledgePoints.slice(0, 50)));
+
+ try {
+ const { content } = await createAiChatCompletion({
+ messages: [{ role: "user", content: prompt }],
+ model: env.AI_MODEL ?? "gpt-4o-mini",
+ temperature: 0.2,
+ });
+
+ const jsonMatch = content.match(/\{[\s\S]*\}/);
+ if (!jsonMatch) return [];
+
+ const parsed: unknown = JSON.parse(jsonMatch[0]);
+ const validated = CurriculumCheckResultSchema.safeParse(parsed);
+ if (!validated.success) return [];
+
+ return validated.data.items;
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * A5:AI 可解释评估。
+ */
+export async function generateExplainableAssessment(
+ doc: LessonPlanDocument,
+): Promise {
+ const exerciseNodes = doc.nodes.filter(
+ (n): n is LessonPlanNode => n.type === "exercise",
+ );
+ if (exerciseNodes.length === 0) return [];
+
+ const prompt = AI_EXPLAINABLE_PROMPT.replace("{doc}", buildDocSummary(doc));
+
+ try {
+ const { content } = await createAiChatCompletion({
+ messages: [{ role: "user", content: prompt }],
+ model: env.AI_MODEL ?? "gpt-4o-mini",
+ temperature: 0.4,
+ });
+
+ const jsonMatch = content.match(/\{[\s\S]*\}/);
+ if (!jsonMatch) return [];
+
+ const parsed: unknown = JSON.parse(jsonMatch[0]);
+ const validated = ExplainableAssessmentResultSchema.safeParse(parsed);
+ if (!validated.success) return [];
+
+ return validated.data.items;
+ } catch {
+ return [];
+ }
+}
diff --git a/src/modules/lesson-preparation/lib/ai-feedback.ts b/src/modules/lesson-preparation/lib/ai-feedback.ts
new file mode 100644
index 0000000..8c18033
--- /dev/null
+++ b/src/modules/lesson-preparation/lib/ai-feedback.ts
@@ -0,0 +1,135 @@
+/**
+ * V5-17 A1/A2:AI 反馈闭环 + 解释性展示。
+ *
+ * 调用 AI 对课案文档进行教学评一致性反馈,返回结构化建议。
+ * 反馈包含:
+ * - strengths:课案优点
+ * - improvements:改进建议
+ * - alignment:教学评一致性评估
+ * - differentiation:差异化教学建议
+ *
+ * 每条建议附带 reason(解释性展示),帮助教师理解 AI 判断依据。
+ */
+import "server-only";
+import { env } from "@/env.mjs";
+import { createAiChatCompletion } from "@/shared/lib/ai";
+import { isRecord } from "@/shared/lib/type-guards";
+import { z } from "zod";
+import type { LessonPlanDocument, LessonPlanNode } from "../types";
+
+/** AI 反馈单条建议 */
+export interface AiFeedbackItem {
+ /** i18n 键后缀(feedback.* 命名空间下) */
+ category: "strengths" | "improvements" | "alignment" | "differentiation";
+ /** 建议标题 */
+ title: string;
+ /** 解释性理由(A2:解释性展示) */
+ reason: string;
+ /** 关联节点 ID(如适用) */
+ nodeId?: string;
+}
+
+/** AI 反馈结果 */
+export interface AiFeedbackResult {
+ items: AiFeedbackItem[];
+ /** 整体评分(0-100) */
+ overallScore: number;
+ /** 摘要 */
+ summary: string;
+}
+
+const FeedbackItemSchema = z.object({
+ category: z.enum(["strengths", "improvements", "alignment", "differentiation"]),
+ title: z.string().min(1),
+ reason: z.string(),
+ nodeId: z.string().optional(),
+});
+
+const FeedbackResultSchema = z.object({
+ items: z.array(FeedbackItemSchema),
+ overallScore: z.number().min(0).max(100),
+ summary: z.string(),
+});
+
+const AI_FEEDBACK_PROMPT_TEMPLATE = `你是资深教学设计专家。请对以下课案文档进行教学评一致性评估,给出结构化反馈。
+
+课案文档(JSON):
+---
+{doc}
+---
+
+请从四个维度评估:
+1. strengths:课案优点
+2. improvements:改进建议
+3. alignment:教学评一致性(目标-教学-评价是否对齐)
+4. differentiation:差异化教学建议
+
+返回 JSON 对象,含:
+- items:数组,每项含 category(维度)/title(建议标题)/reason(解释性理由,说明为何给出此建议)/nodeId(关联节点 ID,可选)
+- overallScore:整体评分 0-100
+- summary:一句话摘要
+
+注意:reason 字段必须解释判断依据,帮助教师理解。`;
+
+/** 安全提取节点文本用于 AI prompt */
+function extractNodeText(node: LessonPlanNode): string {
+ const data = node.data as unknown;
+ if (!isRecord(data)) return "";
+ const html = typeof data.html === "string" ? data.html : "";
+ const sourceText = typeof data.sourceText === "string" ? data.sourceText : "";
+ return html || sourceText || "";
+}
+
+/**
+ * 调用 AI 对课案文档生成结构化反馈。
+ *
+ * @param doc 课案文档
+ * @returns AI 反馈结果;AI 不可用时返回空结果
+ */
+export async function generateLessonPlanFeedback(
+ doc: LessonPlanDocument,
+): Promise {
+ // 提取教学节点摘要(排除正文节点,控制 token 用量)
+ const teachingNodes = doc.nodes.filter(
+ (n): n is LessonPlanNode => n.type !== "textbook_content",
+ );
+ if (teachingNodes.length === 0) {
+ return { items: [], overallScore: 0, summary: "" };
+ }
+
+ const docSummary = teachingNodes.slice(0, 20).map((n) => ({
+ id: n.id,
+ type: n.type,
+ title: n.title,
+ stage: n.stage,
+ differentiation: n.differentiation,
+ text: extractNodeText(n).slice(0, 200),
+ }));
+
+ const prompt = AI_FEEDBACK_PROMPT_TEMPLATE.replace(
+ "{doc}",
+ JSON.stringify(docSummary),
+ );
+
+ try {
+ const { content } = await createAiChatCompletion({
+ messages: [{ role: "user", content: prompt }],
+ model: env.AI_MODEL ?? "gpt-4o-mini",
+ temperature: 0.4,
+ });
+
+ // 从返回内容中提取 JSON 对象
+ const jsonMatch = content.match(/\{[\s\S]*\}/);
+ if (!jsonMatch) return { items: [], overallScore: 0, summary: "" };
+
+ const parsed: unknown = JSON.parse(jsonMatch[0]);
+ const validated = FeedbackResultSchema.safeParse(parsed);
+ if (!validated.success) {
+ return { items: [], overallScore: 0, summary: "" };
+ }
+
+ return validated.data;
+ } catch {
+ return { items: [], overallScore: 0, summary: "" };
+ }
+}
diff --git a/src/modules/lesson-preparation/lib/auto-layout.ts b/src/modules/lesson-preparation/lib/auto-layout.ts
new file mode 100644
index 0000000..726a8a3
--- /dev/null
+++ b/src/modules/lesson-preparation/lib/auto-layout.ts
@@ -0,0 +1,82 @@
+/**
+ * V5-8:画布自动布局
+ *
+ * 使用 dagre 计算 DAG 布局,将教学节点按流程关系自动排列。
+ * 仅对可拖动的教学节点(非正文节点)布局,正文节点位置保持不变。
+ */
+import dagre from "@dagrejs/dagre";
+import type {
+ AnyLessonPlanNode,
+ AnyLessonPlanEdge,
+} from "../types";
+
+export interface AutoLayoutOptions {
+ /** 布局方向:TB(上→下)/ LR(左→右),默认 TB */
+ direction?: "TB" | "LR";
+ /** 节点宽度,默认 240 */
+ nodeWidth?: number;
+ /** 节点高度,默认 120 */
+ nodeHeight?: number;
+ /** 节点水平间距,默认 40 */
+ rankSep?: number;
+ /** 节点垂直间距,默认 60 */
+ nodeSep?: number;
+}
+
+/**
+ * 计算自动布局,返回每个节点的新位置(含原始位置作为兜底)
+ */
+export function computeAutoLayout(
+ nodes: AnyLessonPlanNode[],
+ edges: AnyLessonPlanEdge[],
+ options: AutoLayoutOptions = {},
+): Map {
+ const {
+ direction = "TB",
+ nodeWidth = 240,
+ nodeHeight = 120,
+ rankSep = 60,
+ nodeSep = 40,
+ } = options;
+
+ const result = new Map();
+
+ if (nodes.length === 0) return result;
+
+ const g = new dagre.graphlib.Graph();
+ g.setGraph({ rankdir: direction, ranksep: rankSep, nodesep: nodeSep });
+ g.setDefaultEdgeLabel(() => ({}));
+
+ // 仅对教学节点布局(正文节点固定位置)
+ const layoutableNodes = nodes.filter((n) => n.type !== "textbook_content");
+ const layoutableIds = new Set(layoutableNodes.map((n) => n.id));
+
+ for (const node of layoutableNodes) {
+ g.setNode(node.id, { width: nodeWidth, height: nodeHeight });
+ }
+
+ // 仅添加两端都可布局的 flow 边
+ for (const edge of edges) {
+ if (edge.type !== "flow") continue;
+ if (!layoutableIds.has(edge.source) || !layoutableIds.has(edge.target)) continue;
+ g.setEdge(edge.source, edge.target);
+ }
+
+ // 孤立节点(无边)也要 setNode,dagre 会自动排列
+ dagre.layout(g);
+
+ for (const node of layoutableNodes) {
+ const laid = g.node(node.id);
+ if (laid) {
+ // dagre 返回中心点,React Flow 使用左上角,需减去半宽/半高
+ result.set(node.id, {
+ x: laid.x - nodeWidth / 2,
+ y: laid.y - nodeHeight / 2,
+ });
+ } else {
+ result.set(node.id, node.position);
+ }
+ }
+
+ return result;
+}
diff --git a/src/modules/lesson-preparation/lib/consistency-check.ts b/src/modules/lesson-preparation/lib/consistency-check.ts
new file mode 100644
index 0000000..c6d3b39
--- /dev/null
+++ b/src/modules/lesson-preparation/lib/consistency-check.ts
@@ -0,0 +1,137 @@
+/**
+ * V5-19 T3:目标-评价一致性校验(教学评一致性)。
+ *
+ * 校验规则:
+ * 1. 每个教学目标(objective 节点)应至少被一个评价(exercise 节点)覆盖。
+ * 2. 每个 exercise 节点应至少关联一个知识点。
+ * 3. 课案应至少包含一个 objective 与一个 exercise(仅 warning,不阻断保存)。
+ *
+ * 输出为纯数据结构(ConsistencyResult),UI 层负责渲染。
+ * 该模块为纯函数,无副作用,便于单测。
+ */
+import type {
+ ExerciseBlockData,
+ LessonPlanDocument,
+ LessonPlanNode,
+} from "../types";
+
+/** 校验级别 */
+export type ConsistencySeverity = "warning" | "info";
+
+/** 单条校验结果 */
+export interface ConsistencyIssue {
+ /** i18n 键后缀(consistency.* 命名空间下) */
+ code:
+ | "objectiveNotAssessed"
+ | "exerciseWithoutObjective"
+ | "noObjective"
+ | "noExercise"
+ | "exerciseNoKnowledgePoint"
+ | "objectiveNoKnowledgePoint";
+ severity: ConsistencySeverity;
+ /** 关联节点 ID(如适用) */
+ nodeId?: string;
+ /** 关联节点标题(用于 UI 展示) */
+ nodeTitle?: string;
+ /** i18n 插值参数 */
+ params?: Record;
+}
+
+/** 校验结果汇总 */
+export interface ConsistencyResult {
+ issues: ConsistencyIssue[];
+ /** 统计:目标数 */
+ objectiveCount: number;
+ /** 统计:评价数 */
+ exerciseCount: number;
+ /** 统计:被覆盖的目标数 */
+ coveredObjectiveCount: number;
+ /** 一致性分数(0-100,100 表示完全一致) */
+ score: number;
+}
+
+/** 仅取教学节点(排除正文节点) */
+function getTeachingNodes(doc: LessonPlanDocument): LessonPlanNode[] {
+ return doc.nodes.filter((n): n is LessonPlanNode => n.type !== "textbook_content");
+}
+
+/** 安全读取 exercise 节点的知识点 ID 集合 */
+function getExerciseKpIds(node: LessonPlanNode): Set {
+ if (node.type !== "exercise") return new Set();
+ const data = node.data as ExerciseBlockData;
+ const ids = new Set();
+ for (const item of data.items ?? []) {
+ if (item.source === "inline" && item.inlineContent?.knowledgePointIds) {
+ for (const kp of item.inlineContent.knowledgePointIds) ids.add(kp);
+ }
+ }
+ for (const kp of data.knowledgePointIds ?? []) ids.add(kp);
+ return ids;
+}
+
+/**
+ * 执行目标-评价一致性校验。纯函数,无副作用。
+ *
+ * 当前以"节点"为粒度:objective 节点视为目标单元,exercise 节点视为评价单元。
+ * 只要课案中存在至少一个 exercise,即视为目标已被覆盖(保守判定)。
+ */
+export function checkConsistency(doc: LessonPlanDocument): ConsistencyResult {
+ const teachingNodes = getTeachingNodes(doc);
+ const objectiveNodes = teachingNodes.filter((n) => n.type === "objective");
+ const exerciseNodes = teachingNodes.filter((n) => n.type === "exercise");
+
+ const issues: ConsistencyIssue[] = [];
+
+ if (objectiveNodes.length === 0) {
+ issues.push({ code: "noObjective", severity: "warning" });
+ }
+
+ if (exerciseNodes.length === 0) {
+ issues.push({ code: "noExercise", severity: "warning" });
+ }
+
+ for (const ex of exerciseNodes) {
+ const kpIds = getExerciseKpIds(ex);
+ if (kpIds.size === 0) {
+ issues.push({
+ code: "exerciseNoKnowledgePoint",
+ severity: "warning",
+ nodeId: ex.id,
+ nodeTitle: ex.title,
+ });
+ }
+ }
+
+ let coveredCount = 0;
+ for (const obj of objectiveNodes) {
+ const isCovered = exerciseNodes.length > 0;
+ if (isCovered) {
+ coveredCount++;
+ } else {
+ issues.push({
+ code: "objectiveNotAssessed",
+ severity: "warning",
+ nodeId: obj.id,
+ nodeTitle: obj.title,
+ params: { title: obj.title || obj.type },
+ });
+ }
+ }
+
+ const totalChecks = objectiveNodes.length + exerciseNodes.length;
+ const failedChecks = issues.filter((i) => i.severity === "warning").length;
+ const score = totalChecks === 0 ? 100 : Math.max(0, Math.round(100 - (failedChecks / totalChecks) * 100));
+
+ return {
+ issues,
+ objectiveCount: objectiveNodes.length,
+ exerciseCount: exerciseNodes.length,
+ coveredObjectiveCount: coveredCount,
+ score,
+ };
+}
+
+/** 便捷函数:是否存在 warning 级别问题 */
+export function hasConsistencyWarnings(result: ConsistencyResult): boolean {
+ return result.issues.some((i) => i.severity === "warning");
+}
diff --git a/src/modules/lesson-preparation/lib/curriculum-coverage.ts b/src/modules/lesson-preparation/lib/curriculum-coverage.ts
new file mode 100644
index 0000000..61fd2e5
--- /dev/null
+++ b/src/modules/lesson-preparation/lib/curriculum-coverage.ts
@@ -0,0 +1,149 @@
+/**
+ * V5-20 T4:课标覆盖度统计(教师端课标热力图支撑)。
+ *
+ * 统计教师所有课案对教材知识点的覆盖情况:
+ * - 每个知识点被多少个课案覆盖
+ * - 未被覆盖的知识点(教学盲点)
+ * - 按章节分组的覆盖率
+ *
+ * 纯函数,输入数据由调用方从 data-access 获取。
+ */
+import type { KnowledgePoint } from "@/modules/textbooks/types";
+
+/** 单个知识点的覆盖统计 */
+export interface KpCoverageStat {
+ kpId: string;
+ kpName: string;
+ chapterId: string | null;
+ /** 覆盖此知识点的课案数量 */
+ planCount: number;
+ /** 覆盖此知识点的课案 ID 列表 */
+ planIds: string[];
+ /** 是否为教学盲点(planCount === 0) */
+ isBlindSpot: boolean;
+}
+
+/** 章节覆盖率统计 */
+export interface ChapterCoverageStat {
+ chapterId: string;
+ /** 该章节下知识点总数 */
+ totalKps: number;
+ /** 被覆盖的知识点数 */
+ coveredKps: number;
+ /** 覆盖率(0-100) */
+ coverageRate: number;
+ /** 章节下的知识点统计 */
+ kps: KpCoverageStat[];
+}
+
+/** 课标热力图统计结果 */
+export interface CurriculumCoverageResult {
+ /** 按章节分组 */
+ chapters: ChapterCoverageStat[];
+ /** 总知识点数 */
+ totalKps: number;
+ /** 被覆盖的知识点数 */
+ coveredKps: number;
+ /** 整体覆盖率(0-100) */
+ overallCoverageRate: number;
+ /** 教学盲点(未被任何课案覆盖的知识点) */
+ blindSpots: KpCoverageStat[];
+}
+
+/** 课案知识点关联的扁平结构(由调用方从 LessonPlanDocument 提取) */
+export interface PlanKpLink {
+ planId: string;
+ knowledgePointIds: string[];
+}
+
+/**
+ * 计算课标覆盖度热力图数据。纯函数,无副作用。
+ *
+ * @param allKps 教材下所有知识点(由 getKnowledgePointsByTextbookId 获取)
+ * @param planLinks 教师所有课案的知识点关联列表
+ */
+export function computeCurriculumCoverage(
+ allKps: KnowledgePoint[],
+ planLinks: PlanKpLink[],
+): CurriculumCoverageResult {
+ // 构建知识点 → 课案列表 的反向索引
+ const kpToPlans = new Map();
+ for (const link of planLinks) {
+ for (const kpId of link.knowledgePointIds) {
+ const existing = kpToPlans.get(kpId);
+ if (existing) {
+ if (!existing.planIds.includes(link.planId)) {
+ existing.planIds.push(link.planId);
+ existing.count++;
+ }
+ } else {
+ kpToPlans.set(kpId, { planIds: [link.planId], count: 1 });
+ }
+ }
+ }
+
+ // 按章节分组知识点
+ const chapterMap = new Map();
+ for (const kp of allKps) {
+ const chId = kp.chapterId ?? "__no_chapter__";
+ if (!chapterMap.has(chId)) chapterMap.set(chId, []);
+ chapterMap.get(chId)!.push(kp);
+ }
+
+ const chapters: ChapterCoverageStat[] = [];
+ let totalKps = 0;
+ let coveredKps = 0;
+ const blindSpots: KpCoverageStat[] = [];
+
+ for (const [chapterId, kps] of chapterMap) {
+ const kpStats: KpCoverageStat[] = kps.map((kp) => {
+ const coverage = kpToPlans.get(kp.id);
+ const planIds = coverage?.planIds ?? [];
+ const planCount = coverage?.count ?? 0;
+ const stat: KpCoverageStat = {
+ kpId: kp.id,
+ kpName: kp.name,
+ chapterId: kp.chapterId ?? null,
+ planCount,
+ planIds,
+ isBlindSpot: planCount === 0,
+ };
+ if (stat.isBlindSpot) blindSpots.push(stat);
+ return stat;
+ });
+
+ const chapterTotal = kps.length;
+ const chapterCovered = kpStats.filter((s) => !s.isBlindSpot).length;
+ const coverageRate = chapterTotal === 0 ? 0 : Math.round((chapterCovered / chapterTotal) * 100);
+
+ chapters.push({
+ chapterId,
+ totalKps: chapterTotal,
+ coveredKps: chapterCovered,
+ coverageRate,
+ kps: kpStats,
+ });
+
+ totalKps += chapterTotal;
+ coveredKps += chapterCovered;
+ }
+
+ const overallCoverageRate = totalKps === 0 ? 0 : Math.round((coveredKps / totalKps) * 100);
+
+ return {
+ chapters,
+ totalKps,
+ coveredKps,
+ overallCoverageRate,
+ blindSpots,
+ };
+}
+
+/** 根据覆盖率返回热力图颜色等级(0-4) */
+export function getHeatLevel(coverageRate: number): 0 | 1 | 2 | 3 | 4 {
+ if (coverageRate === 0) return 0;
+ if (coverageRate < 25) return 1;
+ if (coverageRate < 50) return 2;
+ if (coverageRate < 75) return 3;
+ return 4;
+}
diff --git a/src/modules/lesson-preparation/lib/export.ts b/src/modules/lesson-preparation/lib/export.ts
new file mode 100644
index 0000000..acae736
--- /dev/null
+++ b/src/modules/lesson-preparation/lib/export.ts
@@ -0,0 +1,267 @@
+/**
+ * V5-4:课案导出/打印工具
+ *
+ * 将画布式 LessonPlanDocument 扁平化为线性教学环节列表,
+ * 供打印视图(print-view.tsx)渲染。支持详细版/简洁版两种模式:
+ * - detailed: 包含所有 11 种 Block
+ * - concise: 仅包含 objective / new_teaching / exercise / homework
+ */
+
+import type {
+ BlackboardBlockData,
+ BlockData,
+ ExerciseBlockData,
+ HomeworkBlockData,
+ ImportBlockData,
+ KeyPointBlockData,
+ LessonPlan,
+ LessonPlanDocument,
+ NewTeachingBlockData,
+ ObjectiveBlockData,
+ ReflectionBlockData,
+ RichTextBlockData,
+ SummaryBlockData,
+ TextStudyBlockData,
+ TextbookContentNode,
+} from "../types";
+
+/** 导出版本 */
+export type ExportVariant = "detailed" | "concise";
+
+/** 简洁版包含的 Block 类型 */
+const CONCISE_BLOCK_TYPES = new Set([
+ "objective",
+ "new_teaching",
+ "exercise",
+ "homework",
+]);
+
+/** 扁平化后的教学环节 */
+export interface PrintableSection {
+ type: string;
+ title: string;
+ /** 已扁平化为字符串数组的内容 */
+ lines: string[];
+}
+
+/** 导出元信息(页眉/页脚用) */
+export interface ExportMeta {
+ planTitle: string;
+ textbookTitle?: string;
+ chapterTitle?: string;
+ teacherName?: string;
+ className?: string;
+ /** 备课最后保存时间 ISO */
+ lastSavedAt?: string;
+ /** 教学时长(分钟),来自 import 节点 durationMin 求和 */
+ totalDurationMin: number;
+}
+
+/** 导出文档 */
+export interface PrintableLessonPlan {
+ meta: ExportMeta;
+ /** 课文正文(如有 textbook_content 节点) */
+ textbookContent: string | null;
+ /** 教学环节列表(按 order 排序) */
+ sections: PrintableSection[];
+ variant: ExportVariant;
+}
+
+/**
+ * 将画布式文档扁平化为可打印的线性结构。
+ *
+ * @param plan 课案对象
+ * @param meta 元信息(教师名、班级等由调用方注入)
+ * @param variant detailed | concise
+ */
+export function flattenLessonPlanForPrint(
+ plan: LessonPlan,
+ meta: Partial,
+ variant: ExportVariant = "detailed",
+): PrintableLessonPlan {
+ const doc: LessonPlanDocument = plan.content;
+ const textbookContent = extractTextbookContent(doc);
+ const teachingNodes = doc.nodes
+ .filter((n) => n.type !== "textbook_content")
+ .filter((n) => variant === "detailed" || CONCISE_BLOCK_TYPES.has(n.type))
+ .sort((a, b) => a.order - b.order);
+
+ const sections = teachingNodes.map((node) =>
+ flattenBlock(node.type, node.title, node.data as BlockData),
+ );
+
+ // V5-4:教学时长由 import 节点求和
+ const totalDurationMin = doc.nodes
+ .filter((n) => n.type === "import")
+ .reduce((sum, n) => {
+ const data = n.data as ImportBlockData;
+ return sum + (data.durationMin ?? 0);
+ }, 0);
+
+ return {
+ meta: {
+ planTitle: plan.title,
+ lastSavedAt: plan.lastSavedAt ?? undefined,
+ totalDurationMin,
+ ...meta,
+ },
+ textbookContent,
+ sections,
+ variant,
+ };
+}
+
+function extractTextbookContent(doc: LessonPlanDocument): string | null {
+ const node = doc.nodes.find(
+ (n): n is TextbookContentNode => n.type === "textbook_content",
+ );
+ if (!node) return null;
+ return node.data.content || null;
+}
+
+function flattenBlock(
+ type: string,
+ title: string,
+ data: BlockData,
+): PrintableSection {
+ const lines = flattenBlockData(type, data);
+ return { type, title, lines };
+}
+
+function flattenBlockData(type: string, data: BlockData): string[] {
+ switch (type) {
+ case "objective":
+ return flattenObjective(data as ObjectiveBlockData);
+ case "key_point":
+ return flattenKeyPoint(data as KeyPointBlockData);
+ case "import":
+ return flattenImport(data as ImportBlockData);
+ case "new_teaching":
+ return flattenNewTeaching(data as NewTeachingBlockData);
+ case "summary":
+ return flattenSummary(data as SummaryBlockData);
+ case "homework":
+ return flattenHomework(data as HomeworkBlockData);
+ case "blackboard":
+ return flattenBlackboard(data as BlackboardBlockData);
+ case "reflection":
+ return flattenReflection(data as ReflectionBlockData);
+ case "exercise":
+ return flattenExercise(data as ExerciseBlockData);
+ case "text_study":
+ return flattenTextStudy(data as TextStudyBlockData);
+ case "rich_text":
+ return flattenRichText(data as RichTextBlockData);
+ default:
+ return [];
+ }
+}
+
+function flattenObjective(data: ObjectiveBlockData): string[] {
+ const dimensionLabel: Record = {
+ knowledge: "知识与技能",
+ process: "过程与方法",
+ emotion: "情感态度",
+ };
+ return data.objectives.map(
+ (o) => `[${dimensionLabel[o.dimension]}] ${o.text}`,
+ );
+}
+
+function flattenKeyPoint(data: KeyPointBlockData): string[] {
+ return data.keyPoints.map((kp) =>
+ kp.type === "key" ? `[重点] ${kp.text}` : `[难点] ${kp.text}`,
+ );
+}
+
+function flattenImport(data: ImportBlockData): string[] {
+ const methodLabel: Record = {
+ question: "提问导入",
+ situation: "情境导入",
+ review: "复习导入",
+ other: "其他",
+ };
+ return [
+ `方式:${methodLabel[data.method]}`,
+ `时长:${data.durationMin} 分钟`,
+ data.prompt ? `导入语:${data.prompt}` : "",
+ ].filter((s) => s.length > 0);
+}
+
+function flattenNewTeaching(data: NewTeachingBlockData): string[] {
+ const lines: string[] = [];
+ data.teachingPoints.forEach((p, i) => {
+ lines.push(`步骤 ${i + 1}:`);
+ if (p.outline) lines.push(` 提纲:${p.outline}`);
+ if (p.boardNotes) lines.push(` 板书要点:${p.boardNotes}`);
+ });
+ return lines;
+}
+
+function flattenSummary(data: SummaryBlockData): string[] {
+ const lines = data.summaryPoints.map((p, i) => `${i + 1}. ${p}`);
+ if (data.homeworkPreview) lines.push(`作业预览:${data.homeworkPreview}`);
+ return lines;
+}
+
+function flattenHomework(data: HomeworkBlockData): string[] {
+ const typeLabel: Record = {
+ exercise: "练习",
+ reading: "阅读",
+ writing: "写作",
+ };
+ return data.assignments.map(
+ (a) => `[${typeLabel[a.type]}] ${a.description}`,
+ );
+}
+
+function flattenBlackboard(data: BlackboardBlockData): string[] {
+ const layoutLabel: Record = {
+ structure: "结构式",
+ mindmap: "思维导图",
+ text: "文字式",
+ };
+ return [`形式:${layoutLabel[data.layout]}`, data.content].filter(
+ (s) => s.length > 0,
+ );
+}
+
+function flattenReflection(data: ReflectionBlockData): string[] {
+ const aspectLabel: Record = {
+ effectiveness: "教学效果",
+ problems: "存在问题",
+ improvements: "改进措施",
+ };
+ return data.reflection.map(
+ (r) => `[${aspectLabel[r.aspect]}] ${r.text}`,
+ );
+}
+
+function flattenExercise(data: ExerciseBlockData): string[] {
+ if (data.items.length === 0) return ["(无题目)"];
+ return data.items.map((item, i) => {
+ const source = item.source === "inline" ? "课案内新建" : "题库";
+ return `${i + 1}. [${source}] 题目 ID: ${item.questionId} (${item.score} 分)`;
+ });
+}
+
+function flattenTextStudy(data: TextStudyBlockData): string[] {
+ if (data.annotations.length === 0) return ["(无文本研习标注)"];
+ return data.annotations.map(
+ (a, i) => `${i + 1}. [${a.title}] ${a.note}`,
+ );
+}
+
+function flattenRichText(data: RichTextBlockData): string[] {
+ // HTML 简易去标签,仅保留文本(打印友好)
+ const text = data.html
+ .replace(/<[^>]+>/g, "")
+ .replace(/ /g, " ")
+ .trim();
+ return text.length > 0 ? [text] : [];
+}
+
+// 仅用于类型推导的本地导入别名,避免在 switch case 中重复 import
+type ObjectiveItem = ObjectiveBlockData["objectives"][number];
+type HomeworkAssignment = HomeworkBlockData["assignments"][number];
+type ReflectionItem = ReflectionBlockData["reflection"][number];
diff --git a/src/modules/lesson-preparation/lib/version-diff.ts b/src/modules/lesson-preparation/lib/version-diff.ts
new file mode 100644
index 0000000..fb276b5
--- /dev/null
+++ b/src/modules/lesson-preparation/lib/version-diff.ts
@@ -0,0 +1,127 @@
+/**
+ * V5-16 T2:版本对比工具(反思闭环支撑)。
+ *
+ * 对比两个 LessonPlanDocument,输出节点级别的差异:
+ * - added: 新版本中新增的节点
+ * - removed: 旧版本中有但新版本中删除的节点
+ * - modified: 两版本都有但内容(title/data/stage/differentiation)变化的节点
+ * - unchanged: 相同的节点
+ *
+ * 纯函数,无副作用,便于单测。
+ */
+import type { LessonPlanDocument, LessonPlanNode } from "../types";
+
+/** 差异类型 */
+export type DiffChangeType = "added" | "removed" | "modified" | "unchanged";
+
+/** 单个节点的差异 */
+export interface NodeDiff {
+ type: DiffChangeType;
+ /** 新版本中的节点(added/modified/unchanged 时存在) */
+ newNode?: LessonPlanNode;
+ /** 旧版本中的节点(removed/modified/unchanged 时存在) */
+ oldNode?: LessonPlanNode;
+ /** modified 时的字段级变更列表 */
+ changedFields?: string[];
+}
+
+/** 文档对比结果 */
+export interface VersionDiffResult {
+ diffs: NodeDiff[];
+ /** 统计 */
+ summary: {
+ added: number;
+ removed: number;
+ modified: number;
+ unchanged: number;
+ };
+}
+
+/** 安全序列化节点数据用于比较(忽略 position 等非内容字段) */
+function nodeContentKey(n: LessonPlanNode): string {
+ return JSON.stringify({
+ title: n.title,
+ data: n.data,
+ stage: n.stage,
+ differentiation: n.differentiation,
+ type: n.type,
+ });
+}
+
+/**
+ * 对比两个课案文档,返回节点级差异。
+ *
+ * @param oldDoc 旧版本文档
+ * @param newDoc 新版本文档
+ */
+export function diffDocuments(
+ oldDoc: LessonPlanDocument,
+ newDoc: LessonPlanDocument,
+): VersionDiffResult {
+ const oldNodes = new Map();
+ const newNodes = new Map();
+
+ for (const n of oldDoc.nodes) {
+ if (n.type !== "textbook_content") oldNodes.set(n.id, n);
+ }
+ for (const n of newDoc.nodes) {
+ if (n.type !== "textbook_content") newNodes.set(n.id, n);
+ }
+
+ const diffs: NodeDiff[] = [];
+ let added = 0;
+ let removed = 0;
+ let modified = 0;
+ let unchanged = 0;
+
+ // 遍历新版本节点
+ for (const [id, newNode] of newNodes) {
+ const oldNode = oldNodes.get(id);
+ if (!oldNode) {
+ diffs.push({ type: "added", newNode });
+ added++;
+ } else {
+ const oldKey = nodeContentKey(oldNode);
+ const newKey = nodeContentKey(newNode);
+ if (oldKey === newKey) {
+ diffs.push({ type: "unchanged", oldNode, newNode });
+ unchanged++;
+ } else {
+ const changedFields: string[] = [];
+ if (oldNode.title !== newNode.title) changedFields.push("title");
+ if (oldNode.stage !== newNode.stage) changedFields.push("stage");
+ if (oldNode.differentiation !== newNode.differentiation) changedFields.push("differentiation");
+ if (JSON.stringify(oldNode.data) !== JSON.stringify(newNode.data)) changedFields.push("data");
+ diffs.push({ type: "modified", oldNode, newNode, changedFields });
+ modified++;
+ }
+ }
+ }
+
+ // 遍历旧版本中已删除的节点
+ for (const [id, oldNode] of oldNodes) {
+ if (!newNodes.has(id)) {
+ diffs.push({ type: "removed", oldNode });
+ removed++;
+ }
+ }
+
+ // 排序:added/removed/modified 优先,unchanged 靠后
+ const order: Record = {
+ removed: 0,
+ added: 1,
+ modified: 2,
+ unchanged: 3,
+ };
+ diffs.sort((a, b) => order[a.type] - order[b.type]);
+
+ return {
+ diffs,
+ summary: { added, removed, modified, unchanged },
+ };
+}
+
+/** 便捷函数:是否有实际变更 */
+export function hasChanges(result: VersionDiffResult): boolean {
+ return result.summary.added > 0 || result.summary.removed > 0 || result.summary.modified > 0;
+}
diff --git a/src/modules/lesson-preparation/providers/lesson-plan-provider.tsx b/src/modules/lesson-preparation/providers/lesson-plan-provider.tsx
index be8a4e3..1a48ad1 100644
--- a/src/modules/lesson-preparation/providers/lesson-plan-provider.tsx
+++ b/src/modules/lesson-preparation/providers/lesson-plan-provider.tsx
@@ -171,6 +171,50 @@ export interface LessonPlanDataService {
message?: string;
errors?: Record;
}>;
+
+ // ---- V5-5:M6 附件库 UI 集成 ----
+
+ /** 查询课案的所有附件(attachment-picker 使用)*/
+ getLessonPlanAttachments(planId: string): Promise<{
+ success: boolean;
+ data?: { items: LessonPlanAttachmentOption[] };
+ message?: string;
+ }>;
+
+ /** 创建附件记录(上传完成后调用,关联到课案)*/
+ createLessonPlanAttachment(input: {
+ planId: string;
+ blockId?: string;
+ fileId: string;
+ displayName: string;
+ attachmentType?: "reference" | "material" | "supplementary";
+ }): Promise<{
+ success: boolean;
+ data?: { attachment: LessonPlanAttachmentOption | null };
+ message?: string;
+ }>;
+
+ /** 删除附件 */
+ deleteLessonPlanAttachment(attachmentId: string): Promise<{
+ success: boolean;
+ message?: string;
+ }>;
+}
+
+/** V5-5:附件选项(供 picker 渲染)*/
+export interface LessonPlanAttachmentOption {
+ id: string;
+ planId: string;
+ blockId?: string;
+ fileId: string;
+ displayName: string;
+ attachmentType: "reference" | "material" | "supplementary";
+ uploadedBy: string;
+ createdAt: string;
+ /** V5-6 修复:MIME 类型,供 kindIcon 推断渲染方式 */
+ mimeType?: string;
+ /** V5-6 修复:渲染用 URL */
+ url?: string;
}
/**
diff --git a/src/modules/lesson-preparation/services/default-data-service.ts b/src/modules/lesson-preparation/services/default-data-service.ts
index 242bdb7..e004105 100644
--- a/src/modules/lesson-preparation/services/default-data-service.ts
+++ b/src/modules/lesson-preparation/services/default-data-service.ts
@@ -18,6 +18,11 @@ import {
} from "../actions";
import { getKnowledgePointOptionsAction } from "../actions-kp";
import { publishLessonPlanHomeworkAction } from "../actions-publish";
+import {
+ getLessonPlanAttachmentsAction,
+ createLessonPlanAttachmentAction,
+ deleteLessonPlanAttachmentAction,
+} from "../actions-attachments";
import type { LessonPlanDataService } from "../providers/lesson-plan-provider";
/**
@@ -148,5 +153,51 @@ export function createDefaultDataService(): LessonPlanDataService {
}
return { success: false, message: res.message, errors: res.errors };
},
+
+ // V5-5:M6 附件库 UI 集成
+ async getLessonPlanAttachments(planId) {
+ const res = await getLessonPlanAttachmentsAction(planId);
+ if (res.success && res.data) {
+ // 将 DB 层的 Date 转为 ISO string 以匹配 LessonPlanAttachmentOption 类型
+ const items = res.data.items.map((a) => ({
+ id: a.id,
+ planId: a.planId,
+ blockId: a.blockId ?? undefined,
+ fileId: a.fileId,
+ displayName: a.displayName,
+ attachmentType: a.attachmentType,
+ uploadedBy: a.uploadedBy,
+ createdAt: a.createdAt instanceof Date ? a.createdAt.toISOString() : a.createdAt,
+ }));
+ return { success: true, data: { items } };
+ }
+ return { success: false, message: res.message };
+ },
+
+ async createLessonPlanAttachment(input) {
+ const res = await createLessonPlanAttachmentAction(input);
+ if (res.success && res.data) {
+ const a = res.data.attachment;
+ const attachment = a
+ ? {
+ id: a.id,
+ planId: a.planId,
+ blockId: a.blockId ?? undefined,
+ fileId: a.fileId,
+ displayName: a.displayName,
+ attachmentType: a.attachmentType,
+ uploadedBy: a.uploadedBy,
+ createdAt: a.createdAt instanceof Date ? a.createdAt.toISOString() : a.createdAt,
+ }
+ : null;
+ return { success: true, data: { attachment } };
+ }
+ return { success: false, message: res.message };
+ },
+
+ async deleteLessonPlanAttachment(attachmentId) {
+ const res = await deleteLessonPlanAttachmentAction(attachmentId);
+ return { success: res.success, message: res.message };
+ },
};
}
diff --git a/src/modules/lesson-preparation/types.ts b/src/modules/lesson-preparation/types.ts
index c50b641..18e4a54 100644
--- a/src/modules/lesson-preparation/types.ts
+++ b/src/modules/lesson-preparation/types.ts
@@ -71,6 +71,26 @@ export type LessonNodeType = BlockType | TextbookContentNodeType;
export interface RichTextBlockData {
html: string;
knowledgePointIds: string[];
+ /**
+ * V5-5:节点内嵌入的多媒体附件引用。
+ * 字段 attachmentId 关联 lessonPlanAttachments 表;url 为渲染用相对/绝对地址;
+ * kind 决定 UI 渲染(image/audio/video/file)。
+ * 旧数据无此字段时按空数组处理(document-migration 兼容)。
+ */
+ attachments?: RichTextAttachment[];
+}
+
+/** V5-5:富文本节点内的附件引用 */
+export interface RichTextAttachment {
+ attachmentId: string;
+ fileId: string;
+ displayName: string;
+ /** MIME 大类,决定 UI 渲染方式 */
+ kind: "image" | "audio" | "video" | "file";
+ /** 渲染用 URL(由 /api/upload 返回的 url) */
+ url: string;
+ /** MIME 类型(如 image/png),用于 标签 */
+ mimeType?: string;
}
// 文本研习
@@ -209,6 +229,26 @@ export type BlockData =
| BlackboardBlockData
| ReflectionBlockData;
+// V5-15 T1:教学阶段(用于画布节点分组与可视化排序)
+// - import: 导入环节
+// - new_teaching: 新授环节
+// - consolidation: 巩固练习
+// - summary: 总结提升
+// 未设置(undefined)表示教师未显式归类
+export type TeachingStage = "import" | "new_teaching" | "consolidation" | "summary";
+
+// V5-15 i18n 键后缀(与 TeachingStage 一一对应)
+export const TEACHING_STAGE_KEYS = ["import", "new_teaching", "consolidation", "summary"] as const;
+
+// V5-18 W6:差异化教学标记(按学生水平分组)
+// - basic: 基础(全员必做)
+// - intermediate: 提高(多数学生)
+// - advanced: 拓展(学有余力)
+export type DifferentiationLevel = "basic" | "intermediate" | "advanced";
+
+// V5-18 i18n 键后缀
+export const DIFFERENTIATION_LEVEL_KEYS = ["basic", "intermediate", "advanced"] as const;
+
// Block 联合
export interface Block {
id: string;
@@ -216,6 +256,10 @@ export interface Block {
title: string;
data: BlockData;
order: number;
+ /** V5-15 T1:教学阶段分组(可选,教师可显式归类节点)*/
+ stage?: TeachingStage;
+ /** V5-18 W6:差异化教学标记(可选,标注此节点的目标学生水平)*/
+ differentiation?: DifferentiationLevel;
}
// 教学节点(Block + 画布坐标)