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)]
#[serde(rename_all = "camelCase")]
pub struct EntryPatch {
pub name: String,
#[serde(default)]
+1 -1
View File
@@ -1029,7 +1029,7 @@
.admin-entry-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 140px auto;
grid-template-columns: minmax(0, 1fr) 140px auto auto;
align-items: center;
gap: var(--su-space-2);
padding: var(--su-space-2) 0;
+355 -162
View File
@@ -37,7 +37,7 @@ import {
type SettingsTone,
type SiteSettings,
} from '../../settings'
import { createItem, deleteItem } from '../../items'
import { createItem, deleteItem, fetchItem, saveItem } from '../../items'
/**
* 站点设置页(/admin):页头滚动信息 + 侧栏顺序/显隐。
@@ -538,6 +538,12 @@ function SidebarForm({
/>
</div>
))}
{category.kind === 'apps' && (
<div className="admin-nav-group-row">
<NewGroupRow sectionId={category.id} onReload={onReload} />
</div>
)}
</div>
)
})}
@@ -1051,7 +1057,13 @@ function NewGroupRow({ sectionId, onReload }: { sectionId: string; onReload: ()
)
}
// ---------- 内容(新增条目) ----------
// ---------- 内容(条目列表 + 新增/编辑弹窗) ----------
type EntryRow = EntrySummary & {
groupName?: string
sectionId: string
groupId?: string
}
function ContentManager({
categories,
@@ -1061,23 +1073,16 @@ function ContentManager({
onReload: () => void
}) {
const [sectionId, setSectionId] = useState(categories[0]?.id ?? '')
const [groupId, setGroupId] = useState('')
const [key, setKey] = useState('')
const [name, setName] = useState('')
const [href, setHref] = useState('')
const [description, setDescription] = useState('')
const [icon, setIcon] = useState('')
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 [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]
const groups = section?.kind === 'apps' ? (section.groups ?? []) : []
useEffect(() => {
if (categories.length === 0) return
@@ -1086,17 +1091,7 @@ function ContentManager({
}
}, [categories, sectionId])
useEffect(() => {
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 =
const entries: EntryRow[] =
section === undefined
? []
: section.kind === 'apps'
@@ -1104,56 +1099,15 @@ function ContentManager({
(group.items ?? []).map((item) => ({
...item,
groupName: group.name,
sectionId: section.id,
groupId: group.id,
})),
)
: (section.items ?? []).map((item) => ({ ...item, groupName: undefined }))
const submit = async () => {
if (section === undefined || nodeKey === '') {
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()
}
: (section.items ?? []).map((item) => ({
...item,
groupName: undefined,
sectionId: section.id,
}))
const removeEntry = async () => {
if (pendingEntry === null) return
@@ -1172,8 +1126,8 @@ function ContentManager({
return (
<Space direction="column" gap="lg" align="stretch" className="admin-form">
<Text size="sm" tone="muted">
在下面新增网址 / 海报 / 图文条目,并选择它挂在哪个分区、哪个分组(应用类必须选分组)。
新增后,分组出现在侧栏与首页卡片的条件是它被设为显示。
新增网址 / 海报 / 图文条目时,选择它挂在哪个分区、哪个分组(应用类必须选分组);
列表里的条目可以点铅笔修改,包括换分区 / 分组。
</Text>
<Space gap="sm" wrap>
@@ -1187,95 +1141,15 @@ function ContentManager({
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>
)}
{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
loading={busy}
icon={<Icon name="add_line" />}
disabled={
section === undefined ||
nodeKey === '' ||
key.trim() === '' ||
name.trim() === ''
onClick={() =>
setDialog({
open: true,
key: null,
location: section === undefined ? null : { sectionId: section.id },
})
}
onClick={submit}
>
新增条目
</Button>
@@ -1291,6 +1165,19 @@ function ContentManager({
<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"
@@ -1303,6 +1190,18 @@ function ContentManager({
))}
</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={
@@ -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({
+2
View File
@@ -31,6 +31,8 @@ export type EntryInput = {
cover: string
banner: string
href: string
/** 编辑时传目标分区/分组,可移动条目 */
nodeKey?: string
}
export type ItemResult =