refactor(lesson-preparation): 主编辑器三栏布局(结构树 + 纸 + 详情面板)

- 移除 NodeEditor/NodeEditPanel 导入和 JSX
- 改为 StructureTree + PaperEditor + DetailPanel 三栏布局
- 新增 AnchorMigrationBanner
- 移除 aiContentGenerator prop(V4 DetailPanel 自带 AI 按钮)
- 删除 node-edit-panel.tsx(不再被引用)
- 删除 ai-content-generator-slot.tsx(依赖 node-edit-panel)
This commit is contained in:
SpecialX
2026-07-04 11:44:26 +08:00
parent e29f30d278
commit 124ed97060
4 changed files with 23 additions and 416 deletions

View File

@@ -1,27 +0,0 @@
"use client";
import { AiLessonContentGenerator } from "@/modules/ai/components/ai-lesson-content-generator";
import { useAiClientOptional } from "@/modules/ai/context/ai-client-provider";
import type { AiContentGeneratorSlotProps } from "@/modules/lesson-preparation/components/node-edit-panel";
/**
* P0-11 修复AI 内容生成器 slot 实现。
* 此组件在 app 层组合 @/modules/ai 的具体实现,
* 通过 props 注入到 lesson-preparation 模块的 NodeEditPanel
* 避免备课模块直接依赖 @/modules/ai。
*/
export function AiContentGeneratorSlot({
topic,
textbookId,
chapterId,
}: AiContentGeneratorSlotProps) {
const aiClient = useAiClientOptional();
if (!aiClient) return null;
return (
<AiLessonContentGenerator
topic={topic}
textbookId={textbookId}
chapterId={chapterId}
/>
);
}

View File

