import { Brand, Button, Card, Caret, Empty, EngineIcon, FormField, Icon, ICON_NAMES, IconButton, Input, Menu, Modal, Notice, PasswordInput, Select, Space, SortableList, Switch, Table, Tabs, Tag, Text, Textarea, TextButton, Title, ToggleRow, Toaster, toast, type IconName, type MenuItem, } from '@stash/ui' import { useEffect, useMemo, useRef, useState } from 'react' import { changePassword, fetchSession, logout, type SessionState } from '../../auth' import { LoginDialog } from '../auth/LoginDialog' import { GroupMark as GroupGlyph } from '../common/GroupMark' import { isDarkScheme, readThemeOverride, writeThemeOverride } from '../../theme' import { BUILTIN_SEARCH_ENGINE_IDS, DEFAULT_SEARCH_ENGINES, fetchSettings, saveSettings, TONE_OPTIONS, type BackgroundSettings, type FontLink, type FontSource, type ProfileSettings, type SearchSettings, type SettingsDocument, type SettingsTone, type SiteSettings, type SiteTheme, } from '../../settings' import { createCard, createNode, deleteCard, deleteNode, fetchManage, flattenGroups, setCardGroups, updateCard, updateNode, type CardItem, type GroupEntry, type GroupFormat, type GroupNode, } from '../../manage' import { createItem, deleteItem, fetchItem, saveItem } from '../../items' import { useProjects } from '../../useProjects' import { fetchFonts, type FontFile, type Project } from '../../api' import siteBgLight from '../../assets/site-bg.jpg' import siteBgDark from '../../assets/site-bg-dark.jpg' /** * 新版后台:原子模型。 * * - 分组是唯一容器(可多层); * - 内容是条目,挂在分组上; * - 侧边栏、首页卡片都只是「勾选分组」的展示组合。 */ type TabKey = | 'groups' | 'content' | 'home-sidebar' | 'home-cards' | 'home-header' | 'home-background' | 'home-search' | 'profile' | 'system' /** 左侧导航:首页管理下面挂三个子项,页头也归到这一组 */ const ADMIN_NAV: MenuItem[] = [ { key: 'groups', label: '分组管理' }, { key: 'content', label: '数据管理' }, { key: 'home', label: '首页管理', children: [ { key: 'home-header', label: '页头' }, { key: 'home-background', label: '背景管理' }, { key: 'home-search', label: '搜索框' }, { key: 'home-sidebar', label: '侧边栏' }, { key: 'home-cards', label: '内容卡片' }, ], }, { key: 'profile', label: '资料管理' }, { key: 'system', label: '系统管理' }, ] 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 { projects } = useProjects() 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() }, []) // 后台壳也要吃站点级设置:保存后标签标题 / favicon / 配色立刻变,否则会以为「设了没反应」 // 注意:这里的 `document` 是设置文档,浏览器文档要用 `window.document` useEffect(() => { const meta = document?.settings.site if (meta === undefined) return const title = meta.title.trim() if (title !== '') window.document.title = title // 本机偏好优先于站点默认,跟前台一致 window.document.documentElement.dataset.scheme = readThemeOverride() ?? meta.theme const href = meta.favicon.trim() === '' ? '/favicon.svg' : meta.favicon.trim() let link = window.document.head.querySelector('link[rel="icon"]') if (link === null) { link = window.document.createElement('link') link.rel = 'icon' window.document.head.appendChild(link) } link.href = href }, [document?.settings.site.title, document?.settings.site.favicon, document?.settings.site.theme]) 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 saveSidebar = async (sidebar: SiteSettings['sidebar']) => { if (document === null || draft === null) return setSaving(true) const result = await saveSettings(document.version, { ...draft, sidebar }) setSaving(false) if (!result.ok) { toast.error(result.error) void load() return } setDocument(result.document) setDraft(clone(result.document.settings)) toast.success('侧边栏已保存') } /** 页头的明暗切换:改的是站点默认值,并清掉本机偏好,保证点完看到的就是新默认 */ const themeSetting = draft?.site.theme ?? document?.settings.site.theme ?? 'auto' const isDark = isDarkScheme(readThemeOverride() ?? themeSetting) const toggleTheme = async () => { if (document === null || draft === null) return const next = isDark ? 'light' : 'dark' setSaving(true) writeThemeOverride(null) const result = await saveSettings(document.version, { ...draft, site: { ...draft.site, theme: next }, }) setSaving(false) if (!result.ok) { toast.error(result.error) return } setDocument(result.document) setDraft(clone(result.document.settings)) } 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() } const brand = document?.settings.site.brand ?? { name: 'STASH', mark: 'S', logo: '' } const brandName = brand.name.trim() const brandMark = brand.mark.trim() || brandName.slice(0, 1) || 'S' const brandLogo = brand.logo.trim() return (
{/* 与前台页头同一套结构:品牌在左、图标操作在右,整条吸顶 */}
{/* 占住前台的中间列,让右侧操作和前台的右边缘对齐;后台没有滚动信息 */}
站点设置 分组是唯一容器;侧边栏和内容卡片都只是勾选分组来组合展示。 {!authenticated && (
需要登录后才能修改设置。
)} {authenticated && draft !== null && (
{tab === 'groups' && ( setPending({ kind: 'group', id: key, title })} /> )} {tab === 'content' && } {tab === 'home-sidebar' && (
侧边栏 { void saveSidebar({ ...draft.sidebar, ...(card === 'profile' ? { show_profile: visible } : card === 'service' ? { show_service: visible } : { show_nav: visible }), }) }} />
)} {tab === 'home-cards' && (
内容卡片 说明:卡片是首页上的一个区块;在这里勾选它包含哪些分组,并调整这些分组的先后顺序与预览条数。 分组是否启用仍由「分组管理」决定,停用的分组不会出现在卡片里。 setPending({ kind: 'card', id: String(id), title })} />
)} {tab === 'home-header' && ( setDraft({ ...draft, header })} site={draft.site} onChangeSite={(site) => setDraft({ ...draft, site })} /> )} {tab === 'home-background' && ( setDraft({ ...draft, background })} /> )} {tab === 'home-search' && ( setDraft({ ...draft, search })} /> )} {tab === 'profile' && ( setDraft({ ...draft, profile })} /> )} {tab === 'system' && ( setDraft({ ...draft, font })} /> )} {(tab === 'home-header' || tab === 'home-background' || tab === 'home-search' || tab === 'profile' || tab === 'system') && ( )}
)}
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 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 { const keys = new Set([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 = { apps: '应用', posters: '海报', articles: '图文', } function GroupMark({ icon }: { icon: string }) { const value = icon.trim() const isIcon = ICON_NAMES.includes(value as IconName) const classes = [ 'group-mark', value === '' ? 'group-mark-empty' : isIcon ? '' : 'group-mark-text', ] .filter(Boolean) .join(' ') return ( ) } type GroupDialogState = | { mode: 'root' } | { mode: 'child'; parent: GroupNode } | { mode: 'edit'; group: GroupNode } | null function GroupsPanel({ groups, onReload, onDelete, }: { groups: GroupNode[] onReload: () => void onDelete: (key: string, title: string) => void }) { const [collapsed, setCollapsed] = useState>(new Set()) const [dialog, setDialog] = useState(null) // 默认全部收起:数据第一次到位时,把所有有子节点的分组塞进 collapsed const seeded = useRef(false) useEffect(() => { if (seeded.current || groups.length === 0) return seeded.current = true const keys = new Set() const walk = (nodes: GroupNode[]) => { for (const node of nodes) { if (node.children.length > 0) { keys.add(node.key) walk(node.children) } } } walk(groups) setCollapsed(keys) }, [groups]) 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 ( 说明:分组用于控制前台侧边栏与卡片,可多层嵌套。内容(条目)只挂在分组上,格式由每条内容自己决定。
{ const hasChildren = node.children.length > 0 const open = !collapsed.has(node.key) return { key: node.key, name: (
{hasChildren ? ( ) : (
), sort: node.sort, count: countGroupEntries(node), status: ( void toggleEnabled(node, next)} label={`启用 ${node.title}`} size="sm" /> ), actions: (
} onClick={() => setDialog({ mode: 'child', parent: node })} > 新建子分组 } onClick={() => setDialog({ mode: 'edit', group: node })} > 编辑 } onClick={() => onDelete(node.key, node.title)} > 删除
), } })} emptyText={} /> setDialog(null)} onSaved={() => { setDialog(null) onReload() }} /> ) } function GroupDialog({ state, groups, onClose, onSaved, }: { 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 [icon, setIcon] = useState('') const [sort, setSort] = useState('') const [enabled, setEnabled] = useState(true) const [busy, setBusy] = useState(false) const [error, setError] = useState('') const [iconPickerOpen, setIconPickerOpen] = useState(false) const [iconQuery, setIconQuery] = useState('') // 图标库里 446 个名字,先按关键字过滤,没输入时只给前 120 个,避免一次渲染太多 const iconList = useMemo(() => { const query = iconQuery.trim().toLowerCase() const names = query === '' ? ICON_NAMES : ICON_NAMES.filter((name) => name.includes(query)) return names.slice(0, 120) }, [iconQuery]) useEffect(() => { if (!open) return setBusy(false) setError('') if (state?.mode === 'edit') { setKey(state.group.key) setTitle(state.group.title) setParentKey(state.group.parentKey ?? '') setIcon(state.group.icon) setSort(String(state.group.sort)) setEnabled(state.group.enabled) return } setKey('') setTitle('') setIcon('') setSort('') setEnabled(true) setParentKey(state?.mode === 'child' ? state.parent.key : '') }, [open, state]) const excluded = editing !== undefined ? descendantKeys(editing) : new Set() 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 ? {} : { 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: rootMode ? undefined : parentKey, icon, sort: sort.trim() === '' ? undefined : Number(sort), enabled, }) setBusy(false) if (!result.ok) { setError(result.error) return } toast.success(rootMode ? '根分组已创建' : '子分组已创建') onSaved() } return ( undefined : onClose} title={ state?.mode === 'edit' ? '编辑分组' : state?.mode === 'child' ? '新增子分组' : '新建根分组' } size="md" footer={ } > {error !== '' && ( {error} )} {showKey && ( setKey(event.target.value)} /> )} setTitle(event.target.value)} /> {(!isRootEdit) && ( setIcon(event.target.value)} /> {iconPickerOpen && (
setIconQuery('')} onChange={(event) => setIconQuery(event.target.value)} />
{iconList.map((name) => ( ))}
{iconList.length === 0 && ( 没有匹配的图标,换个关键字试试。 )}
)}
setSort(event.target.value)} /> ({ value: option.key, label: option.title, }))} value={group?.key ?? ''} onChange={setGroupKey} />
{group?.title ?? ''} 下共 {rows.length} 条(含子分组)
({ key: entry.id, title: {entry.name ?? entry.title ?? entry.id}, format: ( {FORMAT_LABEL[(entry.format ?? 'apps') as GroupFormat]} ), href: , group: {owner.title}, actions: (
} onClick={() => setDialog({ key: entry.id, groupKey: owner.key })} > 编辑 } onClick={async () => { const result = await deleteItem(entry.id) if (!result.ok) { toast.error(result.error) return } toast.success('条目已删除') onReload() }} > 删除
), }))} emptyText={} /> 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: '', format: 'apps' as GroupFormat, 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: '', format: 'apps', 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, format: result.entry.format, 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 = { format: form.format, 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({ format: next as GroupFormat })} /> 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 })} />