Files
stash/stash-web/src/components/admin/AdminNext.tsx
T
CNWei b1fd82a0b6 style(admin): 侧边栏去掉「侧栏卡片」小标题,说明贴近标题
- 删除 SidebarPanel 里的「侧栏卡片」标签
- SidebarPanel 不再挂 .admin-form(它带 20px margin-top),
  标题到说明的距离由 32px 变成 12px,与「内容卡片」一致
- 三个开关行间距不变(Space gap=md)
2026-11-01 22:14:08 +08:00

2137 lines
65 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 {
Brand,
Button,
Card,
Caret,
Empty,
FormField,
Icon,
ICON_NAMES,
IconButton,
Input,
Menu,
Modal,
Notice,
PasswordInput,
Select,
Space,
SortableList,
Switch,
Table,
Tabs,
Tag,
Text,
Textarea,
TextButton,
Title,
ToggleRow,
Toaster,
toast,
type IconName,
type MenuItem,
} from '@stash/ui'
import { useEffect, useMemo, useRef, useState } from 'react'
import { changePassword, fetchSession, logout, type SessionState } from '../../auth'
import { LoginDialog } from '../auth/LoginDialog'
import { GroupMark as GroupGlyph } from '../common/GroupMark'
import { isDarkScheme, readThemeOverride, writeThemeOverride } from '../../theme'
import {
fetchSettings,
saveSettings,
TONE_OPTIONS,
type ProfileSettings,
type SettingsDocument,
type SettingsTone,
type SiteSettings,
type SiteTheme,
} from '../../settings'
import {
createCard,
createNode,
deleteCard,
deleteNode,
fetchManage,
flattenGroups,
setCardGroups,
updateCard,
updateNode,
type CardItem,
type GroupEntry,
type GroupFormat,
type GroupNode,
} from '../../manage'
import { createItem, deleteItem, fetchItem, saveItem } from '../../items'
import { useProjects } from '../../useProjects'
import type { Project } from '../../api'
/**
* 新版后台:原子模型。
*
* - 分组是唯一容器(可多层、带格式);
* - 内容是条目,挂在分组上;
* - 侧边栏、首页卡片都只是「勾选分组」的展示组合。
*/
type TabKey =
| 'groups'
| 'content'
| 'home-sidebar'
| 'home-cards'
| 'home-header'
| 'profile'
| 'system'
/** 左侧导航:首页管理下面挂三个子项,页头也归到这一组 */
const ADMIN_NAV: MenuItem[] = [
{ key: 'groups', label: '分组管理' },
{ key: 'content', label: '数据管理' },
{
key: 'home',
label: '首页管理',
children: [
{ key: 'home-header', label: '页头' },
{ key: 'home-sidebar', label: '侧边栏' },
{ key: 'home-cards', label: '内容卡片' },
],
},
{ key: 'profile', label: '资料管理' },
{ key: 'system', label: '系统管理' },
]
const FORMAT_OPTIONS = [
{ value: 'apps', label: '应用' },
{ value: 'posters', label: '海报' },
{ value: 'articles', label: '图文' },
]
const clone = (settings: SiteSettings): SiteSettings => structuredClone(settings)
function findGroup(groups: GroupNode[], key: string): GroupNode | undefined {
for (const group of groups) {
if (group.key === key) return group
const found = findGroup(group.children, key)
if (found !== undefined) return found
}
return undefined
}
function findGroupByEntry(groups: GroupNode[], entryId: string): GroupNode | undefined {
for (const group of groups) {
if (group.entries.some((entry) => entry.id === entryId)) return group
const found = findGroupByEntry(group.children, entryId)
if (found !== undefined) return found
}
return undefined
}
function flattenCardsGroups(groups: GroupNode[]) {
return flattenGroups(groups)
}
export function AdminNext() {
const [session, setSession] = useState<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
// 本机偏好优先于站点默认,跟前台一致
window.document.documentElement.dataset.scheme = readThemeOverride() ?? meta.theme
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, document?.settings.site.theme])
const authenticated = session?.authenticated === true
const save = async () => {
if (document === null || draft === null) return
setSaving(true)
const result = await saveSettings(document.version, draft)
setSaving(false)
if (!result.ok) {
toast.error(result.error)
return
}
setDocument(result.document)
setDraft(clone(result.document.settings))
toast.success('已保存')
}
/** 侧边栏开关即时保存(不再单独点保存按钮) */
const saveSidebar = async (sidebar: SiteSettings['sidebar']) => {
if (document === null || draft === null) return
setSaving(true)
const result = await saveSettings(document.version, { ...draft, sidebar })
setSaving(false)
if (!result.ok) {
toast.error(result.error)
void load()
return
}
setDocument(result.document)
setDraft(clone(result.document.settings))
toast.success('侧边栏已保存')
}
/** 页头的明暗切换:改的是站点默认值,并清掉本机偏好,保证点完看到的就是新默认 */
const themeSetting = draft?.site.theme ?? document?.settings.site.theme ?? 'auto'
const isDark = isDarkScheme(readThemeOverride() ?? themeSetting)
const toggleTheme = async () => {
if (document === null || draft === null) return
const next = isDark ? 'light' : 'dark'
setSaving(true)
writeThemeOverride(null)
const result = await saveSettings(document.version, {
...draft,
site: { ...draft.site, theme: next },
})
setSaving(false)
if (!result.ok) {
toast.error(result.error)
return
}
setDocument(result.document)
setDraft(clone(result.document.settings))
}
const confirmDelete = async () => {
if (pending === null) return
setDeleting(true)
const result =
pending.kind === 'group'
? await deleteNode(pending.id)
: await deleteCard(Number(pending.id))
setDeleting(false)
if (!result.ok) {
toast.error(result.error)
return
}
toast.success(`已删除「${pending.title}」`)
setPending(null)
await load()
}
const brand = document?.settings.site.brand ?? { name: 'STASH', mark: 'S', logo: '' }
const brandName = brand.name.trim()
const brandMark = brand.mark.trim() || brandName.slice(0, 1) || 'S'
const brandLogo = brand.logo.trim()
return (
<div className="admin-page">
<Toaster />
{/* 与前台页头同一套结构:品牌在左、图标操作在右,整条吸顶 */}
<header className="admin-topbar">
<div className="site-header-inner">
<Brand href="/" name={brandName} mark={brandMark} logo={brandLogo} />
<div className="header-actions">
<IconButton
label={isDark ? '切换到浅色' : '切换到深色'}
icon={<Icon name={isDark ? 'sun_line' : 'moon_line'} />}
onClick={() => void toggleTheme()}
disabled={!authenticated || saving}
/>
<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 && (
<div className="admin-shell">
<nav className="admin-nav">
<Menu
items={ADMIN_NAV}
activeKey={tab}
onSelect={(key) => setTab(key as TabKey)}
itemHeight={40}
defaultOpenKeys={['home']}
/>
</nav>
<div className="admin-panel">
{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-sidebar' && (
<section className="admin-home-section">
<Title level={4}>侧边栏</Title>
<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>
)}
{tab === 'home-cards' && (
<section className="admin-home-section">
<Title level={4}>内容卡片</Title>
<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>
)}
{tab === 'home-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 === 'home-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>
)}
</div>
</div>
)}
</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 }) {
const value = icon.trim()
const isIcon = ICON_NAMES.includes(value as IconName)
const classes = [
'group-mark',
value === '' ? 'group-mark-empty' : isIcon ? '' : 'group-mark-text',
]
.filter(Boolean)
.join(' ')
return (
<span className={classes} aria-hidden="true">
<GroupGlyph icon={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)
// 默认全部收起:数据第一次到位时,把所有有子节点的分组塞进 collapsed
const seeded = useRef(false)
useEffect(() => {
if (seeded.current || groups.length === 0) return
seeded.current = true
const keys = new Set<string>()
const walk = (nodes: GroupNode[]) => {
for (const node of nodes) {
if (node.children.length > 0) {
keys.add(node.key)
walk(node.children)
}
}
}
walk(groups)
setCollapsed(keys)
}, [groups])
const rows = useMemo(() => {
const result: { node: GroupNode; level: number }[] = []
const walk = (nodes: GroupNode[], level: number) => {
for (const node of nodes) {
result.push({ node, level })
if (!collapsed.has(node.key)) walk(node.children, level + 1)
}
}
walk(groups, 0)
return result
}, [groups, collapsed])
const toggle = (key: string) => {
setCollapsed((current) => {
const next = new Set(current)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
const toggleEnabled = async (group: GroupNode, enabled: boolean) => {
const result = await updateNode(group.key, { enabled })
if (!result.ok) {
toast.error(result.error)
return
}
toast.success(enabled ? '分组已启用' : '分组已禁用')
onReload()
}
return (
<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>
<Table
size="sm"
rowKey="key"
columns={[
{ key: 'name', title: '分组名称' },
{ key: 'sort', title: '排序权重', width: 110 },
{ key: 'count', title: '关联网页数', width: 120 },
{ key: 'status', title: '状态', width: 90 },
{ key: 'actions', title: '操作', width: 260, align: 'right' },
]}
rows={rows.map(({ node, level }) => {
const hasChildren = node.children.length > 0
const open = !collapsed.has(node.key)
return {
key: node.key,
name: (
<div className="group-name-cell" style={{ paddingLeft: level * 20 }}>
{hasChildren ? (
<button
type="button"
className="group-caret"
aria-label={open ? `折叠 ${node.title}` : `展开 ${node.title}`}
aria-expanded={open}
onClick={() => toggle(node.key)}
>
<Caret open={open} />
</button>
) : (
<span className="group-caret group-caret-empty" aria-hidden="true" />
)}
<GroupMark icon={node.icon} />
<span className="group-name">{node.title}</span>
</div>
),
sort: node.sort,
count: countGroupEntries(node),
status: (
<Switch
checked={node.enabled !== false}
onChange={(next) => void toggleEnabled(node, next)}
label={`启用 ${node.title}`}
size="sm"
/>
),
actions: (
<div className="group-actions">
<TextButton
icon={<Icon name="add_line" />}
onClick={() => setDialog({ mode: 'child', parent: node })}
>
新建子分组
</TextButton>
<TextButton
icon={<Icon name="edit_4_line" />}
onClick={() => setDialog({ mode: 'edit', group: node })}
>
编辑
</TextButton>
<TextButton
tone="danger"
icon={<Icon name="delete_2_line" />}
onClick={() => onDelete(node.key, node.title)}
>
删除
</TextButton>
</div>
),
}
})}
emptyText={<Empty text="暂无分组数据,点击上方「新建根分组」" />}
/>
<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('')
const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [iconQuery, setIconQuery] = useState('')
// 图标库里 446 个名字,先按关键字过滤,没输入时只给前 120 个,避免一次渲染太多
const iconList = useMemo(() => {
const query = iconQuery.trim().toLowerCase()
const names =
query === '' ? ICON_NAMES : ICON_NAMES.filter((name) => name.includes(query))
return names.slice(0, 120)
}, [iconQuery])
useEffect(() => {
if (!open) return
setBusy(false)
setError('')
if (state?.mode === 'edit') {
setKey(state.group.key)
setTitle(state.group.title)
setParentKey(state.group.parentKey ?? '')
setIcon(state.group.icon)
setSort(String(state.group.sort))
setEnabled(state.group.enabled)
return
}
setKey('')
setTitle('')
setIcon('')
setSort('')
setEnabled(true)
setParentKey(state?.mode === 'child' ? state.parent.key : '')
}, [open, state])
const excluded = editing !== undefined ? descendantKeys(editing) : new Set<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="从图标库选一个,或直接填一个字符(旧数据里的 emoji 照旧显示)"
>
<div className="group-icon-field">
<span className="group-icon-preview">
<GroupMark icon={icon} />
</span>
<Input
id="group-icon"
value={icon}
placeholder="如 folder_2_line / 📁,留空不显示"
onChange={(event) => setIcon(event.target.value)}
/>
<Button variant="secondary" onClick={() => setIconPickerOpen((open) => !open)}>
选择图标
</Button>
</div>
{iconPickerOpen && (
<div className="icon-picker">
<Input
value={iconQuery}
placeholder="搜索图标名,如 folder / user / play"
icon="search_3_line"
clearable
onClear={() => setIconQuery('')}
onChange={(event) => setIconQuery(event.target.value)}
/>
<div className="icon-picker-grid">
{iconList.map((name) => (
<button
key={name}
type="button"
className="icon-picker-item"
title={name}
aria-label={name}
onClick={() => {
setIcon(name)
setIconPickerOpen(false)
}}
>
<Icon name={name} />
</button>
))}
</div>
{iconList.length === 0 && (
<Text size="sm" tone="muted">
没有匹配的图标,换个关键字试试。
</Text>
)}
</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>
<div className="entry-toolbar">
<div className="entry-toolbar-select">
<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>
</div>
<Text size="sm" tone="muted">
{group?.title ?? ''} 下共 {rows.length} 条(含子分组)
</Text>
<Table
size="sm"
rowKey="key"
columns={[
{ key: 'title', title: '标题' },
{ key: 'format', title: '格式', width: 80 },
{ key: 'href', title: '跳转链接', width: '36%' },
{ key: 'group', title: '所属分组', width: 180 },
{ key: 'actions', title: '操作', width: 160, align: 'right' },
]}
rows={rows.map(({ entry, group: owner }) => ({
key: entry.id,
title: <span className="entry-title">{entry.name ?? entry.title ?? entry.id}</span>,
format: (
<span className="entry-format">
{FORMAT_LABEL[(entry.format ?? 'apps') as GroupFormat]}
</span>
),
href: <EntryLink href={entry.href} />,
group: <span className="entry-group">{owner.title}</span>,
actions: (
<div className="group-actions">
<TextButton
icon={<Icon name="edit_4_line" />}
onClick={() => setDialog({ key: entry.id, groupKey: owner.key })}
>
编辑
</TextButton>
<TextButton
tone="danger"
icon={<Icon name="delete_2_line" />}
onClick={async () => {
const result = await deleteItem(entry.id)
if (!result.ok) {
toast.error(result.error)
return
}
toast.success('条目已删除')
onReload()
}}
>
删除
</TextButton>
</div>
),
}))}
emptyText={<Empty text="该分组下还没有内容" />}
/>
<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">
<Text size="sm" tone="muted">
说明:分组在侧边栏的显隐只由「分组管理 → 状态」决定;这里只管三块侧栏卡片。
</Text>
<ToggleRow
label="本地管理员"
checked={value.show_profile !== false}
onChange={(visible) => onToggleCard('profile', visible)}
switchLabel="显示本地管理员卡片"
disabled={saving}
/>
<ToggleRow
label="本地服务"
checked={value.show_service !== false}
onChange={(visible) => onToggleCard('service', visible)}
switchLabel="显示本地服务卡片"
disabled={saving}
/>
<ToggleRow
label="分组导航"
checked={value.show_nav !== false}
onChange={(visible) => onToggleCard('nav', visible)}
switchLabel="显示分组导航卡片"
disabled={saving}
/>
</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 [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()
}
/** 排序:组件给回完整的 key 顺序,逐个写 sort 字段 */
const reorder = async (keys: string[]) => {
setReordering(true)
const results = await Promise.all(
keys.map((key, index) => updateCard(Number(key), { 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>
{cards.length > 1 && (
<Text size="sm" tone="muted" className="cards-drag-hint">
说明:拖动卡片可调整首页顺序
</Text>
)}
<SortableList
items={cards.map((card) => ({
key: String(card.id),
label: card.title,
meta: card.key === 'local-service' ? projects.length : card.groups.length,
}))}
activeKey={active === undefined ? undefined : String(active.id)}
onSelect={(key) => setActiveId(Number(key))}
onReorder={(keys) => void reorder(keys)}
disabled={reordering}
emptyText="还没有卡片,先新建一张。"
/>
</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>
<Table
size="sm"
rowKey="name"
columns={[
{ key: 'name', title: '项目名称' },
{ key: 'href', title: '访问地址', width: '50%' },
]}
rows={projects.map((project) => ({
name: <span className="group-name">{project.name}</span>,
href: (
<a className="entry-href" href={project.route_path} target="_blank" rel="noreferrer">
{project.route_path}
</a>
),
}))}
emptyText={<Empty text="还没有托管项目" />}
/>
</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>
<Table
size="sm"
rowKey="key"
columns={[
{ key: 'name', title: '分组名称' },
{ key: 'state', title: '状态', width: 90 },
{ key: 'limit', title: '预览条数', width: 110 },
{ key: 'order', title: '顺序', width: 120 },
{ key: 'show', title: '展示', width: 90 },
]}
rows={options.map((option) => {
const checked = option.key in selected
return {
key: option.key,
name: (
<div style={{ paddingLeft: option.depth * 18 }}>
<span className="group-name">{option.title}</span>
</div>
),
state: (
<span className={option.enabled ? 'card-state card-state-on' : 'card-state card-state-off'}>
{option.enabled ? '启用' : '已停用'}
</span>
),
limit: 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>
),
order: 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>
),
show: (
<Switch
checked={checked}
onChange={(next) => toggle(option.key, next)}
label={`卡片包含 ${option.title}`}
disabled={busy || !option.enabled}
/>
),
}
})}
emptyText={<Empty text="还没有可选的分组" />}
/>
</div>
)
}
// ---------- 页头 / 资料 / 系统(沿用旧逻辑) ----------
const TONE_SELECT = TONE_OPTIONS.map((option) => ({ value: option.value, label: option.label }))
const THEME_SELECT = [
{ value: 'auto', label: '跟随系统' },
{ value: 'light', label: '浅色' },
{ value: 'dark', label: '深色' },
]
function HeaderPanel({
value,
onChange,
site,
onChangeSite,
}: {
value: SiteSettings['header']
onChange: (header: SiteSettings['header']) => void
site: SiteSettings['site']
onChangeSite: (site: SiteSettings['site']) => void
}) {
const [tab, setTab] = useState<'site' | 'actions' | 'ticker'>('site')
return (
<Space direction="column" gap="lg" align="stretch" className="admin-form">
<Tabs
className="admin-tabs"
items={[
{ key: 'site', label: '站点信息' },
{ key: 'actions', label: '操作工具' },
{ key: 'ticker', label: '滚动信息' },
]}
activeKey={tab}
onChange={(key) => setTab(key as 'site' | 'actions' | 'ticker')}
/>
{tab === 'site' && (
<Space direction="column" gap="lg" align="stretch">
<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>
<FormField label="配色" hint="深色由组件库的令牌块接管,页面与组件都不用改">
<Select
options={THEME_SELECT}
value={site.theme}
onChange={(theme) => onChangeSite({ ...site, theme: theme as SiteTheme })}
/>
</FormField>
<Text size="sm" strong>
首页 Logo
</Text>
<div className="brand-preview">
<Brand name={site.brand.name} mark={site.brand.mark} logo={site.brand.logo} />
</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>
</Space>
)}
{tab === 'actions' && (
<Space direction="column" gap="md" align="stretch">
<Text size="sm" tone="muted">
说明:这里控制页头右侧显示哪些图标按钮;关掉的按钮不再出现,也不影响其它页面。
</Text>
<ToggleRow
label="明暗切换"
checked={site.actions.theme}
onChange={(next) =>
onChangeSite({ ...site, actions: { ...site.actions, theme: next } })
}
switchLabel="页头显示明暗切换按钮"
/>
<ToggleRow
label="刷新"
checked={site.actions.refresh}
onChange={(next) =>
onChangeSite({ ...site, actions: { ...site.actions, refresh: next } })
}
switchLabel="页头显示刷新按钮"
/>
<ToggleRow
label="站点设置"
checked={site.actions.settings}
onChange={(next) =>
onChangeSite({ ...site, actions: { ...site.actions, settings: next } })
}
switchLabel="页头显示站点设置按钮"
/>
<ToggleRow
label="登录 / 退出"
checked={site.actions.session}
onChange={(next) =>
onChangeSite({ ...site, actions: { ...site.actions, session: next } })
}
switchLabel="页头显示登录或退出按钮"
/>
</Space>
)}
{tab === 'ticker' && (
<Space direction="column" gap="md" align="stretch">
<ToggleRow
label="显示页头滚动信息"
checked={value.enabled}
onChange={(enabled) => onChange({ ...value, enabled })}
/>
<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>
<FormField
label="条目间距"
htmlFor="header-gap"
hint="单位 px;默认 26(约两个中文字宽),最大 120"
>
<Input
id="header-gap"
type="number"
value={String(value.gap ?? 26)}
onChange={(event) => {
const gap = Number(event.target.value)
if (Number.isFinite(gap)) onChange({ ...value, gap })
}}
/>
</FormField>
<Text size="sm" strong>
可用通配符
</Text>
<Text size="sm" tone="muted">
说明:下面这些占位符写在「信息文本」里,渲染时会替换成实时值;写别的花括号不会替换。
</Text>
<div className="ticker-vars">
<code>{'{count}'}</code>
<span>本地应用数量</span>
<code>{'{project}'}</code>
<span>第一个本地项目的名字(没有项目时显示 —)</span>
<code>{'{status}'}</code>
<span>实时同步状态:实时同步已连接 / 等待服务连接</span>
</div>
<Text size="sm" strong>
信息条目
</Text>
{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>
)}
</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>
)
}