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