P0-1 跨模块直查修复:publish-service 不再直查 examQuestions 表,新增 exams/data-access.addExamQuestions 接口,复用 classes/data-access.getStudentIdsByClassIds P0-2 i18n 接入:新增 zh-CN/en 翻译文件,注册 lessonPreparation 命名空间,17 个组件改造为 useTranslations/getTranslations P1 纯函数抽取:lib/document-migration.ts(类型守卫替代 as 断言)、lib/node-summary.ts(翻译函数注入)、lib/rf-mappers.ts P1 错误边界+骨架屏:新增 LessonPlanErrorBoundary 和 4 个 Skeleton 组件 P1 Block 注册表:新增 config/block-registry.tsx(BlockRenderer 组件),node-edit-panel 重构为配置驱动渲染 P1 其他修复:exercise-block 改用 router.refresh(),node-editor/lesson-node 复用 lib/ 纯函数 架构图同步:更新 004 和 005 文档 Refs: docs/architecture/audit/lesson-preparation-audit-report.md
150 lines
4.5 KiB
TypeScript
150 lines
4.5 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useMemo } from "react";
|
||
import { useTranslations } from "next-intl";
|
||
import {
|
||
ReactFlow,
|
||
Background,
|
||
Controls,
|
||
MiniMap,
|
||
type Node,
|
||
type Edge,
|
||
type NodeChange,
|
||
type EdgeChange,
|
||
type Connection,
|
||
applyEdgeChanges,
|
||
BackgroundVariant,
|
||
} from "@xyflow/react";
|
||
import "@xyflow/react/dist/style.css";
|
||
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
||
import { LessonNode } from "./nodes/lesson-node";
|
||
import { toRfNodes, toRfEdges } from "../lib/rf-mappers";
|
||
import type { LessonPlanNode } from "../types";
|
||
|
||
const nodeTypes = { lesson: LessonNode };
|
||
|
||
// NodeEditor 只负责画布交互,内容编辑由 NodeEditPanel 处理
|
||
type Props = Record<string, never>;
|
||
|
||
export function NodeEditor({}: Props) {
|
||
const t = useTranslations("lessonPreparation");
|
||
const { doc, selectedNodeId, updateNodePosition, removeNode, connect, selectNode, setEdges } =
|
||
useLessonPlanEditor();
|
||
|
||
// 使用纯函数映射 nodes/edges
|
||
const rfNodes: Node[] = useMemo(
|
||
() => toRfNodes(doc.nodes, selectedNodeId),
|
||
[doc.nodes, selectedNodeId],
|
||
);
|
||
|
||
const rfEdges: Edge[] = useMemo(
|
||
() => toRfEdges(doc.edges),
|
||
[doc.edges],
|
||
);
|
||
|
||
const onNodesChange = useCallback(
|
||
(changes: NodeChange[]) => {
|
||
changes.forEach((change) => {
|
||
if (change.type === "position" && change.position) {
|
||
// 拖拽结束时(dragging: false)才写入最终位置,避免中间状态污染(修复 P1-1)
|
||
if (change.dragging === false) {
|
||
updateNodePosition(change.id, change.position);
|
||
}
|
||
} else if (change.type === "remove") {
|
||
removeNode(change.id);
|
||
} else if (change.type === "select") {
|
||
selectNode(change.selected ? change.id : null);
|
||
}
|
||
});
|
||
},
|
||
[updateNodePosition, removeNode, selectNode],
|
||
);
|
||
|
||
const onConnect = useCallback(
|
||
(conn: Connection) => {
|
||
if (conn.source && conn.target) {
|
||
connect(conn.source, conn.target);
|
||
}
|
||
},
|
||
[connect],
|
||
);
|
||
|
||
// 同步 edges 变化(如拖拽重连)
|
||
const onEdgesChangeSync = useCallback(
|
||
(changes: EdgeChange[]) => {
|
||
// 简单处理:删除时调用 disconnect
|
||
const nextEdges = applyEdgeChanges(changes, rfEdges);
|
||
const ourEdges = nextEdges.map((e) => ({
|
||
id: e.id,
|
||
source: e.source,
|
||
target: e.target,
|
||
sourceHandle: e.sourceHandle ?? null,
|
||
targetHandle: e.targetHandle ?? null,
|
||
}));
|
||
setEdges(ourEdges);
|
||
},
|
||
[rfEdges, setEdges],
|
||
);
|
||
|
||
return (
|
||
<div className="w-full h-full relative">
|
||
{doc.nodes.length === 0 && (
|
||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||
<div className="text-center text-on-surface-variant">
|
||
<p className="text-lg font-medium">{t("editor.canvasEmpty")}</p>
|
||
<p className="text-sm mt-1">{t("editor.canvasEmptyHint")}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<ReactFlow
|
||
nodes={rfNodes}
|
||
edges={rfEdges}
|
||
nodeTypes={nodeTypes}
|
||
onNodesChange={onNodesChange}
|
||
onEdgesChange={onEdgesChangeSync}
|
||
onConnect={onConnect}
|
||
onNodeClick={(_, node) => selectNode(node.id)}
|
||
onPaneClick={() => selectNode(null)}
|
||
fitView
|
||
fitViewOptions={{ padding: 0.2, maxZoom: 1.2 }}
|
||
defaultEdgeOptions={{
|
||
animated: true,
|
||
style: { stroke: "#1976d2", strokeWidth: 2 },
|
||
}}
|
||
proOptions={{ hideAttribution: true }}
|
||
className="bg-surface-container-low"
|
||
>
|
||
<Background
|
||
variant={BackgroundVariant.Dots}
|
||
gap={20}
|
||
size={1}
|
||
color="#ccc"
|
||
/>
|
||
<Controls className="!bg-surface !border-outline-variant" />
|
||
<MiniMap
|
||
className="!bg-surface !border-outline-variant"
|
||
nodeColor={(n) => {
|
||
const nodeData = (n.data as { node?: LessonPlanNode }).node;
|
||
if (!nodeData) return "#9e9e9e";
|
||
const colors: Record<string, string> = {
|
||
objective: "#4caf50",
|
||
key_point: "#f44336",
|
||
import: "#2196f3",
|
||
new_teaching: "#9c27b0",
|
||
consolidation: "#ff9800",
|
||
summary: "#607d8b",
|
||
homework: "#795548",
|
||
blackboard: "#009688",
|
||
text_study: "#3f51b5",
|
||
exercise: "#e91e63",
|
||
rich_text: "#9e9e9e",
|
||
reflection: "#cddc39",
|
||
};
|
||
return colors[nodeData.type] ?? "#9e9e9e";
|
||
}}
|
||
/>
|
||
</ReactFlow>
|
||
</div>
|
||
);
|
||
}
|