feat(web): 地址栏驱动视图,刷新详情页不再回首页
- 新增 data/view.ts 与 useViewRoute:视图与地址双向同步,支持前进后退 - 条目地址带上所属分组,刷新 / 收藏后「返回」有确定落点 - 服务端只把 /c/** 回落到 index.html,其余路径仍返回 404 - 明细页返回按钮去掉左侧箭头图标
This commit is contained in:
@@ -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,渲染层只支持正文实际用到的子集:
|
||||
|
||||
| 写法 | 效果 |
|
||||
| --- | --- |
|
||||
| 空行分段 | 一个段落 |
|
||||
| `## 小标题` | 正文小标题(`#`~`######` 都渲染成同级) |
|
||||
| 单独一行 `` | 配图 |
|
||||
| `**粗体**`、`` `代码` ``、`[文字](链接)` | 行内格式 |
|
||||
|
||||
列表、表格、引用块暂不支持;需要完整语法时把 [`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
@@ -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(§ion.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) = §ion.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([§ion.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, §ion.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, §ion.key)?;
|
||||
let tags = load_tags(conn, §ion.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, §ion.key)?;
|
||||
let tags = load_tags(conn, §ion.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
@@ -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
@@ -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 §ion.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, §ion.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
File diff suppressed because it is too large
Load Diff
+209
-34
@@ -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
@@ -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
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 级井号都渲染成同一级小标题,标题层级由版式决定)
|
||||
* - 空行分段
|
||||
* - 单独一行的 `` 作为配图
|
||||
* - 行内 `**粗体**`、`` `代码` ``、`[文字](链接)`
|
||||
*
|
||||
* 不支持列表、表格、引用块——这里渲染的是文章正文,不是通用 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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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: [] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user