feat(admin): 内容弹窗新增/编辑、侧栏直接建子分组

- 内容 Tab 改为弹窗式新增;列表支持编辑(含换分区/分组)与删除
- 条目 PATCH 支持 nodeKey 移动,EntryPatch 改为 camelCase 解析
- 侧栏导航的每个应用分区下可直接新建子分组
- 测试 59 passed;Playwright 覆盖 建组→新增→编辑→移动分组
This commit is contained in:
2026-10-30 18:31:41 +08:00
parent 26042c3e3f
commit 71061c6b87
4 changed files with 359 additions and 163 deletions
+1
View File
@@ -31,6 +31,7 @@ pub struct EntryRecord {
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EntryPatch { pub struct EntryPatch {
pub name: String, pub name: String,
#[serde(default)] #[serde(default)]
+1 -1
View File
@@ -1029,7 +1029,7 @@
.admin-entry-row { .admin-entry-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 140px auto; grid-template-columns: minmax(0, 1fr) 140px auto auto;
align-items: center; align-items: center;
gap: var(--su-space-2); gap: var(--su-space-2);
padding: var(--su-space-2) 0; padding: var(--su-space-2) 0;
+355 -162
View File
@@ -37,7 +37,7 @@ import {
type SettingsTone, type SettingsTone,
type SiteSettings, type SiteSettings,
} from '../../settings' } from '../../settings'
import { createItem, deleteItem } from '../../items' import { createItem, deleteItem, fetchItem, saveItem } from '../../items'
/** /**
* 站点设置页(/admin):页头滚动信息 + 侧栏顺序/显隐。 * 站点设置页(/admin):页头滚动信息 + 侧栏顺序/显隐。
@@ -538,6 +538,12 @@ function SidebarForm({
/> />
</div> </div>
))} ))}
{category.kind === 'apps' && (
<div className="admin-nav-group-row">
<NewGroupRow sectionId={category.id} onReload={onReload} />
</div>
)}
</div> </div>
) )
})} })}
@@ -1051,7 +1057,13 @@ function NewGroupRow({ sectionId, onReload }: { sectionId: string; onReload: ()
) )
} }
// ---------- 内容(新增条目) ---------- // ---------- 内容(条目列表 + 新增/编辑弹窗) ----------
type EntryRow = EntrySummary & {
groupName?: string
sectionId: string
groupId?: string
}
function ContentManager({ function ContentManager({
categories, categories,
@@ -1061,23 +1073,16 @@ function ContentManager({
onReload: () => void onReload: () => void
}) { }) {
const [sectionId, setSectionId] = useState(categories[0]?.id ?? '') const [sectionId, setSectionId] = useState(categories[0]?.id ?? '')
const [groupId, setGroupId] = useState('') const [dialog, setDialog] = useState<{
const [key, setKey] = useState('') open: boolean
const [name, setName] = useState('') key: string | null
const [href, setHref] = useState('') location: { sectionId: string; groupId?: string } | null
const [description, setDescription] = useState('') }>({ open: false, key: null, location: null })
const [icon, setIcon] = useState('') const [pendingEntry, setPendingEntry] = useState<EntryRow | null>(null)
const [cover, setCover] = useState('')
const [tags, setTags] = useState('')
const [comment, setComment] = useState('')
const [body, setBody] = useState('')
const [busy, setBusy] = useState(false)
const [pendingEntry, setPendingEntry] = useState<EntrySummary | null>(null)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const section = const section =
categories.find((category) => category.id === sectionId) ?? categories[0] categories.find((category) => category.id === sectionId) ?? categories[0]
const groups = section?.kind === 'apps' ? (section.groups ?? []) : []
useEffect(() => { useEffect(() => {
if (categories.length === 0) return if (categories.length === 0) return
@@ -1086,17 +1091,7 @@ function ContentManager({
} }
}, [categories, sectionId]) }, [categories, sectionId])
useEffect(() => { const entries: EntryRow[] =
if (section?.kind !== 'apps') return
if (!groups.some((group) => group.id === groupId)) {
setGroupId(groups[0]?.id ?? '')
}
}, [section, groups, groupId])
const nodeKey =
section === undefined ? '' : section.kind === 'apps' ? groupId : section.id
const entries =
section === undefined section === undefined
? [] ? []
: section.kind === 'apps' : section.kind === 'apps'
@@ -1104,56 +1099,15 @@ function ContentManager({
(group.items ?? []).map((item) => ({ (group.items ?? []).map((item) => ({
...item, ...item,
groupName: group.name, groupName: group.name,
sectionId: section.id,
groupId: group.id,
})), })),
) )
: (section.items ?? []).map((item) => ({ ...item, groupName: undefined })) : (section.items ?? []).map((item) => ({
...item,
const submit = async () => { groupName: undefined,
if (section === undefined || nodeKey === '') { sectionId: section.id,
toast.error('请先选择分区与分组') }))
return
}
setBusy(true)
const result = await createItem({
key: key.trim(),
nodeKey,
item: {
name: name.trim(),
description,
icon,
tags: tags
.split(/[,,]/)
.map((tag) => tag.trim())
.filter(Boolean),
badge: '',
comment,
summary: '',
body,
cover,
banner: '',
href,
},
})
setBusy(false)
if (!result.ok) {
toast.error(result.error)
return
}
toast.success(`已添加「${name.trim()}」`)
setKey('')
setName('')
setHref('')
setDescription('')
setIcon('')
setCover('')
setTags('')
setComment('')
setBody('')
onReload()
}
const removeEntry = async () => { const removeEntry = async () => {
if (pendingEntry === null) return if (pendingEntry === null) return
@@ -1172,8 +1126,8 @@ function ContentManager({
return ( return (
<Space direction="column" gap="lg" align="stretch" className="admin-form"> <Space direction="column" gap="lg" align="stretch" className="admin-form">
<Text size="sm" tone="muted"> <Text size="sm" tone="muted">
在下面新增网址 / 海报 / 图文条目,并选择它挂在哪个分区、哪个分组(应用类必须选分组)。 新增网址 / 海报 / 图文条目时,选择它挂在哪个分区、哪个分组(应用类必须选分组);
新增后,分组出现在侧栏与首页卡片的条件是它被设为显示。 列表里的条目可以点铅笔修改,包括换分区 / 分组。
</Text> </Text>
<Space gap="sm" wrap> <Space gap="sm" wrap>
@@ -1187,95 +1141,15 @@ function ContentManager({
onChange={setSectionId} onChange={setSectionId}
/> />
</div> </div>
{section?.kind === 'apps' && (
<div style={{ width: 220 }}>
<Select
options={groups.map((group) => ({ value: group.id, label: group.name }))}
value={groupId}
onChange={setGroupId}
/>
</div>
)}
{section?.kind === 'apps' && groups.length === 0 && (
<Text size="sm" tone="muted">
这个分区还没有分组,请先到「分区」Tab 新建分组。
</Text>
)}
</Space>
<div className="admin-content-form">
<Input
value={key}
placeholder="标识,如 my-site"
aria-label="条目标识"
onChange={(event) => setKey(event.target.value)}
/>
<Input
value={name}
placeholder="标题,如 我的站点"
aria-label="条目标题"
onChange={(event) => setName(event.target.value)}
/>
<Input
value={href}
placeholder="跳转地址,如 https://example.com 或 /project/"
aria-label="跳转地址"
onChange={(event) => setHref(event.target.value)}
/>
<Input
value={icon}
placeholder="字符图标,如 G"
aria-label="图标"
onChange={(event) => setIcon(event.target.value)}
/>
<Input
value={description}
placeholder="一句话简介"
aria-label="简介"
onChange={(event) => setDescription(event.target.value)}
/>
<Input
value={cover}
placeholder="封面 / 海报图片地址"
aria-label="封面地址"
onChange={(event) => setCover(event.target.value)}
/>
<Input
value={tags}
placeholder="标签,用逗号分隔"
aria-label="标签"
onChange={(event) => setTags(event.target.value)}
/>
<Input
value={comment}
placeholder="一句话评价"
aria-label="一句话评价"
onChange={(event) => setComment(event.target.value)}
/>
<div className="admin-col-span">
<Textarea
rows={4}
value={body}
placeholder="正文(图文 / Markdown),可留空"
aria-label="正文"
onChange={(event) => setBody(event.target.value)}
/>
</div>
</div>
<Space gap="sm">
<Button <Button
loading={busy}
icon={<Icon name="add_line" />} icon={<Icon name="add_line" />}
disabled={ onClick={() =>
section === undefined || setDialog({
nodeKey === '' || open: true,
key.trim() === '' || key: null,
name.trim() === '' location: section === undefined ? null : { sectionId: section.id },
})
} }
onClick={submit}
> >
新增条目 新增条目
</Button> </Button>
@@ -1291,6 +1165,19 @@ function ContentManager({
<Text size="sm" tone="muted"> <Text size="sm" tone="muted">
{entry.groupName ?? '直属分区'} {entry.groupName ?? '直属分区'}
</Text> </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 <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -1303,6 +1190,18 @@ function ContentManager({
))} ))}
</Space> </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 <ConfirmModal
open={pendingEntry !== null} open={pendingEntry !== null}
title={ title={
@@ -1319,6 +1218,300 @@ function ContentManager({
) )
} }
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?.kind === 'apps' ? (section.groups ?? []) : []
// 打开时重置:新建用当前选择,编辑先拉完整条目
useEffect(() => {
if (!open) return
setError('')
const startSection =
defaultLocation?.sectionId ?? categories[0]?.id ?? ''
setSectionId(startSection)
setGroupId(defaultLocation?.groupId ?? '')
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?.kind !== 'apps') {
setGroupId('')
return
}
if (!groups.some((group) => group.id === groupId)) {
setGroupId(groups[0]?.id ?? '')
}
}, [section, groups, groupId])
const nodeKey =
section === undefined ? '' : section.kind === 'apps' ? 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?.kind === 'apps' && (
<div style={{ width: 220 }}>
<Select
options={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({ function ConfirmModal({
+2
View File
@@ -31,6 +31,8 @@ export type EntryInput = {
cover: string cover: string
banner: string banner: string
href: string href: string
/** 编辑时传目标分区/分组,可移动条目 */
nodeKey?: string
} }
export type ItemResult = export type ItemResult =