feat(admin): 内容页列表化,主分组汇总子分组条目

- 内容页改为列表:标题 / 跳转链接 / 所属分组 / 操作
- 选中主分组时递归列出全部子分组条目;选子分组只列该子树
- 编辑弹窗始终显示「标识」(编辑时只读)
- 图文条目接口补齐 href,列表统一显示跳转链接
- 测试 59 passed;Playwright 验证 20 条汇总、子分组 4 条、标识只读
This commit is contained in:
2026-10-31 10:46:02 +08:00
parent c249fbd826
commit b04842978c
7 changed files with 163 additions and 49 deletions
+39
View File
@@ -1228,6 +1228,45 @@
gap: var(--su-space-3);
}
/* 内容页:与分组管理一致的列表 */
.entry-table .entry-col-href {
width: 40%;
}
.entry-table .entry-col-group {
width: 180px;
}
.entry-title {
color: var(--su-text-main);
font-weight: 500;
}
.entry-href {
display: inline-block;
max-width: 100%;
overflow: hidden;
color: var(--su-color-primary);
font-size: var(--su-font-size-sm);
text-overflow: ellipsis;
text-decoration: none;
vertical-align: bottom;
white-space: nowrap;
}
a.entry-href:hover {
text-decoration: underline;
}
.entry-href-empty {
color: var(--su-text-placeholder);
}
.entry-group {
color: var(--su-text-sub);
font-size: var(--su-font-size-sm);
}
.admin-category-row > * {
min-width: 0;
}
+110 -47
View File
@@ -42,6 +42,7 @@ import {
updateCard,
updateNode,
type CardItem,
type GroupEntry,
type GroupFormat,
type GroupNode,
} from '../../manage'
@@ -782,6 +783,31 @@ function GroupDialog({
// ---------- 内容 ----------
type EntryWithGroup = { entry: GroupEntry; group: GroupNode }
/** 选中某个分组时,把它子树里的条目全部取出来(含子分组) */
function collectGroupEntries(group: GroupNode): EntryWithGroup[] {
return [
...group.entries.map((entry) => ({ entry, group })),
...group.children.flatMap((child) => collectGroupEntries(child)),
]
}
function EntryLink({ href }: { href?: string }) {
const value = (href ?? '').trim()
if (value === '' || value === '#') {
return <span className="entry-href entry-href-empty">—</span>
}
if (/^https?:\/\//i.test(value) || value.startsWith('/')) {
return (
<a className="entry-href" href={value} target="_blank" rel="noreferrer" title={value}>
{value}
</a>
)
}
return <span className="entry-href">{value}</span>
}
function ContentPanel({ groups, onReload }: { groups: GroupNode[]; onReload: () => void }) {
const options = useMemo(() => flattenCardsGroups(groups), [groups])
const [groupKey, setGroupKey] = useState(options[0]?.key ?? '')
@@ -794,11 +820,12 @@ function ContentPanel({ groups, onReload }: { groups: GroupNode[]; onReload: ()
}, [options, groupKey])
const group = findGroup(groups, groupKey)
const rows = group === undefined ? [] : collectGroupEntries(group)
return (
<Space direction="column" gap="lg" align="stretch" className="admin-form">
<Space direction="column" gap="md" align="stretch" className="admin-form">
<Text size="sm" tone="muted">
条目只属于一个分组;新增 / 编辑时选择分组即可调整归属。
条目只属于一个分组;选中主分组时会连同其所有子分组的条目一起列出。
</Text>
<Space gap="sm" wrap>
@@ -806,7 +833,7 @@ function ContentPanel({ groups, onReload }: { groups: GroupNode[]; onReload: ()
<Select
options={options.map((option) => ({
value: option.key,
label: `${' '.repeat(option.depth)}${option.title}(${option.format})`,
label: `${' '.repeat(option.depth)}${option.title}(${FORMAT_LABEL[option.format]})`,
}))}
value={group?.key ?? ''}
onChange={setGroupKey}
@@ -821,42 +848,71 @@ function ContentPanel({ groups, onReload }: { groups: GroupNode[]; onReload: ()
</Button>
</Space>
<Space direction="column" gap="sm" align="stretch">
<Text size="sm" tone="muted">
{group?.title ?? ''} 下 {group?.entries.length ?? 0} 条
</Text>
{(group?.entries ?? []).map((entry) => (
<div className="admin-entry-row" key={entry.id}>
<Text>{entry.name ?? entry.title ?? entry.id}</Text>
<Text size="sm" tone="muted">
{group?.title}
</Text>
<Button
variant="ghost"
size="sm"
icon={<Icon name="pencil_line" />}
aria-label="编辑条目"
onClick={() => setDialog({ key: entry.id, groupKey: group?.key ?? '' })}
/>
<Button
variant="ghost"
size="sm"
className="admin-remove"
icon={<Icon name="delete_2_line" />}
aria-label="删除条目"
onClick={async () => {
const result = await deleteItem(entry.id)
if (!result.ok) {
toast.error(result.error)
return
}
toast.success('条目已删除')
onReload()
}}
/>
</div>
))}
</Space>
<Text size="sm" tone="muted">
{group?.title ?? ''} 下共 {rows.length} 条(含子分组)
</Text>
<div className="group-table-wrap">
<table className="group-table entry-table">
<thead>
<tr>
<th>标题</th>
<th className="entry-col-href">跳转链接</th>
<th className="entry-col-group">所属分组</th>
<th className="group-col-actions">操作</th>
</tr>
</thead>
<tbody>
{rows.map(({ entry, group: owner }) => (
<tr key={entry.id}>
<td>
<span className="entry-title">{entry.name ?? entry.title ?? entry.id}</span>
</td>
<td>
<EntryLink href={entry.href} />
</td>
<td>
<span className="entry-group">{owner.title}</span>
</td>
<td>
<div className="group-actions">
<button
type="button"
className="group-action"
onClick={() => setDialog({ key: entry.id, groupKey: owner.key })}
>
编辑
</button>
<button
type="button"
className="group-action group-action-danger"
onClick={async () => {
const result = await deleteItem(entry.id)
if (!result.ok) {
toast.error(result.error)
return
}
toast.success('条目已删除')
onReload()
}}
>
删除
</button>
</div>
</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={4} className="group-empty">
该分组下还没有内容
</td>
</tr>
)}
</tbody>
</table>
</div>
<EntryDialog
open={dialog !== null}
@@ -1034,15 +1090,22 @@ function EntryDialog({
onChange={setGroupKey}
/>
</div>
<FormField
label="标识"
htmlFor="entry-key"
hint={entryKey === null ? '小写字母、数字与连字符,创建后不可改' : '标识创建后不可修改'}
>
<Input
id="entry-key"
value={form.key}
disabled={entryKey !== null}
placeholder="如 my-site"
onChange={(event) => update({ key: event.target.value })}
/>
</FormField>
<div className="admin-content-form">
{entryKey === null && (
<Input
value={form.key}
placeholder="标识,如 my-site"
aria-label="条目标识"
onChange={(event) => update({ key: event.target.value })}
/>
)}
<Input
value={form.name}
placeholder="标题"
+3
View File
@@ -9,6 +9,9 @@ export type GroupEntry = {
id: string
name?: string
title?: string
href?: string
description?: string
desc?: string
}
export type GroupNode = {