feat(admin): 后台新增条目与分组 CRUD
- POST /api/items + DELETE /api/items/{key}:新增应用/海报/图文,按分区/分组挂载;PATCH 支持 nodeKey 移动
- 分组 CRUD:POST /api/categories/{id}/groups、PATCH/DELETE /api/groups/{id}(删除级联组内条目)
- PATCH /api/categories/{id} 增加 blockNodeKey,首页卡片可选择预览分组
- /admin 新增「内容」Tab(新增/删除条目),「分区」Tab 内联分组管理与卡片预览
- 测试 59 passed(条目创建/移动/删除、分组增删改)
This commit is contained in:
@@ -980,6 +980,62 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 分区管理:一个分区一块,下面挂卡片预览与子分组 */
|
||||
.admin-section-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-3);
|
||||
padding-bottom: var(--su-space-4);
|
||||
border-bottom: 1px dashed var(--su-border-strong);
|
||||
}
|
||||
|
||||
.admin-block-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-2);
|
||||
}
|
||||
|
||||
.admin-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-2);
|
||||
padding-left: var(--su-space-4);
|
||||
}
|
||||
|
||||
.admin-group-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: var(--su-space-2);
|
||||
}
|
||||
|
||||
.admin-new-group-row {
|
||||
display: grid;
|
||||
grid-template-columns: 190px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--su-space-2);
|
||||
}
|
||||
|
||||
/* 内容页:新增条目表单与条目列表 */
|
||||
.admin-content-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--su-space-3);
|
||||
}
|
||||
|
||||
.admin-content-form .admin-col-span {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.admin-entry-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 140px auto;
|
||||
align-items: center;
|
||||
gap: var(--su-space-2);
|
||||
padding: var(--su-space-2) 0;
|
||||
border-bottom: 1px solid var(--su-border);
|
||||
}
|
||||
|
||||
/* 删除按钮:可删的用危险色,不可删的用弱化色(都取主题令牌) */
|
||||
.admin-item-actions .admin-remove {
|
||||
color: var(--su-color-danger);
|
||||
|
||||
@@ -22,11 +22,14 @@ import { changePassword, fetchSession, logout, type SessionState } from '../../a
|
||||
import { LoginDialog } from '../auth/LoginDialog'
|
||||
import {
|
||||
createCategory,
|
||||
createGroup,
|
||||
deleteCategory,
|
||||
deleteGroup,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
TONE_OPTIONS,
|
||||
updateCategory,
|
||||
updateGroup,
|
||||
type HeaderItem,
|
||||
type ProfileSettings,
|
||||
type SectionSettings,
|
||||
@@ -34,6 +37,7 @@ import {
|
||||
type SettingsTone,
|
||||
type SiteSettings,
|
||||
} from '../../settings'
|
||||
import { createItem, deleteItem } from '../../items'
|
||||
|
||||
/**
|
||||
* 站点设置页(/admin):页头滚动信息 + 侧栏顺序/显隐。
|
||||
@@ -41,11 +45,18 @@ import {
|
||||
* 只做「全局配置」;条目内容仍在详情页就地编辑(P2)。
|
||||
* 保存用 `version` 乐观锁,冲突时提示刷新。
|
||||
*/
|
||||
type EntrySummary = {
|
||||
id: string
|
||||
name?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
type CategoryGroupSummary = {
|
||||
id: string
|
||||
name: string
|
||||
/** 数据库里的默认显隐;缺省视为显示 */
|
||||
showInSidebar?: boolean
|
||||
items?: EntrySummary[]
|
||||
}
|
||||
|
||||
type CategorySummary = {
|
||||
@@ -55,10 +66,12 @@ type CategorySummary = {
|
||||
icon?: string
|
||||
barColor?: string
|
||||
previewLimit?: number
|
||||
/** 首页卡片标题(配了展示块时用块的标题,如「常用站点」) */
|
||||
block?: { title?: string } | null
|
||||
/** 首页卡片标题与预览节点 */
|
||||
block?: { title?: string; nodeKey?: string } | null
|
||||
/** 应用类分区下的分组(子分类) */
|
||||
groups?: CategoryGroupSummary[]
|
||||
/** 海报 / 图文类分区直接挂的条目 */
|
||||
items?: EntrySummary[]
|
||||
}
|
||||
|
||||
const clone = (settings: SiteSettings): SiteSettings => structuredClone(settings)
|
||||
@@ -187,7 +200,8 @@ export function AdminPage() {
|
||||
items={[
|
||||
{ key: 'header', label: '页头滚动信息' },
|
||||
{ key: 'sections', label: '首页卡片' },
|
||||
{ key: 'categories', label: '归类' },
|
||||
{ key: 'content', label: '内容' },
|
||||
{ key: 'categories', label: '分区' },
|
||||
{ key: 'sidebar', label: '侧栏导航' },
|
||||
{ key: 'profile', label: '资料' },
|
||||
{ key: 'system', label: '系统' },
|
||||
@@ -209,6 +223,8 @@ export function AdminPage() {
|
||||
onDelete={requestDelete}
|
||||
onReload={load}
|
||||
/>
|
||||
) : tab === 'content' ? (
|
||||
<ContentManager categories={categories} onReload={load} />
|
||||
) : tab === 'categories' ? (
|
||||
<CategoryManager
|
||||
categories={categories}
|
||||
@@ -232,8 +248,8 @@ export function AdminPage() {
|
||||
<SystemPanel />
|
||||
)}
|
||||
|
||||
{/* 「归类」每行自己保存,不参与整页设置的保存/撤销 */}
|
||||
{tab !== 'categories' && (
|
||||
{/* 「内容」「分区」都是逐项保存,不参与整页设置的保存/撤销 */}
|
||||
{tab !== 'categories' && tab !== 'content' && (
|
||||
<Space gap="sm" justify="end" className="admin-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -709,7 +725,7 @@ function CategoryCreator({ onCreated }: { onCreated: () => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- 归类(分类管理) ----------
|
||||
// ---------- 分区与分组管理 ----------
|
||||
|
||||
const KIND_OPTIONS = [
|
||||
{ value: 'apps', label: '应用' },
|
||||
@@ -727,18 +743,26 @@ function CategoryManager({
|
||||
onDelete: (card: CategorySummary) => void
|
||||
}) {
|
||||
return (
|
||||
<Space direction="column" gap="sm" align="stretch" className="admin-form">
|
||||
<Space direction="column" gap="lg" align="stretch" className="admin-form">
|
||||
<Text size="sm" tone="muted">
|
||||
决定每个分区归到哪一类(应用 / 海报 / 图文),以及标题、图标、色条与预览条数。改类型会自动迁移条目:切到「应用」会建默认分组并把条目挪进去。
|
||||
分区决定内容归到哪一类(应用 / 海报 / 图文)。应用类分区下面可以建分组(子分类),
|
||||
分组会出现在侧栏,也可以作为首页卡片的预览来源;改类型会自动迁移条目。
|
||||
</Text>
|
||||
|
||||
{categories.map((category) => (
|
||||
<CategoryRow
|
||||
key={category.id}
|
||||
category={category}
|
||||
onReload={onReload}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
<div className="admin-section-block" key={category.id}>
|
||||
<CategoryRow
|
||||
category={category}
|
||||
onReload={onReload}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
{category.kind === 'apps' && (
|
||||
<>
|
||||
<BlockSelect section={category} onReload={onReload} />
|
||||
<GroupManager section={category} onReload={onReload} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{categories.length === 0 && <Text tone="muted">内容库还没有加载完成。</Text>}
|
||||
@@ -833,6 +857,512 @@ function CategoryRow({
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- 首页卡片预览节点 ----------
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- 内容(新增条目) ----------
|
||||
|
||||
function ContentManager({
|
||||
categories,
|
||||
onReload,
|
||||
}: {
|
||||
categories: CategorySummary[]
|
||||
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 [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
|
||||
if (!categories.some((category) => category.id === sectionId)) {
|
||||
setSectionId(categories[0].id)
|
||||
}
|
||||
}, [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 =
|
||||
section === undefined
|
||||
? []
|
||||
: section.kind === 'apps'
|
||||
? (section.groups ?? []).flatMap((group) =>
|
||||
(group.items ?? []).map((item) => ({
|
||||
...item,
|
||||
groupName: group.name,
|
||||
})),
|
||||
)
|
||||
: (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()
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{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={submit}
|
||||
>
|
||||
新增条目
|
||||
</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"
|
||||
className="admin-remove"
|
||||
icon={<Icon name="delete_2_line" />}
|
||||
aria-label="删除条目"
|
||||
onClick={() => setPendingEntry(entry)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
|
||||
<ConfirmModal
|
||||
open={pendingEntry !== null}
|
||||
title={
|
||||
pendingEntry === null
|
||||
? '删除条目'
|
||||
: `删除「${pendingEntry.name ?? pendingEntry.title ?? pendingEntry.id}」?`
|
||||
}
|
||||
description="删除后不可恢复。"
|
||||
busy={deleting}
|
||||
onCancel={() => setPendingEntry(null)}
|
||||
onConfirm={() => void removeEntry()}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- 通用删除确认(页面级) ----------
|
||||
|
||||
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({
|
||||
|
||||
@@ -91,6 +91,7 @@ export function ItemEditor({ open, itemKey, onClose, onSaved }: ItemEditorProps)
|
||||
|
||||
const result = await saveItem(entry.key, entry.updated_at, {
|
||||
...form,
|
||||
icon: entry.icon,
|
||||
tags: form.tags
|
||||
.split(/[,,]/)
|
||||
.map((tag) => tag.trim())
|
||||
|
||||
@@ -22,6 +22,7 @@ export type EntryRecord = {
|
||||
export type EntryInput = {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
tags: string[]
|
||||
badge: string
|
||||
comment: string
|
||||
@@ -78,3 +79,41 @@ export async function saveItem(
|
||||
return { ok: false, status: 0, error: error instanceof Error ? error.message : '网络错误' }
|
||||
}
|
||||
}
|
||||
|
||||
/** 新建条目:指定分区 / 分组与字段 */
|
||||
export async function createItem(input: {
|
||||
key: string
|
||||
nodeKey: string
|
||||
item: EntryInput
|
||||
}): Promise<SimpleResult> {
|
||||
try {
|
||||
const response = await fetch('/api/items', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
if (response.ok) return { ok: true }
|
||||
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
||||
return { ok: false, error: payload?.error ?? `创建失败(${response.status})` }
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除条目 */
|
||||
export async function deleteItem(key: string): Promise<SimpleResult> {
|
||||
try {
|
||||
const response = await fetch(`/api/items/${encodeURIComponent(key)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
if (response.ok) return { ok: true }
|
||||
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
||||
return { ok: false, error: payload?.error ?? `删除失败(${response.status})` }
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
|
||||
}
|
||||
}
|
||||
|
||||
export type SimpleResult = { ok: true } | { ok: false; error: string }
|
||||
|
||||
@@ -282,6 +282,8 @@ export type UpdateCategoryInput = {
|
||||
kind?: 'apps' | 'posters' | 'articles'
|
||||
previewLimit?: number
|
||||
emptyText?: string
|
||||
/** 首页卡片预览哪个节点(分区自己或其分组) */
|
||||
blockNodeKey?: string
|
||||
}
|
||||
|
||||
export async function updateCategory(
|
||||
@@ -302,3 +304,60 @@ export async function updateCategory(
|
||||
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 分组(子分类) ----------
|
||||
|
||||
export async function createGroup(
|
||||
sectionId: string,
|
||||
input: { id: string; title: string },
|
||||
): Promise<SimpleResult> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/categories/${encodeURIComponent(sectionId)}/groups`,
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
)
|
||||
if (response.ok) return { ok: true }
|
||||
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
||||
return { ok: false, error: payload?.error ?? `创建失败(${response.status})` }
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateGroup(
|
||||
id: string,
|
||||
patch: { title?: string; showInSidebar?: boolean },
|
||||
): Promise<SimpleResult> {
|
||||
try {
|
||||
const response = await fetch(`/api/groups/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
if (response.ok) return { ok: true }
|
||||
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
||||
return { ok: false, error: payload?.error ?? `保存失败(${response.status})` }
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteGroup(id: string): Promise<SimpleResult> {
|
||||
try {
|
||||
const response = await fetch(`/api/groups/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
if (response.ok) return { ok: true }
|
||||
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
||||
return { ok: false, error: payload?.error ?? `删除失败(${response.status})` }
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user