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 { createCategory, createGroup, deleteCategory, deleteGroup, fetchSettings, saveSettings, TONE_OPTIONS, updateCategory, updateGroup, type HeaderItem, type ProfileSettings, type SectionSettings, type SettingsDocument, type SettingsTone, type SiteSettings, } from '../../settings' import { createItem, deleteItem, fetchItem, saveItem } from '../../items' /** * 站点设置页(/admin):页头滚动信息 + 侧栏顺序/显隐。 * * 只做「全局配置」;条目内容仍在详情页就地编辑(P2)。 * 保存用 `version` 乐观锁,冲突时提示刷新。 */ type EntrySummary = { id: string name?: string title?: string } type CategoryGroupSummary = { id: string name: string /** 数据库里的默认显隐;缺省视为显示 */ showInSidebar?: boolean items?: EntrySummary[] } type CategorySummary = { id: string title: string kind?: 'apps' | 'posters' | 'articles' icon?: string barColor?: string previewLimit?: number /** 首页卡片标题与预览节点 */ block?: { title?: string; nodeKey?: string } | null /** 应用类分区下的分组(子分类) */ groups?: CategoryGroupSummary[] /** 海报 / 图文类分区直接挂的条目 */ items?: EntrySummary[] } const clone = (settings: SiteSettings): SiteSettings => structuredClone(settings) export function AdminPage() { const [session, setSession] = useState(null) const [loginOpen, setLoginOpen] = useState(false) const [document, setDocument] = useState(null) const [draft, setDraft] = useState(null) const [categories, setCategories] = useState([]) const [tab, setTab] = useState('header') const [saving, setSaving] = useState(false) /** 待删除的分类;非 null 时确认弹窗打开 */ const [pendingDelete, setPendingDelete] = useState(null) const [deleting, setDeleting] = useState(false) const load = async () => { const [nextSession, nextDocument] = await Promise.all([fetchSession(), fetchSettings()]) setSession(nextSession) setDocument(nextDocument) setDraft(clone(nextDocument.settings)) try { const response = await fetch('/api/catalog', { credentials: 'same-origin' }) if (response.ok) { const payload = (await response.json()) as { categories?: CategorySummary[] } setCategories(payload.categories ?? []) } } catch { setCategories([]) } } useEffect(() => { void load() }, []) 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 requestDelete = (card: CategorySummary) => setPendingDelete(card) const confirmDelete = async () => { if (pendingDelete === null) return setDeleting(true) const result = await deleteCategory(pendingDelete.id) setDeleting(false) if (!result.ok) { toast.error(result.error) return } toast.success(`已删除「${pendingDelete.title}」`) setPendingDelete(null) await load() } const authenticated = session?.authenticated === true return (
STASH {authenticated && ( )}
站点设置 页头滚动信息与侧栏导航都保存在内容库里,改完立即对新打开的页面生效。 {!authenticated && (
需要登录后才能修改设置。
)} {authenticated && draft !== null && ( <> {tab === 'header' ? ( setDraft({ ...draft, header })} /> ) : tab === 'sections' ? ( setDraft({ ...draft, sections })} onDelete={requestDelete} onReload={load} /> ) : tab === 'content' ? ( ) : tab === 'categories' ? ( ) : tab === 'sidebar' ? ( setDraft({ ...draft, sidebar })} onDelete={requestDelete} onReload={load} /> ) : tab === 'profile' ? ( setDraft({ ...draft, profile })} /> ) : ( )} {/* 「内容」「分区」都是逐项保存,不参与整页设置的保存/撤销 */} {tab !== 'categories' && tab !== 'content' && ( )} )}
setLoginOpen(false)} onSuccess={(next) => { setSession(next) setLoginOpen(false) void load() }} /> { if (!deleting) setPendingDelete(null) }} title={pendingDelete === null ? '删除分类' : `删除「${pendingDelete.title}」?`} size="sm" footer={ } > 删除后,该分类下的所有条目会一起删除,且不可恢复。
) } // ---------- 页头表单 ---------- const TONE_SELECT = TONE_OPTIONS.map((option) => ({ value: option.value, label: option.label })) function HeaderForm({ value, onChange, }: { value: SiteSettings['header'] onChange: (header: SiteSettings['header']) => void }) { const update = (index: number, patch: Partial) => { onChange({ ...value, items: value.items.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item, ), }) } const move = (index: number, step: -1 | 1) => { const target = index + step if (target < 0 || target >= value.items.length) return const items = [...value.items] const [item] = items.splice(index, 1) items.splice(target, 0, item) onChange({ ...value, items }) } return (
显示页头滚动信息 onChange({ ...value, enabled })} label="显示页头滚动信息" /> 关闭后页头中间留空
{ const speed = Number(event.target.value) if (Number.isFinite(speed)) onChange({ ...value, speed }) }} /> 支持占位符:{'{count}'} 应用数、{'{project}'} 项目名、{'{status}'} 连接状态 {value.items.map((item, index) => (
update(index, { text: event.target.value })} /> update(index, { tag: event.target.value })} /> setTitle(card.id, event.target.value)} /> setLimit(card.id, event.target.value)} /> move(index, -1)} onDown={() => move(index, 1)} onRemove={card.id === 'local' ? undefined : () => onDelete(card)} disabledRemove={card.id === 'local'} />
))} {cards.length === 0 && 内容库还没有加载完成。}
) } // ---------- 新增分类 ---------- function CategoryCreator({ onCreated }: { onCreated: () => void }) { const [id, setId] = useState('') const [title, setTitle] = useState('') const [kind, setKind] = useState<'apps' | 'posters' | 'articles'>('posters') const [busy, setBusy] = useState(false) const submit = async () => { setBusy(true) const result = await createCategory({ id: id.trim(), title: title.trim(), kind }) setBusy(false) if (!result.ok) { toast.error(result.error) return } toast.success('分类已创建') setId('') setTitle('') onCreated() } return ( 新增分类(会同时出现在首页卡片与侧栏)。标识只允许小写字母、数字与连字符。
setId(event.target.value)} /> setTitle(event.target.value)} /> setTitle(event.target.value)} /> setIcon(event.target.value)} /> setColor(event.target.value)} /> setLimit(event.target.value)} />
) } // ---------- 首页卡片预览节点 ---------- function BlockSelect({ section, onReload }: { section: CategorySummary; onReload: () => void }) { const options = [ { value: section.id, label: '整个分区' }, ...(section.groups ?? []).map((group) => ({ value: group.id, label: group.name })), ] const [busy, setBusy] = useState(false) const change = async (next: string) => { setBusy(true) const result = await updateCategory(section.id, { blockNodeKey: next }) setBusy(false) if (!result.ok) { toast.error(result.error) return } toast.success('首页卡片预览已切换') onReload() } return (
首页卡片预览
setTitle(event.target.value)} />
) } function NewGroupRow({ sectionId, onReload }: { sectionId: string; onReload: () => void }) { const [id, setId] = useState('') const [title, setTitle] = useState('') const [busy, setBusy] = useState(false) const submit = async () => { setBusy(true) const result = await createGroup(sectionId, { id: id.trim(), title: title.trim() }) setBusy(false) if (!result.ok) { toast.error(result.error) return } toast.success('分组已创建') setId('') setTitle('') onReload() } return (
setId(event.target.value)} /> setTitle(event.target.value)} />
) } // ---------- 内容(条目列表 + 新增/编辑弹窗) ---------- type EntryRow = EntrySummary & { groupName?: string sectionId: string groupId?: string } function ContentManager({ categories, onReload, }: { categories: CategorySummary[] onReload: () => void }) { const [sectionId, setSectionId] = useState(categories[0]?.id ?? '') const [dialog, setDialog] = useState<{ open: boolean key: string | null location: { sectionId: string; groupId?: string } | null }>({ open: false, key: null, location: null }) const [pendingEntry, setPendingEntry] = useState(null) const [deleting, setDeleting] = useState(false) const section = categories.find((category) => category.id === sectionId) ?? categories[0] useEffect(() => { if (categories.length === 0) return if (!categories.some((category) => category.id === sectionId)) { setSectionId(categories[0].id) } }, [categories, sectionId]) const entries: EntryRow[] = section === undefined ? [] : [ ...(section.groups ?? []).flatMap((group) => (group.items ?? []).map((item) => ({ ...item, groupName: group.name, sectionId: section.id, groupId: group.id, })), ), ...(section.items ?? []).map((item) => ({ ...item, groupName: undefined, sectionId: section.id, })), ] const removeEntry = async () => { if (pendingEntry === null) return setDeleting(true) const result = await deleteItem(pendingEntry.id) setDeleting(false) if (!result.ok) { toast.error(result.error) return } toast.success('条目已删除') setPendingEntry(null) onReload() } return ( 新增网址 / 海报 / 图文条目时,选择它挂在哪个分区、哪个分组(应用类必须选分组); 列表里的条目可以点铅笔修改,包括换分区 / 分组。
({ value: category.id, label: category.title, }))} value={section?.id ?? ''} onChange={setSectionId} />
{section !== undefined && (
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 })} />