(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 + 画布坐标)