前台 - 搜索框从内容列移到 Layout 的 toolbar 插槽,去掉 fixed + TOP_OFFSET + top-blur 三处补偿 - 左右栏滚动容器不变,左栏吸顶改用 CSS sticky(修回顶时侧栏抖动) - 只让分组导航滚动,资料卡与本地服务固定 - 页头操作支持配置:搜索改为刷新(重扫项目 + 重拉内容库) - 站点级设置:页面标题 / favicon / 品牌 logo / 品牌文字 - 停用的分组在侧边栏、首页卡片、分组页、条目页统一不再出现 后台 - 「侧边栏」「首页卡片」合并为「首页」Tab - 首页卡片表格新增状态列与顺序列(上下箭头即时保存) - 分组显隐只由「分组管理 → 状态」决定,去掉侧栏独立勾选 - 页头 Tab 增加站点信息、首页 Logo(带实时预览)、操作工具开关 - 后台页头与前台同构并吸顶 后端 - settings 新增 site(标题 / favicon / 品牌 / 操作工具)与 sidebar.show_nav - 写接口成功后广播内容库变更,SSE 改为「广播 + data_version 兜底」 - catalog SSE 事件名与前端监听对齐(catalog)
2076 lines
63 KiB
TypeScript
2076 lines
63 KiB
TypeScript
import {
|
||
Button,
|
||
Card,
|
||
FormField,
|
||
Icon,
|
||
ICON_NAMES,
|
||
IconButton,
|
||
Input,
|
||
Modal,
|
||
Notice,
|
||
PasswordInput,
|
||
Select,
|
||
Space,
|
||
Switch,
|
||
Tabs,
|
||
Tag,
|
||
Text,
|
||
Textarea,
|
||
Title,
|
||
Toaster,
|
||
toast,
|
||
type IconName,
|
||
} from '@stash/ui'
|
||
import { useEffect, useMemo, useState } from 'react'
|
||
import { changePassword, fetchSession, logout, type SessionState } from '../../auth'
|
||
import { LoginDialog } from '../auth/LoginDialog'
|
||
import {
|
||
fetchSettings,
|
||
saveSettings,
|
||
TONE_OPTIONS,
|
||
type ProfileSettings,
|
||
type SettingsDocument,
|
||
type SettingsTone,
|
||
type SiteSettings,
|
||
} from '../../settings'
|
||
import {
|
||
createCard,
|
||
createNode,
|
||
deleteCard,
|
||
deleteNode,
|
||
fetchManage,
|
||
flattenGroups,
|
||
setCardGroups,
|
||
updateCard,
|
||
updateNode,
|
||
type CardItem,
|
||
type GroupEntry,
|
||
type GroupFormat,
|
||
type GroupNode,
|
||
} from '../../manage'
|
||
import { createItem, deleteItem, fetchItem, saveItem } from '../../items'
|
||
import { useProjects } from '../../useProjects'
|
||
import type { Project } from '../../api'
|
||
|
||
/**
|
||
* 新版后台:原子模型。
|
||
*
|
||
* - 分组是唯一容器(可多层、带格式);
|
||
* - 内容是条目,挂在分组上;
|
||
* - 侧边栏、首页卡片都只是「勾选分组」的展示组合。
|
||
*/
|
||
type TabKey = 'groups' | 'content' | 'home' | 'header' | 'profile' | 'system'
|
||
|
||
const FORMAT_OPTIONS = [
|
||
{ value: 'apps', label: '应用' },
|
||
{ value: 'posters', label: '海报' },
|
||
{ value: 'articles', label: '图文' },
|
||
]
|
||
|
||
const clone = (settings: SiteSettings): SiteSettings => structuredClone(settings)
|
||
|
||
function findGroup(groups: GroupNode[], key: string): GroupNode | undefined {
|
||
for (const group of groups) {
|
||
if (group.key === key) return group
|
||
const found = findGroup(group.children, key)
|
||
if (found !== undefined) return found
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
function findGroupByEntry(groups: GroupNode[], entryId: string): GroupNode | undefined {
|
||
for (const group of groups) {
|
||
if (group.entries.some((entry) => entry.id === entryId)) return group
|
||
const found = findGroupByEntry(group.children, entryId)
|
||
if (found !== undefined) return found
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
function flattenCardsGroups(groups: GroupNode[]) {
|
||
return flattenGroups(groups)
|
||
}
|
||
|
||
export function AdminNext() {
|
||
const [session, setSession] = useState<SessionState | null>(null)
|
||
const [loginOpen, setLoginOpen] = useState(false)
|
||
const [document, setDocument] = useState<SettingsDocument | null>(null)
|
||
const [draft, setDraft] = useState<SiteSettings | null>(null)
|
||
const [groups, setGroups] = useState<GroupNode[]>([])
|
||
const [cards, setCards] = useState<CardItem[]>([])
|
||
const [tab, setTab] = useState<TabKey>('groups')
|
||
const [saving, setSaving] = useState(false)
|
||
const { projects } = useProjects()
|
||
const [pending, setPending] = useState<{
|
||
kind: 'group' | 'card'
|
||
id: string
|
||
title: string
|
||
} | null>(null)
|
||
const [deleting, setDeleting] = useState(false)
|
||
|
||
const load = async () => {
|
||
const [nextSession, nextDocument, manage] = await Promise.all([
|
||
fetchSession(),
|
||
fetchSettings(),
|
||
fetchManage(),
|
||
])
|
||
setSession(nextSession)
|
||
setDocument(nextDocument)
|
||
setDraft(clone(nextDocument.settings))
|
||
setGroups(manage.groups)
|
||
setCards(manage.cards)
|
||
}
|
||
|
||
useEffect(() => {
|
||
void load()
|
||
}, [])
|
||
|
||
// 后台壳也要吃站点级设置:保存后标签标题 / favicon 立刻变,否则会以为「设了没反应」
|
||
// 注意:这里的 `document` 是设置文档,浏览器文档要用 `window.document`
|
||
useEffect(() => {
|
||
const meta = document?.settings.site
|
||
if (meta === undefined) return
|
||
const title = meta.title.trim()
|
||
if (title !== '') window.document.title = title
|
||
const href = meta.favicon.trim() === '' ? '/favicon.svg' : meta.favicon.trim()
|
||
let link = window.document.head.querySelector<HTMLLinkElement>('link[rel="icon"]')
|
||
if (link === null) {
|
||
link = window.document.createElement('link')
|
||
link.rel = 'icon'
|
||
window.document.head.appendChild(link)
|
||
}
|
||
link.href = href
|
||
}, [document?.settings.site.title, document?.settings.site.favicon])
|
||
|
||
const authenticated = session?.authenticated === true
|
||
|
||
const save = async () => {
|
||
if (document === null || draft === null) return
|
||
setSaving(true)
|
||
const result = await saveSettings(document.version, draft)
|
||
setSaving(false)
|
||
if (!result.ok) {
|
||
toast.error(result.error)
|
||
return
|
||
}
|
||
setDocument(result.document)
|
||
setDraft(clone(result.document.settings))
|
||
toast.success('已保存')
|
||
}
|
||
|
||
/** 侧边栏开关即时保存(不再单独点保存按钮) */
|
||
const saveSidebar = async (sidebar: SiteSettings['sidebar']) => {
|
||
if (document === null || draft === null) return
|
||
setSaving(true)
|
||
const result = await saveSettings(document.version, { ...draft, sidebar })
|
||
setSaving(false)
|
||
if (!result.ok) {
|
||
toast.error(result.error)
|
||
void load()
|
||
return
|
||
}
|
||
setDocument(result.document)
|
||
setDraft(clone(result.document.settings))
|
||
toast.success('侧边栏已保存')
|
||
}
|
||
|
||
const confirmDelete = async () => {
|
||
if (pending === null) return
|
||
setDeleting(true)
|
||
const result =
|
||
pending.kind === 'group'
|
||
? await deleteNode(pending.id)
|
||
: await deleteCard(Number(pending.id))
|
||
setDeleting(false)
|
||
if (!result.ok) {
|
||
toast.error(result.error)
|
||
return
|
||
}
|
||
toast.success(`已删除「${pending.title}」`)
|
||
setPending(null)
|
||
await load()
|
||
}
|
||
|
||
const brand = document?.settings.site.brand ?? { name: 'STASH', mark: 'S', logo: '' }
|
||
const brandName = brand.name.trim()
|
||
const brandMark = brand.mark.trim() || brandName.slice(0, 1) || 'S'
|
||
const brandLogo = brand.logo.trim()
|
||
|
||
return (
|
||
<div className="admin-page">
|
||
<Toaster />
|
||
{/* 与前台页头同一套结构:品牌在左、图标操作在右,整条吸顶 */}
|
||
<header className="admin-topbar">
|
||
<div className="site-header-inner">
|
||
<a className="site-brand" href="/" aria-label={`${brandName || '首页'} 首页`}>
|
||
{brandLogo !== '' ? (
|
||
<img className="site-brand-logo" src={brandLogo} alt="" />
|
||
) : (
|
||
<span className="site-brand-mark" aria-hidden="true">
|
||
{brandMark}
|
||
</span>
|
||
)}
|
||
{brandName !== '' && <span className="site-brand-name">{brandName}</span>}
|
||
</a>
|
||
|
||
<div className="header-actions">
|
||
<IconButton
|
||
label="返回首页"
|
||
icon={<Icon name="arrow_left_line" />}
|
||
onClick={() => window.location.assign('/')}
|
||
/>
|
||
{authenticated && (
|
||
<IconButton
|
||
label="退出登录"
|
||
icon={<Icon name="exit_line" />}
|
||
onClick={() => {
|
||
void logout().then(() => window.location.assign('/'))
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="admin-main">
|
||
<Card padding="lg">
|
||
<Space direction="column" gap="sm" align="stretch">
|
||
<Title level={2}>站点设置</Title>
|
||
<Text size="sm" tone="muted">
|
||
分组是唯一容器;侧边栏和首页卡片都只是勾选分组来组合展示。
|
||
</Text>
|
||
</Space>
|
||
|
||
{!authenticated && (
|
||
<div className="admin-locked">
|
||
<Text>需要登录后才能修改设置。</Text>
|
||
<Button icon={<Icon name="lock_line" />} onClick={() => setLoginOpen(true)}>
|
||
登录
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{authenticated && draft !== null && (
|
||
<>
|
||
<Tabs
|
||
className="admin-tabs"
|
||
items={[
|
||
{ key: 'groups', label: '分组管理' },
|
||
{ key: 'content', label: '内容' },
|
||
{ key: 'home', label: '首页' },
|
||
{ key: 'header', label: '页头' },
|
||
{ key: 'profile', label: '资料' },
|
||
{ key: 'system', label: '系统' },
|
||
]}
|
||
activeKey={tab}
|
||
onChange={(key) => setTab(key as TabKey)}
|
||
/>
|
||
|
||
{tab === 'groups' && (
|
||
<GroupsPanel
|
||
groups={groups}
|
||
onReload={load}
|
||
onDelete={(key, title) => setPending({ kind: 'group', id: key, title })}
|
||
/>
|
||
)}
|
||
|
||
{tab === 'content' && <ContentPanel groups={groups} onReload={load} />}
|
||
|
||
{tab === 'home' && (
|
||
<div className="admin-home">
|
||
<section className="admin-home-section">
|
||
<Text size="md" strong>
|
||
侧边栏
|
||
</Text>
|
||
<SidebarPanel
|
||
value={draft.sidebar}
|
||
saving={saving}
|
||
onToggleCard={(card, visible) => {
|
||
void saveSidebar({
|
||
...draft.sidebar,
|
||
...(card === 'profile'
|
||
? { show_profile: visible }
|
||
: card === 'service'
|
||
? { show_service: visible }
|
||
: { show_nav: visible }),
|
||
})
|
||
}}
|
||
/>
|
||
</section>
|
||
|
||
<div className="sidebar-divider" />
|
||
|
||
<section className="admin-home-section">
|
||
<Text size="md" strong>
|
||
首页卡片
|
||
</Text>
|
||
<Text size="sm" tone="muted">
|
||
卡片 = 一张首页区块 + 勾选它包含哪些分组;分组本身的启停仍由「分组管理」决定。
|
||
</Text>
|
||
<CardsPanel
|
||
groups={groups}
|
||
cards={cards}
|
||
projects={projects}
|
||
onReload={load}
|
||
onDelete={(id, title) => setPending({ kind: 'card', id: String(id), title })}
|
||
/>
|
||
</section>
|
||
</div>
|
||
)}
|
||
|
||
{tab === 'header' && (
|
||
<HeaderPanel
|
||
value={draft.header}
|
||
onChange={(header) => setDraft({ ...draft, header })}
|
||
site={draft.site}
|
||
onChangeSite={(site) => setDraft({ ...draft, site })}
|
||
/>
|
||
)}
|
||
|
||
{tab === 'profile' && (
|
||
<ProfilePanel
|
||
value={draft.profile}
|
||
onChange={(profile) => setDraft({ ...draft, profile })}
|
||
/>
|
||
)}
|
||
|
||
{tab === 'system' && <SystemPanel />}
|
||
|
||
{(tab === 'header' || tab === 'profile') && (
|
||
<Space gap="sm" justify="end" className="admin-actions">
|
||
<Button
|
||
variant="secondary"
|
||
onClick={() => {
|
||
setDraft(clone(draft))
|
||
void load()
|
||
}}
|
||
>
|
||
撤销改动
|
||
</Button>
|
||
<Button loading={saving} icon={<Icon name="check_line" />} onClick={save}>
|
||
保存
|
||
</Button>
|
||
</Space>
|
||
)}
|
||
</>
|
||
)}
|
||
</Card>
|
||
</main>
|
||
|
||
<LoginDialog
|
||
open={loginOpen}
|
||
hasAdmin={session?.has_admin ?? true}
|
||
onClose={() => setLoginOpen(false)}
|
||
onSuccess={(next) => {
|
||
setSession(next)
|
||
setLoginOpen(false)
|
||
void load()
|
||
}}
|
||
/>
|
||
|
||
<Modal
|
||
open={pending !== null}
|
||
onClose={() => {
|
||
if (!deleting) setPending(null)
|
||
}}
|
||
title={pending === null ? '删除' : `删除「${pending.title}」?`}
|
||
size="sm"
|
||
footer={
|
||
<Space gap="sm" justify="end">
|
||
<Button variant="secondary" disabled={deleting} onClick={() => setPending(null)}>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
variant="danger"
|
||
loading={deleting}
|
||
icon={<Icon name="delete_2_line" />}
|
||
onClick={() => void confirmDelete()}
|
||
>
|
||
删除
|
||
</Button>
|
||
</Space>
|
||
}
|
||
>
|
||
<Text>
|
||
{pending?.kind === 'group'
|
||
? '删除分组会连同子分组与其中的条目一起删除,且不可恢复。'
|
||
: '卡片只是展示组合,删除不会影响内容。'}
|
||
</Text>
|
||
</Modal>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---------- 分组管理 ----------
|
||
|
||
function countGroupEntries(group: GroupNode): number {
|
||
return group.entries.length + group.children.reduce((total, child) => total + countGroupEntries(child), 0)
|
||
}
|
||
|
||
function flattenWithDepth(
|
||
groups: GroupNode[],
|
||
depth = 0,
|
||
): { node: GroupNode; depth: number }[] {
|
||
return groups.flatMap((node) => [
|
||
{ node, depth },
|
||
...flattenWithDepth(node.children, depth + 1),
|
||
])
|
||
}
|
||
|
||
function descendantKeys(group: GroupNode): Set<string> {
|
||
const keys = new Set<string>([group.key])
|
||
const walk = (nodes: GroupNode[]) => {
|
||
for (const node of nodes) {
|
||
keys.add(node.key)
|
||
walk(node.children)
|
||
}
|
||
}
|
||
walk(group.children)
|
||
return keys
|
||
}
|
||
|
||
const FORMAT_LABEL: Record<GroupFormat, string> = {
|
||
apps: '应用',
|
||
posters: '海报',
|
||
articles: '图文',
|
||
}
|
||
|
||
function GroupMark({ icon }: { icon: string }) {
|
||
if (icon.trim() === '') {
|
||
return <span className="group-mark group-mark-empty" aria-hidden="true" />
|
||
}
|
||
if (ICON_NAMES.includes(icon as IconName)) {
|
||
return (
|
||
<span className="group-mark" aria-hidden="true">
|
||
<Icon name={icon as IconName} />
|
||
</span>
|
||
)
|
||
}
|
||
return (
|
||
<span className="group-mark group-mark-text" aria-hidden="true">
|
||
{icon}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
type GroupDialogState =
|
||
| { mode: 'root' }
|
||
| { mode: 'child'; parent: GroupNode }
|
||
| { mode: 'edit'; group: GroupNode }
|
||
| null
|
||
|
||
function GroupsPanel({
|
||
groups,
|
||
onReload,
|
||
onDelete,
|
||
}: {
|
||
groups: GroupNode[]
|
||
onReload: () => void
|
||
onDelete: (key: string, title: string) => void
|
||
}) {
|
||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
|
||
const [dialog, setDialog] = useState<GroupDialogState>(null)
|
||
|
||
const rows = useMemo(() => {
|
||
const result: { node: GroupNode; level: number }[] = []
|
||
const walk = (nodes: GroupNode[], level: number) => {
|
||
for (const node of nodes) {
|
||
result.push({ node, level })
|
||
if (!collapsed.has(node.key)) walk(node.children, level + 1)
|
||
}
|
||
}
|
||
walk(groups, 0)
|
||
return result
|
||
}, [groups, collapsed])
|
||
|
||
const toggle = (key: string) => {
|
||
setCollapsed((current) => {
|
||
const next = new Set(current)
|
||
if (next.has(key)) next.delete(key)
|
||
else next.add(key)
|
||
return next
|
||
})
|
||
}
|
||
|
||
const toggleEnabled = async (group: GroupNode, enabled: boolean) => {
|
||
const result = await updateNode(group.key, { enabled })
|
||
if (!result.ok) {
|
||
toast.error(result.error)
|
||
return
|
||
}
|
||
toast.success(enabled ? '分组已启用' : '分组已禁用')
|
||
onReload()
|
||
}
|
||
|
||
return (
|
||
<Space direction="column" gap="md" align="stretch" className="admin-form">
|
||
<Text size="sm" tone="muted">
|
||
分组用于控制前台侧边栏与卡片;可多层嵌套,根分组决定格式。内容(条目)只挂在分组上。
|
||
</Text>
|
||
|
||
<div className="group-toolbar">
|
||
<Button
|
||
icon={<Icon name="add_line" />}
|
||
onClick={() => setDialog({ mode: 'root' })}
|
||
>
|
||
新建根分组
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="group-table-wrap">
|
||
<table className="group-table">
|
||
<thead>
|
||
<tr>
|
||
<th>分组名称</th>
|
||
<th className="group-col-sort">排序权重</th>
|
||
<th className="group-col-count">关联网页数</th>
|
||
<th className="group-col-status">状态</th>
|
||
<th className="group-col-actions">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map(({ node, level }) => {
|
||
const hasChildren = node.children.length > 0
|
||
const open = !collapsed.has(node.key)
|
||
return (
|
||
<tr key={node.key}>
|
||
<td>
|
||
<div className="group-name-cell" style={{ paddingLeft: level * 20 }}>
|
||
{hasChildren ? (
|
||
<button
|
||
type="button"
|
||
className="group-caret"
|
||
aria-label={open ? `折叠 ${node.title}` : `展开 ${node.title}`}
|
||
onClick={() => toggle(node.key)}
|
||
>
|
||
{open ? '▾' : '▸'}
|
||
</button>
|
||
) : (
|
||
<span className="group-caret group-caret-empty" aria-hidden="true" />
|
||
)}
|
||
<GroupMark icon={node.icon} />
|
||
<span className="group-name">{node.title}</span>
|
||
</div>
|
||
</td>
|
||
<td className="group-sort">{node.sort}</td>
|
||
<td className="group-count">{countGroupEntries(node)}</td>
|
||
<td>
|
||
<Switch
|
||
checked={node.enabled !== false}
|
||
onChange={(next) => void toggleEnabled(node, next)}
|
||
label={`启用 ${node.title}`}
|
||
size="sm"
|
||
/>
|
||
</td>
|
||
<td>
|
||
<div className="group-actions">
|
||
<button
|
||
type="button"
|
||
className="group-action"
|
||
onClick={() => setDialog({ mode: 'child', parent: node })}
|
||
>
|
||
加子分组
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="group-action"
|
||
onClick={() => setDialog({ mode: 'edit', group: node })}
|
||
>
|
||
编辑
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="group-action group-action-danger"
|
||
onClick={() => onDelete(node.key, node.title)}
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
|
||
{rows.length === 0 && (
|
||
<tr>
|
||
<td colSpan={5} className="group-empty">
|
||
暂无分组数据,点击左侧「新建根分组」
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<GroupDialog
|
||
state={dialog}
|
||
groups={groups}
|
||
onClose={() => setDialog(null)}
|
||
onSaved={() => {
|
||
setDialog(null)
|
||
onReload()
|
||
}}
|
||
/>
|
||
</Space>
|
||
)
|
||
}
|
||
|
||
function GroupDialog({
|
||
state,
|
||
groups,
|
||
onClose,
|
||
onSaved,
|
||
}: {
|
||
state: GroupDialogState
|
||
groups: GroupNode[]
|
||
onClose: () => void
|
||
onSaved: () => void
|
||
}) {
|
||
const open = state !== null
|
||
const editing = state?.mode === 'edit' ? state.group : undefined
|
||
const isRootEdit = editing !== undefined && editing.parentKey === null
|
||
|
||
const [key, setKey] = useState('')
|
||
const [title, setTitle] = useState('')
|
||
const [parentKey, setParentKey] = useState('')
|
||
const [icon, setIcon] = useState('')
|
||
const [sort, setSort] = useState('')
|
||
const [enabled, setEnabled] = useState(true)
|
||
const [busy, setBusy] = useState(false)
|
||
const [error, setError] = useState('')
|
||
|
||
useEffect(() => {
|
||
if (!open) return
|
||
setBusy(false)
|
||
setError('')
|
||
|
||
if (state?.mode === 'edit') {
|
||
setKey(state.group.key)
|
||
setTitle(state.group.title)
|
||
setParentKey(state.group.parentKey ?? '')
|
||
setIcon(state.group.icon)
|
||
setSort(String(state.group.sort))
|
||
setEnabled(state.group.enabled)
|
||
return
|
||
}
|
||
|
||
setKey('')
|
||
setTitle('')
|
||
setIcon('')
|
||
setSort('')
|
||
setEnabled(true)
|
||
setParentKey(state?.mode === 'child' ? state.parent.key : '')
|
||
}, [open, state])
|
||
|
||
const excluded = editing !== undefined ? descendantKeys(editing) : new Set<string>()
|
||
const parentOptions = flattenWithDepth(groups)
|
||
.filter((option) => !excluded.has(option.node.key))
|
||
.map((option) => ({
|
||
value: option.node.key,
|
||
label: `${' '.repeat(option.depth)}${option.node.title}`,
|
||
}))
|
||
|
||
const rootMode = parentKey === '' || isRootEdit
|
||
const showKey = editing === undefined
|
||
const submit = async () => {
|
||
if (title.trim() === '') {
|
||
setError('分组名称不能为空')
|
||
return
|
||
}
|
||
if (showKey && key.trim() === '') {
|
||
setError('分组标识不能为空')
|
||
return
|
||
}
|
||
|
||
setBusy(true)
|
||
setError('')
|
||
|
||
if (editing !== undefined) {
|
||
const result = await updateNode(editing.key, {
|
||
title: title.trim(),
|
||
icon,
|
||
sort: sort.trim() === '' ? undefined : Number(sort),
|
||
enabled,
|
||
...(isRootEdit ? {} : { parentKey }),
|
||
})
|
||
setBusy(false)
|
||
if (!result.ok) {
|
||
setError(result.error)
|
||
return
|
||
}
|
||
toast.success('分组已保存')
|
||
onSaved()
|
||
return
|
||
}
|
||
|
||
const result = await createNode({
|
||
key: key.trim(),
|
||
title: title.trim(),
|
||
parentKey: rootMode ? undefined : parentKey,
|
||
icon,
|
||
sort: sort.trim() === '' ? undefined : Number(sort),
|
||
enabled,
|
||
})
|
||
setBusy(false)
|
||
if (!result.ok) {
|
||
setError(result.error)
|
||
return
|
||
}
|
||
toast.success(rootMode ? '根分组已创建' : '子分组已创建')
|
||
onSaved()
|
||
}
|
||
|
||
return (
|
||
<Modal
|
||
open={open}
|
||
onClose={busy ? () => undefined : onClose}
|
||
title={
|
||
state?.mode === 'edit'
|
||
? '编辑分组'
|
||
: state?.mode === 'child'
|
||
? '新增子分组'
|
||
: '新建根分组'
|
||
}
|
||
size="md"
|
||
footer={
|
||
<Space gap="sm" justify="end">
|
||
<Button variant="secondary" onClick={onClose}>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
loading={busy}
|
||
icon={<Icon name="check_line" />}
|
||
onClick={() => void submit()}
|
||
>
|
||
保存
|
||
</Button>
|
||
</Space>
|
||
}
|
||
>
|
||
<Space direction="column" gap="md" align="stretch">
|
||
{error !== '' && (
|
||
<Notice tone="error" title="保存失败">
|
||
{error}
|
||
</Notice>
|
||
)}
|
||
|
||
{showKey && (
|
||
<FormField label="分组标识" htmlFor="group-key" hint="小写字母、数字与连字符,创建后不可改">
|
||
<Input
|
||
id="group-key"
|
||
value={key}
|
||
placeholder="如 frontend"
|
||
onChange={(event) => setKey(event.target.value)}
|
||
/>
|
||
</FormField>
|
||
)}
|
||
|
||
<FormField label="分组名称" htmlFor="group-title" required>
|
||
<Input
|
||
id="group-title"
|
||
value={title}
|
||
placeholder="如 前端开发、常用工具"
|
||
onChange={(event) => setTitle(event.target.value)}
|
||
/>
|
||
</FormField>
|
||
|
||
{(!isRootEdit) && (
|
||
<FormField
|
||
label="上级分组"
|
||
htmlFor="group-parent"
|
||
hint={editing === undefined ? '选择「根目录」则创建一级分组' : undefined}
|
||
>
|
||
<Select
|
||
options={[
|
||
...(editing === undefined
|
||
? [{ value: '', label: 'ROOT 根目录(一级分组)' }]
|
||
: []),
|
||
...parentOptions,
|
||
]}
|
||
value={parentKey}
|
||
onChange={setParentKey}
|
||
/>
|
||
</FormField>
|
||
)}
|
||
|
||
<FormField label="图标样式" htmlFor="group-icon" hint="留空则不显示图标;可填 iconfont 图标名或一个字符">
|
||
<div className="group-icon-field">
|
||
<span className="group-icon-preview">
|
||
<GroupMark icon={icon} />
|
||
</span>
|
||
<Input
|
||
id="group-icon"
|
||
value={icon}
|
||
placeholder="如 folder_line / 📁,留空不显示"
|
||
onChange={(event) => setIcon(event.target.value)}
|
||
/>
|
||
</div>
|
||
</FormField>
|
||
|
||
<div className="group-dialog-row">
|
||
<FormField label="排序权重" htmlFor="group-sort" hint="越小越靠前">
|
||
<Input
|
||
id="group-sort"
|
||
type="number"
|
||
value={sort}
|
||
placeholder="自动"
|
||
onChange={(event) => setSort(event.target.value)}
|
||
/>
|
||
</FormField>
|
||
<FormField label="状态" htmlFor="group-enabled">
|
||
<Select
|
||
options={[
|
||
{ value: '1', label: '启用' },
|
||
{ value: '0', label: '禁用' },
|
||
]}
|
||
value={enabled ? '1' : '0'}
|
||
onChange={(next) => setEnabled(next === '1')}
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
</Space>
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
|
||
// ---------- 内容 ----------
|
||
|
||
type EntryWithGroup = { entry: GroupEntry; group: GroupNode }
|
||
|
||
/** 选中某个分组时,把它子树里的条目全部取出来(含子分组) */
|
||
function collectGroupEntries(group: GroupNode): EntryWithGroup[] {
|
||
return [
|
||
...group.entries.map((entry) => ({ entry, group })),
|
||
...group.children.flatMap((child) => collectGroupEntries(child)),
|
||
]
|
||
}
|
||
|
||
function EntryLink({ href }: { href?: string }) {
|
||
const value = (href ?? '').trim()
|
||
if (value === '' || value === '#') {
|
||
return <span className="entry-href entry-href-empty">—</span>
|
||
}
|
||
if (/^https?:\/\//i.test(value) || value.startsWith('/')) {
|
||
return (
|
||
<a className="entry-href" href={value} target="_blank" rel="noreferrer" title={value}>
|
||
{value}
|
||
</a>
|
||
)
|
||
}
|
||
return <span className="entry-href">{value}</span>
|
||
}
|
||
|
||
function ContentPanel({ groups, onReload }: { groups: GroupNode[]; onReload: () => void }) {
|
||
const options = useMemo(() => flattenCardsGroups(groups), [groups])
|
||
const [groupKey, setGroupKey] = useState(options[0]?.key ?? '')
|
||
const [dialog, setDialog] = useState<{ key: string | null; groupKey: string } | null>(null)
|
||
|
||
useEffect(() => {
|
||
if (options.length > 0 && !options.some((option) => option.key === groupKey)) {
|
||
setGroupKey(options[0].key)
|
||
}
|
||
}, [options, groupKey])
|
||
|
||
const group = findGroup(groups, groupKey)
|
||
const rows = group === undefined ? [] : collectGroupEntries(group)
|
||
|
||
return (
|
||
<Space direction="column" gap="md" align="stretch" className="admin-form">
|
||
<Text size="sm" tone="muted">
|
||
条目只属于一个分组;选中主分组时会连同其所有子分组的条目一起列出。
|
||
</Text>
|
||
|
||
<Space gap="sm" wrap>
|
||
<div style={{ width: 260 }}>
|
||
<Select
|
||
options={options.map((option) => ({
|
||
value: option.key,
|
||
label: option.title,
|
||
}))}
|
||
value={group?.key ?? ''}
|
||
onChange={setGroupKey}
|
||
/>
|
||
</div>
|
||
<Button
|
||
icon={<Icon name="add_line" />}
|
||
disabled={group === undefined}
|
||
onClick={() => setDialog({ key: null, groupKey })}
|
||
>
|
||
新增条目
|
||
</Button>
|
||
</Space>
|
||
|
||
<Text size="sm" tone="muted">
|
||
{group?.title ?? ''} 下共 {rows.length} 条(含子分组)
|
||
</Text>
|
||
|
||
<div className="group-table-wrap">
|
||
<table className="group-table entry-table">
|
||
<thead>
|
||
<tr>
|
||
<th>标题</th>
|
||
<th className="entry-col-format">格式</th>
|
||
<th className="entry-col-href">跳转链接</th>
|
||
<th className="entry-col-group">所属分组</th>
|
||
<th className="group-col-actions">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map(({ entry, group: owner }) => (
|
||
<tr key={entry.id}>
|
||
<td>
|
||
<span className="entry-title">{entry.name ?? entry.title ?? entry.id}</span>
|
||
</td>
|
||
<td>
|
||
<span className="entry-format">
|
||
{FORMAT_LABEL[(entry.format ?? 'apps') as GroupFormat]}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<EntryLink href={entry.href} />
|
||
</td>
|
||
<td>
|
||
<span className="entry-group">{owner.title}</span>
|
||
</td>
|
||
<td>
|
||
<div className="group-actions">
|
||
<button
|
||
type="button"
|
||
className="group-action"
|
||
onClick={() => setDialog({ key: entry.id, groupKey: owner.key })}
|
||
>
|
||
编辑
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="group-action group-action-danger"
|
||
onClick={async () => {
|
||
const result = await deleteItem(entry.id)
|
||
if (!result.ok) {
|
||
toast.error(result.error)
|
||
return
|
||
}
|
||
toast.success('条目已删除')
|
||
onReload()
|
||
}}
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
|
||
{rows.length === 0 && (
|
||
<tr>
|
||
<td colSpan={5} className="group-empty">
|
||
该分组下还没有内容
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<EntryDialog
|
||
open={dialog !== null}
|
||
entryKey={dialog?.key ?? null}
|
||
groups={groups}
|
||
defaultGroupKey={dialog?.groupKey ?? groupKey}
|
||
onClose={() => setDialog(null)}
|
||
onSaved={() => {
|
||
setDialog(null)
|
||
onReload()
|
||
}}
|
||
/>
|
||
</Space>
|
||
)
|
||
}
|
||
|
||
function EntryDialog({
|
||
open,
|
||
entryKey,
|
||
groups,
|
||
defaultGroupKey,
|
||
onClose,
|
||
onSaved,
|
||
}: {
|
||
open: boolean
|
||
entryKey: string | null
|
||
groups: GroupNode[]
|
||
defaultGroupKey: string
|
||
onClose: () => void
|
||
onSaved: () => void
|
||
}) {
|
||
const options = useMemo(() => flattenCardsGroups(groups), [groups])
|
||
const [groupKey, setGroupKey] = useState(defaultGroupKey)
|
||
const [updatedAt, setUpdatedAt] = useState(0)
|
||
const [loading, setLoading] = useState(false)
|
||
const [busy, setBusy] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const [form, setForm] = useState({
|
||
key: '',
|
||
format: 'apps' as GroupFormat,
|
||
name: '',
|
||
href: '',
|
||
icon: '',
|
||
description: '',
|
||
cover: '',
|
||
tags: '',
|
||
comment: '',
|
||
body: '',
|
||
})
|
||
|
||
useEffect(() => {
|
||
if (!open) return
|
||
setError('')
|
||
setGroupKey(
|
||
entryKey === null ? defaultGroupKey : (findGroupByEntry(groups, entryKey)?.key ?? defaultGroupKey),
|
||
)
|
||
|
||
if (entryKey === null) {
|
||
setLoading(false)
|
||
setUpdatedAt(0)
|
||
setForm({
|
||
key: '',
|
||
format: 'apps',
|
||
name: '',
|
||
href: '',
|
||
icon: '',
|
||
description: '',
|
||
cover: '',
|
||
tags: '',
|
||
comment: '',
|
||
body: '',
|
||
})
|
||
return
|
||
}
|
||
|
||
setLoading(true)
|
||
fetchItem(entryKey).then((result) => {
|
||
setLoading(false)
|
||
if (!result.ok) {
|
||
setError(result.error)
|
||
return
|
||
}
|
||
setUpdatedAt(result.entry.updated_at)
|
||
setForm({
|
||
key: result.entry.key,
|
||
format: result.entry.format,
|
||
name: result.entry.name,
|
||
href: result.entry.href,
|
||
icon: result.entry.icon,
|
||
description: result.entry.description,
|
||
cover: result.entry.cover,
|
||
tags: result.entry.tags.join(','),
|
||
comment: result.entry.comment,
|
||
body: result.entry.body,
|
||
})
|
||
})
|
||
}, [open, entryKey])
|
||
|
||
const update = (patch: Partial<typeof form>) =>
|
||
setForm((current) => ({ ...current, ...patch }))
|
||
|
||
const submit = async () => {
|
||
if (form.name.trim() === '') {
|
||
setError('标题不能为空')
|
||
return
|
||
}
|
||
if (entryKey === null && form.key.trim() === '') {
|
||
setError('标识不能为空')
|
||
return
|
||
}
|
||
|
||
setBusy(true)
|
||
setError('')
|
||
const item = {
|
||
format: form.format,
|
||
name: form.name.trim(),
|
||
description: form.description,
|
||
icon: form.icon,
|
||
tags: form.tags
|
||
.split(/[,,]/)
|
||
.map((tag) => tag.trim())
|
||
.filter(Boolean),
|
||
badge: '',
|
||
comment: form.comment,
|
||
summary: '',
|
||
body: form.body,
|
||
cover: form.cover,
|
||
banner: '',
|
||
href: form.href,
|
||
nodeKey: groupKey,
|
||
}
|
||
|
||
const result =
|
||
entryKey === null
|
||
? await createItem({ key: form.key.trim(), nodeKey: groupKey, item })
|
||
: await saveItem(entryKey, updatedAt, item)
|
||
setBusy(false)
|
||
if (!result.ok) {
|
||
setError(result.error)
|
||
return
|
||
}
|
||
toast.success(entryKey === null ? '条目已添加' : '条目已保存')
|
||
onSaved()
|
||
}
|
||
|
||
return (
|
||
<Modal
|
||
open={open}
|
||
onClose={busy ? () => undefined : onClose}
|
||
title={entryKey === null ? '新增条目' : '编辑条目'}
|
||
size="lg"
|
||
footer={
|
||
<Space gap="sm" justify="end">
|
||
<Button variant="secondary" onClick={onClose}>
|
||
取消
|
||
</Button>
|
||
<Button loading={busy} disabled={loading} icon={<Icon name="check_line" />} onClick={() => void submit()}>
|
||
保存
|
||
</Button>
|
||
</Space>
|
||
}
|
||
>
|
||
{loading ? (
|
||
<Text>正在读取…</Text>
|
||
) : (
|
||
<Space direction="column" gap="md" align="stretch">
|
||
{error !== '' && (
|
||
<Notice tone="error" title="操作失败">
|
||
{error}
|
||
</Notice>
|
||
)}
|
||
<div style={{ width: 280 }}>
|
||
<Select
|
||
options={options.map((option) => ({
|
||
value: option.key,
|
||
label: option.title,
|
||
}))}
|
||
value={groupKey}
|
||
onChange={setGroupKey}
|
||
/>
|
||
</div>
|
||
|
||
<FormField label="格式" htmlFor="entry-format" hint="每条内容单独选择格式,同一个分组下可以混合">
|
||
<Select
|
||
options={FORMAT_OPTIONS}
|
||
value={form.format}
|
||
onChange={(next) => update({ format: next as GroupFormat })}
|
||
/>
|
||
</FormField>
|
||
|
||
<FormField
|
||
label="标识"
|
||
htmlFor="entry-key"
|
||
hint={entryKey === null ? '小写字母、数字与连字符,创建后不可改' : '标识创建后不可修改'}
|
||
>
|
||
<Input
|
||
id="entry-key"
|
||
value={form.key}
|
||
disabled={entryKey !== null}
|
||
placeholder="如 my-site"
|
||
onChange={(event) => update({ key: event.target.value })}
|
||
/>
|
||
</FormField>
|
||
|
||
<div className="admin-content-form">
|
||
<Input
|
||
value={form.name}
|
||
placeholder="标题"
|
||
aria-label="条目标题"
|
||
onChange={(event) => update({ name: event.target.value })}
|
||
/>
|
||
<Input
|
||
value={form.href}
|
||
placeholder="跳转地址"
|
||
aria-label="跳转地址"
|
||
onChange={(event) => update({ href: event.target.value })}
|
||
/>
|
||
<Input
|
||
value={form.icon}
|
||
placeholder="字符图标"
|
||
aria-label="图标"
|
||
onChange={(event) => update({ icon: event.target.value })}
|
||
/>
|
||
<Input
|
||
value={form.description}
|
||
placeholder="简介"
|
||
aria-label="简介"
|
||
onChange={(event) => update({ description: event.target.value })}
|
||
/>
|
||
<Input
|
||
value={form.cover}
|
||
placeholder="封面地址"
|
||
aria-label="封面地址"
|
||
onChange={(event) => update({ cover: event.target.value })}
|
||
/>
|
||
<Input
|
||
value={form.tags}
|
||
placeholder="标签,逗号分隔"
|
||
aria-label="标签"
|
||
onChange={(event) => update({ tags: event.target.value })}
|
||
/>
|
||
<Input
|
||
value={form.comment}
|
||
placeholder="一句话评价"
|
||
aria-label="一句话评价"
|
||
onChange={(event) => update({ comment: event.target.value })}
|
||
/>
|
||
<div className="admin-col-span">
|
||
<Textarea
|
||
rows={4}
|
||
value={form.body}
|
||
placeholder="正文(Markdown)"
|
||
aria-label="正文"
|
||
onChange={(event) => update({ body: event.target.value })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Space>
|
||
)}
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
// ---------- 侧边栏(勾选分组树) ----------
|
||
|
||
function SidebarPanel({
|
||
value,
|
||
onToggleCard,
|
||
saving,
|
||
}: {
|
||
value: SiteSettings['sidebar']
|
||
onToggleCard: (card: 'profile' | 'service' | 'nav', visible: boolean) => void
|
||
saving: boolean
|
||
}) {
|
||
return (
|
||
<Space direction="column" gap="md" align="stretch" className="admin-form">
|
||
<Text size="sm" tone="muted">
|
||
分组在侧边栏的显隐只由「分组管理 → 状态」决定;这里只管三块侧栏卡片。
|
||
</Text>
|
||
|
||
<Text size="sm" strong>
|
||
侧栏卡片
|
||
</Text>
|
||
<div className="sidebar-toggle-row">
|
||
<Text className="admin-item-title">本地管理员</Text>
|
||
<Switch
|
||
checked={value.show_profile !== false}
|
||
onChange={(visible) => onToggleCard('profile', visible)}
|
||
label="显示本地管理员卡片"
|
||
disabled={saving}
|
||
/>
|
||
</div>
|
||
<div className="sidebar-toggle-row">
|
||
<Text className="admin-item-title">本地服务</Text>
|
||
<Switch
|
||
checked={value.show_service !== false}
|
||
onChange={(visible) => onToggleCard('service', visible)}
|
||
label="显示本地服务卡片"
|
||
disabled={saving}
|
||
/>
|
||
</div>
|
||
<div className="sidebar-toggle-row">
|
||
<Text className="admin-item-title">分组导航</Text>
|
||
<Switch
|
||
checked={value.show_nav !== false}
|
||
onChange={(visible) => onToggleCard('nav', visible)}
|
||
label="显示分组导航卡片"
|
||
disabled={saving}
|
||
/>
|
||
</div>
|
||
</Space>
|
||
)
|
||
}
|
||
|
||
// ---------- 首页卡片(勾选分组) ----------
|
||
|
||
function CardsPanel({
|
||
groups,
|
||
cards,
|
||
projects,
|
||
onReload,
|
||
onDelete,
|
||
}: {
|
||
groups: GroupNode[]
|
||
cards: CardItem[]
|
||
projects: Project[]
|
||
onReload: () => void
|
||
onDelete: (id: number, title: string) => void
|
||
}) {
|
||
const options = useMemo(() => flattenCardsGroups(groups), [groups])
|
||
const [activeId, setActiveId] = useState<number | null>(cards[0]?.id ?? null)
|
||
const [pendingId, setPendingId] = useState<number | null>(null)
|
||
const [newOpen, setNewOpen] = useState(false)
|
||
const [newTitle, setNewTitle] = useState('')
|
||
const [newBusy, setNewBusy] = useState(false)
|
||
const [dragId, setDragId] = useState<number | null>(null)
|
||
const [overId, setOverId] = useState<number | null>(null)
|
||
const [reordering, setReordering] = useState(false)
|
||
|
||
useEffect(() => {
|
||
if (cards.length === 0) {
|
||
setActiveId(null)
|
||
setPendingId(null)
|
||
return
|
||
}
|
||
if (pendingId !== null && cards.some((card) => card.id === pendingId)) {
|
||
setActiveId(pendingId)
|
||
setPendingId(null)
|
||
return
|
||
}
|
||
if (activeId === null || !cards.some((card) => card.id === activeId)) {
|
||
setActiveId(cards[0].id)
|
||
}
|
||
}, [cards, activeId, pendingId])
|
||
|
||
const active = cards.find((card) => card.id === activeId) ?? cards[0]
|
||
|
||
const create = async () => {
|
||
setNewBusy(true)
|
||
const result = await createCard(newTitle.trim())
|
||
setNewBusy(false)
|
||
if (!result.ok) {
|
||
toast.error(result.error)
|
||
return
|
||
}
|
||
setNewTitle('')
|
||
setNewOpen(false)
|
||
setPendingId(result.id)
|
||
toast.success('卡片已创建')
|
||
onReload()
|
||
}
|
||
|
||
const reorder = async (targetId: number) => {
|
||
const from = dragId
|
||
setDragId(null)
|
||
setOverId(null)
|
||
if (from === null || from === targetId) return
|
||
|
||
const original = cards.map((card) => card.id)
|
||
const fromIndex = original.indexOf(from)
|
||
const targetIndex = original.indexOf(targetId)
|
||
if (fromIndex < 0 || targetIndex < 0) return
|
||
|
||
const ids = original.filter((id) => id !== from)
|
||
const targetAfter = ids.indexOf(targetId)
|
||
// 向下拖时插到目标之后,保证能拖到列表最后一项;向上拖时插到目标之前
|
||
const insertAt = fromIndex < targetIndex ? targetAfter + 1 : targetAfter
|
||
ids.splice(insertAt, 0, from)
|
||
|
||
setReordering(true)
|
||
const results = await Promise.all(ids.map((id, index) => updateCard(id, { sort: index })))
|
||
setReordering(false)
|
||
if (results.some((result) => !result.ok)) {
|
||
toast.error('排序保存失败')
|
||
onReload()
|
||
return
|
||
}
|
||
toast.success('顺序已保存')
|
||
onReload()
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="cards-manager">
|
||
<aside className="cards-side">
|
||
<Text size="sm" tone="muted">
|
||
卡片
|
||
</Text>
|
||
<div className="cards-side-new">
|
||
<Button
|
||
size="sm"
|
||
icon={<Icon name="add_line" />}
|
||
onClick={() => {
|
||
setNewTitle('')
|
||
setNewOpen(true)
|
||
}}
|
||
>
|
||
新建卡片
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="cards-side-list">
|
||
{cards.map((card) => (
|
||
<button
|
||
key={card.id}
|
||
type="button"
|
||
draggable={!reordering}
|
||
className={[
|
||
'cards-side-item',
|
||
active?.id === card.id ? 'is-active' : '',
|
||
dragId === card.id ? 'is-dragging' : '',
|
||
overId === card.id && dragId !== null && dragId !== card.id ? 'is-over' : '',
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ')}
|
||
onClick={() => setActiveId(card.id)}
|
||
onDragStart={(event) => {
|
||
setDragId(card.id)
|
||
event.dataTransfer.effectAllowed = 'move'
|
||
event.dataTransfer.setData('text/plain', String(card.id))
|
||
}}
|
||
onDragOver={(event) => {
|
||
event.preventDefault()
|
||
if (overId !== card.id) setOverId(card.id)
|
||
}}
|
||
onDrop={(event) => {
|
||
event.preventDefault()
|
||
void reorder(card.id)
|
||
}}
|
||
onDragEnd={() => {
|
||
setDragId(null)
|
||
setOverId(null)
|
||
}}
|
||
>
|
||
<span className="cards-drag-handle" aria-hidden="true">
|
||
⋮⋮
|
||
</span>
|
||
<span className="cards-side-title">{card.title}</span>
|
||
<span className="cards-side-count">
|
||
{card.key === 'local-service' ? projects.length : card.groups.length}
|
||
</span>
|
||
</button>
|
||
))}
|
||
{cards.length === 0 && (
|
||
<Text size="sm" tone="muted">
|
||
还没有卡片,先新建一张。
|
||
</Text>
|
||
)}
|
||
</div>
|
||
|
||
{cards.length > 1 && (
|
||
<Text size="sm" tone="muted" className="cards-drag-hint">
|
||
拖动卡片可调整首页顺序
|
||
</Text>
|
||
)}
|
||
</aside>
|
||
|
||
<div className="cards-main">
|
||
{active === undefined ? (
|
||
<Text tone="muted">新建一张卡片开始配置。</Text>
|
||
) : active.key === 'local-service' ? (
|
||
<LocalServiceTable projects={projects} />
|
||
) : (
|
||
<CardTable
|
||
key={active.id}
|
||
card={active}
|
||
options={options}
|
||
onReload={onReload}
|
||
onDelete={onDelete}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<Modal
|
||
open={newOpen}
|
||
onClose={() => {
|
||
if (!newBusy) setNewOpen(false)
|
||
}}
|
||
title="新建卡片"
|
||
size="sm"
|
||
footer={
|
||
<Space gap="sm" justify="end">
|
||
<Button variant="secondary" onClick={() => setNewOpen(false)}>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
loading={newBusy}
|
||
disabled={newTitle.trim() === ''}
|
||
icon={<Icon name="check_line" />}
|
||
onClick={() => void create()}
|
||
>
|
||
确认新建
|
||
</Button>
|
||
</Space>
|
||
}
|
||
>
|
||
<FormField label="卡片标题" htmlFor="new-card-title">
|
||
<Input
|
||
id="new-card-title"
|
||
value={newTitle}
|
||
placeholder="如 常用推荐"
|
||
onChange={(event) => setNewTitle(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter' && newTitle.trim() !== '') void create()
|
||
}}
|
||
/>
|
||
</FormField>
|
||
</Modal>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function LocalServiceTable({ projects }: { projects: Project[] }) {
|
||
return (
|
||
<div>
|
||
<div className="cards-table-head">
|
||
<div className="admin-node-title">
|
||
<Input value="本地服务" disabled aria-label="本地服务卡片" />
|
||
</div>
|
||
<Tag tone="neutral">不可删除</Tag>
|
||
</div>
|
||
|
||
<Text size="sm" tone="muted">
|
||
本地服务卡片由托管目录自动生成,随项目扫描更新;后台只能查看,不能删除或改名。
|
||
</Text>
|
||
|
||
<div className="group-table-wrap cards-table-wrap">
|
||
<table className="group-table cards-table">
|
||
<thead>
|
||
<tr>
|
||
<th>项目名称</th>
|
||
<th className="entry-col-href">访问地址</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{projects.map((project) => (
|
||
<tr key={project.name}>
|
||
<td>
|
||
<span className="group-name">{project.name}</span>
|
||
</td>
|
||
<td>
|
||
<a
|
||
className="entry-href"
|
||
href={project.route_path}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>
|
||
{project.route_path}
|
||
</a>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{projects.length === 0 && (
|
||
<tr>
|
||
<td colSpan={2} className="group-empty">
|
||
还没有托管项目
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function CardTable({
|
||
card,
|
||
options,
|
||
onReload,
|
||
onDelete,
|
||
}: {
|
||
card: CardItem
|
||
options: { key: string; title: string; depth: number; enabled: boolean }[]
|
||
onReload: () => void
|
||
onDelete: (id: number, title: string) => void
|
||
}) {
|
||
const [title, setTitle] = useState(card.title)
|
||
const [selected, setSelected] = useState<Record<string, string>>(
|
||
Object.fromEntries(
|
||
card.groups.map((group) => [
|
||
group.groupKey,
|
||
group.previewLimit === null || group.previewLimit === undefined
|
||
? ''
|
||
: String(group.previewLimit),
|
||
]),
|
||
),
|
||
)
|
||
const [busy, setBusy] = useState(false)
|
||
/** 勾选的分组按此顺序在首页卡片里出现;启用先后不再决定顺序 */
|
||
const [order, setOrder] = useState<string[]>(() => card.groups.map((group) => group.groupKey))
|
||
|
||
const persistGroups = async (nextSelected: Record<string, string>, nextOrder: string[]) => {
|
||
setBusy(true)
|
||
const groups = nextOrder
|
||
.filter((key) => key in nextSelected)
|
||
.map((groupKey) => {
|
||
const limit = nextSelected[groupKey] ?? ''
|
||
return {
|
||
groupKey,
|
||
previewLimit: limit.trim() === '' ? undefined : Number(limit),
|
||
}
|
||
})
|
||
const result = await setCardGroups(card.id, groups)
|
||
setBusy(false)
|
||
if (!result.ok) {
|
||
toast.error(result.error)
|
||
onReload()
|
||
return
|
||
}
|
||
onReload()
|
||
}
|
||
|
||
const toggle = (key: string, checked: boolean) => {
|
||
const next = { ...selected }
|
||
let nextOrder = order
|
||
if (checked) {
|
||
next[key] = ''
|
||
if (!order.includes(key)) nextOrder = [...order, key]
|
||
} else {
|
||
delete next[key]
|
||
nextOrder = order.filter((item) => item !== key)
|
||
}
|
||
setSelected(next)
|
||
setOrder(nextOrder)
|
||
void persistGroups(next, nextOrder)
|
||
}
|
||
|
||
const moveGroup = (key: string, delta: number) => {
|
||
const index = order.indexOf(key)
|
||
const target = index + delta
|
||
if (index < 0 || target < 0 || target >= order.length) return
|
||
const nextOrder = [...order]
|
||
nextOrder.splice(index, 1)
|
||
nextOrder.splice(target, 0, key)
|
||
setOrder(nextOrder)
|
||
void persistGroups(selected, nextOrder)
|
||
}
|
||
|
||
const saveTitle = async () => {
|
||
const next = title.trim()
|
||
if (next === '' || next === card.title) {
|
||
setTitle(card.title)
|
||
return
|
||
}
|
||
const result = await updateCard(card.id, { title: next })
|
||
if (!result.ok) {
|
||
toast.error(result.error)
|
||
setTitle(card.title)
|
||
return
|
||
}
|
||
toast.success('卡片已保存')
|
||
onReload()
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<div className="cards-table-head">
|
||
<div className="admin-node-title">
|
||
<Input
|
||
value={title}
|
||
aria-label={`${card.title} 标题`}
|
||
onChange={(event) => setTitle(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') void saveTitle()
|
||
}}
|
||
/>
|
||
</div>
|
||
<Button variant="secondary" size="sm" onClick={() => void saveTitle()}>
|
||
保存
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="admin-remove"
|
||
icon={<Icon name="delete_2_line" />}
|
||
aria-label="删除卡片"
|
||
onClick={() => onDelete(card.id, card.title)}
|
||
>
|
||
删除
|
||
</Button>
|
||
</div>
|
||
|
||
<Text size="sm" tone="muted">
|
||
开关和条数修改后即时保存;条数留空使用默认。
|
||
</Text>
|
||
|
||
<div className="group-table-wrap cards-table-wrap">
|
||
<table className="group-table cards-table">
|
||
<thead>
|
||
<tr>
|
||
<th>分组名称</th>
|
||
<th className="cards-col-state">状态</th>
|
||
<th className="cards-col-limit">预览条数</th>
|
||
<th className="cards-col-order">顺序</th>
|
||
<th className="group-col-status">展示</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{options.map((option) => {
|
||
const checked = option.key in selected
|
||
return (
|
||
<tr key={option.key}>
|
||
<td>
|
||
<div style={{ paddingLeft: option.depth * 18 }}>
|
||
<span className="group-name">{option.title}</span>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<span className={option.enabled ? 'group-state-on' : 'group-off-tag'}>
|
||
{option.enabled ? '启用' : '已停用'}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
{checked ? (
|
||
<Input
|
||
type="number"
|
||
value={selected[option.key]}
|
||
placeholder="默认"
|
||
aria-label={`${option.title} 条数`}
|
||
onChange={(event) =>
|
||
setSelected((current) => ({
|
||
...current,
|
||
[option.key]: event.target.value,
|
||
}))
|
||
}
|
||
onBlur={() => void persistGroups(selected, order)}
|
||
/>
|
||
) : (
|
||
<span className="entry-href entry-href-empty">—</span>
|
||
)}
|
||
</td>
|
||
<td>
|
||
{checked ? (
|
||
<div className="card-order-cell">
|
||
<span className="card-order-index">{order.indexOf(option.key) + 1}</span>
|
||
<IconButton
|
||
label={`${option.title} 上移`}
|
||
icon={<Icon name="arrow_up_line" />}
|
||
size="xs"
|
||
disabled={busy || order.indexOf(option.key) === 0}
|
||
onClick={() => moveGroup(option.key, -1)}
|
||
/>
|
||
<IconButton
|
||
label={`${option.title} 下移`}
|
||
icon={<Icon name="arrow_down_line" />}
|
||
size="xs"
|
||
disabled={busy || order.indexOf(option.key) === order.length - 1}
|
||
onClick={() => moveGroup(option.key, 1)}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<span className="entry-href entry-href-empty">—</span>
|
||
)}
|
||
</td>
|
||
<td>
|
||
<Switch
|
||
checked={checked}
|
||
onChange={(next) => toggle(option.key, next)}
|
||
label={`卡片包含 ${option.title}`}
|
||
disabled={busy || !option.enabled}
|
||
/>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---------- 页头 / 资料 / 系统(沿用旧逻辑) ----------
|
||
|
||
const TONE_SELECT = TONE_OPTIONS.map((option) => ({ value: option.value, label: option.label }))
|
||
|
||
function HeaderPanel({
|
||
value,
|
||
onChange,
|
||
site,
|
||
onChangeSite,
|
||
}: {
|
||
value: SiteSettings['header']
|
||
onChange: (header: SiteSettings['header']) => void
|
||
site: SiteSettings['site']
|
||
onChangeSite: (site: SiteSettings['site']) => void
|
||
}) {
|
||
return (
|
||
<Space direction="column" gap="lg" align="stretch" className="admin-form">
|
||
<Text size="sm" strong>
|
||
站点信息
|
||
</Text>
|
||
<FormField label="页面标题" htmlFor="site-title" hint="浏览器标签页上的文字">
|
||
<Input
|
||
id="site-title"
|
||
value={site.title}
|
||
placeholder="Stash · 本地应用工作区"
|
||
onChange={(event) => onChangeSite({ ...site, title: event.target.value })}
|
||
/>
|
||
</FormField>
|
||
<FormField label="Favicon 地址" htmlFor="site-favicon" hint="留空使用内置图标;支持 .svg / .png 或完整 URL">
|
||
<Input
|
||
id="site-favicon"
|
||
value={site.favicon}
|
||
placeholder="/favicon.svg"
|
||
onChange={(event) => onChangeSite({ ...site, favicon: event.target.value })}
|
||
/>
|
||
</FormField>
|
||
|
||
<Text size="sm" strong>
|
||
首页 Logo
|
||
</Text>
|
||
<div className="brand-preview" aria-hidden="true">
|
||
{site.brand.logo.trim() !== '' ? (
|
||
<img className="site-brand-logo" src={site.brand.logo} alt="" />
|
||
) : (
|
||
<span className="site-brand-mark">
|
||
{(site.brand.mark.trim() || site.brand.name.trim().slice(0, 1) || 'S').slice(0, 4)}
|
||
</span>
|
||
)}
|
||
{site.brand.name.trim() !== '' && (
|
||
<span className="site-brand-name">{site.brand.name}</span>
|
||
)}
|
||
</div>
|
||
<FormField label="Logo 图片地址" htmlFor="site-logo" hint="留空则用右侧的首字圆形标记">
|
||
<Input
|
||
id="site-logo"
|
||
value={site.brand.logo}
|
||
placeholder="https://… 或 /logo.svg"
|
||
onChange={(event) =>
|
||
onChangeSite({ ...site, brand: { ...site.brand, logo: event.target.value } })
|
||
}
|
||
/>
|
||
</FormField>
|
||
<div className="admin-row">
|
||
<FormField label="品牌文字" htmlFor="site-brand-name" hint="可留空只显示 logo">
|
||
<Input
|
||
id="site-brand-name"
|
||
value={site.brand.name}
|
||
placeholder="STASH"
|
||
onChange={(event) =>
|
||
onChangeSite({ ...site, brand: { ...site.brand, name: event.target.value } })
|
||
}
|
||
/>
|
||
</FormField>
|
||
<FormField label="首字标记" htmlFor="site-brand-mark" hint="1–4 个字">
|
||
<Input
|
||
id="site-brand-mark"
|
||
value={site.brand.mark}
|
||
placeholder="S"
|
||
onChange={(event) =>
|
||
onChangeSite({ ...site, brand: { ...site.brand, mark: event.target.value } })
|
||
}
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
|
||
<Text size="sm" strong>
|
||
操作工具
|
||
</Text>
|
||
<div className="sidebar-toggle-row">
|
||
<Text>刷新</Text>
|
||
<Switch
|
||
checked={site.actions.refresh}
|
||
onChange={(next) =>
|
||
onChangeSite({ ...site, actions: { ...site.actions, refresh: next } })
|
||
}
|
||
label="页头显示刷新按钮"
|
||
/>
|
||
</div>
|
||
<div className="sidebar-toggle-row">
|
||
<Text>站点设置</Text>
|
||
<Switch
|
||
checked={site.actions.settings}
|
||
onChange={(next) =>
|
||
onChangeSite({ ...site, actions: { ...site.actions, settings: next } })
|
||
}
|
||
label="页头显示站点设置按钮"
|
||
/>
|
||
</div>
|
||
<div className="sidebar-toggle-row">
|
||
<Text>登录 / 退出</Text>
|
||
<Switch
|
||
checked={site.actions.session}
|
||
onChange={(next) =>
|
||
onChangeSite({ ...site, actions: { ...site.actions, session: next } })
|
||
}
|
||
label="页头显示登录或退出按钮"
|
||
/>
|
||
</div>
|
||
|
||
<Text size="sm" strong>
|
||
滚动信息
|
||
</Text>
|
||
<div className="sidebar-toggle-row">
|
||
<Text>显示页头滚动信息</Text>
|
||
<Switch
|
||
checked={value.enabled}
|
||
onChange={(enabled) => onChange({ ...value, enabled })}
|
||
label="显示页头滚动信息"
|
||
/>
|
||
</div>
|
||
<FormField label="滚动速度" htmlFor="header-speed" hint="秒数越大越慢,范围 8–120">
|
||
<Input
|
||
id="header-speed"
|
||
type="number"
|
||
value={String(value.speed)}
|
||
onChange={(event) => {
|
||
const speed = Number(event.target.value)
|
||
if (Number.isFinite(speed)) onChange({ ...value, speed })
|
||
}}
|
||
/>
|
||
</FormField>
|
||
{value.items.map((item, index) => (
|
||
<div className="admin-item-row" key={index}>
|
||
<Input
|
||
value={item.text}
|
||
placeholder="信息文本"
|
||
aria-label="信息文本"
|
||
onChange={(event) =>
|
||
onChange({
|
||
...value,
|
||
items: value.items.map((current, currentIndex) =>
|
||
currentIndex === index ? { ...current, text: event.target.value } : current,
|
||
),
|
||
})
|
||
}
|
||
/>
|
||
<Input
|
||
value={item.tag ?? ''}
|
||
placeholder="标签"
|
||
aria-label="标签"
|
||
onChange={(event) =>
|
||
onChange({
|
||
...value,
|
||
items: value.items.map((current, currentIndex) =>
|
||
currentIndex === index ? { ...current, tag: event.target.value } : current,
|
||
),
|
||
})
|
||
}
|
||
/>
|
||
<Select
|
||
options={TONE_SELECT}
|
||
value={item.tone}
|
||
onChange={(tone) =>
|
||
onChange({
|
||
...value,
|
||
items: value.items.map((current, currentIndex) =>
|
||
currentIndex === index
|
||
? { ...current, tone: tone as SettingsTone }
|
||
: current,
|
||
),
|
||
})
|
||
}
|
||
/>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
icon={<Icon name="delete_2_line" />}
|
||
aria-label="删除这一条"
|
||
onClick={() =>
|
||
onChange({ ...value, items: value.items.filter((_, current) => current !== index) })
|
||
}
|
||
/>
|
||
</div>
|
||
))}
|
||
<Button
|
||
variant="secondary"
|
||
icon={<Icon name="add_line" />}
|
||
onClick={() =>
|
||
onChange({ ...value, items: [...value.items, { text: '', tag: '', tone: 'plain' }] })
|
||
}
|
||
>
|
||
添加一条
|
||
</Button>
|
||
</Space>
|
||
)
|
||
}
|
||
|
||
function ProfilePanel({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value: ProfileSettings
|
||
onChange: (profile: ProfileSettings) => void
|
||
}) {
|
||
const update = (patch: Partial<ProfileSettings>) => onChange({ ...value, ...patch })
|
||
return (
|
||
<Space direction="column" gap="lg" align="stretch" className="admin-form">
|
||
<FormField label="头像地址" htmlFor="profile-avatar">
|
||
<Input
|
||
id="profile-avatar"
|
||
value={value.avatar}
|
||
onChange={(event) => update({ avatar: event.target.value })}
|
||
/>
|
||
</FormField>
|
||
<FormField label="名称" htmlFor="profile-name">
|
||
<Input
|
||
id="profile-name"
|
||
value={value.name}
|
||
onChange={(event) => update({ name: event.target.value })}
|
||
/>
|
||
</FormField>
|
||
<FormField label="角色" htmlFor="profile-role">
|
||
<Input
|
||
id="profile-role"
|
||
value={value.role}
|
||
onChange={(event) => update({ role: event.target.value })}
|
||
/>
|
||
</FormField>
|
||
<FormField label="简介" htmlFor="profile-description">
|
||
<Textarea
|
||
id="profile-description"
|
||
rows={3}
|
||
value={value.description}
|
||
onChange={(event) => update({ description: event.target.value })}
|
||
/>
|
||
</FormField>
|
||
<FormField label="位置" htmlFor="profile-location">
|
||
<Input
|
||
id="profile-location"
|
||
value={value.location}
|
||
onChange={(event) => update({ location: event.target.value })}
|
||
/>
|
||
</FormField>
|
||
</Space>
|
||
)
|
||
}
|
||
|
||
function SystemPanel() {
|
||
const [current, setCurrent] = useState('')
|
||
const [next, setNext] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [message, setMessage] = useState<{ tone: 'info' | 'error'; text: string } | null>(null)
|
||
|
||
const change = async () => {
|
||
setBusy(true)
|
||
setMessage(null)
|
||
const result = await changePassword(current, next)
|
||
setBusy(false)
|
||
if (!result.ok) {
|
||
setMessage({ tone: 'error', text: result.error })
|
||
return
|
||
}
|
||
setMessage({ tone: 'info', text: '密码已修改,所有会话已失效,请重新登录' })
|
||
setCurrent('')
|
||
setNext('')
|
||
}
|
||
|
||
return (
|
||
<Space direction="column" gap="lg" align="stretch" className="admin-form">
|
||
<FormField label="当前密码" htmlFor="current-password">
|
||
<PasswordInput
|
||
id="current-password"
|
||
value={current}
|
||
onChange={(event) => setCurrent(event.target.value)}
|
||
/>
|
||
</FormField>
|
||
<FormField label="新密码" htmlFor="next-password" hint="至少 8 位">
|
||
<PasswordInput
|
||
id="next-password"
|
||
value={next}
|
||
onChange={(event) => setNext(event.target.value)}
|
||
/>
|
||
</FormField>
|
||
{message !== null && (
|
||
<Notice tone={message.tone} title={message.tone === 'info' ? '已修改' : '修改失败'}>
|
||
{message.text}
|
||
</Notice>
|
||
)}
|
||
<Button variant="secondary" loading={busy} icon={<Icon name="key_2_line" />} onClick={change}>
|
||
修改密码
|
||
</Button>
|
||
<Button icon={<Icon name="download_line" />} onClick={() => window.location.assign('/api/backup')}>
|
||
下载备份
|
||
</Button>
|
||
</Space>
|
||
)
|
||
}
|