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
+211
View File
@@ -0,0 +1,211 @@
//! 首页卡片:只存标题/顺序,内容由 `card_group` 勾选分组组成。
//!
//! 卡片与分组是多对多:一张卡可以放不同格式的分组,分组也可以被多张卡复用。
//! 卡片本身不拥有数据,删卡不影响内容。
use rusqlite::{Connection, OptionalExtension, params};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{db::entries, db::map_error, error::StashError};
#[derive(Debug, Clone, Deserialize)]
pub struct NewCard {
pub title: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateCard {
pub title: Option<String>,
pub sort: Option<i64>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CardGroupInput {
pub group_key: String,
#[serde(default)]
pub preview_limit: Option<i64>,
}
pub fn load(conn: &Connection) -> Result<Vec<Value>, StashError> {
let mut stmt = conn
.prepare("SELECT id, key, title, sort FROM card ORDER BY sort, id")
.map_err(map_error)?;
let cards = stmt
.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, i64>(3)?,
))
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
drop(stmt);
let mut result = Vec::with_capacity(cards.len());
for (id, key, title, sort) in cards {
let mut group_stmt = conn
.prepare(
"SELECT group_key, preview_limit FROM card_group
WHERE card_id = ?1 ORDER BY sort",
)
.map_err(map_error)?;
let groups = group_stmt
.query_map([id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<i64>>(1)?,
))
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
result.push(json!({
"id": id,
"key": key,
"title": title,
"sort": sort,
"groups": groups
.into_iter()
.map(|(group_key, preview_limit)| json!({
"groupKey": group_key,
"previewLimit": preview_limit,
}))
.collect::<Vec<_>>(),
}));
}
Ok(result)
}
pub fn create(conn: &mut Connection, new: NewCard) -> Result<i64, StashError> {
let title = new.title.trim();
if title.is_empty() || title.chars().count() > 40 {
return Err(StashError::InvalidDirectory("卡片名称需为 1–40 个字".into()));
}
let tx = conn.transaction().map_err(map_error)?;
let sort: i64 = tx
.query_row("SELECT COALESCE(MAX(sort), 0) + 1 FROM card", [], |row| {
row.get(0)
})
.map_err(map_error)?;
tx.execute(
"INSERT INTO card (key, title, sort) VALUES ('pending', ?1, ?2)",
params![title, sort],
)
.map_err(map_error)?;
let id = tx.last_insert_rowid();
tx.execute(
"UPDATE card SET key = ?1 WHERE id = ?2",
params![format!("card-{id}"), id],
)
.map_err(map_error)?;
entries::audit(&tx, "card.create", &format!("card-{id}"), title)?;
tx.commit().map_err(map_error)?;
Ok(id)
}
pub fn update(conn: &mut Connection, id: i64, patch: UpdateCard) -> Result<(), StashError> {
let current: Option<(String, i64)> = conn
.query_row("SELECT title, sort FROM card WHERE id = ?1", [id], |row| {
Ok((row.get(0)?, row.get(1)?))
})
.optional()
.map_err(map_error)?;
let Some((current_title, current_sort)) = current else {
return Err(StashError::NotFound(format!("卡片不存在: {id}")));
};
let title = match patch.title {
Some(title) => {
let title = title.trim().to_string();
if title.is_empty() || title.chars().count() > 40 {
return Err(StashError::InvalidDirectory("卡片名称需为 1–40 个字".into()));
}
title
}
None => current_title,
};
let sort = patch.sort.unwrap_or(current_sort);
let tx = conn.transaction().map_err(map_error)?;
tx.execute(
"UPDATE card SET title = ?1, sort = ?2 WHERE id = ?3",
params![title, sort, id],
)
.map_err(map_error)?;
entries::audit(&tx, "card.update", &format!("card-{id}"), &title)?;
tx.commit().map_err(map_error)?;
Ok(())
}
pub fn remove(conn: &Connection, id: i64) -> Result<(), StashError> {
let removed = conn
.execute("DELETE FROM card WHERE id = ?1", [id])
.map_err(map_error)?;
if removed == 0 {
return Err(StashError::NotFound(format!("卡片不存在: {id}")));
}
entries::audit(conn, "card.delete", &format!("card-{id}"), "")?;
Ok(())
}
/// 整组替换卡片勾选的分组(顺序即数组顺序)。
pub fn set_groups(
conn: &mut Connection,
id: i64,
groups: Vec<CardGroupInput>,
) -> Result<(), StashError> {
let exists: i64 = conn
.query_row("SELECT COUNT(*) FROM card WHERE id = ?1", [id], |row| row.get(0))
.map_err(map_error)?;
if exists == 0 {
return Err(StashError::NotFound(format!("卡片不存在: {id}")));
}
for group in &groups {
let node_exists: i64 = conn
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [&group.group_key], |row| {
row.get(0)
})
.map_err(map_error)?;
if node_exists == 0 {
return Err(StashError::NotFound(format!(
"分组不存在: {}",
group.group_key
)));
}
if let Some(limit) = group.preview_limit {
if !(1..=60).contains(&limit) {
return Err(StashError::InvalidDirectory("预览条数需在 1–60 之间".into()));
}
}
}
let tx = conn.transaction().map_err(map_error)?;
tx.execute("DELETE FROM card_group WHERE card_id = ?1", [id])
.map_err(map_error)?;
for (sort, group) in groups.iter().enumerate() {
tx.execute(
"INSERT INTO card_group (card_id, group_key, sort, preview_limit)
VALUES (?1, ?2, ?3, ?4)",
params![id, group.group_key, sort as i64, group.preview_limit],
)
.map_err(map_error)?;
}
entries::audit(&tx, "card.groups", &format!("card-{id}"), "")?;
tx.commit().map_err(map_error)?;
Ok(())
}
+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
+3 -2
View File
@@ -242,9 +242,10 @@ fn validate_target(conn: &Connection, node_key: &str) -> Result<(), StashError>
match (kind.as_str(), parent_kind.as_deref()) {
("section", _) => Ok(()),
("group", Some("section")) => Ok(()),
// 多层分组:父级可以是分区或任意分组
("group", Some(_)) => Ok(()),
("group", _) => Err(StashError::InvalidDirectory(
"分组的父节点必须是分区".into(),
"分组的父节点必须是分区或分组".into(),
)),
_ => Err(StashError::InvalidDirectory(
"条目只能挂在分区或分组下".into(),
+235
View File
@@ -0,0 +1,235 @@
//! 分组树的管理:多层分组、格式、父级移动与排序。
//!
//! 数据层只有一张 `node` 树:顶层节点是「根分组」(`kind='section'`,带
//! `view_mode` 格式),下面的节点都叫分组(`kind='group'`),可以继续嵌套。
//! 条目挂在任意节点上,格式由最近的根分组决定。
use rusqlite::{Connection, OptionalExtension, params};
use serde::Deserialize;
use crate::{db::entries, db::map_error, error::StashError};
const FORMATS: [&str; 3] = ["apps", "posters", "articles"];
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NewNode {
/// 业务键,只允许小写字母、数字与连字符
pub key: String,
pub title: String,
/// 为空表示新建一个根分组
#[serde(default)]
pub parent_key: Option<String>,
/// 根分组的格式:apps / posters / articles(子分组继承,不需要传)
#[serde(default)]
pub format: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateNode {
pub title: Option<String>,
/// 传入即移动到新的父节点;不支持移回根(根分组请直接新建)
pub parent_key: Option<String>,
pub sort: Option<i64>,
/// 只对根分组生效
pub format: Option<String>,
}
fn valid_key(key: &str) -> bool {
!key.is_empty()
&& key.len() <= 64
&& key
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
}
pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
let key = new.key.trim();
let title = new.title.trim();
if !valid_key(key) {
return Err(StashError::InvalidDirectory(
"分组标识只能用小写字母、数字与连字符(≤64)".into(),
));
}
if title.is_empty() || title.chars().count() > 40 {
return Err(StashError::InvalidDirectory("分组名称需为 1–40 个字".into()));
}
let exists: i64 = conn
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [key], |row| {
row.get(0)
})
.map_err(map_error)?;
if exists > 0 {
return Err(StashError::Conflict(format!("分组已存在: {key}")));
}
let (kind, view_mode) = match new.parent_key.as_deref() {
Some(parent) => {
let parent_exists: i64 = conn
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [parent], |row| {
row.get(0)
})
.map_err(map_error)?;
if parent_exists == 0 {
return Err(StashError::NotFound(format!("父分组不存在: {parent}")));
}
("group", None)
}
None => {
let format = new.format.unwrap_or_else(|| "apps".into());
if !FORMATS.contains(&format.as_str()) {
return Err(StashError::InvalidDirectory("格式不合法".into()));
}
("section", Some(format))
}
};
let sort: i64 = match new.parent_key.as_deref() {
Some(parent) => conn
.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1",
[parent],
|row| row.get(0),
)
.map_err(map_error)?,
None => conn
.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key IS NULL",
[],
|row| row.get(0),
)
.map_err(map_error)?,
};
let tx = conn.transaction().map_err(map_error)?;
tx.execute(
"INSERT INTO node (key, parent_key, title, kind, view_mode, sort, show_in_sidebar, allow_groups)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, 1)",
params![key, new.parent_key, title, kind, view_mode, sort],
)
.map_err(map_error)?;
entries::audit(&tx, "node.create", key, title)?;
tx.commit().map_err(map_error)?;
Ok(())
}
pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(), StashError> {
let current: Option<(String, Option<String>, Option<String>, i64)> = conn
.query_row(
"SELECT title, parent_key, view_mode, sort FROM node WHERE key = ?1",
[key],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.optional()
.map_err(map_error)?;
let Some((current_title, current_parent, current_format, current_sort)) = current else {
return Err(StashError::NotFound(format!("分组不存在: {key}")));
};
let title = match patch.title {
Some(title) => {
let title = title.trim().to_string();
if title.is_empty() || title.chars().count() > 40 {
return Err(StashError::InvalidDirectory("分组名称需为 1–40 个字".into()));
}
title
}
None => current_title,
};
let parent_key = match patch.parent_key.clone() {
Some(parent) => {
if parent == key {
return Err(StashError::InvalidDirectory("不能把分组移到自己下面".into()));
}
let exists: i64 = conn
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [&parent], |row| {
row.get(0)
})
.map_err(map_error)?;
if exists == 0 {
return Err(StashError::NotFound(format!("父分组不存在: {parent}")));
}
// 防止把节点移进自己的子孙
let mut cursor = Some(parent.clone());
while let Some(candidate) = cursor {
if candidate == key {
return Err(StashError::InvalidDirectory(
"不能把分组移到自己的子分组下面".into(),
));
}
cursor = conn
.query_row(
"SELECT parent_key FROM node WHERE key = ?1",
[candidate],
|row| row.get::<_, Option<String>>(0),
)
.optional()
.map_err(map_error)?
.flatten();
}
Some(parent)
}
// 不传表示保持原父级(不支持移回根)
None => current_parent.clone(),
};
let is_root = parent_key.is_none();
let format = if is_root {
match patch.format {
Some(format) => {
if !FORMATS.contains(&format.as_str()) {
return Err(StashError::InvalidDirectory("格式不合法".into()));
}
Some(format)
}
None => current_format,
}
} else {
// 子分组没有自己的格式,跟随根分组
None
};
if is_root && format.is_none() {
return Err(StashError::InvalidDirectory("根分组缺少格式".into()));
}
let sort = patch.sort.unwrap_or(current_sort);
let tx = conn.transaction().map_err(map_error)?;
// 移动时重新排到新父级末尾;不移动则保留原顺序
let final_sort = match patch.parent_key {
Some(parent) => tx
.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1 AND key <> ?2",
params![parent, key],
|row| row.get::<_, i64>(0),
)
.map_err(map_error)?,
None => sort,
};
tx.execute(
"UPDATE node SET title = ?1, parent_key = ?2, view_mode = ?3, sort = ?4 WHERE key = ?5",
params![title, parent_key, format, final_sort, key],
)
.map_err(map_error)?;
entries::audit(&tx, "node.update", key, &title)?;
tx.commit().map_err(map_error)?;
Ok(())
}
pub fn remove(conn: &Connection, key: &str) -> Result<(), StashError> {
let removed = conn
.execute("DELETE FROM node WHERE key = ?1", [key])
.map_err(map_error)?;
if removed == 0 {
return Err(StashError::NotFound(format!("分组不存在: {key}")));
}
entries::audit(conn, "node.delete", key, "")?;
Ok(())
}
+54
View File
@@ -9,9 +9,11 @@
//! 启动时会重新建表并灌入内嵌种子。
pub mod auth;
pub mod cards;
pub mod categories;
mod catalog;
pub mod entries;
pub mod groups;
mod schema;
pub mod settings;
@@ -250,6 +252,58 @@ impl Db {
categories::remove_group(&conn, id)
}
// ---------- 原子模型:分组树 / 卡片 ----------
/// 完整分组树(多层,带条目)
pub fn load_tree(&self) -> Result<Value, StashError> {
let conn = self.lock()?;
catalog::load_tree(&conn)
}
pub fn load_cards(&self) -> Result<Vec<Value>, StashError> {
let conn = self.lock()?;
cards::load(&conn)
}
pub fn create_node(&self, new: groups::NewNode) -> Result<(), StashError> {
let mut conn = self.lock()?;
groups::create(&mut conn, new)
}
pub fn update_node(&self, key: &str, patch: groups::UpdateNode) -> Result<(), StashError> {
let mut conn = self.lock()?;
groups::update(&mut conn, key, patch)
}
pub fn remove_node(&self, key: &str) -> Result<(), StashError> {
let conn = self.lock()?;
groups::remove(&conn, key)
}
pub fn create_card(&self, new: cards::NewCard) -> Result<i64, StashError> {
let mut conn = self.lock()?;
cards::create(&mut conn, new)
}
pub fn update_card(&self, id: i64, patch: cards::UpdateCard) -> Result<(), StashError> {
let mut conn = self.lock()?;
cards::update(&mut conn, id, patch)
}
pub fn remove_card(&self, id: i64) -> Result<(), StashError> {
let conn = self.lock()?;
cards::remove(&conn, id)
}
pub fn set_card_groups(
&self,
id: i64,
groups: Vec<cards::CardGroupInput>,
) -> Result<(), StashError> {
let mut conn = self.lock()?;
cards::set_groups(&mut conn, id, groups)
}
pub fn remove_category(&self, id: &str) -> Result<(), StashError> {
let mut conn = self.lock()?;
categories::remove(&mut conn, id)
+168 -1
View File
@@ -4,7 +4,7 @@ use axum::{
http::{HeaderMap, HeaderValue, Method, StatusCode, header},
response::sse::{Event, KeepAlive, Sse},
response::{Html, IntoResponse, Response},
routing::{get, patch, post},
routing::{get, patch, post, put},
};
use include_dir::{Dir, include_dir};
use std::{convert::Infallible, net::SocketAddr, sync::Arc, time::Duration};
@@ -770,6 +770,167 @@ async fn delete_item(
}
}
// ---------- 原子模型:分组树 / 卡片 ----------
/// GET /api/catalog2 —— 分组树 + 卡片(公开读,新首页/侧栏用)
async fn catalog2_api(State(state): State<AppState>) -> Response {
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || -> Result<serde_json::Value, StashError> {
Ok(serde_json::json!({
"groups": db.load_tree()?,
"cards": db.load_cards()?,
}))
})
.await
{
Ok(Ok(payload)) => Json(payload).into_response(),
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
async fn require_login(state: &AppState, headers: &HeaderMap) -> Result<(), Response> {
if !same_origin(headers) {
return Err(api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝"));
}
match read_session(state, headers).await {
Ok(session) if session.authenticated => Ok(()),
Ok(_) => Err(api_error(StatusCode::UNAUTHORIZED, "请先登录")),
Err(response) => Err(response),
}
}
fn node_error(error: StashError) -> Response {
match error {
StashError::Conflict(message) => api_error(StatusCode::CONFLICT, &message),
StashError::NotFound(message) => api_error(StatusCode::NOT_FOUND, &message),
StashError::InvalidDirectory(message) => api_error(StatusCode::BAD_REQUEST, &message),
other => api_error(StatusCode::INTERNAL_SERVER_ERROR, &other.to_string()),
}
}
/// POST /api/groups —— 新建根分组 / 子分组
async fn create_node(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<crate::db::groups::NewNode>,
) -> Response {
if let Err(response) = require_login(&state, &headers).await {
return response;
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.create_node(body)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(error)) => node_error(error),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// PATCH /api/groups/{key} —— 改名 / 移动 / 排序 / 根分组格式
async fn update_node(
State(state): State<AppState>,
Path(key): Path<String>,
headers: HeaderMap,
Json(body): Json<crate::db::groups::UpdateNode>,
) -> Response {
if let Err(response) = require_login(&state, &headers).await {
return response;
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.update_node(&key, body)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(error)) => node_error(error),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// DELETE /api/groups/{key} —— 删除分组(级联子分组与条目)
async fn delete_node(
State(state): State<AppState>,
Path(key): Path<String>,
headers: HeaderMap,
) -> Response {
if let Err(response) = require_login(&state, &headers).await {
return response;
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.remove_node(&key)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(error)) => node_error(error),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// POST /api/cards —— 新建卡片
async fn create_card(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<crate::db::cards::NewCard>,
) -> Response {
if let Err(response) = require_login(&state, &headers).await {
return response;
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.create_card(body)).await {
Ok(Ok(id)) => Json(serde_json::json!({ "id": id })).into_response(),
Ok(Err(error)) => node_error(error),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// PATCH /api/cards/{id} —— 卡片标题 / 顺序
async fn update_card(
State(state): State<AppState>,
Path(id): Path<i64>,
headers: HeaderMap,
Json(body): Json<crate::db::cards::UpdateCard>,
) -> Response {
if let Err(response) = require_login(&state, &headers).await {
return response;
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.update_card(id, body)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(error)) => node_error(error),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// DELETE /api/cards/{id} —— 删除卡片(不影响内容)
async fn delete_card(
State(state): State<AppState>,
Path(id): Path<i64>,
headers: HeaderMap,
) -> Response {
if let Err(response) = require_login(&state, &headers).await {
return response;
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.remove_card(id)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(error)) => node_error(error),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// PUT /api/cards/{id}/groups —— 整组替换卡片勾选的分组
async fn set_card_groups(
State(state): State<AppState>,
Path(id): Path<i64>,
headers: HeaderMap,
Json(body): Json<Vec<crate::db::cards::CardGroupInput>>,
) -> Response {
if let Err(response) = require_login(&state, &headers).await {
return response;
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.set_card_groups(id, body)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(error)) => node_error(error),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// POST /api/categories —— 新建分区(需要登录)
async fn create_category(
State(state): State<AppState>,
@@ -955,6 +1116,12 @@ fn build_router(state: AppState) -> Router {
.route("/api/projects", get(list_projects_api))
.route("/api/projects/events", get(project_events))
.route("/api/catalog", get(catalog_api))
.route("/api/catalog2", get(catalog2_api))
.route("/api/nodes", post(create_node))
.route("/api/nodes/{key}", patch(update_node).delete(delete_node))
.route("/api/cards", post(create_card))
.route("/api/cards/{id}", patch(update_card).delete(delete_card))
.route("/api/cards/{id}/groups", put(set_card_groups))
.route("/api/catalog/events", get(catalog_events))
.route("/api/auth/session", get(auth_session))
.route("/api/auth/setup", post(auth_setup))