feat(lesson-preparation): 备课模块审计重构 — 跨模块解耦 + i18n + 纯函数抽取 + 错误边界
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
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
@@ -11,48 +12,33 @@ import {
|
||||
type NodeChange,
|
||||
type EdgeChange,
|
||||
type Connection,
|
||||
applyNodeChanges,
|
||||
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 };
|
||||
|
||||
interface Props {
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
classes?: { id: string; name: string }[];
|
||||
}
|
||||
// NodeEditor 只负责画布交互,内容编辑由 NodeEditPanel 处理
|
||||
type Props = Record<string, never>;
|
||||
|
||||
export function NodeEditor({ textbookId, chapterId, classes }: Props) {
|
||||
export function NodeEditor({}: Props) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const { doc, selectedNodeId, updateNodePosition, removeNode, connect, selectNode, setEdges } =
|
||||
useLessonPlanEditor();
|
||||
|
||||
// 我们的 nodes → React Flow nodes
|
||||
// 使用纯函数映射 nodes/edges
|
||||
const rfNodes: Node[] = useMemo(
|
||||
() =>
|
||||
doc.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: "lesson",
|
||||
position: n.position,
|
||||
data: n as unknown as Record<string, unknown>,
|
||||
selected: n.id === selectedNodeId,
|
||||
})),
|
||||
() => toRfNodes(doc.nodes, selectedNodeId),
|
||||
[doc.nodes, selectedNodeId],
|
||||
);
|
||||
|
||||
// edges 直接兼容
|
||||
const rfEdges: Edge[] = useMemo(
|
||||
() =>
|
||||
doc.edges.map((e) => ({
|
||||
...e,
|
||||
animated: true,
|
||||
style: { stroke: "#1976d2", strokeWidth: 2 },
|
||||
})),
|
||||
() => toRfEdges(doc.edges),
|
||||
[doc.edges],
|
||||
);
|
||||
|
||||
@@ -60,15 +46,16 @@ export function NodeEditor({ textbookId, chapterId, classes }: Props) {
|
||||
(changes: NodeChange[]) => {
|
||||
changes.forEach((change) => {
|
||||
if (change.type === "position" && change.position) {
|
||||
updateNodePosition(change.id, 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);
|
||||
}
|
||||
});
|
||||
// applyNodeChanges 用于内部状态同步,但我们用 zustand 管理,这里不需要
|
||||
void applyNodeChanges;
|
||||
},
|
||||
[updateNodePosition, removeNode, selectNode],
|
||||
);
|
||||
@@ -100,7 +87,15 @@ export function NodeEditor({ textbookId, chapterId, classes }: Props) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<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}
|
||||
@@ -129,7 +124,8 @@ export function NodeEditor({ textbookId, chapterId, classes }: Props) {
|
||||
<MiniMap
|
||||
className="!bg-surface !border-outline-variant"
|
||||
nodeColor={(n) => {
|
||||
const data = n.data as unknown as LessonPlanNode;
|
||||
const nodeData = (n.data as { node?: LessonPlanNode }).node;
|
||||
if (!nodeData) return "#9e9e9e";
|
||||
const colors: Record<string, string> = {
|
||||
objective: "#4caf50",
|
||||
key_point: "#f44336",
|
||||
@@ -144,12 +140,10 @@ export function NodeEditor({ textbookId, chapterId, classes }: Props) {
|
||||
rich_text: "#9e9e9e",
|
||||
reflection: "#cddc39",
|
||||
};
|
||||
return colors[data.type] ?? "#9e9e9e";
|
||||
return colors[nodeData.type] ?? "#9e9e9e";
|
||||
}}
|
||||
/>
|
||||
</ReactFlow>
|
||||
{/* 隐藏的 props 传递,避免 unused 警告 */}
|
||||
<span className="hidden" data-textbook={textbookId} data-chapter={chapterId} data-classes={classes?.length} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user