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
+1 -1
View File
@@ -98,7 +98,7 @@ README.md 运行、路由、库结构、登录说明
| Tab | 能力 |
| --- | --- |
| 分组管理 | 树形表格:新建根分组(左侧)、加子分组、编辑(名称/上级/格式/图标/排序/状态)、删除;行内状态开关即时生效;图标可留空 |
| 内容 | 选分组新增/编辑/删除条目;条目只属于一个分组 |
| 内容 | 选分组以列表展示条目(选主分组时包含所有子分组);列:标题 / 跳转链接 / 所属分组 / 操作;弹窗新增/编辑,编辑时显示不可改的「标识」 |
| 侧边栏 | 分组树逐项勾选(`settings.sidebar.tree_hidden`),子分组可单独勾选 |
| 首页卡片 | 新建/改名/删除卡片;勾选若干分组(可混合格式),每组单独设条数 |
| 页头 | 启用开关、速度、条目、标签与色调 |
+1 -1
View File
@@ -34,7 +34,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
### 登录与设置(v0.1.1 起)
- 登录是**页内弹窗**:点右上角用户图标;首次使用弹窗里是「设置密码 + 确认」(至少 8 位);
- 登录后点「站点设置」进入 `/admin`:分组管理(树形表格,可建根/子分组、图标可空、排序权重、启停、删除)、内容(选分组新增/编辑条目)、侧边栏(勾选分组树)、首页卡片(建卡 + 勾选分组,可混合格式),以及页头滚动信息、资料与系统设置;
- 登录后点「站点设置」进入 `/admin`:分组管理(树形表格,可建根/子分组、图标可空、排序权重、启停、删除)、内容(选分组以列表查看/编辑条目,主分组含子分组)、侧边栏(勾选分组树)、首页卡片(建卡 + 勾选分组,可混合格式),以及页头滚动信息、资料与系统设置;
- 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁;
- `/admin` 的「系统」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
- 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天);
+8
View File
@@ -147,6 +147,14 @@ DELETE /api/items/:id
## 11. 实施记录
### P11(2026-10-31,内容页列表化)
- 内容页改为与分组管理一致的列表:标题 / 跳转链接 / 所属分组 / 操作;
- 选中主分组时递归列出其所有子分组的条目(显示所属分组),选子分组只列该子树;
- 编辑弹窗始终显示「标识」字段(编辑时只读,创建时可填);
- 条目接口补齐图文的 `href`,列表统一展示跳转链接(空值显示 —);
- 验证:主分组 20 条、子分组 4 条;编辑弹窗标识为 `github` 且只读;`cargo test` 59 passed。
### P10(2026-10-31,分组管理页重做)
- 参考 `TT-Helper/需求/5297/1.html` 重做分组管理:树形表格(分组名称 / 排序权重 / 关联网页数 / 状态 / 操作),去掉「展开全部 / 折叠全部」,新建根分组放左侧;
+1
View File
@@ -488,6 +488,7 @@ fn article_item(entry: &EntryRow, meta: Option<&Vec<String>>) -> Value {
if let Some(body) = entry.body.as_ref().filter(|body| !body.trim().is_empty()) {
object.insert("body".into(), json!(body));
}
insert_optional(&mut object, "href", &entry.href);
Value::Object(object)
}
+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;
}
+94 -31
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,29 +848,44 @@ 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} 条
{group?.title ?? ''} 下共 {rows.length} 条(含子分组)
</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="删除条目"
<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) {
@@ -853,10 +895,24 @@ function ContentPanel({ groups, onReload }: { groups: GroupNode[]; onReload: ()
toast.success('条目已删除')
onReload()
}}
/>
>
删除
</button>
</div>
</td>
</tr>
))}
</Space>
{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>
<div className="admin-content-form">
{entryKey === null && (
<FormField
label="标识"
htmlFor="entry-key"
hint={entryKey === null ? '小写字母、数字与连字符,创建后不可改' : '标识创建后不可修改'}
>
<Input
id="entry-key"
value={form.key}
placeholder="标识,如 my-site"
aria-label="条目标识"
disabled={entryKey !== null}
placeholder="如 my-site"
onChange={(event) => update({ key: event.target.value })}
/>
)}
</FormField>
<div className="admin-content-form">
<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 = {