feat(admin): 内容弹窗新增/编辑、侧栏直接建子分组
- 内容 Tab 改为弹窗式新增;列表支持编辑(含换分区/分组)与删除 - 条目 PATCH 支持 nodeKey 移动,EntryPatch 改为 camelCase 解析 - 侧栏导航的每个应用分区下可直接新建子分组 - 测试 59 passed;Playwright 覆盖 建组→新增→编辑→移动分组
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -31,6 +31,8 @@ export type EntryInput = {
|
||||
cover: string
|
||||
banner: string
|
||||
href: string
|
||||
/** 编辑时传目标分区/分组,可移动条目 */
|
||||
nodeKey?: string
|
||||
}
|
||||
|
||||
export type ItemResult =
|
||||
|
||||
Reference in New Issue
Block a user