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:
SpecialX
2026-06-22 16:17:58 +08:00
parent 4833930834
commit 20691f53ce
32 changed files with 1456 additions and 360 deletions

View File

@@ -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>
);
}