Skip to content

Commit d32e366

Browse files
committed
fix(knowledge): v0.13.9 笔记编辑跳顶与画布接线修复批
1 parent 01cbd40 commit d32e366

14 files changed

Lines changed: 471 additions & 59 deletions

‎app/src/components/CanvasNodeQuestion.tsx‎

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,21 @@ import { memo } from "react";
1111
import { Handle, Position, useStore, type Node, type NodeProps } from "@xyflow/react";
1212
import type { CanvasNodeData } from "../utils/canvasElements";
1313
import { CANVAS_BBOX } from "../utils/layoutRadial";
14+
import { nodeTypeColor, nodeTypeLabel, type KnowledgeNodeType } from "../types/knowledge";
1415

1516
export type QuestionRfNode = Node<CanvasNodeData, "question">;
1617

18+
/** 节点类型图标(与 chip 同源) */
19+
const typeIcon: Record<KnowledgeNodeType, string> = {
20+
question: "❓",
21+
scenario: "🎯",
22+
domain_entry: "📂",
23+
};
24+
25+
/** v0.13.9:四边 Handle 统一样式(透明化——连线只反映既有关系,用户不能画线) */
26+
const hiddenHandle: React.CSSProperties = { opacity: 0, width: 2, height: 2 };
27+
28+
1729
/** 标题最多 2 行省略(内容为问题文本,宽 220px 下 2 行足够) */
1830
const clamp2: React.CSSProperties = {
1931
display: "-webkit-box",
@@ -45,14 +57,29 @@ export default memo(function CanvasNodeQuestion({ data, selected }: NodeProps<Qu
4557
padding: 8,
4658
}}
4759
>
48-
{/* 隐式 Handle:连线只反映既有关系,用户不能手动画线 */}
49-
<Handle type="target" position={Position.Top} style={{ opacity: 0, width: 2, height: 2 }} />
50-
<Handle type="source" position={Position.Bottom} style={{ opacity: 0, width: 2, height: 2 }} />
60+
{/* v0.13.9:四边 Handle——接线方向随源/目标相对方位动态选择
61+
(resolveEdgeHandles);透明化——连线只反映既有关系,用户不能画线 */}
62+
<Handle type="target" id="target-top" position={Position.Top} style={hiddenHandle} />
63+
<Handle type="target" id="target-bottom" position={Position.Bottom} style={hiddenHandle} />
64+
<Handle type="target" id="target-left" position={Position.Left} style={hiddenHandle} />
65+
<Handle type="target" id="target-right" position={Position.Right} style={hiddenHandle} />
66+
<Handle type="source" id="source-top" position={Position.Top} style={hiddenHandle} />
67+
<Handle type="source" id="source-bottom" position={Position.Bottom} style={hiddenHandle} />
68+
<Handle type="source" id="source-left" position={Position.Left} style={hiddenHandle} />
69+
<Handle type="source" id="source-right" position={Position.Right} style={hiddenHandle} />
5170

5271
{full ? (
5372
<>
5473
<div style={{ display: "flex", gap: 6, alignItems: "flex-start", fontSize: 13, fontWeight: 500, color: "#374151" }}>
55-
<span style={{ fontSize: 14, flexShrink: 0 }}>❓</span>
74+
{/* v0.13.9:类型 chip(此前统一 ❓ 无法区分问题/场景/领域入口) */}
75+
{data.nodeType && (
76+
<span
77+
data-testid={`canvas-node-type-${data.entityId}`}
78+
style={{ fontSize: 10, borderRadius: 8, padding: "0 5px", lineHeight: 1.7, whiteSpace: "nowrap", background: "#f9fafb", color: nodeTypeColor[data.nodeType], border: `1px solid ${nodeTypeColor[data.nodeType]}33` }}
79+
>
80+
{typeIcon[data.nodeType]} {nodeTypeLabel[data.nodeType]}
81+
</span>
82+
)}
5683
<span style={clamp2}>{data.title}</span>
5784
</div>
5885
{data.badges.length > 0 && (

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import CanvasNodeModel from "./CanvasNodeModel";
2929
function data(partial: Partial<CanvasNodeData>): CanvasNodeData {
3030
return {
3131
kind: "question", entityId: 1, title: "", subtitle: null, badges: [],
32-
statusText: null, statusColor: null, refCount: 0, ...partial,
32+
statusText: null, statusColor: null, refCount: 0, nodeType: null, ...partial,
3333
};
3434
}
3535

@@ -59,6 +59,27 @@ describe("画布节点缩放分级", () => {
5959
expect(screen.getByText(/2 条笔记/)).toBeTruthy();
6060
});
6161

62+
it("v0.13.9 类型 chip:问题/场景/领域入口分类渲染(full 模式)", () => {
63+
// Arrange + Act
64+
zoomValue = 1;
65+
render(<CanvasNodeQuestion {...qp(data({ entityId: 7, title: "色彩理论", nodeType: "domain_entry" }))} />);
66+
render(<CanvasNodeQuestion {...qp(data({ entityId: 8, title: "完成通勤妆", nodeType: "scenario" }))} />);
67+
render(<CanvasNodeQuestion {...qp(data({ entityId: 9, title: "如何练好化妆", nodeType: "question" }))} />);
68+
// Assert:chip 文本 = 图标 + 类型名
69+
expect(screen.getByTestId("canvas-node-type-7").textContent).toBe("📂 领域入口");
70+
expect(screen.getByTestId("canvas-node-type-8").textContent).toBe("🎯 场景");
71+
expect(screen.getByTestId("canvas-node-type-9").textContent).toBe("❓ 问题");
72+
});
73+
74+
it("v0.13.9 类型 chip 仅 full 模式显示(titleOnly 保持简洁)", () => {
75+
// Arrange + Act
76+
zoomValue = 0.5;
77+
render(<CanvasNodeQuestion {...qp(data({ entityId: 7, title: "色彩理论", nodeType: "domain_entry" }))} />);
78+
// Assert:chip 不渲染,标题保留
79+
expect(screen.queryByTestId("canvas-node-type-7")).toBeNull();
80+
expect(screen.getByText("色彩理论")).toBeTruthy();
81+
});
82+
6283
it("zoom 0.4~0.7:仅标题(徽标/计数隐藏)", () => {
6384
// Arrange + Act
6485
zoomValue = 0.5;

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

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ const links: KnowledgeLink[] = [];
6565
function renderView(overrides: {
6666
nodes?: KnowledgeNode[];
6767
coreQuestion?: string | null;
68+
systemName?: string | null;
6869
onSelectItem?: (kind: string, id: number) => void;
6970
onGoBack?: () => void;
7071
onPositionsSaved?: (updates: { nodeId: number; x: number; y: number }[]) => void;
@@ -76,6 +77,7 @@ function renderView(overrides: {
7677
<KnowledgeCanvasView
7778
systemId={5}
7879
coreQuestion={overrides.coreQuestion ?? null}
80+
systemName={overrides.systemName ?? null}
7981
nodes={overrides.nodes ?? makeNodes()}
8082
concepts={concepts}
8183
models={models}
@@ -251,7 +253,35 @@ describe("KnowledgeCanvasView 画布", () => {
251253
const [, args] = batchCalls()[0];
252254
const positions = args.positions as { nodeId: number; x: number; y: number }[];
253255
expect(rounded(positions.find((p) => p.nodeId === 1)!)).toEqual({ x: -110, y: -260 });
254-
expect(rounded(positions.find((p) => p.nodeId === 2)!)).toEqual({ x: -110, y: -460 });
256+
// v0.13.9:RING_STEP 200→160 → 环 2 半径 420→380 → 子节点上移 40px
257+
expect(rounded(positions.find((p) => p.nodeId === 2)!)).toEqual({ x: -110, y: -420 });
258+
});
259+
260+
it("v0.13.9 领域体系:无核心问题但有体系名 → 圆心体系名卡 + 根节点虚线边", async () => {
261+
// Arrange:领域体系(coreQuestion null)+ systemName 非空
262+
renderView({ systemName: "大学规划" });
263+
// Assert:core 卡标题=体系名、副标=领域体系;仅根节点(node1)连虚线边
264+
await waitFor(() => expect(rfNode("core")).toBeTruthy());
265+
await waitFor(() => expect(batchCalls()).toHaveLength(1));
266+
const core = rfNode("core") as unknown as { data: { title: string; subtitle: string } };
267+
expect(core.data.title).toBe("大学规划");
268+
expect(core.data.subtitle).toBe("领域体系");
269+
const edges = latestRf().edges as { id: string; source: string; target: string; style?: { strokeDasharray?: string } }[];
270+
const coreEdges = edges.filter((e) => e.source === "core");
271+
expect(coreEdges).toHaveLength(1); // node1 根;node2 是子不连根卡
272+
expect(coreEdges[0]).toMatchObject({ id: "e:core:1", target: "q:1" });
273+
expect(coreEdges[0].style?.strokeDasharray).toBe("5 4");
274+
});
275+
276+
it("v0.13.9 接线动态化:edges 携带按相对方位计算的 sourceHandle/targetHandle", async () => {
277+
// Arrange:已存位置(q:1 左上角 (10,20)、q:2 (30,40)——q:2 在 q:1 右下方)
278+
renderView({ nodes: makeNodes(true) });
279+
await waitFor(() => expect(rfProps.length).toBeGreaterThan(0));
280+
// Assert:中心差 dx=20, dy=20(|dx|==|dy| → 垂直接入 source-bottom/target-top)
281+
const edges = latestRf().edges as { id: string; sourceHandle?: string; targetHandle?: string }[];
282+
const e = edges.find((x) => x.id === "e:1:2")!;
283+
expect(e.sourceHandle).toBe("source-bottom");
284+
expect(e.targetHandle).toBe("target-top");
255285
});
256286

257287
it("返回按钮 → onGoBack(切回树视图)", async () => {

‎app/src/components/KnowledgeCanvasView.tsx‎

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,17 @@
1111
* @ai-context: 视口经 knowledge_canvas_states 持久化——切回画布 setViewport 恢复
1212
* (§4.5;无记录时 fitView 按内容兜底)。
1313
*/
14-
import { useCallback, useEffect, useRef, useState } from "react";
14+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1515
import { invoke } from "@tauri-apps/api/core";
1616
import {
17-
Background, BackgroundVariant, Controls, MiniMap, Panel, ReactFlow, ReactFlowProvider,
17+
Background, BackgroundVariant, Controls, Handle, MiniMap, Panel, Position, ReactFlow, ReactFlowProvider,
1818
useEdgesState, useNodesState, useReactFlow,
1919
type Edge, type Node, type NodeProps, type OnMoveEnd, type Viewport,
2020
} from "@xyflow/react";
2121
import "@xyflow/react/dist/style.css";
2222
import type { KnowledgeConcept, KnowledgeLink, KnowledgeModel, KnowledgeNode, CanvasNodePosition } from "../types/knowledge";
2323
import {
24-
buildCanvasElements, canvasKey, entityIdFromKey,
24+
buildCanvasElements, canvasKey, entityIdFromKey, resolveEdgeHandles,
2525
type CanvasNodeData, type CanvasSelectKind,
2626
} from "../utils/canvasElements";
2727
import { CANVAS_BBOX, layoutRadial, type CanvasLayoutItem, type CanvasPoint } from "../utils/layoutRadial";
@@ -33,6 +33,8 @@ interface Props {
3333
systemId: number;
3434
/** 体系核心问题——存在时画布圆心渲染核心问题卡(布局圆心被虚拟核心占用) */
3535
coreQuestion: string | null;
36+
/** v0.13.9:体系名——coreQuestion 为空(领域体系)时圆心渲染体系名卡 */
37+
systemName: string | null;
3638
nodes: KnowledgeNode[];
3739
concepts: KnowledgeConcept[];
3840
models: KnowledgeModel[];
@@ -76,13 +78,21 @@ export default function KnowledgeCanvasView(props: Props) {
7678
}
7779

7880
function CanvasFlow({
79-
systemId, coreQuestion, nodes, concepts, models, links, selectedKey, onSelectItem, onPositionsSaved, onGoBack,
81+
systemId, coreQuestion, systemName, nodes, concepts, models, links, selectedKey, onSelectItem, onPositionsSaved, onGoBack,
8082
}: Props) {
8183
const [rfNodes, setRfNodes, onNodesChange] = useNodesState<Node<CanvasNodeData>>([]);
8284
const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState<Edge>([]);
8385
const [status, setStatus] = useState("");
8486
const { fitView, setViewport } = useReactFlow();
8587

88+
// v0.13.9:体系根卡(全局=核心问题 / 领域=体系名)——null 时不渲染圆心卡
89+
// (防御:无核心问题也无体系名的异常态保持旧行为,不凭空造根)
90+
const rootCard = useMemo<{ title: string; subtitle: string } | null>(() => {
91+
if (coreQuestion) return { title: coreQuestion, subtitle: "核心问题" };
92+
if (systemName) return { title: systemName, subtitle: "领域体系" };
93+
return null;
94+
}, [coreQuestion, systemName]);
95+
8696
// useReactFlow 返回值身份可能随渲染变化——ref 捕获避免恢复 effect 依赖抖动
8797
// (依赖 fitView/setViewport 身份变化会反复触发恢复循环)
8898
const rfApi = useRef({ fitView, setViewport });
@@ -181,22 +191,12 @@ function CanvasFlow({
181191
.catch((e) => setStatus(`布局初始化失败: ${e}`));
182192
}
183193

184-
const { nodes: rfn, edges } = buildCanvasElements({ nodes, concepts, models, links, positions, selectedKey });
185-
const extras: Node<CanvasNodeData>[] = [];
186-
if (coreQuestion) {
187-
extras.push({
188-
id: "core",
189-
type: "core",
190-
position: { x: -120, y: -40 },
191-
draggable: false,
192-
selectable: false,
193-
focusable: false,
194-
data: { kind: "question", entityId: 0, title: coreQuestion, subtitle: null, badges: [], statusText: null, statusColor: null, refCount: 0 },
195-
});
196-
}
197-
setRfNodes([...rfn, ...extras]);
194+
const { nodes: rfn, edges } = buildCanvasElements({ nodes, concepts, models, links, positions, selectedKey, rootCard });
195+
// v0.13.9:rootCard 由 buildCanvasElements 统一产出(含 core 卡 + 根节点虚线边)——
196+
// 初始布局与「自动排列」同路径,杜绝双份逻辑漂移(此前 autoLayout 漏加 core 卡)
197+
setRfNodes(rfn);
198198
setRfEdges(edges);
199-
}, [systemId, coreQuestion, nodes, concepts, models, links, selectedKey, setRfNodes, setRfEdges]);
199+
}, [systemId, rootCard, nodes, concepts, models, links, selectedKey, setRfNodes, setRfEdges]);
200200

201201
// 视口恢复(§4.5):已存 → setViewport;未存 → fitView 按内容兜底
202202
useEffect(() => {
@@ -248,7 +248,7 @@ function CanvasFlow({
248248
...concepts.map((c) => ({ key: canvasKey("concept", c.id), kind: "concept" as const, parentKey: null })),
249249
...models.map((m) => ({ key: canvasKey("model", m.id), kind: "model" as const, parentKey: null })),
250250
];
251-
const layout = layoutRadial({ hasCore: coreQuestion != null, items: layoutItems });
251+
const layout = layoutRadial({ hasCore: rootCard != null, items: layoutItems });
252252
const positions = new Map<string, CanvasPoint>();
253253
const persist: { nodeId: number; x: number; y: number }[] = [];
254254
for (const n of nodes) {
@@ -263,7 +263,7 @@ function CanvasFlow({
263263
for (const m of models) {
264264
positions.set(canvasKey("model", m.id), toTopLeft(layout.get(canvasKey("model", m.id)) ?? { x: 0, y: 0 }, CANVAS_BBOX.model));
265265
}
266-
const { nodes: rfn, edges } = buildCanvasElements({ nodes, concepts, models, links, positions, selectedKey });
266+
const { nodes: rfn, edges } = buildCanvasElements({ nodes, concepts, models, links, positions, selectedKey, rootCard });
267267
setRfNodes(rfn);
268268
setRfEdges(edges);
269269
void invoke("batch_initialize_canvas_positions", { systemId, positions: persist })
@@ -272,6 +272,20 @@ function CanvasFlow({
272272
void fitView({ padding: 0.15 });
273273
};
274274

275+
// v0.13.9:接线方向动态化——按源/目标当前中心相对方位选 Handle(拖拽后随
276+
// rfNodes 位置重算;纯函数 resolveEdgeHandles 可单测)。解决接线位置固定:
277+
// 移动节点后边始终从同一侧接入,产生大量回字形折角。
278+
const smartEdges = useMemo(() => {
279+
const centerOf = (id: string): CanvasPoint => {
280+
const n = rfNodes.find((x) => x.id === id);
281+
const p = n?.position ?? { x: 0, y: 0 };
282+
// core 卡无固定尺寸(maxWidth 240,标题 1-2 行)——按布局假定尺寸估算中心
283+
const bbox = n?.type === "core" ? { w: 240, h: 80 } : CANVAS_BBOX[n?.data.kind ?? "question"];
284+
return { x: p.x + bbox.w / 2, y: p.y + bbox.h / 2 };
285+
};
286+
return rfEdges.map((e) => ({ ...e, ...resolveEdgeHandles(centerOf(e.source), centerOf(e.target)) }));
287+
}, [rfNodes, rfEdges]);
288+
275289
return (
276290
<div style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}>
277291
{/* 顶部:返回树视图(§4.5 内嵌切换——标签页栏「画布」项与这里互通) */}
@@ -286,7 +300,7 @@ function CanvasFlow({
286300
<div style={{ flex: 1, minHeight: 0 }}>
287301
<ReactFlow
288302
nodes={rfNodes}
289-
edges={rfEdges}
303+
edges={smartEdges}
290304
nodeTypes={nodeTypes}
291305
onNodesChange={onNodesChange}
292306
onEdgesChange={onEdgesChange}
@@ -330,14 +344,19 @@ function CanvasFlow({
330344
);
331345
}
332346

333-
/** 核心问题节点(虚拟中心——不可拖/不可选,仅展示体系核心问题) */
347+
/** 体系根卡(虚拟中心——不可拖/不可选,仅展示体系根:全局=核心问题,领域=体系名) */
334348
function CanvasCoreNode({ data }: NodeProps<Node<CanvasNodeData, "core">>) {
335349
return (
336350
<div
337351
data-testid="canvas-core"
338352
style={{ maxWidth: 240, borderRadius: 10, padding: "10px 14px", background: "#0f766e", color: "#fff", border: "1px solid #0f766e", boxShadow: "0 2px 8px rgba(15,118,110,0.25)" }}
339353
>
340-
<div style={{ fontSize: 11, opacity: 0.85 }}>核心问题</div>
354+
{/* v0.13.9:四边 source Handle——根节点虚线边接线方向随相对方位动态选择 */}
355+
<Handle type="source" id="source-top" position={Position.Top} style={{ opacity: 0, width: 2, height: 2 }} />
356+
<Handle type="source" id="source-bottom" position={Position.Bottom} style={{ opacity: 0, width: 2, height: 2 }} />
357+
<Handle type="source" id="source-left" position={Position.Left} style={{ opacity: 0, width: 2, height: 2 }} />
358+
<Handle type="source" id="source-right" position={Position.Right} style={{ opacity: 0, width: 2, height: 2 }} />
359+
<div style={{ fontSize: 11, opacity: 0.85 }}>{data.subtitle ?? "体系"}</div>
341360
<div style={{ fontSize: 13, fontWeight: 600, marginTop: 2 }}>{data.title}</div>
342361
</div>
343362
);

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,29 @@ describe("KnowledgeTreeView 问题树", () => {
6868
expect(screen.getByTestId("node-type-3").textContent).toBe("领域入口");
6969
});
7070

71+
it("v0.13.9 层级缩进:子节点 paddingLeft 随 depth 递增(16px/层),引导线渲染", async () => {
72+
// Arrange + Act:node1 根(depth 0)、node2 是 node1 子(depth 1)、node3 根(depth 0)
73+
renderTree();
74+
await screen.findByTestId("node-1");
75+
// Assert:仅子节点多缩进 16px;根节点不受影响
76+
expect(screen.getByTestId("node-1").style.paddingLeft).toBe("8px");
77+
expect(screen.getByTestId("node-2").style.paddingLeft).toBe("24px");
78+
expect(screen.getByTestId("node-3").style.paddingLeft).toBe("8px");
79+
// 引导线:子节点行有水平短线
80+
expect(screen.getByTestId("node-guide-2")).toBeTruthy();
81+
expect(screen.queryByTestId("node-guide-3")).toBeNull(); // 根节点无引导线
82+
});
83+
84+
it("v0.13.9 折叠后引导线随子树隐藏", async () => {
85+
// Arrange + Act
86+
renderTree();
87+
await screen.findByTestId("node-guide-2");
88+
fireEvent.click(screen.getByTestId("node-toggle-1"));
89+
// Assert:子树(含引导线)整体不渲染
90+
expect(screen.queryByTestId("node-2")).toBeNull();
91+
expect(screen.queryByTestId("node-guide-2")).toBeNull();
92+
});
93+
7194
it("节点选中回调:点击节点 → onSelectNode 携带 id", async () => {
7295
const onSelectNode = vi.fn();
7396
renderTree(onSelectNode);

0 commit comments

Comments
 (0)