feat(lesson-preparation): add readonly view, anchor node selector, and type guards

- Add lesson-plan-readonly-view for viewing published plans

- Add anchor-node-selector and textbook-segments for canvas anchor positioning

- Add i18n-errors and type-guards lib utilities

- Add lesson-plan-provider-setup for provider initialization

- Update actions, data-access (knowledge, versions, main), publish-service

- Update blocks (blackboard, exercise, homework, import, key-point, objective, reflection)

- Update editor, node-editor, node-edit-panel, pickers, and providers
This commit is contained in:
SpecialX
2026-06-24 12:02:42 +08:00
parent a48e7d0e27
commit 6bc113eaff
39 changed files with 2129 additions and 571 deletions

View File

@@ -21,7 +21,7 @@ import { LessonNode } from "./nodes/lesson-node";
import { TextbookContentNode as TextbookContentNodeComponent } from "./nodes/textbook-content-node";
import { toRfNodes, toRfEdges } from "../lib/rf-mappers";
import { getNodeColor } from "../lib/node-summary";
import type { AnyLessonPlanNode } from "../types";
import type { AnyLessonPlanNode, BlockType } from "../types";
const nodeTypes = {
lesson: LessonNode,
@@ -42,22 +42,27 @@ export function NodeEditor({}: Props) {
selectNode,
setEdges,
addAnchor,
updateTextbookContent,
addNode,
} = useLessonPlanEditor();
// P1-1构建可锚定的教学节点列表排除正文节点
const anchorableNodes = useMemo(
() =>
doc.nodes
.filter((n): n is Extract<AnyLessonPlanNode, { type: BlockType }> => n.type !== "textbook_content")
.map((n) => ({ id: n.id, title: n.title, type: n.type })),
[doc.nodes],
);
// 锚点添加回调(正文节点使用)
const handleAddRangeAnchor = useCallback(
(params: { nodeId: string; start: number; end: number; textPreview: string }) => {
// 如果 nodeId 是 __selected__使用当前选中节点
// 如果是 __new__提示用户先创建节点
// __selected__ 表示使用当前选中节点
const actualNodeId =
params.nodeId === "__selected__"
? selectedNodeId ?? ""
: params.nodeId;
if (!actualNodeId || actualNodeId === "__new__") {
// 简化:不自动创建新节点,提示用户先选中或创建
return;
}
if (!actualNodeId) return;
addAnchor({
nodeId: actualNodeId,
type: "range",
@@ -75,9 +80,7 @@ export function NodeEditor({}: Props) {
params.nodeId === "__selected__"
? selectedNodeId ?? ""
: params.nodeId;
if (!actualNodeId || actualNodeId === "__new__") {
return;
}
if (!actualNodeId) return;
addAnchor({
nodeId: actualNodeId,
type: "point",
@@ -87,11 +90,25 @@ export function NodeEditor({}: Props) {
[addAnchor, selectedNodeId],
);
const handleZoomChange = useCallback(
(zoom: number) => {
updateTextbookContent({ zoom });
// P1-1创建新节点并锚定
const handleCreateNewNode = useCallback(
(params: {
anchorType: "range" | "point";
start: number;
end?: number;
textPreview?: string;
}) => {
// 默认创建 rich_text 节点(最通用的类型),用户可后续切换
const newNodeId = addNode("rich_text", undefined, t("blockType.rich_text"));
addAnchor({
nodeId: newNodeId,
type: params.anchorType,
start: params.start,
...(params.end !== undefined ? { end: params.end } : {}),
...(params.textPreview ? { textPreview: params.textPreview } : {}),
});
},
[updateTextbookContent],
[addNode, addAnchor, t],
);
// 使用纯函数映射 nodes/edges
@@ -100,12 +117,13 @@ export function NodeEditor({}: Props) {
toRfNodes(doc.nodes, selectedNodeId, {
anchors: doc.anchors,
selectedNodeId,
anchorableNodes,
onAddRangeAnchor: handleAddRangeAnchor,
onAddPointAnchor: handleAddPointAnchor,
onCreateNewNode: handleCreateNewNode,
onSelectNode: selectNode,
onZoomChange: handleZoomChange,
}),
[doc.nodes, doc.anchors, selectedNodeId, handleAddRangeAnchor, handleAddPointAnchor, selectNode, handleZoomChange],
[doc.nodes, doc.anchors, selectedNodeId, anchorableNodes, handleAddRangeAnchor, handleAddPointAnchor, handleCreateNewNode, selectNode],
);
const rfEdges: Edge[] = useMemo(
@@ -173,7 +191,13 @@ export function NodeEditor({}: Props) {
);
return (
<div className="w-full h-full relative" role="application" aria-label={t("editor.canvasLabel")}>
<div
className="w-full h-full relative"
role="application"
aria-label={t("editor.canvasLabel")}
// 禁用整个画布的浏览器默认右键菜单
onContextMenu={(e) => e.preventDefault()}
>
{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">
@@ -216,9 +240,21 @@ export function NodeEditor({}: Props) {
<MiniMap
className="!bg-surface !border-outline-variant"
nodeColor={(n) => {
const nodeData = (n.data as { node?: AnyLessonPlanNode }).node;
if (!nodeData) return "#9e9e9e";
return getNodeColor(nodeData.type);
// V3 修复:从 React Flow 的 Node.dataRecord<string, unknown>)安全提取 node 字段
// 使用类型守卫替代 as 断言
const data = n.data;
if (!data || typeof data !== "object") return "#9e9e9e";
const nodeData = data.node;
if (
nodeData &&
typeof nodeData === "object" &&
nodeData !== null &&
"type" in nodeData &&
typeof nodeData.type === "string"
) {
return getNodeColor(nodeData.type);
}
return "#9e9e9e";
}}
/>
</ReactFlow>