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
+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))