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
+5 -4
View File
@@ -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/*` 仍保留给旧组件/兼容,后续可删 |
| 登录限流 | 进程内计数,重启清空 |
+1 -1
View File
@@ -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 天);
+8
View File
@@ -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
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)?;
+180
View File
@@ -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;
}
+5 -1
View File
@@ -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
+396 -154
View File
@@ -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
View File
@@ -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)}`, {