feat(api): 原子模型分组树 / 卡片 / catalog2

- 新增 /api/catalog2:多层分组树(继承格式、带条目)+ 卡片勾选列表
- 新增 /api/nodes CRUD:任意层级分组、移动(防环)、排序、根分组格式
- 新增 /api/cards CRUD 与 PUT /api/cards/{id}/groups:一张卡可混合格式、可复用分组
- 条目允许挂在任意层级分组
- 测试 59 passed;API 冒烟通过(三级分组、卡片混合格式)
This commit is contained in:
2026-10-30 20:35:37 +08:00
parent eb4fded8db
commit 6340e435e9
6 changed files with 817 additions and 3 deletions
+146
View File
@@ -53,6 +53,152 @@ pub fn load(conn: &Connection) -> Result<Catalog, StashError> {
})
}
/// 分组树节点(新视图用)
struct TreeNode {
key: String,
parent: Option<String>,
title: String,
view_mode: Option<String>,
icon: Option<String>,
bar_color: Option<String>,
preview_limit: Option<i64>,
sort: i64,
}
/// 新的原子视图:完整分组树(多层、带格式、带条目),供新首页/侧栏/后台使用。
pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
let mut stmt = conn
.prepare(
"SELECT key, parent_key, title, view_mode, icon, bar_color, preview_limit, sort
FROM node ORDER BY sort, key",
)
.map_err(map_error)?;
let nodes = stmt
.query_map([], |row| {
Ok(TreeNode {
key: row.get(0)?,
parent: row.get(1)?,
title: row.get(2)?,
view_mode: row.get(3)?,
icon: row.get(4)?,
bar_color: row.get(5)?,
preview_limit: row.get(6)?,
sort: row.get(7)?,
})
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
drop(stmt);
let index: HashMap<String, usize> = nodes
.iter()
.enumerate()
.map(|(idx, node)| (node.key.clone(), idx))
.collect();
// 子节点索引(按 sort 已排好序)
let mut children: HashMap<Option<String>, Vec<usize>> = HashMap::new();
for (idx, node) in nodes.iter().enumerate() {
children.entry(node.parent.clone()).or_default().push(idx);
}
// 每个节点继承最近根分组的格式
let mut formats: Vec<String> = vec![String::new(); nodes.len()];
for idx in 0..nodes.len() {
let mut cursor = Some(idx);
let mut format = "apps".to_string();
while let Some(current) = cursor {
if let Some(mode) = &nodes[current].view_mode {
format = mode.clone();
break;
}
cursor = nodes[current]
.parent
.as_ref()
.and_then(|parent| index.get(parent).copied());
}
formats[idx] = format;
}
let tags = load_all_tags(conn)?;
fn render(
conn: &Connection,
nodes: &[TreeNode],
children: &HashMap<Option<String>, Vec<usize>>,
formats: &[String],
tags: &HashMap<String, Vec<String>>,
idx: usize,
) -> Result<Value, StashError> {
let node = &nodes[idx];
let format = &formats[idx];
let entries = load_node_entries(conn, &node.key)?;
let items = entries
.iter()
.map(|entry| item_for(entry, format, tags))
.collect::<Vec<_>>();
let child_values = children
.get(&Some(node.key.clone()))
.map(|list| {
list.iter()
.map(|child| render(conn, nodes, children, formats, tags, *child))
.collect::<Result<Vec<_>, _>>()
})
.transpose()?
.unwrap_or_default();
Ok(json!({
"key": node.key,
"parentKey": node.parent,
"title": node.title,
"format": format,
"icon": node.icon.clone().unwrap_or_default(),
"barColor": node.bar_color.clone().unwrap_or_default(),
"previewLimit": node.preview_limit,
"sort": node.sort,
"entries": items,
"children": child_values,
}))
}
let roots = children
.get(&None)
.map(|list| {
list.iter()
.map(|idx| render(conn, &nodes, &children, &formats, &tags, *idx))
.collect::<Result<Vec<_>, _>>()
})
.transpose()?
.unwrap_or_default();
Ok(Value::Array(roots))
}
/// 全部条目标签(一次查询,树组装时用)
fn load_all_tags(conn: &Connection) -> Result<HashMap<String, Vec<String>>, StashError> {
let mut stmt = conn
.prepare("SELECT entry_key, label FROM entry_tag ORDER BY entry_key, sort")
.map_err(map_error)?;
let rows = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
let mut grouped: HashMap<String, Vec<String>> = HashMap::new();
for (entry_key, label) in rows {
grouped.entry(entry_key).or_default().push(label);
}
Ok(grouped)
}
/// 读出全部分区,按 `sort` 排序;顺序即首页分区顺序。
fn load_sections(conn: &Connection) -> Result<Vec<Value>, StashError> {
let mut stmt = conn