feat(admin): 分区增删、资料设置、操作条与交互收尾
- POST/DELETE /api/categories(需登录,级联,local 保护) - profile 设置(头像/名称/角色/简介/位置)应用到资料卡 - 详情操作条:浏览 +1、点赞切换、分享复制、跳转外链 - 编辑按钮垂直居中且留 12px;设置页按钮上间距、保存 Toast、Tabs 去滚动条 - 登录密码可见切换 - 测试 48 passed
This commit is contained in:
@@ -10,16 +10,22 @@ import {
|
||||
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,
|
||||
deleteCategory,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
TONE_OPTIONS,
|
||||
type HeaderItem,
|
||||
type ProfileSettings,
|
||||
type SectionSettings,
|
||||
type SettingsDocument,
|
||||
type SettingsTone,
|
||||
@@ -49,7 +55,6 @@ export function AdminPage() {
|
||||
const [categories, setCategories] = useState<CategorySummary[]>([])
|
||||
const [tab, setTab] = useState('header')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState<{ tone: 'info' | 'error'; text: string } | null>(null)
|
||||
|
||||
const load = async () => {
|
||||
const [nextSession, nextDocument] = await Promise.all([fetchSession(), fetchSettings()])
|
||||
@@ -75,25 +80,36 @@ export function AdminPage() {
|
||||
const save = async () => {
|
||||
if (document === null || draft === null) return
|
||||
setSaving(true)
|
||||
setMessage(null)
|
||||
|
||||
const result = await saveSettings(document.version, draft)
|
||||
setSaving(false)
|
||||
|
||||
if (!result.ok) {
|
||||
setMessage({ tone: 'error', text: result.error })
|
||||
toast.error(result.error)
|
||||
return
|
||||
}
|
||||
|
||||
setDocument(result.document)
|
||||
setDraft(clone(result.document.settings))
|
||||
setMessage({ tone: 'info', text: '已保存,回到首页即可看到效果' })
|
||||
toast.success('已保存')
|
||||
}
|
||||
|
||||
const removeCategory = async (card: CategorySummary) => {
|
||||
if (!window.confirm(`删除「${card.title}」会同时删除它下面的所有条目,确定继续?`)) return
|
||||
const result = await deleteCategory(card.id)
|
||||
if (!result.ok) {
|
||||
toast.error(result.error)
|
||||
return
|
||||
}
|
||||
toast.success(`已删除「${card.title}」`)
|
||||
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">
|
||||
@@ -141,22 +157,18 @@ export function AdminPage() {
|
||||
{authenticated && draft !== null && (
|
||||
<>
|
||||
<Tabs
|
||||
className="admin-tabs"
|
||||
items={[
|
||||
{ key: 'header', label: '页头滚动信息' },
|
||||
{ key: 'sections', label: '首页卡片' },
|
||||
{ key: 'sidebar', label: '侧栏导航' },
|
||||
{ key: 'profile', label: '资料' },
|
||||
{ key: 'system', label: '系统' },
|
||||
]}
|
||||
activeKey={tab}
|
||||
onChange={setTab}
|
||||
/>
|
||||
|
||||
{message !== null && (
|
||||
<Notice tone={message.tone} title={message.tone === 'info' ? '保存成功' : '保存失败'}>
|
||||
{message.text}
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{tab === 'header' ? (
|
||||
<HeaderForm
|
||||
value={draft.header}
|
||||
@@ -167,23 +179,31 @@ export function AdminPage() {
|
||||
categories={categories}
|
||||
value={draft.sections}
|
||||
onChange={(sections) => setDraft({ ...draft, sections })}
|
||||
onDelete={removeCategory}
|
||||
onReload={load}
|
||||
/>
|
||||
) : tab === 'sidebar' ? (
|
||||
<SidebarForm
|
||||
categories={categories}
|
||||
value={draft.sidebar}
|
||||
onChange={(sidebar) => setDraft({ ...draft, sidebar })}
|
||||
onDelete={removeCategory}
|
||||
onReload={load}
|
||||
/>
|
||||
) : tab === 'profile' ? (
|
||||
<ProfileForm
|
||||
value={draft.profile}
|
||||
onChange={(profile) => setDraft({ ...draft, profile })}
|
||||
/>
|
||||
) : (
|
||||
<SystemPanel />
|
||||
)}
|
||||
|
||||
<Space gap="sm" justify="end">
|
||||
<Space gap="sm" justify="end" className="admin-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setDraft(clone(draft))
|
||||
setMessage(null)
|
||||
void load()
|
||||
}}
|
||||
>
|
||||
@@ -316,10 +336,14 @@ 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]))
|
||||
@@ -367,13 +391,18 @@ function SidebarForm({
|
||||
<IconButtonGroup
|
||||
onUp={() => move(index, -1)}
|
||||
onDown={() => move(index, 1)}
|
||||
onRemove={undefined}
|
||||
onRemove={() => onDelete(category)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{ordered.length === 0 && <Text tone="muted">内容库还没有加载完成。</Text>}
|
||||
|
||||
<Text size="sm" tone="muted">
|
||||
新增或删除分类会同时影响首页卡片与侧栏。
|
||||
</Text>
|
||||
<CategoryCreator onCreated={onReload} />
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
@@ -384,10 +413,14 @@ 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 = [
|
||||
@@ -470,12 +503,136 @@ function SectionForm({
|
||||
<IconButtonGroup
|
||||
onUp={() => move(index, -1)}
|
||||
onDown={() => move(index, 1)}
|
||||
onRemove={undefined}
|
||||
onRemove={card.id === 'local' ? undefined : () => onDelete(card)}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- 资料 ----------
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user