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:
@@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,8 +11,6 @@ import { Permissions } from "@/shared/types/permissions"
|
|||||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
import { AiClientProvider } from "@/modules/ai/context/ai-client-provider"
|
import { AiClientProvider } from "@/modules/ai/context/ai-client-provider"
|
||||||
import { createCoreAiClientService } from "@/modules/ai/context/create-ai-client-service"
|
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"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -70,7 +68,6 @@ export default async function EditLessonPlanPage({
|
|||||||
textbookTitle={textbookTitle}
|
textbookTitle={textbookTitle}
|
||||||
chapterTitle={chapterTitle}
|
chapterTitle={chapterTitle}
|
||||||
classes={classes}
|
classes={classes}
|
||||||
aiContentGenerator={AiContentGeneratorSlot}
|
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,15 +3,17 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
||||||
import { NodeEditor } from "./node-editor";
|
import { StructureTree } from "./structure-tree/structure-tree";
|
||||||
import { NodeEditPanel, type AiContentGeneratorSlot } from "./node-edit-panel";
|
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 { VersionHistoryDrawer } from "./version-history-drawer";
|
||||||
import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary";
|
import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary";
|
||||||
import {
|
import {
|
||||||
useLessonPlanContextSafe,
|
useLessonPlanContextSafe,
|
||||||
useLessonPlanTrackerSafe,
|
useLessonPlanTrackerSafe,
|
||||||
} from "../providers/lesson-plan-provider";
|
} from "../providers/lesson-plan-provider";
|
||||||
import type { BlockType, LessonPlanStatus } from "../types";
|
import type { LessonPlanStatus } from "../types";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -24,7 +26,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/shared/components/ui/alert-dialog";
|
} 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 { toast } from "sonner";
|
||||||
import { PrintView } from "./print-view";
|
import { PrintView } from "./print-view";
|
||||||
import { ScheduleDialog } from "./schedule-dialog";
|
import { ScheduleDialog } from "./schedule-dialog";
|
||||||
@@ -43,25 +45,8 @@ interface Props {
|
|||||||
textbookTitle?: string;
|
textbookTitle?: string;
|
||||||
chapterTitle?: string;
|
chapterTitle?: string;
|
||||||
classes?: { id: string; name: 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({
|
export function LessonPlanEditor({
|
||||||
planId,
|
planId,
|
||||||
initialTitle,
|
initialTitle,
|
||||||
@@ -72,7 +57,6 @@ export function LessonPlanEditor({
|
|||||||
textbookTitle,
|
textbookTitle,
|
||||||
chapterTitle,
|
chapterTitle,
|
||||||
classes,
|
classes,
|
||||||
aiContentGenerator,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
const editor = useLessonPlanEditor();
|
const editor = useLessonPlanEditor();
|
||||||
@@ -80,7 +64,6 @@ export function LessonPlanEditor({
|
|||||||
const ctx = useLessonPlanContextSafe();
|
const ctx = useLessonPlanContextSafe();
|
||||||
const service = ctx?.service ?? null;
|
const service = ctx?.service ?? null;
|
||||||
const [showVersions, setShowVersions] = useState(false);
|
const [showVersions, setShowVersions] = useState(false);
|
||||||
const [showAddMenu, setShowAddMenu] = useState(false);
|
|
||||||
const [showPrint, setShowPrint] = useState(false); // V5-4:打印视图
|
const [showPrint, setShowPrint] = useState(false); // V5-4:打印视图
|
||||||
const [showSchedule, setShowSchedule] = useState(false); // V5-7:安排课时对话框
|
const [showSchedule, setShowSchedule] = useState(false); // V5-7:安排课时对话框
|
||||||
const [showConsistency, setShowConsistency] = useState(false); // V5-19:一致性校验对话框
|
const [showConsistency, setShowConsistency] = useState(false); // V5-19:一致性校验对话框
|
||||||
@@ -90,7 +73,6 @@ export function LessonPlanEditor({
|
|||||||
const [publishing, setPublishing] = useState(false);
|
const [publishing, setPublishing] = useState(false);
|
||||||
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const versionTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
const versionTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const addMenuRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// 初始化:仅在 planId 变化时 hydrate(修复 P1-3)
|
// 初始化:仅在 planId 变化时 hydrate(修复 P1-3)
|
||||||
const initKey = planId;
|
const initKey = planId;
|
||||||
@@ -261,18 +243,6 @@ export function LessonPlanEditor({
|
|||||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
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 () => {
|
const handleManualSave = useCallback(async () => {
|
||||||
if (!service) return;
|
if (!service) return;
|
||||||
const state = useLessonPlanEditor.getState();
|
const state = useLessonPlanEditor.getState();
|
||||||
@@ -524,53 +494,24 @@ export function LessonPlanEditor({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 主区域:画布 + 侧边面板 */}
|
{/* V4:v3 锚点失效提示 banner */}
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<AnchorMigrationBanner />
|
||||||
{/* 节点画布 */}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 侧边内容编辑面板:直接用 selectedNodeId 控制显示(修复 P1-2) */}
|
{/* V4:三栏布局(结构树 + 纸区 + 详情面板) */}
|
||||||
{editor.selectedNodeId && (
|
<LessonPlanErrorBoundary>
|
||||||
<div className="w-[420px] flex-shrink-0">
|
<main
|
||||||
<NodeEditPanel
|
style={{
|
||||||
planId={planId}
|
display: "grid",
|
||||||
textbookId={textbookId}
|
gridTemplateColumns: "260px 1fr 380px",
|
||||||
chapterId={chapterId}
|
height: "calc(100vh - 52px)",
|
||||||
classes={classes}
|
overflow: "hidden",
|
||||||
aiContentGenerator={aiContentGenerator}
|
}}
|
||||||
/>
|
>
|
||||||
</div>
|
<StructureTree />
|
||||||
)}
|
<PaperEditor />
|
||||||
</div>
|
<DetailPanel />
|
||||||
|
</main>
|
||||||
|
</LessonPlanErrorBoundary>
|
||||||
|
|
||||||
<LessonPlanErrorBoundary>
|
<LessonPlanErrorBoundary>
|
||||||
<VersionHistoryDrawer
|
<VersionHistoryDrawer
|
||||||
|
|||||||
@@ -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}>
|
|
||||||
“{anchor.textPreview}”
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user