feat(admin): 分组管理改为树形表格(图标/排序/状态)
- 参考需求页重做分组管理:树形表格(名称/排序权重/关联网页数/状态/操作),去掉展开全部/折叠全部,新建根分组置于左侧 - 分组弹窗支持上级分组移动、格式、图标(可留空)、排序权重、启停状态 - 数据层 v20:node.enabled 启停;icon/sort 可编辑;禁用分组前台侧栏与卡片隐藏 - 修复同一父级下编辑排序权重被重置为末尾 - 测试 59 passed;Playwright 验证无图标/图标/排序/禁用/折叠
This commit is contained in:
+5
-4
@@ -97,7 +97,7 @@ README.md 运行、路由、库结构、登录说明
|
||||
### `/admin`(新原子模型)
|
||||
| Tab | 能力 |
|
||||
| --- | --- |
|
||||
| 分组管理 | 多层分组树:新建根/子分组、改名、根分组格式(应用/海报/图文)、删除(级联子分组与条目) |
|
||||
| 分组管理 | 树形表格:新建根分组(左侧)、加子分组、编辑(名称/上级/格式/图标/排序/状态)、删除;行内状态开关即时生效;图标可留空 |
|
||||
| 内容 | 选分组新增/编辑/删除条目;条目只属于一个分组 |
|
||||
| 侧边栏 | 分组树逐项勾选(`settings.sidebar.tree_hidden`),子分组可单独勾选 |
|
||||
| 首页卡片 | 新建/改名/删除卡片;勾选若干分组(可混合格式),每组单独设条数 |
|
||||
@@ -164,8 +164,8 @@ GET /api/backup SQLite 快照附件 需登录
|
||||
## 5. 数据库与迁移
|
||||
|
||||
- 文件:开发态在 `target/debug/data/stash.db`;可用 `--root` / 数据目录覆盖(见 `config.rs`)。
|
||||
- 当前 `PRAGMA user_version = 19`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18→19、更高版本拒绝启动。
|
||||
- `node` 是统一分组树(根节点 `kind='section'` 带 `view_mode` 格式,子节点 `kind='group'` 可多层);`card` / `card_group` 是首页卡片的展示组合(卡片不拥有内容)。
|
||||
- 当前 `PRAGMA user_version = 20`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18→19→20、更高版本拒绝启动。
|
||||
- `node` 是统一分组树(根节点 `kind='section'` 带 `view_mode` 格式,子节点 `kind='group'` 可多层);`enabled` 控制启停;`icon` 可为空。
|
||||
- 主要表:`node`、`entry`、`entry_tag`、`section_block`、`admin`、`session`、`setting`、`audit`。
|
||||
- `entry.updated_at` 用于编辑乐观锁;`setting` 表 `key='site'` 存站点 JSON。
|
||||
- **设置/内容都是 SQLite**:备份用 `/api/backup`,恢复需停机替换文件。
|
||||
@@ -205,8 +205,9 @@ GET /api/backup SQLite 快照附件 需登录
|
||||
| 图片上传 | 只支持 URL;上传需存储目录、大小/类型校验 |
|
||||
| 拖拽排序 | 分组/卡片顺序暂用创建顺序;上移下移/拖拽未做 |
|
||||
| 多用户/角色 | 未做,当前单管理员 |
|
||||
| 分组移动 UI | 后端 `PATCH /api/nodes/{key}` 支持 `parentKey` 移动与防环;后台暂未暴露移动入口 |
|
||||
| 分组移动 | 编辑弹窗里可改「上级分组」(防环);根分组不能移到子级 |
|
||||
| 条目移动 | 内容弹窗里可直接换分组(`PATCH /api/items/{key}` 的 `nodeKey`) |
|
||||
| 分组排序 | 可编辑「排序权重」数值;拖拽排序未做 |
|
||||
| 旧接口 | `/api/catalog`、`/api/categories/*`、`/api/groups/*` 仍保留给旧组件/兼容,后续可删 |
|
||||
| 登录限流 | 进程内计数,重启清空 |
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
|
||||
### 登录与设置(v0.1.1 起)
|
||||
|
||||
- 登录是**页内弹窗**:点右上角用户图标;首次使用弹窗里是「设置密码 + 确认」(至少 8 位);
|
||||
- 登录后点「站点设置」进入 `/admin`:分组管理(多层、格式、增删改)、内容(选分组新增/编辑条目)、侧边栏(勾选分组树)、首页卡片(建卡 + 勾选分组,可混合格式),以及页头滚动信息、资料与系统设置;
|
||||
- 登录后点「站点设置」进入 `/admin`:分组管理(树形表格,可建根/子分组、图标可空、排序权重、启停、删除)、内容(选分组新增/编辑条目)、侧边栏(勾选分组树)、首页卡片(建卡 + 勾选分组,可混合格式),以及页头滚动信息、资料与系统设置;
|
||||
- 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁;
|
||||
- `/admin` 的「系统」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
|
||||
- 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天);
|
||||
|
||||
@@ -147,6 +147,14 @@ DELETE /api/items/:id
|
||||
|
||||
## 11. 实施记录
|
||||
|
||||
### P10(2026-10-31,分组管理页重做)
|
||||
|
||||
- 参考 `TT-Helper/需求/5297/1.html` 重做分组管理:树形表格(分组名称 / 排序权重 / 关联网页数 / 状态 / 操作),去掉「展开全部 / 折叠全部」,新建根分组放左侧;
|
||||
- 分组弹窗:上级分组(可移动、防环)、名称、格式(根)、**图标(可留空)**、排序权重、状态;
|
||||
- 数据层:`node.enabled`(迁移 v20)控制启停,禁用分组在前台侧栏/卡片隐藏;`node.icon` 支持 iconfont 名、字符或空;
|
||||
- 排序修复:同一父级下编辑排序权重不再被当成「移动」重置为末尾;
|
||||
- 验证:无图标/有图标、排序 5、禁用后前台隐藏、折叠行均通过;`cargo test` 59 passed。
|
||||
|
||||
### P9(2026-10-30,前台切换)
|
||||
|
||||
- **前台**:`AppNext` 改为读 `/api/catalog2`,首页按 `card` 勾选的分组渲染(可混合格式,每组条数独立),侧栏按分组树 + `tree_hidden` 渲染,分组页 `/g/<key>`、条目页 `/g/<key>/i/<id>`;
|
||||
|
||||
+4
-1
@@ -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
@@ -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
@@ -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)?;
|
||||
|
||||
|
||||
@@ -1048,6 +1048,186 @@
|
||||
gap: var(--su-space-2);
|
||||
}
|
||||
|
||||
/* 分组管理:参考后台的树形表格 */
|
||||
.group-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.group-table-wrap {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--su-card-border);
|
||||
border-radius: var(--su-radius-md);
|
||||
background-color: var(--su-bg-card);
|
||||
}
|
||||
|
||||
.group-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--su-font-size-md);
|
||||
}
|
||||
|
||||
.group-table th {
|
||||
padding: var(--su-space-3) var(--su-space-4);
|
||||
background-color: var(--su-bg-subtle);
|
||||
border-bottom: 1px solid var(--su-card-border);
|
||||
color: var(--su-text-muted);
|
||||
font-size: var(--su-font-size-sm);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.group-table td {
|
||||
padding: var(--su-space-3) var(--su-space-4);
|
||||
border-bottom: 1px solid var(--su-card-border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.group-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.group-table tbody tr:hover {
|
||||
background-color: var(--su-bg-subtle);
|
||||
}
|
||||
|
||||
.group-col-sort {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.group-col-count {
|
||||
width: 120px;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.group-col-status {
|
||||
width: 90px;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.group-col-actions {
|
||||
width: 190px;
|
||||
text-align: right !important;
|
||||
}
|
||||
|
||||
.group-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-2);
|
||||
}
|
||||
|
||||
.group-caret {
|
||||
width: 20px;
|
||||
flex: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--su-text-muted);
|
||||
font-size: var(--su-font-size-sm);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.group-caret-empty {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.group-mark {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
color: var(--su-color-primary);
|
||||
font-size: var(--su-font-size-lg);
|
||||
}
|
||||
|
||||
.group-mark-empty {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.group-mark-text {
|
||||
font-size: var(--su-font-size-md);
|
||||
}
|
||||
|
||||
.group-name {
|
||||
color: var(--su-text-main);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.group-format {
|
||||
padding: 0 var(--su-space-2);
|
||||
border-radius: var(--su-radius-xs);
|
||||
background-color: var(--su-bg-subtle);
|
||||
color: var(--su-text-muted);
|
||||
font-size: var(--su-font-size-sm);
|
||||
}
|
||||
|
||||
.group-sort {
|
||||
color: var(--su-text-muted);
|
||||
font-family: var(--su-font-mono);
|
||||
font-size: var(--su-font-size-sm);
|
||||
}
|
||||
|
||||
.group-count {
|
||||
color: var(--su-text-sub);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.group-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--su-space-3);
|
||||
}
|
||||
|
||||
.group-action {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--su-color-primary);
|
||||
font-size: var(--su-font-size-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.group-action:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.group-action-danger {
|
||||
color: var(--su-color-danger);
|
||||
}
|
||||
|
||||
.group-empty {
|
||||
padding: var(--su-space-6) 0 !important;
|
||||
color: var(--su-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.group-icon-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-2);
|
||||
}
|
||||
|
||||
.group-icon-preview {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--su-border-strong);
|
||||
border-radius: var(--su-radius-sm);
|
||||
background-color: var(--su-bg-subtle);
|
||||
}
|
||||
|
||||
.group-dialog-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--su-space-3);
|
||||
}
|
||||
|
||||
.admin-category-row > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -241,6 +241,7 @@ export default function AppNext() {
|
||||
|
||||
const build = (nodes: GroupNode[], parentKey: string): MenuItem[] =>
|
||||
sortNodes(nodes, parentKey).flatMap((node) => {
|
||||
if (node.enabled === false) return []
|
||||
if (hidden.has(node.key)) return build(node.children, node.key)
|
||||
const children = build(node.children, node.key)
|
||||
return [
|
||||
@@ -295,7 +296,10 @@ export default function AppNext() {
|
||||
function renderCard(card: CardItem) {
|
||||
const resolved = card.groups
|
||||
.map((ref) => ({ ref, group: findGroup(manage.groups, ref.groupKey) }))
|
||||
.filter((item): item is { ref: typeof item.ref; group: GroupNode } => item.group !== undefined)
|
||||
.filter(
|
||||
(item): item is { ref: typeof item.ref; group: GroupNode } =>
|
||||
item.group !== undefined && item.group.enabled !== false,
|
||||
)
|
||||
|
||||
const total = resolved.reduce((sum, item) => sum + groupEntryCount(item.group), 0)
|
||||
const first = resolved[0]?.group
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Card,
|
||||
FormField,
|
||||
Icon,
|
||||
ICON_NAMES,
|
||||
Input,
|
||||
Modal,
|
||||
Notice,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
Title,
|
||||
Toaster,
|
||||
toast,
|
||||
type IconName,
|
||||
} from '@stash/ui'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { changePassword, fetchSession, logout, type SessionState } from '../../auth'
|
||||
@@ -329,6 +331,62 @@ export function AdminNext() {
|
||||
|
||||
// ---------- 分组管理 ----------
|
||||
|
||||
function countGroupEntries(group: GroupNode): number {
|
||||
return group.entries.length + group.children.reduce((total, child) => total + countGroupEntries(child), 0)
|
||||
}
|
||||
|
||||
function flattenWithDepth(
|
||||
groups: GroupNode[],
|
||||
depth = 0,
|
||||
): { node: GroupNode; depth: number }[] {
|
||||
return groups.flatMap((node) => [
|
||||
{ node, depth },
|
||||
...flattenWithDepth(node.children, depth + 1),
|
||||
])
|
||||
}
|
||||
|
||||
function descendantKeys(group: GroupNode): Set<string> {
|
||||
const keys = new Set<string>([group.key])
|
||||
const walk = (nodes: GroupNode[]) => {
|
||||
for (const node of nodes) {
|
||||
keys.add(node.key)
|
||||
walk(node.children)
|
||||
}
|
||||
}
|
||||
walk(group.children)
|
||||
return keys
|
||||
}
|
||||
|
||||
const FORMAT_LABEL: Record<GroupFormat, string> = {
|
||||
apps: '应用',
|
||||
posters: '海报',
|
||||
articles: '图文',
|
||||
}
|
||||
|
||||
function GroupMark({ icon }: { icon: string }) {
|
||||
if (icon.trim() === '') {
|
||||
return <span className="group-mark group-mark-empty" aria-hidden="true" />
|
||||
}
|
||||
if (ICON_NAMES.includes(icon as IconName)) {
|
||||
return (
|
||||
<span className="group-mark" aria-hidden="true">
|
||||
<Icon name={icon as IconName} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="group-mark group-mark-text" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
type GroupDialogState =
|
||||
| { mode: 'root' }
|
||||
| { mode: 'child'; parent: GroupNode }
|
||||
| { mode: 'edit'; group: GroupNode }
|
||||
| null
|
||||
|
||||
function GroupsPanel({
|
||||
groups,
|
||||
onReload,
|
||||
@@ -338,206 +396,390 @@ function GroupsPanel({
|
||||
onReload: () => void
|
||||
onDelete: (key: string, title: string) => void
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
|
||||
const [dialog, setDialog] = useState<GroupDialogState>(null)
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const result: { node: GroupNode; level: number }[] = []
|
||||
const walk = (nodes: GroupNode[], level: number) => {
|
||||
for (const node of nodes) {
|
||||
result.push({ node, level })
|
||||
if (!collapsed.has(node.key)) walk(node.children, level + 1)
|
||||
}
|
||||
}
|
||||
walk(groups, 0)
|
||||
return result
|
||||
}, [groups, collapsed])
|
||||
|
||||
const toggle = (key: string) => {
|
||||
setCollapsed((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleEnabled = async (group: GroupNode, enabled: boolean) => {
|
||||
const result = await updateNode(group.key, { enabled })
|
||||
if (!result.ok) {
|
||||
toast.error(result.error)
|
||||
return
|
||||
}
|
||||
toast.success(enabled ? '分组已启用' : '分组已禁用')
|
||||
onReload()
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="column" gap="lg" align="stretch" className="admin-form">
|
||||
<Space direction="column" gap="md" align="stretch" className="admin-form">
|
||||
<Text size="sm" tone="muted">
|
||||
分组可多层嵌套,格式由根分组决定。内容(条目)只挂在分组上;侧边栏和卡片都从这里勾选。
|
||||
分组用于控制前台侧边栏与卡片;可多层嵌套,根分组决定格式。内容(条目)只挂在分组上。
|
||||
</Text>
|
||||
|
||||
<NewNodeRow onReload={onReload} />
|
||||
<div className="group-toolbar">
|
||||
<Button
|
||||
icon={<Icon name="add_line" />}
|
||||
onClick={() => setDialog({ mode: 'root' })}
|
||||
>
|
||||
新建根分组
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{groups.map((group) => (
|
||||
<GroupRow
|
||||
key={group.key}
|
||||
group={group}
|
||||
depth={0}
|
||||
isRoot
|
||||
onReload={onReload}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
<div className="group-table-wrap">
|
||||
<table className="group-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>分组名称</th>
|
||||
<th className="group-col-sort">排序权重</th>
|
||||
<th className="group-col-count">关联网页数</th>
|
||||
<th className="group-col-status">状态</th>
|
||||
<th className="group-col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ node, level }) => {
|
||||
const hasChildren = node.children.length > 0
|
||||
const open = !collapsed.has(node.key)
|
||||
return (
|
||||
<tr key={node.key}>
|
||||
<td>
|
||||
<div className="group-name-cell" style={{ paddingLeft: level * 20 }}>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
className="group-caret"
|
||||
aria-label={open ? `折叠 ${node.title}` : `展开 ${node.title}`}
|
||||
onClick={() => toggle(node.key)}
|
||||
>
|
||||
{open ? '▾' : '▸'}
|
||||
</button>
|
||||
) : (
|
||||
<span className="group-caret group-caret-empty" aria-hidden="true" />
|
||||
)}
|
||||
<GroupMark icon={node.icon} />
|
||||
<span className="group-name">{node.title}</span>
|
||||
<span className="group-format">{FORMAT_LABEL[node.format]}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="group-sort">{node.sort}</td>
|
||||
<td className="group-count">{countGroupEntries(node)}</td>
|
||||
<td>
|
||||
<Switch
|
||||
checked={node.enabled !== false}
|
||||
onChange={(next) => void toggleEnabled(node, next)}
|
||||
label={`启用 ${node.title}`}
|
||||
size="sm"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="group-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="group-action"
|
||||
onClick={() => setDialog({ mode: 'child', parent: node })}
|
||||
>
|
||||
加子分组
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="group-action"
|
||||
onClick={() => setDialog({ mode: 'edit', group: node })}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="group-action group-action-danger"
|
||||
onClick={() => onDelete(node.key, node.title)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
|
||||
{groups.length === 0 && <Text tone="muted">还没有分组,先新建一个根分组。</Text>}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="group-empty">
|
||||
暂无分组数据,点击左侧「新建根分组」
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<GroupDialog
|
||||
state={dialog}
|
||||
groups={groups}
|
||||
onClose={() => setDialog(null)}
|
||||
onSaved={() => {
|
||||
setDialog(null)
|
||||
onReload()
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
function NewNodeRow({
|
||||
parentKey,
|
||||
onReload,
|
||||
onCancel,
|
||||
function GroupDialog({
|
||||
state,
|
||||
groups,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
parentKey?: string
|
||||
onReload: () => void
|
||||
onCancel?: () => void
|
||||
state: GroupDialogState
|
||||
groups: GroupNode[]
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const open = state !== null
|
||||
const editing = state?.mode === 'edit' ? state.group : undefined
|
||||
const isRootEdit = editing !== undefined && editing.parentKey === null
|
||||
|
||||
const [key, setKey] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
const [parentKey, setParentKey] = useState('')
|
||||
const [format, setFormat] = useState<GroupFormat>('apps')
|
||||
const [icon, setIcon] = useState('')
|
||||
const [sort, setSort] = useState('')
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setBusy(false)
|
||||
setError('')
|
||||
|
||||
if (state?.mode === 'edit') {
|
||||
setKey(state.group.key)
|
||||
setTitle(state.group.title)
|
||||
setParentKey(state.group.parentKey ?? '')
|
||||
setFormat(state.group.format)
|
||||
setIcon(state.group.icon)
|
||||
setSort(String(state.group.sort))
|
||||
setEnabled(state.group.enabled)
|
||||
return
|
||||
}
|
||||
|
||||
setKey('')
|
||||
setTitle('')
|
||||
setFormat('apps')
|
||||
setIcon('')
|
||||
setSort('')
|
||||
setEnabled(true)
|
||||
setParentKey(state?.mode === 'child' ? state.parent.key : '')
|
||||
}, [open, state])
|
||||
|
||||
const excluded = editing !== undefined ? descendantKeys(editing) : new Set<string>()
|
||||
const parentOptions = flattenWithDepth(groups)
|
||||
.filter((option) => !excluded.has(option.node.key))
|
||||
.map((option) => ({
|
||||
value: option.node.key,
|
||||
label: `${' '.repeat(option.depth)}${option.node.title}`,
|
||||
}))
|
||||
|
||||
const rootMode = parentKey === '' || isRootEdit
|
||||
const showKey = editing === undefined
|
||||
|
||||
const submit = async () => {
|
||||
if (title.trim() === '') {
|
||||
setError('分组名称不能为空')
|
||||
return
|
||||
}
|
||||
if (showKey && key.trim() === '') {
|
||||
setError('分组标识不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
setError('')
|
||||
|
||||
if (editing !== undefined) {
|
||||
const result = await updateNode(editing.key, {
|
||||
title: title.trim(),
|
||||
icon,
|
||||
sort: sort.trim() === '' ? undefined : Number(sort),
|
||||
enabled,
|
||||
...(isRootEdit
|
||||
? { format }
|
||||
: { parentKey: parentKey }),
|
||||
})
|
||||
setBusy(false)
|
||||
if (!result.ok) {
|
||||
setError(result.error)
|
||||
return
|
||||
}
|
||||
toast.success('分组已保存')
|
||||
onSaved()
|
||||
return
|
||||
}
|
||||
|
||||
const result = await createNode({
|
||||
key: key.trim(),
|
||||
title: title.trim(),
|
||||
parentKey,
|
||||
format: parentKey === undefined ? format : undefined,
|
||||
parentKey: rootMode ? undefined : parentKey,
|
||||
format: rootMode ? format : undefined,
|
||||
icon,
|
||||
sort: sort.trim() === '' ? undefined : Number(sort),
|
||||
enabled,
|
||||
})
|
||||
setBusy(false)
|
||||
if (!result.ok) {
|
||||
toast.error(result.error)
|
||||
setError(result.error)
|
||||
return
|
||||
}
|
||||
toast.success(parentKey === undefined ? '根分组已创建' : '子分组已创建')
|
||||
setKey('')
|
||||
setTitle('')
|
||||
onReload()
|
||||
onCancel?.()
|
||||
toast.success(rootMode ? '根分组已创建' : '子分组已创建')
|
||||
onSaved()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-new-node-row">
|
||||
<div style={{ width: 170 }}>
|
||||
<Input
|
||||
value={key}
|
||||
placeholder="标识,如 books"
|
||||
aria-label={parentKey === undefined ? '根分组标识' : '子分组标识'}
|
||||
onChange={(event) => setKey(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 220 }}>
|
||||
<Input
|
||||
value={title}
|
||||
placeholder="名称,如 书单"
|
||||
aria-label={parentKey === undefined ? '根分组名称' : '子分组名称'}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{parentKey === undefined && (
|
||||
<div className="admin-node-format">
|
||||
<Select
|
||||
options={FORMAT_OPTIONS}
|
||||
value={format}
|
||||
onChange={(next) => setFormat(next as GroupFormat)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Icon name="add_line" />}
|
||||
loading={busy}
|
||||
disabled={key.trim() === '' || title.trim() === ''}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{parentKey === undefined ? '新建根分组' : '新增子分组'}
|
||||
</Button>
|
||||
{onCancel !== undefined && (
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={busy ? () => undefined : onClose}
|
||||
title={
|
||||
state?.mode === 'edit'
|
||||
? '编辑分组'
|
||||
: state?.mode === 'child'
|
||||
? '新增子分组'
|
||||
: '新建根分组'
|
||||
}
|
||||
size="md"
|
||||
footer={
|
||||
<Space gap="sm" justify="end">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
loading={busy}
|
||||
icon={<Icon name="check_line" />}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space direction="column" gap="md" align="stretch">
|
||||
{error !== '' && (
|
||||
<Notice tone="error" title="保存失败">
|
||||
{error}
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
function GroupRow({
|
||||
group,
|
||||
depth,
|
||||
isRoot,
|
||||
onReload,
|
||||
onDelete,
|
||||
}: {
|
||||
group: GroupNode
|
||||
depth: number
|
||||
isRoot?: boolean
|
||||
onReload: () => void
|
||||
onDelete: (key: string, title: string) => void
|
||||
}) {
|
||||
const [title, setTitle] = useState(group.title)
|
||||
const [format, setFormat] = useState<GroupFormat>(group.format)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
{showKey && (
|
||||
<FormField label="分组标识" htmlFor="group-key" hint="小写字母、数字与连字符,创建后不可改">
|
||||
<Input
|
||||
id="group-key"
|
||||
value={key}
|
||||
placeholder="如 frontend"
|
||||
onChange={(event) => setKey(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true)
|
||||
const result = await updateNode(group.key, {
|
||||
title,
|
||||
format: isRoot === true ? format : undefined,
|
||||
})
|
||||
setBusy(false)
|
||||
if (!result.ok) {
|
||||
toast.error(result.error)
|
||||
return
|
||||
}
|
||||
toast.success('分组已保存')
|
||||
onReload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginLeft: depth * 18 }}>
|
||||
<div className="admin-node-row">
|
||||
<Text size="sm" tone="muted">
|
||||
{isRoot === true ? '根' : '子'}
|
||||
</Text>
|
||||
<div className="admin-node-title">
|
||||
<FormField label="分组名称" htmlFor="group-title" required>
|
||||
<Input
|
||||
id="group-title"
|
||||
value={title}
|
||||
aria-label={`${group.title} 名称`}
|
||||
placeholder="如 前端开发、常用工具"
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{isRoot === true && (
|
||||
<div className="admin-node-format">
|
||||
</FormField>
|
||||
|
||||
{(!isRootEdit) && (
|
||||
<FormField
|
||||
label="上级分组"
|
||||
htmlFor="group-parent"
|
||||
hint={editing === undefined ? '选择「根目录」则创建一级分组' : undefined}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
...(editing === undefined
|
||||
? [{ value: '', label: 'ROOT 根目录(一级分组)' }]
|
||||
: []),
|
||||
...parentOptions,
|
||||
]}
|
||||
value={parentKey}
|
||||
onChange={setParentKey}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{rootMode && (
|
||||
<FormField label="格式" htmlFor="group-format" hint="根分组决定这一棵树的渲染格式">
|
||||
<Select
|
||||
options={FORMAT_OPTIONS}
|
||||
value={format}
|
||||
onChange={(next) => setFormat(next as GroupFormat)}
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
)}
|
||||
<Button variant="secondary" size="sm" loading={busy} onClick={() => void save()}>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<Icon name="add_line" />}
|
||||
aria-label="新增子分组"
|
||||
onClick={() => setAdding((current) => !current)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="admin-remove"
|
||||
icon={<Icon name="delete_2_line" />}
|
||||
aria-label="删除分组"
|
||||
onClick={() => onDelete(group.key, group.title)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginLeft: 18 }}>
|
||||
{adding && (
|
||||
<NewNodeRow parentKey={group.key} onReload={onReload} onCancel={() => setAdding(false)} />
|
||||
)}
|
||||
{group.children.map((child) => (
|
||||
<GroupRow
|
||||
key={child.key}
|
||||
group={child}
|
||||
depth={0}
|
||||
onReload={onReload}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
<Text size="sm" tone="muted">
|
||||
{group.entries.length} 条内容
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<FormField label="图标样式" htmlFor="group-icon" hint="留空则不显示图标;可填 iconfont 图标名或一个字符">
|
||||
<div className="group-icon-field">
|
||||
<span className="group-icon-preview">
|
||||
<GroupMark icon={icon} />
|
||||
</span>
|
||||
<Input
|
||||
id="group-icon"
|
||||
value={icon}
|
||||
placeholder="如 folder_line / 📁,留空不显示"
|
||||
onChange={(event) => setIcon(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<div className="group-dialog-row">
|
||||
<FormField label="排序权重" htmlFor="group-sort" hint="越小越靠前">
|
||||
<Input
|
||||
id="group-sort"
|
||||
type="number"
|
||||
value={sort}
|
||||
placeholder="自动"
|
||||
onChange={(event) => setSort(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="状态" htmlFor="group-enabled">
|
||||
<Select
|
||||
options={[
|
||||
{ value: '1', label: '启用' },
|
||||
{ value: '0', label: '禁用' },
|
||||
]}
|
||||
value={enabled ? '1' : '0'}
|
||||
onChange={(next) => setEnabled(next === '1')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ---------- 内容 ----------
|
||||
|
||||
function ContentPanel({ groups, onReload }: { groups: GroupNode[]; onReload: () => void }) {
|
||||
|
||||
+13
-1
@@ -20,6 +20,8 @@ export type GroupNode = {
|
||||
barColor: string
|
||||
previewLimit?: number | null
|
||||
sort: number
|
||||
/** 是否启用;禁用后前台不展示 */
|
||||
enabled: boolean
|
||||
entries: GroupEntry[]
|
||||
children: GroupNode[]
|
||||
}
|
||||
@@ -64,6 +66,9 @@ export async function createNode(input: {
|
||||
title: string
|
||||
parentKey?: string
|
||||
format?: GroupFormat
|
||||
icon?: string
|
||||
sort?: number
|
||||
enabled?: boolean
|
||||
}): Promise<SimpleResult> {
|
||||
try {
|
||||
const response = await fetch('/api/nodes', {
|
||||
@@ -80,7 +85,14 @@ export async function createNode(input: {
|
||||
|
||||
export async function updateNode(
|
||||
key: string,
|
||||
patch: { title?: string; parentKey?: string; sort?: number; format?: GroupFormat },
|
||||
patch: {
|
||||
title?: string
|
||||
parentKey?: string
|
||||
sort?: number
|
||||
format?: GroupFormat
|
||||
icon?: string
|
||||
enabled?: boolean
|
||||
},
|
||||
): Promise<SimpleResult> {
|
||||
try {
|
||||
const response = await fetch(`/api/nodes/${encodeURIComponent(key)}`, {
|
||||
|
||||
Reference in New Issue
Block a user