feat(admin): 分组管理改为树形表格(图标/排序/状态)
- 参考需求页重做分组管理:树形表格(名称/排序权重/关联网页数/状态/操作),去掉展开全部/折叠全部,新建根分组置于左侧 - 分组弹窗支持上级分组移动、格式、图标(可留空)、排序权重、启停状态 - 数据层 v20:node.enabled 启停;icon/sort 可编辑;禁用分组前台侧栏与卡片隐藏 - 修复同一父级下编辑排序权重被重置为末尾 - 测试 59 passed;Playwright 验证无图标/图标/排序/禁用/折叠
This commit is contained in:
+5
-4
@@ -97,7 +97,7 @@ README.md 运行、路由、库结构、登录说明
|
|||||||
### `/admin`(新原子模型)
|
### `/admin`(新原子模型)
|
||||||
| Tab | 能力 |
|
| Tab | 能力 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 分组管理 | 多层分组树:新建根/子分组、改名、根分组格式(应用/海报/图文)、删除(级联子分组与条目) |
|
| 分组管理 | 树形表格:新建根分组(左侧)、加子分组、编辑(名称/上级/格式/图标/排序/状态)、删除;行内状态开关即时生效;图标可留空 |
|
||||||
| 内容 | 选分组新增/编辑/删除条目;条目只属于一个分组 |
|
| 内容 | 选分组新增/编辑/删除条目;条目只属于一个分组 |
|
||||||
| 侧边栏 | 分组树逐项勾选(`settings.sidebar.tree_hidden`),子分组可单独勾选 |
|
| 侧边栏 | 分组树逐项勾选(`settings.sidebar.tree_hidden`),子分组可单独勾选 |
|
||||||
| 首页卡片 | 新建/改名/删除卡片;勾选若干分组(可混合格式),每组单独设条数 |
|
| 首页卡片 | 新建/改名/删除卡片;勾选若干分组(可混合格式),每组单独设条数 |
|
||||||
@@ -164,8 +164,8 @@ GET /api/backup SQLite 快照附件 需登录
|
|||||||
## 5. 数据库与迁移
|
## 5. 数据库与迁移
|
||||||
|
|
||||||
- 文件:开发态在 `target/debug/data/stash.db`;可用 `--root` / 数据目录覆盖(见 `config.rs`)。
|
- 文件:开发态在 `target/debug/data/stash.db`;可用 `--root` / 数据目录覆盖(见 `config.rs`)。
|
||||||
- 当前 `PRAGMA user_version = 19`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18→19、更高版本拒绝启动。
|
- 当前 `PRAGMA user_version = 20`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18→19→20、更高版本拒绝启动。
|
||||||
- `node` 是统一分组树(根节点 `kind='section'` 带 `view_mode` 格式,子节点 `kind='group'` 可多层);`card` / `card_group` 是首页卡片的展示组合(卡片不拥有内容)。
|
- `node` 是统一分组树(根节点 `kind='section'` 带 `view_mode` 格式,子节点 `kind='group'` 可多层);`enabled` 控制启停;`icon` 可为空。
|
||||||
- 主要表:`node`、`entry`、`entry_tag`、`section_block`、`admin`、`session`、`setting`、`audit`。
|
- 主要表:`node`、`entry`、`entry_tag`、`section_block`、`admin`、`session`、`setting`、`audit`。
|
||||||
- `entry.updated_at` 用于编辑乐观锁;`setting` 表 `key='site'` 存站点 JSON。
|
- `entry.updated_at` 用于编辑乐观锁;`setting` 表 `key='site'` 存站点 JSON。
|
||||||
- **设置/内容都是 SQLite**:备份用 `/api/backup`,恢复需停机替换文件。
|
- **设置/内容都是 SQLite**:备份用 `/api/backup`,恢复需停机替换文件。
|
||||||
@@ -205,8 +205,9 @@ GET /api/backup SQLite 快照附件 需登录
|
|||||||
| 图片上传 | 只支持 URL;上传需存储目录、大小/类型校验 |
|
| 图片上传 | 只支持 URL;上传需存储目录、大小/类型校验 |
|
||||||
| 拖拽排序 | 分组/卡片顺序暂用创建顺序;上移下移/拖拽未做 |
|
| 拖拽排序 | 分组/卡片顺序暂用创建顺序;上移下移/拖拽未做 |
|
||||||
| 多用户/角色 | 未做,当前单管理员 |
|
| 多用户/角色 | 未做,当前单管理员 |
|
||||||
| 分组移动 UI | 后端 `PATCH /api/nodes/{key}` 支持 `parentKey` 移动与防环;后台暂未暴露移动入口 |
|
| 分组移动 | 编辑弹窗里可改「上级分组」(防环);根分组不能移到子级 |
|
||||||
| 条目移动 | 内容弹窗里可直接换分组(`PATCH /api/items/{key}` 的 `nodeKey`) |
|
| 条目移动 | 内容弹窗里可直接换分组(`PATCH /api/items/{key}` 的 `nodeKey`) |
|
||||||
|
| 分组排序 | 可编辑「排序权重」数值;拖拽排序未做 |
|
||||||
| 旧接口 | `/api/catalog`、`/api/categories/*`、`/api/groups/*` 仍保留给旧组件/兼容,后续可删 |
|
| 旧接口 | `/api/catalog`、`/api/categories/*`、`/api/groups/*` 仍保留给旧组件/兼容,后续可删 |
|
||||||
| 登录限流 | 进程内计数,重启清空 |
|
| 登录限流 | 进程内计数,重启清空 |
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
|
|||||||
### 登录与设置(v0.1.1 起)
|
### 登录与设置(v0.1.1 起)
|
||||||
|
|
||||||
- 登录是**页内弹窗**:点右上角用户图标;首次使用弹窗里是「设置密码 + 确认」(至少 8 位);
|
- 登录是**页内弹窗**:点右上角用户图标;首次使用弹窗里是「设置密码 + 确认」(至少 8 位);
|
||||||
- 登录后点「站点设置」进入 `/admin`:分组管理(多层、格式、增删改)、内容(选分组新增/编辑条目)、侧边栏(勾选分组树)、首页卡片(建卡 + 勾选分组,可混合格式),以及页头滚动信息、资料与系统设置;
|
- 登录后点「站点设置」进入 `/admin`:分组管理(树形表格,可建根/子分组、图标可空、排序权重、启停、删除)、内容(选分组新增/编辑条目)、侧边栏(勾选分组树)、首页卡片(建卡 + 勾选分组,可混合格式),以及页头滚动信息、资料与系统设置;
|
||||||
- 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁;
|
- 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁;
|
||||||
- `/admin` 的「系统」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
|
- `/admin` 的「系统」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
|
||||||
- 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天);
|
- 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天);
|
||||||
|
|||||||
@@ -147,6 +147,14 @@ DELETE /api/items/:id
|
|||||||
|
|
||||||
## 11. 实施记录
|
## 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,前台切换)
|
### P9(2026-10-30,前台切换)
|
||||||
|
|
||||||
- **前台**:`AppNext` 改为读 `/api/catalog2`,首页按 `card` 勾选的分组渲染(可混合格式,每组条数独立),侧栏按分组树 + `tree_hidden` 渲染,分组页 `/g/<key>`、条目页 `/g/<key>/i/<id>`;
|
- **前台**:`AppNext` 改为读 `/api/catalog2`,首页按 `card` 勾选的分组渲染(可混合格式,每组条数独立),侧栏按分组树 + `tree_hidden` 渲染,分组页 `/g/<key>`、条目页 `/g/<key>/i/<id>`;
|
||||||
|
|||||||
+4
-1
@@ -63,13 +63,14 @@ struct TreeNode {
|
|||||||
bar_color: Option<String>,
|
bar_color: Option<String>,
|
||||||
preview_limit: Option<i64>,
|
preview_limit: Option<i64>,
|
||||||
sort: i64,
|
sort: i64,
|
||||||
|
enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 新的原子视图:完整分组树(多层、带格式、带条目),供新首页/侧栏/后台使用。
|
/// 新的原子视图:完整分组树(多层、带格式、带条目),供新首页/侧栏/后台使用。
|
||||||
pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
|
pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.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",
|
FROM node ORDER BY sort, key",
|
||||||
)
|
)
|
||||||
.map_err(map_error)?;
|
.map_err(map_error)?;
|
||||||
@@ -85,6 +86,7 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
|
|||||||
bar_color: row.get(5)?,
|
bar_color: row.get(5)?,
|
||||||
preview_limit: row.get(6)?,
|
preview_limit: row.get(6)?,
|
||||||
sort: row.get(7)?,
|
sort: row.get(7)?,
|
||||||
|
enabled: row.get(8)?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(map_error)?
|
.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(),
|
"barColor": node.bar_color.clone().unwrap_or_default(),
|
||||||
"previewLimit": node.preview_limit,
|
"previewLimit": node.preview_limit,
|
||||||
"sort": node.sort,
|
"sort": node.sort,
|
||||||
|
"enabled": node.enabled,
|
||||||
"entries": items,
|
"entries": items,
|
||||||
"children": child_values,
|
"children": child_values,
|
||||||
}))
|
}))
|
||||||
|
|||||||
+76
-20
@@ -23,6 +23,15 @@ pub struct NewNode {
|
|||||||
/// 根分组的格式:apps / posters / articles(子分组继承,不需要传)
|
/// 根分组的格式:apps / posters / articles(子分组继承,不需要传)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub format: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
@@ -34,6 +43,9 @@ pub struct UpdateNode {
|
|||||||
pub sort: Option<i64>,
|
pub sort: Option<i64>,
|
||||||
/// 只对根分组生效
|
/// 只对根分组生效
|
||||||
pub format: Option<String>,
|
pub format: Option<String>,
|
||||||
|
/// 图标:空字符串表示清除图标
|
||||||
|
pub icon: Option<String>,
|
||||||
|
pub enabled: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn valid_key(key: &str) -> 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
|
Some(parent) => conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1",
|
"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)?,
|
.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)?;
|
let tx = conn.transaction().map_err(map_error)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO node (key, parent_key, title, kind, view_mode, sort, show_in_sidebar, allow_groups)
|
"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, 1, 1)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1, 1)",
|
||||||
params![key, new.parent_key, title, kind, view_mode, sort],
|
params![
|
||||||
|
key,
|
||||||
|
new.parent_key,
|
||||||
|
title,
|
||||||
|
kind,
|
||||||
|
view_mode,
|
||||||
|
icon,
|
||||||
|
enabled,
|
||||||
|
sort
|
||||||
|
],
|
||||||
)
|
)
|
||||||
.map_err(map_error)?;
|
.map_err(map_error)?;
|
||||||
entries::audit(&tx, "node.create", key, title)?;
|
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> {
|
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(
|
.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],
|
[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()
|
.optional()
|
||||||
.map_err(map_error)?;
|
.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}")));
|
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()));
|
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 sort = patch.sort.unwrap_or(current_sort);
|
||||||
|
|
||||||
let tx = conn.transaction().map_err(map_error)?;
|
let tx = conn.transaction().map_err(map_error)?;
|
||||||
// 移动时重新排到新父级末尾;不移动则保留原顺序
|
// 只有真正换了父级才排到末尾;同一父级下编辑排序权重时按传入值保存
|
||||||
let final_sort = match patch.parent_key {
|
let parent_changed = patch
|
||||||
Some(parent) => tx
|
.parent_key
|
||||||
.query_row(
|
.as_deref()
|
||||||
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1 AND key <> ?2",
|
.is_some_and(|parent| Some(parent) != current_parent.as_deref());
|
||||||
params![parent, key],
|
let final_sort = if parent_changed {
|
||||||
|row| row.get::<_, i64>(0),
|
let parent = patch.parent_key.as_deref().unwrap_or_default();
|
||||||
)
|
tx.query_row(
|
||||||
.map_err(map_error)?,
|
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1 AND key <> ?2",
|
||||||
None => sort,
|
params![parent, key],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.map_err(map_error)?
|
||||||
|
} else {
|
||||||
|
sort
|
||||||
};
|
};
|
||||||
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE node SET title = ?1, parent_key = ?2, view_mode = ?3, sort = ?4 WHERE key = ?5",
|
"UPDATE node SET title = ?1, parent_key = ?2, view_mode = ?3, sort = ?4,
|
||||||
params![title, parent_key, format, final_sort, key],
|
icon = ?5, enabled = ?6
|
||||||
|
WHERE key = ?7",
|
||||||
|
params![title, parent_key, format, final_sort, icon, enabled, key],
|
||||||
)
|
)
|
||||||
.map_err(map_error)?;
|
.map_err(map_error)?;
|
||||||
entries::audit(&tx, "node.update", key, &title)?;
|
entries::audit(&tx, "node.update", key, &title)?;
|
||||||
|
|||||||
+13
-1
@@ -48,6 +48,8 @@ CREATE TABLE IF NOT EXISTS node (
|
|||||||
-- 是否出现在侧栏:0 表示只当首页卡片/内容,不进导航
|
-- 是否出现在侧栏:0 表示只当首页卡片/内容,不进导航
|
||||||
allow_groups INTEGER NOT NULL DEFAULT 0 CHECK (allow_groups IN (0, 1)),
|
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, -- 同级顺序,越小越靠前
|
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)
|
||||||
@@ -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:首页卡片改为「卡片 + 勾选分组」。
|
/// v18 → v19:首页卡片改为「卡片 + 勾选分组」。
|
||||||
const V19_SQL: &str = r#"
|
const V19_SQL: &str = r#"
|
||||||
CREATE TABLE IF NOT EXISTS card (
|
CREATE TABLE IF NOT EXISTS card (
|
||||||
@@ -269,7 +276,7 @@ pub struct SeedReport {
|
|||||||
///
|
///
|
||||||
/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来,
|
/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来,
|
||||||
/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。
|
/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。
|
||||||
pub const SCHEMA_VERSION: i64 = 19;
|
pub const SCHEMA_VERSION: i64 = 20;
|
||||||
|
|
||||||
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))
|
||||||
@@ -324,6 +331,11 @@ pub fn migrate(conn: &Connection) -> Result<i64, StashError> {
|
|||||||
version = 19;
|
version = 19;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if version == 19 {
|
||||||
|
conn.execute_batch(V20_SQL).map_err(map_error)?;
|
||||||
|
version = 20;
|
||||||
|
}
|
||||||
|
|
||||||
conn.pragma_update(None, "user_version", version)
|
conn.pragma_update(None, "user_version", version)
|
||||||
.map_err(map_error)?;
|
.map_err(map_error)?;
|
||||||
|
|
||||||
|
|||||||
@@ -1048,6 +1048,186 @@
|
|||||||
gap: var(--su-space-2);
|
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 > * {
|
.admin-category-row > * {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ export default function AppNext() {
|
|||||||
|
|
||||||
const build = (nodes: GroupNode[], parentKey: string): MenuItem[] =>
|
const build = (nodes: GroupNode[], parentKey: string): MenuItem[] =>
|
||||||
sortNodes(nodes, parentKey).flatMap((node) => {
|
sortNodes(nodes, parentKey).flatMap((node) => {
|
||||||
|
if (node.enabled === false) return []
|
||||||
if (hidden.has(node.key)) return build(node.children, node.key)
|
if (hidden.has(node.key)) return build(node.children, node.key)
|
||||||
const children = build(node.children, node.key)
|
const children = build(node.children, node.key)
|
||||||
return [
|
return [
|
||||||
@@ -295,7 +296,10 @@ export default function AppNext() {
|
|||||||
function renderCard(card: CardItem) {
|
function renderCard(card: CardItem) {
|
||||||
const resolved = card.groups
|
const resolved = card.groups
|
||||||
.map((ref) => ({ ref, group: findGroup(manage.groups, ref.groupKey) }))
|
.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 total = resolved.reduce((sum, item) => sum + groupEntryCount(item.group), 0)
|
||||||
const first = resolved[0]?.group
|
const first = resolved[0]?.group
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
FormField,
|
FormField,
|
||||||
Icon,
|
Icon,
|
||||||
|
ICON_NAMES,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
Notice,
|
Notice,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
Toaster,
|
Toaster,
|
||||||
toast,
|
toast,
|
||||||
|
type IconName,
|
||||||
} from '@stash/ui'
|
} from '@stash/ui'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { changePassword, fetchSession, logout, type SessionState } from '../../auth'
|
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({
|
function GroupsPanel({
|
||||||
groups,
|
groups,
|
||||||
onReload,
|
onReload,
|
||||||
@@ -338,206 +396,390 @@ function GroupsPanel({
|
|||||||
onReload: () => void
|
onReload: () => void
|
||||||
onDelete: (key: string, title: string) => 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 (
|
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 size="sm" tone="muted">
|
||||||
分组可多层嵌套,格式由根分组决定。内容(条目)只挂在分组上;侧边栏和卡片都从这里勾选。
|
分组用于控制前台侧边栏与卡片;可多层嵌套,根分组决定格式。内容(条目)只挂在分组上。
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<NewNodeRow onReload={onReload} />
|
<div className="group-toolbar">
|
||||||
|
<Button
|
||||||
|
icon={<Icon name="add_line" />}
|
||||||
|
onClick={() => setDialog({ mode: 'root' })}
|
||||||
|
>
|
||||||
|
新建根分组
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{groups.map((group) => (
|
<div className="group-table-wrap">
|
||||||
<GroupRow
|
<table className="group-table">
|
||||||
key={group.key}
|
<thead>
|
||||||
group={group}
|
<tr>
|
||||||
depth={0}
|
<th>分组名称</th>
|
||||||
isRoot
|
<th className="group-col-sort">排序权重</th>
|
||||||
onReload={onReload}
|
<th className="group-col-count">关联网页数</th>
|
||||||
onDelete={onDelete}
|
<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>
|
</Space>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NewNodeRow({
|
function GroupDialog({
|
||||||
parentKey,
|
state,
|
||||||
onReload,
|
groups,
|
||||||
onCancel,
|
onClose,
|
||||||
|
onSaved,
|
||||||
}: {
|
}: {
|
||||||
parentKey?: string
|
state: GroupDialogState
|
||||||
onReload: () => void
|
groups: GroupNode[]
|
||||||
onCancel?: () => void
|
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 [key, setKey] = useState('')
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
|
const [parentKey, setParentKey] = useState('')
|
||||||
const [format, setFormat] = useState<GroupFormat>('apps')
|
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 [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 () => {
|
const submit = async () => {
|
||||||
|
if (title.trim() === '') {
|
||||||
|
setError('分组名称不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (showKey && key.trim() === '') {
|
||||||
|
setError('分组标识不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setBusy(true)
|
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({
|
const result = await createNode({
|
||||||
key: key.trim(),
|
key: key.trim(),
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
parentKey,
|
parentKey: rootMode ? undefined : parentKey,
|
||||||
format: parentKey === undefined ? format : undefined,
|
format: rootMode ? format : undefined,
|
||||||
|
icon,
|
||||||
|
sort: sort.trim() === '' ? undefined : Number(sort),
|
||||||
|
enabled,
|
||||||
})
|
})
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
toast.error(result.error)
|
setError(result.error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
toast.success(parentKey === undefined ? '根分组已创建' : '子分组已创建')
|
toast.success(rootMode ? '根分组已创建' : '子分组已创建')
|
||||||
setKey('')
|
onSaved()
|
||||||
setTitle('')
|
|
||||||
onReload()
|
|
||||||
onCancel?.()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-new-node-row">
|
<Modal
|
||||||
<div style={{ width: 170 }}>
|
open={open}
|
||||||
<Input
|
onClose={busy ? () => undefined : onClose}
|
||||||
value={key}
|
title={
|
||||||
placeholder="标识,如 books"
|
state?.mode === 'edit'
|
||||||
aria-label={parentKey === undefined ? '根分组标识' : '子分组标识'}
|
? '编辑分组'
|
||||||
onChange={(event) => setKey(event.target.value)}
|
: state?.mode === 'child'
|
||||||
/>
|
? '新增子分组'
|
||||||
</div>
|
: '新建根分组'
|
||||||
<div style={{ flex: 1, minWidth: 220 }}>
|
}
|
||||||
<Input
|
size="md"
|
||||||
value={title}
|
footer={
|
||||||
placeholder="名称,如 书单"
|
<Space gap="sm" justify="end">
|
||||||
aria-label={parentKey === undefined ? '根分组名称' : '子分组名称'}
|
<Button variant="secondary" onClick={onClose}>
|
||||||
onChange={(event) => setTitle(event.target.value)}
|
取消
|
||||||
/>
|
</Button>
|
||||||
</div>
|
<Button
|
||||||
{parentKey === undefined && (
|
loading={busy}
|
||||||
<div className="admin-node-format">
|
icon={<Icon name="check_line" />}
|
||||||
<Select
|
onClick={() => void submit()}
|
||||||
options={FORMAT_OPTIONS}
|
>
|
||||||
value={format}
|
保存
|
||||||
onChange={(next) => setFormat(next as GroupFormat)}
|
</Button>
|
||||||
/>
|
</Space>
|
||||||
</div>
|
}
|
||||||
)}
|
>
|
||||||
<Button
|
<Space direction="column" gap="md" align="stretch">
|
||||||
variant="secondary"
|
{error !== '' && (
|
||||||
size="sm"
|
<Notice tone="error" title="保存失败">
|
||||||
icon={<Icon name="add_line" />}
|
{error}
|
||||||
loading={busy}
|
</Notice>
|
||||||
disabled={key.trim() === '' || title.trim() === ''}
|
)}
|
||||||
onClick={() => void submit()}
|
|
||||||
>
|
|
||||||
{parentKey === undefined ? '新建根分组' : '新增子分组'}
|
|
||||||
</Button>
|
|
||||||
{onCancel !== undefined && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function GroupRow({
|
{showKey && (
|
||||||
group,
|
<FormField label="分组标识" htmlFor="group-key" hint="小写字母、数字与连字符,创建后不可改">
|
||||||
depth,
|
<Input
|
||||||
isRoot,
|
id="group-key"
|
||||||
onReload,
|
value={key}
|
||||||
onDelete,
|
placeholder="如 frontend"
|
||||||
}: {
|
onChange={(event) => setKey(event.target.value)}
|
||||||
group: GroupNode
|
/>
|
||||||
depth: number
|
</FormField>
|
||||||
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)
|
|
||||||
|
|
||||||
const save = async () => {
|
<FormField label="分组名称" htmlFor="group-title" required>
|
||||||
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">
|
|
||||||
<Input
|
<Input
|
||||||
|
id="group-title"
|
||||||
value={title}
|
value={title}
|
||||||
aria-label={`${group.title} 名称`}
|
placeholder="如 前端开发、常用工具"
|
||||||
onChange={(event) => setTitle(event.target.value)}
|
onChange={(event) => setTitle(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
{isRoot === true && (
|
|
||||||
<div className="admin-node-format">
|
{(!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
|
<Select
|
||||||
options={FORMAT_OPTIONS}
|
options={FORMAT_OPTIONS}
|
||||||
value={format}
|
value={format}
|
||||||
onChange={(next) => setFormat(next as GroupFormat)}
|
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 }}>
|
<FormField label="图标样式" htmlFor="group-icon" hint="留空则不显示图标;可填 iconfont 图标名或一个字符">
|
||||||
{adding && (
|
<div className="group-icon-field">
|
||||||
<NewNodeRow parentKey={group.key} onReload={onReload} onCancel={() => setAdding(false)} />
|
<span className="group-icon-preview">
|
||||||
)}
|
<GroupMark icon={icon} />
|
||||||
{group.children.map((child) => (
|
</span>
|
||||||
<GroupRow
|
<Input
|
||||||
key={child.key}
|
id="group-icon"
|
||||||
group={child}
|
value={icon}
|
||||||
depth={0}
|
placeholder="如 folder_line / 📁,留空不显示"
|
||||||
onReload={onReload}
|
onChange={(event) => setIcon(event.target.value)}
|
||||||
onDelete={onDelete}
|
/>
|
||||||
/>
|
</div>
|
||||||
))}
|
</FormField>
|
||||||
<Text size="sm" tone="muted">
|
|
||||||
{group.entries.length} 条内容
|
<div className="group-dialog-row">
|
||||||
</Text>
|
<FormField label="排序权重" htmlFor="group-sort" hint="越小越靠前">
|
||||||
</div>
|
<Input
|
||||||
</div>
|
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 }) {
|
function ContentPanel({ groups, onReload }: { groups: GroupNode[]; onReload: () => void }) {
|
||||||
|
|||||||
+13
-1
@@ -20,6 +20,8 @@ export type GroupNode = {
|
|||||||
barColor: string
|
barColor: string
|
||||||
previewLimit?: number | null
|
previewLimit?: number | null
|
||||||
sort: number
|
sort: number
|
||||||
|
/** 是否启用;禁用后前台不展示 */
|
||||||
|
enabled: boolean
|
||||||
entries: GroupEntry[]
|
entries: GroupEntry[]
|
||||||
children: GroupNode[]
|
children: GroupNode[]
|
||||||
}
|
}
|
||||||
@@ -64,6 +66,9 @@ export async function createNode(input: {
|
|||||||
title: string
|
title: string
|
||||||
parentKey?: string
|
parentKey?: string
|
||||||
format?: GroupFormat
|
format?: GroupFormat
|
||||||
|
icon?: string
|
||||||
|
sort?: number
|
||||||
|
enabled?: boolean
|
||||||
}): Promise<SimpleResult> {
|
}): Promise<SimpleResult> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/nodes', {
|
const response = await fetch('/api/nodes', {
|
||||||
@@ -80,7 +85,14 @@ export async function createNode(input: {
|
|||||||
|
|
||||||
export async function updateNode(
|
export async function updateNode(
|
||||||
key: string,
|
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> {
|
): Promise<SimpleResult> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/nodes/${encodeURIComponent(key)}`, {
|
const response = await fetch(`/api/nodes/${encodeURIComponent(key)}`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user