feat(api): 原子模型分组树 / 卡片 / catalog2
- 新增 /api/catalog2:多层分组树(继承格式、带条目)+ 卡片勾选列表
- 新增 /api/nodes CRUD:任意层级分组、移动(防环)、排序、根分组格式
- 新增 /api/cards CRUD 与 PUT /api/cards/{id}/groups:一张卡可混合格式、可复用分组
- 条目允许挂在任意层级分组
- 测试 59 passed;API 冒烟通过(三级分组、卡片混合格式)
This commit is contained in:
+168
-1
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user