feat(web): 地址栏驱动视图,刷新详情页不再回首页

- 新增 data/view.ts 与 useViewRoute:视图与地址双向同步,支持前进后退
- 条目地址带上所属分组,刷新 / 收藏后「返回」有确定落点
- 服务端只把 /c/** 回落到 index.html,其余路径仍返回 404
- 明细页返回按钮去掉左侧箭头图标
This commit is contained in:
2026-10-24 17:18:30 +08:00
parent 41fed5e64d
commit 19ead7b716
22 changed files with 2098 additions and 1321 deletions
+238 -43
View File
@@ -39,6 +39,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
| --- | --- |
| `/api/*` | JSON / SSE 接口 |
| `/`、`/index.html` | 内嵌前端的 `index.html`(`no-cache`) |
| `/c/**` | 前端路由地址(分类明细 / 条目详情),没有实体文件时同样返回 `index.html` |
| `/assets/**` | 内嵌前端的静态资源(文件名带 hash,长缓存) |
| `/favicon.svg` | 内嵌前端根目录文件 |
| 其他路径 | `ServeDir` 托管 `--root` 目录,目录自动补 `index.html` |
@@ -47,6 +48,34 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
托管目录下的每个一级子目录,只要包含 `index.html` 或 `index.htm`,就会被识别为一个项目,
通过 `/<目录名>/` 访问。目录名会做 URL 路径段编码。
## 前端路由
控制台是单页应用,但「当前在哪一页」写在地址栏里,所以刷新、收藏、前进后退都不会丢上下文:
| 地址 | 页面 |
| --- | --- |
| `/` | 首页分区卡片 |
| `/c/<分区>` | 分类明细 |
| `/c/<分区>/<分组>` | 只展示该分组 |
| `/c/<分区>/i/<条目>` | 条目详情,返回 → 分类明细 |
| `/c/<分区>/<分组>/i/<条目>` | 条目详情,返回 → 该分组 |
地址里的每一段都是**业务键**(也就是接口 JSON 里的 `id`):库里是 `node.key` / `entry.key`,
本地项目则是目录名(访问地址仍然是 `route_path`,两者不再混用)。
几条约定:
- 条目地址带上它自己所在的分组,所以**一个条目只有一个地址**;刷新之后点「返回」
仍然落回条目所属的分组,而不是去猜一个返回目标;
- 「返回」按钮在应用内有上一页时交给浏览器(`history.back()`),
直接打开地址(刷新 / 收藏,应用内没有上一页)时退到上面的推导结果;
- 服务端只把 `/c/**` 回落到入口页(`src/server.rs` 的 `SPA_PREFIXES`),
托管目录里写错的地址仍然是 404 页面——不做「所有未命中都返回 200」的万能回落,
否则分不清是路由拼错还是文件真的不存在;
- 地址转换规则在 [`stash-web/src/data/view.ts`](stash-web/src/data/view.ts),
与 history 打交道的部分在 [`stash-web/src/useViewRoute.ts`](stash-web/src/useViewRoute.ts)。
新增一类页面时两处都要改:前端加前缀,服务端把它加进 `SPA_PREFIXES`。
## 目录结构
```text
@@ -102,46 +131,214 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
### 表结构
| 表 | 说明 |
| --- | --- |
| `category` | 分类;`kind` 取值 `apps` / `posters` / `articles`,`sort` 决定首页分区顺序 |
| `app_group` | 应用类分类下的分组,`sort` 决定组内顺序 |
| `entry` | 条目;`name` 是展示名,`views` / `likes` 单独成列,其余字段在 `data` JSON 列里 |
| `nav_entry` | 侧栏导航;自引用树,`parent_id` 为空的是顶层项,带 `category_id` 为直达链接,否则是可折叠分组 |
三张表 + 一张展示配置表:
`entry.data` 的键名与前端 [`types.ts`](stash-web/src/data/types.ts) 一一对应:
应用的 `desc` / `icon` / `href` / `cover` / `hot` / `banner` / `badge`,
海报的 `tags` / `summary`,图文的 `blocks` / `meta`。想加字段直接改 JSON,后端会原样透传。
- **分区与分组是同一张自引用树**——它们都是「装条目的容器」,只是层级不同;
- **侧栏直接由这棵树派生**(分区下挂分组),不再有独立的导航表;
- **首页分区展示什么由 `section_block` 决定**,卡片不再混排所有分组。
文章正文是一整段 Markdown,直接存在 `entry.body` 里(一篇文章一行),不再拆成正文块表。
主键统一是 `id INTEGER PRIMARY KEY`(SQLite rowid,自动分配,不用 `AUTOINCREMENT`),
另有一列 `key` 作为**唯一业务编码**;外键一律写成 `<目标>_key`,指向对方的 `key`。
#### node —— 分区与分组(一棵容器树)
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | INTEGER | 主键,库内唯一标识(自动分配) |
| `key` | TEXT | 业务编码,全局唯一(如 `recommend` / `ui`) |
| `parent_key` | TEXT? | 父节点编码:分组指向所属分区,分区为 NULL |
| `title` | TEXT | 展示名:分区标题 / 分组小标题 |
| `kind` | TEXT | `section` 顶层分区 / `group` 分区下的分组 |
| `view_mode` | TEXT? | 仅分区:`apps` / `posters` / `articles`,决定渲染形态 |
| `bar_color` | TEXT? | 仅分区:标题左侧 4×16 色条颜色(如 `#3858f6`) |
| `icon` | TEXT? | 仅分区:明细页 hero 左侧的字符图标 |
| `preview_limit` | INTEGER? | 仅分区:首页预览条数;为空时按类型取默认 |
| `empty_text` | TEXT? | 仅分区:分区为空时的提示文案 |
| `show_in_sidebar` | INTEGER | 是否出现在侧栏,`0` / `1`;0 表示只当首页卡片/内容,不进导航 |
| `sort` | INTEGER | 同级顺序,越小越靠前 |
两条约束写在表上:
- `CHECK`:`section` 必须 `parent_key IS NULL` 且有 `view_mode`;`group` 必须有父节点且无 `view_mode`;
- `key` 唯一(UNIQUE),全局不重复。
#### entry —— 内容条目(网站 / 电影 / 文章共用)
| 字段 | 类型 | 说明 / 前端用途 |
| --- | --- | --- |
| `id` | INTEGER | 主键,库内唯一标识(自动分配) |
| `key` | TEXT | 业务编码,全局唯一(如 `github` / `movie-1`);详情页按它查找 |
| `node_key` | TEXT | 直接所属容器,外键 → `node.key`:应用是分组,海报 / 图文是分区本身 |
| `name` | TEXT | 名称:网站名 / 电影名 / 文章标题 |
| `description` | TEXT? | 一句话简介,列表卡片的副标题 |
| `icon` | TEXT? | 字符图标,封面缺失时的占位 |
| `href` | TEXT? | 点击后打开的页面;空或 `#` 表示暂无可跳转地址 |
| `cover` | TEXT? | 封面图:应用头像 / 电影海报 / 文章配图 |
| `banner` | TEXT? | 详情页横幅图 |
| `badge` | TEXT? | 详情页标签行上的一句话标记(如「推荐好站」) |
| `comment` | TEXT? | 详情页的一句话评价(引用块) |
| `summary` | TEXT? | 详情页正文段落(电影简介这类长文本) |
| `body` | TEXT? | 文章正文,一整段 Markdown;一篇一行,不拆块 |
| `hot` | INTEGER | 是否热门,`0` / `1`;为 1 时列表显示 HOT 徽标 |
| `views` | INTEGER | 浏览量,详情页操作条「眼睛」后的数字 |
| `likes` | INTEGER | 点赞数,详情页操作条「赞」后的数字 |
| `sort` | INTEGER | 条目顺序(组内或分区内),越小越靠前 |
条目只存 `node_key`,**所属分区由它推导**:父节点是分组就取该分组的 `parent_key`,
父节点是分区就取它自己。查询「整个分区」时用:
```sql
SELECT e.*
FROM entry e
JOIN node p ON p.key = e.node_key
WHERE COALESCE(p.parent_key, p.key) = 'recommend';
```
好处是没有冗余列可以写错——条目不会出现「声称属于 A 区、实际挂在 B 区分组下」的情况;
`node_key` 还有 `ON UPDATE CASCADE`,改编码时条目自动跟随。
三种内容共用一张表,只是**用到的字段不同**,形态由所属分区的 `view_mode` 决定:
| 类型 | 常用字段 |
| --- | --- |
| 应用 | `name` `description` `icon` `href` `cover` `badge` `banner` `hot` |
| 电影 | `name`(前端叫 `title`)`cover` `summary` `comment` `href` + `entry_tag` |
| 文章 | `name`(前端叫 `title`)`description` `cover` `body`(Markdown)+ `entry_tag` |
#### 正文格式
`entry.body` 存的是 Markdown,渲染层只支持正文实际用到的子集:
| 写法 | 效果 |
| --- | --- |
| 空行分段 | 一个段落 |
| `## 小标题` | 正文小标题(`#`~`######` 都渲染成同级) |
| 单独一行 `![说明](url)` | 配图 |
| `**粗体**`、`` `代码` ``、`[文字](链接)` | 行内格式 |
列表、表格、引用块暂不支持;需要完整语法时把 [`ArticleBody.tsx`](stash-web/src/components/content/ArticleBody.tsx)
换成 react-markdown 即可,渲染层是隔离的。
#### entry_tag —— 行内文本列表(标签 / 元信息)
| 字段 | 类型 | 说明 / 前端用途 |
| --- | --- | --- |
| `entry_key` | TEXT | 所属条目编码,外键 → `entry.key` |
| `sort` | INTEGER | 展示顺序 |
| `label` | TEXT | 标签文字(如「推荐电影」「2 年前」) |
主键 `(entry_key, sort)`。海报的 `tags` 与文章的 `meta` 都存这里——它们都是「有序字符串列表」,
前端只是在不同版式里渲染。
#### section_block —— 首页卡片的展示配置
| 字段 | 类型 | 说明 / 前端用途 |
| --- | --- | --- |
| `id` | INTEGER | 主键,库内唯一标识(自动分配) |
| `node_key` | TEXT | 要展示的节点编码,外键 → `node.key`;通常是分组,电影/文章填分区自己 |
| `title` | TEXT? | 块标题;为空时用节点 `title` |
| `preview_limit` | INTEGER? | 卡片显示几条;为空时用分类默认值 |
| `sort` | INTEGER | 卡片顺序 |
**不存所属分区**:一张卡片属于哪个分区,由 `node_key` 推导——节点是分组就取 `parent_key`,
节点是分区就取它自己。所以这一列没必要重复存。
一张卡片展示「你选定的那个分组」,而不是把分区下所有分组混在一起;
点卡片标题或「···」都打开这个块指向的节点。
### 表关系
```text
node (kind=section) 1 ──── n node (kind=group)
│ │
└────────── n entry ───────┘
│
├──── n entry_tag (标签 / 元信息)
└──── n section_block (首页卡片,指向节点)
section_block ──── 1 node(分区由 node 自己推导,不单独存)
```
| 关系 | 外键 | 级联 |
| --- | --- | --- |
| 分区 → 分组 | `node.parent_key → node.key` | 删分区连带删分组 |
| 节点 → 条目 | `entry.node_key → node.key` | 删分区或分组连带删条目;改编码时条目自动跟随 |
| 条目 → 标签 | `entry_tag.entry_key → entry.key` | 删条目删标签 |
| 展示块 → 节点 | `section_block.node_key → node.key` | 删被展示的节点连带删该卡片 |
几条约束值得记住:
- **API 只暴露业务编码**:响应里的 `id` 都是 `key`,数字主键只在库内使用;
- `entry.node_key`:应用指向分组,海报与图文直接指向分区;
- 条目 `key` 是主键,全局唯一;
- **侧栏就是内容树**:分区展开是它的分组,名称取自 `node.title`,不再有别名或跨区编排;
- 首页卡片展示哪个分组由 `section_block.node_key` 决定,改一行即可换内容。
### 前端怎么用这些表
| 界面 | 用到的表 | 关键字段 |
| --- | --- | --- |
| 首页分区卡片 | `node`(section) + `section_block` + `entry` | 卡片标题用展示块标题(默认被展示节点的 `title`),色条用分区 `bar_color`;预览条目取该节点下的前 N 条 |
| 分类明细页 | `node`(section/group) + `entry` | `icon`(hero 图标)、分区 `title` 或分组 `title`、条目卡片字段 |
| 应用详情 | `entry`(apps) | `name` `description` `icon` `href` `cover` `banner` `badge` `comment` `views` `likes` |
| 海报详情 | `entry` + `entry_tag` | `name`(→`title`)`cover` `comment` `summary` `href` + `tags` |
| 图文详情 | `entry` + `entry_tag` | `name`(→`title`)`description` `cover` `body` + `meta` |
| 侧栏导航 | `node` | 走 `parent_key` 的分区 + 分组,`show_in_sidebar = 0` 的节点跳过;「本地服务」由前端排除 |
| 本地服务 | 不在库里 | 由 `/api/projects` 扫描托管目录后在前端拼接 |
### 侧栏导航
侧栏的层级、标签和跳转目标都在 `nav_entry` 表里,不再写死在前端:
侧栏不再是单独一层数据,**直接由内容树派生**:
```text
nav_entry
├─ id / parent_id / label / category_id / group_id / sort
├─ 顶层项 parent_id IS NULL
│ ├─ category_id 非空 → 直达链接(如「常用推荐」→ recommend)
│ └─ category_id 为空 → 可折叠分组(如「素材资源」)
└─ 叶子 parent_id 指向分组,category_id 必填,group_id 可选
常用推荐(section,可折叠)
├── 常用站点(group → 打开该分组)
├── UI 设计资源
├── 矢量图标
├── 免费壁纸
└── 开发工具
推荐电影(section,无分组 → 直接打开)
CES 现场报道(section,无分组 → 直接打开)
```
两个细节是刻意设计的:
规则很简单:
- 分组名可以与分类名不同(「素材资源」并不对应任何分类);
- 多个叶子可以指向同一个目标(「前端调试工具」和「在线编辑器」都打开 `recommend` 的 `dev` 分组)。
- 分区有分组时渲染成可折叠节点,子项就是分组;
- 分区没有分组时(电影 / 文章)整行是链接;
- 名字一律用 `node.title`,没有别名,也没有跨分区的收纳层;
- `show_in_sidebar = 0` 的节点不出现(种子把「常用站点」设成了 0,因为它已经是首页卡片);
- 「本地服务」是前端拼出来的分类,侧栏按 id 直接排除。
删除分类时,指向它的导航叶子会被外键级联删掉,不会留下点了没反应的死链接。
### 首页展示块
首页卡片展示什么由 `section_block` 决定,不再把分区下所有分组混排:
```sql
-- 常用推荐卡片改成展示「UI 设计资源」分组,最多 6 条
UPDATE section_block
SET node_key = 'ui', preview_limit = 6
WHERE node_key = 'common';
```
每张卡片一行,`node_key` 指向要展示的节点;它属于哪个分区由节点自己决定。
点卡片标题或「···」都会打开这个节点;电影 / 文章这类没有分组的,`node_key` 填分区自己的编码。
**卡片标题跟着展示内容走**:配了展示块时用块标题(没配则用节点 `title`),
所以「常用推荐」这张卡片会显示成「常用站点」——它实际展示的就是这个分组。
需要单独起名时,写 `section_block.title` 即可。
### 自己改内容
1. 用任何 SQLite 工具(DB Browser for SQLite、`sqlite3` CLI、DBeaver…)打开 `data/stash.db`;
2. 分类内容改 `category` / `app_group` / `entry`,侧栏改 `nav_entry`;
2. 内容改 `node`(分区与分组)与 `entry`(文章正文就是 `entry.body` 里的 Markdown),标签改 `entry_tag`,首页展示改 `section_block`;
3. 提交修改后,页面会在 **5 秒内**自动刷新——服务端轮询 `PRAGMA data_version` 检测外部写入;
4. 也可以点侧栏的「重新扫描」或直接刷新浏览器。
改坏 `data` 列(比如写成非法 JSON)时,接口会返回带「分类/条目 id」的错误信息,
页面顶部会显示出来,改回去即可。
新增条目时 `id` 不用填,`key` 必须写(全局唯一);`node_key` 填直接所在的分组编码
(没有分组就填分区编码),所属分区由它推导。
先 `SELECT key, kind, parent_key FROM node` 看一眼现有编码即可。
### 重置
@@ -153,6 +350,16 @@ rm data/stash.db # Linux / macOS
Remove-Item data\stash.db # Windows PowerShell
```
库结构版本记在 `PRAGMA user_version` 里(当前为 `15`)。新版本二进制遇到旧库时会**直接拒绝启动**
并提示删库,而不是拿旧表执行新查询:
```text
[FATAL] 启动失败: 数据库错误: 内容库结构版本为 14,当前程序需要 15。
开发阶段不做自动迁移,请删除数据库后重启(种子会自动重新导入)。
```
`SCHEMA_VERSION` 定义在 [`src/db/schema.rs`](src/db/schema.rs),改动表结构时把它 +1。
## 前端开发
```bash
@@ -242,6 +449,7 @@ GET /api/catalog
"title": "常用推荐",
"barColor": "#3858f6",
"icon": "🔖",
"block": { "nodeKey": "common", "previewLimit": 4 },
"groups": [
{
"id": "common",
@@ -258,24 +466,9 @@ GET /api/catalog
「本地服务」分类**不在**这个接口里,它由 `/api/projects` 扫描托管目录后在前端拼接。
返回体里还有一个 `nav` 字段,即侧栏导航,结构与 [`nav.ts`](stash-web/src/data/nav.ts) 的 `NavModel` 一致:
```json
{
"nav": {
"topLevel": [{ "id": "nav-recommend", "label": "常用推荐", "target": { "categoryId": "recommend" } }],
"groups": [
{
"id": "nav-assets",
"label": "素材资源",
"leaves": [
{ "id": "nav-ui", "label": "UI 设计资源", "target": { "categoryId": "recommend", "groupId": "ui" } }
]
}
]
}
}
```
每个分类上的 `block` 就是首页卡片的展示配置:`nodeKey` 指向要展示的节点(通常是分组),
`previewLimit` 是卡片里显示几条。侧栏不再单独下发,前端直接用 `categories` 里的
分区与分组渲染。
### 订阅内容库变化
@@ -296,7 +489,7 @@ data: {"changed":true}
## 测试
```bash
cargo test # 目录扫描、隐藏目录过滤、排序、路径编码、文件监听广播、内容库
cargo test # 目录扫描、隐藏目录过滤、排序、路径编码、文件监听广播、内容库、SPA 回落
cd stash-web && npm run build # tsc -b + vite build,同时做类型检查
```
@@ -310,7 +503,9 @@ $env:TEMP = "$PWD\target\test-tmp"; $env:TMP = $env:TEMP; cargo test
## 已知限制
- 控制台是单页应用但没有客户端路由,因此没有 SPA history 回退;刷新不存在的物理路径会返回 404。
- 前端路由只有 `/c/**` 一个前缀;新增一类页面时,`stash-web/src/data/view.ts` 与
`src/server.rs` 的 `SPA_PREFIXES` 必须一起改,否则刷新那条地址会拿到 404 页面。
- 前进 / 后退能回到对应页面,但不恢复滚动位置:切换视图统一滚回内容区顶部。
- 内嵌资源占用了 `/assets/` 前缀,托管目录下如果存在同名目录会被遮蔽。
- `/` 固定由控制台占用,上一版「根目录有 `index.html` 就直接当单站点展示」的行为已取消;
托管目录里的项目统一走 `/<目录名>/`。