Skip to content

Commit 20da94e

Browse files
committed
feat(v0.14.1): 笔记组 CRUD 与画布连线/布局偏好
1 parent 35a8613 commit 20da94e

34 files changed

Lines changed: 2161 additions & 108 deletions

‎app/src-tauri/src/commands_groups.rs‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
use tauri::State;
99

1010
use crate::commands::{normalize_title, AppState};
11-
use crate::types::{NewNoteGroup, Note, NoteGroup};
11+
use crate::types::{GroupDeleteImpact, NewNoteGroup, Note, NoteGroup};
1212
use crate::video_profile_domain::DomainKind;
1313

1414
/// 组类别白名单(非法值拒绝——前端枚举同口径)。
@@ -183,3 +183,53 @@ pub fn update_group_color(
183183
.update_group_color(id, color.as_deref())
184184
.map_err(|e| e.to_string())
185185
}
186+
187+
/// 组删除影响面(v0.14.1;只读——确认弹窗数据源,无副作用)。
188+
///
189+
/// @ai-context: 两步删除(先 impact 后 delete)——确认弹窗展示的数量与执行时的
190+
/// 真实状态分离,防"确认后数据漂移"造成误删语义(规格 §3.2)。
191+
#[tauri::command]
192+
pub fn get_group_delete_impact(
193+
state: State<'_, AppState>,
194+
id: i64,
195+
) -> Result<GroupDeleteImpact, String> {
196+
get_group_delete_impact_inner(&state.db, id)
197+
}
198+
199+
/// 删除组(v0.14.1:影响面确认后级联——单事务清理引用再删组)。
200+
///
201+
/// @ai-context: 不区分组来源(container/feed、course/topic/standalone 皆可删——
202+
/// 误生成纠错是主诉求);删除后组内笔记/碎片经 FK SET NULL 移入
203+
/// "全部",闪卡/结算/周契约 CASCADE,体系引用显式清理。
204+
#[tauri::command]
205+
pub fn delete_note_group(state: State<'_, AppState>, id: i64) -> Result<bool, String> {
206+
delete_note_group_inner(&state.db, id)
207+
}
208+
209+
pub(crate) fn get_group_delete_impact_inner(
210+
db: &crate::db::Db,
211+
id: i64,
212+
) -> Result<GroupDeleteImpact, String> {
213+
if id <= 0 {
214+
return Err("无效的组 id".to_string());
215+
}
216+
if db.get_group(id).map_err(|e| e.to_string())?.is_none() {
217+
return Err(format!("笔记组不存在: {}", id));
218+
}
219+
db.group_delete_impact(id).map_err(|e| e.to_string())
220+
}
221+
222+
pub(crate) fn delete_note_group_inner(db: &crate::db::Db, id: i64) -> Result<bool, String> {
223+
if id <= 0 {
224+
return Err("无效的组 id".to_string());
225+
}
226+
if db.get_group(id).map_err(|e| e.to_string())?.is_none() {
227+
return Err(format!("笔记组不存在: {}", id));
228+
}
229+
db.delete_group(id).map_err(|e| e.to_string())
230+
}
231+
232+
/// 命令层单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
233+
#[cfg(test)]
234+
#[path = "commands_groups_tests.rs"]
235+
mod tests;
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
//! commands_groups 删除命令单测(v0.14.1;inner 纯编排——内存库 AAA 模式)。
2+
3+
use crate::commands_groups::{delete_note_group_inner, get_group_delete_impact_inner};
4+
use crate::db::Db;
5+
use crate::types::{NewNote, NewNoteGroup};
6+
7+
fn mem_db() -> Db {
8+
Db::open(":memory:").expect("内存库打开")
9+
}
10+
11+
fn group(name: &str) -> NewNoteGroup {
12+
NewNoteGroup {
13+
name: name.to_string(),
14+
terrain: "container".to_string(),
15+
kind: "standalone".to_string(),
16+
domain_tag: None,
17+
source: "route".to_string(),
18+
series_key: None,
19+
route_reason: None,
20+
}
21+
}
22+
23+
#[test]
24+
fn impact_rejects_invalid_id() {
25+
// Arrange
26+
let db = mem_db();
27+
// Act / Assert
28+
assert!(get_group_delete_impact_inner(&db, 0).is_err());
29+
assert!(get_group_delete_impact_inner(&db, -1).is_err());
30+
}
31+
32+
#[test]
33+
fn impact_rejects_missing_group() {
34+
// Arrange
35+
let db = mem_db();
36+
// Act
37+
let err = get_group_delete_impact_inner(&db, 999).expect_err("missing");
38+
// Assert
39+
assert!(err.contains("不存在"));
40+
}
41+
42+
#[test]
43+
fn impact_returns_counts_for_existing_group() {
44+
// Arrange:组 + 1 笔记
45+
let db = mem_db();
46+
let g = db.create_group(&group("将删")).expect("create");
47+
db.create_note(&NewNote {
48+
title: "A".to_string(),
49+
content: "x".to_string(),
50+
source: "manual".to_string(),
51+
session_id: None,
52+
rule_version: None,
53+
purify_stats: None,
54+
tags: None,
55+
properties: None,
56+
group_id: Some(g.id),
57+
})
58+
.expect("note");
59+
// Act
60+
let impact = get_group_delete_impact_inner(&db, g.id).expect("impact");
61+
// Assert
62+
assert_eq!(impact.notes, 1);
63+
assert_eq!(impact.cards, 0);
64+
assert_eq!(impact.system_refs, 0);
65+
}
66+
67+
#[test]
68+
fn delete_rejects_invalid_and_missing() {
69+
// Arrange
70+
let db = mem_db();
71+
// Act / Assert
72+
assert!(delete_note_group_inner(&db, 0).is_err());
73+
assert!(delete_note_group_inner(&db, 999).is_err());
74+
}
75+
76+
#[test]
77+
fn delete_removes_group() {
78+
// Arrange
79+
let db = mem_db();
80+
let g = db.create_group(&group("将删")).expect("create");
81+
// Act
82+
let ok = delete_note_group_inner(&db, g.id).expect("delete");
83+
// Assert
84+
assert!(ok);
85+
assert!(db.get_group(g.id).expect("get").is_none());
86+
}

