- 删除 SidebarPanel 里的「侧栏卡片」标签 - SidebarPanel 不再挂 .admin-form(它带 20px margin-top), 标题到说明的距离由 32px 变成 12px,与「内容卡片」一致 - 三个开关行间距不变(Space gap=md)
2137 lines
65 KiB
TypeScript
2137 lines
65 KiB
TypeScript
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>
|
||
)
|
||
}
|