From be7a0747edc7c3268660612bce95a440e98d15c3 Mon Sep 17 00:00:00 2001 From: CNWei Date: Fri, 30 Oct 2026 20:05:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E5=88=86=E5=8C=BA=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E3=80=8C=E5=85=81=E8=AE=B8=E5=AD=90=E5=88=86=E7=BB=84?= =?UTF-8?q?=E3=80=8D=E5=BC=80=E5=85=B3=EF=BC=88=E4=B8=8E=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E8=A7=A3=E8=80=A6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - node 新增 allow_groups(迁移 v18):应用默认开、海报/图文默认关,已有分组的非应用分区自动开启 - 后台每行一个「子分组」开关;分组管理、卡片预览、内容弹窗选组、侧栏建组都跟随开关 - 关闭开关时条目平铺且不丢内容;首页卡片指向分组会自动挪回分区 - 三种类型共用同一模板,类型只决定渲染方式 - 测试 59 passed(迁移 15→18、开关建组、类型切换) --- src/db/catalog.rs | 11 +- src/db/categories.rs | 128 +++++++++++++----- src/db/mod.rs | 2 +- src/db/schema.rs | 30 +++- stash-web/src/App.css | 9 +- stash-web/src/components/admin/AdminPage.tsx | 35 +++-- .../src/components/sidebar/CategoryNav.tsx | 7 +- stash-web/src/settings.ts | 2 + 8 files changed, 167 insertions(+), 57 deletions(-) diff --git a/src/db/catalog.rs b/src/db/catalog.rs index 180d1c2..f2b03c7 100644 --- a/src/db/catalog.rs +++ b/src/db/catalog.rs @@ -25,6 +25,7 @@ struct NodeRow { preview_limit: Option, empty_text: Option, show_in_sidebar: bool, + allow_groups: bool, } /// 条目行:与 `entry` 表的列一一对应 @@ -56,7 +57,7 @@ pub fn load(conn: &Connection) -> Result { fn load_sections(conn: &Connection) -> Result, StashError> { let mut stmt = conn .prepare( - "SELECT key, title, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar + "SELECT key, title, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar, allow_groups FROM node WHERE kind = 'section' ORDER BY sort, key", @@ -74,6 +75,7 @@ fn load_sections(conn: &Connection) -> Result, StashError> { preview_limit: row.get(5)?, empty_text: row.get(6)?, show_in_sidebar: row.get(7)?, + allow_groups: row.get(8)?, }) }) .map_err(map_error)? @@ -159,6 +161,7 @@ fn build_section( ); object.insert("icon".into(), json!(section.icon.clone().unwrap_or_default())); object.insert("showInSidebar".into(), json!(section.show_in_sidebar)); + object.insert("allowGroups".into(), json!(section.allow_groups)); // 可选字段:没配就不下发,交给前端用 `?? 默认值` 处理 if let Some(limit) = section.preview_limit { @@ -181,9 +184,9 @@ fn build_section( } } - // 应用类:分区 → 分组 → 条目;海报 / 图文是平铺列表,分组不参与渲染 + // 「允许子分组」开启:分区 → 分组 → 条目;关闭:全部条目平铺 let tags = load_tags(conn, §ion.key)?; - if view_mode == "apps" { + if section.allow_groups { object.insert( "groups".into(), build_groups(conn, section, view_mode, &tags)?, @@ -196,7 +199,7 @@ fn build_section( object.insert("items".into(), json!(items)); } else { object.insert("groups".into(), json!([])); - // 平铺:把(可能存在的)分组里的条目一起取出来,切换类型时不丢内容 + // 平铺:把(可能存在的)分组里的条目一起取出来,关闭开关时不丢内容 let entries = load_section_entries(conn, §ion.key)?; let items = entries .iter() diff --git a/src/db/categories.rs b/src/db/categories.rs index 80ed452..30b46f5 100644 --- a/src/db/categories.rs +++ b/src/db/categories.rs @@ -32,6 +32,8 @@ pub struct UpdateCategory { pub empty_text: Option, /// 首页卡片预览哪个节点(分区自己或它下面的分组) pub block_node_key: Option, + /// 是否允许子分组(与类型无关,后台开关) + pub allow_groups: Option, } /// 新建分组(子分类) @@ -90,9 +92,9 @@ pub fn create(conn: &mut Connection, new: NewCategory) -> Result<(), StashError> .map_err(map_error)?; tx.execute( - "INSERT INTO node (key, parent_key, title, kind, view_mode, sort, show_in_sidebar) - VALUES (?1, NULL, ?2, 'section', ?3, ?4, 1)", - params![id, title, new.kind, sort], + "INSERT INTO node (key, parent_key, title, kind, view_mode, allow_groups, sort, show_in_sidebar) + VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, 1)", + params![id, title, new.kind, i64::from(new.kind == "apps"), sort], ) .map_err(map_error)?; @@ -121,6 +123,7 @@ struct SectionRow { view_mode: Option, preview_limit: Option, empty_text: Option, + allow_groups: bool, } fn valid_color(color: &str) -> bool { @@ -148,7 +151,7 @@ pub fn update(conn: &mut Connection, id: &str, patch: UpdateCategory) -> Result< let current = tx .query_row( - "SELECT title, icon, bar_color, view_mode, preview_limit, empty_text + "SELECT title, icon, bar_color, view_mode, preview_limit, empty_text, allow_groups FROM node WHERE key = ?1 AND kind = 'section'", [id], |row| { @@ -159,6 +162,7 @@ pub fn update(conn: &mut Connection, id: &str, patch: UpdateCategory) -> Result< view_mode: row.get(3)?, preview_limit: row.get(4)?, empty_text: row.get(5)?, + allow_groups: row.get(6)?, }) }, ) @@ -237,9 +241,10 @@ pub fn update(conn: &mut Connection, id: &str, patch: UpdateCategory) -> Result< let was_apps = current.view_mode.as_deref() == Some("apps"); let is_apps = kind == "apps"; + let allow_groups = patch.allow_groups.unwrap_or(current.allow_groups); - if is_apps && !was_apps { - // 切到应用:条目必须有分组可挂;没有就建一个「全部」 + if is_apps && !was_apps && allow_groups { + // 切到应用且开启子分组:条目必须有分组可挂;没有就建一个「全部」 let group_count: i64 = tx .query_row( "SELECT COUNT(*) FROM node WHERE kind = 'group' AND parent_key = ?1", @@ -277,8 +282,10 @@ pub fn update(conn: &mut Connection, id: &str, patch: UpdateCategory) -> Result< params![group_key, id], ) .map_err(map_error)?; - } else if !is_apps && was_apps { - // 离开应用:首页卡片从「某个分组」挪回分区,标题交还给分区 + } + + // 关闭子分组:首页卡片如果指着分组,挪回分区,避免标题/预览错位 + if !allow_groups { tx.execute( "UPDATE section_block SET node_key = ?1, title = NULL WHERE node_key IN ( @@ -291,9 +298,18 @@ pub fn update(conn: &mut Connection, id: &str, patch: UpdateCategory) -> Result< 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], + preview_limit = ?5, empty_text = ?6, allow_groups = ?7 + WHERE key = ?8", + params![ + title, + icon, + bar_color, + kind, + preview_limit, + empty_text, + allow_groups, + id + ], ) .map_err(map_error)?; @@ -361,21 +377,21 @@ pub fn create_group(conn: &mut Connection, section_id: &str, new: NewGroup) -> R return Err(StashError::InvalidDirectory("分组标题需为 1–40 个字".into())); } - let view_mode: Option = conn + let allow_groups: Option = conn .query_row( - "SELECT view_mode FROM node WHERE key = ?1 AND kind = 'section'", + "SELECT allow_groups 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 { + let Some(allow_groups) = allow_groups else { return Err(StashError::NotFound(format!("分区不存在: {section_id}"))); }; - if view_mode != "apps" { + if !allow_groups { return Err(StashError::InvalidDirectory( - "只有应用类分区可以添加分组".into(), + "该分区未开启「允许子分组」".into(), )); } @@ -500,7 +516,8 @@ mod tests { preview_limit INTEGER, empty_text TEXT, sort INTEGER NOT NULL DEFAULT 0, - show_in_sidebar INTEGER NOT NULL DEFAULT 1 + show_in_sidebar INTEGER NOT NULL DEFAULT 1, + allow_groups INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE entry ( key TEXT PRIMARY KEY, @@ -635,7 +652,7 @@ mod tests { } #[test] - fn switching_apps_to_posters_moves_card_back_to_section() { + fn switching_apps_to_posters_keeps_groups_until_disabled() { let mut conn = db(); create(&mut conn, section("books", "书单", "apps")).expect("应能创建"); conn.execute( @@ -643,28 +660,45 @@ mod tests { [], ) .expect("应能建分组"); - conn.execute( - "INSERT INTO entry (key, node_key) VALUES ('b1', 'books-g')", - [], - ) - .expect("应能建条目"); + conn.execute("INSERT INTO entry (key, node_key) VALUES ('b1', 'books-g')", []) + .expect("应能建条目"); + // create() 会顺手建一张分区卡片;这里只保留分组卡片,模拟真实配置 + conn.execute("DELETE FROM section_block", []).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("应能改类型"); - + // 类型切到海报但子分组仍开启:分组和卡片都保留 + 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)) + assert_eq!(block, "books-g"); + + // 关闭子分组:卡片挪回分区 + update( + &mut conn, + "books", + UpdateCategory { + allow_groups: Some(false), + ..Default::default() + }, + ) + .expect("应能关闭"); + let block: String = conn + .query_row("SELECT node_key FROM section_block", [], |row| row.get(0)) .expect("应能查"); - assert_eq!(mode, "posters"); + assert_eq!(block, "books", "关闭子分组后卡片应指回分区"); } #[test] @@ -725,8 +759,28 @@ mod tests { } ) .is_err(), - "海报 / 图文分区不提供分组" + "未开启子分组的分区不能建组" ); + + // 打开「允许子分组」后,海报分区也可以建组 + update( + &mut conn, + "films", + UpdateCategory { + allow_groups: Some(true), + ..Default::default() + }, + ) + .expect("应能开启"); + create_group( + &mut conn, + "films", + NewGroup { + id: "g1".into(), + title: "片单".into(), + }, + ) + .expect("开启后应能建组"); } #[test] @@ -740,8 +794,16 @@ mod tests { ) .expect("应能建卡片"); - update(&mut conn, "films", UpdateCategory { kind: Some("apps".into()), ..Default::default() }) - .expect("应能改类型"); + update( + &mut conn, + "films", + UpdateCategory { + kind: Some("apps".into()), + allow_groups: Some(true), + ..Default::default() + }, + ) + .expect("应能改类型"); let (entry_node, block_node): (String, String) = conn .query_row( diff --git a/src/db/mod.rs b/src/db/mod.rs index e4d585f..588f21d 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -600,7 +600,7 @@ mod tests { let fresh = Connection::open_in_memory().expect("内存库应能打开"); fresh .execute_batch( - "CREATE TABLE node (key TEXT PRIMARY KEY); + "CREATE TABLE node (key TEXT PRIMARY KEY, parent_key TEXT, kind TEXT, view_mode TEXT); CREATE TABLE entry (key TEXT PRIMARY KEY, node_key TEXT NOT NULL, name TEXT NOT NULL); PRAGMA user_version = 15;", ) diff --git a/src/db/schema.rs b/src/db/schema.rs index 8a4d3cb..40b3a20 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -46,6 +46,8 @@ CREATE TABLE IF NOT EXISTS node ( empty_text TEXT, -- 分区为空时的提示文案 show_in_sidebar INTEGER NOT NULL DEFAULT 1 CHECK (show_in_sidebar IN (0, 1)), -- 是否出现在侧栏:0 表示只当首页卡片/内容,不进导航 + allow_groups INTEGER NOT NULL DEFAULT 0 CHECK (allow_groups IN (0, 1)), + -- 是否允许子分组(与类型无关,后台开关) sort INTEGER NOT NULL DEFAULT 0, -- 同级顺序,越小越靠前 CHECK ( (kind = 'section' AND parent_key IS NULL AND view_mode IS NOT NULL) @@ -154,6 +156,22 @@ CREATE TABLE IF NOT EXISTS audit ( ); "#; +/// v17 → v18:分区增加「允许子分组」开关(与类型无关)。 +/// +/// 老库默认保持原行为:应用类开启,其余类型关闭;已经有分组的非应用分区也开启。 +const V18_SQL: &str = r#" +ALTER TABLE node ADD COLUMN allow_groups INTEGER NOT NULL DEFAULT 1; + +UPDATE node SET allow_groups = 0 +WHERE kind = 'section' AND view_mode <> 'apps'; + +UPDATE node SET allow_groups = 1 +WHERE kind = 'section' AND key IN ( + SELECT DISTINCT parent_key FROM node + WHERE kind = 'group' AND parent_key IS NOT NULL +); +"#; + /// v15 → v16 的增量(老库升级用;新库直接走 SCHEMA_SQL)。 const V16_SQL: &str = r#" CREATE TABLE IF NOT EXISTS admin ( @@ -195,7 +213,7 @@ pub struct SeedReport { /// /// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来, /// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。 -pub const SCHEMA_VERSION: i64 = 17; +pub const SCHEMA_VERSION: i64 = 18; fn current_version(conn: &Connection) -> Result { conn.pragma_query_value(None, "user_version", |row| row.get(0)) @@ -239,6 +257,11 @@ pub fn migrate(conn: &Connection) -> Result { version = 17; } + if version == 17 { + conn.execute_batch(V18_SQL).map_err(map_error)?; + version = 18; + } + conn.pragma_update(None, "user_version", version) .map_err(map_error)?; @@ -283,8 +306,8 @@ pub fn seed(conn: &mut Connection) -> Result { } tx.execute( - "INSERT INTO node (key, parent_key, title, kind, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar, sort) - VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + "INSERT INTO node (key, parent_key, title, kind, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar, allow_groups, sort) + VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", params![ section.key, section.title, @@ -294,6 +317,7 @@ pub fn seed(conn: &mut Connection) -> Result { section.preview_limit, section.empty_text, i64::from(section.show_in_sidebar), + i64::from(view == "apps"), section.sort, ], ) diff --git a/stash-web/src/App.css b/stash-web/src/App.css index f6b52dc..d40ba49 100644 --- a/stash-web/src/App.css +++ b/stash-web/src/App.css @@ -985,11 +985,18 @@ /* 归类页:一行一个分区,标题 / 类型 / 图标 / 色条 / 条数 / 保存 / 删除 */ .admin-category-row { display: grid; - grid-template-columns: minmax(0, 1.4fr) 150px 80px 110px 90px auto auto; + grid-template-columns: minmax(0, 1.4fr) 150px 80px 110px 90px auto auto auto; align-items: center; gap: var(--su-space-2); } +.admin-toggle { + display: flex; + align-items: center; + gap: var(--su-space-2); + white-space: nowrap; +} + .admin-category-row > * { min-width: 0; } diff --git a/stash-web/src/components/admin/AdminPage.tsx b/stash-web/src/components/admin/AdminPage.tsx index efa53f9..48890fd 100644 --- a/stash-web/src/components/admin/AdminPage.tsx +++ b/stash-web/src/components/admin/AdminPage.tsx @@ -66,6 +66,8 @@ type CategorySummary = { icon?: string barColor?: string previewLimit?: number + /** 是否允许子分组(与类型无关的开关) */ + allowGroups?: boolean /** 首页卡片标题与预览节点 */ block?: { title?: string; nodeKey?: string } | null /** 应用类分区下的分组(子分类) */ @@ -539,7 +541,7 @@ function SidebarForm({ ))} - {category.kind === 'apps' && ( + {category.allowGroups === true && (
@@ -751,8 +753,9 @@ function CategoryManager({ return ( - 分区决定内容归到哪一类(应用 / 海报 / 图文)。应用类分区下面可以建分组(子分类), - 分组会出现在侧栏,也可以作为首页卡片的预览来源;改类型会自动迁移条目。 + 三种类型使用同一套模板:分区决定内容归到哪一类(应用 / 海报 / 图文), + 「子分组」开关决定要不要分组。开启后可建分组、分组会出现在侧栏,也可以作为首页卡片预览; + 关闭后条目按平铺渲染,已有分组里的内容不会丢。 {categories.map((category) => ( @@ -762,7 +765,7 @@ function CategoryManager({ onReload={onReload} onDelete={onDelete} /> - {category.kind === 'apps' && ( + {category.allowGroups === true && ( <> @@ -794,6 +797,7 @@ function CategoryRow({ const [limit, setLimit] = useState( category.previewLimit === undefined ? '' : String(category.previewLimit), ) + const [allowGroups, setAllowGroups] = useState(category.allowGroups === true) const [busy, setBusy] = useState(false) const save = async () => { @@ -804,6 +808,7 @@ function CategoryRow({ icon, barColor: color, previewLimit: limit.trim() === '' ? undefined : Number(limit), + allowGroups, }) setBusy(false) @@ -848,6 +853,16 @@ function CategoryRow({ aria-label={`${category.title} 预览条数`} onChange={(event) => setLimit(event.target.value)} /> +
+ + + 子分组 + +
@@ -1275,7 +1290,7 @@ function EntryDialog({ const section = categories.find((category) => category.id === sectionId) ?? categories[0] - const groups = section?.kind === 'apps' ? (section.groups ?? []) : [] + const groups = section?.allowGroups === true ? (section.groups ?? []) : [] // 打开时重置:新建用当前选择,编辑先拉完整条目 useEffect(() => { @@ -1288,7 +1303,7 @@ function EntryDialog({ const startCategory = categories.find((category) => category.id === startSection) setGroupId( defaultLocation?.groupId ?? - (startCategory?.kind === 'apps' ? (startCategory.groups?.[0]?.id ?? '') : ''), + (startCategory?.allowGroups === true ? (startCategory.groups?.[0]?.id ?? '') : ''), ) if (entryKey === null) { @@ -1326,9 +1341,9 @@ function EntryDialog({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, entryKey]) - // 只有应用类分区有分组;其余类型始终是直属分区 + // 只有开启「允许子分组」的分区有分组;其余始终是直属分区 useEffect(() => { - if (section?.kind !== 'apps') { + if (section?.allowGroups !== true) { if (groupId !== '') setGroupId('') return } @@ -1340,7 +1355,7 @@ function EntryDialog({ const nodeKey = section === undefined ? '' - : section.kind === 'apps' && groupId !== '' + : section.allowGroups === true && groupId !== '' ? groupId : section.id @@ -1439,7 +1454,7 @@ function EntryDialog({ onChange={setSectionId} /> - {section?.kind === 'apps' && ( + {section?.allowGroups === true && (