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
+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)?;