feat(admin): 分组管理改为树形表格(图标/排序/状态)
- 参考需求页重做分组管理:树形表格(名称/排序权重/关联网页数/状态/操作),去掉展开全部/折叠全部,新建根分组置于左侧 - 分组弹窗支持上级分组移动、格式、图标(可留空)、排序权重、启停状态 - 数据层 v20:node.enabled 启停;icon/sort 可编辑;禁用分组前台侧栏与卡片隐藏 - 修复同一父级下编辑排序权重被重置为末尾 - 测试 59 passed;Playwright 验证无图标/图标/排序/禁用/折叠
This commit is contained in:
@@ -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