|
| 1 | +# v0.13.8 设计规格:知识体系画布(React Flow 节点式无限画布) |
| 2 | + |
| 3 | +> 状态:**已批准**(2026-08-24,用户裁决方案2——React Flow 标注画布) |
| 4 | +> 关联:[v0.13 系列文档](../../versions/v0.13.md) · [ADR-024](../../adr/ADR-024-knowledge-system-layer.md) · [v0.13.7 上手路径](../../archive/2026-08-24/2026-08-24-v0.13.7-knowledge-system-onboarding-design.md)([ ] 已归档) |
| 5 | +> 范围:v0.13.8——知识体系画布视图。**不含** v0.13.4 审计 UI、v0.13.5 书籍、REQ-029 力导向图。 |
| 6 | +
|
| 7 | +## 一、问题定义 |
| 8 | + |
| 9 | +知识体系层当前只提供**树视图**一种结构展示方式(KnowledgeTreeView),节点以递归缩进列表排列。随着体系节点增多(> 10 个问题 + 概念/模型引用),树视图的局限性开始显现: |
| 10 | + |
| 11 | +1. **空间限制**:一级节点超过 5 个后纵向滚动沉重;子节点折叠在一层里看不见。 |
| 12 | +2. **概念/模型游离**:概念和模型不在树中——它们在独立标签页里,用户无法在"看结构"的同时看到哪些节点关联了哪些概念。 |
| 13 | +3. **整体感缺失**:树视图只展示问题轴,概念/模型/决策日志各占独立标签页——用户无法一眼看到体系全貌。 |
| 14 | +4. **无法自由组织**:用户想按自己的认知习惯排布节点(重要的放中间、相关的放近处),树视图不允许——父子关系决定位置。 |
| 15 | + |
| 16 | +**设计目标**:给用户一张可以自由排布知识节点的无限画布——不取代树视图,而是提供互补的"鸟瞰"视角。 |
| 17 | + |
| 18 | +## 二、设计约束(核心纪律) |
| 19 | + |
| 20 | +本功能受且仅受以下纪律约束: |
| 21 | + |
| 22 | +1. **不是图可视化(REQ-029 P3 维持)**:画布不是力导向图。节点位置由用户拖拽决定,首次打开时以辐射布局计算初始位置——算法只在首次生效,用户拖走位置就不再改变。 |
| 23 | +2. **不取代树视图**:画布与树视图共存。用户通过标签页栏的「画布」项或树视图内的浮钮切换。 |
| 24 | +3. **零破坏既有数据**:canvas_x/y 列无值不影响树视图和既有命令。 |
| 25 | +4. **连线只反映既有关系**:画布连线源于 knowledge_nodes.parent_id 和 knowledge_links。用户不能画线即建引用。 |
| 26 | +5. **不做全屏白板模式**:画布在体系页中栏渲染,不是独立页面。 |
| 27 | + |
| 28 | +## 三、技术选型 |
| 29 | + |
| 30 | +**React Flow v12**(@xyflow/react) |
| 31 | + |
| 32 | +| 维度 | React Flow | 理由 | |
| 33 | +|------|-----------|------| |
| 34 | +| 无限画布 | pan/zoom 开箱即用 | 零自研 | |
| 35 | +| 自定义节点 | React 组件 | 可复用现有知识卡片 UI | |
| 36 | +| 连线 | smoothstep/bezier | parent_id 映射为边 | |
| 37 | +| 性能 | 虚拟化渲染(视口外不渲染) | 百节点级流畅 | |
| 38 | +| 框选/拖拽 | 内置 | 用户排布体验 | |
| 39 | +| minimap | 内置 MiniMap 组件 | 全局导航 | |
| 40 | +| 控制面板 | 内置 Controls 组件 | 缩放/适配/锁定 | |
| 41 | +| 包体积 | ~95KB gzip | 可接受 | |
| 42 | + |
| 43 | +对比方案: |
| 44 | +- 自研 DOM 画布:无虚拟化,~50 节点后抖。弃用。 |
| 45 | +- 自研 Canvas:千节点级但 React 组件桥接复杂。弃用(成本高且收益过剩——知识体系节点数上限预计 < 200)。 |
| 46 | + |
| 47 | +## 四、架构设计 |
| 48 | + |
| 49 | +### 4.1 页面结构 |
| 50 | + |
| 51 | +``` |
| 52 | +KnowledgePage |
| 53 | +├── 左栏:体系列表(不变) |
| 54 | +├── 中栏: |
| 55 | +│ ├── [树视图] 标签(不变)—— KnowledgeTreeView |
| 56 | +│ │ └── 树视图顶部新增浮动「画布」按钮(切换 middleView) |
| 57 | +│ ├── [概念] 标签(不变)—— 概念列表 |
| 58 | +│ ├── [模型] 标签(不变)—— 模型列表 |
| 59 | +│ ├── [决策] 标签(不变)—— 决策日志 |
| 60 | +│ ├── [画布] 标签(新增)—— KnowledgeCanvasView |
| 61 | +│ │ └── ReactFlow(无限画布) |
| 62 | +│ │ ├── CanvasNodeQuestion(自定义节点) |
| 63 | +│ │ ├── CanvasNodeConcept(自定义节点) |
| 64 | +│ │ ├── CanvasNodeModel(自定义节点) |
| 65 | +│ │ ├── edges(parent_id 映射) |
| 66 | +│ │ ├── MiniMap |
| 67 | +│ │ └── Controls |
| 68 | +│ └── 浮动工具栏(添加节点/适配视图/刷新布局) |
| 69 | +└── 右栏:详情面板(不变) |
| 70 | +``` |
| 71 | + |
| 72 | +**切换交互**:用户选择了树视图内嵌切换 + 标签页栏并存。 |
| 73 | +- 树视图顶部新增小号「画布」图标按钮,点击切到 middleView="canvas" |
| 74 | +- 画布视图顶部保留「树视图」返回按钮,点击切回 middleView="tree" |
| 75 | +- 同时标签页栏(概念/模型/决策日志/画布)保持可见,语义一致 |
| 76 | +- 切换回树视图时折叠/展开状态和滚动位置保持(组件不退场) |
| 77 | + |
| 78 | +### 4.2 组件设计 |
| 79 | + |
| 80 | +#### KnowledgeCanvasView(新增,~200 行) |
| 81 | + |
| 82 | +``` |
| 83 | +Props: |
| 84 | + systemId: number |
| 85 | + nodes: KnowledgeNode[] |
| 86 | + concepts: KnowledgeConcept[] |
| 87 | + models: KnowledgeModel[] |
| 88 | + links: KnowledgeLink[] |
| 89 | + selectedNodeId: number | null |
| 90 | + onSelectNode: (id: number) => void |
| 91 | + onChanged: () => void |
| 92 | +
|
| 93 | +职责: |
| 94 | + 1. nodes/concepts/models → React Flow 节点(Node[]) |
| 95 | + 2. parent_id → React Flow 边(Edge[]) |
| 96 | + 3. 首次切换时计算辐射布局初始位置 |
| 97 | + 4. 拖拽结束 → invoke("update_node_canvas_position") |
| 98 | + 5. 节点点击 → onSelectNode(与树视图选中联动) |
| 99 | + 6. 切换回树视图时选中态保持一致 |
| 100 | +``` |
| 101 | + |
| 102 | +#### CanvasNodeQuestion(~60 行) |
| 103 | + |
| 104 | +React Flow 自定义节点。渲染单个知识问题。 |
| 105 | + |
| 106 | +``` |
| 107 | +┌────────────────────┐ |
| 108 | +│ ❓ 照片为什么发灰 │ |
| 109 | +│ 曝光三角 · 安全快门│ ← 关联概念徽标(若有) |
| 110 | +│ ◇ 黄金时刻法则 │ ← 关联模型徽标(若有) |
| 111 | +│ 📋 2 条笔记 │ ← 引用计数 |
| 112 | +└────────────────────┘ |
| 113 | +``` |
| 114 | + |
| 115 | +- 宽度固定 220px,高度自适应 |
| 116 | +- 标题区:类型图标 + 文本(省略号,最大 2 行) |
| 117 | +- 底部概念/模型/引用徽标行(复用 SystemBadge 风格) |
| 118 | + |
| 119 | +#### CanvasNodeConcept(~50 行) |
| 120 | + |
| 121 | +``` |
| 122 | +┌──────────────────────┐ |
| 123 | +│ 🧬 曝光三角 │ |
| 124 | +│ 本质:光圈/快门/ISO │ |
| 125 | +│ ● 活跃 │ |
| 126 | +└──────────────────────┘ |
| 127 | +``` |
| 128 | + |
| 129 | +- 宽度固定 180px |
| 130 | +- 概念名 + 本质摘要(1 行)+ 状态指示符 |
| 131 | + |
| 132 | +#### CanvasNodeModel(~45 行) |
| 133 | + |
| 134 | +``` |
| 135 | +┌──────────────────────┐ |
| 136 | +│ ⚙ 黄金时刻法则 │ |
| 137 | +│ 日出日落前后 1 小时 │ |
| 138 | +│ 🏷 摄影 │ |
| 139 | +└──────────────────────┘ |
| 140 | +``` |
| 141 | + |
| 142 | +- 宽度固定 180px |
| 143 | +- 模型名 + 主张摘录(1 行)+ 学科标签 |
| 144 | + |
| 145 | +### 4.3 数据流 |
| 146 | + |
| 147 | +``` |
| 148 | +父级 (KnowledgePage) |
| 149 | + │ |
| 150 | + ├─ 加载 systems → nodes/concepts/models/links(复用 loadSystemDetail) |
| 151 | + │ |
| 152 | + └─ middleView === "canvas" |
| 153 | + └─ KnowledgeCanvasView |
| 154 | + │ |
| 155 | + ├─ React Flow 初始化 |
| 156 | + │ ├─ nodes: 辐射布局(首次)or 已存 canvas_x/y |
| 157 | + │ ├─ edges: parent_id → Edge[] |
| 158 | + │ └─ 渲染自定义节点 |
| 159 | + │ |
| 160 | + ├─ onNodeDragStop → debounce → invoke("update_node_canvas_position") |
| 161 | + │ └─ { nodeId, canvasX, canvasY } |
| 162 | + │ |
| 163 | + ├─ onNodeClick → onSelectNode(nodeId) → 右栏详情面板更新 |
| 164 | + │ |
| 165 | + └─ onFitView → React Flow fitView() |
| 166 | +``` |
| 167 | + |
| 168 | +### 4.4 辐射布局算法 |
| 169 | + |
| 170 | +用户选择了辐射布局——核心问题在圆心,子节点向外辐射。 |
| 171 | + |
| 172 | +``` |
| 173 | +算法:BFS 辐射布局(首次切到画布时触发 + 用户点"自动排列"时重新计算) |
| 174 | +
|
| 175 | +输入:nodes(按 parent_id 组织) |
| 176 | +输出:Map<nodeId, {x, y}> |
| 177 | +
|
| 178 | +术语: |
| 179 | + - 层级(ring):圆心为 ring 0,向外每层 +1 |
| 180 | + - BBox:问题节点 220x80px、概念节点 180x70px、模型节点 180x70px |
| 181 | + - 环半径:圆心到该环中心线的距离,ring 1 = 220px,每环 +200px |
| 182 | +
|
| 183 | +步骤: |
| 184 | + 1. 找出 parentId === null 的根节点组 |
| 185 | + 2. ring 0(圆心):无明确核心问题时第一个根在 (0, 0); |
| 186 | + 有 coreQuestion 且有关联节点时,coreQuestion 虚拟节点在圆心 |
| 187 | + 3. ring 1:根节点的直接子节点,均匀分布在环半径上 |
| 188 | + 角度步长 = 360° / ring 1 节点数 |
| 189 | + 4. ring 2+:子节点均匀分布在父节点方向扇区内 |
| 190 | + 规则:子节点角度 = 父节点角度 ± spread |
| 191 | + spread = 60° / (子节点数 + 1) |
| 192 | + 5. parentId 找不到父节点 → 最外环随机角度(兜底) |
| 193 | + 6. 碰撞检测:放置前检查 BBox 重叠 → 沿角度外推 +50px,最多 2 次后到下一环 |
| 194 | +``` |
| 195 | + |
| 196 | +> **纪律**:只计算一次。用户拖拽后位置不再参与算法。 |
| 197 | +> 用户想重置布局可点"自动排列"按钮(触发全部重新计算,覆盖已存位置)。 |
| 198 | +
|
| 199 | +### 4.5 切换交互 |
| 200 | + |
| 201 | +用户在头脑风暴中选择了"树视图内嵌切换"。具体交互设计: |
| 202 | + |
| 203 | +- KnowledgeTreeView 顶部新增一个图标按钮「🎨 画布」(小号,无色边框,hover 时变色) |
| 204 | +- 点击后 middleView 从 "tree" 切到 "canvas" |
| 205 | +- KnowledgeCanvasView 顶部新增返回按钮「← 树视图」 |
| 206 | +- 标签页栏(概念/模型/决策日志/画布)中的「画布」项保持可用——两种入口互通 |
| 207 | +- 切换回树视图时折叠展开状态保持(该状态由 KnowledgeTreeView 内部管理,组件不卸载) |
| 208 | +- 切换回画布时视口位置恢复(canvas_x/y + canvas_state 中的 viewport 信息) |
| 209 | + |
| 210 | +### 4.6 DB 变更 |
| 211 | + |
| 212 | +``` |
| 213 | +-- 节点画布位置(幂等 ensure_column) |
| 214 | +ALTER TABLE knowledge_nodes ADD COLUMN canvas_x REAL; |
| 215 | +ALTER TABLE knowledge_nodes ADD COLUMN canvas_y REAL; |
| 216 | +
|
| 217 | +-- 体系画布状态表(视口位置恢复) |
| 218 | +CREATE TABLE IF NOT EXISTS knowledge_canvas_states ( |
| 219 | + system_id INTEGER PRIMARY KEY REFERENCES knowledge_systems(id), |
| 220 | + viewport_x REAL DEFAULT 0, |
| 221 | + viewport_y REAL DEFAULT 0, |
| 222 | + zoom REAL DEFAULT 1.0 |
| 223 | +); |
| 224 | +``` |
| 225 | + |
| 226 | +**命令增量**(3 条新命令): |
| 227 | + |
| 228 | +| 命令 | 入参 | 返回值 | 说明 | |
| 229 | +|------|------|--------|------| |
| 230 | +| update_node_canvas_position | nodeId, canvasX, canvasY | bool | 保存节点拖拽位置(防抖后调用) | |
| 231 | +| batch_initialize_canvas_positions | systemId, positions: [{nodeId, x, y}] | bool | 批量写入初始辐射位置 | |
| 232 | +| save_canvas_viewport | systemId, viewportX, viewportY, zoom | bool | 保存画布视口(切回时恢复) | |
| 233 | + |
| 234 | +## 五、与既有模式的冲突与兼容 |
| 235 | + |
| 236 | +| 既有事实 | 画布如何兼容 | |
| 237 | +|---------|-------------| |
| 238 | +| knowledge_nodes 无 canvas_x/y | 默认为 null → 首次打开触发 batch_initialize | |
| 239 | +| 树视图选中节点高亮 | 画布选中同一节点:高亮画布节点 + 右栏不变(shared selectedNodeId) | |
| 240 | +| 概念/模型在独立标签页 | 画布上的概念/模型节点 = 浮动参照,新建仍需走概念/模型标签页 | |
| 241 | +| 树视图不支持概念/模型混排 | 画布同时显示问题+概念+模型(按类型区分颜色/图标) | |
| 242 | +| testid 驱动的测试模式 | 辐射布局算法纯函数可测;React Flow 渲染跳过 jsdom(见 §六) | |
| 243 | + |
| 244 | +## 六、测试策略 |
| 245 | + |
| 246 | +- **KnowledgeTreeView 既有测试不变**——画布不影响树视图。 |
| 247 | +- **辐射布局算法**:纯函数 layoutRadial(nodes) → 单测断言中心节点在 (0,0)、子节点均匀分布。零 React 依赖。 |
| 248 | +- **KnowledgeCanvasView**:仅测数据转换(nodes → React Flow nodes/edges)。React Flow 渲染用 e2e 或跳过。 |
| 249 | +- **命令单元测试**:Rust 侧 commands_knowledge 新增 canvas_x/y 写入与读取测试。 |
| 250 | + |
| 251 | +## 七、UI 缩放体验 |
| 252 | + |
| 253 | +- 画布缩放与节点文本挂钩:zoom > 0.7 显示完整内容,0.4~0.7 仅标题,< 0.4 缩略卡片(仅图标+节点名缩写) |
| 254 | +- 右键菜单(远期):节点右键 → 编辑/删除/添加子节点 |
| 255 | + |
| 256 | +## 八、性能预算 |
| 257 | + |
| 258 | +| 场景 | 节点数 | 帧率目标 | 首开时间 | |
| 259 | +|------|--------|---------|---------| |
| 260 | +| 小体系 | < 20 | 60fps | < 500ms | |
| 261 | +| 中型体系 | 20-50 | 30-60fps | < 1s | |
| 262 | +| 大型体系 | 50-100 | 30fps | < 1.5s | |
| 263 | +| 超大型 | > 100 | >= 24fps | < 2s | |
| 264 | + |
| 265 | +React Flow 虚拟化在此数据量级下应无瓶颈。 |
| 266 | + |
| 267 | +## 九、不做清单(本版) |
| 268 | + |
| 269 | +- 画布上新建/编辑节点(只做展示+拖拽) |
| 270 | +- 手动连线(用户不能画新边) |
| 271 | +- 节点颜色/标签自定义 |
| 272 | +- 画布导出为图片 |
| 273 | +- 框选批量移动(React Flow 内置但本版暂不暴露) |
| 274 | +- 移动端触控适配 |
| 275 | +- AI 建议布局 |
| 276 | + |
| 277 | +## 十、参考 |
| 278 | + |
| 279 | +- ADR-024 知识体系层(方案 B)——有界体系通过、自由双链/图谱仍出局 |
| 280 | +- v0.13.1 知识体系基建规格([ ] 已归档) |
| 281 | +- 知识体系设计理念(§四 为什么不算"知识图谱回归") |
| 282 | +- React Flow 官方文档:https://reactflow.dev/ |
0 commit comments