‎app/src-tauri/src/commands_knowledge_canvas.rs‎

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@ use tauri::State;
1616
use crate::commands::AppState;
1717
use crate::commands_knowledge::require_id;
1818
use crate::db::Db;
19-
use crate::types::{CanvasNodePosition, CanvasViewport};
19+
use crate::types::{CanvasNodePosition, CanvasPrefs, CanvasViewport};
20+
21+
/// 连线样式白名单(v0.14.1;前端枚举同口径——未知值拒绝,防字符串入库)。
22+
const EDGE_STYLES: [&str; 4] = ["straight", "bezier", "smoothstep", "step"];
23+
24+
/// 布局算法白名单(v0.14.1;前端枚举同口径)。
25+
const LAYOUT_ALGORITHMS: [&str; 6] = ["radial", "mindmap", "treeRight", "org", "fishbone", "dualRing"];
2026

2127
/// 保存节点画布位置(拖拽落点;防抖后调用;幂等覆盖)。
2228
///
@@ -163,6 +169,61 @@ pub(crate) fn get_canvas_viewport_inner(
163169
.map_err(|e| e.to_string())
164170
}
165171

172+
/// 读取体系画布偏好(v0.14.1;从未保存 → None——前端回落默认值)。
173+
#[tauri::command]
174+
pub fn get_canvas_prefs(state: State<'_, AppState>, system_id: i64) -> Result<Option<CanvasPrefs>, String> {
175+
get_canvas_prefs_inner(&state.db, system_id)
176+
}
177+
178+
/// 保存体系画布偏好(upsert;枚举白名单校验)。
179+
#[tauri::command]
180+
pub fn save_canvas_prefs(
181+
state: State<'_, AppState>,
182+
system_id: i64,
183+
edge_style: String,
184+
edge_arrows: bool,
185+
layout_algorithm: String,
186+
) -> Result<bool, String> {
187+
save_canvas_prefs_inner(&state.db, system_id, &edge_style, edge_arrows, &layout_algorithm)
188+
}
189+
190+
pub(crate) fn get_canvas_prefs_inner(db: &Db, system_id: i64) -> Result<Option<CanvasPrefs>, String> {
191+
require_id(system_id)?;
192+
if db.get_knowledge_system(system_id).map_err(|e| e.to_string())?.is_none() {
193+
return Err(format!("体系不存在: {}", system_id));
194+
}
195+
db.get_canvas_prefs(system_id).map_err(|e| e.to_string())
196+
}
197+
198+
pub(crate) fn save_canvas_prefs_inner(
199+
db: &Db,
200+
system_id: i64,
201+
edge_style: &str,
202+
edge_arrows: bool,
203+
layout_algorithm: &str,
204+
) -> Result<bool, String> {
205+
require_id(system_id)?;
206+
if db.get_knowledge_system(system_id).map_err(|e| e.to_string())?.is_none() {
207+
return Err(format!("体系不存在: {}", system_id));
208+
}
209+
if !EDGE_STYLES.contains(&edge_style) {
210+
return Err(format!(
211+
"不支持的连线样式: {}(支持: {})",
212+
edge_style,
213+
EDGE_STYLES.join("/")
214+
));
215+
}
216+
if !LAYOUT_ALGORITHMS.contains(&layout_algorithm) {
217+
return Err(format!(
218+
"不支持的布局算法: {}(支持: {})",
219+
layout_algorithm,
220+
LAYOUT_ALGORITHMS.join("/")
221+
));
222+
}
223+
db.save_canvas_prefs(system_id, edge_style, edge_arrows, layout_algorithm)
224+
.map_err(|e| e.to_string())
225+
}
226+
166227
/// 命令层单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
167228
#[cfg(test)]
168229
#[path = "commands_knowledge_canvas_tests.rs"]

