Files
stash/stash-web/src/components/admin/AdminNext.tsx
T
CNWei 59fd61d832 feat: 搜索带并入 Layout toolbar,后台重组首页配置
前台
- 搜索框从内容列移到 Layout 的 toolbar 插槽,去掉 fixed + TOP_OFFSET + top-blur 三处补偿
- 左右栏滚动容器不变,左栏吸顶改用 CSS sticky(修回顶时侧栏抖动)
- 只让分组导航滚动,资料卡与本地服务固定
- 页头操作支持配置:搜索改为刷新(重扫项目 + 重拉内容库)
- 站点级设置:页面标题 / favicon / 品牌 logo / 品牌文字
- 停用的分组在侧边栏、首页卡片、分组页、条目页统一不再出现

后台
- 「侧边栏」「首页卡片」合并为「首页」Tab
- 首页卡片表格新增状态列与顺序列(上下箭头即时保存)
- 分组显隐只由「分组管理 → 状态」决定,去掉侧栏独立勾选
- 页头 Tab 增加站点信息、首页 Logo(带实时预览)、操作工具开关
- 后台页头与前台同构并吸顶

后端
- settings 新增 site(标题 / favicon / 品牌 / 操作工具)与 sidebar.show_nav
- 写接口成功后广播内容库变更,SSE 改为「广播 + data_version 兜底」
- catalog SSE 事件名与前端监听对齐(catalog)
2026-11-01 15:15:20 +08:00

