diff --git a/src/db/categories.rs b/src/db/categories.rs index 20841b3..afe6260 100644 --- a/src/db/categories.rs +++ b/src/db/categories.rs @@ -30,6 +30,23 @@ pub struct UpdateCategory { pub kind: Option, pub preview_limit: Option, pub empty_text: Option, + /// 首页卡片预览哪个节点(分区自己或它下面的分组) + pub block_node_key: Option, +} + +/// 新建分组(子分类) +#[derive(Debug, Clone, Deserialize)] +pub struct NewGroup { + pub id: String, + pub title: String, +} + +/// 修改分组:改名 / 侧栏显隐 +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateGroup { + pub title: Option, + pub show_in_sidebar: Option, } fn valid_id(id: &str) -> bool { @@ -280,11 +297,172 @@ pub fn update(conn: &mut Connection, id: &str, patch: UpdateCategory) -> Result< ) .map_err(map_error)?; + // 首页卡片预览哪个节点:分区自己或它下面的分组 + if let Some(block_node_key) = patch.block_node_key { + let block_node_key = block_node_key.trim().to_string(); + let valid: i64 = tx + .query_row( + "SELECT COUNT(*) FROM node n LEFT JOIN node p ON p.key = n.parent_key + WHERE n.key = ?1 AND (n.key = ?2 OR p.key = ?2)", + params![block_node_key, id], + |row| row.get(0), + ) + .map_err(map_error)?; + if valid == 0 { + return Err(StashError::InvalidDirectory( + "首页卡片只能预览本分区或它的分组".into(), + )); + } + + let updated = tx + .execute( + "UPDATE section_block SET node_key = ?1, title = NULL + WHERE id IN ( + SELECT b.id FROM section_block b + JOIN node n ON n.key = b.node_key + LEFT JOIN node p ON p.key = n.parent_key + WHERE COALESCE(p.key, n.key) = ?2 + )", + params![block_node_key, id], + ) + .map_err(map_error)?; + + if updated == 0 { + let sort: i64 = tx + .query_row("SELECT COALESCE(MAX(sort), 0) + 1 FROM section_block", [], |row| { + row.get(0) + }) + .map_err(map_error)?; + tx.execute( + "INSERT INTO section_block (node_key, title, preview_limit, sort) + VALUES (?1, NULL, NULL, ?2)", + params![block_node_key, sort], + ) + .map_err(map_error)?; + } + } + entries::audit(&tx, "category.update", id, &title)?; tx.commit().map_err(map_error)?; Ok(()) } +/// 在应用类分区下新建分组(子分类)。 +pub fn create_group(conn: &mut Connection, section_id: &str, new: NewGroup) -> Result<(), StashError> { + let id = new.id.trim(); + let title = new.title.trim(); + + if !valid_id(id) { + return Err(StashError::InvalidDirectory( + "分组标识只能用小写字母、数字与连字符(≤32)".into(), + )); + } + if title.is_empty() || title.chars().count() > 40 { + return Err(StashError::InvalidDirectory("分组标题需为 1–40 个字".into())); + } + + let view_mode: Option = conn + .query_row( + "SELECT view_mode FROM node WHERE key = ?1 AND kind = 'section'", + [section_id], + |row| row.get(0), + ) + .optional() + .map_err(map_error)?; + + let Some(view_mode) = view_mode else { + return Err(StashError::NotFound(format!("分区不存在: {section_id}"))); + }; + if view_mode != "apps" { + return Err(StashError::InvalidDirectory( + "只有应用类分区可以添加分组".into(), + )); + } + + let exists: i64 = conn + .query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [id], |row| { + row.get(0) + }) + .map_err(map_error)?; + if exists > 0 { + return Err(StashError::Conflict(format!("分组已存在: {id}"))); + } + + let sort: i64 = conn + .query_row( + "SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1", + [section_id], + |row| row.get(0), + ) + .map_err(map_error)?; + + conn.execute( + "INSERT INTO node (key, parent_key, title, kind, view_mode, sort, show_in_sidebar) + VALUES (?1, ?2, ?3, 'group', NULL, ?4, 1)", + params![id, section_id, title, sort], + ) + .map_err(map_error)?; + + entries::audit(conn, "group.create", id, title)?; + Ok(()) +} + +/// 修改分组:改名 / 侧栏显隐。 +pub fn update_group(conn: &Connection, id: &str, patch: UpdateGroup) -> Result<(), StashError> { + let current: Option = conn + .query_row( + "SELECT title FROM node WHERE key = ?1 AND kind = 'group'", + [id], + |row| row.get(0), + ) + .optional() + .map_err(map_error)?; + + let Some(current) = 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, + }; + + match patch.show_in_sidebar { + Some(show) => conn.execute( + "UPDATE node SET title = ?1, show_in_sidebar = ?2 WHERE key = ?3", + params![title, show, id], + ), + None => conn.execute( + "UPDATE node SET title = ?1 WHERE key = ?2", + params![title, id], + ), + } + .map_err(map_error)?; + + entries::audit(conn, "group.update", id, &title)?; + Ok(()) +} + +/// 删除分组:组内条目会一起删除(前端需二次确认)。 +pub fn remove_group(conn: &Connection, id: &str) -> Result<(), StashError> { + let removed = conn + .execute("DELETE FROM node WHERE key = ?1 AND kind = 'group'", [id]) + .map_err(map_error)?; + + if removed == 0 { + return Err(StashError::NotFound(format!("分组不存在: {id}"))); + } + + entries::audit(conn, "group.delete", id, "")?; + Ok(()) +} + pub fn remove(conn: &mut Connection, id: &str) -> Result<(), StashError> { if id == "local" { return Err(StashError::InvalidDirectory("本地服务不能删除".into())); @@ -490,8 +668,69 @@ mod tests { } #[test] - fn switching_posters_to_apps_creates_group_and_moves_entries() { + fn creates_updates_and_removes_group() { let mut conn = db(); + create(&mut conn, section("apps1", "应用", "apps")).expect("应能创建"); + + create_group( + &mut conn, + "apps1", + NewGroup { + id: "g1".into(), + title: "常用".into(), + }, + ) + .expect("应能建分组"); + + update_group( + &conn, + "g1", + UpdateGroup { + title: Some("改名".into()), + show_in_sidebar: Some(false), + }, + ) + .expect("应能改分组"); + + let (title, show): (String, i64) = conn + .query_row( + "SELECT title, show_in_sidebar FROM node WHERE key='g1'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("应能查"); + assert_eq!(title, "改名"); + assert_eq!(show, 0); + + remove_group(&conn, "g1").expect("应能删分组"); + let left: i64 = conn + .query_row("SELECT COUNT(*) FROM node WHERE key='g1'", [], |row| { + row.get(0) + }) + .expect("应能查"); + assert_eq!(left, 0); + } + + #[test] + fn groups_only_under_apps_sections() { + let mut conn = db(); + create(&mut conn, section("films", "电影", "posters")).expect("应能创建"); + assert!( + create_group( + &mut conn, + "films", + NewGroup { + id: "g1".into(), + title: "分组".into() + } + ) + .is_err(), + "海报分区不能建分组" + ); + } + + #[test] + fn switching_posters_to_apps_creates_group_and_moves_entries() { let mut conn = db(); create(&mut conn, section("films", "电影", "posters")).expect("应能创建"); conn.execute("INSERT INTO entry (key, node_key) VALUES ('f1', 'films')", []) .expect("应能建条目"); diff --git a/src/db/entries.rs b/src/db/entries.rs index d631821..d56cde7 100644 --- a/src/db/entries.rs +++ b/src/db/entries.rs @@ -53,6 +53,18 @@ pub struct EntryPatch { pub body: String, #[serde(default)] pub tags: Vec, + /// 可选:把条目移动到另一个分区 / 分组(不传则保持原位) + #[serde(default)] + pub node_key: Option, +} + +/// 新建条目的请求体:`key` 是业务键,`nodeKey` 决定挂在哪个分区 / 分组。 +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NewEntry { + pub key: String, + pub node_key: String, + pub item: EntryPatch, } fn now() -> i64 { @@ -210,9 +222,150 @@ pub fn load(conn: &Connection, key: &str) -> Result, StashEr Ok(Some(entry)) } +/// 条目能挂到哪:非应用分区本身,或应用分区下的分组。 +fn validate_target(conn: &Connection, node_key: &str) -> Result<(), StashError> { + let row: Option<(String, Option, Option)> = conn + .query_row( + "SELECT n.kind, n.view_mode, p.view_mode + FROM node n LEFT JOIN node p ON p.key = n.parent_key + WHERE n.key = ?1", + [node_key], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional() + .map_err(map_error)?; + + let Some((kind, view_mode, parent_mode)) = row else { + return Err(StashError::NotFound(format!("节点不存在: {node_key}"))); + }; + + match kind.as_str() { + "group" if parent_mode.as_deref() == Some("apps") => Ok(()), + "group" => Err(StashError::InvalidDirectory( + "分组只能挂在应用类分区下".into(), + )), + "section" if view_mode.as_deref() == Some("apps") => Err(StashError::InvalidDirectory( + "应用类条目必须选择一个分组".into(), + )), + "section" => Ok(()), + _ => Err(StashError::InvalidDirectory( + "条目只能挂在分区或分组下".into(), + )), + } +} + +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 == '-') +} + +fn write_tags(conn: &Connection, key: &str, tags: &[String]) -> Result<(), StashError> { + conn.execute("DELETE FROM entry_tag WHERE entry_key = ?1", [key]) + .map_err(map_error)?; + for (index, tag) in tags.iter().enumerate() { + conn.execute( + "INSERT INTO entry_tag (entry_key, sort, label) VALUES (?1, ?2, ?3)", + params![key, index as i64, tag], + ) + .map_err(map_error)?; + } + Ok(()) +} + +/// 新建条目:用于后台「内容」页添加网址 / 海报 / 图文。 +pub fn create(conn: &mut Connection, new: NewEntry) -> Result { + let key = new.key.trim().to_string(); + if !valid_key(&key) { + return Err(StashError::InvalidDirectory( + "标识只能用小写字母、数字与连字符(≤64)".into(), + )); + } + + let patch = new.item; + validate(&patch)?; + let href = normalize_href(&patch.href)?; + validate_target(conn, &new.node_key)?; + + let exists: i64 = conn + .query_row("SELECT COUNT(*) FROM entry WHERE key = ?1", [&key], |row| { + row.get(0) + }) + .map_err(map_error)?; + if exists > 0 { + return Err(StashError::Conflict(format!("条目已存在: {key}"))); + } + + let tags: Vec = patch + .tags + .iter() + .map(|tag| tag.trim().to_string()) + .filter(|tag| !tag.is_empty()) + .collect(); + + let timestamp = now(); + let tx = conn.transaction().map_err(map_error)?; + + let sort: i64 = tx + .query_row( + "SELECT COALESCE(MAX(sort), 0) + 1 FROM entry WHERE node_key = ?1", + [&new.node_key], + |row| row.get(0), + ) + .map_err(map_error)?; + + tx.execute( + "INSERT INTO entry (key, node_key, name, description, icon, href, cover, banner, + badge, comment, summary, body, hot, views, likes, sort, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 0, 0, 0, ?13, ?14)", + params![ + key, + new.node_key, + patch.name.trim(), + patch.description, + patch.icon, + href, + patch.cover, + patch.banner, + patch.badge, + patch.comment, + patch.summary, + patch.body, + sort, + timestamp + ], + ) + .map_err(map_error)?; + + write_tags(&tx, &key, &tags)?; + tx.execute( + "INSERT INTO audit (at, action, target, detail) VALUES (?1, 'item.create', ?2, ?3)", + params![timestamp, key, patch.name.trim()], + ) + .map_err(map_error)?; + + tx.commit().map_err(map_error)?; + load(conn, &key)? + .ok_or_else(|| StashError::NotFound(format!("条目不存在: {key}"))) +} + +/// 删除条目(后台内容页用)。 +pub fn remove(conn: &Connection, key: &str) -> Result<(), StashError> { + let removed = conn + .execute("DELETE FROM entry WHERE key = ?1", [key]) + .map_err(map_error)?; + if removed == 0 { + return Err(StashError::NotFound(format!("条目不存在: {key}"))); + } + + audit(conn, "item.delete", key, "")?; + Ok(()) +} + /// 更新条目;`expected_updated_at` 与库里不一致时冲突。 -pub fn update( - conn: &mut Connection, +pub fn update( conn: &mut Connection, key: &str, patch: EntryPatch, expected_updated_at: i64, @@ -220,6 +373,9 @@ pub fn update( validate(&patch)?; // 跳转地址先规范化:补 https、拒绝 javascript: 等危险协议 let href = normalize_href(&patch.href)?; + if let Some(node_key) = patch.node_key.as_deref() { + validate_target(conn, node_key)?; + } let tags: Vec = patch .tags @@ -236,7 +392,8 @@ pub fn update( "UPDATE entry SET name = ?1, description = ?2, icon = ?3, href = ?4, cover = ?5, banner = ?6, badge = ?7, comment = ?8, - summary = ?9, body = ?10, updated_at = ?11 + summary = ?9, body = ?10, updated_at = ?11, + node_key = COALESCE(?14, node_key) WHERE key = ?12 AND updated_at = ?13", params![ patch.name.trim(), @@ -251,7 +408,8 @@ pub fn update( patch.body, timestamp, key, - expected_updated_at + expected_updated_at, + patch.node_key ], ) .map_err(map_error)?; @@ -351,6 +509,17 @@ mod tests { target TEXT, detail TEXT ); + CREATE TABLE node ( + key TEXT PRIMARY KEY, + parent_key TEXT, + title TEXT NOT NULL, + kind TEXT NOT NULL, + view_mode TEXT + ); + INSERT INTO node (key, parent_key, title, kind, view_mode) VALUES + ('movie', NULL, '推荐电影', 'section', 'posters'), + ('apps1', NULL, '常用推荐', 'section', 'apps'), + ('g1', 'apps1', '常用站点', 'group', NULL); INSERT INTO entry (key, node_key, name, updated_at) VALUES ('movie-1', 'movie', '千与千寻', 0);", ) .expect("应能建表"); @@ -370,6 +539,7 @@ mod tests { summary: "正文".into(), body: "## 标题".into(), tags: vec!["推荐电影".into(), "动画".into()], + node_key: None, } } @@ -440,4 +610,62 @@ mod tests { let saved = update(&mut conn, "movie-1", input, 0).expect("应能更新"); assert_eq!(saved.href, "https://www.zhihu.com"); } + + #[test] + fn creates_entry_under_section_or_group() { + let mut conn = db(); + let saved = create( + &mut conn, + NewEntry { + key: "new-site".into(), + node_key: "g1".into(), + item: patch("新站点"), + }, + ) + .expect("应能创建"); + assert_eq!(saved.key, "new-site"); + assert_eq!(saved.name, "新站点"); + + let node: String = conn + .query_row("SELECT node_key FROM entry WHERE key='new-site'", [], |row| { + row.get(0) + }) + .expect("应能查"); + assert_eq!(node, "g1"); + + // 应用分区不能直接挂条目,必须先选分组 + assert!( + create( + &mut conn, + NewEntry { + key: "bad-site".into(), + node_key: "apps1".into(), + item: patch("错误"), + } + ) + .is_err() + ); + } + + #[test] + fn moves_entry_between_nodes() { + let mut conn = db(); + let mut input = patch("搬家"); + input.node_key = Some("g1".into()); + update(&mut conn, "movie-1", input, 0).expect("应能移动"); + + let node: String = conn + .query_row("SELECT node_key FROM entry WHERE key='movie-1'", [], |row| { + row.get(0) + }) + .expect("应能查"); + assert_eq!(node, "g1"); + } + + #[test] + fn deletes_entry() { + let conn = db(); + remove(&conn, "movie-1").expect("应能删除"); + assert!(remove(&conn, "movie-1").is_err(), "重复删除应失败"); + } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 9c377c2..e4d585f 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -185,6 +185,16 @@ impl Db { entries::load(&conn, key) } + pub fn create_entry(&self, new: entries::NewEntry) -> Result { + let mut conn = self.lock()?; + entries::create(&mut conn, new) + } + + pub fn remove_entry(&self, key: &str) -> Result<(), StashError> { + let conn = self.lock()?; + entries::remove(&conn, key) + } + pub fn update_entry( &self, key: &str, @@ -221,6 +231,25 @@ impl Db { categories::update(&mut conn, id, patch) } + pub fn create_group( + &self, + section_id: &str, + new: categories::NewGroup, + ) -> Result<(), StashError> { + let mut conn = self.lock()?; + categories::create_group(&mut conn, section_id, new) + } + + pub fn update_group(&self, id: &str, patch: categories::UpdateGroup) -> Result<(), StashError> { + let conn = self.lock()?; + categories::update_group(&conn, id, patch) + } + + pub fn remove_group(&self, id: &str) -> Result<(), StashError> { + let conn = self.lock()?; + categories::remove_group(&conn, id) + } + 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 dfd9bd4..2c5f3e4 100644 --- a/src/server.rs +++ b/src/server.rs @@ -718,6 +718,58 @@ async fn post_like( } } +/// POST /api/items —— 新建条目(需要登录) +async fn create_item( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Response { + if !same_origin(&headers) { + return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝"); + } + match read_session(&state, &headers).await { + Ok(session) if session.authenticated => {} + Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"), + Err(response) => return response, + } + + let db = Arc::clone(&state.db); + match tokio::task::spawn_blocking(move || db.create_entry(body)).await { + Ok(Ok(entry)) => Json(entry).into_response(), + Ok(Err(StashError::Conflict(message))) => api_error(StatusCode::CONFLICT, &message), + Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message), + Ok(Err(StashError::InvalidDirectory(message))) => { + api_error(StatusCode::BAD_REQUEST, &message) + } + Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + } +} + +/// DELETE /api/items/{key} —— 删除条目(需要登录) +async fn delete_item( + State(state): State, + Path(key): Path, + headers: HeaderMap, +) -> Response { + if !same_origin(&headers) { + return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝"); + } + match read_session(&state, &headers).await { + Ok(session) if session.authenticated => {} + Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"), + Err(response) => return response, + } + + let db = Arc::clone(&state.db); + match tokio::task::spawn_blocking(move || db.remove_entry(&key)).await { + Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(), + Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message), + Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + } +} + /// POST /api/categories —— 新建分区(需要登录) async fn create_category( State(state): State, @@ -773,6 +825,90 @@ async fn update_category( } } +/// POST /api/categories/{id}/groups —— 新建分组(需要登录) +async fn create_group( + State(state): State, + Path(section_id): Path, + headers: HeaderMap, + Json(body): Json, +) -> Response { + if !same_origin(&headers) { + return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝"); + } + match read_session(&state, &headers).await { + Ok(session) if session.authenticated => {} + Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"), + Err(response) => return response, + } + + let db = Arc::clone(&state.db); + match tokio::task::spawn_blocking(move || db.create_group(§ion_id, body)).await { + Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(), + Ok(Err(StashError::Conflict(message))) => api_error(StatusCode::CONFLICT, &message), + Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message), + Ok(Err(StashError::InvalidDirectory(message))) => { + api_error(StatusCode::BAD_REQUEST, &message) + } + Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + } +} + +/// PATCH /api/groups/{id} —— 分组改名 / 显隐(需要登录) +async fn update_group( + State(state): State, + Path(id): Path, + headers: HeaderMap, + Json(body): Json, +) -> Response { + if !same_origin(&headers) { + return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝"); + } + match read_session(&state, &headers).await { + Ok(session) if session.authenticated => {} + Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"), + Err(response) => return response, + } + + let db = Arc::clone(&state.db); + match tokio::task::spawn_blocking(move || db.update_group(&id, body)).await { + Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(), + Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message), + Ok(Err(StashError::InvalidDirectory(message))) => { + api_error(StatusCode::BAD_REQUEST, &message) + } + Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + } +} + +/// DELETE /api/groups/{id} —— 删除分组(需要登录,组内条目级联删除) +async fn delete_group( + State(state): State, + Path(id): Path, + headers: HeaderMap, +) -> Response { + if !same_origin(&headers) { + return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝"); + } + match read_session(&state, &headers).await { + Ok(session) if session.authenticated => {} + Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"), + Err(response) => return response, + } + + let db = Arc::clone(&state.db); + match tokio::task::spawn_blocking(move || db.remove_group(&id)).await { + Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(), + Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message), + Ok(Err(StashError::InvalidDirectory(message))) => { + api_error(StatusCode::BAD_REQUEST, &message) + } + Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + } +} + /// DELETE /api/categories/{id} —— 删除分区(需要登录,级联内容) async fn delete_category( State(state): State, @@ -826,7 +962,8 @@ fn build_router(state: AppState) -> Router { .route("/api/auth/logout", post(auth_logout)) .route("/api/auth/password", patch(auth_password)) .route("/api/settings", get(get_settings).patch(patch_settings)) - .route("/api/items/{key}", get(get_item).patch(patch_item)) + .route("/api/items/{key}", get(get_item).patch(patch_item).delete(delete_item)) + .route("/api/items", post(create_item)) .route("/api/items/{key}/view", post(post_view)) .route("/api/items/{key}/like", post(post_like)) .route("/api/categories", post(create_category)) @@ -834,6 +971,8 @@ fn build_router(state: AppState) -> Router { "/api/categories/{id}", patch(update_category).delete(delete_category), ) + .route("/api/categories/{id}/groups", post(create_group)) + .route("/api/groups/{id}", patch(update_group).delete(delete_group)) .route("/api/backup", get(backup_db)) .route("/", get(web_index)) .route("/index.html", get(web_index)) diff --git a/stash-web/src/App.css b/stash-web/src/App.css index 58cc73e..b0fc63e 100644 --- a/stash-web/src/App.css +++ b/stash-web/src/App.css @@ -980,6 +980,62 @@ min-width: 0; } +/* 分区管理:一个分区一块,下面挂卡片预览与子分组 */ +.admin-section-block { + display: flex; + flex-direction: column; + gap: var(--su-space-3); + padding-bottom: var(--su-space-4); + border-bottom: 1px dashed var(--su-border-strong); +} + +.admin-block-row { + display: flex; + align-items: center; + gap: var(--su-space-2); +} + +.admin-groups { + display: flex; + flex-direction: column; + gap: var(--su-space-2); + padding-left: var(--su-space-4); +} + +.admin-group-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + align-items: center; + gap: var(--su-space-2); +} + +.admin-new-group-row { + display: grid; + grid-template-columns: 190px minmax(0, 1fr) auto; + align-items: center; + gap: var(--su-space-2); +} + +/* 内容页:新增条目表单与条目列表 */ +.admin-content-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--su-space-3); +} + +.admin-content-form .admin-col-span { + grid-column: 1 / -1; +} + +.admin-entry-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 140px auto; + align-items: center; + gap: var(--su-space-2); + padding: var(--su-space-2) 0; + border-bottom: 1px solid var(--su-border); +} + /* 删除按钮:可删的用危险色,不可删的用弱化色(都取主题令牌) */ .admin-item-actions .admin-remove { color: var(--su-color-danger); diff --git a/stash-web/src/components/admin/AdminPage.tsx b/stash-web/src/components/admin/AdminPage.tsx index e56dbd7..df317f6 100644 --- a/stash-web/src/components/admin/AdminPage.tsx +++ b/stash-web/src/components/admin/AdminPage.tsx @@ -22,11 +22,14 @@ import { changePassword, fetchSession, logout, type SessionState } from '../../a import { LoginDialog } from '../auth/LoginDialog' import { createCategory, + createGroup, deleteCategory, + deleteGroup, fetchSettings, saveSettings, TONE_OPTIONS, updateCategory, + updateGroup, type HeaderItem, type ProfileSettings, type SectionSettings, @@ -34,6 +37,7 @@ import { type SettingsTone, type SiteSettings, } from '../../settings' +import { createItem, deleteItem } from '../../items' /** * 站点设置页(/admin):页头滚动信息 + 侧栏顺序/显隐。 @@ -41,11 +45,18 @@ import { * 只做「全局配置」;条目内容仍在详情页就地编辑(P2)。 * 保存用 `version` 乐观锁,冲突时提示刷新。 */ +type EntrySummary = { + id: string + name?: string + title?: string +} + type CategoryGroupSummary = { id: string name: string /** 数据库里的默认显隐;缺省视为显示 */ showInSidebar?: boolean + items?: EntrySummary[] } type CategorySummary = { @@ -55,10 +66,12 @@ type CategorySummary = { icon?: string barColor?: string previewLimit?: number - /** 首页卡片标题(配了展示块时用块的标题,如「常用站点」) */ - block?: { title?: string } | null + /** 首页卡片标题与预览节点 */ + block?: { title?: string; nodeKey?: string } | null /** 应用类分区下的分组(子分类) */ groups?: CategoryGroupSummary[] + /** 海报 / 图文类分区直接挂的条目 */ + items?: EntrySummary[] } const clone = (settings: SiteSettings): SiteSettings => structuredClone(settings) @@ -187,7 +200,8 @@ export function AdminPage() { items={[ { key: 'header', label: '页头滚动信息' }, { key: 'sections', label: '首页卡片' }, - { key: 'categories', label: '归类' }, + { key: 'content', label: '内容' }, + { key: 'categories', label: '分区' }, { key: 'sidebar', label: '侧栏导航' }, { key: 'profile', label: '资料' }, { key: 'system', label: '系统' }, @@ -209,6 +223,8 @@ export function AdminPage() { onDelete={requestDelete} onReload={load} /> + ) : tab === 'content' ? ( + ) : tab === 'categories' ? ( )} - {/* 「归类」每行自己保存,不参与整页设置的保存/撤销 */} - {tab !== 'categories' && ( + {/* 「内容」「分区」都是逐项保存,不参与整页设置的保存/撤销 */} + {tab !== 'categories' && tab !== 'content' && ( + + + ) +} + +// ---------- 内容(新增条目) ---------- + +function ContentManager({ + categories, + onReload, +}: { + categories: CategorySummary[] + onReload: () => void +}) { + const [sectionId, setSectionId] = useState(categories[0]?.id ?? '') + const [groupId, setGroupId] = useState('') + const [key, setKey] = useState('') + const [name, setName] = useState('') + const [href, setHref] = useState('') + const [description, setDescription] = useState('') + const [icon, setIcon] = useState('') + const [cover, setCover] = useState('') + const [tags, setTags] = useState('') + const [comment, setComment] = useState('') + const [body, setBody] = useState('') + const [busy, setBusy] = useState(false) + const [pendingEntry, setPendingEntry] = useState(null) + const [deleting, setDeleting] = useState(false) + + const section = + categories.find((category) => category.id === sectionId) ?? categories[0] + const groups = section?.kind === 'apps' ? (section.groups ?? []) : [] + + useEffect(() => { + if (categories.length === 0) return + if (!categories.some((category) => category.id === sectionId)) { + setSectionId(categories[0].id) + } + }, [categories, sectionId]) + + useEffect(() => { + if (section?.kind !== 'apps') return + if (!groups.some((group) => group.id === groupId)) { + setGroupId(groups[0]?.id ?? '') + } + }, [section, groups, groupId]) + + const nodeKey = + section === undefined ? '' : section.kind === 'apps' ? groupId : section.id + + const entries = + section === undefined + ? [] + : section.kind === 'apps' + ? (section.groups ?? []).flatMap((group) => + (group.items ?? []).map((item) => ({ + ...item, + groupName: group.name, + })), + ) + : (section.items ?? []).map((item) => ({ ...item, groupName: undefined })) + + const submit = async () => { + if (section === undefined || nodeKey === '') { + toast.error('请先选择分区与分组') + return + } + + setBusy(true) + const result = await createItem({ + key: key.trim(), + nodeKey, + item: { + name: name.trim(), + description, + icon, + tags: tags + .split(/[,,]/) + .map((tag) => tag.trim()) + .filter(Boolean), + badge: '', + comment, + summary: '', + body, + cover, + banner: '', + href, + }, + }) + setBusy(false) + + if (!result.ok) { + toast.error(result.error) + return + } + + toast.success(`已添加「${name.trim()}」`) + setKey('') + setName('') + setHref('') + setDescription('') + setIcon('') + setCover('') + setTags('') + setComment('') + setBody('') + onReload() + } + + const removeEntry = async () => { + if (pendingEntry === null) return + setDeleting(true) + const result = await deleteItem(pendingEntry.id) + setDeleting(false) + if (!result.ok) { + toast.error(result.error) + return + } + toast.success('条目已删除') + setPendingEntry(null) + onReload() + } + + return ( + + + 在下面新增网址 / 海报 / 图文条目,并选择它挂在哪个分区、哪个分组(应用类必须选分组)。 + 新增后,分组出现在侧栏与首页卡片的条件是它被设为显示。 + + + +
+ ({ value: group.id, label: group.name }))} + value={groupId} + onChange={setGroupId} + /> +
+ )} + + {section?.kind === 'apps' && groups.length === 0 && ( + + 这个分区还没有分组,请先到「分区」Tab 新建分组。 + + )} +
+ +
+ setKey(event.target.value)} + /> + setName(event.target.value)} + /> + setHref(event.target.value)} + /> + setIcon(event.target.value)} + /> + setDescription(event.target.value)} + /> + setCover(event.target.value)} + /> + setTags(event.target.value)} + /> + setComment(event.target.value)} + /> +
+