‎app/src-tauri/src/commands_knowledge_canvas_tests.rs‎

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
//! ③ 坐标有限值/zoom 正数校验(NaN/Infinity/0/负值/超界拒绝)。
88
99
use crate::commands_knowledge_canvas::{
10-
batch_initialize_canvas_positions_inner, get_canvas_viewport_inner,
11-
save_canvas_viewport_inner, update_node_canvas_position_inner,
10+
batch_initialize_canvas_positions_inner, get_canvas_prefs_inner, get_canvas_viewport_inner,
11+
save_canvas_prefs_inner, save_canvas_viewport_inner, update_node_canvas_position_inner,
1212
};
1313
use crate::commands_knowledge_systems::{
1414
add_knowledge_node_inner, create_knowledge_system_inner, list_knowledge_nodes_inner,
@@ -229,3 +229,54 @@ fn viewport_rejects_bad_zoom_and_unknown_system() {
229229
// 拒绝后无记录
230230
assert_eq!(get_canvas_viewport_inner(&db, sid).expect("读"), None);
231231
}
232+
233+
#[test]
234+
fn canvas_prefs_save_get_roundtrip_and_default_missing() {
235+
// Arrange
236+
let db = mem_db();
237+
let sid = make_global(&db);
238+
// Act:从未保存 → None(前端回落默认 smoothstep + radial)
239+
assert_eq!(get_canvas_prefs_inner(&db, sid).expect("首次读"), None);
240+
// 保存 → 回读
241+
save_canvas_prefs_inner(&db, sid, "bezier", true, "mindmap").expect("保存");
242+
let p = get_canvas_prefs_inner(&db, sid).expect("读").expect("有记录");
243+
// Assert:camelCase 契约字段与箭头布尔往返
244+
assert_eq!(p.edge_style, "bezier");
245+
assert!(p.edge_arrows);
246+
assert_eq!(p.layout_algorithm, "mindmap");
247+
// upsert 覆盖
248+
save_canvas_prefs_inner(&db, sid, "straight", false, "org").expect("覆盖");
249+
let p2 = get_canvas_prefs_inner(&db, sid).expect("读").expect("有");
250+
assert_eq!((p2.edge_style.as_str(), p2.edge_arrows, p2.layout_algorithm.as_str()), ("straight", false, "org"));
251+
}
252+
253+
#[test]
254+
fn canvas_prefs_rejects_unknown_enum_and_system() {
255+
// Arrange
256+
let db = mem_db();
257+
let sid = make_global(&db);
258+
// Act/Assert:非法连线/布局枚举拒绝(白名单前后端同口径)
259+
assert!(save_canvas_prefs_inner(&db, sid, "curvy", false, "radial").is_err());
260+
assert!(save_canvas_prefs_inner(&db, sid, "smoothstep", false, "force").is_err());
261+
assert!(save_canvas_prefs_inner(&db, sid, "", false, "").is_err());
262+
// 不存在的体系(save/get 都拒绝)
263+
assert!(save_canvas_prefs_inner(&db, 99999, "straight", false, "radial").is_err());
264+
assert!(get_canvas_prefs_inner(&db, 99999).is_err());
265+
// 非法 id 拒绝
266+
assert!(save_canvas_prefs_inner(&db, 0, "straight", false, "radial").is_err());
267+
}
268+
269+
#[test]
270+
fn canvas_prefs_whitelist_all_valid_values() {
271+
// Arrange:合法枚举全套逐一试(防白名单漏项——前端枚举同口径)
272+
let db = mem_db();
273+
let sid = make_global(&db);
274+
for style in ["straight", "bezier", "smoothstep", "step"] {
275+
for algo in ["radial", "mindmap", "treeRight", "org", "fishbone", "dualRing"] {
276+
save_canvas_prefs_inner(&db, sid, style, false, algo).expect("合法保存");
277+
let p = get_canvas_prefs_inner(&db, sid).expect("读").expect("有");
278+
assert_eq!(p.edge_style, style);
279+
assert_eq!(p.layout_algorithm, algo);
280+
}
281+
}
282+
}

‎app/src-tauri/src/db_knowledge_canvas.rs‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use rusqlite::params;
1414

1515
use crate::db::Db;
1616
use crate::error::Result;
17+
use crate::types::CanvasPrefs;
1718

1819
impl Db {
1920
/// 保存单个节点画布位置(拖拽落点;幂等覆盖;节点不存在返回 false)。
@@ -82,6 +83,52 @@ impl Db {
8283
Ok(true)
8384
})
8485
}
86+
87+
/// 读取体系画布偏好(v0.14.1;从未保存返回 None——前端回落默认值)。
88+
///
89+
/// @ai-context: 与视口同表(states 1:1),但行可能先由视口写入而偏好列落
90+
/// DEFAULT——视口与偏好独立读写互不覆盖。
91+
pub fn get_canvas_prefs(&self, system_id: i64) -> Result<Option<CanvasPrefs>> {
92+
self.with_conn(|conn| {
93+
let mut stmt = conn.prepare(
94+
"SELECT edge_style, edge_arrows, layout_algorithm FROM knowledge_canvas_states WHERE system_id = ?1",
95+
)?;
96+
let mut rows = stmt.query_map(params![system_id], |row| {
97+
Ok(CanvasPrefs {
98+
edge_style: row.get(0)?,
99+
edge_arrows: row.get::<_, i64>(1)? != 0,
100+
layout_algorithm: row.get(2)?,
101+
})
102+
})?;
103+
match rows.next() {
104+
Some(Ok(v)) => Ok(Some(v)),
105+
Some(Err(e)) => Err(e.into()),
106+
None => Ok(None),
107+
}
108+
})
109+
}
110+
111+
/// 保存体系画布偏好(upsert——只更新偏好列,视口列保持不动)。
112+
///
113+
/// @ai-context: 与 save_canvas_viewport 同 ON CONFLICT(system_id) 模式;枚举
114+
/// 合法性校验在 command 层(白名单前后端同口径)——本层只落库。
115+
pub fn save_canvas_prefs(
116+
&self,
117+
system_id: i64,
118+
edge_style: &str,
119+
edge_arrows: bool,
120+
layout_algorithm: &str,
121+
) -> Result<bool> {
122+
self.with_conn(|conn| {
123+
conn.execute(
124+
"INSERT INTO knowledge_canvas_states (system_id, edge_style, edge_arrows, layout_algorithm)
125+
VALUES (?1, ?2, ?3, ?4)
126+
ON CONFLICT(system_id) DO UPDATE SET edge_style = ?2, edge_arrows = ?3, layout_algorithm = ?4",
127+
params![system_id, edge_style, edge_arrows, layout_algorithm],
128+
)?;
129+
Ok(true)
130+
})
131+
}
85132
}
86133

87134
/// 命令层单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。

0 commit comments

Comments
 (0)