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)?; let tags = load_tags(conn, &section.key)?;
object.insert( if view_mode == "apps" {
"groups".into(), object.insert(
build_groups(conn, section, view_mode, &tags)?, "groups".into(),
); build_groups(conn, section, view_mode, &tags)?,
);
let direct = load_node_entries(conn, &section.key)?; let direct = load_node_entries(conn, &section.key)?;
let items = direct let items = direct
.iter() .iter()
.map(|entry| item_for(entry, view_mode, &tags)) .map(|entry| item_for(entry, view_mode, &tags))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
object.insert("items".into(), json!(items)); 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)) Ok(Value::Object(object))
} }
@@ -341,8 +351,49 @@ fn insert_optional(object: &mut Map<String, Value>, key: &str, value: &Option<St
/// ///
/// 条目只存 `node_key`,所属分区由父节点推导: /// 条目只存 `node_key`,所属分区由父节点推导:
/// 父节点是分组就取它的 `parent_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 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( .prepare(
"SELECT e.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 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())); return Err(StashError::InvalidDirectory("分组标题需为 1–40 个字".into()));
} }
let section_exists: Option<String> = conn let view_mode: Option<String> = conn
.query_row( .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], [section_id],
|row| row.get(0), |row| row.get(0),
) )
.optional() .optional()
.map_err(map_error)?; .map_err(map_error)?;
if section_exists.is_none() { let Some(view_mode) = view_mode else {
return Err(StashError::NotFound(format!("分区不存在: {section_id}"))); return Err(StashError::NotFound(format!("分区不存在: {section_id}")));
};
if view_mode != "apps" {
return Err(StashError::InvalidDirectory(
"只有应用类分区可以添加分组".into(),
));
} }
let exists: i64 = conn let exists: i64 = conn
@@ -707,18 +712,21 @@ mod tests {
} }
#[test] #[test]
fn groups_under_any_kind_of_section() { fn groups_only_under_apps_sections() {
let mut conn = db(); let mut conn = db();
create(&mut conn, section("films", "电影", "posters")).expect("应能创建"); create(&mut conn, section("films", "电影", "posters")).expect("应能创建");
create_group( assert!(
&mut conn, create_group(
"films", &mut conn,
NewGroup { "films",
id: "g1".into(), NewGroup {
title: "片单".into(), id: "g1".into(),
}, title: "片单".into()
) }
.expect("海报分区也应能建分组"); )
.is_err(),
"海报 / 图文分区不提供分组"
);
} }
#[test] #[test]
+21 -10
View File
@@ -539,7 +539,7 @@ function SidebarForm({
</div> </div>
))} ))}
{( {category.kind === 'apps' && (
<div className="admin-nav-group-row"> <div className="admin-nav-group-row">
<NewGroupRow sectionId={category.id} onReload={onReload} /> <NewGroupRow sectionId={category.id} onReload={onReload} />
</div> </div>
@@ -762,8 +762,12 @@ function CategoryManager({
onReload={onReload} onReload={onReload}
onDelete={onDelete} onDelete={onDelete}
/> />
<BlockSelect section={category} onReload={onReload} /> {category.kind === 'apps' && (
<GroupManager section={category} onReload={onReload} /> <>
<BlockSelect section={category} onReload={onReload} />
<GroupManager section={category} onReload={onReload} />
</>
)}
</div> </div>
))} ))}
@@ -1271,7 +1275,7 @@ function EntryDialog({
const section = const section =
categories.find((category) => category.id === sectionId) ?? categories[0] categories.find((category) => category.id === sectionId) ?? categories[0]
const groups = section?.groups ?? [] const groups = section?.kind === 'apps' ? (section.groups ?? []) : []
// 打开时重置:新建用当前选择,编辑先拉完整条目 // 打开时重置:新建用当前选择,编辑先拉完整条目
useEffect(() => { useEffect(() => {
@@ -1281,10 +1285,10 @@ function EntryDialog({
const startSection = const startSection =
defaultLocation?.sectionId ?? categories[0]?.id ?? '' defaultLocation?.sectionId ?? categories[0]?.id ?? ''
setSectionId(startSection) setSectionId(startSection)
const startCategory = categories.find((category) => category.id === startSection)
setGroupId( setGroupId(
defaultLocation?.groupId ?? defaultLocation?.groupId ??
categories.find((category) => category.id === startSection)?.groups?.[0]?.id ?? (startCategory?.kind === 'apps' ? (startCategory.groups?.[0]?.id ?? '') : ''),
'',
) )
if (entryKey === null) { if (entryKey === null) {
@@ -1322,16 +1326,23 @@ function EntryDialog({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, entryKey]) }, [open, entryKey])
// 分区切换后保证分组仍然合法(空字符串 = 直属分区) // 只有应用类分区有分组;其余类型始终是直属分区
useEffect(() => { useEffect(() => {
if (section === undefined) return if (section?.kind !== 'apps') {
if (groupId !== '') setGroupId('')
return
}
if (groupId !== '' && !groups.some((group) => group.id === groupId)) { if (groupId !== '' && !groups.some((group) => group.id === groupId)) {
setGroupId(groups[0]?.id ?? '') setGroupId(groups[0]?.id ?? '')
} }
}, [section, groups, groupId]) }, [section, groups, groupId])
const nodeKey = const nodeKey =
section === undefined ? '' : groupId !== '' ? groupId : section.id section === undefined
? ''
: section.kind === 'apps' && groupId !== ''
? groupId
: section.id
const update = (patch: Partial<EntryForm>) => const update = (patch: Partial<EntryForm>) =>
setForm((previous) => ({ ...previous, ...patch })) setForm((previous) => ({ ...previous, ...patch }))
@@ -1428,7 +1439,7 @@ function EntryDialog({
onChange={setSectionId} onChange={setSectionId}
/> />
</div> </div>
{section !== undefined && ( {section?.kind === 'apps' && (
<div style={{ width: 240 }}> <div style={{ width: 240 }}>
<Select <Select
options={[ options={[
@@ -38,9 +38,11 @@ export function CategoryNav({
const items: MenuItem[] = [ const items: MenuItem[] = [
{ key: HOME_KEY, label: '首页' }, { key: HOME_KEY, label: '首页' },
...categories.map((category) => { ...categories.map((category) => {
// 分组已由 App 按 /admin 的侧栏设置过滤、排序(含 show_in_sidebar 覆盖); // 只有应用类有分组;空分组不出现在侧栏(后台仍保留)
// 应用 / 海报 / 图文都支持分组 const groups =
const groups = category.groups category.kind === 'apps'
? category.groups.filter((group) => group.items.length > 0)
: []
return { return {
key: categoryKey(category.id), key: categoryKey(category.id),