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
+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)}`, {