feat(admin): 内容页列表化,主分组汇总子分组条目
- 内容页改为列表:标题 / 跳转链接 / 所属分组 / 操作 - 选中主分组时递归列出全部子分组条目;选子分组只列该子树 - 编辑弹窗始终显示「标识」(编辑时只读) - 图文条目接口补齐 href,列表统一显示跳转链接 - 测试 59 passed;Playwright 验证 20 条汇总、子分组 4 条、标识只读
This commit is contained in:
+1
-1
@@ -98,7 +98,7 @@ README.md 运行、路由、库结构、登录说明
|
|||||||
| Tab | 能力 |
|
| Tab | 能力 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 分组管理 | 树形表格:新建根分组(左侧)、加子分组、编辑(名称/上级/格式/图标/排序/状态)、删除;行内状态开关即时生效;图标可留空 |
|
| 分组管理 | 树形表格:新建根分组(左侧)、加子分组、编辑(名称/上级/格式/图标/排序/状态)、删除;行内状态开关即时生效;图标可留空 |
|
||||||
| 内容 | 选分组新增/编辑/删除条目;条目只属于一个分组 |
|
| 内容 | 选分组以列表展示条目(选主分组时包含所有子分组);列:标题 / 跳转链接 / 所属分组 / 操作;弹窗新增/编辑,编辑时显示不可改的「标识」 |
|
||||||
| 侧边栏 | 分组树逐项勾选(`settings.sidebar.tree_hidden`),子分组可单独勾选 |
|
| 侧边栏 | 分组树逐项勾选(`settings.sidebar.tree_hidden`),子分组可单独勾选 |
|
||||||
| 首页卡片 | 新建/改名/删除卡片;勾选若干分组(可混合格式),每组单独设条数 |
|
| 首页卡片 | 新建/改名/删除卡片;勾选若干分组(可混合格式),每组单独设条数 |
|
||||||
| 页头 | 启用开关、速度、条目、标签与色调 |
|
| 页头 | 启用开关、速度、条目、标签与色调 |
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
|
|||||||
### 登录与设置(v0.1.1 起)
|
### 登录与设置(v0.1.1 起)
|
||||||
|
|
||||||
- 登录是**页内弹窗**:点右上角用户图标;首次使用弹窗里是「设置密码 + 确认」(至少 8 位);
|
- 登录是**页内弹窗**:点右上角用户图标;首次使用弹窗里是「设置密码 + 确认」(至少 8 位);
|
||||||
- 登录后点「站点设置」进入 `/admin`:分组管理(树形表格,可建根/子分组、图标可空、排序权重、启停、删除)、内容(选分组新增/编辑条目)、侧边栏(勾选分组树)、首页卡片(建卡 + 勾选分组,可混合格式),以及页头滚动信息、资料与系统设置;
|
- 登录后点「站点设置」进入 `/admin`:分组管理(树形表格,可建根/子分组、图标可空、排序权重、启停、删除)、内容(选分组以列表查看/编辑条目,主分组含子分组)、侧边栏(勾选分组树)、首页卡片(建卡 + 勾选分组,可混合格式),以及页头滚动信息、资料与系统设置;
|
||||||
- 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁;
|
- 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁;
|
||||||
- `/admin` 的「系统」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
|
- `/admin` 的「系统」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
|
||||||
- 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天);
|
- 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天);
|
||||||
|
|||||||
@@ -147,6 +147,14 @@ DELETE /api/items/:id
|
|||||||
|
|
||||||
## 11. 实施记录
|
## 11. 实施记录
|
||||||
|
|
||||||
|
### P11(2026-10-31,内容页列表化)
|
||||||
|
|
||||||
|
- 内容页改为与分组管理一致的列表:标题 / 跳转链接 / 所属分组 / 操作;
|
||||||
|
- 选中主分组时递归列出其所有子分组的条目(显示所属分组),选子分组只列该子树;
|
||||||
|
- 编辑弹窗始终显示「标识」字段(编辑时只读,创建时可填);
|
||||||
|
- 条目接口补齐图文的 `href`,列表统一展示跳转链接(空值显示 —);
|
||||||
|
- 验证:主分组 20 条、子分组 4 条;编辑弹窗标识为 `github` 且只读;`cargo test` 59 passed。
|
||||||
|
|
||||||
### P10(2026-10-31,分组管理页重做)
|
### P10(2026-10-31,分组管理页重做)
|
||||||
|
|
||||||
- 参考 `TT-Helper/需求/5297/1.html` 重做分组管理:树形表格(分组名称 / 排序权重 / 关联网页数 / 状态 / 操作),去掉「展开全部 / 折叠全部」,新建根分组放左侧;
|
- 参考 `TT-Helper/需求/5297/1.html` 重做分组管理:树形表格(分组名称 / 排序权重 / 关联网页数 / 状态 / 操作),去掉「展开全部 / 折叠全部」,新建根分组放左侧;
|
||||||
|
|||||||
@@ -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()) {
|
if let Some(body) = entry.body.as_ref().filter(|body| !body.trim().is_empty()) {
|
||||||
object.insert("body".into(), json!(body));
|
object.insert("body".into(), json!(body));
|
||||||
}
|
}
|
||||||
|
insert_optional(&mut object, "href", &entry.href);
|
||||||
|
|
||||||
Value::Object(object)
|
Value::Object(object)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1228,6 +1228,45 @@
|
|||||||
gap: var(--su-space-3);
|
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 > * {
|
.admin-category-row > * {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
updateCard,
|
updateCard,
|
||||||
updateNode,
|
updateNode,
|
||||||
type CardItem,
|
type CardItem,
|
||||||
|
type GroupEntry,
|
||||||
type GroupFormat,
|
type GroupFormat,
|
||||||
type GroupNode,
|
type GroupNode,
|
||||||
} from '../../manage'
|
} 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 }) {
|
function ContentPanel({ groups, onReload }: { groups: GroupNode[]; onReload: () => void }) {
|
||||||
const options = useMemo(() => flattenCardsGroups(groups), [groups])
|
const options = useMemo(() => flattenCardsGroups(groups), [groups])
|
||||||
const [groupKey, setGroupKey] = useState(options[0]?.key ?? '')
|
const [groupKey, setGroupKey] = useState(options[0]?.key ?? '')
|
||||||
@@ -794,11 +820,12 @@ function ContentPanel({ groups, onReload }: { groups: GroupNode[]; onReload: ()
|
|||||||
}, [options, groupKey])
|
}, [options, groupKey])
|
||||||
|
|
||||||
const group = findGroup(groups, groupKey)
|
const group = findGroup(groups, groupKey)
|
||||||
|
const rows = group === undefined ? [] : collectGroupEntries(group)
|
||||||
|
|
||||||
return (
|
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 size="sm" tone="muted">
|
||||||
条目只属于一个分组;新增 / 编辑时选择分组即可调整归属。
|
条目只属于一个分组;选中主分组时会连同其所有子分组的条目一起列出。
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Space gap="sm" wrap>
|
<Space gap="sm" wrap>
|
||||||
@@ -806,7 +833,7 @@ function ContentPanel({ groups, onReload }: { groups: GroupNode[]; onReload: ()
|
|||||||
<Select
|
<Select
|
||||||
options={options.map((option) => ({
|
options={options.map((option) => ({
|
||||||
value: option.key,
|
value: option.key,
|
||||||
label: `${' '.repeat(option.depth)}${option.title}(${option.format})`,
|
label: `${' '.repeat(option.depth)}${option.title}(${FORMAT_LABEL[option.format]})`,
|
||||||
}))}
|
}))}
|
||||||
value={group?.key ?? ''}
|
value={group?.key ?? ''}
|
||||||
onChange={setGroupKey}
|
onChange={setGroupKey}
|
||||||
@@ -821,42 +848,71 @@ function ContentPanel({ groups, onReload }: { groups: GroupNode[]; onReload: ()
|
|||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<Space direction="column" gap="sm" align="stretch">
|
<Text size="sm" tone="muted">
|
||||||
<Text size="sm" tone="muted">
|
{group?.title ?? ''} 下共 {rows.length} 条(含子分组)
|
||||||
{group?.title ?? ''} 下 {group?.entries.length ?? 0} 条
|
</Text>
|
||||||
</Text>
|
|
||||||
{(group?.entries ?? []).map((entry) => (
|
<div className="group-table-wrap">
|
||||||
<div className="admin-entry-row" key={entry.id}>
|
<table className="group-table entry-table">
|
||||||
<Text>{entry.name ?? entry.title ?? entry.id}</Text>
|
<thead>
|
||||||
<Text size="sm" tone="muted">
|
<tr>
|
||||||
{group?.title}
|
<th>标题</th>
|
||||||
</Text>
|
<th className="entry-col-href">跳转链接</th>
|
||||||
<Button
|
<th className="entry-col-group">所属分组</th>
|
||||||
variant="ghost"
|
<th className="group-col-actions">操作</th>
|
||||||
size="sm"
|
</tr>
|
||||||
icon={<Icon name="pencil_line" />}
|
</thead>
|
||||||
aria-label="编辑条目"
|
<tbody>
|
||||||
onClick={() => setDialog({ key: entry.id, groupKey: group?.key ?? '' })}
|
{rows.map(({ entry, group: owner }) => (
|
||||||
/>
|
<tr key={entry.id}>
|
||||||
<Button
|
<td>
|
||||||
variant="ghost"
|
<span className="entry-title">{entry.name ?? entry.title ?? entry.id}</span>
|
||||||
size="sm"
|
</td>
|
||||||
className="admin-remove"
|
<td>
|
||||||
icon={<Icon name="delete_2_line" />}
|
<EntryLink href={entry.href} />
|
||||||
aria-label="删除条目"
|
</td>
|
||||||
onClick={async () => {
|
<td>
|
||||||
const result = await deleteItem(entry.id)
|
<span className="entry-group">{owner.title}</span>
|
||||||
if (!result.ok) {
|
</td>
|
||||||
toast.error(result.error)
|
<td>
|
||||||
return
|
<div className="group-actions">
|
||||||
}
|
<button
|
||||||
toast.success('条目已删除')
|
type="button"
|
||||||
onReload()
|
className="group-action"
|
||||||
}}
|
onClick={() => setDialog({ key: entry.id, groupKey: owner.key })}
|
||||||
/>
|
>
|
||||||
</div>
|
编辑
|
||||||
))}
|
</button>
|
||||||
</Space>
|
<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
|
<EntryDialog
|
||||||
open={dialog !== null}
|
open={dialog !== null}
|
||||||
@@ -1034,15 +1090,22 @@ function EntryDialog({
|
|||||||
onChange={setGroupKey}
|
onChange={setGroupKey}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="admin-content-form">
|
||||||
{entryKey === null && (
|
|
||||||
<Input
|
|
||||||
value={form.key}
|
|
||||||
placeholder="标识,如 my-site"
|
|
||||||
aria-label="条目标识"
|
|
||||||
onChange={(event) => update({ key: event.target.value })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Input
|
<Input
|
||||||
value={form.name}
|
value={form.name}
|
||||||
placeholder="标题"
|
placeholder="标题"
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export type GroupEntry = {
|
|||||||
id: string
|
id: string
|
||||||
name?: string
|
name?: string
|
||||||
title?: string
|
title?: string
|
||||||
|
href?: string
|
||||||
|
description?: string
|
||||||
|
desc?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GroupNode = {
|
export type GroupNode = {
|
||||||
|
|||||||
Reference in New Issue
Block a user