@@ -11,8 +11,6 @@ import { Permissions } from "@/shared/types/permissions"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { AiClientProvider } from "@/modules/ai/context/ai-client-provider"
import { createCoreAiClientService } from "@/modules/ai/context/create-ai-client-service"
// P0-11 修复:通过 slot 组件注入 AI 功能,避免备课模块直接 import @/modules/ai
import { AiContentGeneratorSlot } from "./ai-content-generator-slot"
export const dynamic = "force-dynamic"
@@ -70,7 +68,6 @@ export default async function EditLessonPlanPage({
textbookTitle={textbookTitle}
chapterTitle={chapterTitle}
classes={classes}
aiContentGenerator={AiContentGeneratorSlot}
/>
</Suspense>
</div>

View File

@@ -3,15 +3,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { NodeEditor } from "./node-editor";
import { NodeEditPanel, type AiContentGeneratorSlot } from "./node-edit-panel";
import { StructureTree } from "./structure-tree/structure-tree";
import { PaperEditor } from "./paper-editor/paper-editor";
import { DetailPanel } from "./detail-panel/detail-panel";
import { AnchorMigrationBanner } from "./anchor-migration-banner";
import { VersionHistoryDrawer } from "./version-history-drawer";
import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary";
import {
useLessonPlanContextSafe,
useLessonPlanTrackerSafe,
} from "../providers/lesson-plan-provider";
import type { BlockType, LessonPlanStatus } from "../types";
import type { LessonPlanStatus } from "../types";
import { Button } from "@/shared/components/ui/button";
import {
AlertDialog,
@@ -24,7 +26,7 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/components/ui/alert-dialog";
import { Plus, Save, History, Book, FileText, Send, Undo2, RotateCw, WifiOff, Printer, Undo, Redo, CalendarClock, ClipboardCheck, Sparkles, Layers } from "lucide-react";
import { Save, History, Book, FileText, Send, Undo2, RotateCw, WifiOff, Printer, Undo, Redo, CalendarClock, ClipboardCheck, Sparkles, Layers } from "lucide-react";
import { toast } from "sonner";
import { PrintView } from "./print-view";
import { ScheduleDialog } from "./schedule-dialog";
@@ -43,25 +45,8 @@ interface Props {
textbookTitle?: string;
chapterTitle?: string;
classes?: { id: string; name: string }[];
/** AI 内容生成器(可选,通过 props 注入避免模块耦合P0-11 修复)*/
aiContentGenerator?: AiContentGeneratorSlot;
}
const BLOCK_TYPES_TO_ADD: BlockType[] = [
"objective",
"key_point",
"import",
"new_teaching",
"consolidation",
"summary",
"homework",
"blackboard",
"exercise",
"text_study",
"rich_text",
"reflection",
];
export function LessonPlanEditor({
planId,
initialTitle,
@@ -72,7 +57,6 @@ export function LessonPlanEditor({
textbookTitle,
chapterTitle,
classes,
aiContentGenerator,
}: Props) {
const t = useTranslations("lessonPreparation");
const editor = useLessonPlanEditor();
@@ -80,7 +64,6 @@ export function LessonPlanEditor({
const ctx = useLessonPlanContextSafe();
const service = ctx?.service ?? null;
const [showVersions, setShowVersions] = useState(false);
const [showAddMenu, setShowAddMenu] = useState(false);
const [showPrint, setShowPrint] = useState(false); // V5-4打印视图
const [showSchedule, setShowSchedule] = useState(false); // V5-7安排课时对话框
const [showConsistency, setShowConsistency] = useState(false); // V5-19一致性校验对话框
@@ -90,7 +73,6 @@ export function LessonPlanEditor({
const [publishing, setPublishing] = useState(false);
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const versionTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const addMenuRef = useRef<HTMLDivElement>(null);
// 初始化:仅在 planId 变化时 hydrate修复 P1-3
const initKey = planId;
@@ -261,18 +243,6 @@ export function LessonPlanEditor({
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, []);
// 添加节点菜单点击外部关闭P3-2
useEffect(() => {
if (!showAddMenu) return;
function handleClickOutside(e: MouseEvent) {
if (addMenuRef.current && !addMenuRef.current.contains(e.target as Node)) {
setShowAddMenu(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showAddMenu]);
const handleManualSave = useCallback(async () => {
if (!service) return;
const state = useLessonPlanEditor.getState();
@@ -524,53 +494,24 @@ export function LessonPlanEditor({
)}
</div>
{/* 主区域:画布 + 侧边面板 */}
<div className="flex-1 flex overflow-hidden">
{/* 节点画布 */}
<div className="flex-1 relative">
<LessonPlanErrorBoundary>
<NodeEditor />
</LessonPlanErrorBoundary>
{/* 添加节点浮动按钮 */}
<div className="absolute bottom-4 left-4 z-10" ref={addMenuRef}>
<Button
variant="default"
onClick={() => setShowAddMenu(!showAddMenu)}
>
<Plus className="w-4 h-4 mr-1" /> {t("action.addNode")}
</Button>
{showAddMenu && (
<div className="absolute bottom-12 left-0 bg-surface border border-outline-variant rounded-lg shadow-lg p-2 grid grid-cols-2 gap-1 w-72 max-h-[60vh] overflow-y-auto">
{BLOCK_TYPES_TO_ADD.map((blockType) => (
<button
key={blockType}
onClick={() => {
editor.addNode(blockType, undefined, t(`blockType.${blockType}`));
setShowAddMenu(false);
}}
className="text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded"
>
{t(`blockType.${blockType}`)}
</button>
))}
</div>
)}
</div>
</div>
{/* V4v3 锚点失效提示 banner */}
<AnchorMigrationBanner />
{/* 侧边内容编辑面板:直接用 selectedNodeId 控制显示(修复 P1-2 */}
{editor.selectedNodeId && (
<div className="w-[420px] flex-shrink-0">
<NodeEditPanel
planId={planId}
textbookId={textbookId}
chapterId={chapterId}
classes={classes}
aiContentGenerator={aiContentGenerator}
/>
</div>
)}
</div>
{/* V4三栏布局结构树 + 纸区 + 详情面板 */}
<LessonPlanErrorBoundary>
<main
style={{
display: "grid",
gridTemplateColumns: "260px 1fr 380px",
height: "calc(100vh - 52px)",
overflow: "hidden",
}}
>
<StructureTree />
<PaperEditor />
<DetailPanel />
</main>
</LessonPlanErrorBoundary>
<LessonPlanErrorBoundary>
<VersionHistoryDrawer

View File

@@ -1,304 +0,0 @@
"use client";
import { useState, type ComponentType } from "react";
import { useTranslations } from "next-intl";
import { Sparkles, ChevronDown, ChevronUp } from "lucide-react";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { BlockRenderer, BLOCK_REGISTRY } from "../config/block-registry";
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 类型。
* 通过 props 注入 AI 组件,避免直接 import @/modules/ai。
* 调用方teacher edit 页面)提供具体实现。
*/
export interface AiContentGeneratorSlotProps {
topic: string;
textbookId?: string;
chapterId?: string;
}
export type AiContentGeneratorSlot = ComponentType<AiContentGeneratorSlotProps>;
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, planId, aiContentGenerator }: Props) {
const t = useTranslations("lessonPreparation");
const tAi = useTranslations("ai");
const { doc, selectedNodeId, updateNode, removeNode, selectNode, removeAnchor } =
useLessonPlanEditor();
const [showAiPanel, setShowAiPanel] = useState(false);
const node = doc.nodes.find((n) => n.id === selectedNodeId);
if (!node) {
return (
<div className="h-full flex items-center justify-center text-on-surface-variant text-sm p-4">
{t("editor.selectNodeHint")}
</div>
);
}
// P2-1正文节点显示操作提示 + 锚点列表(而非误导性的"内容为空"
if (node.type === "textbook_content") {
// 收集所有锚点,并关联到对应的教学节点
const anchorsWithNode = doc.anchors
.map((a) => {
const linkedNode = doc.nodes.find((n) => n.id === a.nodeId);
return { anchor: a, nodeTitle: linkedNode?.title ?? "?", nodeType: linkedNode?.type ?? "rich_text" };
})
.sort((a, b) => a.anchor.start - b.anchor.start);
return (
<div className="h-full flex flex-col border-l border-outline-variant bg-surface">
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant">
<span className="flex-1 font-title-md text-title-md">
{t("editor.textbookContent")}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => selectNode(null)}
aria-label={t("action.close")}
>
<X className="w-4 h-4" aria-hidden="true" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* 操作提示 */}
<div className="rounded-md border border-outline-variant bg-surface-container-low p-3 text-sm text-on-surface-variant">
{t("editor.textbookOperateHint")}
</div>
{/* 锚点列表 */}
<div>
<div className="text-xs font-medium text-on-surface-variant mb-2">
{t("editor.anchorListTitle")}{anchorsWithNode.length}
</div>
{anchorsWithNode.length === 0 ? (
<p className="text-sm text-on-surface-variant italic">
{t("editor.anchorListEmpty")}
</p>
) : (
<ul className="space-y-1">
{anchorsWithNode.map(({ anchor, nodeTitle, nodeType }) => (
<li
key={anchor.id}
className="flex items-center gap-2 px-2 py-1.5 rounded border border-outline-variant bg-surface-container-lowest text-sm"
>
<span
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: getNodeColor(nodeType) }}
/>
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-secondary text-secondary-foreground flex-shrink-0">
{anchor.type === "range"
? t("editor.anchorRangeLabel")
: t("editor.anchorPointLabel")}
</span>
<span className="truncate flex-1" title={nodeTitle}>
{nodeTitle}
</span>
{anchor.textPreview && (
<span className="truncate text-xs text-on-surface-variant max-w-[120px]" title={anchor.textPreview}>
&ldquo;{anchor.textPreview}&rdquo;
</span>
)}
<Button
variant="ghost"
size="sm"
className="!p-1 !h-6 !w-6 text-on-surface-variant hover:text-error"
onClick={() => removeAnchor(anchor.id)}
aria-label={t("action.delete")}
>
<Trash2 className="w-3 h-3" />
</Button>
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
}
// 教学节点textbook_content 分支已上方 return此处 TypeScript 已收窄为 LessonPlanNode
const lessonNode = node;
// 从节点标题提取主题用于 AI 内容生成
const aiTopic = lessonNode.title || t("editor.textbookContent");
return (
<div className="h-full flex flex-col border-l border-outline-variant bg-surface">
{/* 面板头部 */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant">
<input
value={lessonNode.title}
onChange={(e) => updateNode(lessonNode.id, { title: e.target.value })}
className="flex-1 bg-transparent font-title-md text-title-md focus:outline-none"
aria-label={lessonNode.title}
/>
<Button
variant="ghost"
size="sm"
onClick={() => selectNode(null)}
aria-label={t("action.close")}
>
<X className="w-4 h-4" aria-hidden="true" />
</Button>
</div>
{/* V5-15 T1 + V5-18 W6节点属性条教学阶段 + 差异化标记) */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant bg-surface-container-low">
{/* 教学阶段 */}
<label className="text-xs text-on-surface-variant flex items-center gap-1">
{t("editor.stageLabel")}
</label>
<select
value={lessonNode.stage ?? ""}
onChange={(e) => {
const v = e.target.value;
updateNode(lessonNode.id, {
stage: v === "" ? undefined : (v as TeachingStage),
});
}}
className="text-xs border border-outline-variant rounded px-1.5 py-0.5 bg-surface"
aria-label={t("editor.stageLabel")}
>
<option value="">{t("editor.stageNone")}</option>
{TEACHING_STAGE_KEYS.map((s) => (
<option key={s} value={s}>
{t(`editor.stage.${s}`)}
</option>
))}
</select>
{/* 差异化标记 */}
<label className="text-xs text-on-surface-variant flex items-center gap-1 ml-2">
{t("editor.differentiationLabel")}
</label>
<select
value={lessonNode.differentiation ?? ""}
onChange={(e) => {
const v = e.target.value;
updateNode(lessonNode.id, {
differentiation: v === "" ? undefined : (v as DifferentiationLevel),
});
}}
className="text-xs border border-outline-variant rounded px-1.5 py-0.5 bg-surface"
aria-label={t("editor.differentiationLabel")}
>
<option value="">{t("editor.differentiationNone")}</option>
{DIFFERENTIATION_LEVEL_KEYS.map((d) => (
<option key={d} value={d}>
{t(`editor.differentiation.${d}`)}
</option>
))}
</select>
</div>
{/* 内容编辑区 - 使用 Error Boundary 包裹 + BlockRenderer 配置驱动渲染 */}
<div className="flex-1 overflow-y-auto p-3">
<LessonPlanErrorBoundary>
<BlockRenderer
type={lessonNode.type}
blockId={lessonNode.id}
data={lessonNode.data}
textbookId={textbookId}
chapterId={chapterId}
classes={classes}
planId={planId}
onUpdate={(d) => updateNode(lessonNode.id, { data: d })}
/>
{/* BlockRenderer 返回 null 时显示未知类型提示 */}
<UnknownBlockHint type={lessonNode.type} t={t} />
</LessonPlanErrorBoundary>
{/* AI 内容生成区(可折叠)— P0-11 修复:通过 props 注入,不直接 import @/modules/ai */}
{aiContentGenerator ? (
<div className="mt-4 border-t border-outline-variant pt-3">
<Button
variant="ghost"
size="sm"
className="w-full justify-between"
onClick={() => setShowAiPanel(!showAiPanel)}
aria-expanded={showAiPanel}
>
<span className="flex items-center gap-1">
<Sparkles className="w-3.5 h-3.5 text-primary" />
{tAi("lessonPrep.generateContent")}
</span>
{showAiPanel ? (
<ChevronUp className="w-3.5 h-3.5" />
) : (
<ChevronDown className="w-3.5 h-3.5" />
)}
</Button>
{showAiPanel ? (
<div className="mt-2">
{(() => {
const AiGenerator = aiContentGenerator;
return (
<AiGenerator
topic={aiTopic}
textbookId={textbookId}
chapterId={chapterId}
/>
);
})()}
</div>
) : null}
</div>
) : null}
</div>
{/* 底部操作 */}
<div className="px-4 py-2 border-t border-outline-variant">
<Button
variant="outline"
size="sm"
className="text-error"
onClick={() => removeNode(lessonNode.id)}
>
<Trash2 className="w-3 h-3 mr-1" aria-hidden="true" />
{t("action.delete")}
</Button>
</div>
</div>
);
}
/**
* 未知 Block 类型提示(配置驱动,当 BlockRenderer 返回 null 时显示)。
* 独立组件避免在主组件中条件渲染。
*/
function UnknownBlockHint({
type,
t,
}: {
type: string;
t: ReturnType<typeof useTranslations>;
}) {
// V4 P1-9 修复knownTypes 从 BLOCK_REGISTRY 派生,消除重复定义
const knownTypes: readonly string[] = Object.keys(BLOCK_REGISTRY);
if (knownTypes.includes(type)) {
return null;
}
return (
<div className="text-on-surface-variant text-sm p-4">
{t("editor.unknownBlockType")}
</div>
);
}