|
| 1 | +//! 知识体系画布命令层(v0.13.8 系统层;4 命令)。 |
| 2 | +//! |
| 3 | +//! @ai-context: 本层只做参数校验、调用数据层、错误映射(AGENTS.md §6);编排逻辑 |
| 4 | +//! `fn xxx_inner(db, ...)` 为纯函数(:memory: 可测),薄 `#[tauri::command]` |
| 5 | +//! 壳只取 state.db 调 inner。画布=手动画布非自动图(REQ-029 P3)—— |
| 6 | +//! 位置只由用户拖拽(update)/辐射布局初始化(batch)/「自动排列」(batch)写入。 |
| 7 | +//! @ai-context: 入参出参契约——id>0;坐标必须为有限值(拒绝 NaN/Infinity—— |
| 8 | +//! JS 侧拖拽坐标可能混入数学异常,防损坏值入库);zoom 必须 >0 且 ≤10 |
| 9 | +//! (防御性上界,防错误缩放值把画布缩到不可见)。 |
| 10 | +//! @ai-context: 设计规格 §4.6 列 3 条命令;规格同时要求"切回画布视口恢复", |
| 11 | +//! 恢复需要读路径——补 `get_canvas_viewport`(第 4 条)补齐读写闭环, |
| 12 | +//! 已在版本文档 v0.13.8 交付记录登记(规格增补,非契约变更)。 |
| 13 | +
|
| 14 | +use tauri::State; |
| 15 | + |
| 16 | +use crate::commands::AppState; |
| 17 | +use crate::commands_knowledge::require_id; |
| 18 | +use crate::db::Db; |
| 19 | +use crate::types::{CanvasNodePosition, CanvasViewport}; |
| 20 | + |
| 21 | +/// 保存节点画布位置(拖拽落点;防抖后调用;幂等覆盖)。 |
| 22 | +/// |
| 23 | +/// @ai-context: 不触发列表刷新(位置不入树视图);不存在或跨体系由存在性校验兜底。 |
| 24 | +#[tauri::command] |
| 25 | +pub fn update_node_canvas_position( |
| 26 | + state: State<'_, AppState>, |
| 27 | + node_id: i64, |
| 28 | + canvas_x: f64, |
| 29 | + canvas_y: f64, |
| 30 | +) -> Result<bool, String> { |
| 31 | + update_node_canvas_position_inner(&state.db, node_id, canvas_x, canvas_y) |
| 32 | +} |
| 33 | + |
| 34 | +/// 批量写入节点画布位置(辐射布局首批初始化 / 「自动排列」覆盖)。 |
| 35 | +/// |
| 36 | +/// @ai-context: 全量校验通过才落库(单事务);同一 nodeId 重复出现时后端拒绝 |
| 37 | +/// (前端不会构造重复项,重复即契约异常——防幂等遗漏导致歧义)。 |
| 38 | +#[tauri::command] |
| 39 | +pub fn batch_initialize_canvas_positions( |
| 40 | + state: State<'_, AppState>, |
| 41 | + system_id: i64, |
| 42 | + positions: Vec<CanvasNodePosition>, |
| 43 | +) -> Result<bool, String> { |
| 44 | + batch_initialize_canvas_positions_inner(&state.db, system_id, positions) |
| 45 | +} |
| 46 | + |
| 47 | +/// 保存体系画布视口(切换/缩放结束时防抖调用;upsert)。 |
| 48 | +#[tauri::command] |
| 49 | +pub fn save_canvas_viewport( |
| 50 | + state: State<'_, AppState>, |
| 51 | + system_id: i64, |
| 52 | + viewport_x: f64, |
| 53 | + viewport_y: f64, |
| 54 | + zoom: f64, |
| 55 | +) -> Result<bool, String> { |
| 56 | + save_canvas_viewport_inner(&state.db, system_id, viewport_x, viewport_y, zoom) |
| 57 | +} |
| 58 | + |
| 59 | +/// 读取体系画布视口(从未保存返回 None——前端按内容 fitView 兜底)。 |
| 60 | +/// |
| 61 | +/// @ai-context: 设计规格 §4.5「切回画布视口恢复」的读路径(读写闭环)。 |
| 62 | +#[tauri::command] |
| 63 | +pub fn get_canvas_viewport( |
| 64 | + state: State<'_, AppState>, |
| 65 | + system_id: i64, |
| 66 | +) -> Result<Option<CanvasViewport>, String> { |
| 67 | + get_canvas_viewport_inner(&state.db, system_id) |
| 68 | +} |
| 69 | + |
| 70 | +/// 校验坐标有限值(拒绝 NaN/Infinity——浮点拖拽坐标的异常防护)。 |
| 71 | +fn require_coord(v: f64, label: &str) -> Result<(), String> { |
| 72 | + if v.is_finite() { |
| 73 | + Ok(()) |
| 74 | + } else { |
| 75 | + Err(format!("{}无效(必须为有限数值)", label)) |
| 76 | + } |
| 77 | +} |
| 78 | + |
| 79 | +/// 保存单个节点位置(节点存在性校验 + 坐标校验)。 |
| 80 | +pub(crate) fn update_node_canvas_position_inner( |
| 81 | + db: &Db, |
| 82 | + node_id: i64, |
| 83 | + canvas_x: f64, |
| 84 | + canvas_y: f64, |
| 85 | +) -> Result<bool, String> { |
| 86 | + require_id(node_id)?; |
| 87 | + require_coord(canvas_x, "画布 X 坐标")?; |
| 88 | + require_coord(canvas_y, "画布 Y 坐标")?; |
| 89 | + if db.get_knowledge_node(node_id).map_err(|e| e.to_string())?.is_none() { |
| 90 | + return Err(format!("节点不存在: {}", node_id)); |
| 91 | + } |
| 92 | + db.update_node_canvas_position(node_id, canvas_x, canvas_y).map_err(|e| e.to_string()) |
| 93 | +} |
| 94 | + |
| 95 | +/// 批量写入节点位置(体系存在 + 每节点存在且属该体系;事务原子)。 |
| 96 | +pub(crate) fn batch_initialize_canvas_positions_inner( |
| 97 | + db: &Db, |
| 98 | + system_id: i64, |
| 99 | + positions: Vec<CanvasNodePosition>, |
| 100 | +) -> Result<bool, String> { |
| 101 | + require_id(system_id)?; |
| 102 | + if db.get_knowledge_system(system_id).map_err(|e| e.to_string())?.is_none() { |
| 103 | + return Err(format!("体系不存在: {}", system_id)); |
| 104 | + } |
| 105 | + if positions.is_empty() { |
| 106 | + return Err("位置列表不能为空".to_string()); |
| 107 | + } |
| 108 | + // 全量预校验(任何一项失败整体拒绝——事务前校验,无部分写入) |
| 109 | + let mut seen: Vec<i64> = Vec::new(); |
| 110 | + for p in &positions { |
| 111 | + require_id(p.node_id)?; |
| 112 | + require_coord(p.x, "X 坐标")?; |
| 113 | + require_coord(p.y, "Y 坐标")?; |
| 114 | + if seen.contains(&p.node_id) { |
| 115 | + return Err(format!("节点位置重复: {}", p.node_id)); |
| 116 | + } |
| 117 | + seen.push(p.node_id); |
| 118 | + let node = db.get_knowledge_node(p.node_id).map_err(|e| e.to_string())?; |
| 119 | + match node { |
| 120 | + Some(n) => { |
| 121 | + if n.system_id != system_id { |
| 122 | + return Err(format!("节点 {} 不属于该体系", p.node_id)); |
| 123 | + } |
| 124 | + } |
| 125 | + None => return Err(format!("节点不存在: {}", p.node_id)), |
| 126 | + } |
| 127 | + } |
| 128 | + let tuples: Vec<(i64, f64, f64)> = positions.into_iter().map(|p| (p.node_id, p.x, p.y)).collect(); |
| 129 | + db.set_node_canvas_positions(&tuples).map_err(|e| e.to_string()) |
| 130 | +} |
| 131 | + |
| 132 | +/// 保存视口(坐标/缩放校验后 upsert)。 |
| 133 | +pub(crate) fn save_canvas_viewport_inner( |
| 134 | + db: &Db, |
| 135 | + system_id: i64, |
| 136 | + viewport_x: f64, |
| 137 | + viewport_y: f64, |
| 138 | + zoom: f64, |
| 139 | +) -> Result<bool, String> { |
| 140 | + require_id(system_id)?; |
| 141 | + if db.get_knowledge_system(system_id).map_err(|e| e.to_string())?.is_none() { |
| 142 | + return Err(format!("体系不存在: {}", system_id)); |
| 143 | + } |
| 144 | + require_coord(viewport_x, "视口 X")?; |
| 145 | + require_coord(viewport_y, "视口 Y")?; |
| 146 | + if !(zoom.is_finite() && zoom > 0.0 && zoom <= 10.0) { |
| 147 | + return Err(format!("缩放无效: {}(必须为 (0, 10] 的有限数值)", zoom)); |
| 148 | + } |
| 149 | + db.save_canvas_viewport(system_id, viewport_x, viewport_y, zoom).map_err(|e| e.to_string()) |
| 150 | +} |
| 151 | + |
| 152 | +/// 读取视口(体系存在性校验;无记录返回 None)。 |
| 153 | +pub(crate) fn get_canvas_viewport_inner( |
| 154 | + db: &Db, |
| 155 | + system_id: i64, |
| 156 | +) -> Result<Option<CanvasViewport>, String> { |
| 157 | + require_id(system_id)?; |
| 158 | + if db.get_knowledge_system(system_id).map_err(|e| e.to_string())?.is_none() { |
| 159 | + return Err(format!("体系不存在: {}", system_id)); |
| 160 | + } |
| 161 | + db.get_canvas_viewport(system_id) |
| 162 | + .map(|v| v.map(|(x, y, z)| CanvasViewport { viewport_x: x, viewport_y: y, zoom: z })) |
| 163 | + .map_err(|e| e.to_string()) |
| 164 | +} |
| 165 | + |
| 166 | +/// 命令层单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。 |
| 167 | +#[cfg(test)] |
| 168 | +#[path = "commands_knowledge_canvas_tests.rs"] |
| 169 | +mod tests; |
0 commit comments