feat(content): 分组对所有内容类型生效,统一分区/分组/条目模型
- 分区=容器,分组=任意类型的可选子容器,条目可挂分区或分组(直属分区) - catalog 所有类型统一输出 groups + items,条目查询按节点取 - 详情/侧栏/首页卡片预览支持所有类型的分组;多组显示小标题 + 未分组 - 后台「分区」「侧栏导航」对所有类型可建组;「内容」弹窗分组可选直属分区,编辑可换组 - 修复首页卡片配置为「整个分区」时预览为空 - 测试 59 passed
This commit is contained in:
+51
-57
@@ -30,7 +30,6 @@ struct NodeRow {
|
||||
/// 条目行:与 `entry` 表的列一一对应
|
||||
struct EntryRow {
|
||||
key: String,
|
||||
node_key: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
icon: Option<String>,
|
||||
@@ -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::<Vec<_>>();
|
||||
object.insert("items".into(), json!(items));
|
||||
|
||||
Ok(Value::Object(object))
|
||||
}
|
||||
|
||||
/// 应用类:取出分组节点,再把条目按 `node_key` 分派进去。
|
||||
fn build_groups(conn: &Connection, section: &NodeRow) -> Result<Value, StashError> {
|
||||
/// 按条目类型组装列表项。
|
||||
fn item_for(entry: &EntryRow, view_mode: &str, tags: &HashMap<String, Vec<String>>) -> 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<String, Vec<String>>,
|
||||
) -> Result<Value, StashError> {
|
||||
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<Value, StashErro
|
||||
|
||||
drop(stmt);
|
||||
|
||||
let entries = load_entries(conn, §ion.key)?;
|
||||
let mut rendered_groups = Vec::with_capacity(groups.len());
|
||||
|
||||
for (group_key, group_title, show_in_sidebar) in groups {
|
||||
let entries = load_node_entries(conn, &group_key)?;
|
||||
let items = entries
|
||||
.iter()
|
||||
// 保持 entry.sort 决定的顺序
|
||||
.filter(|entry| entry.node_key == group_key)
|
||||
.map(app_item)
|
||||
.map(|entry| item_for(entry, view_mode, tags))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
rendered_groups.push(json!({
|
||||
@@ -234,32 +256,6 @@ fn build_groups(conn: &Connection, section: &NodeRow) -> Result<Value, StashErro
|
||||
Ok(Value::Array(rendered_groups))
|
||||
}
|
||||
|
||||
/// 海报类:竖版卡片要 `title` / `cover` / `tags`。
|
||||
fn build_poster_items(conn: &Connection, section: &NodeRow) -> Result<Value, StashError> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
Ok(Value::Array(items))
|
||||
}
|
||||
|
||||
/// 图文类:需要 `title` / `desc` / `cover` / `meta` / `body`。
|
||||
fn build_article_items(conn: &Connection, section: &NodeRow) -> Result<Value, StashError> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
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<String, Value>, key: &str, value: &Option<St
|
||||
///
|
||||
/// 条目只存 `node_key`,所属分区由父节点推导:
|
||||
/// 父节点是分组就取它的 `parent_key`,父节点是分区就取它自己。
|
||||
fn load_entries(conn: &Connection, section_key: &str) -> Result<Vec<EntryRow>, StashError> {
|
||||
fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>, 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)?
|
||||
|
||||
+13
-21
@@ -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<String> = conn
|
||||
let section_exists: Option<String> = 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]
|
||||
|
||||
+21
-25
@@ -223,32 +223,29 @@ pub fn load(conn: &Connection, key: &str) -> Result<Option<EntryRecord>, StashEr
|
||||
Ok(Some(entry))
|
||||
}
|
||||
|
||||
/// 条目能挂到哪:非应用分区本身,或应用分区下的分组。
|
||||
/// 条目能挂到哪:分区本身,或分区下的分组(任意类型都支持分组)。
|
||||
fn validate_target(conn: &Connection, node_key: &str) -> Result<(), StashError> {
|
||||
let row: Option<(String, Option<String>, Option<String>)> = conn
|
||||
let row: Option<(String, Option<String>)> = 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]
|
||||
|
||||
Reference in New Issue
Block a user