feat(admin): 分组能力按类型收口(应用可分组,海报/图文平铺)
- 只有应用类分区显示分组管理与卡片预览分组;海报/图文保持平铺 - 后端 create_group 拒绝非应用分区;海报/图文读取时把历史分组条目一并平铺 - 侧栏不再显示非应用分组与空分组 - 类型 应用 ↔ 海报 切换时分组入口和侧栏子项随之出现/消失 - 测试 59 passed
This commit is contained in:
+64
-13
@@ -181,19 +181,29 @@ fn build_section(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 所有类型统一:分组是可选子容器,条目可以挂在分组,也可以直属分区
|
// 应用类:分区 → 分组 → 条目;海报 / 图文是平铺列表,分组不参与渲染
|
||||||
let tags = load_tags(conn, §ion.key)?;
|
let tags = load_tags(conn, §ion.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, §ion.key)?;
|
let direct = load_node_entries(conn, §ion.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, §ion.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
@@ -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]
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user