feat(admin): 分组能力按类型收口(应用可分组,海报/图文平铺)

- 只有应用类分区显示分组管理与卡片预览分组;海报/图文保持平铺
- 后端 create_group 拒绝非应用分区;海报/图文读取时把历史分组条目一并平铺
- 侧栏不再显示非应用分组与空分组
- 类型 应用 ↔ 海报 切换时分组入口和侧栏子项随之出现/消失
- 测试 59 passed
This commit is contained in:
2026-10-30 19:55:45 +08:00
parent 1a4a6a3655
commit 261d202ce0
4 changed files with 111 additions and 39 deletions
+64 -13
View File
@@ -181,19 +181,29 @@ fn build_section(
}
}
// 所有类型统一:分组是可选子容器,条目可以挂在分组,也可以直属分区
// 应用类:分区 → 分组 → 条目;海报 / 图文是平铺列表,分组不参与渲染
let tags = load_tags(conn, &section.key)?;
object.insert(
"groups".into(),
build_groups(conn, section, view_mode, &tags)?,
);
let direct = load_node_entries(conn, &section.key)?;
let items = direct
.iter()
.map(|entry| item_for(entry, view_mode, &tags))
.collect::<Vec<_>>();
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, &section.key)?;
let items = direct
.iter()
.map(|entry| item_for(entry, view_mode, &tags))
.collect::<Vec<_>>();
object.insert("items".into(), json!(items));
} else {
object.insert("groups".into(), json!([]));
// 平铺:把(可能存在的)分组里的条目一起取出来,切换类型时不丢内容
let entries = load_section_entries(conn, &section.key)?;
let items = entries
.iter()
.map(|entry| item_for(entry, view_mode, &tags))
.collect::<Vec<_>>();
object.insert("items".into(), json!(items));
}
Ok(Value::Object(object))
}
@@ -341,8 +351,49 @@ fn insert_optional(object: &mut Map<String, Value>, key: &str, value: &Option<St
///
/// 条目只存 `node_key`,所属分区由父节点推导:
/// 父节点是分组就取它的 `parent_key`,父节点是分区就取它自己。
fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>, StashError> {
/// 取某个分区下的全部条目(含分组内的),海报 / 图文平铺用。
fn load_section_entries(
conn: &Connection,
section_key: &str,
) -> Result<Vec<EntryRow>, 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::<Result<Vec<_>, _>>()
.map_err(map_error)?;
Ok(entries)
}
fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>, 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
+21 -13
View File
@@ -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<String> = conn
let view_mode: Option<String> = 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]