Files
stash/stash-web/src/components/admin/AdminPage.tsx
T
CNWei be7a0747ed feat(admin): 分区增加「允许子分组」开关(与类型解耦)
- node 新增 allow_groups(迁移 v18):应用默认开、海报/图文默认关,已有分组的非应用分区自动开启
- 后台每行一个「子分组」开关;分组管理、卡片预览、内容弹窗选组、侧栏建组都跟随开关
- 关闭开关时条目平铺且不丢内容;首页卡片指向分组会自动挪回分区
- 三种类型共用同一模板,类型只决定渲染方式
- 测试 59 passed(迁移 15→18、开关建组、类型切换)
2026-10-30 20:05:33 +08:00

1752 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
/** 是否允许子分组(与类型无关的开关) */
allowGroups?: boolean
/** 首页卡片标题与预览节点 */
block?: { title?: string; nodeKey?: string } | null
/** 应用类分区下的分组(子分类) */
groups?: CategoryGroupSummary[]
/** 海报 / 图文类分区直接挂的条目 */
items?: EntrySummary[]
}
const clone = (settings: SiteSettings): SiteSettings => structuredClone(settings)
export function AdminPage() {
const [session, setSession] = useState<SessionState | null>(null)
const [loginOpen, setLoginOpen] = useState(false)
const [document, setDocument] = useState<SettingsDocument | null>(null)
const [draft, setDraft] = useState<SiteSettings | null>(null)
const [categories, setCategories] = useState<CategorySummary[]>([])
const [tab, setTab] = useState('header')
const [saving, setSaving] = useState(false)
/** 待删除的分类;非 null 时确认弹窗打开 */
const [pendingDelete, setPendingDelete] = useState<CategorySummary | null>(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 (
<div className="admin-page">
<Toaster />
<header className="admin-topbar">
<a className="site-brand" href="/" aria-label="Stash 首页">
<span className="site-brand-mark" aria-hidden="true">
S
</span>
<span className="site-brand-name">STASH</span>
</a>
<Space gap="sm">
<Button variant="ghost" icon={<Icon name="arrow_left_line" />} onClick={() => window.location.assign('/')}>
返回首页
</Button>
{authenticated && (
<Button
variant="secondary"
icon={<Icon name="exit_line" />}
onClick={() => {
void logout().then(() => window.location.assign('/'))
}}
>
退出登录
</Button>
)}
</Space>
</header>
<main className="admin-main">
<Card padding="lg">
<Space direction="column" gap="sm" align="stretch">
<Title level={2}>站点设置</Title>
<Text size="sm" tone="muted">
页头滚动信息与侧栏导航都保存在内容库里,改完立即对新打开的页面生效。
</Text>
</Space>
{!authenticated && (
<div className="admin-locked">
<Text>需要登录后才能修改设置。</Text>
<Button icon={<Icon name="lock_line" />} onClick={() => setLoginOpen(true)}>
登录
</Button>
</div>
)}
{authenticated && draft !== null && (
<>
<Tabs
className="admin-tabs"
items={[
{ key: 'header', label: '页头滚动信息' },
{ key: 'sections', label: '首页卡片' },
{ key: 'content', label: '内容' },
{ key: 'categories', label: '分区' },
{ key: 'sidebar', label: '侧栏导航' },
{ key: 'profile', label: '资料' },
{ key: 'system', label: '系统' },
]}
activeKey={tab}
onChange={setTab}
/>
{tab === 'header' ? (
<HeaderForm
value={draft.header}
onChange={(header) => setDraft({ ...draft, header })}
/>
) : tab === 'sections' ? (
<SectionForm
categories={categories}
value={draft.sections}
onChange={(sections) => setDraft({ ...draft, sections })}
onDelete={requestDelete}
onReload={load}
/>
) : tab === 'content' ? (
<ContentManager categories={categories} onReload={load} />
) : tab === 'categories' ? (
<CategoryManager
categories={categories}
onReload={load}
onDelete={requestDelete}
/>
) : tab === 'sidebar' ? (
<SidebarForm
categories={categories}
value={draft.sidebar}
onChange={(sidebar) => setDraft({ ...draft, sidebar })}
onDelete={requestDelete}
onReload={load}
/>
) : tab === 'profile' ? (
<ProfileForm
value={draft.profile}
onChange={(profile) => setDraft({ ...draft, profile })}
/>
) : (
<SystemPanel />
)}
{/* 「内容」「分区」都是逐项保存,不参与整页设置的保存/撤销 */}
{tab !== 'categories' && tab !== 'content' && (
<Space gap="sm" justify="end" className="admin-actions">
<Button
variant="secondary"
onClick={() => {
setDraft(clone(draft))
void load()
}}
>
撤销改动
</Button>
<Button loading={saving} icon={<Icon name="check_line" />} onClick={save}>
保存
</Button>
</Space>
)}
</>
)}
</Card>
</main>
<LoginDialog
open={loginOpen}
hasAdmin={session?.has_admin ?? true}
onClose={() => setLoginOpen(false)}
onSuccess={(next) => {
setSession(next)
setLoginOpen(false)
void load()
}}
/>
<Modal
open={pendingDelete !== null}
onClose={() => {
if (!deleting) setPendingDelete(null)
}}
title={pendingDelete === null ? '删除分类' : `删除「${pendingDelete.title}」?`}
size="sm"
footer={
<Space gap="sm" justify="end">
<Button variant="secondary" disabled={deleting} onClick={() => setPendingDelete(null)}>
取消
</Button>
<Button
variant="danger"
loading={deleting}
icon={<Icon name="delete_2_line" />}
onClick={() => void confirmDelete()}
>
删除
</Button>
</Space>
}
>
<Text>删除后,该分类下的所有条目会一起删除,且不可恢复。</Text>
</Modal>
</div>
)
}
// ---------- 页头表单 ----------
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<HeaderItem>) => {
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 (
<Space direction="column" gap="lg" align="stretch" className="admin-form">
<div className="admin-row">
<Text>显示页头滚动信息</Text>
<Switch
checked={value.enabled}
onChange={(enabled) => onChange({ ...value, enabled })}
label="显示页头滚动信息"
/>
<Text size="sm" tone="muted">
关闭后页头中间留空
</Text>
</div>
<FormField label="滚动速度" htmlFor="header-speed" hint="秒数越大越慢,范围 8–120">
<Input
id="header-speed"
type="number"
value={String(value.speed)}
onChange={(event) => {
const speed = Number(event.target.value)
if (Number.isFinite(speed)) onChange({ ...value, speed })
}}
/>
</FormField>
<Space direction="column" gap="sm" align="stretch">
<Text size="sm" tone="muted">
支持占位符:{'{count}'} 应用数、{'{project}'} 项目名、{'{status}'} 连接状态
</Text>
{value.items.map((item, index) => (
<div className="admin-item-row" key={index}>
<Input
value={item.text}
placeholder="信息文本"
onChange={(event) => update(index, { text: event.target.value })}
/>
<Input
value={item.tag ?? ''}
placeholder="标签(可选)"
onChange={(event) => update(index, { tag: event.target.value })}
/>
<Select
options={TONE_SELECT}
value={item.tone}
onChange={(tone) => update(index, { tone: tone as SettingsTone })}
/>
<IconButtonGroup
onUp={() => move(index, -1)}
onDown={() => move(index, 1)}
onRemove={() => onChange({ ...value, items: value.items.filter((_, i) => i !== index) })}
/>
</div>
))}
<Button
variant="secondary"
icon={<Icon name="add_line" />}
disabled={value.items.length >= 8}
onClick={() => onChange({ ...value, items: [...value.items, { text: '', tag: '', tone: 'plain' }] })}
>
添加一条
</Button>
</Space>
</Space>
)
}
// ---------- 侧栏表单 ----------
function SidebarForm({
categories,
value,
onChange,
onDelete,
onReload,
}: {
categories: CategorySummary[]
value: SiteSettings['sidebar']
onChange: (sidebar: SiteSettings['sidebar']) => void
onDelete: (card: CategorySummary) => void
onReload: () => void
}) {
const ordered = useMemo(() => {
const ranks = new Map(value.order.map((id, index) => [id, index]))
return [...categories].sort((a, b) => {
const left = ranks.get(a.id) ?? Number.MAX_SAFE_INTEGER
const right = ranks.get(b.id) ?? Number.MAX_SAFE_INTEGER
if (left === right) return 0
return left - right
})
}, [categories, value.order])
const move = (index: number, step: -1 | 1) => {
const target = index + step
if (target < 0 || target >= ordered.length) return
const ids = ordered.map((category) => category.id)
const [id] = ids.splice(index, 1)
ids.splice(target, 0, id)
onChange({ ...value, order: ids })
}
const toggle = (id: string, visible: boolean) => {
const hidden = visible
? value.hidden.filter((entry) => entry !== id)
: [...new Set([...value.hidden, id])]
onChange({ ...value, hidden, order: ordered.map((category) => category.id) })
}
const orderedGroups = (category: CategorySummary) => {
const ranks = new Map(
(value.group_order[category.id] ?? []).map((id, index) => [id, index] as const),
)
return [...(category.groups ?? [])].sort((a, b) => {
const left = ranks.get(a.id) ?? Number.MAX_SAFE_INTEGER
const right = ranks.get(b.id) ?? Number.MAX_SAFE_INTEGER
if (left === right) return 0
return left - right
})
}
const groupVisible = (category: CategorySummary, group: CategoryGroupSummary) => {
const key = `${category.id}/${group.id}`
return (
!value.hidden_groups.includes(key) &&
(group.showInSidebar !== false || value.shown_groups.includes(key))
)
}
const toggleGroup = (
category: CategorySummary,
group: CategoryGroupSummary,
visible: boolean,
) => {
const key = `${category.id}/${group.id}`
const hidden_groups = visible
? value.hidden_groups.filter((entry) => entry !== key)
: [...new Set([...value.hidden_groups, key])]
const shown_groups = visible
? [...new Set([...value.shown_groups, key])]
: value.shown_groups.filter((entry) => entry !== key)
onChange({ ...value, hidden_groups, shown_groups })
}
const moveGroup = (category: CategorySummary, index: number, step: -1 | 1) => {
const groups = orderedGroups(category)
const target = index + step
if (target < 0 || target >= groups.length) return
const ids = groups.map((group) => group.id)
const [id] = ids.splice(index, 1)
ids.splice(target, 0, id)
onChange({ ...value, group_order: { ...value.group_order, [category.id]: ids } })
}
return (
<Space direction="column" gap="sm" align="stretch" className="admin-form">
<Text size="sm" tone="muted">
顺序决定侧栏显示位置;关掉的分类仍可通过地址直接访问。应用类分区下的子分类(分组)可以单独显隐与排序。
</Text>
{ordered.map((category, index) => {
const visible = !value.hidden.includes(category.id)
const groups = orderedGroups(category)
return (
<div className="admin-nav-block" key={category.id}>
<div className="admin-item-row admin-nav-row">
<Switch
checked={visible}
onChange={(next) => toggle(category.id, next)}
label={`显示 ${category.title}`}
/>
<Text className="admin-item-title">{category.title}</Text>
<IconButtonGroup
onUp={() => move(index, -1)}
onDown={() => move(index, 1)}
onRemove={() => onDelete(category)}
/>
</div>
{groups.map((group, groupIndex) => (
<div
className="admin-item-row admin-nav-row admin-nav-group-row"
key={group.id}
>
<Switch
checked={groupVisible(category, group)}
onChange={(next) => toggleGroup(category, group, next)}
label={`显示 ${group.name}`}
/>
<Text className="admin-item-title">{group.name}</Text>
<IconButtonGroup
onUp={() => moveGroup(category, groupIndex, -1)}
onDown={() => moveGroup(category, groupIndex, 1)}
/>
</div>
))}
{category.allowGroups === true && (
<div className="admin-nav-group-row">
<NewGroupRow sectionId={category.id} onReload={onReload} />
</div>
)}
</div>
)
})}
{ordered.length === 0 && <Text tone="muted">内容库还没有加载完成。</Text>}
<Text size="sm" tone="muted">
新增或删除分类会同时影响首页卡片与侧栏。
</Text>
<CategoryCreator onCreated={onReload} />
</Space>
)
}
// ---------- 首页卡片表单 ----------
function SectionForm({
categories,
value,
onChange,
onDelete,
onReload,
}: {
categories: CategorySummary[]
value: SectionSettings
onChange: (sections: SectionSettings) => void
onDelete: (card: CategorySummary) => void
onReload: () => void
}) {
const cards = useMemo(() => {
const base = [
{ id: 'local', title: '本地服务' },
...categories
.filter((category) => category.id !== 'local')
.map((category) => ({
id: category.id,
title: category.block?.title ?? category.title,
})),
]
const ranks = new Map(value.order.map((id, index) => [id, index]))
return [...base].sort((a, b) => {
const left = ranks.get(a.id) ?? Number.MAX_SAFE_INTEGER
const right = ranks.get(b.id) ?? Number.MAX_SAFE_INTEGER
return left - right
})
}, [categories, value.order])
const setOrder = (ids: string[]) => onChange({ ...value, order: ids })
const move = (index: number, step: -1 | 1) => {
const target = index + step
if (target < 0 || target >= cards.length) return
const ids = cards.map((card) => card.id)
const [id] = ids.splice(index, 1)
ids.splice(target, 0, id)
setOrder(ids)
}
const toggle = (id: string, visible: boolean) => {
const hidden = visible
? value.hidden.filter((entry) => entry !== id)
: [...new Set([...value.hidden, id])]
onChange({ ...value, hidden, order: cards.map((card) => card.id) })
}
const setTitle = (id: string, title: string) => {
const titles = { ...value.titles }
if (title.trim() === '') delete titles[id]
else titles[id] = title
onChange({ ...value, titles })
}
const setLimit = (id: string, raw: string) => {
const limits = { ...value.limits }
const limit = Number(raw)
if (raw.trim() === '' || !Number.isFinite(limit) || limit <= 0) delete limits[id]
else limits[id] = Math.min(60, Math.round(limit))
onChange({ ...value, limits })
}
return (
<Space direction="column" gap="sm" align="stretch" className="admin-form">
<Text size="sm" tone="muted">
控制首页卡片:显示 / 隐藏、标题、每张卡预览条数与顺序。留空表示用内容库里的默认值。
</Text>
{cards.map((card, index) => (
<div className="admin-item-row admin-section-row" key={card.id}>
<Switch
checked={!value.hidden.includes(card.id)}
onChange={(next) => toggle(card.id, next)}
label={`显示 ${card.title}`}
/>
<Input
value={value.titles[card.id] ?? ''}
placeholder={card.title}
aria-label={`${card.title} 标题`}
onChange={(event) => setTitle(card.id, event.target.value)}
/>
<Input
type="number"
value={value.limits[card.id] === undefined ? '' : String(value.limits[card.id])}
placeholder="条数"
aria-label={`${card.title} 预览条数`}
onChange={(event) => setLimit(card.id, event.target.value)}
/>
<IconButtonGroup
onUp={() => move(index, -1)}
onDown={() => move(index, 1)}
onRemove={card.id === 'local' ? undefined : () => onDelete(card)}
disabledRemove={card.id === 'local'}
/>
</div>
))}
{cards.length === 0 && <Text tone="muted">内容库还没有加载完成。</Text>}
<CategoryCreator onCreated={onReload} />
</Space>
)
}
// ---------- 新增分类 ----------
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 (
<Space direction="column" gap="sm" align="stretch" className="admin-create">
<Text size="sm" tone="muted">
新增分类(会同时出现在首页卡片与侧栏)。标识只允许小写字母、数字与连字符。
</Text>
<div className="admin-create-row">
<Input
value={id}
placeholder="标识,如 books"
aria-label="新分类标识"
onChange={(event) => setId(event.target.value)}
/>
<Input
value={title}
placeholder="标题,如 书单"
aria-label="新分类标题"
onChange={(event) => setTitle(event.target.value)}
/>
<Select
options={[
{ value: 'apps', label: '应用' },
{ value: 'posters', label: '海报' },
{ value: 'articles', label: '图文' },
]}
value={kind}
onChange={(next) => setKind(next as typeof kind)}
/>
<Button
loading={busy}
disabled={id.trim() === '' || title.trim() === ''}
icon={<Icon name="add_line" />}
onClick={submit}
>
新增
</Button>
</div>
</Space>
)
}
// ---------- 分区与分组管理 ----------
const KIND_OPTIONS = [
{ value: 'apps', label: '应用' },
{ value: 'posters', label: '海报' },
{ value: 'articles', label: '图文' },
]
function CategoryManager({
categories,
onReload,
onDelete,
}: {
categories: CategorySummary[]
onReload: () => void
onDelete: (card: CategorySummary) => void
}) {
return (
<Space direction="column" gap="lg" align="stretch" className="admin-form">
<Text size="sm" tone="muted">
三种类型使用同一套模板:分区决定内容归到哪一类(应用 / 海报 / 图文),
「子分组」开关决定要不要分组。开启后可建分组、分组会出现在侧栏,也可以作为首页卡片预览;
关闭后条目按平铺渲染,已有分组里的内容不会丢。
</Text>
{categories.map((category) => (
<div className="admin-section-block" key={category.id}>
<CategoryRow
category={category}
onReload={onReload}
onDelete={onDelete}
/>
{category.allowGroups === true && (
<>
<BlockSelect section={category} onReload={onReload} />
<GroupManager section={category} onReload={onReload} />
</>
)}
</div>
))}
{categories.length === 0 && <Text tone="muted">内容库还没有加载完成。</Text>}
<CategoryCreator onCreated={onReload} />
</Space>
)
}
function CategoryRow({
category,
onReload,
onDelete,
}: {
category: CategorySummary
onReload: () => void
onDelete: (card: CategorySummary) => void
}) {
const [title, setTitle] = useState(category.title)
const [kind, setKind] = useState(category.kind ?? 'apps')
const [icon, setIcon] = useState(category.icon ?? '')
const [color, setColor] = useState(category.barColor ?? '')
const [limit, setLimit] = useState(
category.previewLimit === undefined ? '' : String(category.previewLimit),
)
const [allowGroups, setAllowGroups] = useState(category.allowGroups === true)
const [busy, setBusy] = useState(false)
const save = async () => {
setBusy(true)
const result = await updateCategory(category.id, {
title,
kind,
icon,
barColor: color,
previewLimit: limit.trim() === '' ? undefined : Number(limit),
allowGroups,
})
setBusy(false)
if (!result.ok) {
toast.error(result.error)
return
}
toast.success(`已保存「${title}」`)
onReload()
}
return (
<div className="admin-category-row">
<Input
value={title}
placeholder="标题"
aria-label={`${category.title} 标题`}
onChange={(event) => setTitle(event.target.value)}
/>
<Select
options={KIND_OPTIONS}
value={kind}
onChange={(next) => setKind(next as typeof kind)}
/>
<Input
value={icon}
placeholder="图标"
aria-label={`${category.title} 图标`}
onChange={(event) => setIcon(event.target.value)}
/>
<Input
value={color}
placeholder="#3858f6"
aria-label={`${category.title} 色条`}
onChange={(event) => setColor(event.target.value)}
/>
<Input
type="number"
value={limit}
placeholder="条数"
aria-label={`${category.title} 预览条数`}
onChange={(event) => setLimit(event.target.value)}
/>
<div className="admin-toggle">
<Switch
checked={allowGroups}
onChange={setAllowGroups}
label={`${category.title} 允许子分组`}
/>
<Text size="sm" tone="muted">
子分组
</Text>
</div>
<Button variant="secondary" size="sm" loading={busy} onClick={save}>
保存
</Button>
<Button
variant="ghost"
size="sm"
className="admin-remove"
icon={<Icon name="delete_2_line" />}
aria-label="删除"
onClick={() => onDelete(category)}
/>
</div>
)
}
// ---------- 首页卡片预览节点 ----------
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 (
<div className="admin-block-row">
<Text size="sm" tone="muted">
首页卡片预览
</Text>
<div style={{ width: 220 }}>
<Select
options={options}
value={section.block?.nodeKey ?? section.id}
onChange={(next) => void change(next)}
/>
</div>
{busy && (
<Text size="sm" tone="muted">
保存中…
</Text>
)}
</div>
)
}
// ---------- 分组(子分类)管理 ----------
function GroupManager({ section, onReload }: { section: CategorySummary; onReload: () => void }) {
const groups = section.groups ?? []
const [pending, setPending] = useState<CategoryGroupSummary | null>(null)
const [busy, setBusy] = useState(false)
const remove = async () => {
if (pending === null) return
setBusy(true)
const result = await deleteGroup(pending.id)
setBusy(false)
if (!result.ok) {
toast.error(result.error)
return
}
toast.success(`已删除分组「${pending.name}」`)
setPending(null)
onReload()
}
return (
<div className="admin-groups">
<Text size="sm" tone="muted">
子分组:可改名、控制侧栏显隐;删除会连同组内条目一起删除。
</Text>
{groups.map((group) => (
<GroupRow
key={group.id}
group={group}
onReload={onReload}
onDelete={() => setPending(group)}
/>
))}
<NewGroupRow sectionId={section.id} onReload={onReload} />
<ConfirmModal
open={pending !== null}
title={pending === null ? '删除分组' : `删除分组「${pending.name}」?`}
description="组内所有条目会一起删除,且不可恢复。"
busy={busy}
onCancel={() => setPending(null)}
onConfirm={() => void remove()}
/>
</div>
)
}
function GroupRow({
group,
onReload,
onDelete,
}: {
group: CategoryGroupSummary
onReload: () => void
onDelete: () => void
}) {
const [title, setTitle] = useState(group.name)
const [visible, setVisible] = useState(group.showInSidebar !== false)
const [busy, setBusy] = useState(false)
const save = async () => {
setBusy(true)
const result = await updateGroup(group.id, {
title,
showInSidebar: visible,
})
setBusy(false)
if (!result.ok) {
toast.error(result.error)
return
}
toast.success(`已保存分组「${title}」`)
onReload()
}
return (
<div className="admin-group-row">
<Switch
checked={visible}
onChange={setVisible}
label={`侧栏显示 ${group.name}`}
/>
<Input
value={title}
aria-label={`${group.name} 名称`}
onChange={(event) => setTitle(event.target.value)}
/>
<Button variant="secondary" size="sm" loading={busy} onClick={save}>
保存
</Button>
<Button
variant="ghost"
size="sm"
className="admin-remove"
icon={<Icon name="delete_2_line" />}
aria-label="删除分组"
onClick={onDelete}
/>
</div>
)
}
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 (
<div className="admin-new-group-row">
<Input
value={id}
placeholder="分组标识,如 ui-kit"
aria-label="新分组标识"
onChange={(event) => setId(event.target.value)}
/>
<Input
value={title}
placeholder="分组名称,如 设计资源"
aria-label="新分组名称"
onChange={(event) => setTitle(event.target.value)}
/>
<Button
variant="secondary"
size="sm"
icon={<Icon name="add_line" />}
loading={busy}
disabled={id.trim() === '' || title.trim() === ''}
onClick={submit}
>
新增分组
</Button>
</div>
)
}
// ---------- 内容(条目列表 + 新增/编辑弹窗) ----------
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<EntryRow | null>(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 (
<Space direction="column" gap="lg" align="stretch" className="admin-form">
<Text size="sm" tone="muted">
新增网址 / 海报 / 图文条目时,选择它挂在哪个分区、哪个分组(应用类必须选分组);
列表里的条目可以点铅笔修改,包括换分区 / 分组。
</Text>
<Space gap="sm" wrap>
<div style={{ width: 200 }}>
<Select
options={categories.map((category) => ({
value: category.id,
label: category.title,
}))}
value={section?.id ?? ''}
onChange={setSectionId}
/>
</div>
<Button
icon={<Icon name="add_line" />}
onClick={() =>
setDialog({
open: true,
key: null,
location: section === undefined ? null : { sectionId: section.id },
})
}
>
新增条目
</Button>
</Space>
<Space direction="column" gap="sm" align="stretch">
<Text size="sm" tone="muted">
{section?.title ?? ''} 下已有 {entries.length} 条
</Text>
{entries.map((entry) => (
<div className="admin-entry-row" key={entry.id}>
<Text>{entry.name ?? entry.title ?? entry.id}</Text>
<Text size="sm" tone="muted">
{entry.groupName ?? '直属分区'}
</Text>
<Button
variant="ghost"
size="sm"
icon={<Icon name="pencil_line" />}
aria-label="编辑条目"
onClick={() =>
setDialog({
open: true,
key: entry.id,
location: { sectionId: entry.sectionId, groupId: entry.groupId },
})
}
/>
<Button
variant="ghost"
size="sm"
className="admin-remove"
icon={<Icon name="delete_2_line" />}
aria-label="删除条目"
onClick={() => setPendingEntry(entry)}
/>
</div>
))}
</Space>
<EntryDialog
open={dialog.open}
entryKey={dialog.key}
categories={categories}
defaultLocation={dialog.location}
onClose={() => setDialog({ open: false, key: null, location: null })}
onSaved={() => {
setDialog({ open: false, key: null, location: null })
onReload()
}}
/>
<ConfirmModal
open={pendingEntry !== null}
title={
pendingEntry === null
? '删除条目'
: `删除「${pendingEntry.name ?? pendingEntry.title ?? pendingEntry.id}」?`
}
description="删除后不可恢复。"
busy={deleting}
onCancel={() => setPendingEntry(null)}
onConfirm={() => void removeEntry()}
/>
</Space>
)
}
type EntryForm = {
key: string
name: string
href: string
icon: string
description: string
cover: string
tags: string
comment: string
body: string
badge: string
summary: string
banner: string
}
const EMPTY_ENTRY_FORM: EntryForm = {
key: '',
name: '',
href: '',
icon: '',
description: '',
cover: '',
tags: '',
comment: '',
body: '',
badge: '',
summary: '',
banner: '',
}
function EntryDialog({
open,
entryKey,
categories,
defaultLocation,
onClose,
onSaved,
}: {
open: boolean
entryKey: string | null
categories: CategorySummary[]
defaultLocation: { sectionId: string; groupId?: string } | null
onClose: () => void
onSaved: () => void
}) {
const editing = entryKey !== null
const [loading, setLoading] = useState(false)
const [updatedAt, setUpdatedAt] = useState(0)
const [sectionId, setSectionId] = useState('')
const [groupId, setGroupId] = useState('')
const [form, setForm] = useState<EntryForm>(EMPTY_ENTRY_FORM)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const section =
categories.find((category) => category.id === sectionId) ?? categories[0]
const groups = section?.allowGroups === true ? (section.groups ?? []) : []
// 打开时重置:新建用当前选择,编辑先拉完整条目
useEffect(() => {
if (!open) return
setError('')
const startSection =
defaultLocation?.sectionId ?? categories[0]?.id ?? ''
setSectionId(startSection)
const startCategory = categories.find((category) => category.id === startSection)
setGroupId(
defaultLocation?.groupId ??
(startCategory?.allowGroups === true ? (startCategory.groups?.[0]?.id ?? '') : ''),
)
if (entryKey === null) {
setLoading(false)
setUpdatedAt(0)
setForm(EMPTY_ENTRY_FORM)
return
}
setLoading(true)
fetchItem(entryKey).then((result) => {
setLoading(false)
if (!result.ok) {
setError(result.error)
return
}
const entry = result.entry
setUpdatedAt(entry.updated_at)
setForm({
key: entry.key,
name: entry.name,
href: entry.href,
icon: entry.icon,
description: entry.description,
cover: entry.cover,
tags: entry.tags.join(','),
comment: entry.comment,
body: entry.body,
badge: entry.badge,
summary: entry.summary,
banner: entry.banner,
})
})
// 只在打开/切换条目时重置,表单输入不应触发重新拉取
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, entryKey])
// 只有开启「允许子分组」的分区有分组;其余始终是直属分区
useEffect(() => {
if (section?.allowGroups !== true) {
if (groupId !== '') setGroupId('')
return
}
if (groupId !== '' && !groups.some((group) => group.id === groupId)) {
setGroupId(groups[0]?.id ?? '')
}
}, [section, groups, groupId])
const nodeKey =
section === undefined
? ''
: section.allowGroups === true && groupId !== ''
? groupId
: section.id
const update = (patch: Partial<EntryForm>) =>
setForm((previous) => ({ ...previous, ...patch }))
const submit = async () => {
if (section === undefined || nodeKey === '') {
setError('请先选择分区与分组')
return
}
if (!editing && form.key.trim() === '') {
setError('标识不能为空')
return
}
if (form.name.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: form.badge,
comment: form.comment,
summary: form.summary,
body: form.body,
cover: form.cover,
banner: form.banner,
href: form.href,
nodeKey,
}
const result =
editing && entryKey !== null
? await saveItem(entryKey, updatedAt, item)
: await createItem({ key: form.key.trim(), nodeKey, item })
setBusy(false)
if (!result.ok) {
setError(result.error)
return
}
toast.success(editing ? '条目已保存' : `已添加「${form.name.trim()}」`)
onSaved()
}
return (
<Modal
open={open}
onClose={busy ? () => undefined : onClose}
title={editing ? '编辑条目' : '新增条目'}
size="lg"
footer={
<Space gap="sm" justify="end">
<Button variant="secondary" onClick={onClose}>
取消
</Button>
<Button
loading={busy}
disabled={loading}
icon={<Icon name="check_line" />}
onClick={submit}
>
保存
</Button>
</Space>
}
>
{loading ? (
<Text>正在读取…</Text>
) : (
<Space direction="column" gap="md" align="stretch">
{error !== '' && (
<Notice tone="error" title="操作失败">
{error}
</Notice>
)}
<Space gap="sm" wrap>
<div style={{ width: 200 }}>
<Select
options={categories.map((category) => ({
value: category.id,
label: category.title,
}))}
value={section?.id ?? ''}
onChange={setSectionId}
/>
</div>
{section?.allowGroups === true && (
<div style={{ width: 240 }}>
<Select
options={[
{ value: '', label: '直属分区(不分组)' },
...groups.map((group) => ({
value: group.id,
label: group.name,
})),
]}
value={groupId}
onChange={setGroupId}
/>
</div>
)}
</Space>
<div className="admin-content-form">
{!editing && (
<Input
value={form.key}
placeholder="标识,如 my-site"
aria-label="条目标识"
onChange={(event) => update({ key: event.target.value })}
/>
)}
<Input
value={form.name}
placeholder="标题,如 我的站点"
aria-label="条目标题"
onChange={(event) => update({ name: event.target.value })}
/>
<Input
value={form.href}
placeholder="跳转地址,如 https://example.com 或 /project/"
aria-label="跳转地址"
onChange={(event) => update({ href: event.target.value })}
/>
<Input
value={form.icon}
placeholder="字符图标,如 G"
aria-label="图标"
onChange={(event) => update({ icon: event.target.value })}
/>
<Input
value={form.description}
placeholder="一句话简介"
aria-label="简介"
onChange={(event) => update({ description: event.target.value })}
/>
<Input
value={form.cover}
placeholder="封面 / 海报图片地址"
aria-label="封面地址"
onChange={(event) => update({ cover: event.target.value })}
/>
<Input
value={form.tags}
placeholder="标签,用逗号分隔"
aria-label="标签"
onChange={(event) => update({ tags: event.target.value })}
/>
<Input
value={form.comment}
placeholder="一句话评价"
aria-label="一句话评价"
onChange={(event) => update({ comment: event.target.value })}
/>
<div className="admin-col-span">
<Textarea
rows={4}
value={form.body}
placeholder="正文(图文 / Markdown),可留空"
aria-label="正文"
onChange={(event) => update({ body: event.target.value })}
/>
</div>
</div>
</Space>
)}
</Modal>
)
}
// ---------- 通用删除确认(页面级) ----------
function ConfirmModal({
open,
title,
description,
busy,
onCancel,
onConfirm,
}: {
open: boolean
title: string
description: string
busy: boolean
onCancel: () => void
onConfirm: () => void
}) {
return (
<Modal
open={open}
onClose={busy ? () => undefined : onCancel}
title={title}
size="sm"
footer={
<Space gap="sm" justify="end">
<Button variant="secondary" disabled={busy} onClick={onCancel}>
取消
</Button>
<Button
variant="danger"
loading={busy}
icon={<Icon name="delete_2_line" />}
onClick={onConfirm}
>
删除
</Button>
</Space>
}
>
<Text>{description}</Text>
</Modal>
)
}
// ---------- 资料 ----------
function ProfileForm({
value,
onChange,
}: {
value: ProfileSettings
onChange: (profile: ProfileSettings) => void
}) {
const update = (patch: Partial<ProfileSettings>) => onChange({ ...value, ...patch })
return (
<Space direction="column" gap="lg" align="stretch" className="admin-form">
<FormField label="头像地址" htmlFor="profile-avatar" hint="留空则用图标占位;支持 https 图片地址">
<Input
id="profile-avatar"
value={value.avatar}
placeholder="https://…"
onChange={(event) => update({ avatar: event.target.value })}
/>
</FormField>
<FormField label="名称" htmlFor="profile-name" required>
<Input
id="profile-name"
value={value.name}
onChange={(event) => update({ name: event.target.value })}
/>
</FormField>
<FormField label="角色" htmlFor="profile-role">
<Input
id="profile-role"
value={value.role}
onChange={(event) => update({ role: event.target.value })}
/>
</FormField>
<FormField label="简介" htmlFor="profile-description">
<Textarea
id="profile-description"
rows={3}
value={value.description}
onChange={(event) => update({ description: event.target.value })}
/>
</FormField>
<FormField label="位置" htmlFor="profile-location" hint="直接显示在资料卡底部,如「IP属地:局域网」">
<Input
id="profile-location"
value={value.location}
onChange={(event) => update({ location: event.target.value })}
/>
</FormField>
</Space>
)
}
// ---------- 系统 ----------
function SystemPanel() {
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState<{ tone: 'info' | 'error'; text: string } | null>(null)
const change = async () => {
setBusy(true)
setMessage(null)
const result = await changePassword(current, next)
setBusy(false)
if (!result.ok) {
setMessage({ tone: 'error', text: result.error })
return
}
setMessage({ tone: 'info', text: '密码已修改,所有会话已失效,请重新登录' })
setCurrent('')
setNext('')
}
return (
<Space direction="column" gap="lg" align="stretch" className="admin-form">
<FormField label="当前密码" htmlFor="current-password">
<PasswordInput
id="current-password"
autoComplete="current-password"
value={current}
onChange={(event) => setCurrent(event.target.value)}
/>
</FormField>
<FormField label="新密码" htmlFor="next-password" hint="至少 8 位">
<PasswordInput
id="next-password"
autoComplete="new-password"
value={next}
onChange={(event) => setNext(event.target.value)}
/>
</FormField>
{message !== null && (
<Notice tone={message.tone} title={message.tone === 'info' ? '已修改' : '修改失败'}>
{message.text}
</Notice>
)}
<Button
variant="secondary"
loading={busy}
icon={<Icon name="key_2_line" />}
onClick={change}
>
修改密码
</Button>
<Text size="sm" tone="muted">
备份:下载一份 SQLite 快照(VACUUM INTO 生成,一致性副本)。恢复时停掉服务,用它替换
data/stash.db 再启动。
</Text>
<Button
icon={<Icon name="download_line" />}
onClick={() => window.location.assign('/api/backup')}
>
下载备份
</Button>
</Space>
)
}
// ---------- 行内按钮组 ----------
function IconButtonGroup({
onUp,
onDown,
onRemove,
disabledRemove = false,
}: {
onUp: () => void
onDown: () => void
onRemove?: (() => void) | undefined
/** 显示一个不可点的删除按钮(灰色),用于「本地服务」这类受保护项 */
disabledRemove?: boolean
}) {
return (
<Space gap="xs" className="admin-item-actions">
<Button variant="ghost" size="sm" icon={<Icon name="arrow_up_line" />} onClick={onUp} aria-label="上移" />
<Button variant="ghost" size="sm" icon={<Icon name="arrow_down_line" />} onClick={onDown} aria-label="下移" />
{onRemove !== undefined ? (
<Button
variant="ghost"
size="sm"
className="admin-remove"
icon={<Icon name="delete_2_line" />}
onClick={onRemove}
aria-label="删除"
/>
) : disabledRemove ? (
<Button
variant="ghost"
size="sm"
className="admin-remove"
icon={<Icon name="delete_2_line" />}
disabled
aria-label="不可删除"
title="本地服务不能删除"
/>
) : null}
</Space>
)
}