Skip to content

Commit 6464abd

Browse files
committed
fix(knowledge): 画布重挂载位置丢失与框选批量移动治理
审查发现(新增代码审查·逻辑性/牵连性): - 高:拖拽/批量初始化持久化后 props 陈旧——树↔画布往返重挂载时辐射布局 重算覆盖已拖走位置(违反 §4.4 位置只由用户决定)。修复:onPositionsSaved 回传父页 applyCanvasPositions 合并进 nodes props(重挂载即已存位置)+ 回归测试 - 中:elementsSelectable 默认开启——shift 框选 + 多节点拖拽仅主节点保存, 其余节点视觉位移不持久(下次打开回弹)。修复:elementsSelectable=false + selectionKeyCode=null(§九 暂不暴露框选批量移动) - 低:KnowledgePage.test withSystemMock 画布命令分支死代码(视图已 mock)清理; 行数豁免登记刷新(KnowledgePage 322 行)
1 parent f1cc8df commit 6464abd

5 files changed

Lines changed: 82 additions & 20 deletions

File tree

‎app/src/components/KnowledgeCanvasView.test.tsx‎

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,11 @@ function renderView(overrides: {
6767
coreQuestion?: string | null;
6868
onSelectItem?: (kind: string, id: number) => void;
6969
onGoBack?: () => void;
70+
onPositionsSaved?: (updates: { nodeId: number; x: number; y: number }[]) => void;
7071
} = {}) {
7172
const onSelectItem = overrides.onSelectItem ?? vi.fn();
7273
const onGoBack = overrides.onGoBack ?? vi.fn();
74+
const onPositionsSaved = (overrides.onPositionsSaved ?? vi.fn()) as unknown as ReturnType<typeof vi.fn>;
7375
const utils = render(
7476
<KnowledgeCanvasView
7577
systemId={5}
@@ -80,10 +82,11 @@ function renderView(overrides: {
8082
links={links}
8183
selectedKey={null}
8284
onSelectItem={onSelectItem}
85+
onPositionsSaved={onPositionsSaved as unknown as (updates: { nodeId: number; x: number; y: number }[]) => void}
8386
onGoBack={onGoBack}
8487
/>,
8588
);
86-
return { onSelectItem, onGoBack, ...utils };
89+
return { onSelectItem, onGoBack, onPositionsSaved, ...utils };
8790
}
8891

8992
/** 最近一次 ReactFlow 收到的 props */
@@ -123,9 +126,9 @@ function batchCalls() {
123126
}
124127

125128
describe("KnowledgeCanvasView 画布", () => {
126-
it("首次打开(全部未布局)→ 辐射布局批量初始化,位置落入 RF 节点", async () => {
129+
it("首次打开(全部未布局)→ 辐射布局批量初始化,位置落入 RF 节点且回传父页合并", async () => {
127130
// Arrange + Act
128-
renderView();
131+
const { onPositionsSaved } = renderView();
129132
// Assert:批量初始化携全量节点(左上角口径——根 (−110,−40),子 (−110,−260))
130133
await waitFor(() => expect(batchCalls()).toHaveLength(1));
131134
const [, args] = batchCalls()[0];
@@ -136,6 +139,28 @@ describe("KnowledgeCanvasView 画布", () => {
136139
expect(rounded(positions.find((p) => p.nodeId === 2)!)).toEqual({ x: -110, y: -260 });
137140
// RF 节点位置与持久化一致(root0 圆心 (0,0) → 左上角 (-110,-40))
138141
await waitFor(() => expect(rounded(rfNode("q:1").position)).toEqual({ x: -110, y: -40 }));
142+
// 父页合并回调(防重挂载覆盖)
143+
await waitFor(() => expect(onPositionsSaved).toHaveBeenCalled());
144+
expect(rounded((onPositionsSaved.mock.calls[0][0] as { x: number; y: number }[])[0])).toEqual({ x: -110, y: -40 });
145+
});
146+
147+
it("回归:重挂载且父页已合并位置 → 不再批量初始化(拖拽落点不被辐射重算覆盖)", async () => {
148+
// Arrange:首次打开 → 批量初始化 → 父页合并(模拟 onPositionsSaved 生效)
149+
const saved: { nodeId: number; x: number; y: number }[] = [];
150+
const { unmount } = renderView({
151+
onPositionsSaved: (updates) => { saved.push(...updates); },
152+
});
153+
await waitFor(() => expect(saved).toHaveLength(2));
154+
unmount();
155+
// Act:以"已存位置"的 props 重挂载(父页合并后的状态)
156+
renderView({ nodes: makeNodes(true).map((n) => {
157+
const s = saved.find((x) => x.nodeId === n.id)!;
158+
return { ...n, canvasX: s.x, canvasY: s.y };
159+
}) });
160+
// Assert:不再触发第二次批量初始化,位置来自存储
161+
await waitFor(() => expect(rfProps.length).toBeGreaterThan(0));
162+
expect(batchCalls()).toHaveLength(1);
163+
expect(rounded(rfNode("q:1").position)).toEqual({ x: -110, y: -40 });
139164
});
140165

141166
it("已存位置 → 不触发初始化,位置来自存储", async () => {
@@ -148,9 +173,9 @@ describe("KnowledgeCanvasView 画布", () => {
148173
expect(rfNode("q:2").position).toEqual({ x: 30, y: 40 });
149174
});
150175

151-
it("拖拽结束 → 防抖后保存 update_node_canvas_position", async () => {
176+
it("拖拽结束 → 防抖后保存 update_node_canvas_position 并回传父页合并", async () => {
152177
// Arrange
153-
renderView({ nodes: makeNodes(true) });
178+
const { onPositionsSaved } = renderView({ nodes: makeNodes(true) });
154179
await waitFor(() => expect(rfProps.length).toBeGreaterThan(0));
155180
const onNodeDragStop = latestRf().onNodeDragStop as (e: unknown, node: { id: string; position: { x: number; y: number } }) => void;
156181
// Act:拖拽 q:1 到 (100, 200)
@@ -159,6 +184,8 @@ describe("KnowledgeCanvasView 画布", () => {
159184
await waitFor(() =>
160185
expect(invokeMock).toHaveBeenCalledWith("update_node_canvas_position", { nodeId: 1, canvasX: 100, canvasY: 200 }),
161186
);
187+
// 父页合并回调(拖拽位置同步进 props——重挂载不丢)
188+
await waitFor(() => expect(onPositionsSaved).toHaveBeenCalledWith([{ nodeId: 1, x: 100, y: 200 }]));
162189
});
163190

164191
it("节点点击 → onSelectItem(问题/概念/模型分派)", async () => {

‎app/src/components/KnowledgeCanvasView.tsx‎

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
type Edge, type Node, type NodeProps, type OnMoveEnd, type Viewport,
2020
} from "@xyflow/react";
2121
import "@xyflow/react/dist/style.css";
22-
import type { KnowledgeConcept, KnowledgeLink, KnowledgeModel, KnowledgeNode } from "../types/knowledge";
22+
import type { KnowledgeConcept, KnowledgeLink, KnowledgeModel, KnowledgeNode, CanvasNodePosition } from "../types/knowledge";
2323
import {
2424
buildCanvasElements, canvasKey, entityIdFromKey,
2525
type CanvasNodeData, type CanvasSelectKind,
@@ -40,6 +40,11 @@ interface Props {
4040
/** 当前选中实体 key(`q:1`/`c:2`/`m:3`——与树视图/列表共享选中态) */
4141
selectedKey: string | null;
4242
onSelectItem: (kind: CanvasSelectKind, entityId: number) => void;
43+
/**
44+
* 位置持久化成功回调(父页合并进 nodes props——重挂载后 props 即为已存位置,
45+
* 杜绝"辐射布局重算覆盖已拖走位置";规格 §4.4 纪律:位置只由用户决定)。
46+
*/
47+
onPositionsSaved?: (updates: CanvasNodePosition[]) => void;
4348
/** 返回树视图(§4.5 切换) */
4449
onGoBack: () => void;
4550
}
@@ -71,7 +76,7 @@ export default function KnowledgeCanvasView(props: Props) {
7176
}
7277

7378
function CanvasFlow({
74-
systemId, coreQuestion, nodes, concepts, models, links, selectedKey, onSelectItem, onGoBack,
79+
systemId, coreQuestion, nodes, concepts, models, links, selectedKey, onSelectItem, onPositionsSaved, onGoBack,
7580
}: Props) {
7681
const [rfNodes, setRfNodes, onNodesChange] = useNodesState<Node<CanvasNodeData>>([]);
7782
const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState<Edge>([]);
@@ -84,6 +89,10 @@ function CanvasFlow({
8489
rfApi.current.fitView = fitView;
8590
rfApi.current.setViewport = setViewport;
8691

92+
// onPositionsSaved 经 ref 透传:防抖/卸载回调在闭包外执行,ref 保证拿到最新回调
93+
const savedRef = useRef(onPositionsSaved);
94+
savedRef.current = onPositionsSaved;
95+
8796
// 拖拽落点本地覆盖(props 未刷新前保持视图正确;systemId 切换随组件卸载清空)
8897
const localOverrides = useRef(new Map<number, CanvasPoint>());
8998
// 待保存拖拽位置(防抖批量刷 DB;卸载时兜底刷新)
@@ -98,23 +107,30 @@ function CanvasFlow({
98107
}
99108
const entries = [...pendingPositions.current.entries()];
100109
pendingPositions.current.clear();
101-
for (const [nodeId, pos] of entries) {
102-
void invoke("update_node_canvas_position", { nodeId, canvasX: pos.x, canvasY: pos.y })
103-
.catch((e) => setStatus(`位置保存失败: ${e}`));
104-
}
110+
if (entries.length === 0) return;
111+
// 全部成功才回传父页合并(部分失败不污染 props——下次重挂载用 DB 兜底)
112+
void Promise.all(
113+
entries.map(([nodeId, pos]) =>
114+
invoke("update_node_canvas_position", { nodeId, canvasX: pos.x, canvasY: pos.y }),
115+
),
116+
)
117+
.then(() => savedRef.current?.(entries.map(([nodeId, pos]) => ({ nodeId, x: pos.x, y: pos.y }))))
118+
.catch((e) => setStatus(`位置保存失败: ${e}`));
105119
}, []);
106120

107-
// 卸载兜底:清定时器 + 刷新未保存的拖拽落点(防切页丢失)
121+
// 卸载兜底:清定时器 + 刷新未保存的拖拽落点(防切页丢失;父页存活,合并安全)
108122
useEffect(() => {
109123
return () => {
110124
if (dragTimer.current != null) window.clearTimeout(dragTimer.current);
111125
if (vpTimer.current != null) window.clearTimeout(vpTimer.current);
112126
if (pendingPositions.current.size > 0) {
113127
const entries = [...pendingPositions.current.entries()];
114128
pendingPositions.current.clear();
115-
for (const [nodeId, pos] of entries) {
116-
void invoke("update_node_canvas_position", { nodeId, canvasX: pos.x, canvasY: pos.y });
117-
}
129+
void Promise.all(
130+
entries.map(([nodeId, pos]) =>
131+
invoke("update_node_canvas_position", { nodeId, canvasX: pos.x, canvasY: pos.y }),
132+
),
133+
).then(() => savedRef.current?.(entries.map(([nodeId, pos]) => ({ nodeId, x: pos.x, y: pos.y }))));
118134
}
119135
};
120136
}, []);
@@ -161,6 +177,7 @@ function CanvasFlow({
161177
if (batchPersist.length > 0) {
162178
// 首次打开(或新增未布局节点)→ 批量初始化;失败不阻塞渲染(下次打开重试)
163179
void invoke("batch_initialize_canvas_positions", { systemId, positions: batchPersist })
180+
.then(() => savedRef.current?.(batchPersist))
164181
.catch((e) => setStatus(`布局初始化失败: ${e}`));
165182
}
166183

@@ -250,6 +267,7 @@ function CanvasFlow({
250267
setRfNodes(rfn);
251268
setRfEdges(edges);
252269
void invoke("batch_initialize_canvas_positions", { systemId, positions: persist })
270+
.then(() => savedRef.current?.(persist))
253271
.catch((e) => setStatus(`布局保存失败: ${e}`));
254272
void fitView({ padding: 0.15 });
255273
};
@@ -279,6 +297,10 @@ function CanvasFlow({
279297
maxZoom={2}
280298
nodesConnectable={false}
281299
edgesReconnectable={false}
300+
// §九 不做清单:框选批量移动(RF 内置但本版暂不暴露)——禁用元素选择
301+
// 引擎,杜绝 shift 框选 + 多节点拖拽导致的"其余节点视觉位移不持久"竞态
302+
elementsSelectable={false}
303+
selectionKeyCode={null}
282304
deleteKeyCode={null}
283305
fitView={false}
284306
>

‎app/src/pages/KnowledgePage.test.tsx‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const aNode = {
3838
orderIdx: 0, status: "active", createdAt: 0, canvasX: null, canvasY: null,
3939
};
4040

41-
/** 有体系 + 画布命令的 mock(画布激活时需要的命令回路) */
41+
/** 有体系的 mock(画布视图已 mock——本层只需列表命令回路) */
4242
function withSystemMock() {
4343
invokeMock.mockImplementation(async (cmd: string) => {
4444
switch (cmd) {
@@ -47,8 +47,6 @@ function withSystemMock() {
4747
case "list_knowledge_concepts": return [];
4848
case "list_knowledge_models": return [];
4949
case "list_knowledge_links": return [];
50-
case "get_canvas_viewport": return null;
51-
case "batch_initialize_canvas_positions": return true;
5250
default: throw new Error(`unexpected: ${cmd}`);
5351
}
5452
});

‎app/src/pages/KnowledgePage.tsx‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
1616
import { invoke } from "@tauri-apps/api/core";
1717
import type {
1818
KnowledgeSystem, KnowledgeNode, KnowledgeConcept, KnowledgeModel,
19-
KnowledgeLink, KnowledgeSelection,
19+
KnowledgeLink, KnowledgeSelection, CanvasNodePosition,
2020
} from "../types/knowledge";
2121
import { systemStatusLabel } from "../types/knowledge";
2222
import { canvasKey } from "../utils/canvasElements";
@@ -124,6 +124,20 @@ export default function KnowledgePage({ focusSystemId }: Props) {
124124
return canvasKey("model", selection.id);
125125
}, [selection]);
126126

127+
// v0.13.8:画布位置持久化成功 → 合并进本页 nodes props(重挂载后 props 即已存
128+
// 位置——防辐射布局重算覆盖用户拖走的落点,§4.4 纪律"位置只由用户决定")
129+
const applyCanvasPositions = useCallback((updates: CanvasNodePosition[]) => {
130+
const byId = new Map(updates.map((u) => [u.nodeId, u]));
131+
setNodes((prev) =>
132+
prev.map((n) => {
133+
const u = byId.get(n.id);
134+
if (!u) return n;
135+
if (n.canvasX === u.x && n.canvasY === u.y) return n;
136+
return { ...n, canvasX: u.x, canvasY: u.y };
137+
}),
138+
);
139+
}, []);
140+
127141
const globalSystem = systems.find((s) => s.kind === "global") ?? null;
128142
const domainSystems = systems.filter((s) => s.kind === "domain");
129143
const selectedSystem = systems.find((s) => s.id === selectedSystemId) ?? null;
@@ -260,6 +274,7 @@ export default function KnowledgePage({ focusSystemId }: Props) {
260274
links={links}
261275
selectedKey={selectedCanvasKey}
262276
onSelectItem={(kind, id) => setSelection({ type: kind, id })}
277+
onPositionsSaved={applyCanvasPositions}
263278
onGoBack={() => setMiddleView("tree")}
264279
/>
265280
</div>

‎docs/standards/line-limit-exemptions.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777
| app/src/components/NoteEditView.tsx | —— | v0.12.2 行数修正:实测 299 行(登记值 315 过期),本版新增 autoFocus 一行后仍 300 行以内——登记移除,见"已拆分/登记移除记录" | —— |
7878
| app/src/pages/NotesPage.tsx | 359 | v0.12.2 三栏编排(GroupSidebar/FeedFragmentList/NoteListView 插槽 + 收件箱动线 onPromoted 接线 + 新建零对话框)——编排层内聚(数据获取/选中态/快捷键/辅助面板插槽),组件已全部下沉拆件;2026-08-23 审查修复(onPromoted 同步清搜索态/onOpenInbox 清组过滤)+6 行;v0.12.8 列表级批量删除(runBatchDelete 确认+逐条 invoke+刷新,confirm 导入,+27 行);v0.13.6(REQ-223)编辑退出统一刷新回调 + 审查轮(ESC flush 出口) | 若再增长:键盘监听与收件箱动线回调拆至 useNotesPageShortcuts.ts / useInboxFlow.ts |
7979
| app/src/components/AiProviderSettings.tsx | 329 | v0.11.6 M1 code-review 修复(2026-08-22):删除/清钥加 window.confirm、window.prompt 改卡片内联 password 输入(+2 state + 内联表单)、模型列表 input 改 textarea、fallbackOrder 透传 initial、run 置"处理中"反馈、预设双源 presetOptions 后端拉取——修复净增约 13 行越线(实测 329,含 4 行豁免头注释) | 若再增长:内联密钥表单拆至 AiProviderKeyInput.tsx |
80-
| app/src/pages/KnowledgePage.tsx | 310 | v0.13.8 画布接线越线(原 296):中栏新增「画布」标签 + 树/画布双入口(树视图浮钮 + 标签栏)+ 中栏 display 切换(树常驻/画布激活挂载)——页面编排层内聚(数据获取/选中态/标签态为页面本地状态),子组件已全部下沉(Tree/Canvas/DetailPanel/Wizard/ConceptCardRow/Sample) | 若再增长:中栏视图块(树/画布/概念/模型 + 标签栏)拆至 KnowledgeMiddlePane.tsx,SystemCard 拆至 SystemSidebarCard.tsx |
80+
| app/src/pages/KnowledgePage.tsx | 322 | v0.13.8 画布接线越线(原 296):中栏新增「画布」标签 + 树/画布双入口(树视图浮钮 + 标签栏)+ 中栏 display 切换(树常驻/画布激活挂载)+ 审查修复(applyCanvasPositions 位置合并回传)——页面编排层内聚(数据获取/选中态/标签态为页面本地状态),子组件已全部下沉(Tree/Canvas/DetailPanel/Wizard/ConceptCardRow/Sample) | 若再增长:中栏视图块(树/画布/概念/模型 + 标签栏)拆至 KnowledgeMiddlePane.tsx,SystemCard 拆至 SystemSidebarCard.tsx |
8181

8282
> 前端 **拆分中**(Task #9 笔记域修复进行中,暂不登记行数):`app/src/types.ts`——待前端拆分完成后以实测行数重新评估。
8383
> 前端 SessionsPage.tsx 审查快照 304 行(登记值),v0.7.1 硬拆后长期 ≤300,本轮审查期间轻微越线;随 NotesPage/types.ts 拆分任务一并复核,若仍越线按上表模式登记。

0 commit comments

Comments
 (0)