feat(admin): 分区增加「允许子分组」开关(与类型解耦)
- node 新增 allow_groups(迁移 v18):应用默认开、海报/图文默认关,已有分组的非应用分区自动开启 - 后台每行一个「子分组」开关;分组管理、卡片预览、内容弹窗选组、侧栏建组都跟随开关 - 关闭开关时条目平铺且不丢内容;首页卡片指向分组会自动挪回分区 - 三种类型共用同一模板,类型只决定渲染方式 - 测试 59 passed(迁移 15→18、开关建组、类型切换)
This commit is contained in:
+7
-4
@@ -25,6 +25,7 @@ struct NodeRow {
|
|||||||
preview_limit: Option<i64>,
|
preview_limit: Option<i64>,
|
||||||
empty_text: Option<String>,
|
empty_text: Option<String>,
|
||||||
show_in_sidebar: bool,
|
show_in_sidebar: bool,
|
||||||
|
allow_groups: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 条目行:与 `entry` 表的列一一对应
|
/// 条目行:与 `entry` 表的列一一对应
|
||||||
@@ -56,7 +57,7 @@ pub fn load(conn: &Connection) -> Result<Catalog, StashError> {
|
|||||||
fn load_sections(conn: &Connection) -> Result<Vec<Value>, StashError> {
|
fn load_sections(conn: &Connection) -> Result<Vec<Value>, StashError> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT key, title, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar
|
"SELECT key, title, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar, allow_groups
|
||||||
FROM node
|
FROM node
|
||||||
WHERE kind = 'section'
|
WHERE kind = 'section'
|
||||||
ORDER BY sort, key",
|
ORDER BY sort, key",
|
||||||
@@ -74,6 +75,7 @@ fn load_sections(conn: &Connection) -> Result<Vec<Value>, StashError> {
|
|||||||
preview_limit: row.get(5)?,
|
preview_limit: row.get(5)?,
|
||||||
empty_text: row.get(6)?,
|
empty_text: row.get(6)?,
|
||||||
show_in_sidebar: row.get(7)?,
|
show_in_sidebar: row.get(7)?,
|
||||||
|
allow_groups: row.get(8)?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(map_error)?
|
.map_err(map_error)?
|
||||||
@@ -159,6 +161,7 @@ fn build_section(
|
|||||||
);
|
);
|
||||||
object.insert("icon".into(), json!(section.icon.clone().unwrap_or_default()));
|
object.insert("icon".into(), json!(section.icon.clone().unwrap_or_default()));
|
||||||
object.insert("showInSidebar".into(), json!(section.show_in_sidebar));
|
object.insert("showInSidebar".into(), json!(section.show_in_sidebar));
|
||||||
|
object.insert("allowGroups".into(), json!(section.allow_groups));
|
||||||
|
|
||||||
// 可选字段:没配就不下发,交给前端用 `?? 默认值` 处理
|
// 可选字段:没配就不下发,交给前端用 `?? 默认值` 处理
|
||||||
if let Some(limit) = section.preview_limit {
|
if let Some(limit) = section.preview_limit {
|
||||||
@@ -181,9 +184,9 @@ fn build_section(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 应用类:分区 → 分组 → 条目;海报 / 图文是平铺列表,分组不参与渲染
|
// 「允许子分组」开启:分区 → 分组 → 条目;关闭:全部条目平铺
|
||||||
let tags = load_tags(conn, §ion.key)?;
|
let tags = load_tags(conn, §ion.key)?;
|
||||||
if view_mode == "apps" {
|
if section.allow_groups {
|
||||||
object.insert(
|
object.insert(
|
||||||
"groups".into(),
|
"groups".into(),
|
||||||
build_groups(conn, section, view_mode, &tags)?,
|
build_groups(conn, section, view_mode, &tags)?,
|
||||||
@@ -196,7 +199,7 @@ fn build_section(
|
|||||||
object.insert("items".into(), json!(items));
|
object.insert("items".into(), json!(items));
|
||||||
} else {
|
} else {
|
||||||
object.insert("groups".into(), json!([]));
|
object.insert("groups".into(), json!([]));
|
||||||
// 平铺:把(可能存在的)分组里的条目一起取出来,切换类型时不丢内容
|
// 平铺:把(可能存在的)分组里的条目一起取出来,关闭开关时不丢内容
|
||||||
let entries = load_section_entries(conn, §ion.key)?;
|
let entries = load_section_entries(conn, §ion.key)?;
|
||||||
let items = entries
|
let items = entries
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
+95
-33
@@ -32,6 +32,8 @@ pub struct UpdateCategory {
|
|||||||
pub empty_text: Option<String>,
|
pub empty_text: Option<String>,
|
||||||
/// 首页卡片预览哪个节点(分区自己或它下面的分组)
|
/// 首页卡片预览哪个节点(分区自己或它下面的分组)
|
||||||
pub block_node_key: Option<String>,
|
pub block_node_key: Option<String>,
|
||||||
|
/// 是否允许子分组(与类型无关,后台开关)
|
||||||
|
pub allow_groups: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 新建分组(子分类)
|
/// 新建分组(子分类)
|
||||||
@@ -90,9 +92,9 @@ pub fn create(conn: &mut Connection, new: NewCategory) -> Result<(), StashError>
|
|||||||
.map_err(map_error)?;
|
.map_err(map_error)?;
|
||||||
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO node (key, parent_key, title, kind, view_mode, sort, show_in_sidebar)
|
"INSERT INTO node (key, parent_key, title, kind, view_mode, allow_groups, sort, show_in_sidebar)
|
||||||
VALUES (?1, NULL, ?2, 'section', ?3, ?4, 1)",
|
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, 1)",
|
||||||
params![id, title, new.kind, sort],
|
params![id, title, new.kind, i64::from(new.kind == "apps"), sort],
|
||||||
)
|
)
|
||||||
.map_err(map_error)?;
|
.map_err(map_error)?;
|
||||||
|
|
||||||
@@ -121,6 +123,7 @@ struct SectionRow {
|
|||||||
view_mode: Option<String>,
|
view_mode: Option<String>,
|
||||||
preview_limit: Option<i64>,
|
preview_limit: Option<i64>,
|
||||||
empty_text: Option<String>,
|
empty_text: Option<String>,
|
||||||
|
allow_groups: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn valid_color(color: &str) -> bool {
|
fn valid_color(color: &str) -> bool {
|
||||||
@@ -148,7 +151,7 @@ pub fn update(conn: &mut Connection, id: &str, patch: UpdateCategory) -> Result<
|
|||||||
|
|
||||||
let current = tx
|
let current = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT title, icon, bar_color, view_mode, preview_limit, empty_text
|
"SELECT title, icon, bar_color, view_mode, preview_limit, empty_text, allow_groups
|
||||||
FROM node WHERE key = ?1 AND kind = 'section'",
|
FROM node WHERE key = ?1 AND kind = 'section'",
|
||||||
[id],
|
[id],
|
||||||
|row| {
|
|row| {
|
||||||
@@ -159,6 +162,7 @@ pub fn update(conn: &mut Connection, id: &str, patch: UpdateCategory) -> Result<
|
|||||||
view_mode: row.get(3)?,
|
view_mode: row.get(3)?,
|
||||||
preview_limit: row.get(4)?,
|
preview_limit: row.get(4)?,
|
||||||
empty_text: row.get(5)?,
|
empty_text: row.get(5)?,
|
||||||
|
allow_groups: row.get(6)?,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -237,9 +241,10 @@ pub fn update(conn: &mut Connection, id: &str, patch: UpdateCategory) -> Result<
|
|||||||
|
|
||||||
let was_apps = current.view_mode.as_deref() == Some("apps");
|
let was_apps = current.view_mode.as_deref() == Some("apps");
|
||||||
let is_apps = kind == "apps";
|
let is_apps = kind == "apps";
|
||||||
|
let allow_groups = patch.allow_groups.unwrap_or(current.allow_groups);
|
||||||
|
|
||||||
if is_apps && !was_apps {
|
if is_apps && !was_apps && allow_groups {
|
||||||
// 切到应用:条目必须有分组可挂;没有就建一个「全部」
|
// 切到应用且开启子分组:条目必须有分组可挂;没有就建一个「全部」
|
||||||
let group_count: i64 = tx
|
let group_count: i64 = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT COUNT(*) FROM node WHERE kind = 'group' AND parent_key = ?1",
|
"SELECT COUNT(*) FROM node WHERE kind = 'group' AND parent_key = ?1",
|
||||||
@@ -277,8 +282,10 @@ pub fn update(conn: &mut Connection, id: &str, patch: UpdateCategory) -> Result<
|
|||||||
params![group_key, id],
|
params![group_key, id],
|
||||||
)
|
)
|
||||||
.map_err(map_error)?;
|
.map_err(map_error)?;
|
||||||
} else if !is_apps && was_apps {
|
}
|
||||||
// 离开应用:首页卡片从「某个分组」挪回分区,标题交还给分区
|
|
||||||
|
// 关闭子分组:首页卡片如果指着分组,挪回分区,避免标题/预览错位
|
||||||
|
if !allow_groups {
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE section_block SET node_key = ?1, title = NULL
|
"UPDATE section_block SET node_key = ?1, title = NULL
|
||||||
WHERE node_key IN (
|
WHERE node_key IN (
|
||||||
@@ -291,9 +298,18 @@ pub fn update(conn: &mut Connection, id: &str, patch: UpdateCategory) -> Result<
|
|||||||
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE node SET title = ?1, icon = ?2, bar_color = ?3, view_mode = ?4,
|
"UPDATE node SET title = ?1, icon = ?2, bar_color = ?3, view_mode = ?4,
|
||||||
preview_limit = ?5, empty_text = ?6
|
preview_limit = ?5, empty_text = ?6, allow_groups = ?7
|
||||||
WHERE key = ?7",
|
WHERE key = ?8",
|
||||||
params![title, icon, bar_color, kind, preview_limit, empty_text, id],
|
params![
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
bar_color,
|
||||||
|
kind,
|
||||||
|
preview_limit,
|
||||||
|
empty_text,
|
||||||
|
allow_groups,
|
||||||
|
id
|
||||||
|
],
|
||||||
)
|
)
|
||||||
.map_err(map_error)?;
|
.map_err(map_error)?;
|
||||||
|
|
||||||
@@ -361,21 +377,21 @@ 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 view_mode: Option<String> = conn
|
let allow_groups: Option<bool> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT view_mode FROM node WHERE key = ?1 AND kind = 'section'",
|
"SELECT allow_groups 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)?;
|
||||||
|
|
||||||
let Some(view_mode) = view_mode else {
|
let Some(allow_groups) = allow_groups else {
|
||||||
return Err(StashError::NotFound(format!("分区不存在: {section_id}")));
|
return Err(StashError::NotFound(format!("分区不存在: {section_id}")));
|
||||||
};
|
};
|
||||||
if view_mode != "apps" {
|
if !allow_groups {
|
||||||
return Err(StashError::InvalidDirectory(
|
return Err(StashError::InvalidDirectory(
|
||||||
"只有应用类分区可以添加分组".into(),
|
"该分区未开启「允许子分组」".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,7 +516,8 @@ mod tests {
|
|||||||
preview_limit INTEGER,
|
preview_limit INTEGER,
|
||||||
empty_text TEXT,
|
empty_text TEXT,
|
||||||
sort INTEGER NOT NULL DEFAULT 0,
|
sort INTEGER NOT NULL DEFAULT 0,
|
||||||
show_in_sidebar INTEGER NOT NULL DEFAULT 1
|
show_in_sidebar INTEGER NOT NULL DEFAULT 1,
|
||||||
|
allow_groups INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
CREATE TABLE entry (
|
CREATE TABLE entry (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
@@ -635,7 +652,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn switching_apps_to_posters_moves_card_back_to_section() {
|
fn switching_apps_to_posters_keeps_groups_until_disabled() {
|
||||||
let mut conn = db();
|
let mut conn = db();
|
||||||
create(&mut conn, section("books", "书单", "apps")).expect("应能创建");
|
create(&mut conn, section("books", "书单", "apps")).expect("应能创建");
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -643,28 +660,45 @@ mod tests {
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.expect("应能建分组");
|
.expect("应能建分组");
|
||||||
conn.execute(
|
conn.execute("INSERT INTO entry (key, node_key) VALUES ('b1', 'books-g')", [])
|
||||||
"INSERT INTO entry (key, node_key) VALUES ('b1', 'books-g')",
|
.expect("应能建条目");
|
||||||
[],
|
// create() 会顺手建一张分区卡片;这里只保留分组卡片,模拟真实配置
|
||||||
)
|
conn.execute("DELETE FROM section_block", []).expect("应能清卡片");
|
||||||
.expect("应能建条目");
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO section_block (node_key, title, sort) VALUES ('books-g', '分组', 1)",
|
"INSERT INTO section_block (node_key, title, sort) VALUES ('books-g', '分组', 1)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.expect("应能建卡片");
|
.expect("应能建卡片");
|
||||||
|
|
||||||
update(&mut conn, "books", UpdateCategory { kind: Some("posters".into()), ..Default::default() })
|
// 类型切到海报但子分组仍开启:分组和卡片都保留
|
||||||
.expect("应能改类型");
|
update(
|
||||||
|
&mut conn,
|
||||||
|
"books",
|
||||||
|
UpdateCategory {
|
||||||
|
kind: Some("posters".into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("应能改类型");
|
||||||
let block: String = conn
|
let block: String = conn
|
||||||
.query_row("SELECT node_key FROM section_block", [], |row| row.get(0))
|
.query_row("SELECT node_key FROM section_block", [], |row| row.get(0))
|
||||||
.expect("应能查");
|
.expect("应能查");
|
||||||
assert_eq!(block, "books", "卡片应挪回分区");
|
assert_eq!(block, "books-g");
|
||||||
let mode: String = conn
|
|
||||||
.query_row("SELECT view_mode FROM node WHERE key='books'", [], |row| row.get(0))
|
// 关闭子分组:卡片挪回分区
|
||||||
|
update(
|
||||||
|
&mut conn,
|
||||||
|
"books",
|
||||||
|
UpdateCategory {
|
||||||
|
allow_groups: Some(false),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("应能关闭");
|
||||||
|
let block: String = conn
|
||||||
|
.query_row("SELECT node_key FROM section_block", [], |row| row.get(0))
|
||||||
.expect("应能查");
|
.expect("应能查");
|
||||||
assert_eq!(mode, "posters");
|
assert_eq!(block, "books", "关闭子分组后卡片应指回分区");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -725,8 +759,28 @@ mod tests {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
.is_err(),
|
.is_err(),
|
||||||
"海报 / 图文分区不提供分组"
|
"未开启子分组的分区不能建组"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 打开「允许子分组」后,海报分区也可以建组
|
||||||
|
update(
|
||||||
|
&mut conn,
|
||||||
|
"films",
|
||||||
|
UpdateCategory {
|
||||||
|
allow_groups: Some(true),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("应能开启");
|
||||||
|
create_group(
|
||||||
|
&mut conn,
|
||||||
|
"films",
|
||||||
|
NewGroup {
|
||||||
|
id: "g1".into(),
|
||||||
|
title: "片单".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("开启后应能建组");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -740,8 +794,16 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("应能建卡片");
|
.expect("应能建卡片");
|
||||||
|
|
||||||
update(&mut conn, "films", UpdateCategory { kind: Some("apps".into()), ..Default::default() })
|
update(
|
||||||
.expect("应能改类型");
|
&mut conn,
|
||||||
|
"films",
|
||||||
|
UpdateCategory {
|
||||||
|
kind: Some("apps".into()),
|
||||||
|
allow_groups: Some(true),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("应能改类型");
|
||||||
|
|
||||||
let (entry_node, block_node): (String, String) = conn
|
let (entry_node, block_node): (String, String) = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
|
|||||||
+1
-1
@@ -600,7 +600,7 @@ mod tests {
|
|||||||
let fresh = Connection::open_in_memory().expect("内存库应能打开");
|
let fresh = Connection::open_in_memory().expect("内存库应能打开");
|
||||||
fresh
|
fresh
|
||||||
.execute_batch(
|
.execute_batch(
|
||||||
"CREATE TABLE node (key TEXT PRIMARY KEY);
|
"CREATE TABLE node (key TEXT PRIMARY KEY, parent_key TEXT, kind TEXT, view_mode TEXT);
|
||||||
CREATE TABLE entry (key TEXT PRIMARY KEY, node_key TEXT NOT NULL, name TEXT NOT NULL);
|
CREATE TABLE entry (key TEXT PRIMARY KEY, node_key TEXT NOT NULL, name TEXT NOT NULL);
|
||||||
PRAGMA user_version = 15;",
|
PRAGMA user_version = 15;",
|
||||||
)
|
)
|
||||||
|
|||||||
+27
-3
@@ -46,6 +46,8 @@ CREATE TABLE IF NOT EXISTS node (
|
|||||||
empty_text TEXT, -- 分区为空时的提示文案
|
empty_text TEXT, -- 分区为空时的提示文案
|
||||||
show_in_sidebar INTEGER NOT NULL DEFAULT 1 CHECK (show_in_sidebar IN (0, 1)),
|
show_in_sidebar INTEGER NOT NULL DEFAULT 1 CHECK (show_in_sidebar IN (0, 1)),
|
||||||
-- 是否出现在侧栏:0 表示只当首页卡片/内容,不进导航
|
-- 是否出现在侧栏:0 表示只当首页卡片/内容,不进导航
|
||||||
|
allow_groups INTEGER NOT NULL DEFAULT 0 CHECK (allow_groups IN (0, 1)),
|
||||||
|
-- 是否允许子分组(与类型无关,后台开关)
|
||||||
sort INTEGER NOT NULL DEFAULT 0, -- 同级顺序,越小越靠前
|
sort INTEGER NOT NULL DEFAULT 0, -- 同级顺序,越小越靠前
|
||||||
CHECK (
|
CHECK (
|
||||||
(kind = 'section' AND parent_key IS NULL AND view_mode IS NOT NULL)
|
(kind = 'section' AND parent_key IS NULL AND view_mode IS NOT NULL)
|
||||||
@@ -154,6 +156,22 @@ CREATE TABLE IF NOT EXISTS audit (
|
|||||||
);
|
);
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// v17 → v18:分区增加「允许子分组」开关(与类型无关)。
|
||||||
|
///
|
||||||
|
/// 老库默认保持原行为:应用类开启,其余类型关闭;已经有分组的非应用分区也开启。
|
||||||
|
const V18_SQL: &str = r#"
|
||||||
|
ALTER TABLE node ADD COLUMN allow_groups INTEGER NOT NULL DEFAULT 1;
|
||||||
|
|
||||||
|
UPDATE node SET allow_groups = 0
|
||||||
|
WHERE kind = 'section' AND view_mode <> 'apps';
|
||||||
|
|
||||||
|
UPDATE node SET allow_groups = 1
|
||||||
|
WHERE kind = 'section' AND key IN (
|
||||||
|
SELECT DISTINCT parent_key FROM node
|
||||||
|
WHERE kind = 'group' AND parent_key IS NOT NULL
|
||||||
|
);
|
||||||
|
"#;
|
||||||
|
|
||||||
/// v15 → v16 的增量(老库升级用;新库直接走 SCHEMA_SQL)。
|
/// v15 → v16 的增量(老库升级用;新库直接走 SCHEMA_SQL)。
|
||||||
const V16_SQL: &str = r#"
|
const V16_SQL: &str = r#"
|
||||||
CREATE TABLE IF NOT EXISTS admin (
|
CREATE TABLE IF NOT EXISTS admin (
|
||||||
@@ -195,7 +213,7 @@ pub struct SeedReport {
|
|||||||
///
|
///
|
||||||
/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来,
|
/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来,
|
||||||
/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。
|
/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。
|
||||||
pub const SCHEMA_VERSION: i64 = 17;
|
pub const SCHEMA_VERSION: i64 = 18;
|
||||||
|
|
||||||
fn current_version(conn: &Connection) -> Result<i64, StashError> {
|
fn current_version(conn: &Connection) -> Result<i64, StashError> {
|
||||||
conn.pragma_query_value(None, "user_version", |row| row.get(0))
|
conn.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||||
@@ -239,6 +257,11 @@ pub fn migrate(conn: &Connection) -> Result<i64, StashError> {
|
|||||||
version = 17;
|
version = 17;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if version == 17 {
|
||||||
|
conn.execute_batch(V18_SQL).map_err(map_error)?;
|
||||||
|
version = 18;
|
||||||
|
}
|
||||||
|
|
||||||
conn.pragma_update(None, "user_version", version)
|
conn.pragma_update(None, "user_version", version)
|
||||||
.map_err(map_error)?;
|
.map_err(map_error)?;
|
||||||
|
|
||||||
@@ -283,8 +306,8 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO node (key, parent_key, title, kind, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar, sort)
|
"INSERT INTO node (key, parent_key, title, kind, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar, allow_groups, sort)
|
||||||
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||||
params![
|
params![
|
||||||
section.key,
|
section.key,
|
||||||
section.title,
|
section.title,
|
||||||
@@ -294,6 +317,7 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
|
|||||||
section.preview_limit,
|
section.preview_limit,
|
||||||
section.empty_text,
|
section.empty_text,
|
||||||
i64::from(section.show_in_sidebar),
|
i64::from(section.show_in_sidebar),
|
||||||
|
i64::from(view == "apps"),
|
||||||
section.sort,
|
section.sort,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -985,11 +985,18 @@
|
|||||||
/* 归类页:一行一个分区,标题 / 类型 / 图标 / 色条 / 条数 / 保存 / 删除 */
|
/* 归类页:一行一个分区,标题 / 类型 / 图标 / 色条 / 条数 / 保存 / 删除 */
|
||||||
.admin-category-row {
|
.admin-category-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.4fr) 150px 80px 110px 90px auto auto;
|
grid-template-columns: minmax(0, 1.4fr) 150px 80px 110px 90px auto auto auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--su-space-2);
|
gap: var(--su-space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--su-space-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-category-row > * {
|
.admin-category-row > * {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ type CategorySummary = {
|
|||||||
icon?: string
|
icon?: string
|
||||||
barColor?: string
|
barColor?: string
|
||||||
previewLimit?: number
|
previewLimit?: number
|
||||||
|
/** 是否允许子分组(与类型无关的开关) */
|
||||||
|
allowGroups?: boolean
|
||||||
/** 首页卡片标题与预览节点 */
|
/** 首页卡片标题与预览节点 */
|
||||||
block?: { title?: string; nodeKey?: string } | null
|
block?: { title?: string; nodeKey?: string } | null
|
||||||
/** 应用类分区下的分组(子分类) */
|
/** 应用类分区下的分组(子分类) */
|
||||||
@@ -539,7 +541,7 @@ function SidebarForm({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{category.kind === 'apps' && (
|
{category.allowGroups === true && (
|
||||||
<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>
|
||||||
@@ -751,8 +753,9 @@ function CategoryManager({
|
|||||||
return (
|
return (
|
||||||
<Space direction="column" gap="lg" align="stretch" className="admin-form">
|
<Space direction="column" gap="lg" align="stretch" className="admin-form">
|
||||||
<Text size="sm" tone="muted">
|
<Text size="sm" tone="muted">
|
||||||
分区决定内容归到哪一类(应用 / 海报 / 图文)。应用类分区下面可以建分组(子分类),
|
三种类型使用同一套模板:分区决定内容归到哪一类(应用 / 海报 / 图文),
|
||||||
分组会出现在侧栏,也可以作为首页卡片的预览来源;改类型会自动迁移条目。
|
「子分组」开关决定要不要分组。开启后可建分组、分组会出现在侧栏,也可以作为首页卡片预览;
|
||||||
|
关闭后条目按平铺渲染,已有分组里的内容不会丢。
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
@@ -762,7 +765,7 @@ function CategoryManager({
|
|||||||
onReload={onReload}
|
onReload={onReload}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
{category.kind === 'apps' && (
|
{category.allowGroups === true && (
|
||||||
<>
|
<>
|
||||||
<BlockSelect section={category} onReload={onReload} />
|
<BlockSelect section={category} onReload={onReload} />
|
||||||
<GroupManager section={category} onReload={onReload} />
|
<GroupManager section={category} onReload={onReload} />
|
||||||
@@ -794,6 +797,7 @@ function CategoryRow({
|
|||||||
const [limit, setLimit] = useState(
|
const [limit, setLimit] = useState(
|
||||||
category.previewLimit === undefined ? '' : String(category.previewLimit),
|
category.previewLimit === undefined ? '' : String(category.previewLimit),
|
||||||
)
|
)
|
||||||
|
const [allowGroups, setAllowGroups] = useState(category.allowGroups === true)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
@@ -804,6 +808,7 @@ function CategoryRow({
|
|||||||
icon,
|
icon,
|
||||||
barColor: color,
|
barColor: color,
|
||||||
previewLimit: limit.trim() === '' ? undefined : Number(limit),
|
previewLimit: limit.trim() === '' ? undefined : Number(limit),
|
||||||
|
allowGroups,
|
||||||
})
|
})
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
|
|
||||||
@@ -848,6 +853,16 @@ function CategoryRow({
|
|||||||
aria-label={`${category.title} 预览条数`}
|
aria-label={`${category.title} 预览条数`}
|
||||||
onChange={(event) => setLimit(event.target.value)}
|
onChange={(event) => setLimit(event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<div className="admin-toggle">
|
||||||
|
<Switch
|
||||||
|
checked={allowGroups}
|
||||||
|
onChange={setAllowGroups}
|
||||||
|
label={`${category.title} 允许子分组`}
|
||||||
|
/>
|
||||||
|
<Text size="sm" tone="muted">
|
||||||
|
子分组
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
<Button variant="secondary" size="sm" loading={busy} onClick={save}>
|
<Button variant="secondary" size="sm" loading={busy} onClick={save}>
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1275,7 +1290,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?.kind === 'apps' ? (section.groups ?? []) : []
|
const groups = section?.allowGroups === true ? (section.groups ?? []) : []
|
||||||
|
|
||||||
// 打开时重置:新建用当前选择,编辑先拉完整条目
|
// 打开时重置:新建用当前选择,编辑先拉完整条目
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1288,7 +1303,7 @@ function EntryDialog({
|
|||||||
const startCategory = categories.find((category) => category.id === startSection)
|
const startCategory = categories.find((category) => category.id === startSection)
|
||||||
setGroupId(
|
setGroupId(
|
||||||
defaultLocation?.groupId ??
|
defaultLocation?.groupId ??
|
||||||
(startCategory?.kind === 'apps' ? (startCategory.groups?.[0]?.id ?? '') : ''),
|
(startCategory?.allowGroups === true ? (startCategory.groups?.[0]?.id ?? '') : ''),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (entryKey === null) {
|
if (entryKey === null) {
|
||||||
@@ -1326,9 +1341,9 @@ 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?.kind !== 'apps') {
|
if (section?.allowGroups !== true) {
|
||||||
if (groupId !== '') setGroupId('')
|
if (groupId !== '') setGroupId('')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1340,7 +1355,7 @@ function EntryDialog({
|
|||||||
const nodeKey =
|
const nodeKey =
|
||||||
section === undefined
|
section === undefined
|
||||||
? ''
|
? ''
|
||||||
: section.kind === 'apps' && groupId !== ''
|
: section.allowGroups === true && groupId !== ''
|
||||||
? groupId
|
? groupId
|
||||||
: section.id
|
: section.id
|
||||||
|
|
||||||
@@ -1439,7 +1454,7 @@ function EntryDialog({
|
|||||||
onChange={setSectionId}
|
onChange={setSectionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{section?.kind === 'apps' && (
|
{section?.allowGroups === true && (
|
||||||
<div style={{ width: 240 }}>
|
<div style={{ width: 240 }}>
|
||||||
<Select
|
<Select
|
||||||
options={[
|
options={[
|
||||||
|
|||||||
@@ -38,11 +38,8 @@ export function CategoryNav({
|
|||||||
const items: MenuItem[] = [
|
const items: MenuItem[] = [
|
||||||
{ key: HOME_KEY, label: '首页' },
|
{ key: HOME_KEY, label: '首页' },
|
||||||
...categories.map((category) => {
|
...categories.map((category) => {
|
||||||
// 只有应用类有分组;空分组不出现在侧栏(后台仍保留)
|
// 「允许子分组」的分区才有 groups;空分组不出现在侧栏(后台仍保留)
|
||||||
const groups =
|
const groups = category.groups.filter((group) => group.items.length > 0)
|
||||||
category.kind === 'apps'
|
|
||||||
? category.groups.filter((group) => group.items.length > 0)
|
|
||||||
: []
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: categoryKey(category.id),
|
key: categoryKey(category.id),
|
||||||
|
|||||||
@@ -296,6 +296,8 @@ export type UpdateCategoryInput = {
|
|||||||
emptyText?: string
|
emptyText?: string
|
||||||
/** 首页卡片预览哪个节点(分区自己或其分组) */
|
/** 首页卡片预览哪个节点(分区自己或其分组) */
|
||||||
blockNodeKey?: string
|
blockNodeKey?: string
|
||||||
|
/** 是否允许子分组(与类型无关的开关) */
|
||||||
|
allowGroups?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCategory(
|
export async function updateCategory(
|
||||||
|
|||||||
Reference in New Issue
Block a user