2076 lines
63 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
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<SessionState | null>(null)
const [loginOpen, setLoginOpen] = useState(false)
const [document, setDocument] = useState<SettingsDocument | null>(null)
const [draft, setDraft] = useState<SiteSettings | null>(null)
const [groups, setGroups] = useState<GroupNode[]>([])
const [cards, setCards] = useState<CardItem[]>([])
const [tab, setTab] = useState<TabKey>('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<HTMLLinkElement>('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 (
<div className="admin-page">
<Toaster />
{/* 与前台页头同一套结构:品牌在左、图标操作在右,整条吸顶 */}
<header className="admin-topbar">
<div className="site-header-inner">
<a className="site-brand" href="/" aria-label={`${brandName || '首页'} 首页`}>
{brandLogo !== '' ? (
<img className="site-brand-logo" src={brandLogo} alt="" />
) : (
<span className="site-brand-mark" aria-hidden="true">
{brandMark}
</span>
)}
{brandName !== '' && <span className="site-brand-name">{brandName}</span>}
</a>
<div className="header-actions">
<IconButton
label="返回首页"
icon={<Icon name="arrow_left_line" />}
onClick={() => window.location.assign('/')}
/>
{authenticated && (
<IconButton
label="退出登录"
icon={<Icon name="exit_line" />}
onClick={() => {
void logout().then(() => window.location.assign('/'))
}}
/>
)}
</div>
</div>
</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: 'groups', label: '分组管理' },
{ key: 'content', label: '内容' },
{ key: 'home', label: '首页' },
{ key: 'header', label: '页头' },
{ key: 'profile', label: '资料' },
{ key: 'system', label: '系统' },
]}
activeKey={tab}
onChange={(key) => setTab(key as TabKey)}
/>
{tab === 'groups' && (
<GroupsPanel
groups={groups}
onReload={load}
onDelete={(key, title) => setPending({ kind: 'group', id: key, title })}
/>
)}
{tab === 'content' && <ContentPanel groups={groups} onReload={load} />}
{tab === 'home' && (
<div className="admin-home">
<section className="admin-home-section">
<Text size="md" strong>
侧边栏
</Text>
<SidebarPanel
value={draft.sidebar}
saving={saving}
onToggleCard={(card, visible) => {
void saveSidebar({
...draft.sidebar,
...(card === 'profile'
? { show_profile: visible }
: card === 'service'
? { show_service: visible }
: { show_nav: visible }),
})
}}
/>
</section>
<div className="sidebar-divider" />
<section className="admin-home-section">
<Text size="md" strong>
首页卡片
</Text>
<Text size="sm" tone="muted">
卡片 = 一张首页区块 + 勾选它包含哪些分组;分组本身的启停仍由「分组管理」决定。
</Text>
<CardsPanel
groups={groups}
cards={cards}
projects={projects}
onReload={load}
onDelete={(id, title) => setPending({ kind: 'card', id: String(id), title })}
/>
</section>
</div>
)}
{tab === 'header' && (
<HeaderPanel
value={draft.header}
onChange={(header) => setDraft({ ...draft, header })}
site={draft.site}
onChangeSite={(site) => setDraft({ ...draft, site })}
/>
)}
{tab === 'profile' && (
<ProfilePanel
value={draft.profile}
onChange={(profile) => setDraft({ ...draft, profile })}
/>
)}
{tab === 'system' && <SystemPanel />}
{(tab === 'header' || tab === 'profile') && (
<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={pending !== null}
onClose={() => {
if (!deleting) setPending(null)
}}
title={pending === null ? '删除' : `删除「${pending.title}」?`}
size="sm"
footer={
<Space gap="sm" justify="end">
<Button variant="secondary" disabled={deleting} onClick={() => setPending(null)}>
取消
</Button>
<Button
variant="danger"
loading={deleting}
icon={<Icon name="delete_2_line" />}
onClick={() => void confirmDelete()}
>
删除
</Button>
</Space>
}
>
<Text>
{pending?.kind === 'group'
? '删除分组会连同子分组与其中的条目一起删除,且不可恢复。'
: '卡片只是展示组合,删除不会影响内容。'}
</Text>
</Modal>
</div>
)
}
// ---------- 分组管理 ----------
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<string> {
const keys = new Set<string>([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<GroupFormat, string> = {
apps: '应用',
posters: '海报',
articles: '图文',
}
function GroupMark({ icon }: { icon: string }) {
if (icon.trim() === '') {
return <span className="group-mark group-mark-empty" aria-hidden="true" />
}
if (ICON_NAMES.includes(icon as IconName)) {
return (
<span className="group-mark" aria-hidden="true">
<Icon name={icon as IconName} />
</span>
)
}
return (
<span className="group-mark group-mark-text" aria-hidden="true">
{icon}
</span>
)
}
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<Set<string>>(new Set())
const [dialog, setDialog] = useState<GroupDialogState>(null)
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 (
<Space direction="column" gap="md" align="stretch" className="admin-form">
<Text size="sm" tone="muted">
分组用于控制前台侧边栏与卡片;可多层嵌套,根分组决定格式。内容(条目)只挂在分组上。
</Text>
<div className="group-toolbar">
<Button
icon={<Icon name="add_line" />}
onClick={() => setDialog({ mode: 'root' })}
>
新建根分组
</Button>
</div>
<div className="group-table-wrap">
<table className="group-table">
<thead>
<tr>
<th>分组名称</th>
<th className="group-col-sort">排序权重</th>
<th className="group-col-count">关联网页数</th>
<th className="group-col-status">状态</th>
<th className="group-col-actions">操作</th>
</tr>
</thead>
<tbody>
{rows.map(({ node, level }) => {
const hasChildren = node.children.length > 0
const open = !collapsed.has(node.key)
return (
<tr key={node.key}>
<td>
<div className="group-name-cell" style={{ paddingLeft: level * 20 }}>
{hasChildren ? (
<button
type="button"
className="group-caret"
aria-label={open ? `折叠 ${node.title}` : `展开 ${node.title}`}
onClick={() => toggle(node.key)}
>
{open ? '▾' : '▸'}
</button>
) : (
<span className="group-caret group-caret-empty" aria-hidden="true" />
)}
<GroupMark icon={node.icon} />
<span className="group-name">{node.title}</span>
</div>
</td>
<td className="group-sort">{node.sort}</td>
<td className="group-count">{countGroupEntries(node)}</td>
<td>
<Switch
checked={node.enabled !== false}
onChange={(next) => void toggleEnabled(node, next)}
label={`启用 ${node.title}`}
size="sm"
/>
</td>
<td>
<div className="group-actions">
<button
type="button"
className="group-action"
onClick={() => setDialog({ mode: 'child', parent: node })}
>
加子分组
</button>
<button
type="button"
className="group-action"
onClick={() => setDialog({ mode: 'edit', group: node })}
>
编辑
</button>
<button
type="button"
className="group-action group-action-danger"
onClick={() => onDelete(node.key, node.title)}
>
删除
</button>
</div>
</td>
</tr>
)
})}
{rows.length === 0 && (
<tr>
<td colSpan={5} className="group-empty">
暂无分组数据,点击左侧「新建根分组」
</td>
</tr>
)}
</tbody>
</table>
</div>
<GroupDialog
state={dialog}
groups={groups}
onClose={() => setDialog(null)}
onSaved={() => {
setDialog(null)
onReload()
}}
/>
</Space>
)
}
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('')
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<string>()
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 (
<Modal
open={open}
onClose={busy ? () => undefined : onClose}
title={
state?.mode === 'edit'
? '编辑分组'
: state?.mode === 'child'
? '新增子分组'
: '新建根分组'
}
size="md"
footer={
<Space gap="sm" justify="end">
<Button variant="secondary" onClick={onClose}>
取消
</Button>
<Button
loading={busy}
icon={<Icon name="check_line" />}
onClick={() => void submit()}
>
保存
</Button>
</Space>
}
>
<Space direction="column" gap="md" align="stretch">
{error !== '' && (
<Notice tone="error" title="保存失败">
{error}
</Notice>
)}
{showKey && (
<FormField label="分组标识" htmlFor="group-key" hint="小写字母、数字与连字符,创建后不可改">
<Input
id="group-key"
value={key}
placeholder="如 frontend"
onChange={(event) => setKey(event.target.value)}
/>
</FormField>
)}
<FormField label="分组名称" htmlFor="group-title" required>
<Input
id="group-title"
value={title}
placeholder="如 前端开发、常用工具"
onChange={(event) => setTitle(event.target.value)}
/>
</FormField>
{(!isRootEdit) && (
<FormField
label="上级分组"
htmlFor="group-parent"
hint={editing === undefined ? '选择「根目录」则创建一级分组' : undefined}
>
<Select
options={[
...(editing === undefined
? [{ value: '', label: 'ROOT 根目录(一级分组)' }]
: []),
...parentOptions,
]}
value={parentKey}
onChange={setParentKey}
/>
</FormField>
)}
<FormField label="图标样式" htmlFor="group-icon" hint="留空则不显示图标;可填 iconfont 图标名或一个字符">
<div className="group-icon-field">
<span className="group-icon-preview">
<GroupMark icon={icon} />
</span>
<Input
id="group-icon"
value={icon}
placeholder="如 folder_line / 📁,留空不显示"
onChange={(event) => setIcon(event.target.value)}
/>
</div>
</FormField>
<div className="group-dialog-row">
<FormField label="排序权重" htmlFor="group-sort" hint="越小越靠前">
<Input
id="group-sort"
type="number"
value={sort}
placeholder="自动"
onChange={(event) => setSort(event.target.value)}
/>
</FormField>
<FormField label="状态" htmlFor="group-enabled">
<Select
options={[
{ value: '1', label: '启用' },
{ value: '0', label: '禁用' },
]}
value={enabled ? '1' : '0'}
onChange={(next) => setEnabled(next === '1')}
/>
</FormField>
</div>
</Space>
</Modal>
)
}
// ---------- 内容 ----------
type EntryWithGroup = { entry: GroupEntry; group: GroupNode }
/** 选中某个分组时,把它子树里的条目全部取出来(含子分组) */
function collectGroupEntries(group: GroupNode): EntryWithGroup[] {
return [
...group.entries.map((entry) => ({ entry, group })),
...group.children.flatMap((child) => collectGroupEntries(child)),
]
}
function EntryLink({ href }: { href?: string }) {
const value = (href ?? '').trim()
if (value === '' || value === '#') {
return <span className="entry-href entry-href-empty">—</span>
}
if (/^https?:\/\//i.test(value) || value.startsWith('/')) {
return (
<a className="entry-href" href={value} target="_blank" rel="noreferrer" title={value}>
{value}
</a>
)
}
return <span className="entry-href">{value}</span>
}
function ContentPanel({ groups, onReload }: { groups: GroupNode[]; onReload: () => void }) {
const options = useMemo(() => flattenCardsGroups(groups), [groups])
const [groupKey, setGroupKey] = useState(options[0]?.key ?? '')
const [dialog, setDialog] = useState<{ key: string | null; groupKey: string } | null>(null)
useEffect(() => {
if (options.length > 0 && !options.some((option) => option.key === groupKey)) {
setGroupKey(options[0].key)
}
}, [options, groupKey])
const group = findGroup(groups, groupKey)
const rows = group === undefined ? [] : collectGroupEntries(group)
return (
<Space direction="column" gap="md" align="stretch" className="admin-form">
<Text size="sm" tone="muted">
条目只属于一个分组;选中主分组时会连同其所有子分组的条目一起列出。
</Text>
<Space gap="sm" wrap>
<div style={{ width: 260 }}>
<Select
options={options.map((option) => ({
value: option.key,
label: option.title,
}))}
value={group?.key ?? ''}
onChange={setGroupKey}
/>
</div>
<Button
icon={<Icon name="add_line" />}
disabled={group === undefined}
onClick={() => setDialog({ key: null, groupKey })}
>
新增条目
</Button>
</Space>
<Text size="sm" tone="muted">
{group?.title ?? ''} 下共 {rows.length} 条(含子分组)
</Text>
<div className="group-table-wrap">
<table className="group-table entry-table">
<thead>
<tr>
<th>标题</th>
<th className="entry-col-format">格式</th>
<th className="entry-col-href">跳转链接</th>
<th className="entry-col-group">所属分组</th>
<th className="group-col-actions">操作</th>
</tr>
</thead>
<tbody>
{rows.map(({ entry, group: owner }) => (
<tr key={entry.id}>
<td>
<span className="entry-title">{entry.name ?? entry.title ?? entry.id}</span>
</td>
<td>
<span className="entry-format">
{FORMAT_LABEL[(entry.format ?? 'apps') as GroupFormat]}
</span>
</td>
<td>
<EntryLink href={entry.href} />
</td>
<td>
<span className="entry-group">{owner.title}</span>
</td>
<td>
<div className="group-actions">
<button
type="button"
className="group-action"
onClick={() => setDialog({ key: entry.id, groupKey: owner.key })}
>
编辑
</button>
<button
type="button"
className="group-action group-action-danger"
onClick={async () => {
const result = await deleteItem(entry.id)
if (!result.ok) {
toast.error(result.error)
return
}
toast.success('条目已删除')
onReload()
}}
>
删除
</button>
</div>
</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={5} className="group-empty">
该分组下还没有内容
</td>
</tr>
)}
</tbody>
</table>
</div>
<EntryDialog
open={dialog !== null}
entryKey={dialog?.key ?? null}
groups={groups}
defaultGroupKey={dialog?.groupKey ?? groupKey}
onClose={() => setDialog(null)}
onSaved={() => {
setDialog(null)
onReload()
}}
/>
</Space>
)
}
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<typeof form>) =>
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 (
<Modal
open={open}
onClose={busy ? () => undefined : onClose}
title={entryKey === null ? '新增条目' : '编辑条目'}
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={() => void submit()}>
保存
</Button>
</Space>
}
>
{loading ? (
<Text>正在读取…</Text>
) : (
<Space direction="column" gap="md" align="stretch">
{error !== '' && (
<Notice tone="error" title="操作失败">
{error}
</Notice>
)}
<div style={{ width: 280 }}>
<Select
options={options.map((option) => ({
value: option.key,
label: option.title,
}))}
value={groupKey}
onChange={setGroupKey}
/>
</div>
<FormField label="格式" htmlFor="entry-format" hint="每条内容单独选择格式,同一个分组下可以混合">
<Select
options={FORMAT_OPTIONS}
value={form.format}
onChange={(next) => update({ format: next as GroupFormat })}
/>
</FormField>
<FormField
label="标识"
htmlFor="entry-key"
hint={entryKey === null ? '小写字母、数字与连字符,创建后不可改' : '标识创建后不可修改'}
>
<Input
id="entry-key"
value={form.key}
disabled={entryKey !== null}
placeholder="如 my-site"
onChange={(event) => update({ key: event.target.value })}
/>
</FormField>
<div className="admin-content-form">
<Input
value={form.name}
placeholder="标题"
aria-label="条目标题"
onChange={(event) => update({ name: event.target.value })}
/>
<Input
value={form.href}
placeholder="跳转地址"
aria-label="跳转地址"
onChange={(event) => update({ href: event.target.value })}
/>
<Input
value={form.icon}
placeholder="字符图标"
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 SidebarPanel({
value,
onToggleCard,
saving,
}: {
value: SiteSettings['sidebar']
onToggleCard: (card: 'profile' | 'service' | 'nav', visible: boolean) => void
saving: boolean
}) {
return (
<Space direction="column" gap="md" align="stretch" className="admin-form">
<Text size="sm" tone="muted">
分组在侧边栏的显隐只由「分组管理 → 状态」决定;这里只管三块侧栏卡片。
</Text>
<Text size="sm" strong>
侧栏卡片
</Text>
<div className="sidebar-toggle-row">
<Text className="admin-item-title">本地管理员</Text>
<Switch
checked={value.show_profile !== false}
onChange={(visible) => onToggleCard('profile', visible)}
label="显示本地管理员卡片"
disabled={saving}
/>
</div>
<div className="sidebar-toggle-row">
<Text className="admin-item-title">本地服务</Text>
<Switch
checked={value.show_service !== false}
onChange={(visible) => onToggleCard('service', visible)}
label="显示本地服务卡片"
disabled={saving}
/>
</div>
<div className="sidebar-toggle-row">
<Text className="admin-item-title">分组导航</Text>
<Switch
checked={value.show_nav !== false}
onChange={(visible) => onToggleCard('nav', visible)}
label="显示分组导航卡片"
disabled={saving}
/>
</div>
</Space>
)
}
// ---------- 首页卡片(勾选分组) ----------
function CardsPanel({
groups,
cards,
projects,
onReload,
onDelete,
}: {
groups: GroupNode[]
cards: CardItem[]
projects: Project[]
onReload: () => void
onDelete: (id: number, title: string) => void
}) {
const options = useMemo(() => flattenCardsGroups(groups), [groups])
const [activeId, setActiveId] = useState<number | null>(cards[0]?.id ?? null)
const [pendingId, setPendingId] = useState<number | null>(null)
const [newOpen, setNewOpen] = useState(false)
const [newTitle, setNewTitle] = useState('')
const [newBusy, setNewBusy] = useState(false)
const [dragId, setDragId] = useState<number | null>(null)
const [overId, setOverId] = useState<number | null>(null)
const [reordering, setReordering] = useState(false)
useEffect(() => {
if (cards.length === 0) {
setActiveId(null)
setPendingId(null)
return
}
if (pendingId !== null && cards.some((card) => card.id === pendingId)) {
setActiveId(pendingId)
setPendingId(null)
return
}
if (activeId === null || !cards.some((card) => card.id === activeId)) {
setActiveId(cards[0].id)
}
}, [cards, activeId, pendingId])
const active = cards.find((card) => card.id === activeId) ?? cards[0]
const create = async () => {
setNewBusy(true)
const result = await createCard(newTitle.trim())
setNewBusy(false)
if (!result.ok) {
toast.error(result.error)
return
}
setNewTitle('')
setNewOpen(false)
setPendingId(result.id)
toast.success('卡片已创建')
onReload()
}
const reorder = async (targetId: number) => {
const from = dragId
setDragId(null)
setOverId(null)
if (from === null || from === targetId) return
const original = cards.map((card) => card.id)
const fromIndex = original.indexOf(from)
const targetIndex = original.indexOf(targetId)
if (fromIndex < 0 || targetIndex < 0) return
const ids = original.filter((id) => id !== from)
const targetAfter = ids.indexOf(targetId)
// 向下拖时插到目标之后,保证能拖到列表最后一项;向上拖时插到目标之前
const insertAt = fromIndex < targetIndex ? targetAfter + 1 : targetAfter
ids.splice(insertAt, 0, from)
setReordering(true)
const results = await Promise.all(ids.map((id, index) => updateCard(id, { sort: index })))
setReordering(false)
if (results.some((result) => !result.ok)) {
toast.error('排序保存失败')
onReload()
return
}
toast.success('顺序已保存')
onReload()
}
return (
<>
<div className="cards-manager">
<aside className="cards-side">
<Text size="sm" tone="muted">
卡片
</Text>
<div className="cards-side-new">
<Button
size="sm"
icon={<Icon name="add_line" />}
onClick={() => {
setNewTitle('')
setNewOpen(true)
}}
>
新建卡片
</Button>
</div>
<div className="cards-side-list">
{cards.map((card) => (
<button
key={card.id}
type="button"
draggable={!reordering}
className={[
'cards-side-item',
active?.id === card.id ? 'is-active' : '',
dragId === card.id ? 'is-dragging' : '',
overId === card.id && dragId !== null && dragId !== card.id ? 'is-over' : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => setActiveId(card.id)}
onDragStart={(event) => {
setDragId(card.id)
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', String(card.id))
}}
onDragOver={(event) => {
event.preventDefault()
if (overId !== card.id) setOverId(card.id)
}}
onDrop={(event) => {
event.preventDefault()
void reorder(card.id)
}}
onDragEnd={() => {
setDragId(null)
setOverId(null)
}}
>
<span className="cards-drag-handle" aria-hidden="true">
⋮⋮
</span>
<span className="cards-side-title">{card.title}</span>
<span className="cards-side-count">
{card.key === 'local-service' ? projects.length : card.groups.length}
</span>
</button>
))}
{cards.length === 0 && (
<Text size="sm" tone="muted">
还没有卡片,先新建一张。
</Text>
)}
</div>
{cards.length > 1 && (
<Text size="sm" tone="muted" className="cards-drag-hint">
拖动卡片可调整首页顺序
</Text>
)}
</aside>
<div className="cards-main">
{active === undefined ? (
<Text tone="muted">新建一张卡片开始配置。</Text>
) : active.key === 'local-service' ? (
<LocalServiceTable projects={projects} />
) : (
<CardTable
key={active.id}
card={active}
options={options}
onReload={onReload}
onDelete={onDelete}
/>
)}
</div>
</div>
<Modal
open={newOpen}
onClose={() => {
if (!newBusy) setNewOpen(false)
}}
title="新建卡片"
size="sm"
footer={
<Space gap="sm" justify="end">
<Button variant="secondary" onClick={() => setNewOpen(false)}>
取消
</Button>
<Button
loading={newBusy}
disabled={newTitle.trim() === ''}
icon={<Icon name="check_line" />}
onClick={() => void create()}
>
确认新建
</Button>
</Space>
}
>
<FormField label="卡片标题" htmlFor="new-card-title">
<Input
id="new-card-title"
value={newTitle}
placeholder="如 常用推荐"
onChange={(event) => setNewTitle(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && newTitle.trim() !== '') void create()
}}
/>
</FormField>
</Modal>
</>
)
}
function LocalServiceTable({ projects }: { projects: Project[] }) {
return (
<div>
<div className="cards-table-head">
<div className="admin-node-title">
<Input value="本地服务" disabled aria-label="本地服务卡片" />
</div>
<Tag tone="neutral">不可删除</Tag>
</div>
<Text size="sm" tone="muted">
本地服务卡片由托管目录自动生成,随项目扫描更新;后台只能查看,不能删除或改名。
</Text>
<div className="group-table-wrap cards-table-wrap">
<table className="group-table cards-table">
<thead>
<tr>
<th>项目名称</th>
<th className="entry-col-href">访问地址</th>
</tr>
</thead>
<tbody>
{projects.map((project) => (
<tr key={project.name}>
<td>
<span className="group-name">{project.name}</span>
</td>
<td>
<a
className="entry-href"
href={project.route_path}
target="_blank"
rel="noreferrer"
>
{project.route_path}
</a>
</td>
</tr>
))}
{projects.length === 0 && (
<tr>
<td colSpan={2} className="group-empty">
还没有托管项目
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}
function CardTable({
card,
options,
onReload,
onDelete,
}: {
card: CardItem
options: { key: string; title: string; depth: number; enabled: boolean }[]
onReload: () => void
onDelete: (id: number, title: string) => void
}) {
const [title, setTitle] = useState(card.title)
const [selected, setSelected] = useState<Record<string, string>>(
Object.fromEntries(
card.groups.map((group) => [
group.groupKey,
group.previewLimit === null || group.previewLimit === undefined
? ''
: String(group.previewLimit),
]),
),
)
const [busy, setBusy] = useState(false)
/** 勾选的分组按此顺序在首页卡片里出现;启用先后不再决定顺序 */
const [order, setOrder] = useState<string[]>(() => card.groups.map((group) => group.groupKey))
const persistGroups = async (nextSelected: Record<string, string>, nextOrder: string[]) => {
setBusy(true)
const groups = nextOrder
.filter((key) => key in nextSelected)
.map((groupKey) => {
const limit = nextSelected[groupKey] ?? ''
return {
groupKey,
previewLimit: limit.trim() === '' ? undefined : Number(limit),
}
})
const result = await setCardGroups(card.id, groups)
setBusy(false)
if (!result.ok) {
toast.error(result.error)
onReload()
return
}
onReload()
}
const toggle = (key: string, checked: boolean) => {
const next = { ...selected }
let nextOrder = order
if (checked) {
next[key] = ''
if (!order.includes(key)) nextOrder = [...order, key]
} else {
delete next[key]
nextOrder = order.filter((item) => item !== key)
}
setSelected(next)
setOrder(nextOrder)
void persistGroups(next, nextOrder)
}
const moveGroup = (key: string, delta: number) => {
const index = order.indexOf(key)
const target = index + delta
if (index < 0 || target < 0 || target >= order.length) return
const nextOrder = [...order]
nextOrder.splice(index, 1)
nextOrder.splice(target, 0, key)
setOrder(nextOrder)
void persistGroups(selected, nextOrder)
}
const saveTitle = async () => {
const next = title.trim()
if (next === '' || next === card.title) {
setTitle(card.title)
return
}
const result = await updateCard(card.id, { title: next })
if (!result.ok) {
toast.error(result.error)
setTitle(card.title)
return
}
toast.success('卡片已保存')
onReload()
}
return (
<div>
<div className="cards-table-head">
<div className="admin-node-title">
<Input
value={title}
aria-label={`${card.title} 标题`}
onChange={(event) => setTitle(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void saveTitle()
}}
/>
</div>
<Button variant="secondary" size="sm" onClick={() => void saveTitle()}>
保存
</Button>
<Button
variant="ghost"
size="sm"
className="admin-remove"
icon={<Icon name="delete_2_line" />}
aria-label="删除卡片"
onClick={() => onDelete(card.id, card.title)}
>
删除
</Button>
</div>
<Text size="sm" tone="muted">
开关和条数修改后即时保存;条数留空使用默认。
</Text>
<div className="group-table-wrap cards-table-wrap">
<table className="group-table cards-table">
<thead>
<tr>
<th>分组名称</th>
<th className="cards-col-state">状态</th>
<th className="cards-col-limit">预览条数</th>
<th className="cards-col-order">顺序</th>
<th className="group-col-status">展示</th>
</tr>
</thead>
<tbody>
{options.map((option) => {
const checked = option.key in selected
return (
<tr key={option.key}>
<td>
<div style={{ paddingLeft: option.depth * 18 }}>
<span className="group-name">{option.title}</span>
</div>
</td>
<td>
<span className={option.enabled ? 'group-state-on' : 'group-off-tag'}>
{option.enabled ? '启用' : '已停用'}
</span>
</td>
<td>
{checked ? (
<Input
type="number"
value={selected[option.key]}
placeholder="默认"
aria-label={`${option.title} 条数`}
onChange={(event) =>
setSelected((current) => ({
...current,
[option.key]: event.target.value,
}))
}
onBlur={() => void persistGroups(selected, order)}
/>
) : (
<span className="entry-href entry-href-empty">—</span>
)}
</td>
<td>
{checked ? (
<div className="card-order-cell">
<span className="card-order-index">{order.indexOf(option.key) + 1}</span>
<IconButton
label={`${option.title} 上移`}
icon={<Icon name="arrow_up_line" />}
size="xs"
disabled={busy || order.indexOf(option.key) === 0}
onClick={() => moveGroup(option.key, -1)}
/>
<IconButton
label={`${option.title} 下移`}
icon={<Icon name="arrow_down_line" />}
size="xs"
disabled={busy || order.indexOf(option.key) === order.length - 1}
onClick={() => moveGroup(option.key, 1)}
/>
</div>
) : (
<span className="entry-href entry-href-empty">—</span>
)}
</td>
<td>
<Switch
checked={checked}
onChange={(next) => toggle(option.key, next)}
label={`卡片包含 ${option.title}`}
disabled={busy || !option.enabled}
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
// ---------- 页头 / 资料 / 系统(沿用旧逻辑) ----------
const TONE_SELECT = TONE_OPTIONS.map((option) => ({ value: option.value, label: option.label }))
function HeaderPanel({
value,
onChange,
site,
onChangeSite,
}: {
value: SiteSettings['header']
onChange: (header: SiteSettings['header']) => void
site: SiteSettings['site']
onChangeSite: (site: SiteSettings['site']) => void
}) {
return (
<Space direction="column" gap="lg" align="stretch" className="admin-form">
<Text size="sm" strong>
站点信息
</Text>
<FormField label="页面标题" htmlFor="site-title" hint="浏览器标签页上的文字">
<Input
id="site-title"
value={site.title}
placeholder="Stash · 本地应用工作区"
onChange={(event) => onChangeSite({ ...site, title: event.target.value })}
/>
</FormField>
<FormField label="Favicon 地址" htmlFor="site-favicon" hint="留空使用内置图标;支持 .svg / .png 或完整 URL">
<Input
id="site-favicon"
value={site.favicon}
placeholder="/favicon.svg"
onChange={(event) => onChangeSite({ ...site, favicon: event.target.value })}
/>
</FormField>
<Text size="sm" strong>
首页 Logo
</Text>
<div className="brand-preview" aria-hidden="true">
{site.brand.logo.trim() !== '' ? (
<img className="site-brand-logo" src={site.brand.logo} alt="" />
) : (
<span className="site-brand-mark">
{(site.brand.mark.trim() || site.brand.name.trim().slice(0, 1) || 'S').slice(0, 4)}
</span>
)}
{site.brand.name.trim() !== '' && (
<span className="site-brand-name">{site.brand.name}</span>
)}
</div>
<FormField label="Logo 图片地址" htmlFor="site-logo" hint="留空则用右侧的首字圆形标记">
<Input
id="site-logo"
value={site.brand.logo}
placeholder="https://… 或 /logo.svg"
onChange={(event) =>
onChangeSite({ ...site, brand: { ...site.brand, logo: event.target.value } })
}
/>
</FormField>
<div className="admin-row">
<FormField label="品牌文字" htmlFor="site-brand-name" hint="可留空只显示 logo">
<Input
id="site-brand-name"
value={site.brand.name}
placeholder="STASH"
onChange={(event) =>
onChangeSite({ ...site, brand: { ...site.brand, name: event.target.value } })
}
/>
</FormField>
<FormField label="首字标记" htmlFor="site-brand-mark" hint="1–4 个字">
<Input
id="site-brand-mark"
value={site.brand.mark}
placeholder="S"
onChange={(event) =>
onChangeSite({ ...site, brand: { ...site.brand, mark: event.target.value } })
}
/>
</FormField>
</div>
<Text size="sm" strong>
操作工具
</Text>
<div className="sidebar-toggle-row">
<Text>刷新</Text>
<Switch
checked={site.actions.refresh}
onChange={(next) =>
onChangeSite({ ...site, actions: { ...site.actions, refresh: next } })
}
label="页头显示刷新按钮"
/>
</div>
<div className="sidebar-toggle-row">
<Text>站点设置</Text>
<Switch
checked={site.actions.settings}
onChange={(next) =>
onChangeSite({ ...site, actions: { ...site.actions, settings: next } })
}
label="页头显示站点设置按钮"
/>
</div>
<div className="sidebar-toggle-row">
<Text>登录 / 退出</Text>
<Switch
checked={site.actions.session}
onChange={(next) =>
onChangeSite({ ...site, actions: { ...site.actions, session: next } })
}
label="页头显示登录或退出按钮"
/>
</div>
<Text size="sm" strong>
滚动信息
</Text>
<div className="sidebar-toggle-row">
<Text>显示页头滚动信息</Text>
<Switch
checked={value.enabled}
onChange={(enabled) => onChange({ ...value, enabled })}
label="显示页头滚动信息"
/>
</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>
{value.items.map((item, index) => (
<div className="admin-item-row" key={index}>
<Input
value={item.text}
placeholder="信息文本"
aria-label="信息文本"
onChange={(event) =>
onChange({
...value,
items: value.items.map((current, currentIndex) =>
currentIndex === index ? { ...current, text: event.target.value } : current,
),
})
}
/>
<Input
value={item.tag ?? ''}
placeholder="标签"
aria-label="标签"
onChange={(event) =>
onChange({
...value,
items: value.items.map((current, currentIndex) =>
currentIndex === index ? { ...current, tag: event.target.value } : current,
),
})
}
/>
<Select
options={TONE_SELECT}
value={item.tone}
onChange={(tone) =>
onChange({
...value,
items: value.items.map((current, currentIndex) =>
currentIndex === index
? { ...current, tone: tone as SettingsTone }
: current,
),
})
}
/>
<Button
variant="ghost"
size="sm"
icon={<Icon name="delete_2_line" />}
aria-label="删除这一条"
onClick={() =>
onChange({ ...value, items: value.items.filter((_, current) => current !== index) })
}
/>
</div>
))}
<Button
variant="secondary"
icon={<Icon name="add_line" />}
onClick={() =>
onChange({ ...value, items: [...value.items, { text: '', tag: '', tone: 'plain' }] })
}
>
添加一条
</Button>
</Space>
)
}
function ProfilePanel({
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">
<Input
id="profile-avatar"
value={value.avatar}
onChange={(event) => update({ avatar: event.target.value })}
/>
</FormField>
<FormField label="名称" htmlFor="profile-name">
<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">
<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"
value={current}
onChange={(event) => setCurrent(event.target.value)}
/>
</FormField>
<FormField label="新密码" htmlFor="next-password" hint="至少 8 位">
<PasswordInput
id="next-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>
<Button icon={<Icon name="download_line" />} onClick={() => window.location.assign('/api/backup')}>
下载备份
</Button>
</Space>
)
}