feat(admin): 分组管理改为树形表格(图标/排序/状态)

- 参考需求页重做分组管理:树形表格(名称/排序权重/关联网页数/状态/操作),去掉展开全部/折叠全部,新建根分组置于左侧
- 分组弹窗支持上级分组移动、格式、图标(可留空)、排序权重、启停状态
- 数据层 v20:node.enabled 启停;icon/sort 可编辑;禁用分组前台侧栏与卡片隐藏
- 修复同一父级下编辑排序权重被重置为末尾
- 测试 59 passed;Playwright 验证无图标/图标/排序/禁用/折叠
This commit is contained in:
2026-10-31 10:22:58 +08:00
parent 165d3f112e
commit c249fbd826
10 changed files with 701 additions and 183 deletions
+4 -1
View File
@@ -63,13 +63,14 @@ struct TreeNode {
bar_color: Option<String>,
preview_limit: Option<i64>,
sort: i64,
enabled: bool,
}
/// 新的原子视图:完整分组树(多层、带格式、带条目),供新首页/侧栏/后台使用。
pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
let mut stmt = conn
.prepare(
"SELECT key, parent_key, title, view_mode, icon, bar_color, preview_limit, sort
"SELECT key, parent_key, title, view_mode, icon, bar_color, preview_limit, sort, enabled
FROM node ORDER BY sort, key",
)
.map_err(map_error)?;
@@ -85,6 +86,7 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
bar_color: row.get(5)?,
preview_limit: row.get(6)?,
sort: row.get(7)?,
enabled: row.get(8)?,
})
})
.map_err(map_error)?
@@ -160,6 +162,7 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
"barColor": node.bar_color.clone().unwrap_or_default(),
"previewLimit": node.preview_limit,
"sort": node.sort,
"enabled": node.enabled,
"entries": items,
"children": child_values,
}))
+76 -20
View File
@@ -23,6 +23,15 @@ pub struct NewNode {
/// 根分组的格式:apps / posters / articles(子分组继承,不需要传)
#[serde(default)]
pub format: Option<String>,
/// 图标:可以是 iconfont 名或字符;空字符串表示不显示
#[serde(default)]
pub icon: Option<String>,
/// 排序权重;不传则排到同级末尾
#[serde(default)]
pub sort: Option<i64>,
/// 是否启用,默认 true
#[serde(default)]
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
@@ -34,6 +43,9 @@ pub struct UpdateNode {
pub sort: Option<i64>,
/// 只对根分组生效
pub format: Option<String>,
/// 图标:空字符串表示清除图标
pub icon: Option<String>,
pub enabled: Option<bool>,
}
fn valid_key(key: &str) -> bool {
@@ -87,7 +99,7 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
}
};
let sort: i64 = match new.parent_key.as_deref() {
let default_sort: i64 = match new.parent_key.as_deref() {
Some(parent) => conn
.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1",
@@ -103,12 +115,29 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
)
.map_err(map_error)?,
};
let sort = new.sort.unwrap_or(default_sort);
let icon = new
.icon
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let enabled = new.enabled.unwrap_or(true);
let tx = conn.transaction().map_err(map_error)?;
tx.execute(
"INSERT INTO node (key, parent_key, title, kind, view_mode, sort, show_in_sidebar, allow_groups)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, 1)",
params![key, new.parent_key, title, kind, view_mode, sort],
"INSERT INTO node (key, parent_key, title, kind, view_mode, icon, enabled, sort, show_in_sidebar, allow_groups)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1, 1)",
params![
key,
new.parent_key,
title,
kind,
view_mode,
icon,
enabled,
sort
],
)
.map_err(map_error)?;
entries::audit(&tx, "node.create", key, title)?;
@@ -117,16 +146,27 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
}
pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(), StashError> {
let current: Option<(String, Option<String>, Option<String>, i64)> = conn
let current: Option<(String, Option<String>, Option<String>, i64, Option<String>, bool)> = conn
.query_row(
"SELECT title, parent_key, view_mode, sort FROM node WHERE key = ?1",
"SELECT title, parent_key, view_mode, sort, icon, enabled FROM node WHERE key = ?1",
[key],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
))
},
)
.optional()
.map_err(map_error)?;
let Some((current_title, current_parent, current_format, current_sort)) = current else {
let Some((current_title, current_parent, current_format, current_sort, current_icon, current_enabled)) =
current
else {
return Err(StashError::NotFound(format!("分组不存在: {key}")));
};
@@ -198,24 +238,40 @@ pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(),
return Err(StashError::InvalidDirectory("根分组缺少格式".into()));
}
let icon = match patch.icon {
Some(icon) => {
let icon = icon.trim().to_string();
(!icon.is_empty()).then_some(icon)
}
None => current_icon,
};
let enabled = patch.enabled.unwrap_or(current_enabled);
let sort = patch.sort.unwrap_or(current_sort);
let tx = conn.transaction().map_err(map_error)?;
// 移动时重新排到新父级末尾;不移动则保留原顺序
let final_sort = match patch.parent_key {
Some(parent) => tx
.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1 AND key <> ?2",
params![parent, key],
|row| row.get::<_, i64>(0),
)
.map_err(map_error)?,
None => sort,
// 只有真正换了父级才排到末尾;同一父级下编辑排序权重时按传入值保存
let parent_changed = patch
.parent_key
.as_deref()
.is_some_and(|parent| Some(parent) != current_parent.as_deref());
let final_sort = if parent_changed {
let parent = patch.parent_key.as_deref().unwrap_or_default();
tx.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1 AND key <> ?2",
params![parent, key],
|row| row.get::<_, i64>(0),
)
.map_err(map_error)?
} else {
sort
};
tx.execute(
"UPDATE node SET title = ?1, parent_key = ?2, view_mode = ?3, sort = ?4 WHERE key = ?5",
params![title, parent_key, format, final_sort, key],
"UPDATE node SET title = ?1, parent_key = ?2, view_mode = ?3, sort = ?4,
icon = ?5, enabled = ?6
WHERE key = ?7",
params![title, parent_key, format, final_sort, icon, enabled, key],
)
.map_err(map_error)?;
entries::audit(&tx, "node.update", key, &title)?;
+13 -1
View File
@@ -48,6 +48,8 @@ CREATE TABLE IF NOT EXISTS node (
-- 是否出现在侧栏:0 表示只当首页卡片/内容,不进导航
allow_groups INTEGER NOT NULL DEFAULT 0 CHECK (allow_groups IN (0, 1)),
-- 是否允许子分组(与类型无关,后台开关)
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
-- 是否启用(禁用后前台侧栏/卡片不展示)
sort INTEGER NOT NULL DEFAULT 0, -- 同级顺序,越小越靠前
CHECK (
(kind = 'section' AND parent_key IS NULL AND view_mode IS NOT NULL)
@@ -210,6 +212,11 @@ WHERE kind = 'section' AND key IN (
);
"#;
/// v19 → v20:分组增加启停状态。
const V20_SQL: &str = r#"
ALTER TABLE node ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1;
"#;
/// v18 → v19:首页卡片改为「卡片 + 勾选分组」。
const V19_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS card (
@@ -269,7 +276,7 @@ pub struct SeedReport {
///
/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来,
/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。
pub const SCHEMA_VERSION: i64 = 19;
pub const SCHEMA_VERSION: i64 = 20;
fn current_version(conn: &Connection) -> Result<i64, StashError> {
conn.pragma_query_value(None, "user_version", |row| row.get(0))
@@ -324,6 +331,11 @@ pub fn migrate(conn: &Connection) -> Result<i64, StashError> {
version = 19;
}
if version == 19 {
conn.execute_batch(V20_SQL).map_err(map_error)?;
version = 20;
}
conn.pragma_update(None, "user_version", version)
.map_err(map_error)?;