From 261d202ce0e738e810da599b6e03b968861c3ed0 Mon Sep 17 00:00:00 2001 From: CNWei Date: Fri, 30 Oct 2026 19:55:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E5=88=86=E7=BB=84=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E6=8C=89=E7=B1=BB=E5=9E=8B=E6=94=B6=E5=8F=A3=EF=BC=88?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E5=8F=AF=E5=88=86=E7=BB=84=EF=BC=8C=E6=B5=B7?= =?UTF-8?q?=E6=8A=A5/=E5=9B=BE=E6=96=87=E5=B9=B3=E9=93=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 只有应用类分区显示分组管理与卡片预览分组;海报/图文保持平铺 - 后端 create_group 拒绝非应用分区;海报/图文读取时把历史分组条目一并平铺 - 侧栏不再显示非应用分组与空分组 - 类型 应用 ↔ 海报 切换时分组入口和侧栏子项随之出现/消失 - 测试 59 passed --- src/db/catalog.rs | 77 +++++++++++++++---- src/db/categories.rs | 34 ++++---- stash-web/src/components/admin/AdminPage.tsx | 31 +++++--- .../src/components/sidebar/CategoryNav.tsx | 8 +- 4 files changed, 111 insertions(+), 39 deletions(-) diff --git a/src/db/catalog.rs b/src/db/catalog.rs index cdeb10a..180d1c2 100644 --- a/src/db/catalog.rs +++ b/src/db/catalog.rs @@ -181,19 +181,29 @@ fn build_section( } } - // 所有类型统一:分组是可选子容器,条目可以挂在分组,也可以直属分区 + // 应用类:分区 → 分组 → 条目;海报 / 图文是平铺列表,分组不参与渲染 let tags = load_tags(conn, §ion.key)?; - object.insert( - "groups".into(), - build_groups(conn, section, view_mode, &tags)?, - ); - - let direct = load_node_entries(conn, §ion.key)?; - let items = direct - .iter() - .map(|entry| item_for(entry, view_mode, &tags)) - .collect::>(); - object.insert("items".into(), json!(items)); + if view_mode == "apps" { + object.insert( + "groups".into(), + build_groups(conn, section, view_mode, &tags)?, + ); + let direct = load_node_entries(conn, §ion.key)?; + let items = direct + .iter() + .map(|entry| item_for(entry, view_mode, &tags)) + .collect::>(); + object.insert("items".into(), json!(items)); + } else { + object.insert("groups".into(), json!([])); + // 平铺:把(可能存在的)分组里的条目一起取出来,切换类型时不丢内容 + let entries = load_section_entries(conn, §ion.key)?; + let items = entries + .iter() + .map(|entry| item_for(entry, view_mode, &tags)) + .collect::>(); + object.insert("items".into(), json!(items)); + } Ok(Value::Object(object)) } @@ -341,8 +351,49 @@ fn insert_optional(object: &mut Map, key: &str, value: &Option Result, StashError> { +/// 取某个分区下的全部条目(含分组内的),海报 / 图文平铺用。 +fn load_section_entries( + conn: &Connection, + section_key: &str, +) -> Result, StashError> { let mut stmt = conn + .prepare( + "SELECT e.key, e.name, e.description, e.icon, e.href, e.cover, + e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes + FROM entry e + JOIN node p ON p.key = e.node_key + WHERE COALESCE(p.parent_key, p.key) = ?1 + ORDER BY e.sort, e.key", + ) + .map_err(map_error)?; + + let entries = stmt + .query_map([section_key], |row| { + Ok(EntryRow { + key: row.get(0)?, + name: row.get(1)?, + description: row.get(2)?, + icon: row.get(3)?, + href: row.get(4)?, + cover: row.get(5)?, + banner: row.get(6)?, + badge: row.get(7)?, + comment: row.get(8)?, + summary: row.get(9)?, + body: row.get(10)?, + hot: row.get(11)?, + views: row.get(12)?, + likes: row.get(13)?, + }) + }) + .map_err(map_error)? + .collect::, _>>() + .map_err(map_error)?; + + Ok(entries) +} + +fn load_node_entries(conn: &Connection, node_key: &str) -> Result, StashError> { let mut stmt = conn .prepare( "SELECT e.key, e.name, e.description, e.icon, e.href, e.cover, e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes diff --git a/src/db/categories.rs b/src/db/categories.rs index 8f23029..80ed452 100644 --- a/src/db/categories.rs +++ b/src/db/categories.rs @@ -361,17 +361,22 @@ pub fn create_group(conn: &mut Connection, section_id: &str, new: NewGroup) -> R return Err(StashError::InvalidDirectory("分组标题需为 1–40 个字".into())); } - let section_exists: Option = conn + let view_mode: Option = conn .query_row( - "SELECT key FROM node WHERE key = ?1 AND kind = 'section'", + "SELECT view_mode FROM node WHERE key = ?1 AND kind = 'section'", [section_id], |row| row.get(0), ) .optional() .map_err(map_error)?; - if section_exists.is_none() { + 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 @@ -707,18 +712,21 @@ mod tests { } #[test] - fn groups_under_any_kind_of_section() { + fn groups_only_under_apps_sections() { let mut conn = db(); create(&mut conn, section("films", "电影", "posters")).expect("应能创建"); - create_group( - &mut conn, - "films", - NewGroup { - id: "g1".into(), - title: "片单".into(), - }, - ) - .expect("海报分区也应能建分组"); + assert!( + create_group( + &mut conn, + "films", + NewGroup { + id: "g1".into(), + title: "片单".into() + } + ) + .is_err(), + "海报 / 图文分区不提供分组" + ); } #[test] diff --git a/stash-web/src/components/admin/AdminPage.tsx b/stash-web/src/components/admin/AdminPage.tsx index 66c97df..efa53f9 100644 --- a/stash-web/src/components/admin/AdminPage.tsx +++ b/stash-web/src/components/admin/AdminPage.tsx @@ -539,7 +539,7 @@ function SidebarForm({ ))} - {( + {category.kind === 'apps' && (
@@ -762,8 +762,12 @@ function CategoryManager({ onReload={onReload} onDelete={onDelete} /> - - + {category.kind === 'apps' && ( + <> + + + + )} ))} @@ -1271,7 +1275,7 @@ function EntryDialog({ const section = categories.find((category) => category.id === sectionId) ?? categories[0] - const groups = section?.groups ?? [] + const groups = section?.kind === 'apps' ? (section.groups ?? []) : [] // 打开时重置:新建用当前选择,编辑先拉完整条目 useEffect(() => { @@ -1281,10 +1285,10 @@ function EntryDialog({ const startSection = defaultLocation?.sectionId ?? categories[0]?.id ?? '' setSectionId(startSection) + const startCategory = categories.find((category) => category.id === startSection) setGroupId( defaultLocation?.groupId ?? - categories.find((category) => category.id === startSection)?.groups?.[0]?.id ?? - '', + (startCategory?.kind === 'apps' ? (startCategory.groups?.[0]?.id ?? '') : ''), ) if (entryKey === null) { @@ -1322,16 +1326,23 @@ function EntryDialog({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, entryKey]) - // 分区切换后保证分组仍然合法(空字符串 = 直属分区) + // 只有应用类分区有分组;其余类型始终是直属分区 useEffect(() => { - if (section === undefined) return + if (section?.kind !== 'apps') { + if (groupId !== '') setGroupId('') + return + } if (groupId !== '' && !groups.some((group) => group.id === groupId)) { setGroupId(groups[0]?.id ?? '') } }, [section, groups, groupId]) const nodeKey = - section === undefined ? '' : groupId !== '' ? groupId : section.id + section === undefined + ? '' + : section.kind === 'apps' && groupId !== '' + ? groupId + : section.id const update = (patch: Partial) => setForm((previous) => ({ ...previous, ...patch })) @@ -1428,7 +1439,7 @@ function EntryDialog({ onChange={setSectionId} /> - {section !== undefined && ( + {section?.kind === 'apps' && (