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` 就直接当单站点展示」的行为已取消;
托管目录里的项目统一走 `/<目录名>/`。
+288 -195
View File
@@ -1,9 +1,11 @@
//! 内容库的查询组装:把关系表还原成前端 `types.ts` 里的 `Category[]`。
//! 内容库的查询组装:把统一的 node 树还原成前端 `types.ts` 想要的三种形态。
//!
//! 这里输出的是 `serde_json::Value` 而不是一堆 Rust DTO,理由是:
//! 条目的可变字段本来就存在 JSON 列里,直接用 Value 拼装可以保证
//! 「用户往 JSON 里加什么字段,前端就能读到什么字段」,不需要每加一个
//! 字段就改一次 Rust 结构体。
//! 库里只有一套容器树(分区 → 分组),前端需要 `apps / posters / articles`
//! 的可辨识联合,那是**查询层按 `node.view_mode` 组装**的事。
//!
//! 所有关联都走业务键(`key`),SQL 里能直接看懂指向的是哪个分区 / 分组。
use std::collections::HashMap;
use rusqlite::Connection;
use serde_json::{Map, Value, json};
@@ -13,64 +15,66 @@ use crate::{
error::StashError,
};
/// 分类行:与 `category` 表一一对应
struct CategoryRow {
id: String,
kind: String,
/// 容器节点行:分区与分组共用
struct NodeRow {
key: String,
title: String,
bar_color: String,
icon: String,
view_mode: Option<String>,
bar_color: Option<String>,
icon: Option<String>,
preview_limit: Option<i64>,
empty_text: Option<String>,
show_in_sidebar: bool,
}
/// 导航行:侧栏自引用树的原始形态
struct NavRow {
id: String,
parent_id: Option<String>,
label: String,
category_id: Option<String>,
group_id: Option<String>,
}
/// 条目行:`data` 里是尚未展开的 JSON 文本
/// 条目行:与 `entry` 表的列一一对应
struct EntryRow {
id: String,
group_id: Option<String>,
key: String,
node_key: String,
name: String,
description: Option<String>,
icon: Option<String>,
href: Option<String>,
cover: Option<String>,
banner: Option<String>,
badge: Option<String>,
comment: Option<String>,
summary: Option<String>,
body: Option<String>,
hot: bool,
views: i64,
likes: i64,
data: String,
}
/// 读出整个内容目录:分类树 + 侧栏导航。
/// 读出整个内容目录。
pub fn load(conn: &Connection) -> Result<Catalog, StashError> {
Ok(Catalog {
categories: load_categories(conn)?,
nav: load_nav(conn)?,
categories: load_sections(conn)?,
})
}
/// 读出全部分类,按 `sort` 排序;顺序即首页分区顺序。
fn load_categories(conn: &Connection) -> Result<Vec<Value>, StashError> {
/// 读出全部分区,按 `sort` 排序;顺序即首页分区顺序。
fn load_sections(conn: &Connection) -> Result<Vec<Value>, StashError> {
let mut stmt = conn
.prepare(
"SELECT id, kind, title, bar_color, icon, preview_limit, empty_text
FROM category
ORDER BY sort, id",
"SELECT key, title, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar
FROM node
WHERE kind = 'section'
ORDER BY sort, key",
)
.map_err(map_error)?;
let categories = stmt
let sections = stmt
.query_map([], |row| {
Ok(CategoryRow {
id: row.get(0)?,
kind: row.get(1)?,
title: row.get(2)?,
Ok(NodeRow {
key: row.get(0)?,
title: row.get(1)?,
view_mode: row.get(2)?,
bar_color: row.get(3)?,
icon: row.get(4)?,
preview_limit: row.get(5)?,
empty_text: row.get(6)?,
show_in_sidebar: row.get(7)?,
})
})
.map_err(map_error)?
@@ -80,36 +84,102 @@ fn load_categories(conn: &Connection) -> Result<Vec<Value>, StashError> {
// 先释放语句再发后续查询,避免同一连接上的借用冲突
drop(stmt);
categories
// 每个分区对应的首页卡片:展示哪个节点、几条
let blocks = load_blocks(conn)?;
sections
.iter()
.map(|category| build_category(conn, category))
.map(|section| build_section(conn, section, blocks.get(&section.key)))
.collect()
}
/// 按 kind 分派到不同的组装方式。
fn build_category(conn: &Connection, category: &CategoryRow) -> Result<Value, StashError> {
/// 读出所有卡片配置,按推导出的分区业务键索引。
///
/// 分区不单独存:节点是分组就取它的 `parent_key`,节点是分区就取它自己。
/// `title` 同样在 SQL 里解析:卡片自己配了标题就用它,否则用被展示节点的标题。
fn load_blocks(conn: &Connection) -> Result<HashMap<String, Value>, StashError> {
let mut stmt = conn
.prepare(
"SELECT COALESCE(p.key, n.key) AS section_key,
b.node_key,
COALESCE(b.title, n.title),
b.preview_limit
FROM section_block b
JOIN node n ON n.key = b.node_key
LEFT JOIN node p ON p.key = n.parent_key
ORDER BY b.sort, b.node_key",
)
.map_err(map_error)?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<i64>>(3)?,
))
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
let mut blocks = HashMap::new();
for (section_key, node_key, title, preview_limit) in rows {
let mut block = Map::new();
block.insert("nodeKey".into(), json!(node_key));
block.insert("title".into(), json!(title));
if let Some(limit) = preview_limit {
block.insert("previewLimit".into(), json!(limit));
}
// 同一分区配了多个卡片时只保留第一个:当前 UI 一张卡片展示一个节点
blocks.entry(section_key).or_insert(Value::Object(block));
}
Ok(blocks)
}
/// 按 `view_mode` 分派到不同的组装方式。
fn build_section(
conn: &Connection,
section: &NodeRow,
block: Option<&Value>,
) -> Result<Value, StashError> {
let view_mode = section
.view_mode
.as_deref()
.ok_or_else(|| StashError::Database(format!("分区 {} 缺少 view_mode", section.key)))?;
let mut object = Map::new();
object.insert("id".into(), json!(category.id));
object.insert("kind".into(), json!(category.kind));
object.insert("title".into(), json!(category.title));
object.insert("barColor".into(), json!(category.bar_color));
object.insert("icon".into(), json!(category.icon));
object.insert("id".into(), json!(section.key));
object.insert("kind".into(), json!(view_mode));
object.insert("title".into(), json!(section.title));
object.insert(
"barColor".into(),
json!(section.bar_color.clone().unwrap_or_default()),
);
object.insert("icon".into(), json!(section.icon.clone().unwrap_or_default()));
object.insert("showInSidebar".into(), json!(section.show_in_sidebar));
// 可选字段:没配就不下发,交给前端用 `?? 默认值` 处理
if let Some(limit) = category.preview_limit {
if let Some(limit) = section.preview_limit {
object.insert("previewLimit".into(), json!(limit));
}
if let Some(empty_text) = &category.empty_text {
if let Some(empty_text) = &section.empty_text {
object.insert("emptyText".into(), json!(empty_text));
}
if let Some(block) = block {
object.insert("block".into(), block.clone());
}
match category.kind.as_str() {
"apps" => object.insert("groups".into(), build_groups(conn, category)?),
"posters" | "articles" => object.insert("items".into(), build_items(conn, category)?),
match view_mode {
"apps" => object.insert("groups".into(), build_groups(conn, section)?),
"posters" => object.insert("items".into(), build_poster_items(conn, section)?),
"articles" => object.insert("items".into(), build_article_items(conn, section)?),
other => {
return Err(StashError::Database(format!(
"分类 {} 的 kind 非法: {other}",
category.id
"分区 {} 的 view_mode 非法: {other}",
section.key
)));
}
};
@@ -117,17 +187,23 @@ fn build_category(conn: &Connection, category: &CategoryRow) -> Result<Value, St
Ok(Value::Object(object))
}
/// 应用类:先取出分组,再把条目按 `group_id` 分派进去。
///
/// 只用两条查询(分组一条、条目一条)而不是每组一条,避免 N+1。
fn build_groups(conn: &Connection, category: &CategoryRow) -> Result<Value, StashError> {
/// 应用类:取出分组节点,再把条目按 `node_key` 分派进去。
fn build_groups(conn: &Connection, section: &NodeRow) -> Result<Value, StashError> {
let mut stmt = conn
.prepare("SELECT id, name FROM app_group WHERE category_id = ?1 ORDER BY sort, id")
.prepare(
"SELECT key, title, show_in_sidebar FROM node
WHERE kind = 'group' AND parent_key = ?1
ORDER BY sort, key",
)
.map_err(map_error)?;
let groups = stmt
.query_map([&category.id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
.query_map([&section.key], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, bool>(2)?,
))
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
@@ -135,23 +211,22 @@ fn build_groups(conn: &Connection, category: &CategoryRow) -> Result<Value, Stas
drop(stmt);
let entries = load_entries(conn, &category.id)?;
let entries = load_entries(conn, &section.key)?;
let mut rendered_groups = Vec::with_capacity(groups.len());
for (group_id, group_name) in groups {
let mut items = Vec::new();
// 保持 entry.sort 决定的顺序;group_id 为空的条目不属于任何分组
for entry in entries.iter().filter(|entry| {
entry.group_id.as_deref() == Some(group_id.as_str())
}) {
items.push(Value::Object(entry_object(entry, &category.id, "name")?));
}
for (group_key, group_title, show_in_sidebar) in groups {
let items = entries
.iter()
// 保持 entry.sort 决定的顺序
.filter(|entry| entry.node_key == group_key)
.map(app_item)
.collect::<Vec<_>>();
rendered_groups.push(json!({
"id": group_id,
"name": group_name,
// 对外用业务键,不用数字主键
"id": group_key,
"name": group_title,
"showInSidebar": show_in_sidebar,
"items": items,
}));
}
@@ -159,38 +234,147 @@ fn build_groups(conn: &Connection, category: &CategoryRow) -> Result<Value, Stas
Ok(Value::Array(rendered_groups))
}
/// 海报 / 图文类:没有分组,直接输出条目数组。
fn build_items(conn: &Connection, category: &CategoryRow) -> Result<Value, StashError> {
let entries = load_entries(conn, &category.id)?;
/// 海报类:竖版卡片要 `title` / `cover` / `tags`。
fn build_poster_items(conn: &Connection, section: &NodeRow) -> Result<Value, StashError> {
let entries = load_entries(conn, &section.key)?;
let tags = load_tags(conn, &section.key)?;
let items = entries
.iter()
.map(|entry| entry_object(entry, &category.id, "title").map(Value::Object))
.collect::<Result<Vec<_>, _>>()?;
.map(|entry| poster_item(entry, tags.get(&entry.key)))
.collect::<Vec<_>>();
Ok(Value::Array(items))
}
/// 取某个分类下的全部条目,按 `sort` 升序。
fn load_entries(conn: &Connection, category_id: &str) -> Result<Vec<EntryRow>, StashError> {
/// 图文类:需要 `title` / `desc` / `cover` / `meta` / `body`。
fn build_article_items(conn: &Connection, section: &NodeRow) -> Result<Value, StashError> {
let entries = load_entries(conn, &section.key)?;
let tags = load_tags(conn, &section.key)?;
let items = entries
.iter()
.map(|entry| article_item(entry, tags.get(&entry.key)))
.collect::<Vec<_>>();
Ok(Value::Array(items))
}
/// 应用条目:`name` / `desc` / `icon` / `href` 是必填,其余按需下发。
fn app_item(entry: &EntryRow) -> Value {
let mut object = Map::new();
object.insert("id".into(), json!(entry.key));
object.insert("name".into(), json!(entry.name));
// 列表卡片必须有副标题,空字符串比缺字段安全
object.insert(
"desc".into(),
json!(entry.description.clone().unwrap_or_default()),
);
object.insert("icon".into(), json!(entry.icon.clone().unwrap_or_default()));
object.insert(
"href".into(),
json!(entry.href.clone().unwrap_or_else(|| "#".to_string())),
);
insert_optional(&mut object, "cover", &entry.cover);
insert_optional(&mut object, "banner", &entry.banner);
insert_optional(&mut object, "badge", &entry.badge);
insert_optional(&mut object, "comment", &entry.comment);
// hot 只在为真时下发,前端判 `item.hot === true`
if entry.hot {
object.insert("hot".into(), json!(true));
}
object.insert("views".into(), json!(entry.views));
object.insert("likes".into(), json!(entry.likes));
Value::Object(object)
}
/// 海报条目:前端用 `title` 而不是 `name`。
fn poster_item(entry: &EntryRow, tags: Option<&Vec<String>>) -> Value {
let mut object = Map::new();
object.insert("id".into(), json!(entry.key));
object.insert("title".into(), json!(entry.name));
object.insert("cover".into(), json!(entry.cover.clone().unwrap_or_default()));
if let Some(tags) = tags.filter(|tags| !tags.is_empty()) {
object.insert("tags".into(), json!(tags));
}
insert_optional(&mut object, "comment", &entry.comment);
insert_optional(&mut object, "summary", &entry.summary);
insert_optional(&mut object, "href", &entry.href);
object.insert("views".into(), json!(entry.views));
object.insert("likes".into(), json!(entry.likes));
Value::Object(object)
}
/// 图文条目:`meta` 来自 entry_tag,`body` 是 Markdown 正文。
fn article_item(entry: &EntryRow, meta: Option<&Vec<String>>) -> Value {
let mut object = Map::new();
object.insert("id".into(), json!(entry.key));
object.insert("title".into(), json!(entry.name));
object.insert(
"desc".into(),
json!(entry.description.clone().unwrap_or_default()),
);
object.insert("cover".into(), json!(entry.cover.clone().unwrap_or_default()));
if let Some(meta) = meta.filter(|meta| !meta.is_empty()) {
object.insert("meta".into(), json!(meta));
}
if let Some(body) = entry.body.as_ref().filter(|body| !body.trim().is_empty()) {
object.insert("body".into(), json!(body));
}
Value::Object(object)
}
/// 可选字段:`None` 就不下发,前端用 `??` 兜底。
fn insert_optional(object: &mut Map<String, Value>, key: &str, value: &Option<String>) {
if let Some(value) = value {
object.insert(key.into(), json!(value));
}
}
/// 取某个分区下的全部条目,按 `sort` 升序。
///
/// 条目只存 `node_key`,所属分区由父节点推导:
/// 父节点是分组就取它的 `parent_key`,父节点是分区就取它自己。
fn load_entries(conn: &Connection, section_key: &str) -> Result<Vec<EntryRow>, StashError> {
let mut stmt = conn
.prepare(
"SELECT id, group_id, name, views, likes, data
FROM entry
WHERE category_id = ?1
ORDER BY sort, id",
"SELECT e.key, e.node_key, e.name, e.description, e.icon, e.href, e.cover,
e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes
FROM entry e
JOIN node p ON p.key = e.node_key
WHERE COALESCE(p.parent_key, p.key) = ?1
ORDER BY e.sort, e.key",
)
.map_err(map_error)?;
let entries = stmt
.query_map([category_id], |row| {
.query_map([section_key], |row| {
Ok(EntryRow {
id: row.get(0)?,
group_id: row.get(1)?,
key: row.get(0)?,
node_key: row.get(1)?,
name: row.get(2)?,
views: row.get(3)?,
likes: row.get(4)?,
data: row.get(5)?,
description: row.get(3)?,
icon: row.get(4)?,
href: row.get(5)?,
cover: row.get(6)?,
banner: row.get(7)?,
badge: row.get(8)?,
comment: row.get(9)?,
summary: row.get(10)?,
body: row.get(11)?,
hot: row.get(12)?,
views: row.get(13)?,
likes: row.get(14)?,
})
})
.map_err(map_error)?
@@ -200,122 +384,31 @@ fn load_entries(conn: &Connection, category_id: &str) -> Result<Vec<EntryRow>, S
Ok(entries)
}
/// 把一条条目展开成对象。
///
/// `name_key` 决定展示名落在哪个字段:应用用 `name`,海报与图文用 `title`。
/// 数据库列优先于 JSON,用户即便在 `data` 里写了同名键也不会顶掉真实值。
fn entry_object(
entry: &EntryRow,
category_id: &str,
name_key: &str,
) -> Result<Map<String, Value>, StashError> {
let parsed: Value = serde_json::from_str(&entry.data).map_err(|error| {
// 把位置写进报错里,用户改坏 JSON 时能立刻定位到是哪一行
StashError::Database(format!(
"条目 {category_id}/{} 的 data 列不是合法 JSON: {error}",
entry.id
))
})?;
let Value::Object(mut object) = parsed else {
return Err(StashError::Database(format!(
"条目 {category_id}/{} 的 data 列必须是 JSON 对象",
entry.id
)));
};
object.insert("id".into(), json!(entry.id));
object.insert(name_key.into(), json!(entry.name));
object.insert("views".into(), json!(entry.views));
object.insert("likes".into(), json!(entry.likes));
Ok(object)
}
// ---------------------------------------------------------------------------
// 侧栏导航
// ---------------------------------------------------------------------------
/// 读出侧栏导航,组装成 `{ topLevel, groups }`,结构与 frontend 的 `nav.ts` 一致。
///
/// 侧栏只有两层(顶层项 / 分组内的叶子),所以不需要通用递归:
/// 顶层里带 `category_id` 的是直达链接,不带的是可折叠分组,其子节点即叶子。
fn load_nav(conn: &Connection) -> Result<Value, StashError> {
/// 取某个分区下所有条目的标签,按条目业务键聚合;一次查询避免 N+1。
fn load_tags(conn: &Connection, section_key: &str) -> Result<HashMap<String, Vec<String>>, StashError> {
let mut stmt = conn
.prepare(
"SELECT id, parent_id, label, category_id, group_id
FROM nav_entry
ORDER BY sort, id",
"SELECT t.entry_key, t.label
FROM entry_tag t
JOIN entry e ON e.key = t.entry_key
JOIN node p ON p.key = e.node_key
WHERE COALESCE(p.parent_key, p.key) = ?1
ORDER BY t.entry_key, t.sort",
)
.map_err(map_error)?;
let rows = stmt
.query_map([], |row| {
Ok(NavRow {
id: row.get(0)?,
parent_id: row.get(1)?,
label: row.get(2)?,
category_id: row.get(3)?,
group_id: row.get(4)?,
})
.query_map([section_key], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
drop(stmt);
let mut top_level = Vec::new();
let mut groups = Vec::new();
for row in &rows {
// 有 parent_id 的都是分组内的叶子,在这里由分组统一收集
if row.parent_id.is_some() {
continue;
}
if row.category_id.is_some() {
// 顶层直达项
top_level.push(nav_leaf(row));
continue;
}
// 可折叠分组:收集它的子节点
let leaves = rows
.iter()
.filter(|child| child.parent_id.as_deref() == Some(row.id.as_str()))
.filter(|child| child.category_id.is_some())
.map(nav_leaf)
.collect::<Vec<_>>();
groups.push(json!({
"id": row.id,
"label": row.label,
"leaves": leaves,
}));
let mut grouped: HashMap<String, Vec<String>> = HashMap::new();
for (entry_key, label) in rows {
grouped.entry(entry_key).or_default().push(label);
}
Ok(json!({
"topLevel": top_level,
"groups": groups,
}))
}
/// 把一个导航叶子转成前端结构;`groupId` 为空时不下发该键。
fn nav_leaf(row: &NavRow) -> Value {
let mut target = Map::new();
target.insert(
"categoryId".into(),
json!(row.category_id.clone().unwrap_or_default()),
);
if let Some(group_id) = &row.group_id {
target.insert("groupId".into(), json!(group_id));
}
json!({
"id": row.id,
"label": row.label,
"target": Value::Object(target),
})
Ok(grouped)
}
+296 -79
View File
@@ -21,15 +21,13 @@ use crate::{config::Config, error::StashError};
pub use schema::SeedReport;
/// 一次查询得到的完整内容目录:分类树 + 侧栏导航。
/// 一次查询得到的完整内容目录。
///
/// 两者总是一起下发:首页与侧栏同时需要,拆成两个接口只会多一次往返。
/// 侧栏已改为由这份分类树派生,不再单独下发导航数据。
#[derive(Debug)]
pub struct Catalog {
/// 分类树,结构对应前端 `types.ts` 的 `Category[]`
pub categories: Vec<Value>,
/// 侧栏导航,结构对应前端 `nav.ts` 的 `{ topLevel, groups }`
pub nav: Value,
}
/// 内容库句柄。
@@ -59,41 +57,36 @@ impl Db {
)
.map_err(map_error)?;
// 先校验结构版本,再动任何东西:旧库直接报错,避免用旧表跑新查询
let version = schema::check_version(&conn)?;
schema::apply(&conn)?;
// 结构版本:开发期只写不校验,等结构稳定后再拿它做迁移判断
conn.pragma_update(None, "user_version", 1)
// 写入当前结构版本,供下次启动比对
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION)
.map_err(map_error)?;
// 空库才灌种子:用户删掉内置条目后重启不会被重新塞回来
let existing: i64 = conn
.query_row("SELECT COUNT(*) FROM category", [], |row| row.get(0))
.query_row("SELECT COUNT(*) FROM node", [], |row| row.get(0))
.map_err(map_error)?;
if existing == 0 {
// 全新库是常态;版本号为 0 但库里已经有数据属于异常(比如手工清表),给条提示
if version != 0 {
warn!("结构版本为 {version} 但节点表为空,将按空库重新导入种子");
}
let report = schema::seed(&mut conn)?;
info!(
"内容库为空,已导入内嵌种子:{} 分类 / {} 分组 / {} 条目 / {} 导航节点",
report.categories, report.groups, report.entries, report.nav_items
"内容库为空,已导入内嵌种子:{} 分区 / {} 分组 / {} 条目 / {} 标签 / {} 展示块",
report.sections, report.groups, report.entries, report.tags, report.blocks
);
} else {
info!(
"内容库已存在,沿用现有数据:{}",
crate::config::display_path(&config.db_path)
);
// 开发阶段新增过表(比如侧栏导航),旧库不会被补数据。
// 这里给一条明确提示,避免用户对着空侧栏猜原因。
let nav_nodes: i64 = conn
.query_row("SELECT COUNT(*) FROM nav_entry", [], |row| row.get(0))
.map_err(map_error)?;
if nav_nodes == 0 {
warn!(
"内容库有分类但侧栏导航为空:如果这是旧版本留下的库,删掉 {} 后重启即可重新导入种子",
crate::config::display_path(&config.db_path)
);
}
}
Ok(Self {
@@ -101,7 +94,7 @@ impl Db {
})
}
/// 读出完整的分类树与侧栏导航,供 `/api/catalog` 直接序列化。
/// 读出完整的内容目录,供 `/api/catalog` 直接序列化。
pub fn load_catalog(&self) -> Result<Catalog, StashError> {
let conn = self.lock()?;
catalog::load(&conn)
@@ -129,6 +122,18 @@ pub(crate) fn map_error(error: rusqlite::Error) -> StashError {
StashError::Database(error.to_string())
}
#[cfg(test)]
impl Db {
/// 测试用:包一个空的内存库。
///
/// 路由测试只关心静态资源与回落规则,不查内容,所以既不建表也不灌种子。
pub(crate) fn in_memory() -> Self {
Self {
conn: Mutex::new(Connection::open_in_memory().expect("内存库应能打开")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -144,26 +149,78 @@ mod tests {
}
#[test]
fn seed_fills_every_category_group_and_entry() {
fn seed_fills_every_section_group_and_entry() {
let conn = seeded();
let categories: i64 = conn
.query_row("SELECT COUNT(*) FROM category", [], |row| row.get(0))
.expect("应能统计分类");
let sections: i64 = conn
.query_row(
"SELECT COUNT(*) FROM node WHERE kind = 'section'",
[],
|row| row.get(0),
)
.expect("应能统计分区");
let groups: i64 = conn
.query_row("SELECT COUNT(*) FROM app_group", [], |row| row.get(0))
.query_row(
"SELECT COUNT(*) FROM node WHERE kind = 'group'",
[],
|row| row.get(0),
)
.expect("应能统计分组");
let entries: i64 = conn
.query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0))
.expect("应能统计条目");
let nav_items: i64 = conn
.query_row("SELECT COUNT(*) FROM nav_entry", [], |row| row.get(0))
.expect("应能统计导航节点");
let blocks: i64 = conn
.query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0))
.expect("应能统计展示块");
let tags: i64 = conn
.query_row("SELECT COUNT(*) FROM entry_tag", [], |row| row.get(0))
.expect("应能统计标签");
let articles_with_body: i64 = conn
.query_row(
"SELECT COUNT(*) FROM entry WHERE body IS NOT NULL",
[],
|row| row.get(0),
)
.expect("应能统计带正文的条目");
assert_eq!(categories, 3, "种子应含 常用推荐 / 推荐电影 / CES 三个分类");
assert_eq!(sections, 3, "种子应含 常用推荐 / 推荐电影 / CES 三个分区");
assert_eq!(groups, 5, "常用推荐应有 5 个分组");
assert_eq!(entries, 42, "18 应用 + 12 电影 + 12 图文");
assert_eq!(nav_items, 8, "1 个顶层直达 + 2 个折叠分组 + 5 个叶子");
// 12 部电影各 2 个 tags,12 篇图文各 2 个 meta
assert_eq!(tags, 48, "标签与元信息共用 entry_tag");
// 正文是一篇一段 Markdown,只有 12 篇文章有
assert_eq!(articles_with_body, 12, "只有图文条目带正文");
assert_eq!(blocks, 3, "每个分区一个首页展示块");
}
/// 分区与分组是同一张表:分组必须挂在分区下,且不能有 view_mode。
#[test]
fn groups_are_nodes_under_their_section() {
let conn = seeded();
let (parent_key, view_mode): (String, Option<String>) = conn
.query_row(
"SELECT p.key, g.view_mode
FROM node g
JOIN node p ON p.key = g.parent_key
WHERE g.kind = 'group' AND g.key = 'ui'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.expect("应能查到分组及其父节点");
assert_eq!(parent_key, "recommend");
assert!(view_mode.is_none(), "分组不应带 view_mode");
// 分区一定是顶层节点
let root_sections: i64 = conn
.query_row(
"SELECT COUNT(*) FROM node WHERE kind = 'section' AND parent_key IS NULL",
[],
|row| row.get(0),
)
.expect("应能统计数据");
assert_eq!(root_sections, 3);
}
#[test]
@@ -178,8 +235,16 @@ mod tests {
assert_eq!(apps["id"], "recommend");
assert_eq!(apps["kind"], "apps");
assert_eq!(apps["barColor"], "#3858f6");
assert_eq!(apps["showInSidebar"], true);
assert_eq!(apps["groups"].as_array().expect("应有分组").len(), 5);
// 常用站点只作为首页卡片展示,不进侧栏
assert_eq!(
apps["groups"][0]["showInSidebar"], false,
"常用站点不应出现在侧栏"
);
assert_eq!(apps["groups"][1]["showInSidebar"], true);
let first_app = &apps["groups"][0]["items"][0];
assert_eq!(first_app["name"], "GitHub");
assert_eq!(first_app["hot"], true);
@@ -195,63 +260,182 @@ mod tests {
"海报不应同时下发 name 字段"
);
assert!(posters["items"][0]["title"].is_string());
// tags 来自 entry_tag 子表
assert_eq!(
posters["items"][0]["tags"]
.as_array()
.expect("海报应有标签")
.len(),
2
);
let articles = &categories[2];
assert_eq!(articles["kind"], "articles");
assert!(articles["items"][0]["blocks"].is_array(), "图文正文应是块数组");
// ---- 侧栏导航 ----
let nav = &catalog.nav;
let top_level = nav["topLevel"].as_array().expect("应有顶层项");
assert_eq!(top_level.len(), 1);
assert_eq!(top_level[0]["label"], "常用推荐");
assert_eq!(top_level[0]["target"]["categoryId"], "recommend");
// 正文是一整段 Markdown,而不是块数组
let body = articles["items"][0]["body"]
.as_str()
.expect("图文应有正文");
assert!(body.contains("## 设计细节"), "正文应含小标题: {body}");
assert!(body.contains("!["), "正文应含配图语法: {body}");
assert!(body.contains("\n\n"), "段落之间用空行分隔");
assert!(
top_level[0]["target"].get("groupId").is_none(),
"整分类直达时不应下发 groupId"
articles["items"][0].get("blocks").is_none(),
"不应再下发 blocks 数组"
);
let nav_groups = nav["groups"].as_array().expect("应有折叠分组");
assert_eq!(nav_groups.len(), 2);
assert_eq!(nav_groups[0]["label"], "素材资源");
assert_eq!(nav_groups[0]["leaves"].as_array().expect("应有叶子").len(), 3);
assert_eq!(nav_groups[1]["label"], "开发工具");
assert_eq!(nav_groups[1]["leaves"][0]["label"], "前端调试工具");
assert_eq!(nav_groups[1]["leaves"][0]["target"]["groupId"], "dev");
}
/// 删除分类时,指向它的导航叶子必须一起消失(外键级联),
/// 否则侧栏会留下点了没反应的死链接。
#[test]
fn nav_leaves_cascade_when_their_category_is_removed() {
let conn = seeded();
conn.execute("DELETE FROM category WHERE id = 'recommend'", [])
.expect("应能删除分类");
let catalog = catalog::load(&conn).expect("应能组装内容目录");
assert!(
catalog.nav["topLevel"]
// meta 与海报的 tags 共用 entry_tag 表
assert_eq!(
articles["items"][0]["meta"]
.as_array()
.expect("应有顶层数组")
.is_empty(),
"指向已删分类的顶层链接应被级联删除"
.expect("图文应有元信息")
.len(),
2
);
// ---- 首页展示块 ----
// 常用推荐卡片展示「常用站点」分组,最多 4 条
assert_eq!(apps["block"]["nodeKey"], "common");
assert_eq!(apps["block"]["previewLimit"], 4);
// 电影/文章没有分组,块直接指向分区自己
assert_eq!(posters["block"]["nodeKey"], "movie");
assert_eq!(articles["block"]["nodeKey"], "ces");
}
/// 删除分区时,它的展示块一起消失(外键级联),不会留下指向空分区的卡片。
#[test]
fn broken_json_names_the_offending_entry() {
fn section_blocks_cascade_with_their_section() {
let conn = seeded();
conn.execute("DELETE FROM node WHERE key = 'recommend' AND kind = 'section'", [])
.expect("应能删除分区");
let remaining: i64 = conn
.query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0))
.expect("应能统计展示块");
assert_eq!(remaining, 2, "三个展示块应随分区删除只剩两个");
}
/// 删除分组节点时,组内条目也要一起删掉。
#[test]
fn entries_cascade_when_their_group_is_removed() {
let conn = seeded();
let before: i64 = conn
.query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0))
.expect("应能统计条目");
conn.execute(
"UPDATE entry SET data = '{oops' WHERE category_id = 'recommend' AND id = 'github'",
"DELETE FROM node WHERE kind = 'group' AND key = 'ui' AND parent_key = 'recommend'",
[],
)
.expect("应能写入坏数据");
.expect("应能删除分组");
let error = catalog::load(&conn).expect_err("坏 JSON 应报错");
let message = error.to_string();
let after: i64 = conn
.query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0))
.expect("应能统计条目");
assert!(message.contains("recommend/github"), "报错要能定位到具体条目: {message}");
assert_eq!(before - after, 5, "ui 分组原有 5 个应用,应被级联删除");
}
/// 主键是 `id`(SQLite rowid),`key` 是唯一业务键;外键仍指向 key。
#[test]
fn primary_key_is_id_and_key_is_unique() {
let conn = seeded();
for table in ["node", "entry", "section_block"] {
let pk: String = conn
.query_row(
&format!("SELECT name FROM pragma_table_info('{table}') WHERE pk = 1"),
[],
|row| row.get(0),
)
.expect("应能查到主键列");
assert_eq!(pk, "id", "{table} 的主键应为 id");
}
let duplicate = conn.execute(
"INSERT INTO entry (key, node_key, name)
VALUES ('github', 'common', '重复')",
[],
);
assert!(duplicate.is_err(), "key 必须唯一");
}
/// `key` 是业务键:不允许重复,否则详情页按它查找会命中错行。
#[test]
fn entry_key_is_unique() {
let conn = seeded();
let duplicate = conn.execute(
"INSERT INTO entry (key, node_key, name)
SELECT key, node_key, '重复条目'
FROM entry WHERE key = 'github'",
[],
);
assert!(duplicate.is_err(), "重复的条目 key 必须被拒绝");
}
/// 条目不再存所属分区,分区由 `node_key` 推导:
/// 改分区业务键后,它的分组与条目应当自动归到新键下。
#[test]
fn section_membership_is_derived_from_parent() {
let conn = seeded();
conn.execute("UPDATE node SET key = 'recommend-v2' WHERE key = 'recommend'", [])
.expect("应能改分区业务键");
let catalog = catalog::load(&conn).expect("应能组装内容目录");
let apps = catalog
.categories
.iter()
.find(|category| category["id"] == "recommend-v2")
.expect("应能找到改名后的分区");
let total: usize = apps["groups"]
.as_array()
.expect("应有分组")
.iter()
.map(|group| group["items"].as_array().expect("应有条目").len())
.sum();
assert_eq!(total, 18, "改名后条目仍应归在这个分区下");
}
/// 基础字段现在是真列,不再塞在 JSON 里:直接按列查询也应该拿得到。
#[test]
fn entry_fields_are_columns_not_json_blobs() {
let conn = seeded();
let (name, href, cover, hot, views): (String, String, String, i64, i64) = conn
.query_row(
"SELECT name, href, cover, hot, views FROM entry WHERE key = 'github'",
[],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
},
)
.expect("应能按列读取条目");
assert_eq!(name, "GitHub");
assert_eq!(href, "#");
assert!(cover.starts_with("https://"));
assert_eq!(hot, 1, "布尔字段以 0/1 存储");
assert_eq!(views, 429);
// 旧结构里那个 data 列已经不存在了
let has_data_column: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('entry') WHERE name = 'data'",
[],
|row| row.get(0),
)
.expect("应能查询表结构");
assert_eq!(has_data_column, 0, "entry 不应再有 data JSON 列");
}
#[test]
@@ -262,6 +446,39 @@ mod tests {
assert!(db.data_version().expect("应能读取 data_version") >= 0);
}
/// 全新库(user_version = 0)允许直接建表;当前版本也允许。
#[test]
fn accepts_fresh_or_current_schema_version() {
let conn = Connection::open_in_memory().expect("内存库应能打开");
assert_eq!(
schema::check_version(&conn).expect("全新库应放行"),
0,
"新库的 user_version 应为 0"
);
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION)
.expect("应能写入版本");
assert_eq!(
schema::check_version(&conn).expect("当前版本应放行"),
schema::SCHEMA_VERSION
);
}
/// 结构变过之后,旧库必须被拒绝并提示删库,而不是用旧表跑新查询。
#[test]
fn rejects_database_from_older_schema() {
let conn = Connection::open_in_memory().expect("内存库应能打开");
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION - 1)
.expect("应能写入旧版本号");
let error = schema::check_version(&conn).expect_err("旧库必须被拒绝");
let message = error.to_string();
assert!(message.contains("结构版本"), "报错要说明原因: {message}");
assert!(message.contains("删除数据库"), "报错要给出处理方式: {message}");
}
/// 回归测试:外部连接提交后,服务端连接读到的 data_version 必须变化。
///
/// 这是「用户用 DB Browser 改库,页面自动刷新」整条链路的地基;
@@ -287,14 +504,14 @@ mod tests {
// 模拟外部工具:另开一个连接提交一次写事务
let external = Connection::open(&path).expect("外部连接应能打开");
external
.execute(
"UPDATE entry SET name = '改过的名字' WHERE category_id = 'recommend' AND id = 'github'",
[],
)
let changed = external
.execute("UPDATE entry SET name = '改过的名字' WHERE key = 'github'", [])
.expect("外部写入应成功");
drop(external);
// 没有实际改动就不会推进 data_version,这里先确认确实改到了行
assert_eq!(changed, 1, "外部更新应命中 github 条目");
let after = db.data_version().expect("应能读取新版本");
assert_ne!(before, after, "外部提交后 data_version 必须变化");
}
+279 -236
View File
@@ -1,13 +1,19 @@
//! 内容库的表结构与种子导入。
//!
//! 表结构刻意保持「薄」:分类和分组是关系表,条目除 id / 排序 / 计数外的
//! 全部字段都塞进 `entry.data` 的 JSON 列。原因是前端 `types.ts` 用的是
//! 可辨识联合(apps / posters / articles),三种形态字段差异很大,
//! 拆成三张表会让查询和后续加字段都变啰嗦;JSON 列则天然与前端结构 1:1。
//! 两条设计原则:
//!
//! 1. **一个容器树**:分区(section)与分组(group)是同一种东西——「装条目的容器」,
//! 合并在 `node` 一张自引用表里,用 `kind` 区分层级。
//! 2. **内容与展示分离**:`nav_item` 不存内容,只存侧栏的排列(哪个节点、叫什么、放在哪)。
//!
//! 命名规则:
//!
//! - 所有表用**业务编码 `code`** 做主键(`recommend` / `ui` / `github`),不再有数字 id;
//! - 外键统一写成 `<目标>_code`:`parent_code`、`section_code`、`node_code`、`entry_code`;
//! - 改编码时下游由 `ON UPDATE CASCADE` 自动跟随。
use rusqlite::{Connection, params};
use serde::Deserialize;
use serde_json::{Map, Value};
use crate::{db::map_error, error::StashError};
@@ -20,69 +26,114 @@ const SEED_JSON: &str = include_str!("seed.json");
/// 不实现版本迁移:需要改结构时删掉 `data/stash.db` 重启即可,
/// 种子会自动重新灌一份干净数据。
const SCHEMA_SQL: &str = r#"
-- 分类:首页的一张大卡片
CREATE TABLE IF NOT EXISTS category (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('apps', 'posters', 'articles')),
title TEXT NOT NULL,
bar_color TEXT NOT NULL,
icon TEXT NOT NULL,
preview_limit INTEGER,
empty_text TEXT,
sort INTEGER NOT NULL DEFAULT 0
-- 内容节点:分区与分组共用一张自引用树
-- kind = 'section' → 顶层分区(首页一张卡片 / 明细页 hero),parent_code 必须为 NULL
-- kind = 'group' → 分区下的分组,parent_code 指向所属分区
-- view_mode 只对分区有意义,决定首页与明细页怎么渲染
CREATE TABLE IF NOT EXISTS node (
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配,不用 AUTOINCREMENT)
key TEXT NOT NULL UNIQUE, -- 业务键,全局唯一(如 recommend / ui)
parent_key TEXT REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
-- 父节点业务键:分组指向分区,分区为 NULL
title TEXT NOT NULL, -- 展示名:分区标题 / 分组小标题
kind TEXT NOT NULL CHECK (kind IN ('section', 'group')),
-- 容器层级
view_mode TEXT CHECK (view_mode IN ('apps', 'posters', 'articles')),
-- 分区渲染形态;分组为 NULL
bar_color TEXT, -- 分区标题左侧色条颜色(分组为 NULL)
icon TEXT, -- 分区 hero 字符图标(分组为 NULL)
preview_limit INTEGER, -- 首页预览条数;NULL 时前端按类型取默认
empty_text TEXT, -- 分区为空时的提示文案
show_in_sidebar INTEGER NOT NULL DEFAULT 1 CHECK (show_in_sidebar IN (0, 1)),
-- 是否出现在侧栏:0 表示只当首页卡片/内容,不进导航
sort INTEGER NOT NULL DEFAULT 0, -- 同级顺序,越小越靠前
CHECK (
(kind = 'section' AND parent_key IS NULL AND view_mode IS NOT NULL)
OR (kind = 'group' AND parent_key IS NOT NULL AND view_mode IS NULL)
)
);
-- 应用类分类的分组:明细页按组展示,首页把各组前几项拼成预览
CREATE TABLE IF NOT EXISTS app_group (
category_id TEXT NOT NULL REFERENCES category(id) ON DELETE CASCADE,
id TEXT NOT NULL,
name TEXT NOT NULL,
sort INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (category_id, id)
);
CREATE INDEX IF NOT EXISTS node_parent_idx ON node(parent_key, sort);
-- 条目:应用 / 海报 / 图文共用一张表
-- name 展示名(应用是 name,海报与图文是 title)
-- group_id 只有应用类用得上,其余为 NULL
-- data 该条目其余字段的 JSON,键名与前端 types.ts 一致
-- 条目:网站 / 电影 / 文章都在这张表里,基础字段各占一列
CREATE TABLE IF NOT EXISTS entry (
category_id TEXT NOT NULL REFERENCES category(id) ON DELETE CASCADE,
id TEXT NOT NULL,
group_id TEXT,
name TEXT NOT NULL,
views INTEGER NOT NULL DEFAULT 0,
likes INTEGER NOT NULL DEFAULT 0,
sort INTEGER NOT NULL DEFAULT 0,
data TEXT NOT NULL,
PRIMARY KEY (category_id, id)
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配)
key TEXT NOT NULL UNIQUE, -- 业务键,全局唯一(如 github / movie-1)
node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
-- 直接所属容器:应用是分组,海报/图文是分区本身
-- 所属分区不单独存:由 node_key 推导(分组看它的 parent_key)
name TEXT NOT NULL, -- 名称:网站名 / 电影名 / 文章标题
description TEXT, -- 一句话简介,列表卡片的副标题
icon TEXT, -- 字符图标(封面缺失时的占位)
href TEXT, -- 点击后打开的页面;空表示暂无可跳转地址
cover TEXT, -- 封面图:应用头像 / 海报 / 文章配图
banner TEXT, -- 详情页横幅图
badge TEXT, -- 详情页标签行上的一句话标记(如「推荐好站」)
comment TEXT, -- 详情页的一句话评价(引用块)
summary TEXT, -- 详情页正文段落(电影简介这类长文本)
body TEXT, -- 文章正文(Markdown):一篇一段文本
hot INTEGER NOT NULL DEFAULT 0 CHECK (hot IN (0, 1)),-- 是否热门:列表里显示 HOT 徽标
views INTEGER NOT NULL DEFAULT 0, -- 浏览量,详情页操作条「眼睛」后的数字
likes INTEGER NOT NULL DEFAULT 0, -- 点赞数,详情页操作条「赞」后的数字
sort INTEGER NOT NULL DEFAULT 0 -- 条目顺序(组内或分区内),越小越靠前
);
CREATE INDEX IF NOT EXISTS entry_group_idx ON entry(category_id, group_id, sort);
CREATE INDEX IF NOT EXISTS entry_parent_idx ON entry(node_key, sort);
-- 侧栏导航:自引用树,一条表同时表达两种节点
-- parent_id 为 NULL → 顶层项
-- category_id 非 NULL → 叶子,点击后切到该分类(可带分组)
-- category_id 为 NULL → 可折叠分组,它的子节点通过 parent_id 指向它
-- 层级和标签必须单独存:侧栏的「素材资源 / 开发工具」分组名与分类名不同,
-- 同一个分组还能被多个叶子复用(如「前端调试工具」和「在线编辑器」都指向 recommend/dev)。
CREATE TABLE IF NOT EXISTS nav_entry (
id TEXT PRIMARY KEY,
parent_id TEXT REFERENCES nav_entry(id) ON DELETE CASCADE,
label TEXT NOT NULL,
category_id TEXT REFERENCES category(id) ON DELETE CASCADE,
group_id TEXT,
sort INTEGER NOT NULL DEFAULT 0
-- 条目的行内文本列表:海报的 tags、图文的 meta 都放这里(有序、一对多)
CREATE TABLE IF NOT EXISTS entry_tag (
entry_key TEXT NOT NULL REFERENCES entry(key) ON DELETE CASCADE ON UPDATE CASCADE,
-- 所属条目业务键
sort INTEGER NOT NULL DEFAULT 0, -- 展示顺序
label TEXT NOT NULL, -- 标签文字(如「推荐电影」「2 年前」)
PRIMARY KEY (entry_key, sort)
);
-- 首页卡片:一行一个卡片,说明展示哪个节点
-- node_key 指向要展示的节点(通常是分组;电影/文章这类无分组的填分区自己)
-- 「属于哪个分区」不单独存:分组看 parent_key,分区就是它自己,查询时推导
CREATE TABLE IF NOT EXISTS section_block (
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配)
node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
-- 要展示的节点业务键
title TEXT, -- 块标题;NULL 时用节点 title
preview_limit INTEGER, -- 卡片里显示几条;NULL 时用前端默认
sort INTEGER NOT NULL DEFAULT 0 -- 卡片顺序
);
"#;
/// 种子导入结果,仅用于启动日志。
#[derive(Debug, Default, PartialEq, Eq)]
pub struct SeedReport {
pub categories: usize,
pub sections: usize,
pub groups: usize,
pub entries: usize,
/// 侧栏导航节点数(顶层项 + 叶子)
pub nav_items: usize,
pub tags: usize,
/// 首页展示块数量(每个分区一个)
pub blocks: usize,
}
/// 当前表结构版本,写在 SQLite 的 `PRAGMA user_version` 里。
///
/// 开发阶段不做迁移:版本对不上时直接拒绝启动,让用户删库重建,
/// 比悄悄用旧表跑新查询(最终表现为 500)好排查得多。
/// 结构调整时把这个数字 +1。
pub const SCHEMA_VERSION: i64 = 15;
/// 校验库结构版本:0 表示全新库,允许;其它非当前版本一律拒绝。
pub fn check_version(conn: &Connection) -> Result<i64, StashError> {
let version: i64 = conn
.pragma_query_value(None, "user_version", |row| row.get(0))
.map_err(map_error)?;
if version != 0 && version != SCHEMA_VERSION {
return Err(StashError::Database(format!(
"内容库结构版本为 {version},当前程序需要 {SCHEMA_VERSION}。\
开发阶段不做自动迁移,请删除数据库后重启(种子会自动重新导入)。"
)));
}
Ok(version)
}
/// 建表(幂等):每次启动都跑一遍,缺表就补,已有数据不动。
@@ -92,8 +143,8 @@ pub fn apply(conn: &Connection) -> Result<(), StashError> {
/// 把内嵌种子写进空库。
///
/// 只在数据库文件是本次启动新建时调用,所以不存在「覆盖用户数据」的问题;
/// 用户删掉库以后重启会重新得到一份干净的内置内容,这也是开发期的重置手段。
/// 只用业务编码,不需要先插入再回查自增 id:父子关系在种子文件里已经写清楚,
/// 按「分区 → 分组 → 条目 → 侧栏」的顺序插入即可满足外键。
pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
let seed: SeedFile = serde_json::from_str(SEED_JSON)
.map_err(|error| StashError::Database(format!("种子文件不是合法 JSON: {error}")))?;
@@ -102,173 +153,144 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
// 单事务写入:避免中途失败留下半个内容库
let tx = conn.transaction().map_err(map_error)?;
for category in &seed.categories {
let kind = category.kind.as_str();
for section in &seed.sections {
let view = section.view.as_str();
// 只认识前端支持的三种形态,拼错时立刻报错而不是静默写脏数据
if !matches!(kind, "apps" | "posters" | "articles") {
if !matches!(view, "apps" | "posters" | "articles") {
return Err(StashError::Database(format!(
"种子分类 {} 的 kind 非法: {kind}",
category.id
"种子分区 {} 的 view 非法: {view}",
section.key
)));
}
// 只有 apps 分区才允许有分组:其余类型直接挂条目
if view != "apps" && !section.groups.is_empty() {
return Err(StashError::Database(format!(
"种子分区 {} 是 {view} 类型,不应包含 groups",
section.key
)));
}
tx.execute(
"INSERT INTO category (id, kind, title, bar_color, icon, preview_limit, empty_text, sort)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
"INSERT INTO node (key, parent_key, title, kind, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar, sort)
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
category.id,
kind,
category.title,
category.bar_color,
category.icon,
category.preview_limit,
category.empty_text,
category.sort,
section.key,
section.title,
view,
section.bar_color,
section.icon,
section.preview_limit,
section.empty_text,
i64::from(section.show_in_sidebar),
section.sort,
],
)
.map_err(map_error)?;
report.categories += 1;
report.sections += 1;
if kind == "apps" {
for group in &category.groups {
if view == "apps" {
for group in &section.groups {
tx.execute(
"INSERT INTO app_group (category_id, id, name, sort) VALUES (?1, ?2, ?3, ?4)",
params![category.id, group.id, group.name, group.sort],
"INSERT INTO node (key, parent_key, title, kind, show_in_sidebar, sort)
VALUES (?1, ?2, ?3, 'group', ?4, ?5)",
params![
group.key,
section.key,
group.title,
i64::from(group.show_in_sidebar),
group.sort
],
)
.map_err(map_error)?;
report.groups += 1;
for (index, item) in group.items.iter().enumerate() {
insert_entry(&tx, &category.id, Some(&group.id), index, item)?;
report.entries += 1;
for (index, entry) in group.entries.iter().enumerate() {
insert_entry(&tx, &group.key, index, entry, &mut report)?;
}
}
continue;
} else {
// 海报与图文没有分组,直接挂在分区上(node_key 就是分区自己)
for (index, entry) in section.entries.iter().enumerate() {
insert_entry(&tx, &section.key, index, entry, &mut report)?;
}
}
// 海报与图文没有分组,直接挂在分类下
for (index, item) in category.items.iter().enumerate() {
insert_entry(&tx, &category.id, None, index, item)?;
report.entries += 1;
}
// 首页该分区展示哪个节点:节点必须已经插入(分组或分区本身)
tx.execute(
"INSERT INTO section_block (node_key, title, preview_limit, sort)
VALUES (?1, ?2, ?3, ?4)",
params![
section.block.node_key,
section.block.title,
section.block.preview_limit,
section.sort,
],
)
.map_err(|error| {
StashError::Database(format!(
"分区 {} 的展示节点 {} 无效: {error}",
section.key, section.block.node_key
))
})?;
report.blocks += 1;
}
// 侧栏导航必须放在分类之后插入:叶子对 category(id) 有外键依赖
report.nav_items = seed_nav(&tx, &seed.nav)?;
tx.commit().map_err(map_error)?;
Ok(report)
}
/// 导入侧栏导航。
/// 写入一个条目:基础字段进 entry,行内标签进 entry_tag。
///
/// 先插顶层项(parent_id 为空),再插各分组的叶子——自引用外键要求父节点先存在。
/// 返回写入的节点总数。
fn seed_nav(tx: &rusqlite::Transaction<'_>, nav: &SeedNav) -> Result<usize, StashError> {
let mut inserted = 0usize;
// 顶层直达项与折叠分组是同一层的兄弟节点,必须共用一个递增序号,
// 否则两组序号都从 0 开始、互相撞号,最终顺序会退化成按 id 排序。
let mut top_sort = 0usize;
// 顶层直达项:有 category_id,渲染成不带展开箭头的链接
for leaf in &nav.top_level {
insert_nav_node(tx, leaf, None, top_sort)?;
top_sort += 1;
inserted += 1;
}
// 折叠分组:本身没有 category_id,子节点才是真正的链接
for group in &nav.groups {
tx.execute(
"INSERT INTO nav_entry (id, parent_id, label, category_id, group_id, sort)
VALUES (?1, NULL, ?2, NULL, NULL, ?3)",
params![group.id, group.label, top_sort as i64],
)
.map_err(map_error)?;
top_sort += 1;
inserted += 1;
// 组内叶子单独编号:它们是另一个层级,序号从 0 起不会和顶层冲突
for (leaf_index, leaf) in group.leaves.iter().enumerate() {
insert_nav_node(tx, leaf, Some(&group.id), leaf_index)?;
inserted += 1;
}
}
Ok(inserted)
}
/// 写入一个导航叶子节点,指向「分类 + 可选分组」。
fn insert_nav_node(
tx: &rusqlite::Transaction<'_>,
leaf: &SeedNavLeaf,
parent_id: Option<&str>,
sort: usize,
) -> Result<(), StashError> {
tx.execute(
"INSERT INTO nav_entry (id, parent_id, label, category_id, group_id, sort)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
leaf.id,
parent_id,
leaf.label,
leaf.target.category_id,
leaf.target.group_id,
sort as i64,
],
)
.map_err(|error| {
// 外键失败通常意味着种子里的 target 写错了分类 id,把节点名带进报错方便定位
StashError::Database(format!("写入导航节点 {} 失败: {error}", leaf.id))
})?;
Ok(())
}
/// 写入一个条目,并把 `views` / `likes` 从 JSON 里提到独立列。
///
/// 计数放列上是为了让用户用任何 SQLite 工具都能直接改数,
/// 也避免将来自增时重写整个 JSON。
/// `node_key` 是直接容器的业务键;所属分区由它推导,不单独存。
fn insert_entry(
tx: &rusqlite::Transaction<'_>,
category_id: &str,
group_id: Option<&str>,
node_key: &str,
index: usize,
item: &SeedItem,
entry: &SeedEntry,
report: &mut SeedReport,
) -> Result<(), StashError> {
let mut extra = item.extra.clone();
let views = take_count(&mut extra, "views");
let likes = take_count(&mut extra, "likes");
let data = Value::Object(extra).to_string();
tx.execute(
"INSERT INTO entry (category_id, id, group_id, name, views, likes, sort, data)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
"INSERT INTO entry (
key, node_key, name, description, icon, href, cover,
banner, badge, comment, summary, body, hot, views, likes, sort
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
params![
category_id,
item.id,
group_id,
item.name,
views,
likes,
entry.key,
node_key,
entry.name,
entry.description,
entry.icon,
entry.href,
entry.cover,
entry.banner,
entry.badge,
entry.comment,
entry.summary,
entry.body,
i64::from(entry.hot),
entry.views,
entry.likes,
index as i64,
data,
],
)
.map_err(map_error)?;
Ok(())
}
report.entries += 1;
/// 从 JSON 对象里取出计数值并删除该键,缺省或类型不对时按 0 处理。
fn take_count(extra: &mut Map<String, Value>, key: &str) -> i64 {
match extra.remove(key) {
Some(Value::Number(number)) => number.as_i64().unwrap_or(0),
_ => 0,
// 标签:海报的 tags 与图文的 meta 共用一张子表,按出现顺序编号
for (sort, label) in entry.tags.iter().chain(entry.meta.iter()).enumerate() {
tx.execute(
"INSERT INTO entry_tag (entry_key, sort, label) VALUES (?1, ?2, ?3)",
params![entry.key, sort as i64, label],
)
.map_err(map_error)?;
report.tags += 1;
}
Ok(())
}
// ---------------------------------------------------------------------------
@@ -278,54 +300,16 @@ fn take_count(extra: &mut Map<String, Value>, key: &str) -> i64 {
/// 种子文件根节点
#[derive(Debug, Deserialize)]
struct SeedFile {
categories: Vec<SeedCategory>,
/// 侧栏导航;缺省时侧栏为空,但不影响分类内容
#[serde(default)]
nav: SeedNav,
sections: Vec<SeedSection>,
}
/// 侧栏导航:顶层直达项 + 可折叠分组
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedNav {
#[serde(default)]
top_level: Vec<SeedNavLeaf>,
#[serde(default)]
groups: Vec<SeedNavGroup>,
}
/// 一个可折叠分组,本身不可点击,只负责收纳叶子
#[derive(Debug, Deserialize)]
struct SeedNavGroup {
id: String,
label: String,
#[serde(default)]
leaves: Vec<SeedNavLeaf>,
}
/// 导航叶子:点击后切到 `target` 指定的分类 / 分组
#[derive(Debug, Deserialize)]
struct SeedNavLeaf {
id: String,
label: String,
target: SeedNavTarget,
}
/// 导航目标;`group_id` 为空表示直接打开整个分类
/// 一个分区(顶层节点)
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedNavTarget {
category_id: String,
#[serde(default)]
group_id: Option<String>,
}
/// 一个分类;`groups` 与 `items` 按 kind 二者取一
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedCategory {
id: String,
kind: String,
struct SeedSection {
key: String,
/// 渲染形态:apps / posters / articles
view: String,
title: String,
bar_color: String,
icon: String,
@@ -334,28 +318,87 @@ struct SeedCategory {
preview_limit: Option<i64>,
#[serde(default)]
empty_text: Option<String>,
/// 是否出现在侧栏;默认 true
#[serde(default = "default_true")]
show_in_sidebar: bool,
/// 仅 apps 分区使用
#[serde(default)]
groups: Vec<SeedGroup>,
/// 非 apps 分区直接挂条目
#[serde(default)]
items: Vec<SeedItem>,
entries: Vec<SeedEntry>,
/// 首页卡片展示哪个节点(分组编码,或分区自己的编码)
block: SeedBlockConfig,
}
/// 应用类分类下的一个分组
/// 首页分区的展示配置
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedBlockConfig {
/// 要展示的节点业务键;通常是分组,电影/文章填分区自己
node_key: String,
/// 块标题;空则用节点 title
#[serde(default)]
title: Option<String>,
/// 卡片显示几条;空则用前端默认
#[serde(default)]
preview_limit: Option<i64>,
}
/// 分区下的一个分组
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedGroup {
id: String,
name: String,
key: String,
title: String,
sort: i64,
items: Vec<SeedItem>,
/// 是否出现在侧栏;默认 true
#[serde(default = "default_true")]
show_in_sidebar: bool,
entries: Vec<SeedEntry>,
}
/// 一条内容;除 id / name 外全部字段原样落进 JSON 列。
/// serde 默认值:侧栏默认显示
fn default_true() -> bool {
true
}
/// 一条内容:字段与 `entry` 表的列一一对应。
///
/// 用 `flatten` 收集剩余键,这样种子文件里新增字段不需要同步改 Rust 结构。
#[derive(Debug, Deserialize)]
struct SeedItem {
id: String,
/// `tags` / `meta` 都写进 `entry_tag`(海报用 tags,图文用 meta);
/// `body` 是文章的 Markdown 正文,一篇一段文本。
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedEntry {
key: String,
name: String,
#[serde(flatten)]
extra: Map<String, Value>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
href: Option<String>,
#[serde(default)]
cover: Option<String>,
#[serde(default)]
banner: Option<String>,
#[serde(default)]
badge: Option<String>,
#[serde(default)]
comment: Option<String>,
#[serde(default)]
summary: Option<String>,
/// 文章正文(Markdown)
#[serde(default)]
body: Option<String>,
#[serde(default)]
hot: bool,
#[serde(default)]
views: i64,
#[serde(default)]
likes: i64,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
meta: Vec<String>,
}
+177 -546
View File
File diff suppressed because it is too large Load Diff
+209 -34
View File
@@ -1,7 +1,7 @@
use axum::{
Json, Router,
extract::{OriginalUri, Path, State},
http::{HeaderValue, StatusCode, header},
http::{HeaderValue, Method, StatusCode, header},
response::sse::{Event, KeepAlive, Sse},
response::{Html, IntoResponse, Response},
routing::get,
@@ -113,7 +113,30 @@ fn not_found_page(path: &str) -> Response {
(StatusCode::NOT_FOUND, Html(body)).into_response()
}
async fn not_found_handler(OriginalUri(uri): OriginalUri) -> Response {
/// 前端客户端路由占用的路径前缀(见 `stash-web/src/data/view.ts`)。
///
/// 这些地址在服务端没有对应文件:`/c/apps` 刷新时 `ServeDir` 找不到东西,
/// 会落到下面的兜底处理器,由这里回落到内嵌的 `index.html`,
/// 前端再按 `location.pathname` 还原出同一页。
///
/// 只接管明确列出的前缀,托管目录里写错的路径仍然返回 404 页面:
/// 把所有未命中都冒充成 200 的「万能 SPA 回落」,会让人分不清
/// 到底是路由拼错了还是文件真的不存在。
const SPA_PREFIXES: &[&str] = &["/c/"];
/// 是否为前端路由路径(没有实体文件,应该返回入口页)。
fn is_spa_path(path: &str) -> bool {
SPA_PREFIXES.iter().any(|prefix| path.starts_with(prefix))
}
/// `ServeDir` 的兜底:前端路由回落到入口页,其余路径仍是 404 页面。
async fn fallback_handler(OriginalUri(uri): OriginalUri, method: Method) -> Response {
let is_page = method == Method::GET || method == Method::HEAD;
if is_page && is_spa_path(uri.path()) {
return web_index().await;
}
not_found_page(uri.path())
}
@@ -141,9 +164,10 @@ async fn list_projects_api(State(state): State<AppState>) -> impl IntoResponse {
}))
}
/// API: 完整内容目录(分类 / 分组 / 条目 + 侧栏导航)
/// API: 完整内容目录(分区 / 分组 / 条目 + 首页展示块)
///
/// 数据量很小(几十 KB),一次全量下发,前端搜索仍然在客户端做。
/// 侧栏由这份数据派生,不再单独下发导航。
async fn catalog_api(State(state): State<AppState>) -> Response {
let db = state.db.clone();
@@ -151,7 +175,6 @@ async fn catalog_api(State(state): State<AppState>) -> Response {
match tokio::task::spawn_blocking(move || db.load_catalog()).await {
Ok(Ok(catalog)) => Json(serde_json::json!({
"categories": catalog.categories,
"nav": catalog.nav,
}))
.into_response(),
Ok(Err(error)) => {
@@ -252,6 +275,47 @@ fn project_event(projects: Vec<AppProject>) -> Event {
})
}
/// 组装路由:API + 内嵌前端 + 托管目录。
///
/// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、
/// 起监听任务,没法在单元测试里用。
fn build_router(state: AppState) -> Router {
let root = state.config.root.clone();
let enable_cors = state.config.enable_cors;
// 顺序就是优先级:显式路由(API / 内嵌前端)→ 托管目录 → 前端路由回落 → 404
let serve_dir = ServeDir::new(&root)
.append_index_html_on_directories(true)
.fallback(axum::handler::HandlerWithoutStateExt::into_service(
fallback_handler,
));
let mut app = Router::new()
.route("/api/health", get(health_check))
.route("/api/projects", get(list_projects_api))
.route("/api/projects/events", get(project_events))
.route("/api/catalog", get(catalog_api))
.route("/api/catalog/events", get(catalog_events))
.route("/", get(web_index))
.route("/index.html", get(web_index))
.route("/favicon.svg", get(web_root_file))
.route("/assets/{*path}", get(web_asset))
.fallback_service(serve_dir)
.with_state(state)
.layer(TraceLayer::new_for_http());
if enable_cors {
app = app.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any),
);
}
app
}
pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
if !config.root.exists() {
return Err(StashError::InvalidDirectory(
@@ -271,36 +335,7 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
db,
};
// 托管目录里找不到的路径,交给 404 页面而不是 SPA 回落:
// 前端目前没有客户端路由,把 404 冒充 200 只会让排错更难。
let serve_dir = ServeDir::new(&config.root)
.append_index_html_on_directories(true)
.fallback(axum::handler::HandlerWithoutStateExt::into_service(
not_found_handler,
));
let mut app = Router::new()
.route("/api/health", get(health_check))
.route("/api/projects", get(list_projects_api))
.route("/api/projects/events", get(project_events))
.route("/api/catalog", get(catalog_api))
.route("/api/catalog/events", get(catalog_events))
.route("/", get(web_index))
.route("/index.html", get(web_index))
.route("/favicon.svg", get(web_root_file))
.route("/assets/{*path}", get(web_asset))
.fallback_service(serve_dir)
.with_state(shared_state)
.layer(TraceLayer::new_for_http());
if config.enable_cors {
app = app.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any),
);
}
let app = build_router(shared_state);
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
let listener = tokio::net::TcpListener::bind(addr)
@@ -328,3 +363,143 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
local_addr,
})
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{body::Body, http::Request};
use std::path::PathBuf;
use tower::ServiceExt;
/// 测试用的托管目录:放在 `target/` 下,不依赖系统临时目录的权限。
fn hosted_root() -> PathBuf {
let root =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/spa-root");
std::fs::create_dir_all(root.join("demo")).expect("应能创建测试项目");
std::fs::write(
root.join("demo/index.html"),
"<!doctype html><title>demo</title>",
)
.expect("应能写入项目入口页");
std::fs::write(root.join("note.txt"), "plain file").expect("应能写入普通文件");
root
}
fn router() -> Router {
let mut config = Config::default();
config.root = hosted_root();
// 测试不关心 CORS 标头
config.enable_cors = false;
let (project_updates, _) = broadcast::channel(4);
build_router(AppState {
config: Arc::new(config),
project_updates,
db: Arc::new(Db::in_memory()),
})
}
/// 发一个 GET,返回(状态码,Content-Type,响应体)。
async fn get(path: &str) -> (StatusCode, String, String) {
let response = router()
.oneshot(
Request::builder()
.uri(path)
.body(Body::empty())
.expect("请求应能构造"),
)
.await
.expect("路由应能处理请求");
let status = response.status();
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("应能读出响应体");
(
status,
content_type,
String::from_utf8_lossy(&bytes).into_owned(),
)
}
/// 刷新分类明细页:服务端没有 `/c/apps` 这个文件,必须回落到前端入口页。
#[tokio::test]
async fn spa_routes_fall_back_to_the_embedded_index() {
let (status, content_type, body) = get("/c/apps").await;
assert_eq!(status, StatusCode::OK);
assert!(
content_type.starts_with("text/html"),
"应返回 HTML: {content_type}"
);
assert!(body.contains(r#"<div id="root">"#), "应返回前端入口页");
}
/// 条目详情地址(含分组)同样回落:`/c/` 前缀下的任意层级都是前端路由。
#[tokio::test]
async fn item_routes_fall_back_to_the_embedded_index() {
let (status, _, body) = get("/c/apps/common/i/github").await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains(r#"<div id="root">"#));
// 本地项目:条目 id 是项目名,可能带空格 / 中文,照样要能落到入口页
let (status, _, _) = get("/c/local/local-all/i/demo").await;
assert_eq!(status, StatusCode::OK);
}
/// 托管目录里的真实文件优先于回落,不能被前端路由吞掉。
#[tokio::test]
async fn hosted_files_still_win_over_the_spa_fallback() {
let (status, _, body) = get("/demo/").await;
assert_eq!(status, StatusCode::OK);
assert!(
body.contains("<title>demo</title>"),
"应直接返回项目入口页: {body}"
);
let (status, _, body) = get("/note.txt").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, "plain file");
}
/// 不是前端路由的路径仍然返回 404 页面(带请求路径,便于排错)。
#[tokio::test]
async fn unknown_paths_still_return_the_404_page() {
let (status, content_type, body) = get("/missing.txt").await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(content_type.starts_with("text/html"));
assert!(body.contains("404"));
assert!(body.contains("/missing.txt"), "404 页面应显示请求路径");
}
/// API 不能被回落影响。
#[tokio::test]
async fn api_routes_are_not_swallowed_by_the_fallback() {
let (status, content_type, body) = get("/api/health").await;
assert_eq!(status, StatusCode::OK);
assert!(content_type.starts_with("application/json"));
assert!(body.contains("\"status\":\"ok\""));
}
/// 只接管登记过的前缀:`/c` 本身与 `/css/...` 都还是普通路径。
#[test]
fn only_registered_prefixes_fall_back() {
assert!(is_spa_path("/c/apps"));
assert!(is_spa_path("/c/"));
assert!(!is_spa_path("/c"));
assert!(!is_spa_path("/css/app.css"));
}
}
+108 -31
View File
@@ -17,6 +17,7 @@ import {
import { filterCategories } from './data/filter'
import type { NavTarget } from './data/nav'
import {
type Category,
findAppItem,
findArticleItem,
findPosterItem,
@@ -25,25 +26,17 @@ import {
} from './data/types'
import { useProjects } from './useProjects'
import { useCatalog } from './useCatalog'
import { useViewRoute } from './useViewRoute'
import type { ItemRoute, View } from './data/view'
import './App.css'
/**
* 当前展示的视图:
* - home:首页分区卡片流
* - detail:某个分类(可指定分组)的明细
* - item:单个条目(应用或电影)的详情,returnTo 记录返回目标
*/
type View =
| { type: 'home' }
| { type: 'detail'; categoryId: string; groupId?: string }
| { type: 'item'; categoryId: string; itemId: string; returnTo: View }
export default function App() {
const { status, projects, error, live, refreshing, refresh } = useProjects()
// 内容目录来自后端 SQLite 内容库;本地服务分类仍由托管项目动态生成
const catalog = useCatalog()
const [keyword, setKeyword] = useState('')
const [view, setView] = useState<View>({ type: 'home' })
// 当前视图由地址栏决定:刷新 / 收藏 / 前进后退都回到同一页(见 useViewRoute.ts)
const { view, go, back } = useViewRoute()
const [scrolled, setScrolled] = useState(false)
const searchRef = useRef<HTMLInputElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
@@ -57,6 +50,15 @@ export default function App() {
[categories, keyword],
)
// 侧栏不显示「本地服务」,也不显示被标记 show_in_sidebar = 0 的分区
const sidebarCategories = useMemo(
() =>
categories.filter(
(category) => category.id !== 'local' && category.showInSidebar !== false,
),
[categories],
)
const detailView = view.type === 'detail' ? view : undefined
const itemView = view.type === 'item' ? view : undefined
@@ -119,18 +121,81 @@ export default function App() {
return () => window.removeEventListener('scroll', sync)
}, [])
const openDetail = (categoryId: string) => setView({ type: 'detail', categoryId })
/**
* 「打开这个分区」应该落到哪个视图。
*
* 应用类分区配了首页展示块时,直接落到那个分组;其余情况打开整个分区。
* 这样卡片上的「···」和分区标题都通向卡片里实际展示的那块内容。
*/
const detailViewFor = (categoryId: string): View => {
const category = categories.find((item) => item.id === categoryId)
const openItem = (categoryId: string, itemId: string, returnTo: View) =>
setView({ type: 'item', categoryId, itemId, returnTo })
if (category?.kind === 'apps' && category.block !== undefined) {
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
if (group !== undefined) {
return { type: 'detail', categoryId, groupId: group.id }
}
}
const navigate = (target: NavTarget) =>
setView({ type: 'detail', categoryId: target.categoryId, groupId: target.groupId })
return { type: 'detail', categoryId }
}
const goHome = () => setView({ type: 'home' })
const openDetail = (categoryId: string) => go(detailViewFor(categoryId))
/** 条目所在的分组:应用按组组织,海报 / 图文直接挂在分区下 */
const groupOfItem = (category: Category, itemId: string): string | undefined => {
if (category.kind !== 'apps') return undefined
return category.groups.find((group) => group.items.some((item) => item.id === itemId))?.id
}
const openItem = (categoryId: string, itemId: string) => {
const category = categories.find((item) => item.id === categoryId)
go({
type: 'item',
categoryId,
itemId,
// 地址里带上条目自己的分组,刷新后「返回」才有确定的落点
groupId: category === undefined ? undefined : groupOfItem(category, itemId),
})
}
/**
* 条目详情的「返回」目标:优先回到该条目所在的分组,
* 没有分组(海报 / 图文)时回到分区,走和侧栏一样的展示块规则。
*/
const detailTargetFor = (item: ItemRoute): View =>
item.groupId === undefined
? detailViewFor(item.categoryId)
: { type: 'detail', categoryId: item.categoryId, groupId: item.groupId }
/**
* 侧栏导航:带分组时直接打开那个分组;
* 点分区本身时,交给 openDetail 按展示块落到对应节点。
*/
const navigate = (target: NavTarget) => {
if (target.groupId === undefined) {
openDetail(target.categoryId)
return
}
go({ type: 'detail', categoryId: target.categoryId, groupId: target.groupId })
}
const goHome = () => go({ type: 'home' })
const searching = keyword.trim() !== ''
const viewCategoryId = detailView?.categoryId ?? itemView?.categoryId
/**
* 首屏数据还没回来时,地址指向的分类 / 条目只是「暂时查不到」,
* 不能直接报「不存在」——刷新详情页时最容易被这个误导。
*/
const dataPending =
viewCategoryId !== undefined &&
(catalog.status === 'loading' || (viewCategoryId === 'local' && status === 'loading'))
/**
* 条目详情按分类类型分流:
* 图文走长文版式,应用 / 电影走通用的条目版式。
@@ -138,26 +203,38 @@ export default function App() {
function renderItemView() {
if (itemView === undefined) return null
if (itemCategory === undefined) {
return <p className="section-empty">分类不存在</p>
return (
<p className="section-empty">
{dataPending ? '正在读取内容库…' : '分类不存在'}
</p>
)
}
if (itemCategory.kind === 'articles') {
const article = findArticleItem(itemCategory, itemView.itemId)
if (article === undefined) {
return <p className="section-empty">条目不存在</p>
return (
<p className="section-empty">
{dataPending ? '正在读取内容库…' : '条目不存在'}
</p>
)
}
return (
<ArticleDetailView
article={article}
categoryTitle={itemCategory.title}
onBack={() => setView(itemView.returnTo)}
onBack={() => back(detailTargetFor(itemView))}
/>
)
}
if (detailModel === undefined) {
return <p className="section-empty">条目不存在</p>
return (
<p className="section-empty">
{dataPending ? '正在读取内容库…' : '条目不存在'}
</p>
)
}
return (
@@ -165,8 +242,8 @@ export default function App() {
breadcrumb={`${itemCategory.title} / ${detailModel.title}`}
model={detailModel}
related={relatedItems}
onBack={() => setView(itemView.returnTo)}
onOpenItem={(itemId) => setView({ ...itemView, itemId })}
onBack={() => back(detailTargetFor(itemView))}
onOpenItem={(itemId) => openItem(itemCategory.id, itemId)}
/>
)
}
@@ -190,11 +267,11 @@ export default function App() {
<div className="layout">
<Sidebar
projects={projects}
nav={catalog.nav}
categories={sidebarCategories}
live={live}
refreshing={refreshing}
onRefresh={refresh}
activeCategoryId={detailView?.categoryId ?? itemView?.categoryId}
activeCategoryId={viewCategoryId}
activeGroupId={detailView?.groupId}
onNavigate={navigate}
/>
@@ -244,9 +321,7 @@ export default function App() {
<HomeView
categories={visibleCategories}
onOpenDetail={openDetail}
onOpenItem={(categoryId, itemId) =>
openItem(categoryId, itemId, { type: 'home' })
}
onOpenItem={openItem}
/>
{searching && visibleCategories.length === 0 && (
@@ -263,12 +338,14 @@ export default function App() {
category={detailCategory}
groupId={detailView.groupId}
onBack={goHome}
onOpenItem={(itemId) => openItem(detailCategory.id, itemId, detailView)}
onOpenItem={(itemId) => openItem(detailCategory.id, itemId)}
/>
)}
{detailView !== undefined && detailCategory === undefined && (
<p className="section-empty">分类不存在</p>
<p className="section-empty">
{dataPending ? '正在读取内容库…' : '分类不存在'}
</p>
)}
{itemView !== undefined && renderItemView()}
+1 -14
View File
@@ -1,4 +1,3 @@
import type { NavModel } from './data/nav'
import type { Category } from './data/types'
export type Project = {
@@ -79,28 +78,17 @@ const CATALOG_EVENTS_ENDPOINT = '/api/catalog/events'
type CatalogPayload = {
categories?: Category[]
nav?: NavModel
}
/** 内容目录的完整返回:分类树 + 侧栏导航 */
/** 内容目录的完整返回;侧栏由这份分类树派生 */
export type Catalog = {
categories: Category[]
nav: NavModel
}
/** 服务端字段可能缺失,统一在这里补成空数组,组件层就不用判空 */
function normalizeNav(nav: NavModel | undefined): NavModel {
return {
topLevel: Array.isArray(nav?.topLevel) ? nav.topLevel : [],
groups: Array.isArray(nav?.groups) ? nav.groups : [],
}
}
/**
* 拉取完整内容目录。
*
* 数据量只有几十 KB,一次全量取回即可;搜索仍然在前端按关键字过滤。
* 分类与导航一起下发,因为首页和侧栏同时需要它们。
*/
export async function fetchCatalog(signal?: AbortSignal): Promise<Catalog> {
const response = await fetch(CATALOG_ENDPOINT, {
@@ -116,7 +104,6 @@ export async function fetchCatalog(signal?: AbortSignal): Promise<Catalog> {
return {
categories: Array.isArray(payload.categories) ? payload.categories : [],
nav: normalizeNav(payload.nav),
}
}
-4
View File
@@ -58,10 +58,6 @@ export function ArrowUpIcon({ className }: IconProps) {
return <i className={base('arrow-up', className)} aria-hidden="true" />
}
export function ArrowLeftIcon({ className }: IconProps) {
return <i className={base('arrow-left', className)} aria-hidden="true" />
}
export function ArrowDownIcon({ className }: IconProps) {
return <i className={base('arrow-down', className)} aria-hidden="true" />
}
@@ -0,0 +1,133 @@
import type { ReactNode } from 'react'
/**
* 极简 Markdown 渲染:只覆盖文章正文实际用到的子集。
*
* 支持:
* - `## 小标题`(1~6 级井号都渲染成同一级小标题,标题层级由版式决定)
* - 空行分段
* - 单独一行的 `![alt](url)` 作为配图
* - 行内 `**粗体**`、`` `代码` ``、`[文字](链接)`
*
* 不支持列表、表格、引用块——这里渲染的是文章正文,不是通用 Markdown 编辑器。
* 需要完整语法时再换 react-markdown,渲染层已经隔离在这个组件里。
*/
type Block =
| { kind: 'heading'; text: string }
| { kind: 'image'; alt: string; src: string }
| { kind: 'paragraph'; text: string }
/** 行内语法:粗体 / 代码 / 链接;其余按纯文本处理 */
const INLINE_PATTERN = /(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g
/** 把 Markdown 文本切成块;规则尽量简单,宁可少解析也不误伤正文 */
function parseBlocks(markdown: string): Block[] {
const blocks: Block[] = []
const lines = markdown.replace(/\r\n/g, '\n').split('\n')
let paragraph: string[] = []
// 空行或遇到标题/图片时,把攒着的行收成一个段落
const flush = () => {
if (paragraph.length === 0) return
blocks.push({ kind: 'paragraph', text: paragraph.join(' ') })
paragraph = []
}
for (const raw of lines) {
const line = raw.trim()
if (line === '') {
flush()
continue
}
const heading = /^#{1,6}\s+(.*)$/.exec(line)
if (heading !== null) {
flush()
blocks.push({ kind: 'heading', text: heading[1] })
continue
}
const image = /^!\[([^\]]*)\]\(([^)\s]+)\)$/.exec(line)
if (image !== null) {
flush()
blocks.push({ kind: 'image', alt: image[1], src: image[2] })
continue
}
paragraph.push(line)
}
flush()
return blocks
}
/** 渲染一行里的行内语法 */
function renderInline(text: string): ReactNode[] {
return text
.split(INLINE_PATTERN)
.filter((part) => part !== '')
.map((part, index) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={index}>{part.slice(2, -2)}</strong>
}
if (part.startsWith('`') && part.endsWith('`')) {
return <code key={index}>{part.slice(1, -1)}</code>
}
const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(part)
if (link !== null) {
return (
<a key={index} href={link[2]} target="_blank" rel="noreferrer">
{link[1]}
</a>
)
}
return <span key={index}>{part}</span>
})
}
type ArticleBodyProps = {
/** Markdown 正文;为空时组件渲染空内容 */
markdown?: string
}
export function ArticleBody({ markdown }: ArticleBodyProps) {
if (markdown === undefined || markdown.trim() === '') return null
return (
<>
{parseBlocks(markdown).map((block, index) => {
if (block.kind === 'heading') {
return (
<h3 className="article-body-heading" key={index}>
{renderInline(block.text)}
</h3>
)
}
if (block.kind === 'image') {
return (
<img
className="article-body-image"
key={index}
src={block.src}
alt={block.alt}
loading="lazy"
/>
)
}
return (
<p className="article-body-text" key={index}>
{renderInline(block.text)}
</p>
)
})}
</>
)
}
@@ -1,12 +1,12 @@
import type { ArticleItem } from '../../data/types'
import { ArrowLeftIcon } from '../common/Icon'
import { ArticleBody } from './ArticleBody'
/**
* 文章详情页(对应模板图二):
* 大标题 + [编辑] + 元信息行 + 白色正文卡(段落 / 小标题 / 配图混排)。
* 大标题 + [编辑] + 元信息行 + 白色正文卡。
*
* 正文是 block 数组,后端以后可以直接把富文本结构映射过来,
* 不用再改渲染层。
* 正文是一整段 Markdown,交给 ArticleBody 解析渲染;
* 渲染细节(支持哪些语法)都收在那一个组件里。
*/
type ArticleDetailViewProps = {
article: ArticleItem
@@ -21,7 +21,6 @@ export function ArticleDetailView({ article, categoryTitle, onBack }: ArticleDet
<div className="detail-view article-view">
<div className="detail-nav card-box">
<button type="button" className="detail-back" onClick={onBack}>
<ArrowLeftIcon />
返回
</button>
<span className="detail-breadcrumb">{categoryTitle} / 文章</span>
@@ -46,33 +45,7 @@ export function ArticleDetailView({ article, categoryTitle, onBack }: ArticleDet
</header>
<article className="article-body card-box">
{(article.blocks ?? []).map((block, index) => {
if (block.type === 'heading') {
return (
<h3 className="article-body-heading" key={index}>
{block.text}
</h3>
)
}
if (block.type === 'image') {
return (
<img
className="article-body-image"
key={index}
src={block.src}
alt=""
loading="lazy"
/>
)
}
return (
<p className="article-body-text" key={index}>
{block.text}
</p>
)
})}
<ArticleBody markdown={article.body} />
</article>
</div>
)
@@ -1,5 +1,4 @@
import { categoryCount, type Category } from '../../data/types'
import { ArrowLeftIcon } from '../common/Icon'
import { AppGrid } from './AppGrid'
import { CesCardGrid } from './CesCardGrid'
import { GroupBlock } from './GroupBlock'
@@ -68,7 +67,6 @@ export function DetailView({ category, groupId, onBack, onOpenItem }: DetailView
<div className="detail-view">
<div className="detail-nav card-box">
<button type="button" className="detail-back" onClick={onBack}>
<ArrowLeftIcon />
返回首页
</button>
<span className="detail-breadcrumb">{breadcrumb}</span>
+28 -3
View File
@@ -22,8 +22,10 @@ export function HomeView({ categories, onOpenDetail, onOpenItem }: HomeViewProps
{categories.map((category) => (
<SectionCard
key={category.id}
title={category.title}
count={categoryCount(category)}
// 卡片标题跟着实际展示的内容走:配了展示块就用块标题(如「常用站点」),
// 没配(如「本地服务」)才用分类标题
title={category.block?.title ?? category.title}
count={cardCount(category)}
barColor={category.barColor}
onMore={() => onOpenDetail(category.id)}
>
@@ -34,13 +36,36 @@ export function HomeView({ categories, onOpenDetail, onOpenItem }: HomeViewProps
)
}
/**
* 卡片右上角的数量角标。
*
* 配了「分组展示块」时,卡片实际展示的是那个分组,所以数量也应该只数它;
* 否则(电影 / 文章 / 本地服务)数整个分区。
*/
function cardCount(category: Category): number {
if (category.kind === 'apps' && category.block !== undefined) {
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
if (group !== undefined) return group.items.length
}
return categoryCount(category)
}
/** 按分类类型输出对应的预览网格;应用类为空时给出提示文案 */
function renderPreview(
category: Category,
onOpenItem: (categoryId: string, itemId: string) => void,
) {
if (category.kind === 'apps') {
const items = flattenAppItems(category).slice(0, category.previewLimit ?? 4)
// 配了展示块就只展示那个分组;没有配置(如「本地服务」)则退回全部条目
const blockGroup =
category.block !== undefined
? category.groups.find((group) => group.id === category.block?.nodeKey)
: undefined
const source = category.block !== undefined ? (blockGroup?.items ?? []) : flattenAppItems(category)
const limit = category.block?.previewLimit ?? category.previewLimit ?? 4
const items = source.slice(0, limit)
if (items.length === 0) {
return <p className="section-empty">{category.emptyText ?? '暂无内容'}</p>
@@ -1,7 +1,6 @@
import type { ItemDetailModel } from '../../data/detail'
import type { RelatedItem } from '../../data/types'
import { ExternalIcon, EyeIcon, ShareIcon, ThumbUpIcon } from '../common/Icon'
import { ArrowLeftIcon } from '../common/Icon'
import { RelatedList } from './RelatedList'
/**
@@ -30,7 +29,6 @@ export function ItemDetailView({
<div className="detail-view item-view">
<div className="detail-nav card-box">
<button type="button" className="detail-back" onClick={onBack}>
<ArrowLeftIcon />
返回
</button>
<span className="detail-breadcrumb">{breadcrumb}</span>
@@ -1,23 +1,28 @@
import { useState } from 'react'
import type { NavModel, NavTarget } from '../../data/nav'
import type { NavTarget } from '../../data/nav'
import type { Category } from '../../data/types'
/**
* 分类导航树:顶层直达链接 + 可折叠分组。
* 分类导航树:直接由内容树派生,不再读独立的导航数据。
*
* 导航内容来自内容库(`nav_entry` 表),由父组件透传进来;
* 点击叶子节点不跳转页面,而是把目标(分类 + 可选分组)交给 onNavigate,
* 由父组件切换右侧的明细视图。
* - 有分组的分类(apps)渲染成可折叠节点,子项是分组(分组可用 show_in_sidebar 单独隐藏);
* - 没有分组、或分组全被隐藏的分类,直接是链接。
*/
type CategoryNavProps = {
/** 侧栏导航数据;内容库为空时是空模型 */
nav: NavModel
/** 内容目录;顺序即侧栏顺序 */
categories: Category[]
/** 当前明细视图的分类 / 分组,用于高亮 */
activeCategoryId?: string
activeGroupId?: string
onNavigate: (target: NavTarget) => void
}
export function CategoryNav({ nav, activeCategoryId, activeGroupId, onNavigate }: CategoryNavProps) {
export function CategoryNav({
categories,
activeCategoryId,
activeGroupId,
onNavigate,
}: CategoryNavProps) {
const [openGroups, setOpenGroups] = useState<string[]>([])
const toggle = (id: string) => {
@@ -31,52 +36,62 @@ export function CategoryNav({ nav, activeCategoryId, activeGroupId, onNavigate }
return (
<nav className="nav-card card-box" aria-label="站点分类">
<ul className="nav-group">
{nav.topLevel.map((leaf) => (
<li className="nav-item" key={leaf.id}>
<button
type="button"
className={`nav-link${
activeCategoryId === leaf.target.categoryId && activeGroupId === undefined
? ' active'
: ''
}`}
onClick={() => onNavigate(leaf.target)}
>
<span>{leaf.label}</span>
</button>
</li>
))}
{categories.map((category) => {
// 只显示允许出现在侧栏的分组
const groups =
category.kind === 'apps'
? category.groups.filter((group) => group.showInSidebar !== false)
: []
{nav.groups.map((group) => (
<li className={`nav-item${isOpen(group.id) ? ' open' : ''}`} key={group.id}>
<button
type="button"
className="nav-link has-children"
aria-expanded={isOpen(group.id)}
onClick={() => toggle(group.id)}
>
<span>{group.label}</span>
</button>
// 没有分组的分类:整行就是一个链接
if (groups.length === 0) {
return (
<li className="nav-item" key={category.id}>
<button
type="button"
className={`nav-link${
activeCategoryId === category.id && activeGroupId === undefined ? ' active' : ''
}`}
onClick={() => onNavigate({ categoryId: category.id })}
>
<span>{category.title}</span>
</button>
</li>
)
}
{/* 用 grid-template-rows 做高度动画:不需要猜 max-height,
也不会在过渡期间出现滚动条 */}
<div className={`submenu-wrap${isOpen(group.id) ? ' is-open' : ''}`}>
<ul className="submenu">
{group.leaves.map((leaf) => (
<li key={leaf.id}>
<button
type="button"
className={`nav-link${activeGroupId === leaf.target.groupId ? ' active' : ''}`}
onClick={() => onNavigate(leaf.target)}
>
<span>{leaf.label}</span>
</button>
</li>
))}
</ul>
</div>
</li>
))}
// 有分组:分区可折叠,分组是真正的链接
return (
<li className={`nav-item${isOpen(category.id) ? ' open' : ''}`} key={category.id}>
<button
type="button"
className="nav-link has-children"
aria-expanded={isOpen(category.id)}
onClick={() => toggle(category.id)}
>
<span>{category.title}</span>
</button>
{/* 用 grid-template-rows 做高度动画:不需要猜 max-height,
也不会在过渡期间出现滚动条 */}
<div className={`submenu-wrap${isOpen(category.id) ? ' is-open' : ''}`}>
<ul className="submenu">
{groups.map((group) => (
<li key={group.id}>
<button
type="button"
className={`nav-link${activeGroupId === group.id ? ' active' : ''}`}
onClick={() => onNavigate({ categoryId: category.id, groupId: group.id })}
>
<span>{group.name}</span>
</button>
</li>
))}
</ul>
</div>
</li>
)
})}
</ul>
</nav>
)
+6 -5
View File
@@ -1,5 +1,6 @@
import type { Project } from '../../api'
import type { NavModel, NavTarget } from '../../data/nav'
import type { NavTarget } from '../../data/nav'
import type { Category } from '../../data/types'
import { CategoryNav } from './CategoryNav'
import { ProfileCard } from './ProfileCard'
import { ServicePanel } from './ServicePanel'
@@ -10,8 +11,8 @@ import { ServicePanel } from './ServicePanel'
*/
type SidebarProps = {
projects: Project[]
/** 侧栏导航数据,来自内容库 */
nav: NavModel
/** 内容目录;侧栏直接由它派生 */
categories: Category[]
live: boolean
refreshing: boolean
onRefresh: () => void
@@ -22,7 +23,7 @@ type SidebarProps = {
export function Sidebar({
projects,
nav,
categories,
live,
refreshing,
onRefresh,
@@ -42,7 +43,7 @@ export function Sidebar({
/>
<CategoryNav
nav={nav}
categories={categories}
activeCategoryId={activeCategoryId}
activeGroupId={activeGroupId}
onNavigate={onNavigate}
+4 -2
View File
@@ -17,12 +17,14 @@ export type HostedProject = {
/**
* 把托管项目映射成应用条目。
*
* id 直接用路由地址,保证同一项目在前端各处(首页卡片、明细、详情)唯一。
* id 用项目名(就是目录名):它在前端各处(首页卡片、明细、详情)唯一,
* 又正好是可以直接放进地址栏的一段——条目详情地址是 `/c/local/<分组>/i/<项目名>`。
* 访问地址仍然用 `route_path`(`/demo/`),两者不再混用。
* 本地项目没有封面图,详情页会退化成字符头像。
*/
function toLocalItem(project: HostedProject): AppItem {
return {
id: project.route_path,
id: project.name,
name: project.name,
desc: project.route_path,
icon: project.name.slice(0, 1).toUpperCase(),
+3 -26
View File
@@ -1,8 +1,8 @@
/**
* 侧栏分类导航的数据结构。
* 导航目标。
*
* 导航数据存在内容库的 `nav_entry` 表里(自引用树),随 `/api/catalog` 一起下发;
* 这里只保留类型定义和空模型兜底,不再有写死的导航常量。
* 侧栏现在直接由内容树(分区 → 分组)派生,不再有独立的导航数据模型;
* 这里只保留「点击后要去哪」的最小类型。
*/
export type NavTarget = {
@@ -10,26 +10,3 @@ export type NavTarget = {
/** 指定后明细页只展示该分组 */
groupId?: string
}
/** 一个可点击的导航叶子 */
export type NavLeaf = {
id: string
label: string
target: NavTarget
}
/** 可折叠分组:本身不可点击,只负责收纳叶子 */
export type NavGroupNode = {
id: string
label: string
leaves: NavLeaf[]
}
/** 侧栏的完整数据模型:顶层直达项 + 可折叠分组 */
export type NavModel = {
topLevel: NavLeaf[]
groups: NavGroupNode[]
}
/** 内容库还没有导航数据时的兜底,避免渲染时到处判空 */
export const EMPTY_NAV: NavModel = { topLevel: [], groups: [] }
+27 -9
View File
@@ -30,10 +30,12 @@ export type AppItem = {
likes?: number
}
/** 应用分组:明细页按组展示,首页取所有分组的前几项做预览 */
/** 应用分组:明细页按组展示,首页按展示块取其中一组做预览 */
export type AppGroup = {
id: string
name: string
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
items: AppItem[]
}
@@ -63,12 +65,6 @@ export type RelatedItem = {
icon?: string
}
/** 文章正文块:段落 / 小标题 / 配图 */
export type ArticleBlock =
| { type: 'paragraph'; text: string }
| { type: 'heading'; text: string }
| { type: 'image'; src: string }
/** 图文条目(报道) */
export type ArticleItem = {
id: string
@@ -77,8 +73,18 @@ export type ArticleItem = {
cover: string
/** 详情页标题下的元信息,如 ['2 年前', 'CES 现场报道'] */
meta?: string[]
/** 详情页正文 */
blocks?: ArticleBlock[]
/** 详情页正文:一整段 Markdown(一篇一段文本,不再拆块) */
body?: string
}
/** 首页展示块:分区卡片展示哪个节点、显示几条 */
export type SectionBlock = {
/** 要展示的节点业务键:通常是分组;电影/文章这类无分组的填分区自己 */
nodeKey: string
/** 块标题,缺省用节点标题 */
title?: string
/** 卡片显示几条,缺省用分类的 previewLimit 或默认值 */
previewLimit?: number
}
/** 应用类分类:内部按分组组织 */
@@ -95,6 +101,10 @@ export type AppCategory = {
previewLimit?: number
/** 首页预览为空时的提示文案 */
emptyText?: string
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
/** 首页卡片展示哪一块内容 */
block?: SectionBlock
}
/** 海报类分类:竖版海报网格 */
@@ -107,6 +117,10 @@ export type PosterCategory = {
items: PosterItem[]
/** 首页预览条数,默认 6 */
previewLimit?: number
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
/** 首页卡片展示哪一块内容 */
block?: SectionBlock
}
/** 图文类分类:上图下文卡片网格 */
@@ -119,6 +133,10 @@ export type ArticleCategory = {
items: ArticleItem[]
/** 首页预览条数,默认 4 */
previewLimit?: number
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
/** 首页卡片展示哪一块内容 */
block?: SectionBlock
}
export type Category = AppCategory | PosterCategory | ArticleCategory
+124
View File
@@ -0,0 +1,124 @@
/**
* 视图与地址的对应关系:地址栏是唯一的视图状态源。
*
* 控制台是单页应用,但「当前在哪一页」写在 URL 里,所以刷新、收藏、
* 前进后退都不会丢上下文。服务端对应的规则在 `src/server.rs`:
* `/c/**` 没有实体文件时回落到内嵌的 `index.html`,由前端按 pathname 重新出页面。
*
* 路由形状(各段都是业务键,也就是接口 JSON 里的 `id`):
*
* / 首页
* /c/<分区> 分类明细
* /c/<分区>/<分组> 只展示该分组
* /c/<分区>/i/<条目> 条目详情,返回 → 分类明细
* /c/<分区>/<分组>/i/<条目> 条目详情,返回 → 该分组
*
* 条目地址带上它自己所在的分组,URL 才是自解释的:刷新之后点「返回」
* 仍然落回条目所属的分组,而不是去猜一个返回目标。
*
* 这里只做「地址 ↔ 视图」的纯转换,读写 history 的部分在 `useViewRoute.ts`。
*/
export type DetailRoute = {
type: 'detail'
categoryId: string
/** 指定后明细页只展示该分组 */
groupId?: string
}
export type ItemRoute = {
type: 'item'
categoryId: string
itemId: string
/**
* 条目所在的分组(应用才有)。
*
* 它只描述「条目在哪」,不描述「从哪来」:返回目标由它推导,
* 这样同一个条目永远只有一个地址,刷新和分享都不会跑偏。
*/
groupId?: string
}
export type View =
| { type: 'home' }
| DetailRoute
| ItemRoute
/** 前端路由前缀;改动这里要同步 `src/server.rs` 的 `SPA_PREFIXES`。 */
const CATEGORY_PREFIX = '/c/'
/** 前缀去掉两侧斜杠后的那一段,拼地址时当第一个路径段用 */
const CATEGORY_SEGMENT = CATEGORY_PREFIX.slice(1, -1)
/** 条目段标记:`/c/<分区>[/<分组>]/i/<条目>` */
const ITEM_MARK = 'i'
/** 首页视图;未知地址一律归到这里 */
export const HOME_VIEW: View = { type: 'home' }
/** 把视图转成地址。 */
export function viewPath(view: View): string {
if (view.type === 'home') return '/'
const segments = [CATEGORY_SEGMENT, encodeSegment(view.categoryId)]
if (view.groupId !== undefined) segments.push(encodeSegment(view.groupId))
if (view.type === 'item') segments.push(ITEM_MARK, encodeSegment(view.itemId))
return `/${segments.join('/')}`
}
/**
* 把地址解析成视图;看不懂的地址(段数不对、百分号编码非法)按首页处理。
*
* 这里只做形状校验,不判断分类 / 条目是否真的存在——那是数据层的事,
* 而且首屏还没拿到内容库时,它们本来就「暂时不存在」。
*/
export function parseViewPath(pathname: string): View {
if (!pathname.startsWith(CATEGORY_PREFIX)) return HOME_VIEW
const segments = pathname
.slice(CATEGORY_PREFIX.length)
.split('/')
.filter((segment) => segment !== '')
const [categorySegment] = segments
if (categorySegment === undefined) return HOME_VIEW
const categoryId = decodeSegment(categorySegment)
if (categoryId === undefined) return HOME_VIEW
if (segments.length === 1) return { type: 'detail', categoryId }
if (segments.length === 2) {
const groupId = decodeSegment(segments[1])
if (groupId === undefined) return HOME_VIEW
return { type: 'detail', categoryId, groupId }
}
if (segments.length === 3 && segments[1] === ITEM_MARK) {
const itemId = decodeSegment(segments[2])
if (itemId === undefined) return HOME_VIEW
return { type: 'item', categoryId, itemId }
}
if (segments.length === 4 && segments[2] === ITEM_MARK) {
const groupId = decodeSegment(segments[1])
const itemId = decodeSegment(segments[3])
if (groupId === undefined || itemId === undefined) return HOME_VIEW
return { type: 'item', categoryId, itemId, groupId }
}
return HOME_VIEW
}
/** 单个路径段编码:id 里可能带斜杠、空格、中文 */
function encodeSegment(value: string): string {
return encodeURIComponent(value)
}
/** 解码单个路径段;百分号编码非法时返回 undefined */
function decodeSegment(segment: string): string | undefined {
try {
return decodeURIComponent(segment)
} catch {
return undefined
}
}
-6
View File
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { fetchCatalog, subscribeCatalog } from './api'
import { EMPTY_NAV, type NavModel } from './data/nav'
import type { Category } from './data/types'
export type CatalogStatus = 'loading' | 'ready' | 'error'
@@ -8,8 +7,6 @@ export type CatalogStatus = 'loading' | 'ready' | 'error'
export type CatalogState = {
status: CatalogStatus
categories: Category[]
/** 侧栏导航;与分类同一次请求下发 */
nav: NavModel
error: string | null
}
@@ -32,7 +29,6 @@ export function useCatalog(): CatalogController {
const [state, setState] = useState<CatalogState>({
status: 'loading',
categories: [],
nav: EMPTY_NAV,
error: null,
})
const controllerRef = useRef<AbortController | null>(null)
@@ -54,7 +50,6 @@ export function useCatalog(): CatalogController {
setState({
status: 'ready',
categories: catalog.categories,
nav: catalog.nav,
error: null,
})
}
@@ -65,7 +60,6 @@ export function useCatalog(): CatalogController {
// 已经有内容时只提示错误,不清空列表
status: previous.categories.length > 0 ? 'ready' : 'error',
categories: previous.categories,
nav: previous.nav,
error: toErrorMessage(error),
}))
}
+105
View File
@@ -0,0 +1,105 @@
import { useCallback, useEffect, useState } from 'react'
import { parseViewPath, viewPath, type View } from './data/view'
/**
* 视图 ↔ 地址同步。
*
* 三个入口都要覆盖:首屏(解析地址)、点击(写地址)、浏览器前进后退(popstate)。
* 视图本身仍然放在 React state 里,地址只是它的镜像,避免每次渲染都去读 URL;
* 地址与视图的换算规则收在 `data/view.ts`,这里只管 history。
*/
/** 存在 history.state 里的键:当前这条记录是应用内第几页(0 = 没有上一页) */
const HISTORY_INDEX_KEY = 'stashIndex'
type StashHistoryState = { [HISTORY_INDEX_KEY]?: number }
/** 读取当前历史深度;不是本应用写的记录一律当作 0 */
function historyIndex(): number {
const state = window.history.state as StashHistoryState | null
const index = state === null ? undefined : state[HISTORY_INDEX_KEY]
return typeof index === 'number' ? index : 0
}
/** 当前 history.state 的副本,写入前先保留别人(如浏览器扩展)放的字段 */
function currentHistoryState(): StashHistoryState {
const state = window.history.state as StashHistoryState | null
return state === null ? {} : { ...state }
}
export type ViewRoute = {
/** 当前视图,初值来自地址栏 */
view: View
/** 打开一个视图:默认压一条新历史,`replace` 表示替换当前这条 */
go: (view: View, options?: { replace?: boolean }) => void
/**
* 返回。
*
* 应用内有上一页时交给浏览器(`history.back()`),
* 直接打开条目地址(收藏 / 刷新后没有上一页)时退到 `fallback`,
* 这时它替换当前记录,避免在历史里留下一条「自己指向自己」的条目。
*/
back: (fallback: View) => void
}
export function useViewRoute(): ViewRoute {
const [view, setView] = useState<View>(() => parseViewPath(window.location.pathname))
// 首屏把地址规范化:去掉多余的斜杠 / 尾部斜杠,未知地址归到首页,
// 并补上历史深度(直接打开时是 0,说明应用内没有上一页)
useEffect(() => {
const canonical = viewPath(parseViewPath(window.location.pathname))
const state = currentHistoryState()
const recorded = state[HISTORY_INDEX_KEY] !== undefined
if (canonical === window.location.pathname && recorded) return
window.history.replaceState({ ...state, [HISTORY_INDEX_KEY]: historyIndex() }, '', canonical)
}, [])
// 前进后退:地址变了,视图跟着变
useEffect(() => {
const handlePopState = () => setView(parseViewPath(window.location.pathname))
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [])
const go = useCallback((next: View, options?: { replace?: boolean }) => {
const path = viewPath(next)
const index = historyIndex()
// 已经在同一页(例如重复点同一个侧栏项):不写历史,也不改深度
if (path === window.location.pathname) {
setView(next)
return
}
const replace = options?.replace === true
const state: StashHistoryState = {
...currentHistoryState(),
[HISTORY_INDEX_KEY]: replace ? index : index + 1,
}
if (replace) {
window.history.replaceState(state, '', path)
} else {
window.history.pushState(state, '', path)
}
setView(next)
}, [])
const back = useCallback(
(fallback: View) => {
if (historyIndex() > 0) {
window.history.back()
return
}
go(fallback, { replace: true })
},
[go],
)
return { view, go, back }
}