diff --git a/src/db/catalog.rs b/src/db/catalog.rs index cea1330..cdeb10a 100644 --- a/src/db/catalog.rs +++ b/src/db/catalog.rs @@ -30,7 +30,6 @@ struct NodeRow { /// 条目行:与 `entry` 表的列一一对应 struct EntryRow { key: String, - node_key: String, name: String, description: Option, icon: Option, @@ -173,22 +172,48 @@ fn build_section( } match view_mode { - "apps" => object.insert("groups".into(), build_groups(conn, section)?), - "posters" => object.insert("items".into(), build_poster_items(conn, section)?), - "articles" => object.insert("items".into(), build_article_items(conn, section)?), + "apps" | "posters" | "articles" => {} other => { return Err(StashError::Database(format!( "分区 {} 的 view_mode 非法: {other}", section.key ))); } - }; + } + + // 所有类型统一:分组是可选子容器,条目可以挂在分组,也可以直属分区 + 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)); Ok(Value::Object(object)) } -/// 应用类:取出分组节点,再把条目按 `node_key` 分派进去。 -fn build_groups(conn: &Connection, section: &NodeRow) -> Result { +/// 按条目类型组装列表项。 +fn item_for(entry: &EntryRow, view_mode: &str, tags: &HashMap>) -> Value { + match view_mode { + "posters" => poster_item(entry, tags.get(&entry.key)), + "articles" => article_item(entry, tags.get(&entry.key)), + _ => app_item(entry), + } +} + +/// 取出某个分区下的分组,并把条目按 `node_key` 分派进去。 +fn build_groups( + conn: &Connection, + section: &NodeRow, + view_mode: &str, + tags: &HashMap>, +) -> Result { let mut stmt = conn .prepare( "SELECT key, title, show_in_sidebar FROM node @@ -211,15 +236,12 @@ fn build_groups(conn: &Connection, section: &NodeRow) -> Result>(); rendered_groups.push(json!({ @@ -234,32 +256,6 @@ fn build_groups(conn: &Connection, section: &NodeRow) -> Result Result { - let entries = load_entries(conn, §ion.key)?; - let tags = load_tags(conn, §ion.key)?; - - let items = entries - .iter() - .map(|entry| poster_item(entry, tags.get(&entry.key))) - .collect::>(); - - Ok(Value::Array(items)) -} - -/// 图文类:需要 `title` / `desc` / `cover` / `meta` / `body`。 -fn build_article_items(conn: &Connection, section: &NodeRow) -> Result { - let entries = load_entries(conn, §ion.key)?; - let tags = load_tags(conn, §ion.key)?; - - let items = entries - .iter() - .map(|entry| article_item(entry, tags.get(&entry.key))) - .collect::>(); - - Ok(Value::Array(items)) -} - /// 应用条目:`name` / `desc` / `icon` / `href` 是必填,其余按需下发。 fn app_item(entry: &EntryRow) -> Value { let mut object = Map::new(); @@ -345,36 +341,34 @@ fn insert_optional(object: &mut Map, key: &str, value: &Option Result, StashError> { +fn load_node_entries(conn: &Connection, node_key: &str) -> Result, StashError> { let mut stmt = conn .prepare( - "SELECT e.key, e.node_key, e.name, e.description, e.icon, e.href, e.cover, + "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 + WHERE e.node_key = ?1 ORDER BY e.sort, e.key", ) .map_err(map_error)?; let entries = stmt - .query_map([section_key], |row| { + .query_map([node_key], |row| { Ok(EntryRow { key: row.get(0)?, - node_key: row.get(1)?, - name: row.get(2)?, - description: row.get(3)?, - icon: row.get(4)?, - href: row.get(5)?, - cover: row.get(6)?, - banner: row.get(7)?, - badge: row.get(8)?, - comment: row.get(9)?, - summary: row.get(10)?, - body: row.get(11)?, - hot: row.get(12)?, - views: row.get(13)?, - likes: row.get(14)?, + 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)? diff --git a/src/db/categories.rs b/src/db/categories.rs index afe6260..8f23029 100644 --- a/src/db/categories.rs +++ b/src/db/categories.rs @@ -361,22 +361,17 @@ 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 section_exists: Option = conn .query_row( - "SELECT view_mode FROM node WHERE key = ?1 AND kind = 'section'", + "SELECT key 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 { + if section_exists.is_none() { return Err(StashError::NotFound(format!("分区不存在: {section_id}"))); - }; - if view_mode != "apps" { - return Err(StashError::InvalidDirectory( - "只有应用类分区可以添加分组".into(), - )); } let exists: i64 = conn @@ -712,21 +707,18 @@ mod tests { } #[test] - fn groups_only_under_apps_sections() { + fn groups_under_any_kind_of_section() { 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(), - "海报分区不能建分组" - ); + create_group( + &mut conn, + "films", + NewGroup { + id: "g1".into(), + title: "片单".into(), + }, + ) + .expect("海报分区也应能建分组"); } #[test] diff --git a/src/db/entries.rs b/src/db/entries.rs index 6709633..6a5f197 100644 --- a/src/db/entries.rs +++ b/src/db/entries.rs @@ -223,32 +223,29 @@ 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 + let row: Option<(String, Option)> = conn .query_row( - "SELECT n.kind, n.view_mode, p.view_mode + "SELECT n.kind, p.kind 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)?)), + |row| Ok((row.get(0)?, row.get(1)?)), ) .optional() .map_err(map_error)?; - let Some((kind, view_mode, parent_mode)) = row else { + let Some((kind, parent_kind)) = 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(), + match (kind.as_str(), parent_kind.as_deref()) { + ("section", _) => Ok(()), + ("group", Some("section")) => Ok(()), + ("group", _) => Err(StashError::InvalidDirectory( + "分组的父节点必须是分区".into(), )), - "section" if view_mode.as_deref() == Some("apps") => Err(StashError::InvalidDirectory( - "应用类条目必须选择一个分组".into(), - )), - "section" => Ok(()), _ => Err(StashError::InvalidDirectory( "条目只能挂在分区或分组下".into(), )), @@ -634,18 +631,17 @@ mod tests { .expect("应能查"); assert_eq!(node, "g1"); - // 应用分区不能直接挂条目,必须先选分组 - assert!( - create( - &mut conn, - NewEntry { - key: "bad-site".into(), - node_key: "apps1".into(), - item: patch("错误"), - } - ) - .is_err() - ); + // 任何类型的分区都允许条目直属分区 + let direct = create( + &mut conn, + NewEntry { + key: "direct-site".into(), + node_key: "apps1".into(), + item: patch("直属条目"), + }, + ) + .expect("应能创建"); + assert_eq!(direct.key, "direct-site"); } #[test] diff --git a/stash-web/src/App.tsx b/stash-web/src/App.tsx index 1d332d2..4f412df 100644 --- a/stash-web/src/App.tsx +++ b/stash-web/src/App.tsx @@ -177,7 +177,7 @@ export default function App() { const detailViewFor = (categoryId: string): View => { const category = categories.find((item) => item.id === categoryId) - if (category?.kind === 'apps' && category.block !== undefined) { + if (category?.block !== undefined) { const group = category.groups.find((item) => item.id === category.block?.nodeKey) if (group !== undefined) { return { type: 'detail', categoryId, groupId: group.id } @@ -189,9 +189,8 @@ export default function App() { const openDetail = (categoryId: string) => go(detailViewFor(categoryId)) - /** 条目所在的分组:应用按组组织,海报 / 图文直接挂在分区下 */ + /** 条目所在的分组:应用 / 海报 / 图文都可能有分组 */ const groupOfItem = (category: Category, itemId: string): string | undefined => { - if (category.kind !== 'apps') return undefined return category.groups.find((group) => group.items.some((item) => item.id === itemId))?.id } diff --git a/stash-web/src/components/admin/AdminPage.tsx b/stash-web/src/components/admin/AdminPage.tsx index ca56bdd..66c97df 100644 --- a/stash-web/src/components/admin/AdminPage.tsx +++ b/stash-web/src/components/admin/AdminPage.tsx @@ -503,7 +503,7 @@ function SidebarForm({ {ordered.map((category, index) => { const visible = !value.hidden.includes(category.id) - const groups = category.kind === 'apps' ? orderedGroups(category) : [] + const groups = orderedGroups(category) return (
@@ -539,7 +539,7 @@ function SidebarForm({
))} - {category.kind === 'apps' && ( + {(
@@ -762,12 +762,8 @@ function CategoryManager({ onReload={onReload} onDelete={onDelete} /> - {category.kind === 'apps' && ( - <> - - - - )} + + ))} @@ -1094,20 +1090,21 @@ function ContentManager({ const entries: EntryRow[] = section === undefined ? [] - : section.kind === 'apps' - ? (section.groups ?? []).flatMap((group) => + : [ + ...(section.groups ?? []).flatMap((group) => (group.items ?? []).map((item) => ({ ...item, groupName: group.name, sectionId: section.id, groupId: group.id, })), - ) - : (section.items ?? []).map((item) => ({ + ), + ...(section.items ?? []).map((item) => ({ ...item, groupName: undefined, sectionId: section.id, - })) + })), + ] const removeEntry = async () => { if (pendingEntry === null) return @@ -1274,7 +1271,7 @@ function EntryDialog({ const section = categories.find((category) => category.id === sectionId) ?? categories[0] - const groups = section?.kind === 'apps' ? (section.groups ?? []) : [] + const groups = section?.groups ?? [] // 打开时重置:新建用当前选择,编辑先拉完整条目 useEffect(() => { @@ -1284,7 +1281,11 @@ function EntryDialog({ const startSection = defaultLocation?.sectionId ?? categories[0]?.id ?? '' setSectionId(startSection) - setGroupId(defaultLocation?.groupId ?? '') + setGroupId( + defaultLocation?.groupId ?? + categories.find((category) => category.id === startSection)?.groups?.[0]?.id ?? + '', + ) if (entryKey === null) { setLoading(false) @@ -1321,19 +1322,16 @@ function EntryDialog({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, entryKey]) - // 分区切换后保证分组仍然合法 + // 分区切换后保证分组仍然合法(空字符串 = 直属分区) useEffect(() => { - if (section?.kind !== 'apps') { - setGroupId('') - return - } - if (!groups.some((group) => group.id === groupId)) { + if (section === undefined) return + if (groupId !== '' && !groups.some((group) => group.id === groupId)) { setGroupId(groups[0]?.id ?? '') } }, [section, groups, groupId]) const nodeKey = - section === undefined ? '' : section.kind === 'apps' ? groupId : section.id + section === undefined ? '' : groupId !== '' ? groupId : section.id const update = (patch: Partial) => setForm((previous) => ({ ...previous, ...patch })) @@ -1430,13 +1428,16 @@ function EntryDialog({ onChange={setSectionId} /> - {section?.kind === 'apps' && ( -
+ {section !== undefined && ( +