-
+
+
+
+
+
+
+
+
{error && (
{error}
diff --git a/src/modules/lesson-preparation/components/lesson-plan-publish-button.tsx b/src/modules/lesson-preparation/components/lesson-plan-publish-button.tsx
new file mode 100644
index 0000000..8ce6e98
--- /dev/null
+++ b/src/modules/lesson-preparation/components/lesson-plan-publish-button.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import type { JSX } from "react";
+import { useTranslations } from "next-intl";
+import { Send, Undo2 } from "lucide-react";
+import type { LessonPlanStatus } from "../types";
+import { Button } from "@/shared/components/ui/button";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/shared/components/ui/alert-dialog";
+
+interface Props {
+ planStatus: LessonPlanStatus;
+ publishing: boolean;
+ onPublish: () => void;
+ onUnpublish: () => void;
+}
+
+export function LessonPlanPublishButton({
+ planStatus,
+ publishing,
+ onPublish,
+ onUnpublish,
+}: Props): JSX.Element {
+ const t = useTranslations("lessonPreparation");
+ if (planStatus === "published") {
+ return (
+
+
+
+
+
+
+ {t("action.unpublishPlan")}
+
+ {t("action.unpublishPlanConfirm")}
+
+
+
+ {t("action.cancel")}
+
+ {t("action.confirm")}
+
+
+
+
+ );
+ }
+ return (
+
+
+
+
+
+
+ {t("action.publishPlan")}
+
+ {t("action.publishPlanConfirm")}
+
+
+
+ {t("action.cancel")}
+
+ {t("action.confirm")}
+
+
+
+
+ );
+}
diff --git a/src/modules/lesson-preparation/components/lesson-plan-skeleton.tsx b/src/modules/lesson-preparation/components/lesson-plan-skeleton.tsx
deleted file mode 100644
index 903b196..0000000
--- a/src/modules/lesson-preparation/components/lesson-plan-skeleton.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import type { JSX } from "react";
-import { Skeleton } from "@/shared/components/ui/skeleton";
-
-/** 版本列表骨架屏 */
-export function VersionListSkeleton(): JSX.Element {
- return (
-
- {Array.from({ length: 5 }).map((_, i) => (
-
- ))}
-
- );
-}
-
-/** 题库列表骨架屏 */
-export function QuestionBankSkeleton(): JSX.Element {
- return (
-
- {Array.from({ length: 6 }).map((_, i) => (
-
-
-
-
-
- ))}
-
- );
-}
-
-/** 知识点列表骨架屏 */
-export function KnowledgePointSkeleton(): JSX.Element {
- return (
-
- {Array.from({ length: 8 }).map((_, i) => (
-
-
-
-
- ))}
-
- );
-}
-
-/** 课案列表骨架屏 */
-export function LessonPlanListSkeleton(): JSX.Element {
- return (
-
- {Array.from({ length: 6 }).map((_, i) => (
-
- ))}
-
- );
-}
diff --git a/src/modules/lesson-preparation/components/lesson-plan-toolbar.tsx b/src/modules/lesson-preparation/components/lesson-plan-toolbar.tsx
new file mode 100644
index 0000000..04dbdc6
--- /dev/null
+++ b/src/modules/lesson-preparation/components/lesson-plan-toolbar.tsx
@@ -0,0 +1,212 @@
+"use client";
+
+import type { JSX } from "react";
+import { useTranslations } from "next-intl";
+import {
+ Save,
+ History,
+ Book,
+ FileText,
+ RotateCw,
+ WifiOff,
+ Printer,
+ Undo,
+ Redo,
+ CalendarClock,
+ ClipboardCheck,
+ Sparkles,
+ Layers,
+} from "lucide-react";
+import type { LessonPlanStatus } from "../types";
+import { Button } from "@/shared/components/ui/button";
+import { LessonPlanPublishButton } from "./lesson-plan-publish-button";
+
+interface Props {
+ title: string;
+ onTitleChange: (value: string) => void;
+ textbookTitle?: string;
+ chapterTitle?: string;
+ isSaving: boolean;
+ isDirty: boolean;
+ saveError: boolean;
+ isOnline: boolean;
+ canUndo: boolean;
+ canRedo: boolean;
+ onRetrySave: () => void;
+ onUndo: () => void;
+ onRedo: () => void;
+ onManualSave: () => void;
+ onShowVersions: () => void;
+ onShowPrint: () => void;
+ onShowSchedule: () => void;
+ onShowConsistency: () => void;
+ onShowAiFeedback: () => void;
+ onShowAiDifferentiation: () => void;
+ planStatus: LessonPlanStatus;
+ publishing: boolean;
+ onPublish: () => void;
+ onUnpublish: () => void;
+ showScheduleButton: boolean;
+}
+
+export function LessonPlanToolbar({
+ title,
+ onTitleChange,
+ textbookTitle,
+ chapterTitle,
+ isSaving,
+ isDirty,
+ saveError,
+ isOnline,
+ canUndo,
+ canRedo,
+ onRetrySave,
+ onUndo,
+ onRedo,
+ onManualSave,
+ onShowVersions,
+ onShowPrint,
+ onShowSchedule,
+ onShowConsistency,
+ onShowAiFeedback,
+ onShowAiDifferentiation,
+ planStatus,
+ publishing,
+ onPublish,
+ onUnpublish,
+ showScheduleButton,
+}: Props): JSX.Element {
+ const t = useTranslations("lessonPreparation");
+ return (
+
+
onTitleChange(e.target.value)}
+ className="flex-1 bg-transparent font-headline-md text-headline-md focus:outline-none"
+ />
+ {/* 教材/章节指示器 */}
+ {textbookTitle && (
+
+
+ {/* arbitrary-value: layout fixed size */}
+ {textbookTitle}
+ {chapterTitle && (
+ <>
+ /
+
+ {/* arbitrary-value: layout fixed size */}
+ {chapterTitle}
+ >
+ )}
+
+ )}
+
+ {isSaving
+ ? t("status.saving")
+ : isDirty
+ ? t("status.unsaved")
+ : t("status.saved")}
+
+ {/* V5-1:保存失败/离线提示与重试按钮 */}
+ {saveError && (
+
+ )}
+ {!isOnline && (
+
+
+ {t("status.offlineBadge")}
+
+ )}
+
+ {/* V5-2:撤销/重做按钮 */}
+
+
+
+ {/* V5-4:导出/打印按钮 */}
+
+ {/* V5-7:安排课时按钮 */}
+ {showScheduleButton && (
+
+ )}
+ {/* V5-19 T3:一致性校验按钮 */}
+
+ {/* V5-17 A1/A2:AI 反馈按钮 */}
+
+ {/* V5-21 A3/A4/A5:AI 差异化与课标核对按钮 */}
+
+ {/* 发布/撤回发布按钮(P0-1 修复)*/}
+
+
+ );
+}
diff --git a/src/modules/lesson-preparation/components/question-bank-picker.tsx b/src/modules/lesson-preparation/components/question-bank-picker.tsx
index c88c063..e3b3968 100644
--- a/src/modules/lesson-preparation/components/question-bank-picker.tsx
+++ b/src/modules/lesson-preparation/components/question-bank-picker.tsx
@@ -6,7 +6,7 @@ import { useQuestionService } from "../providers/lesson-plan-provider"
import type { QuestionPickerItem, QuestionPickerParams } from "../providers/lesson-plan-provider"
import { Button } from "@/shared/components/ui/button"
import { FocusTrap } from "@/shared/components/a11y/focus-trap"
-import { QuestionBankSkeleton } from "./lesson-plan-skeleton"
+import { SkeletonCard } from "@/shared/components/ui/skeleton"
import { useDebounce } from "@/shared/hooks/use-debounce"
import { X, ChevronDown, ChevronRight } from "lucide-react"
import { QuestionBankFilters } from "@/shared/components/question/question-bank-filters"
@@ -196,7 +196,7 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
{loading ? (
-
+
) : error ? (
{error}
) : questions.length === 0 ? (
diff --git a/src/modules/lesson-preparation/components/version-history-drawer.tsx b/src/modules/lesson-preparation/components/version-history-drawer.tsx
index 6ad4e2b..b6b73b2 100644
--- a/src/modules/lesson-preparation/components/version-history-drawer.tsx
+++ b/src/modules/lesson-preparation/components/version-history-drawer.tsx
@@ -6,7 +6,7 @@ import { toast } from "sonner";
import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
import { Button } from "@/shared/components/ui/button";
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
-import { VersionListSkeleton } from "./lesson-plan-skeleton";
+import { SkeletonCard } from "@/shared/components/ui/skeleton";
import {
AlertDialog,
AlertDialogAction,
@@ -128,7 +128,7 @@ export function VersionHistoryDrawer({
<>
{t("version.title")}
{loading ? (
-
+
) : versions.length === 0 ? (
{t("version.empty")}
) : (
diff --git a/src/modules/lesson-preparation/hooks/use-lesson-plan-persistence.ts b/src/modules/lesson-preparation/hooks/use-lesson-plan-persistence.ts
new file mode 100644
index 0000000..3dac12b
--- /dev/null
+++ b/src/modules/lesson-preparation/hooks/use-lesson-plan-persistence.ts
@@ -0,0 +1,155 @@
+"use client";
+
+import { useCallback, useEffect, useRef } from "react";
+import { useTranslations } from "next-intl";
+import { toast } from "sonner";
+import { useLessonPlanEditor } from "./use-lesson-plan-editor";
+import type {
+ LessonPlanDataService,
+ LessonPlanTracker,
+} from "../providers/lesson-plan-provider";
+
+interface Params {
+ planId: string;
+ service: LessonPlanDataService | null;
+ tracker: LessonPlanTracker;
+}
+
+interface PersistenceActions {
+ handleRetrySave: () => Promise
;
+ handleManualSave: () => Promise;
+ handleReverted: () => Promise;
+}
+
+/**
+ * 课案持久化逻辑:自动保存(debounce 3s)、定时版本(30min)、手动保存/重试/回退刷新。
+ * 从 LessonPlanEditor 容器提取,避免容器超过 300 行。
+ */
+export function useLessonPlanPersistence({
+ planId,
+ service,
+ tracker,
+}: Params): PersistenceActions {
+ const t = useTranslations("lessonPreparation");
+ const isDirty = useLessonPlanEditor((s) => s.isDirty);
+ const isOnline = useLessonPlanEditor((s) => s.isOnline);
+ const doc = useLessonPlanEditor((s) => s.doc);
+ const autoSaveTimer = useRef | null>(null);
+ const versionTimer = useRef | null>(null);
+
+ // 自动保存(debounce 3s)- 用 getState() 获取最新值(修复 P1-4)
+ // V5-1:保存失败显示 toast + 设置 saveError;断网时不触发保存
+ useEffect(() => {
+ if (!isDirty || !service || !isOnline) return;
+ if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
+ autoSaveTimer.current = setTimeout(async () => {
+ 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) {
+ 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);
+ }
+ }, 3000);
+ return () => {
+ if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
+ };
+ }, [isDirty, doc, planId, service, isOnline, t]);
+
+ // 定时自动版本(30min)
+ useEffect(() => {
+ if (!service) return;
+ versionTimer.current = setInterval(async () => {
+ const state = useLessonPlanEditor.getState();
+ if (!state.isDirty) return;
+ try {
+ await service.saveLessonPlanVersion({
+ planId: state.planId,
+ content: state.doc,
+ label: t("version.autoLabel"),
+ });
+ } catch (e) {
+ console.error("[LessonPlanEditor] auto-version failed", e);
+ }
+ }, 30 * 60 * 1000);
+ return () => {
+ if (versionTimer.current) clearInterval(versionTimer.current);
+ };
+ }, [planId, t, service]);
+
+ // 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]);
+
+ const handleManualSave = useCallback(async () => {
+ if (!service) return;
+ const state = useLessonPlanEditor.getState();
+ state.setSaving(true);
+ try {
+ const res = await service.saveLessonPlanVersion({
+ planId: state.planId,
+ content: state.doc,
+ });
+ if (res.success) {
+ state.markSaved();
+ tracker.track("lesson_plan.save", { planId: state.planId, source: "manual" });
+ }
+ } catch (e) {
+ console.error("[LessonPlanEditor] manual save failed", e);
+ } finally {
+ state.setSaving(false);
+ }
+ }, [tracker, service]);
+
+ // 版本回退后刷新内容(修复 P1-1)
+ const handleReverted = useCallback(async () => {
+ if (!service) return;
+ const state = useLessonPlanEditor.getState();
+ try {
+ const res = await service.getLessonPlanById(state.planId);
+ if (res.success && res.data?.plan) {
+ state.hydrate(state.planId, res.data.plan.title, res.data.plan.content);
+ }
+ } catch (e) {
+ console.error("[LessonPlanEditor] reload after revert failed", e);
+ }
+ }, [service]);
+
+ return { handleRetrySave, handleManualSave, handleReverted };
+}
diff --git a/src/shared/components/ui/stats-grid.tsx b/src/shared/components/ui/stats-grid.tsx
index 08ffcc1..8b7f667 100644
--- a/src/shared/components/ui/stats-grid.tsx
+++ b/src/shared/components/ui/stats-grid.tsx
@@ -11,7 +11,7 @@ import { StatCard } from "@/shared/components/ui/stat-card"
* - practice-stats-cards / practice-overview-stats-cards
* - analytics-stats-cards
*/
-interface StatItem {
+export interface StatItem {
/** 卡片标题 */
label: string
/** 统计数值 */
diff --git a/src/shared/i18n/messages/en/lesson-preparation.json b/src/shared/i18n/messages/en/lesson-preparation.json
index a691b94..127af58 100644
--- a/src/shared/i18n/messages/en/lesson-preparation.json
+++ b/src/shared/i18n/messages/en/lesson-preparation.json
@@ -591,6 +591,8 @@
"createFailed": "Creation failed",
"loadFailed": "Page load failed",
"loadFailedDesc": "Sorry, an unexpected error occurred. Please try again later.",
+ "boundaryTitle": "Lesson preparation section failed to load",
+ "boundaryDescription": "An error occurred while loading lesson preparation data. Please retry.",
"retry": "Retry",
"invalidQuestionType": "Invalid question type",
"duplicateSuffix": " - Copy",
diff --git a/src/shared/i18n/messages/zh-CN/lesson-preparation.json b/src/shared/i18n/messages/zh-CN/lesson-preparation.json
index bd150e2..0a034ce 100644
--- a/src/shared/i18n/messages/zh-CN/lesson-preparation.json
+++ b/src/shared/i18n/messages/zh-CN/lesson-preparation.json
@@ -591,6 +591,8 @@
"createFailed": "创建失败",
"loadFailed": "页面加载失败",
"loadFailedDesc": "抱歉,页面加载时发生了意外错误。请稍后重试。",
+ "boundaryTitle": "备课区块加载失败",
+ "boundaryDescription": "加载备课数据时发生错误,请重试。",
"retry": "重试",
"invalidQuestionType": "无效的题目类型",
"duplicateSuffix": " - 副本",