|
| 1 | +# v0.14.1 设计规格:笔记组 CRUD + 知识画布连线/布局选项 |
| 2 | + |
| 3 | +> 状态:✅ 已批准(2026-08-29,逐项确认;用户裁决:删除语义=影响面确认后级联;偏好记忆=按体系持久化;布局/连线候选全纳入) |
| 4 | +> 所属版本:v0.14.1(体验与质量攻坚系列续作;v0.14 D4 ROI 接线为本版另一挂账项,不在本设计范围) |
| 5 | +> 范围:① 笔记组新建/重命名/删除(前端接线 + 新增删除命令)② 知识画布连线样式(直线/曲线/圆角折线/硬折线 + 箭头开关)与布局算法(6 种)选择 + 按体系持久化 |
| 6 | +
|
| 7 | +## 1. 背景与目标 |
| 8 | + |
| 9 | +### 1.1 现状与痛点 |
| 10 | + |
| 11 | +| # | 痛点 | 现状根因 | |
| 12 | +|---|------|----------| |
| 13 | +| G1 | 笔记组只能由会话自动生成,用户不能主动新建/改名 | 后端 `create_topic_group` / `rename_note_group`(v0.11.0)已存在,前端零接线;组侧栏(v0.14 C1 重构)无任何新建/编辑入口 | |
| 14 | +| G2 | 组无法删除——误生成的组永久存在 | 无 `delete_note_group` 命令;ⓘ 弹层只有改判/管理动作 | |
| 15 | +| G3 | 画布连线只有圆角折线一种 | v0.13.8 规格曾记 smoothstep/bezier 候选(`docs/archive/2026-08-24/2026-08-24-v0.13.8-knowledge-canvas-design.md`),实现仅 smoothstep | |
| 16 | +| G4 | 自动排列只有辐射布局 | v0.13.8 后画布仍是唯一 BFS 辐射算法;用户拖拽/多根/深树场景下无备选 | |
| 17 | + |
| 18 | +用户反馈(2026-08-29):**① 笔记组要能添加、编辑(新建/编辑等操作逻辑);② 知识体系要有直线/曲线的选择,布局上要有其他选择(头脑风暴)**。 |
| 19 | + |
| 20 | +### 1.2 目标 |
| 21 | + |
| 22 | +1. **组管理闭环**:手动新建(名称+领域+颜色)/ 行内重命名 / 影响面确认后级联删除 |
| 23 | +2. **画布连线样式**:直线 / 曲线 / 圆角折线(现状默认)/ 硬折线 + 可选箭头 |
| 24 | +3. **画布布局算法**:辐射(现状默认)/ 双翼思维导图 / 水平逻辑树 / 垂直组织树 / 鱼骨图(简化版)/ 双环(浮动项贴树) |
| 25 | +4. **按体系持久化**:连线样式 + 布局算法 + 箭头开关存 `knowledge_canvas_states`(每体系独立记忆) |
| 26 | + |
| 27 | +### 1.3 约束(守既有裁决,不越界) |
| 28 | + |
| 29 | +- **力导向不入**:ADR-024 不做清单"力导向图"与 v0.13.8"画布=手动画布非自动图"纪律保持——布局只作为「自动排列」算法菜单,用户显式触发才覆盖已存位置(规格 §4.4 纪律) |
| 30 | +- **画布不做清单保持**:不新建/编辑节点、不手动连线、不导出图片、不做框选批量移动(v0.13.8 §九) |
| 31 | +- **组模型不变**:无父子组;三条设计契约(粒度对齐 DomainTag / 组是学习单元 / 组结算防沼泽)延续 |
| 32 | +- **零破坏迁移**:`ALTER TABLE ADD COLUMN` 幂等模式(v0.14 B 同款,已验证);旧库无新列 → 默认值兜底 |
| 33 | + |
| 34 | +## 2. 方案选型记录 |
| 35 | + |
| 36 | +### 2.1 组删除语义(已裁决:影响面确认后级联) |
| 37 | + |
| 38 | +| 变体 | 结论 | |
| 39 | +|---|---| |
| 40 | +| A. 影响面确认后级联 | ✅ 采纳——先展示数量(笔记/碎片→移入"全部";闪卡/结算/周契约→级联删除;体系引用→清理悬空),用户勾选确认后单事务执行 | |
| 41 | +| B. 有闪卡先禁删 | 否决——闪卡无法迁到无组状态(GROUP_ID NOT NULL),操作繁琐 | |
| 42 | +| C. 改为归档语义 | 否决——用户原意是删除;归档组与现状软归档体系语义混叠 | |
| 43 | + |
| 44 | +### 2.2 偏好记忆方式(已裁决:按体系持久化) |
| 45 | + |
| 46 | +| 变体 | 结论 | |
| 47 | +|---|---| |
| 48 | +| 按体系持久化 | ✅ 采纳——`knowledge_canvas_states`(system_id 1:1)加列;不同体系可不同风格 | |
| 49 | +| 全局一次设置 | 否决——体系间风格无法区分 | |
| 50 | +| 仅会话内 | 否决——切页/重启丢失 | |
| 51 | + |
| 52 | +### 2.3 布局算法候选(用户全选 6 项;力导向被否决) |
| 53 | + |
| 54 | +| 算法 | 适配 | 说明 | |
| 55 | +|---|---|---| |
| 56 | +| 辐射 `radial` | 现状默认 | 现有 `layoutRadial` 纯函数不动(回归安全) | |
| 57 | +| 双翼思维导图 `mindmap` | 浅/中树 | XMind 默认心智:核心/根居中,根节点左右分翼,翼内树向下展开 | |
| 58 | +| 水平逻辑树 `treeRight` | 深树 | XMind「逻辑图(向右)」:根最左,整树向右单向展开 | |
| 59 | +| 垂直组织树 `org` | 层级浏览 | 根最上,子层向下逐层展开 | |
| 60 | +| 鱼骨图 `fishbone` | 根分支多 | **简化版**(用户确认):主干=体系根水平线,一级根上下交替为刺,二级沿刺展开;完整鱼骨因果语义与知识树不匹配,不做 | |
| 61 | +| 双环 `dualRing` | 概念/模型多 | 问题树辐射布局 + 概念/模型浮动项独占「树最深环+1」贴树环(修复现状"浮动项总落最外环远离树") | |
| 62 | + |
| 63 | +### 2.4 连线样式候选(用户全选) |
| 64 | + |
| 65 | +| 样式 | RF edge type | 说明 | |
| 66 | +|---|---|---| |
| 67 | +| 直线 `straight` | `straight` | 紧凑无歧义,交叉多时易乱 | |
| 68 | +| 曲线 `bezier` | `bezier`(RF 默认) | 视觉柔和,边多时如面条 | |
| 69 | +| 圆角折线 `smoothstep` | `smoothstep` | **现状默认**——横平竖直干线,最像树/流程图 | |
| 70 | +| 硬折线 `step` | `step` | 直角无圆角,"电路图"感 | |
| 71 | + |
| 72 | +附加开关:**箭头**(`edge_arrows`,默认 false 保持现状);**分类边样式**:本次只保证结构不变——父子边按所选样式实线、体系根卡边按所选样式虚线(现状仅根卡虚),不引入新边类型/着色(预留扩展位)。 |
| 73 | + |
| 74 | +## 3. 部分一:笔记组 CRUD |
| 75 | + |
| 76 | +### 3.1 数据层(db_note_groups.rs,无新表) |
| 77 | + |
| 78 | +外键已就绪(`db.rs` 每连接 `PRAGMA foreign_keys=ON`,v0.11.0 建表即定):`notes.group_id`/`fragments.group_id` ON DELETE SET NULL;`flashcards`/`settlements`/`contracts`.group_id ON DELETE CASCADE。新增: |
| 79 | + |
| 80 | +- `group_delete_impact(id) -> Result<GroupDeleteImpact>`:影响面计数(只读,无副作用) |
| 81 | +- `delete_group(id) -> Result<bool>`:**单事务**——① 显式 `DELETE FROM knowledge_links WHERE target_type='note_group' AND target_id=?`(该表无 FK 到 note_groups,必须手动清悬空引用)② `DELETE FROM note_groups WHERE id=?`(笔记/碎片 SET NULL 与闪卡/结算/契约 CASCADE 由 FK 自动生效)③ 组不存在 → 返回 false(command 层转错误) |
| 82 | + |
| 83 | +`GroupDeleteImpact`(types.rs,serde camelCase 与前端同口径): |
| 84 | + |
| 85 | +```rust |
| 86 | +pub struct GroupDeleteImpact { |
| 87 | + pub notes: i64, // 笔记数 → 移入「全部」 |
| 88 | + pub fragments: i64, // 碎片数 → 移入「全部」 |
| 89 | + pub cards: i64, // 闪卡数 → 将级联删除 |
| 90 | + pub settlements: i64, // 结算记录数 → 将级联删除 |
| 91 | + pub contracts: i64, // 周契约数 → 将级联删除 |
| 92 | + pub system_refs: i64, // 体系引用数 → 引用将解除 |
| 93 | +} |
| 94 | +``` |
| 95 | + |
| 96 | +### 3.2 命令层(commands_groups.rs) |
| 97 | + |
| 98 | +- `get_group_delete_impact(id: i64) -> Result<GroupDeleteImpact>`:id>0 + 存在性校验 |
| 99 | +- `delete_note_group(id: i64) -> Result<bool>`:id>0 + 存在性校验;调 `db.delete_group` |
| 100 | + |
| 101 | +删除**不区分组来源**(container/feed、课程/主题/独立皆可删——误生成纠错是主诉求); |
| 102 | +删除前**必须先调 impact 再调 delete**(两步——确认弹窗数据源与执行分离,防确认后数据漂移造成"以为删的是 A 实际删的是 B")。 |
| 103 | + |
| 104 | +### 3.3 前端 |
| 105 | + |
| 106 | +| 入口 | 行为 | 命令调用 | |
| 107 | +|---|---|---| |
| 108 | +| 侧栏头「+ 新建组」(GroupSidebar 头部工具条) | `GroupCreateDialog.tsx` 弹窗:名称(必填)+ 领域标签(15 类下拉,必填——契约一,`DOMAIN_OPTIONS` 自 RouteInfoPopover 迁至 `utils/domainOptions.ts` 共享常量)+ 颜色(可选,复用 `NoteColorPicker`) | `create_topic_group` → `update_group_color` → `onChanged` | |
| 109 | +| 组行 hover「✎」(GroupSidebarRow) | 名称变内联输入框(受控编辑态;行单击过滤/色点/ⓘ 全部 stopPropagation);Enter 保存 / Esc 取消;空白名拒绝 | `rename_note_group` → `onChanged` | |
| 110 | +| ⓘ 弹层新增「组管理·危险操作」区(RouteInfoPopover 第五区) | 「删除组」按钮 → `GroupDeleteConfirm.tsx` 确认弹窗:loading 展示影响面六项 → 勾选「我了解后果」(有级联项时必勾;无级联项时点按钮即执行)→ 执行 | `get_group_delete_impact` → `delete_note_group` → `onChanged` | |
| 111 | + |
| 112 | +## 4. 部分二:知识画布连线/布局偏好 |
| 113 | + |
| 114 | +### 4.1 迁移(db_migrations.rs) |
| 115 | + |
| 116 | +```sql |
| 117 | +ALTER TABLE knowledge_canvas_states ADD COLUMN edge_style TEXT NOT NULL DEFAULT 'smoothstep'; |
| 118 | +ALTER TABLE knowledge_canvas_states ADD COLUMN edge_arrows INTEGER NOT NULL DEFAULT 0; |
| 119 | +ALTER TABLE knowledge_canvas_states ADD COLUMN layout_algorithm TEXT NOT NULL DEFAULT 'radial'; |
| 120 | +``` |
| 121 | + |
| 122 | +- 幂等模式:迁移器按列探测(v0.14 B 同款 `column_exists` 手法) |
| 123 | +- `CREATE TABLE IF NOT EXISTS knowledge_canvas_states` 同步补三列(新库直接建齐) |
| 124 | +- 表仍 1:1(system_id 主键),**行可能不存在**(首开无视口)——读偏好返回 `Option`,前端缺省回落默认值 |
| 125 | + |
| 126 | +### 4.2 数据层(db_knowledge_canvas.rs) |
| 127 | + |
| 128 | +- `CanvasPrefs { edge_style: String, edge_arrows: bool, layout_algorithm: String }`(types.rs;serde camelCase) |
| 129 | +- `get_canvas_prefs(system_id) -> Result<Option<CanvasPrefs>>` |
| 130 | +- `save_canvas_prefs(system_id, edge_style, edge_arrows, layout_algorithm) -> Result<bool>`:**upsert**(ON CONFLICT(system_id) DO UPDATE——与 save_canvas_viewport 同款) |
| 131 | + |
| 132 | +### 4.3 命令层(commands_knowledge_canvas.rs) |
| 133 | + |
| 134 | +- `get_canvas_prefs(system_id)` / `save_canvas_prefs(system_id, edgeStyle, edgeArrows, layoutAlgorithm)` |
| 135 | +- 白名单校验(前后端枚举同口径): |
| 136 | + |
| 137 | +```rust |
| 138 | +const EDGE_STYLES: [&str; 4] = ["straight", "bezier", "smoothstep", "step"]; |
| 139 | +const LAYOUT_ALGORITHMS: [&str; 6] = ["radial", "mindmap", "treeRight", "org", "fishbone", "dualRing"]; |
| 140 | +``` |
| 141 | + |
| 142 | +非法值拒绝(防任意字符串入库);system_id>0 + 体系存在性校验。 |
| 143 | + |
| 144 | +### 4.4 布局算法纯函数(app/src/utils/) |
| 145 | + |
| 146 | +统一接口(与 `layoutRadial` 同构——纯函数零副作用零随机): |
| 147 | + |
| 148 | +```ts |
| 149 | +type LayoutFn = (input: { hasCore: boolean; items: CanvasLayoutItem[] }) => Map<string, CanvasPoint>; |
| 150 | +``` |
| 151 | + |
| 152 | +- `layoutRadial.ts`:**行为零变化**——公共件(`CANVAS_BBOX`/`polar`/碰撞检测)迁出至新建 |
| 153 | + `layoutShared.ts`(layoutRadial 改为 import 引用;输出/签名不变,回归安全) |
| 154 | +- 新增纯函数文件(各 ≤300 行:`layoutShared.ts` + 5 个算法 + 分发器): |
| 155 | + - `layoutMindmap.ts`:中心=core 卡(hasCore)或第一根;根节点按序分左右两翼(交替分配),翼内树向下展开;x=深度步长、y=翼内顺序步长(紧凑层叠,无重叠——复用碰撞外推) |
| 156 | + - `layoutTreeRight.ts`:根 x=0;深度决定 x;同层 y 按父顺序紧凑下排 |
| 157 | + - `layoutOrg.ts`:根 y=0;深度决定 y;同层 x 水平展开 |
| 158 | + - `layoutFishbone.ts`:简化版——主干 y=0 水平线;一级根上下交替(y=±step)、沿 x 等距;二级沿所属骨刺向下展开;其余(三级+)沿骨刺继续延伸 |
| 159 | + - `layoutDualRing.ts`:问题树按 radial 分层(核心近心);概念/模型浮动项独占 `最深环+1` 环(半径=外圈,贴树不分离);孤儿沿用 golden angle 兜底(与 radial 一致) |
| 160 | + - `layoutCanvas.ts`:分发器 `layoutCanvas(input, algorithm)`——未知算法名回退 `radial`(前端枚举同口径 + 防御) |
| 161 | +- 确定性要求:相同输入恒相同输出(单测可精确断言);每个算法保证所有输入 item 的 key 都有位置(孤儿/未知 parent 兜底) |
| 162 | + |
| 163 | +### 4.5 连线样式(canvasElements.ts) |
| 164 | + |
| 165 | +- `buildCanvasElements` 输入增加 `edgeStyle: EdgeStyle` + `edgeArrows: boolean` |
| 166 | +- 父子边与根卡虚边 `type` 按映射输出(straight/bezier/smoothstep/step);根卡边保持 `strokeDasharray` 虚线 + 所选 type |
| 167 | +- `edgeArrows=true` → 全部边加 `markerEnd: { type: MarkerType.ArrowClosed }`(与主题色一致) |
| 168 | +- 三自定义问题/概念/模型节点 + core 节点的四边 Handle 已齐备(v0.13.9)——所有边类型均从 handle 接线,`resolveEdgeHandles` 不变 |
| 169 | + |
| 170 | +### 4.6 画布 UI(KnowledgeCanvasView.tsx) |
| 171 | + |
| 172 | +右上 Panel 调整(替换现行「✨ 自动排列」「⛶ 适配视图」按钮组): |
| 173 | + |
| 174 | +| 控件 | 行为 | |
| 175 | +|---|---| |
| 176 | +| 「✨ 布局 ▾」下拉(6 项 + 简述) | 选中 = 用该算法立即重排(覆盖已存位置,规格 §4.4 显式触发语义)+ `save_canvas_prefs` + `fitView` | |
| 177 | +| 「〰 连线 ▾」下拉(4 项 + ✓箭头勾选) | 选中 = 即时重渲染 + `save_canvas_prefs` | |
| 178 | +| 「⛶ 适配视图」 | 保留 | |
| 179 | + |
| 180 | +- 初始渲染:连线样式/箭头读 prefs(缺省默认);布局算法读 prefs(缺省 `radial`)——首次打开缺位节点的初始化位置用**已存算法**而非固定辐射 |
| 181 | +- 「自动排列」按钮语义并入「布局 ▾」(原按钮删除——避免双入口语义分歧) |
| 182 | + |
| 183 | +## 5. 错误处理与边界 |
| 184 | + |
| 185 | +- 组删除:impact 读取失败 → 确认弹窗展示错误不执行;delete 失败 → 弹窗内状态行展示错误,组列表不变(onChanged 不触发) |
| 186 | +- 新建组:领域必填(后端 `create_topic_group` 已强制);同领域同地形已有主题组 → 后端幂等复用返回既有组(前端提示"已存在,已选中") |
| 187 | +- 重命名冲突/非法名 → `normalize_title` 兜底(后端已有);空名拒绝在前端层 |
| 188 | +- 画布偏好:枚举非法值命令层拒绝;states 行缺失 → 默认值;`save_canvas_prefs` 失败 → 状态行展示错误,视觉选项仍生效(会话内),下次打开回落旧值 |
| 189 | +- layout 纯函数输入异常(空 items、全部孤儿、hasCore 但无根)→ 不 panic,输出空/兜底位置 |
| 190 | + |
| 191 | +## 6. 测试策略 |
| 192 | + |
| 193 | +**Rust(cargo test,内存库)**: |
| 194 | + |
| 195 | +| 模块 | 用例(AAA) | |
| 196 | +|---|---| |
| 197 | +| db_note_groups | impact 六项计数正确(含零项);delete 后 notes/fragments 组置 NULL、flashcards/settlements/contracts 归零、knowledge_links 悬空清理、再查组不存在;不存在组 delete → false | |
| 198 | +| commands_groups | id≤0/不存在组拒绝;非法领域拒绝(沿用现有校验链) | |
| 199 | +| db_knowledge_canvas | 无行 get → None;save 后 get 全字段回读;upsert 覆盖;第二体系互不影响 | |
| 200 | +| commands_knowledge_canvas | 枚举白名单(合法 4+6 + 非法拒绝);system_id 校验;camelCase 契约回读 | |
| 201 | + |
| 202 | +**前端(Vitest)**: |
| 203 | + |
| 204 | +| 模块 | 用例 | |
| 205 | +|---|---| |
| 206 | +| layout 纯函数 ×5 + 分发器 | 所有 key 有位置;确定性(两跑一致);无重叠(碰撞断言);mindmap 左右翼/树深 x 正确;treeRight x 随深度;org y 随深度;fishbone 一二级上下交替;dualRing 浮动项在最深环+1;未知算法回退 radial | |
| 207 | +| canvasElements | edge type 按 4 枚举映射;edgeArrows → markerEnd;根卡边保持虚线 | |
| 208 | +| GroupCreateDialog | 领域必填校验;invoke 序列(create → color → onChanged) | |
| 209 | +| GroupSidebarRow 重命名 | ✎ 进入编辑态;Enter 保存/Esc 取消;与行单击不冲突 | |
| 210 | +| GroupDeleteConfirm | impact 展示六项;级联>0 时未勾选禁执行;勾选后调 delete | |
| 211 | +| KnowledgeCanvasView | 布局下拉选中 → autoLayout + save_canvas_prefs;连线下拉 → 重渲染 + save;初始读 prefs | |
| 212 | + |
| 213 | +## 7. 文件清单 |
| 214 | + |
| 215 | +**Rust(app/src-tauri/src/)**: |
| 216 | + |
| 217 | +| 文件 | 变化 | |
| 218 | +|---|---| |
| 219 | +| `types.rs` | +`GroupDeleteImpact`、+`CanvasPrefs` | |
| 220 | +| `db_migrations.rs` | +canvas_states 三列迁移(幂等)+ CREATE 语句同步 | |
| 221 | +| `db_note_groups.rs` | +`group_delete_impact`/`delete_group`;`db_note_groups_tests.rs` 补用例 | |
| 222 | +| `commands_groups.rs` | +2 命令;`commands_groups_tests.rs`(如无则新建) | |
| 223 | +| `db_knowledge_canvas.rs` | +`get_canvas_prefs`/`save_canvas_prefs`;`db_knowledge_canvas_tests.rs` 补用例 | |
| 224 | +| `commands_knowledge_canvas.rs` | +2 命令 + 白名单;`commands_knowledge_canvas_tests.rs` 补用例 | |
| 225 | +| `lib.rs` | +4 命令注册 | |
| 226 | + |
| 227 | +**前端(app/src/)**: |
| 228 | + |
| 229 | +| 文件 | 变化 | |
| 230 | +|---|---| |
| 231 | +| `utils/layoutMindmap.ts` / `layoutTreeRight.ts` / `layoutOrg.ts` / `layoutFishbone.ts` / `layoutDualRing.ts` / `layoutCanvas.ts` / `layoutShared.ts` | 新增纯函数 + 单测 | |
| 232 | +| `utils/layoutRadial.ts` | 公共件迁出至 layoutShared 后的 import 调整(行为零变化) | |
| 233 | +| `utils/canvasElements.ts` | +`edgeStyle`/`edgeArrows` 入参与映射 | |
| 234 | +| `components/KnowledgeCanvasView.tsx` | 布局/连线下拉 + prefs 读写 | |
| 235 | +| `components/GroupSidebar.tsx` | 「+ 新建组」+ 重命名态接线 | |
| 236 | +| `components/GroupSidebarRow.tsx` | ✎ 按钮 + 内联编辑态 | |
| 237 | +| `components/GroupCreateDialog.tsx` | 新建弹窗(新增) | |
| 238 | +| `components/GroupDeleteConfirm.tsx` | 删除影响面确认(新增) | |
| 239 | +| `components/RouteInfoPopover.tsx` | 第五区「删除组」 | |
| 240 | +| 相应 `.test.tsx` | 交互测试 | |
| 241 | + |
| 242 | +## 8. 验收标准 |
| 243 | + |
| 244 | +1. 组侧栏「+ 新建组」→ 填名称/领域/颜色 → 新组出现在「主题」分区且色点正确 |
| 245 | +2. 组行 ✎ → 改名 → Enter 后行显示新名(刷新后保持) |
| 246 | +3. ⓘ 弹层「删除组」→ 影响面弹窗数字正确 → 勾选确认 → 组消失;组内笔记留在「全部笔记」;闪卡/结算/契约消失;体系页引用该组处消失 |
| 247 | +4. 画布「布局 ▾」选水平逻辑树 → 整树右向展开且节点不重叠;选回辐射 → 恢复圆形排布 |
| 248 | +5. 画布「连线 ▾」切曲线 → 亲子连线变曲线;勾选箭头 → 边末端出现箭头;硬折线/直线同理 |
| 249 | +6. 切换体系再切回 → 布局/连线偏好保持上次选择;重启应用后保持 |
| 250 | +7. 旧库升级(无新列)→ 默认 smoothstep + radial,功能正常 |
| 251 | +8. 单测全绿(新增用例数预计:Rust ~20、前端 ~35) |
0 commit comments