diff --git a/src/db/cards.rs b/src/db/cards.rs new file mode 100644 index 0000000..ee56a8e --- /dev/null +++ b/src/db/cards.rs @@ -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, + pub sort: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CardGroupInput { + pub group_key: String, + #[serde(default)] + pub preview_limit: Option, +} + +pub fn load(conn: &Connection) -> Result, 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::, _>>() + .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>(1)?, + )) + }) + .map_err(map_error)? + .collect::, _>>() + .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::>(), + })); + } + + Ok(result) +} + +pub fn create(conn: &mut Connection, new: NewCard) -> Result { + 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, +) -> 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(()) +} diff --git a/src/db/catalog.rs b/src/db/catalog.rs index f2b03c7..ff0f60c 100644 --- a/src/db/catalog.rs +++ b/src/db/catalog.rs @@ -53,6 +53,152 @@ pub fn load(conn: &Connection) -> Result { }) } +/// 分组树节点(新视图用) +struct TreeNode { + key: String, + parent: Option, + title: String, + view_mode: Option, + icon: Option, + bar_color: Option, + preview_limit: Option, + sort: i64, +} + +/// 新的原子视图:完整分组树(多层、带格式、带条目),供新首页/侧栏/后台使用。 +pub fn load_tree(conn: &Connection) -> Result { + 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::, _>>() + .map_err(map_error)?; + + drop(stmt); + + let index: HashMap = nodes + .iter() + .enumerate() + .map(|(idx, node)| (node.key.clone(), idx)) + .collect(); + + // 子节点索引(按 sort 已排好序) + let mut children: HashMap, Vec> = HashMap::new(); + for (idx, node) in nodes.iter().enumerate() { + children.entry(node.parent.clone()).or_default().push(idx); + } + + // 每个节点继承最近根分组的格式 + let mut formats: Vec = 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, Vec>, + formats: &[String], + tags: &HashMap>, + idx: usize, + ) -> Result { + 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::>(); + + let child_values = children + .get(&Some(node.key.clone())) + .map(|list| { + list.iter() + .map(|child| render(conn, nodes, children, formats, tags, *child)) + .collect::, _>>() + }) + .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::, _>>() + }) + .transpose()? + .unwrap_or_default(); + + Ok(Value::Array(roots)) +} + +/// 全部条目标签(一次查询,树组装时用) +fn load_all_tags(conn: &Connection) -> Result>, 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::, _>>() + .map_err(map_error)?; + + let mut grouped: HashMap> = 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, StashError> { let mut stmt = conn diff --git a/src/db/entries.rs b/src/db/entries.rs index 6a5f197..d42dadb 100644 --- a/src/db/entries.rs +++ b/src/db/entries.rs @@ -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(), diff --git a/src/db/groups.rs b/src/db/groups.rs new file mode 100644 index 0000000..215be24 --- /dev/null +++ b/src/db/groups.rs @@ -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, + /// 根分组的格式:apps / posters / articles(子分组继承,不需要传) + #[serde(default)] + pub format: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateNode { + pub title: Option, + /// 传入即移动到新的父节点;不支持移回根(根分组请直接新建) + pub parent_key: Option, + pub sort: Option, + /// 只对根分组生效 + pub format: Option, +} + +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, Option, 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>(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(()) +} diff --git a/src/db/mod.rs b/src/db/mod.rs index f0f1bc8..d3722c4 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -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 { + let conn = self.lock()?; + catalog::load_tree(&conn) + } + + pub fn load_cards(&self) -> Result, 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 { + 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, + ) -> 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) diff --git a/src/server.rs b/src/server.rs index 2c5f3e4..e9e8c0a 100644 --- a/src/server.rs +++ b/src/server.rs @@ -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) -> Response { + let db = Arc::clone(&state.db); + match tokio::task::spawn_blocking(move || -> Result { + 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, + headers: HeaderMap, + Json(body): Json, +) -> 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, + Path(key): Path, + headers: HeaderMap, + Json(body): Json, +) -> 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, + Path(key): Path, + 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, + headers: HeaderMap, + Json(body): Json, +) -> 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, + Path(id): Path, + headers: HeaderMap, + Json(body): Json, +) -> 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, + Path(id): Path, + 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, + Path(id): Path, + headers: HeaderMap, + Json(body): Json>, +) -> 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, @@ -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))