import { Button, Card, FormField, Icon, ICON_NAMES, IconButton, Input, Modal, Notice, PasswordInput, Select, Space, Switch, Tabs, Tag, Text, Textarea, Title, Toaster, toast, type IconName, } 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 GroupEntry, type GroupFormat, type GroupNode, } from '../../manage' import { createItem, deleteItem, fetchItem, saveItem } from '../../items' import { useProjects } from '../../useProjects' import type { Project } from '../../api' /** * 新版后台:原子模型。 * * - 分组是唯一容器(可多层、带格式); * - 内容是条目,挂在分组上; * - 侧边栏、首页卡片都只是「勾选分组」的展示组合。 */ type TabKey = 'groups' | 'content' | 'home' | '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 { 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 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]) 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 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 (
{/* 与前台页头同一套结构:品牌在左、图标操作在右,整条吸顶 */}
{brandLogo !== '' ? ( ) : ( )} {brandName !== '' && {brandName}}
} onClick={() => window.location.assign('/')} /> {authenticated && ( } onClick={() => { void logout().then(() => window.location.assign('/')) }} /> )}
站点设置 分组是唯一容器;侧边栏和首页卡片都只是勾选分组来组合展示。 {!authenticated && (
需要登录后才能修改设置。
)} {authenticated && draft !== null && ( <> setTab(key as TabKey)} /> {tab === 'groups' && ( setPending({ kind: 'group', id: key, title })} /> )} {tab === 'content' && } {tab === 'home' && (
侧边栏 { void saveSidebar({ ...draft.sidebar, ...(card === 'profile' ? { show_profile: visible } : card === 'service' ? { show_service: visible } : { show_nav: visible }), }) }} />
首页卡片 卡片 = 一张首页区块 + 勾选它包含哪些分组;分组本身的启停仍由「分组管理」决定。 setPending({ kind: 'card', id: String(id), title })} />
)} {tab === 'header' && ( setDraft({ ...draft, header })} site={draft.site} onChangeSite={(site) => setDraft({ ...draft, site })} /> )} {tab === 'profile' && ( setDraft({ ...draft, profile })} /> )} {tab === 'system' && } {(tab === 'header' || tab === 'profile') && ( )} )}
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 }) { if (icon.trim() === '') { return