diff --git a/src/db/categories.rs b/src/db/categories.rs index 42bff6b..20841b3 100644 --- a/src/db/categories.rs +++ b/src/db/categories.rs @@ -3,7 +3,7 @@ //! 分区就是 `node` 表里 `kind='section'` 的行;删除会级联分组、条目与展示块。 //! 新增时顺手建一条首页展示块,保证它立刻出现在首页。 -use rusqlite::{Connection, params}; +use rusqlite::{Connection, OptionalExtension, params}; use serde::Deserialize; use crate::{db::map_error, error::StashError, db::entries}; @@ -19,6 +19,19 @@ pub struct NewCategory { pub kind: String, } +/// 修改分区:字段都可选,只改传进来的。 +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateCategory { + pub title: Option, + pub icon: Option, + pub bar_color: Option, + /// 改类型(应用 / 海报 / 图文)时会顺带迁移条目归属 + pub kind: Option, + pub preview_limit: Option, + pub empty_text: Option, +} + fn valid_id(id: &str) -> bool { !id.is_empty() && id.len() <= 32 @@ -84,6 +97,194 @@ pub fn create(conn: &mut Connection, new: NewCategory) -> Result<(), StashError> Ok(()) } +struct SectionRow { + title: String, + icon: Option, + bar_color: Option, + view_mode: Option, + preview_limit: Option, + empty_text: Option, +} + +fn valid_color(color: &str) -> bool { + if color.is_empty() { + return true; + } + let hex = color.strip_prefix('#').unwrap_or(""); + (hex.len() == 3 || hex.len() == 6) && hex.chars().all(|ch| ch.is_ascii_hexdigit()) +} + +/// 修改分区:标题 / 图标 / 色条 / 预览条数 / 空态文案,以及「归类」类型。 +/// +/// 改类型时迁移数据,保证切换后条目不丢: +/// - 海报 / 图文 → 应用:确保有一个分组,把直接挂在分区上的条目挪进去, +/// 首页卡片也改指向该分组; +/// - 应用 → 海报 / 图文:卡片从分组挪回分区,分组保留(不影响条目读取)。 +pub fn update(conn: &mut Connection, id: &str, patch: UpdateCategory) -> Result<(), StashError> { + if id == "local" { + return Err(StashError::InvalidDirectory( + "「本地服务」是运行时分类,不能编辑".into(), + )); + } + + let tx = conn.transaction().map_err(map_error)?; + + let current = tx + .query_row( + "SELECT title, icon, bar_color, view_mode, preview_limit, empty_text + FROM node WHERE key = ?1 AND kind = 'section'", + [id], + |row| { + Ok(SectionRow { + title: row.get(0)?, + icon: row.get(1)?, + bar_color: row.get(2)?, + view_mode: row.get(3)?, + preview_limit: row.get(4)?, + empty_text: row.get(5)?, + }) + }, + ) + .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.title, + }; + + let icon = match patch.icon { + Some(icon) => { + let icon = icon.trim().to_string(); + if icon.chars().count() > 8 { + return Err(StashError::InvalidDirectory("图标最多 8 个字符".into())); + } + (!icon.is_empty()).then_some(icon) + } + None => current.icon, + }; + + let bar_color = match patch.bar_color { + Some(color) => { + let color = color.trim().to_ascii_lowercase(); + if !valid_color(&color) { + return Err(StashError::InvalidDirectory( + "色条需为 #rgb 或 #rrggbb".into(), + )); + } + (!color.is_empty()).then_some(color) + } + None => current.bar_color, + }; + + let empty_text = match patch.empty_text { + Some(text) => { + let text = text.trim().to_string(); + (!text.is_empty()).then_some(text) + } + None => current.empty_text, + }; + + let preview_limit = match patch.preview_limit { + Some(limit) => { + if !(1..=60).contains(&limit) { + return Err(StashError::InvalidDirectory("预览条数需在 1–60 之间".into())); + } + Some(limit) + } + None => current.preview_limit, + }; + + let kind = match patch.kind { + Some(kind) => { + if !KINDS.contains(&kind.as_str()) { + return Err(StashError::InvalidDirectory("类型不合法".into())); + } + kind + } + None => current + .view_mode + .clone() + .ok_or_else(|| StashError::Database(format!("分区 {id} 缺少 view_mode")))?, + }; + + let was_apps = current.view_mode.as_deref() == Some("apps"); + let is_apps = kind == "apps"; + + if is_apps && !was_apps { + // 切到应用:条目必须有分组可挂;没有就建一个「全部」 + let group_count: i64 = tx + .query_row( + "SELECT COUNT(*) FROM node WHERE kind = 'group' AND parent_key = ?1", + [id], + |row| row.get(0), + ) + .map_err(map_error)?; + + let group_key = if group_count == 0 { + let group_key = format!("{id}-all"); + tx.execute( + "INSERT INTO node (key, parent_key, title, kind, sort, show_in_sidebar) + VALUES (?1, ?2, '全部', 'group', 1, 1)", + params![group_key, id], + ) + .map_err(map_error)?; + group_key + } else { + tx.query_row( + "SELECT key FROM node WHERE kind = 'group' AND parent_key = ?1 + ORDER BY sort, key LIMIT 1", + [id], + |row| row.get(0), + ) + .map_err(map_error)? + }; + + tx.execute( + "UPDATE entry SET node_key = ?1 WHERE node_key = ?2", + params![group_key, id], + ) + .map_err(map_error)?; + tx.execute( + "UPDATE section_block SET node_key = ?1 WHERE node_key = ?2", + params![group_key, id], + ) + .map_err(map_error)?; + } else if !is_apps && was_apps { + // 离开应用:首页卡片从「某个分组」挪回分区,标题交还给分区 + tx.execute( + "UPDATE section_block SET node_key = ?1, title = NULL + WHERE node_key IN ( + SELECT key FROM node WHERE kind = 'group' AND parent_key = ?1 + )", + [id], + ) + .map_err(map_error)?; + } + + tx.execute( + "UPDATE node SET title = ?1, icon = ?2, bar_color = ?3, view_mode = ?4, + preview_limit = ?5, empty_text = ?6 + WHERE key = ?7", + params![title, icon, bar_color, kind, preview_limit, empty_text, id], + ) + .map_err(map_error)?; + + entries::audit(&tx, "category.update", id, &title)?; + tx.commit().map_err(map_error)?; + Ok(()) +} + pub fn remove(conn: &mut Connection, id: &str) -> Result<(), StashError> { if id == "local" { return Err(StashError::InvalidDirectory("本地服务不能删除".into())); @@ -116,9 +317,17 @@ mod tests { title TEXT NOT NULL, kind TEXT NOT NULL, view_mode TEXT, + icon TEXT, + bar_color TEXT, + preview_limit INTEGER, + empty_text TEXT, sort INTEGER NOT NULL DEFAULT 0, show_in_sidebar INTEGER NOT NULL DEFAULT 1 ); + CREATE TABLE entry ( + key TEXT PRIMARY KEY, + node_key TEXT NOT NULL + ); CREATE TABLE section_block ( id INTEGER PRIMARY KEY, node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE, @@ -199,4 +408,119 @@ mod tests { .expect_err("重复应失败"); assert!(matches!(error, StashError::Conflict(_))); } + + fn section(id: &str, title: &str, kind: &str) -> NewCategory { + NewCategory { + id: id.into(), + title: title.into(), + kind: kind.into(), + } + } + + #[test] + fn updates_fields_and_rejects_bad_values() { + let mut conn = db(); + create(&mut conn, section("books", "书单", "posters")).expect("应能创建"); + + update( + &mut conn, + "books", + UpdateCategory { + title: Some("读过的书".into()), + icon: Some("📚".into()), + bar_color: Some("#ff6034".into()), + preview_limit: Some(8), + ..Default::default() + }, + ) + .expect("应能更新"); + + let (title, icon, color, limit): (String, Option, Option, Option) = + conn.query_row( + "SELECT title, icon, bar_color, preview_limit FROM node WHERE key='books'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .expect("应能查"); + assert_eq!(title, "读过的书"); + assert_eq!(icon.as_deref(), Some("📚")); + assert_eq!(color.as_deref(), Some("#ff6034")); + assert_eq!(limit, Some(8)); + + assert!(update(&mut conn, "books", UpdateCategory { bar_color: Some("red".into()), ..Default::default() }).is_err()); + assert!(update(&mut conn, "books", UpdateCategory { kind: Some("videos".into()), ..Default::default() }).is_err()); + assert!(update(&mut conn, "local", UpdateCategory { title: Some("x".into()), ..Default::default() }).is_err()); + assert!(matches!( + update(&mut conn, "none", UpdateCategory::default()).expect_err("应不存在"), + StashError::NotFound(_) + )); + } + + #[test] + fn switching_apps_to_posters_moves_card_back_to_section() { + let mut conn = db(); + create(&mut conn, section("books", "书单", "apps")).expect("应能创建"); + conn.execute( + "INSERT INTO node (key, parent_key, title, kind, sort) VALUES ('books-g', 'books', '分组', 'group', 1)", + [], + ) + .expect("应能建分组"); + conn.execute( + "INSERT INTO entry (key, node_key) VALUES ('b1', 'books-g')", + [], + ) + .expect("应能建条目"); + conn.execute( + "INSERT INTO section_block (node_key, title, sort) VALUES ('books-g', '分组', 1)", + [], + ) + .expect("应能建卡片"); + + update(&mut conn, "books", UpdateCategory { kind: Some("posters".into()), ..Default::default() }) + .expect("应能改类型"); + + let block: String = conn + .query_row("SELECT node_key FROM section_block", [], |row| row.get(0)) + .expect("应能查"); + assert_eq!(block, "books", "卡片应挪回分区"); + let mode: String = conn + .query_row("SELECT view_mode FROM node WHERE key='books'", [], |row| row.get(0)) + .expect("应能查"); + assert_eq!(mode, "posters"); + } + + #[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("应能建条目"); + conn.execute( + "INSERT INTO section_block (node_key, title, sort) VALUES ('films', '电影', 1)", + [], + ) + .expect("应能建卡片"); + + update(&mut conn, "films", UpdateCategory { kind: Some("apps".into()), ..Default::default() }) + .expect("应能改类型"); + + let (entry_node, block_node): (String, String) = conn + .query_row( + "SELECT (SELECT node_key FROM entry WHERE key='f1'), + (SELECT node_key FROM section_block)", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("应能查"); + assert_eq!(entry_node, "films-all", "条目应挂到默认分组"); + assert_eq!(block_node, "films-all", "首页卡片应指向默认分组"); + let groups: i64 = conn + .query_row( + "SELECT COUNT(*) FROM node WHERE kind='group' AND parent_key='films'", + [], + |row| row.get(0), + ) + .expect("应能查"); + assert_eq!(groups, 1); + } } diff --git a/src/db/entries.rs b/src/db/entries.rs index 4f5c766..d631821 100644 --- a/src/db/entries.rs +++ b/src/db/entries.rs @@ -62,6 +62,81 @@ fn now() -> i64 { .unwrap_or(0) } +/// 规范化跳转地址:补协议、拒绝危险协议、空值统一成空串。 +/// +/// 用户经常直接填 `www.zhihu.com`。原样存进 `href` 会被浏览器当成 +/// 相对地址(点「跳转到」会跳到 `/c/.../www.zhihu.com`),所以这里统一补 +/// `https://`。`href` 会原样渲染进 ``,`javascript:` / `data:` 这类 +/// 协议必须直接拒绝,不能只靠前端过滤。 +fn normalize_href(raw: &str) -> Result { + let value = raw.trim(); + if value.is_empty() || value == "#" { + return Ok(String::new()); + } + if value.chars().any(char::is_whitespace) { + return Err(StashError::InvalidDirectory("跳转地址不能包含空格".into())); + } + + // 站内路径(单个斜杠开头);`//host` 是协议相对地址,按外链处理 + if value.starts_with('/') && !value.starts_with("//") { + return Ok(value.to_string()); + } + + let lower = value.to_ascii_lowercase(); + if lower.starts_with("http://") || lower.starts_with("https://") { + return Ok(value.to_string()); + } + if value.starts_with("//") { + return Ok(format!("https:{value}")); + } + + // 带「其他协议」的地址一律拒绝(javascript: / data: / vbscript: / file: / mailto: …); + // 只放行上面已经处理过的 http(s) 与站内路径。 + if let Some((scheme, _)) = lower.split_once(':') { + let looks_like_scheme = !scheme.is_empty() + && scheme.chars().next().is_some_and(|ch| ch.is_ascii_alphabetic()) + && scheme + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '+' || ch == '-' || ch == '.'); + if looks_like_scheme && !looks_like_domain(value) { + return Err(StashError::InvalidDirectory( + "跳转地址只支持 http(s)、站内路径或域名".into(), + )); + } + } + + if looks_like_domain(value) { + return Ok(format!("https://{value}")); + } + + Err(StashError::InvalidDirectory( + "跳转地址需以 http(s):// 或 / 开头".into(), + )) +} + +/// 裸域名判断:`www.zhihu.com`、`example.com:8080/path` 这类没有协议头的地址。 +fn looks_like_domain(value: &str) -> bool { + let host_port = value.split('/').next().unwrap_or(""); + let host = host_port.split(':').next().unwrap_or(""); + if !host.contains('.') { + return false; + } + + let mut parts = host.split('.'); + let Some(tld) = parts.next_back() else { + return false; + }; + + tld.len() >= 2 + && tld.chars().all(|ch| ch.is_ascii_alphabetic()) + && parts.all(|part| { + !part.is_empty() + && part + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-') + }) +} + fn validate(patch: &EntryPatch) -> Result<(), StashError> { if patch.name.trim().is_empty() { return Err(StashError::InvalidDirectory("标题不能为空".into())); @@ -143,6 +218,8 @@ pub fn update( expected_updated_at: i64, ) -> Result { validate(&patch)?; + // 跳转地址先规范化:补 https、拒绝 javascript: 等危险协议 + let href = normalize_href(&patch.href)?; let tags: Vec = patch .tags @@ -165,7 +242,7 @@ pub fn update( patch.name.trim(), patch.description, patch.icon, - patch.href, + href, patch.cover, patch.banner, patch.badge, @@ -331,4 +408,36 @@ mod tests { let error = update(&mut conn, "none", patch("X"), 0).expect_err("应不存在"); assert!(matches!(error, StashError::Conflict(_) | StashError::NotFound(_))); } + + #[test] + fn normalizes_links_so_jump_works() { + // 裸域名补 https,避免被浏览器当成相对地址 + assert_eq!(normalize_href("www.zhihu.com").unwrap(), "https://www.zhihu.com"); + assert_eq!( + normalize_href("example.com:8080/path").unwrap(), + "https://example.com:8080/path" + ); + // 已带协议的保持原样;站内路径保持站内 + assert_eq!(normalize_href("https://example.com/a").unwrap(), "https://example.com/a"); + assert_eq!(normalize_href("/git_helper/").unwrap(), "/git_helper/"); + // 空值和占位符统一成空串 + assert_eq!(normalize_href(" ").unwrap(), ""); + assert_eq!(normalize_href("#").unwrap(), ""); + } + + #[test] + fn rejects_dangerous_or_unknown_links() { + for bad in ["javascript:alert(1)", "data:text/html,hi", "mailto:a@b.com", "not a url"] { + assert!(normalize_href(bad).is_err(), "{bad} 应被拒绝"); + } + } + + #[test] + fn update_stores_normalized_href() { + let mut conn = db(); + let mut input = patch("知乎"); + input.href = "www.zhihu.com".into(); + let saved = update(&mut conn, "movie-1", input, 0).expect("应能更新"); + assert_eq!(saved.href, "https://www.zhihu.com"); + } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 821efa3..9c377c2 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -212,6 +212,15 @@ impl Db { categories::create(&mut conn, new) } + pub fn update_category( + &self, + id: &str, + patch: categories::UpdateCategory, + ) -> Result<(), StashError> { + let mut conn = self.lock()?; + categories::update(&mut conn, id, patch) + } + pub fn remove_category(&self, id: &str) -> Result<(), StashError> { let mut conn = self.lock()?; categories::remove(&mut conn, id) diff --git a/src/db/settings.rs b/src/db/settings.rs index 9bd0ff0..7ae7385 100644 --- a/src/db/settings.rs +++ b/src/db/settings.rs @@ -86,6 +86,12 @@ pub struct SidebarSettings { pub hidden: Vec, /// 分类显示顺序(id);未列出的排在后面,保持原有顺序 pub order: Vec, + /// 隐藏的分组,键为 `分区id/分组id` + pub hidden_groups: Vec, + /// 强制显示的分组(覆盖数据库里的 show_in_sidebar=0) + pub shown_groups: Vec, + /// 每个分区下分组的顺序:分区 id → 分组 id 列表 + pub group_order: std::collections::HashMap>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] @@ -172,6 +178,12 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> { if settings.sidebar.hidden.len() > 100 || settings.sidebar.order.len() > 100 { return Err(StashError::InvalidDirectory("侧栏配置过长".into())); } + if settings.sidebar.hidden_groups.len() > 200 + || settings.sidebar.shown_groups.len() > 200 + || settings.sidebar.group_order.len() > 100 + { + return Err(StashError::InvalidDirectory("侧栏分组配置过长".into())); + } if settings.sections.hidden.len() > 100 || settings.sections.order.len() > 100 { return Err(StashError::InvalidDirectory("首页卡片配置过长".into())); } diff --git a/src/server.rs b/src/server.rs index 47fe824..dfd9bd4 100644 --- a/src/server.rs +++ b/src/server.rs @@ -745,6 +745,34 @@ async fn create_category( } } +/// PATCH /api/categories/{id} —— 修改分区(需要登录) +async fn update_category( + 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_category(&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/categories/{id} —— 删除分区(需要登录,级联内容) async fn delete_category( State(state): State, @@ -802,7 +830,10 @@ fn build_router(state: AppState) -> Router { .route("/api/items/{key}/view", post(post_view)) .route("/api/items/{key}/like", post(post_like)) .route("/api/categories", post(create_category)) - .route("/api/categories/{id}", axum::routing::delete(delete_category)) + .route( + "/api/categories/{id}", + patch(update_category).delete(delete_category), + ) .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 d67daba..58cc73e 100644 --- a/stash-web/src/App.css +++ b/stash-web/src/App.css @@ -958,6 +958,28 @@ min-width: 0; } +/* 侧栏里的子分类(分组):缩进一层,弱化文字 */ +.admin-nav-group-row { + padding-left: var(--su-space-6); +} + +.admin-nav-group-row .admin-item-title { + color: var(--su-text-sub); + font-size: var(--su-font-size-sm); +} + +/* 归类页:一行一个分区,标题 / 类型 / 图标 / 色条 / 条数 / 保存 / 删除 */ +.admin-category-row { + display: grid; + grid-template-columns: minmax(0, 1.4fr) 150px 80px 110px 90px auto auto; + align-items: center; + gap: var(--su-space-2); +} + +.admin-category-row > * { + min-width: 0; +} + /* 删除按钮:可删的用危险色,不可删的用弱化色(都取主题令牌) */ .admin-item-actions .admin-remove { color: var(--su-color-danger); diff --git a/stash-web/src/App.tsx b/stash-web/src/App.tsx index 535dff0..1d332d2 100644 --- a/stash-web/src/App.tsx +++ b/stash-web/src/App.tsx @@ -30,6 +30,7 @@ import { useCatalog } from './useCatalog' import { useViewRoute } from './useViewRoute' import { fetchSession, logout as logoutRequest, type SessionState } from './auth' import { + applySidebarGroups, applySidebarSettings, defaultSettings, fetchSettings, @@ -81,12 +82,15 @@ export default function App() { ) // 侧栏不显示「本地服务」,也不显示被标记 show_in_sidebar = 0 的分区; - // 再按 /admin 里的顺序与显隐设置重排一遍 + // 再按 /admin 里的顺序、显隐与分组设置重排一遍 const sidebarCategories = useMemo( () => - applySidebarSettings( - categories.filter( - (category) => category.id !== 'local' && category.showInSidebar !== false, + applySidebarGroups( + applySidebarSettings( + categories.filter( + (category) => category.id !== 'local' && category.showInSidebar !== false, + ), + settings.settings.sidebar, ), settings.settings.sidebar, ), diff --git a/stash-web/src/components/admin/AdminPage.tsx b/stash-web/src/components/admin/AdminPage.tsx index 14e2cd6..e56dbd7 100644 --- a/stash-web/src/components/admin/AdminPage.tsx +++ b/stash-web/src/components/admin/AdminPage.tsx @@ -26,6 +26,7 @@ import { fetchSettings, saveSettings, TONE_OPTIONS, + updateCategory, type HeaderItem, type ProfileSettings, type SectionSettings, @@ -40,11 +41,24 @@ import { * 只做「全局配置」;条目内容仍在详情页就地编辑(P2)。 * 保存用 `version` 乐观锁,冲突时提示刷新。 */ +type CategoryGroupSummary = { + id: string + name: string + /** 数据库里的默认显隐;缺省视为显示 */ + showInSidebar?: boolean +} + type CategorySummary = { id: string title: string + kind?: 'apps' | 'posters' | 'articles' + icon?: string + barColor?: string + previewLimit?: number /** 首页卡片标题(配了展示块时用块的标题,如「常用站点」) */ block?: { title?: string } | null + /** 应用类分区下的分组(子分类) */ + groups?: CategoryGroupSummary[] } const clone = (settings: SiteSettings): SiteSettings => structuredClone(settings) @@ -173,6 +187,7 @@ export function AdminPage() { items={[ { key: 'header', label: '页头滚动信息' }, { key: 'sections', label: '首页卡片' }, + { key: 'categories', label: '归类' }, { key: 'sidebar', label: '侧栏导航' }, { key: 'profile', label: '资料' }, { key: 'system', label: '系统' }, @@ -194,6 +209,12 @@ export function AdminPage() { onDelete={requestDelete} onReload={load} /> + ) : tab === 'categories' ? ( + ) : tab === 'sidebar' ? ( )} - - - - + {/* 「归类」每行自己保存,不参与整页设置的保存/撤销 */} + {tab !== 'categories' && ( + + + + + )} )} @@ -409,28 +433,95 @@ function SidebarForm({ onChange({ ...value, hidden, order: ordered.map((category) => category.id) }) } + const orderedGroups = (category: CategorySummary) => { + const ranks = new Map( + (value.group_order[category.id] ?? []).map((id, index) => [id, index] as const), + ) + return [...(category.groups ?? [])].sort((a, b) => { + const left = ranks.get(a.id) ?? Number.MAX_SAFE_INTEGER + const right = ranks.get(b.id) ?? Number.MAX_SAFE_INTEGER + if (left === right) return 0 + return left - right + }) + } + + const groupVisible = (category: CategorySummary, group: CategoryGroupSummary) => { + const key = `${category.id}/${group.id}` + return ( + !value.hidden_groups.includes(key) && + (group.showInSidebar !== false || value.shown_groups.includes(key)) + ) + } + + const toggleGroup = ( + category: CategorySummary, + group: CategoryGroupSummary, + visible: boolean, + ) => { + const key = `${category.id}/${group.id}` + const hidden_groups = visible + ? value.hidden_groups.filter((entry) => entry !== key) + : [...new Set([...value.hidden_groups, key])] + const shown_groups = visible + ? [...new Set([...value.shown_groups, key])] + : value.shown_groups.filter((entry) => entry !== key) + onChange({ ...value, hidden_groups, shown_groups }) + } + + const moveGroup = (category: CategorySummary, index: number, step: -1 | 1) => { + const groups = orderedGroups(category) + const target = index + step + if (target < 0 || target >= groups.length) return + + const ids = groups.map((group) => group.id) + const [id] = ids.splice(index, 1) + ids.splice(target, 0, id) + onChange({ ...value, group_order: { ...value.group_order, [category.id]: ids } }) + } + return ( - 顺序决定侧栏显示位置;关掉的分类仍可通过地址直接访问。 + 顺序决定侧栏显示位置;关掉的分类仍可通过地址直接访问。应用类分区下的子分类(分组)可以单独显隐与排序。 {ordered.map((category, index) => { const visible = !value.hidden.includes(category.id) + const groups = category.kind === 'apps' ? orderedGroups(category) : [] return ( -
- toggle(category.id, next)} - label={`显示 ${category.title}`} - /> - {category.title} - move(index, -1)} - onDown={() => move(index, 1)} - onRemove={() => onDelete(category)} - /> +
+
+ toggle(category.id, next)} + label={`显示 ${category.title}`} + /> + {category.title} + move(index, -1)} + onDown={() => move(index, 1)} + onRemove={() => onDelete(category)} + /> +
+ + {groups.map((group, groupIndex) => ( +
+ toggleGroup(category, group, next)} + label={`显示 ${group.name}`} + /> + {group.name} + moveGroup(category, groupIndex, -1)} + onDown={() => moveGroup(category, groupIndex, 1)} + /> +
+ ))}
) })} @@ -618,6 +709,130 @@ function CategoryCreator({ onCreated }: { onCreated: () => void }) { ) } +// ---------- 归类(分类管理) ---------- + +const KIND_OPTIONS = [ + { value: 'apps', label: '应用' }, + { value: 'posters', label: '海报' }, + { value: 'articles', label: '图文' }, +] + +function CategoryManager({ + categories, + onReload, + onDelete, +}: { + categories: CategorySummary[] + onReload: () => void + onDelete: (card: CategorySummary) => void +}) { + return ( + + + 决定每个分区归到哪一类(应用 / 海报 / 图文),以及标题、图标、色条与预览条数。改类型会自动迁移条目:切到「应用」会建默认分组并把条目挪进去。 + + + {categories.map((category) => ( + + ))} + + {categories.length === 0 && 内容库还没有加载完成。} + + + + ) +} + +function CategoryRow({ + category, + onReload, + onDelete, +}: { + category: CategorySummary + onReload: () => void + onDelete: (card: CategorySummary) => void +}) { + const [title, setTitle] = useState(category.title) + const [kind, setKind] = useState(category.kind ?? 'apps') + const [icon, setIcon] = useState(category.icon ?? '') + const [color, setColor] = useState(category.barColor ?? '') + const [limit, setLimit] = useState( + category.previewLimit === undefined ? '' : String(category.previewLimit), + ) + const [busy, setBusy] = useState(false) + + const save = async () => { + setBusy(true) + const result = await updateCategory(category.id, { + title, + kind, + icon, + barColor: color, + previewLimit: limit.trim() === '' ? undefined : Number(limit), + }) + setBusy(false) + + if (!result.ok) { + toast.error(result.error) + return + } + + toast.success(`已保存「${title}」`) + onReload() + } + + return ( +
+ setTitle(event.target.value)} + /> + setIcon(event.target.value)} + /> + setColor(event.target.value)} + /> + setLimit(event.target.value)} + /> + +
+ ) +} + // ---------- 资料 ---------- function ProfileForm({ diff --git a/stash-web/src/components/content/ItemDetailView.tsx b/stash-web/src/components/content/ItemDetailView.tsx index 5dd2396..e192a27 100644 --- a/stash-web/src/components/content/ItemDetailView.tsx +++ b/stash-web/src/components/content/ItemDetailView.tsx @@ -13,6 +13,7 @@ import { } from '@stash/ui' import { useEffect, useState } from 'react' import type { ItemDetailModel } from '../../data/detail' +import { resolveHref } from '../../data/detail' import type { RelatedItem } from '../../data/types' import { RelatedList } from './RelatedList' @@ -49,6 +50,8 @@ export function ItemDetailView({ }: ItemDetailViewProps) { const [views, setViews] = useState(model.views) const [likes, setLikes] = useState(model.likes) + // 历史数据可能是裸域名或占位符 #;统一解析成可跳转地址后再渲染 + const jumpHref = resolveHref(model.href) const [liked, setLiked] = useState(() => { try { return window.localStorage.getItem(`stash-liked:${itemKey}`) === '1' @@ -202,14 +205,14 @@ export function ItemDetailView({ /> - {/* 占位数据没有真实地址时,按钮置灰而不是打开空白页 */} - {model.href === '#' ? ( - ) : ( -
+ 跳转到 diff --git a/stash-web/src/components/content/ItemEditor.tsx b/stash-web/src/components/content/ItemEditor.tsx index 57b12f1..8877f8d 100644 --- a/stash-web/src/components/content/ItemEditor.tsx +++ b/stash-web/src/components/content/ItemEditor.tsx @@ -188,11 +188,15 @@ export function ItemEditor({ open, itemKey, onClose, onSaved }: ItemEditorProps) /> - + update({ href: event.target.value })} /> diff --git a/stash-web/src/components/sidebar/CategoryNav.tsx b/stash-web/src/components/sidebar/CategoryNav.tsx index 2d441ac..5c284ec 100644 --- a/stash-web/src/components/sidebar/CategoryNav.tsx +++ b/stash-web/src/components/sidebar/CategoryNav.tsx @@ -38,10 +38,8 @@ export function CategoryNav({ const items: MenuItem[] = [ { key: HOME_KEY, label: '首页' }, ...categories.map((category) => { - const groups = - category.kind === 'apps' - ? category.groups.filter((group) => group.showInSidebar !== false) - : [] + // 分组已由 App 按 /admin 的侧栏设置过滤、排序(含 show_in_sidebar 覆盖) + const groups = category.kind === 'apps' ? category.groups : [] return { key: categoryKey(category.id), diff --git a/stash-web/src/data/detail.ts b/stash-web/src/data/detail.ts index f3e216a..bb98714 100644 --- a/stash-web/src/data/detail.ts +++ b/stash-web/src/data/detail.ts @@ -28,8 +28,7 @@ export type ItemDetailModel = { } /** 把应用条目转成详情模型 */ -export function appDetailModel(item: AppItem): ItemDetailModel { - return { +export function appDetailModel(item: AppItem): ItemDetailModel { return { title: item.name, cover: item.cover, shape: 'square', @@ -73,3 +72,38 @@ export function posterToRelatedItem(item: PosterItem): RelatedItem { cover: item.cover, } } + +/** + * 把库里的 `href` 解析成真正可跳转的地址。 + * + * 兼容历史数据里没写协议的裸域名(`www.zhihu.com` → `https://…`); + * 空值、`#` 与不安全/看不懂的协议返回 `undefined`,由页面把按钮置灰。 + */ +export function resolveHref(raw: string | undefined): string | undefined { + const value = raw?.trim() ?? '' + if (value === '' || value === '#') return undefined + + // 站内路径;`//host` 是协议相对地址,按外链补 https + if (value.startsWith('/') && !value.startsWith('//')) return value + if (/^https?:\/\//i.test(value)) return value + if (value.startsWith('//')) return `https:${value}` + + // 其他协议(javascript: / data: / mailto: …)一律不当作跳转 + if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return undefined + + return looksLikeDomain(value) ? `https://${value}` : undefined +} + +function looksLikeDomain(value: string): boolean { + const [hostPort = ''] = value.split('/') + const [host = ''] = hostPort.split(':') + if (!host.includes('.')) return false + + const parts = host.split('.') + const tld = parts.pop() ?? '' + return ( + tld.length >= 2 && + /^[a-z]+$/i.test(tld) && + parts.every((part) => /^[a-z0-9-]+$/i.test(part)) + ) +} diff --git a/stash-web/src/settings.ts b/stash-web/src/settings.ts index ca316e7..b14465c 100644 --- a/stash-web/src/settings.ts +++ b/stash-web/src/settings.ts @@ -4,6 +4,8 @@ * 接口是公开读、登录后写;写的时候带 `version` 做乐观锁, * 冲突(多标签页同时改)时返回 409,页面提示刷新。 */ +import type { Category } from './data/types' + export type SettingsTone = 'plain' | 'primary' | 'success' | 'neutral' export type HeaderItem = { @@ -39,6 +41,12 @@ export type SiteSettings = { sidebar: { hidden: string[] order: string[] + /** 隐藏的分组:`分区id/分组id` */ + hidden_groups: string[] + /** 强制显示的分组(覆盖数据库 show_in_sidebar=0) */ + shown_groups: string[] + /** 每个分区下的分组顺序:分区 id → 分组 id 列表 */ + group_order: Record } sections: SectionSettings profile: ProfileSettings @@ -71,7 +79,13 @@ export function defaultSettings(): SiteSettings { { text: '{status}', tag: '状态', tone: 'success' }, ], }, - sidebar: { hidden: [], order: [] }, + sidebar: { + hidden: [], + order: [], + hidden_groups: [], + shown_groups: [], + group_order: {}, + }, sections: { hidden: [], order: [], titles: {}, limits: {} }, profile: { name: '本地管理员', @@ -151,6 +165,42 @@ export function applySectionSettings( return orderAndFilter(categories, sections) } +/** + * 侧栏子分类(分组):按设置过滤、排序。 + * + * 数据库里的 `show_in_sidebar` 是默认值;`shown_groups` 可以把它强行打开, + * `hidden_groups` 则再隐藏一层。 + */ +export function applySidebarGroups( + categories: Category[], + sidebar: SiteSettings['sidebar'], +): Category[] { + const hidden = new Set(sidebar.hidden_groups) + const shown = new Set(sidebar.shown_groups) + + return categories.map((category) => { + if (category.kind !== 'apps') return category + + const ranks = new Map( + (sidebar.group_order[category.id] ?? []).map((id, index) => [id, index] as const), + ) + const groups = category.groups + .filter((group) => { + const key = `${category.id}/${group.id}` + return !hidden.has(key) && (group.showInSidebar !== false || shown.has(key)) + }) + .map((group, index) => ({ + group, + index, + rank: ranks.get(group.id) ?? Number.MAX_SAFE_INTEGER, + })) + .sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank)) + .map((entry) => entry.group) + + return { ...category, groups } + }) +} + /** 卡片标题:设置里的覆盖值优先 */ export function sectionTitle( id: string, @@ -223,3 +273,32 @@ export async function deleteCategory(id: string): Promise { return { ok: false, error: error instanceof Error ? error.message : '网络错误' } } } + +/** 修改分区(归类、标题、图标、色条、预览条数) */ +export type UpdateCategoryInput = { + title?: string + icon?: string + barColor?: string + kind?: 'apps' | 'posters' | 'articles' + previewLimit?: number + emptyText?: string +} + +export async function updateCategory( + id: string, + patch: UpdateCategoryInput, +): Promise { + try { + const response = await fetch(`/api/categories/${encodeURIComponent(id)}`, { + method: 'PATCH', + credentials: 'same-origin', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(patch), + }) + if (response.ok) return { ok: true } + const payload = (await response.json().catch(() => null)) as { error?: string } | null + return { ok: false, error: payload?.error ?? `保存失败(${response.status})` } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : '网络错误' } + } +}