Skip to content

Commit a92eaad

Browse files
committed
feat(knowledge): v0.13.8 画布数据层与视口命令
- db_migrations:knowledge_nodes 补 canvas_x/canvas_y(新库建表内联 + 旧库 ensure_column 零破坏)+ knowledge_canvas_states(system_id 1:1 视口 upsert) - types:KnowledgeNode 扩 canvas_x/y(serde default 兼容旧 JSON)+ CanvasNodePosition/CanvasViewport - commands_knowledge_canvas:4 命令(位置单写/批量事务/视口读存)——坐标有限值、 zoom (0,10]、batch 全量预校验原子拒绝;11 项单测(AAA + 内存库)
1 parent 39cb772 commit a92eaad

8 files changed

Lines changed: 637 additions & 2 deletions
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
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

Comments
 (0)