From 2ccfb9b6236e92d08a0ed617ff7117665e606cb2 Mon Sep 17 00:00:00 2001 From: CNWei Date: Fri, 30 Oct 2026 20:40:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=96=B0=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E6=9E=B6=E6=9E=84=EF=BC=88=E5=88=86=E7=BB=84?= =?UTF-8?q?=E7=AE=A1=E7=90=86/=E5=86=85=E5=AE=B9/=E4=BE=A7=E8=BE=B9?= =?UTF-8?q?=E6=A0=8F/=E5=8D=A1=E7=89=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /admin 切换为 AdminNext:分组管理(多层树、格式、增删改)、内容(选分组新增/编辑/删除条目) - 侧边栏按分组树勾选,存入 settings.sidebar.tree_hidden - 首页卡片:新建卡片、勾选分组(可混合格式)、每组条数 - 新增 manage.ts 客户端;旧 AdminPage 暂留作回退 - 测试 59 passed;Playwright 跑通 建根/子分组 → 加条目 → 侧栏勾选 → 混合卡片 --- src/db/settings.rs | 6 + stash-web/src/App.css | 24 + stash-web/src/components/admin/AdminNext.tsx | 1305 ++++++++++++++++++ stash-web/src/main.tsx | 4 +- stash-web/src/manage.ts | 181 +++ stash-web/src/settings.ts | 6 + 6 files changed, 1524 insertions(+), 2 deletions(-) create mode 100644 stash-web/src/components/admin/AdminNext.tsx create mode 100644 stash-web/src/manage.ts diff --git a/src/db/settings.rs b/src/db/settings.rs index 7ae7385..f4553bb 100644 --- a/src/db/settings.rs +++ b/src/db/settings.rs @@ -92,6 +92,10 @@ pub struct SidebarSettings { pub shown_groups: Vec, /// 每个分区下分组的顺序:分区 id → 分组 id 列表 pub group_order: std::collections::HashMap>, + /// 新模型:侧栏隐藏的分组 key(任意层级) + pub tree_hidden: Vec, + /// 新模型:每级分组的顺序,键是父分组 key(根级用空串) + pub tree_order: std::collections::HashMap>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] @@ -181,6 +185,8 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> { if settings.sidebar.hidden_groups.len() > 200 || settings.sidebar.shown_groups.len() > 200 || settings.sidebar.group_order.len() > 100 + || settings.sidebar.tree_hidden.len() > 500 + || settings.sidebar.tree_order.len() > 200 { return Err(StashError::InvalidDirectory("侧栏分组配置过长".into())); } diff --git a/stash-web/src/App.css b/stash-web/src/App.css index d40ba49..6b69a50 100644 --- a/stash-web/src/App.css +++ b/stash-web/src/App.css @@ -997,6 +997,30 @@ white-space: nowrap; } +/* 新后台:分组树行 / 新建行 / 卡片头,用弹性布局避免网格列数对不上 */ +.admin-node-row { + display: flex; + align-items: center; + gap: var(--su-space-2); +} + +.admin-node-title { + flex: 1; + min-width: 0; +} + +.admin-node-format { + width: 170px; + flex: none; +} + +.admin-new-node-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--su-space-2); +} + .admin-category-row > * { min-width: 0; } diff --git a/stash-web/src/components/admin/AdminNext.tsx b/stash-web/src/components/admin/AdminNext.tsx new file mode 100644 index 0000000..8d3fe94 --- /dev/null +++ b/stash-web/src/components/admin/AdminNext.tsx @@ -0,0 +1,1305 @@ +import { + Button, + Card, + FormField, + Icon, + Input, + Modal, + Notice, + PasswordInput, + Select, + Space, + Switch, + Tabs, + Text, + Textarea, + Title, + Toaster, + toast, +} from '@stash/ui' +import { useEffect, useMemo, useState } from 'react' +import { changePassword, fetchSession, logout, type SessionState } from '../../auth' +import { LoginDialog } from '../auth/LoginDialog' +import { + fetchSettings, + saveSettings, + TONE_OPTIONS, + type ProfileSettings, + type SettingsDocument, + type SettingsTone, + type SiteSettings, +} from '../../settings' +import { + createCard, + createNode, + deleteCard, + deleteNode, + fetchManage, + flattenGroups, + setCardGroups, + updateCard, + updateNode, + type CardItem, + type GroupFormat, + type GroupNode, +} from '../../manage' +import { createItem, deleteItem, fetchItem, saveItem } from '../../items' + +/** + * 新版后台:原子模型。 + * + * - 分组是唯一容器(可多层、带格式); + * - 内容是条目,挂在分组上; + * - 侧边栏、首页卡片都只是「勾选分组」的展示组合。 + */ +type TabKey = 'groups' | 'content' | 'sidebar' | 'cards' | 'header' | 'profile' | 'system' + +const FORMAT_OPTIONS = [ + { value: 'apps', label: '应用' }, + { value: 'posters', label: '海报' }, + { value: 'articles', label: '图文' }, +] + +const clone = (settings: SiteSettings): SiteSettings => structuredClone(settings) + +function findGroup(groups: GroupNode[], key: string): GroupNode | undefined { + for (const group of groups) { + if (group.key === key) return group + const found = findGroup(group.children, key) + if (found !== undefined) return found + } + return undefined +} + +function findGroupByEntry(groups: GroupNode[], entryId: string): GroupNode | undefined { + for (const group of groups) { + if (group.entries.some((entry) => entry.id === entryId)) return group + const found = findGroupByEntry(group.children, entryId) + if (found !== undefined) return found + } + return undefined +} + +function flattenCardsGroups(groups: GroupNode[]) { + return flattenGroups(groups) +} + +export function AdminNext() { + const [session, setSession] = useState(null) + const [loginOpen, setLoginOpen] = useState(false) + const [document, setDocument] = useState(null) + const [draft, setDraft] = useState(null) + const [groups, setGroups] = useState([]) + const [cards, setCards] = useState([]) + const [tab, setTab] = useState('groups') + const [saving, setSaving] = useState(false) + const [pending, setPending] = useState<{ + kind: 'group' | 'card' + id: string + title: string + } | null>(null) + const [deleting, setDeleting] = useState(false) + + const load = async () => { + const [nextSession, nextDocument, manage] = await Promise.all([ + fetchSession(), + fetchSettings(), + fetchManage(), + ]) + setSession(nextSession) + setDocument(nextDocument) + setDraft(clone(nextDocument.settings)) + setGroups(manage.groups) + setCards(manage.cards) + } + + useEffect(() => { + void load() + }, []) + + const authenticated = session?.authenticated === true + + const save = async () => { + if (document === null || draft === null) return + setSaving(true) + const result = await saveSettings(document.version, draft) + setSaving(false) + if (!result.ok) { + toast.error(result.error) + return + } + setDocument(result.document) + setDraft(clone(result.document.settings)) + toast.success('已保存') + } + + const confirmDelete = async () => { + if (pending === null) return + setDeleting(true) + const result = + pending.kind === 'group' + ? await deleteNode(pending.id) + : await deleteCard(Number(pending.id)) + setDeleting(false) + if (!result.ok) { + toast.error(result.error) + return + } + toast.success(`已删除「${pending.title}」`) + setPending(null) + await load() + } + + return ( +
+ +
+ + + STASH + + + + {authenticated && ( + + )} + +
+ +
+ + + 站点设置 + + 分组是唯一容器;侧边栏和首页卡片都只是勾选分组来组合展示。 + + + + {!authenticated && ( +
+ 需要登录后才能修改设置。 + +
+ )} + + {authenticated && draft !== null && ( + <> + setTab(key as TabKey)} + /> + + {tab === 'groups' && ( + setPending({ kind: 'group', id: key, title })} + /> + )} + + {tab === 'content' && } + + {tab === 'sidebar' && ( + setDraft({ ...draft, sidebar })} + onSave={save} + saving={saving} + /> + )} + + {tab === 'cards' && ( + setPending({ kind: 'card', id: String(id), title })} + /> + )} + + {tab === 'header' && ( + setDraft({ ...draft, header })} + /> + )} + + {tab === 'profile' && ( + setDraft({ ...draft, profile })} + /> + )} + + {tab === 'system' && } + + {(tab === 'header' || tab === 'profile' || tab === 'sidebar') && ( + + + + + )} + + )} +
+
+ + setLoginOpen(false)} + onSuccess={(next) => { + setSession(next) + setLoginOpen(false) + void load() + }} + /> + + { + if (!deleting) setPending(null) + }} + title={pending === null ? '删除' : `删除「${pending.title}」?`} + size="sm" + footer={ + + + + + } + > + + {pending?.kind === 'group' + ? '删除分组会连同子分组与其中的条目一起删除,且不可恢复。' + : '卡片只是展示组合,删除不会影响内容。'} + + +
+ ) +} + +// ---------- 分组管理 ---------- + +function GroupsPanel({ + groups, + onReload, + onDelete, +}: { + groups: GroupNode[] + onReload: () => void + onDelete: (key: string, title: string) => void +}) { + return ( + + + 分组可多层嵌套,格式由根分组决定。内容(条目)只挂在分组上;侧边栏和卡片都从这里勾选。 + + + + + {groups.map((group) => ( + + ))} + + {groups.length === 0 && 还没有分组,先新建一个根分组。} + + ) +} + +function NewNodeRow({ + parentKey, + onReload, + onCancel, +}: { + parentKey?: string + onReload: () => void + onCancel?: () => void +}) { + const [key, setKey] = useState('') + const [title, setTitle] = useState('') + const [format, setFormat] = useState('apps') + const [busy, setBusy] = useState(false) + + const submit = async () => { + setBusy(true) + const result = await createNode({ + key: key.trim(), + title: title.trim(), + parentKey, + format: parentKey === undefined ? format : undefined, + }) + setBusy(false) + if (!result.ok) { + toast.error(result.error) + return + } + toast.success(parentKey === undefined ? '根分组已创建' : '子分组已创建') + setKey('') + setTitle('') + onReload() + onCancel?.() + } + + return ( +
+
+ setKey(event.target.value)} + /> +
+
+ setTitle(event.target.value)} + /> +
+ {parentKey === undefined && ( +
+ setTitle(event.target.value)} + /> +
+ {isRoot === true && ( +
+ ({ + value: option.key, + label: `${' '.repeat(option.depth)}${option.title}(${option.format})`, + }))} + value={group?.key ?? ''} + onChange={setGroupKey} + /> +
+ + + + + + {group?.title ?? ''} 下 {group?.entries.length ?? 0} 条 + + {(group?.entries ?? []).map((entry) => ( +
+ {entry.name ?? entry.title ?? entry.id} + + {group?.title} + +
+ ))} +
+ + setDialog(null)} + onSaved={() => { + setDialog(null) + onReload() + }} + /> + + ) +} + +function EntryDialog({ + open, + entryKey, + groups, + defaultGroupKey, + onClose, + onSaved, +}: { + open: boolean + entryKey: string | null + groups: GroupNode[] + defaultGroupKey: string + onClose: () => void + onSaved: () => void +}) { + const options = useMemo(() => flattenCardsGroups(groups), [groups]) + const [groupKey, setGroupKey] = useState(defaultGroupKey) + const [updatedAt, setUpdatedAt] = useState(0) + const [loading, setLoading] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const [form, setForm] = useState({ + key: '', + name: '', + href: '', + icon: '', + description: '', + cover: '', + tags: '', + comment: '', + body: '', + }) + + useEffect(() => { + if (!open) return + setError('') + setGroupKey( + entryKey === null ? defaultGroupKey : (findGroupByEntry(groups, entryKey)?.key ?? defaultGroupKey), + ) + + if (entryKey === null) { + setLoading(false) + setUpdatedAt(0) + setForm({ + key: '', + name: '', + href: '', + icon: '', + description: '', + cover: '', + tags: '', + comment: '', + body: '', + }) + return + } + + setLoading(true) + fetchItem(entryKey).then((result) => { + setLoading(false) + if (!result.ok) { + setError(result.error) + return + } + setUpdatedAt(result.entry.updated_at) + setForm({ + key: result.entry.key, + name: result.entry.name, + href: result.entry.href, + icon: result.entry.icon, + description: result.entry.description, + cover: result.entry.cover, + tags: result.entry.tags.join(','), + comment: result.entry.comment, + body: result.entry.body, + }) + }) + }, [open, entryKey]) + + const update = (patch: Partial) => + setForm((current) => ({ ...current, ...patch })) + + const submit = async () => { + if (form.name.trim() === '') { + setError('标题不能为空') + return + } + if (entryKey === null && form.key.trim() === '') { + setError('标识不能为空') + return + } + + setBusy(true) + setError('') + const item = { + name: form.name.trim(), + description: form.description, + icon: form.icon, + tags: form.tags + .split(/[,,]/) + .map((tag) => tag.trim()) + .filter(Boolean), + badge: '', + comment: form.comment, + summary: '', + body: form.body, + cover: form.cover, + banner: '', + href: form.href, + nodeKey: groupKey, + } + + const result = + entryKey === null + ? await createItem({ key: form.key.trim(), nodeKey: groupKey, item }) + : await saveItem(entryKey, updatedAt, item) + setBusy(false) + if (!result.ok) { + setError(result.error) + return + } + toast.success(entryKey === null ? '条目已添加' : '条目已保存') + onSaved() + } + + return ( + undefined : onClose} + title={entryKey === null ? '新增条目' : '编辑条目'} + size="lg" + footer={ + + + + + } + > + {loading ? ( + 正在读取… + ) : ( + + {error !== '' && ( + + {error} + + )} +
+ update({ key: event.target.value })} + /> + )} + update({ name: event.target.value })} + /> + update({ href: event.target.value })} + /> + update({ icon: event.target.value })} + /> + update({ description: event.target.value })} + /> + update({ cover: event.target.value })} + /> + update({ tags: event.target.value })} + /> + update({ comment: event.target.value })} + /> +
+