From 19ead7b7161b6deaced42260e6e7a2c00266fb17 Mon Sep 17 00:00:00 2001 From: CNWei Date: Sat, 24 Oct 2026 17:18:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E5=9C=B0=E5=9D=80=E6=A0=8F?= =?UTF-8?q?=E9=A9=B1=E5=8A=A8=E8=A7=86=E5=9B=BE=EF=BC=8C=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=E4=B8=8D=E5=86=8D=E5=9B=9E=E9=A6=96?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 data/view.ts 与 useViewRoute:视图与地址双向同步,支持前进后退 - 条目地址带上所属分组,刷新 / 收藏后「返回」有确定落点 - 服务端只把 /c/** 回落到 index.html,其余路径仍返回 404 - 明细页返回按钮去掉左侧箭头图标 --- README.md | 281 +++++-- src/db/catalog.rs | 483 +++++++----- src/db/mod.rs | 375 +++++++-- src/db/schema.rs | 515 +++++++------ src/db/seed.json | 723 +++++------------- src/server.rs | 243 +++++- stash-web/src/App.tsx | 139 +++- stash-web/src/api.ts | 15 +- stash-web/src/components/common/Icon.tsx | 4 - .../src/components/content/ArticleBody.tsx | 133 ++++ .../components/content/ArticleDetailView.tsx | 37 +- .../src/components/content/DetailView.tsx | 2 - stash-web/src/components/content/HomeView.tsx | 31 +- .../src/components/content/ItemDetailView.tsx | 2 - .../src/components/sidebar/CategoryNav.tsx | 119 +-- stash-web/src/components/sidebar/Sidebar.tsx | 11 +- stash-web/src/data/local.ts | 6 +- stash-web/src/data/nav.ts | 29 +- stash-web/src/data/types.ts | 36 +- stash-web/src/data/view.ts | 124 +++ stash-web/src/useCatalog.ts | 6 - stash-web/src/useViewRoute.ts | 105 +++ 22 files changed, 2098 insertions(+), 1321 deletions(-) create mode 100644 stash-web/src/components/content/ArticleBody.tsx create mode 100644 stash-web/src/data/view.ts create mode 100644 stash-web/src/useViewRoute.ts diff --git a/README.md b/README.md index b855292..762081c 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000 | --- | --- | | `/api/*` | JSON / SSE 接口 | | `/`、`/index.html` | 内嵌前端的 `index.html`(`no-cache`) | +| `/c/**` | 前端路由地址(分类明细 / 条目详情),没有实体文件时同样返回 `index.html` | | `/assets/**` | 内嵌前端的静态资源(文件名带 hash,长缓存) | | `/favicon.svg` | 内嵌前端根目录文件 | | 其他路径 | `ServeDir` 托管 `--root` 目录,目录自动补 `index.html` | @@ -47,6 +48,34 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000 托管目录下的每个一级子目录,只要包含 `index.html` 或 `index.htm`,就会被识别为一个项目, 通过 `/<目录名>/` 访问。目录名会做 URL 路径段编码。 +## 前端路由 + +控制台是单页应用,但「当前在哪一页」写在地址栏里,所以刷新、收藏、前进后退都不会丢上下文: + +| 地址 | 页面 | +| --- | --- | +| `/` | 首页分区卡片 | +| `/c/<分区>` | 分类明细 | +| `/c/<分区>/<分组>` | 只展示该分组 | +| `/c/<分区>/i/<条目>` | 条目详情,返回 → 分类明细 | +| `/c/<分区>/<分组>/i/<条目>` | 条目详情,返回 → 该分组 | + +地址里的每一段都是**业务键**(也就是接口 JSON 里的 `id`):库里是 `node.key` / `entry.key`, +本地项目则是目录名(访问地址仍然是 `route_path`,两者不再混用)。 + +几条约定: + +- 条目地址带上它自己所在的分组,所以**一个条目只有一个地址**;刷新之后点「返回」 + 仍然落回条目所属的分组,而不是去猜一个返回目标; +- 「返回」按钮在应用内有上一页时交给浏览器(`history.back()`), + 直接打开地址(刷新 / 收藏,应用内没有上一页)时退到上面的推导结果; +- 服务端只把 `/c/**` 回落到入口页(`src/server.rs` 的 `SPA_PREFIXES`), + 托管目录里写错的地址仍然是 404 页面——不做「所有未命中都返回 200」的万能回落, + 否则分不清是路由拼错还是文件真的不存在; +- 地址转换规则在 [`stash-web/src/data/view.ts`](stash-web/src/data/view.ts), + 与 history 打交道的部分在 [`stash-web/src/useViewRoute.ts`](stash-web/src/useViewRoute.ts)。 + 新增一类页面时两处都要改:前端加前缀,服务端把它加进 `SPA_PREFIXES`。 + ## 目录结构 ```text @@ -102,46 +131,214 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000 ### 表结构 -| 表 | 说明 | -| --- | --- | -| `category` | 分类;`kind` 取值 `apps` / `posters` / `articles`,`sort` 决定首页分区顺序 | -| `app_group` | 应用类分类下的分组,`sort` 决定组内顺序 | -| `entry` | 条目;`name` 是展示名,`views` / `likes` 单独成列,其余字段在 `data` JSON 列里 | -| `nav_entry` | 侧栏导航;自引用树,`parent_id` 为空的是顶层项,带 `category_id` 为直达链接,否则是可折叠分组 | +三张表 + 一张展示配置表: -`entry.data` 的键名与前端 [`types.ts`](stash-web/src/data/types.ts) 一一对应: -应用的 `desc` / `icon` / `href` / `cover` / `hot` / `banner` / `badge`, -海报的 `tags` / `summary`,图文的 `blocks` / `meta`。想加字段直接改 JSON,后端会原样透传。 +- **分区与分组是同一张自引用树**——它们都是「装条目的容器」,只是层级不同; +- **侧栏直接由这棵树派生**(分区下挂分组),不再有独立的导航表; +- **首页分区展示什么由 `section_block` 决定**,卡片不再混排所有分组。 + +文章正文是一整段 Markdown,直接存在 `entry.body` 里(一篇文章一行),不再拆成正文块表。 + +主键统一是 `id INTEGER PRIMARY KEY`(SQLite rowid,自动分配,不用 `AUTOINCREMENT`), +另有一列 `key` 作为**唯一业务编码**;外键一律写成 `<目标>_key`,指向对方的 `key`。 + +#### node —— 分区与分组(一棵容器树) + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | INTEGER | 主键,库内唯一标识(自动分配) | +| `key` | TEXT | 业务编码,全局唯一(如 `recommend` / `ui`) | +| `parent_key` | TEXT? | 父节点编码:分组指向所属分区,分区为 NULL | +| `title` | TEXT | 展示名:分区标题 / 分组小标题 | +| `kind` | TEXT | `section` 顶层分区 / `group` 分区下的分组 | +| `view_mode` | TEXT? | 仅分区:`apps` / `posters` / `articles`,决定渲染形态 | +| `bar_color` | TEXT? | 仅分区:标题左侧 4×16 色条颜色(如 `#3858f6`) | +| `icon` | TEXT? | 仅分区:明细页 hero 左侧的字符图标 | +| `preview_limit` | INTEGER? | 仅分区:首页预览条数;为空时按类型取默认 | +| `empty_text` | TEXT? | 仅分区:分区为空时的提示文案 | +| `show_in_sidebar` | INTEGER | 是否出现在侧栏,`0` / `1`;0 表示只当首页卡片/内容,不进导航 | +| `sort` | INTEGER | 同级顺序,越小越靠前 | + +两条约束写在表上: + +- `CHECK`:`section` 必须 `parent_key IS NULL` 且有 `view_mode`;`group` 必须有父节点且无 `view_mode`; +- `key` 唯一(UNIQUE),全局不重复。 + +#### entry —— 内容条目(网站 / 电影 / 文章共用) + +| 字段 | 类型 | 说明 / 前端用途 | +| --- | --- | --- | +| `id` | INTEGER | 主键,库内唯一标识(自动分配) | +| `key` | TEXT | 业务编码,全局唯一(如 `github` / `movie-1`);详情页按它查找 | +| `node_key` | TEXT | 直接所属容器,外键 → `node.key`:应用是分组,海报 / 图文是分区本身 | +| `name` | TEXT | 名称:网站名 / 电影名 / 文章标题 | +| `description` | TEXT? | 一句话简介,列表卡片的副标题 | +| `icon` | TEXT? | 字符图标,封面缺失时的占位 | +| `href` | TEXT? | 点击后打开的页面;空或 `#` 表示暂无可跳转地址 | +| `cover` | TEXT? | 封面图:应用头像 / 电影海报 / 文章配图 | +| `banner` | TEXT? | 详情页横幅图 | +| `badge` | TEXT? | 详情页标签行上的一句话标记(如「推荐好站」) | +| `comment` | TEXT? | 详情页的一句话评价(引用块) | +| `summary` | TEXT? | 详情页正文段落(电影简介这类长文本) | +| `body` | TEXT? | 文章正文,一整段 Markdown;一篇一行,不拆块 | +| `hot` | INTEGER | 是否热门,`0` / `1`;为 1 时列表显示 HOT 徽标 | +| `views` | INTEGER | 浏览量,详情页操作条「眼睛」后的数字 | +| `likes` | INTEGER | 点赞数,详情页操作条「赞」后的数字 | +| `sort` | INTEGER | 条目顺序(组内或分区内),越小越靠前 | + +条目只存 `node_key`,**所属分区由它推导**:父节点是分组就取该分组的 `parent_key`, +父节点是分区就取它自己。查询「整个分区」时用: + +```sql +SELECT e.* +FROM entry e +JOIN node p ON p.key = e.node_key +WHERE COALESCE(p.parent_key, p.key) = 'recommend'; +``` + +好处是没有冗余列可以写错——条目不会出现「声称属于 A 区、实际挂在 B 区分组下」的情况; +`node_key` 还有 `ON UPDATE CASCADE`,改编码时条目自动跟随。 + +三种内容共用一张表,只是**用到的字段不同**,形态由所属分区的 `view_mode` 决定: + +| 类型 | 常用字段 | +| --- | --- | +| 应用 | `name` `description` `icon` `href` `cover` `badge` `banner` `hot` | +| 电影 | `name`(前端叫 `title`)`cover` `summary` `comment` `href` + `entry_tag` | +| 文章 | `name`(前端叫 `title`)`description` `cover` `body`(Markdown)+ `entry_tag` | + +#### 正文格式 + +`entry.body` 存的是 Markdown,渲染层只支持正文实际用到的子集: + +| 写法 | 效果 | +| --- | --- | +| 空行分段 | 一个段落 | +| `## 小标题` | 正文小标题(`#`~`######` 都渲染成同级) | +| 单独一行 `![说明](url)` | 配图 | +| `**粗体**`、`` `代码` ``、`[文字](链接)` | 行内格式 | + +列表、表格、引用块暂不支持;需要完整语法时把 [`ArticleBody.tsx`](stash-web/src/components/content/ArticleBody.tsx) +换成 react-markdown 即可,渲染层是隔离的。 + +#### entry_tag —— 行内文本列表(标签 / 元信息) + +| 字段 | 类型 | 说明 / 前端用途 | +| --- | --- | --- | +| `entry_key` | TEXT | 所属条目编码,外键 → `entry.key` | +| `sort` | INTEGER | 展示顺序 | +| `label` | TEXT | 标签文字(如「推荐电影」「2 年前」) | + +主键 `(entry_key, sort)`。海报的 `tags` 与文章的 `meta` 都存这里——它们都是「有序字符串列表」, +前端只是在不同版式里渲染。 + +#### section_block —— 首页卡片的展示配置 + +| 字段 | 类型 | 说明 / 前端用途 | +| --- | --- | --- | +| `id` | INTEGER | 主键,库内唯一标识(自动分配) | +| `node_key` | TEXT | 要展示的节点编码,外键 → `node.key`;通常是分组,电影/文章填分区自己 | +| `title` | TEXT? | 块标题;为空时用节点 `title` | +| `preview_limit` | INTEGER? | 卡片显示几条;为空时用分类默认值 | +| `sort` | INTEGER | 卡片顺序 | + +**不存所属分区**:一张卡片属于哪个分区,由 `node_key` 推导——节点是分组就取 `parent_key`, +节点是分区就取它自己。所以这一列没必要重复存。 + +一张卡片展示「你选定的那个分组」,而不是把分区下所有分组混在一起; +点卡片标题或「···」都打开这个块指向的节点。 + +### 表关系 + +```text +node (kind=section) 1 ──── n node (kind=group) + │ │ + └────────── n entry ───────┘ + │ + ├──── n entry_tag (标签 / 元信息) + └──── n section_block (首页卡片,指向节点) + +section_block ──── 1 node(分区由 node 自己推导,不单独存) +``` + +| 关系 | 外键 | 级联 | +| --- | --- | --- | +| 分区 → 分组 | `node.parent_key → node.key` | 删分区连带删分组 | +| 节点 → 条目 | `entry.node_key → node.key` | 删分区或分组连带删条目;改编码时条目自动跟随 | +| 条目 → 标签 | `entry_tag.entry_key → entry.key` | 删条目删标签 | +| 展示块 → 节点 | `section_block.node_key → node.key` | 删被展示的节点连带删该卡片 | + +几条约束值得记住: + +- **API 只暴露业务编码**:响应里的 `id` 都是 `key`,数字主键只在库内使用; +- `entry.node_key`:应用指向分组,海报与图文直接指向分区; +- 条目 `key` 是主键,全局唯一; +- **侧栏就是内容树**:分区展开是它的分组,名称取自 `node.title`,不再有别名或跨区编排; +- 首页卡片展示哪个分组由 `section_block.node_key` 决定,改一行即可换内容。 + +### 前端怎么用这些表 + +| 界面 | 用到的表 | 关键字段 | +| --- | --- | --- | +| 首页分区卡片 | `node`(section) + `section_block` + `entry` | 卡片标题用展示块标题(默认被展示节点的 `title`),色条用分区 `bar_color`;预览条目取该节点下的前 N 条 | +| 分类明细页 | `node`(section/group) + `entry` | `icon`(hero 图标)、分区 `title` 或分组 `title`、条目卡片字段 | +| 应用详情 | `entry`(apps) | `name` `description` `icon` `href` `cover` `banner` `badge` `comment` `views` `likes` | +| 海报详情 | `entry` + `entry_tag` | `name`(→`title`)`cover` `comment` `summary` `href` + `tags` | +| 图文详情 | `entry` + `entry_tag` | `name`(→`title`)`description` `cover` `body` + `meta` | +| 侧栏导航 | `node` | 走 `parent_key` 的分区 + 分组,`show_in_sidebar = 0` 的节点跳过;「本地服务」由前端排除 | +| 本地服务 | 不在库里 | 由 `/api/projects` 扫描托管目录后在前端拼接 | ### 侧栏导航 -侧栏的层级、标签和跳转目标都在 `nav_entry` 表里,不再写死在前端: +侧栏不再是单独一层数据,**直接由内容树派生**: ```text -nav_entry -├─ id / parent_id / label / category_id / group_id / sort -├─ 顶层项 parent_id IS NULL -│ ├─ category_id 非空 → 直达链接(如「常用推荐」→ recommend) -│ └─ category_id 为空 → 可折叠分组(如「素材资源」) -└─ 叶子 parent_id 指向分组,category_id 必填,group_id 可选 +常用推荐(section,可折叠) +├── 常用站点(group → 打开该分组) +├── UI 设计资源 +├── 矢量图标 +├── 免费壁纸 +└── 开发工具 +推荐电影(section,无分组 → 直接打开) +CES 现场报道(section,无分组 → 直接打开) ``` -两个细节是刻意设计的: +规则很简单: -- 分组名可以与分类名不同(「素材资源」并不对应任何分类); -- 多个叶子可以指向同一个目标(「前端调试工具」和「在线编辑器」都打开 `recommend` 的 `dev` 分组)。 +- 分区有分组时渲染成可折叠节点,子项就是分组; +- 分区没有分组时(电影 / 文章)整行是链接; +- 名字一律用 `node.title`,没有别名,也没有跨分区的收纳层; +- `show_in_sidebar = 0` 的节点不出现(种子把「常用站点」设成了 0,因为它已经是首页卡片); +- 「本地服务」是前端拼出来的分类,侧栏按 id 直接排除。 -删除分类时,指向它的导航叶子会被外键级联删掉,不会留下点了没反应的死链接。 +### 首页展示块 + +首页卡片展示什么由 `section_block` 决定,不再把分区下所有分组混排: + +```sql +-- 常用推荐卡片改成展示「UI 设计资源」分组,最多 6 条 +UPDATE section_block +SET node_key = 'ui', preview_limit = 6 +WHERE node_key = 'common'; +``` + +每张卡片一行,`node_key` 指向要展示的节点;它属于哪个分区由节点自己决定。 +点卡片标题或「···」都会打开这个节点;电影 / 文章这类没有分组的,`node_key` 填分区自己的编码。 + +**卡片标题跟着展示内容走**:配了展示块时用块标题(没配则用节点 `title`), +所以「常用推荐」这张卡片会显示成「常用站点」——它实际展示的就是这个分组。 +需要单独起名时,写 `section_block.title` 即可。 ### 自己改内容 1. 用任何 SQLite 工具(DB Browser for SQLite、`sqlite3` CLI、DBeaver…)打开 `data/stash.db`; -2. 分类内容改 `category` / `app_group` / `entry`,侧栏改 `nav_entry`; +2. 内容改 `node`(分区与分组)与 `entry`(文章正文就是 `entry.body` 里的 Markdown),标签改 `entry_tag`,首页展示改 `section_block`; 3. 提交修改后,页面会在 **5 秒内**自动刷新——服务端轮询 `PRAGMA data_version` 检测外部写入; 4. 也可以点侧栏的「重新扫描」或直接刷新浏览器。 -改坏 `data` 列(比如写成非法 JSON)时,接口会返回带「分类/条目 id」的错误信息, -页面顶部会显示出来,改回去即可。 +新增条目时 `id` 不用填,`key` 必须写(全局唯一);`node_key` 填直接所在的分组编码 +(没有分组就填分区编码),所属分区由它推导。 +先 `SELECT key, kind, parent_key FROM node` 看一眼现有编码即可。 ### 重置 @@ -153,6 +350,16 @@ rm data/stash.db # Linux / macOS Remove-Item data\stash.db # Windows PowerShell ``` +库结构版本记在 `PRAGMA user_version` 里(当前为 `15`)。新版本二进制遇到旧库时会**直接拒绝启动** +并提示删库,而不是拿旧表执行新查询: + +```text +[FATAL] 启动失败: 数据库错误: 内容库结构版本为 14,当前程序需要 15。 + 开发阶段不做自动迁移,请删除数据库后重启(种子会自动重新导入)。 +``` + +`SCHEMA_VERSION` 定义在 [`src/db/schema.rs`](src/db/schema.rs),改动表结构时把它 +1。 + ## 前端开发 ```bash @@ -242,6 +449,7 @@ GET /api/catalog "title": "常用推荐", "barColor": "#3858f6", "icon": "🔖", + "block": { "nodeKey": "common", "previewLimit": 4 }, "groups": [ { "id": "common", @@ -258,24 +466,9 @@ GET /api/catalog 「本地服务」分类**不在**这个接口里,它由 `/api/projects` 扫描托管目录后在前端拼接。 -返回体里还有一个 `nav` 字段,即侧栏导航,结构与 [`nav.ts`](stash-web/src/data/nav.ts) 的 `NavModel` 一致: - -```json -{ - "nav": { - "topLevel": [{ "id": "nav-recommend", "label": "常用推荐", "target": { "categoryId": "recommend" } }], - "groups": [ - { - "id": "nav-assets", - "label": "素材资源", - "leaves": [ - { "id": "nav-ui", "label": "UI 设计资源", "target": { "categoryId": "recommend", "groupId": "ui" } } - ] - } - ] - } -} -``` +每个分类上的 `block` 就是首页卡片的展示配置:`nodeKey` 指向要展示的节点(通常是分组), +`previewLimit` 是卡片里显示几条。侧栏不再单独下发,前端直接用 `categories` 里的 +分区与分组渲染。 ### 订阅内容库变化 @@ -296,7 +489,7 @@ data: {"changed":true} ## 测试 ```bash -cargo test # 目录扫描、隐藏目录过滤、排序、路径编码、文件监听广播、内容库 +cargo test # 目录扫描、隐藏目录过滤、排序、路径编码、文件监听广播、内容库、SPA 回落 cd stash-web && npm run build # tsc -b + vite build,同时做类型检查 ``` @@ -310,7 +503,9 @@ $env:TEMP = "$PWD\target\test-tmp"; $env:TMP = $env:TEMP; cargo test ## 已知限制 -- 控制台是单页应用但没有客户端路由,因此没有 SPA history 回退;刷新不存在的物理路径会返回 404。 +- 前端路由只有 `/c/**` 一个前缀;新增一类页面时,`stash-web/src/data/view.ts` 与 + `src/server.rs` 的 `SPA_PREFIXES` 必须一起改,否则刷新那条地址会拿到 404 页面。 +- 前进 / 后退能回到对应页面,但不恢复滚动位置:切换视图统一滚回内容区顶部。 - 内嵌资源占用了 `/assets/` 前缀,托管目录下如果存在同名目录会被遮蔽。 - `/` 固定由控制台占用,上一版「根目录有 `index.html` 就直接当单站点展示」的行为已取消; 托管目录里的项目统一走 `/<目录名>/`。 diff --git a/src/db/catalog.rs b/src/db/catalog.rs index faf546c..cea1330 100644 --- a/src/db/catalog.rs +++ b/src/db/catalog.rs @@ -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, + bar_color: Option, + icon: Option, preview_limit: Option, empty_text: Option, + show_in_sidebar: bool, } -/// 导航行:侧栏自引用树的原始形态 -struct NavRow { - id: String, - parent_id: Option, - label: String, - category_id: Option, - group_id: Option, -} - -/// 条目行:`data` 里是尚未展开的 JSON 文本 +/// 条目行:与 `entry` 表的列一一对应 struct EntryRow { - id: String, - group_id: Option, + key: String, + node_key: String, name: String, + description: Option, + icon: Option, + href: Option, + cover: Option, + banner: Option, + badge: Option, + comment: Option, + summary: Option, + body: Option, + hot: bool, views: i64, likes: i64, - data: String, } -/// 读出整个内容目录:分类树 + 侧栏导航。 +/// 读出整个内容目录。 pub fn load(conn: &Connection) -> Result { Ok(Catalog { - categories: load_categories(conn)?, - nav: load_nav(conn)?, + categories: load_sections(conn)?, }) } -/// 读出全部分类,按 `sort` 排序;顺序即首页分区顺序。 -fn load_categories(conn: &Connection) -> Result, StashError> { +/// 读出全部分区,按 `sort` 排序;顺序即首页分区顺序。 +fn load_sections(conn: &Connection) -> Result, 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, 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 { +/// 读出所有卡片配置,按推导出的分区业务键索引。 +/// +/// 分区不单独存:节点是分组就取它的 `parent_key`,节点是分区就取它自己。 +/// `title` 同样在 SQL 里解析:卡片自己配了标题就用它,否则用被展示节点的标题。 +fn load_blocks(conn: &Connection) -> Result, 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>(3)?, + )) + }) + .map_err(map_error)? + .collect::, _>>() + .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 { + 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 Result { +/// 应用类:取出分组节点,再把条目按 `node_key` 分派进去。 +fn build_groups(conn: &Connection, section: &NodeRow) -> Result { 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::, _>>() @@ -135,23 +211,22 @@ fn build_groups(conn: &Connection, category: &CategoryRow) -> Result>(); 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 Result { - let entries = load_entries(conn, &category.id)?; +/// 海报类:竖版卡片要 `title` / `cover` / `tags`。 +fn build_poster_items(conn: &Connection, section: &NodeRow) -> Result { + 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::, _>>()?; + .map(|entry| poster_item(entry, tags.get(&entry.key))) + .collect::>(); Ok(Value::Array(items)) } -/// 取某个分类下的全部条目,按 `sort` 升序。 -fn load_entries(conn: &Connection, category_id: &str) -> Result, StashError> { +/// 图文类:需要 `title` / `desc` / `cover` / `meta` / `body`。 +fn build_article_items(conn: &Connection, section: &NodeRow) -> Result { + 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::>(); + + 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>) -> 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>) -> 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, key: &str, value: &Option) { + 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, 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, S Ok(entries) } -/// 把一条条目展开成对象。 -/// -/// `name_key` 决定展示名落在哪个字段:应用用 `name`,海报与图文用 `title`。 -/// 数据库列优先于 JSON,用户即便在 `data` 里写了同名键也不会顶掉真实值。 -fn entry_object( - entry: &EntryRow, - category_id: &str, - name_key: &str, -) -> Result, 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 { +/// 取某个分区下所有条目的标签,按条目业务键聚合;一次查询避免 N+1。 +fn load_tags(conn: &Connection, section_key: &str) -> Result>, 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::, _>>() .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::>(); - - groups.push(json!({ - "id": row.id, - "label": row.label, - "leaves": leaves, - })); + let mut grouped: HashMap> = 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) } diff --git a/src/db/mod.rs b/src/db/mod.rs index bf4e950..b45accb 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -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, - /// 侧栏导航,结构对应前端 `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 { 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) = 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 必须变化"); } diff --git a/src/db/schema.rs b/src/db/schema.rs index 2230c63..c6b0d3d 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -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 { + 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 { 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 { // 单事务写入:避免中途失败留下半个内容库 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 { - 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, 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, key: &str) -> i64 { /// 种子文件根节点 #[derive(Debug, Deserialize)] struct SeedFile { - categories: Vec, - /// 侧栏导航;缺省时侧栏为空,但不影响分类内容 - #[serde(default)] - nav: SeedNav, + sections: Vec, } -/// 侧栏导航:顶层直达项 + 可折叠分组 -#[derive(Debug, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SeedNav { - #[serde(default)] - top_level: Vec, - #[serde(default)] - groups: Vec, -} - -/// 一个可折叠分组,本身不可点击,只负责收纳叶子 -#[derive(Debug, Deserialize)] -struct SeedNavGroup { - id: String, - label: String, - #[serde(default)] - leaves: Vec, -} - -/// 导航叶子:点击后切到 `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, -} - -/// 一个分类;`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, #[serde(default)] empty_text: Option, + /// 是否出现在侧栏;默认 true + #[serde(default = "default_true")] + show_in_sidebar: bool, + /// 仅 apps 分区使用 #[serde(default)] groups: Vec, + /// 非 apps 分区直接挂条目 #[serde(default)] - items: Vec, + entries: Vec, + /// 首页卡片展示哪个节点(分组编码,或分区自己的编码) + block: SeedBlockConfig, } -/// 应用类分类下的一个分组 +/// 首页分区的展示配置 #[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SeedBlockConfig { + /// 要展示的节点业务键;通常是分组,电影/文章填分区自己 + node_key: String, + /// 块标题;空则用节点 title + #[serde(default)] + title: Option, + /// 卡片显示几条;空则用前端默认 + #[serde(default)] + preview_limit: Option, +} + +/// 分区下的一个分组 +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] struct SeedGroup { - id: String, - name: String, + key: String, + title: String, sort: i64, - items: Vec, + /// 是否出现在侧栏;默认 true + #[serde(default = "default_true")] + show_in_sidebar: bool, + entries: Vec, } -/// 一条内容;除 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, + #[serde(default)] + description: Option, + #[serde(default)] + icon: Option, + #[serde(default)] + href: Option, + #[serde(default)] + cover: Option, + #[serde(default)] + banner: Option, + #[serde(default)] + badge: Option, + #[serde(default)] + comment: Option, + #[serde(default)] + summary: Option, + /// 文章正文(Markdown) + #[serde(default)] + body: Option, + #[serde(default)] + hot: bool, + #[serde(default)] + views: i64, + #[serde(default)] + likes: i64, + #[serde(default)] + tags: Vec, + #[serde(default)] + meta: Vec, } diff --git a/src/db/seed.json b/src/db/seed.json index 7c14143..194695b 100644 --- a/src/db/seed.json +++ b/src/db/seed.json @@ -1,295 +1,285 @@ { - "categories": [ + "sections": [ { - "id": "recommend", - "kind": "apps", + "key": "recommend", + "view": "apps", "title": "常用推荐", "barColor": "#3858f6", "icon": "🔖", "sort": 1, + "block": { + "nodeKey": "common", + "previewLimit": 4 + }, "groups": [ { - "id": "common", - "name": "常用站点", + "key": "common", + "title": "常用站点", "sort": 1, - "items": [ + "showInSidebar": false, + "entries": [ { - "id": "github", + "key": "github", "name": "GitHub", - "desc": "代码托管与开源社区", + "description": "代码托管与开源社区", "icon": "G", "href": "#", "cover": "https://picsum.photos/seed/github/200/200", - "hot": true, - "comment": "收录在本地导航里的常用站点,点开就能直达。", "banner": "https://picsum.photos/seed/github-banner/800/300", "badge": "推荐好站", + "comment": "收录在本地导航里的常用站点,点开就能直达。", + "hot": true, "views": 429, "likes": 38 }, { - "id": "zhihu", + "key": "zhihu", "name": "知乎", - "desc": "深度内容问答社区", + "description": "深度内容问答社区", "icon": "Z", "href": "#", "cover": "https://picsum.photos/seed/zhihu/200/200", - "hot": false, - "comment": "收录在本地导航里的常用站点,点开就能直达。", "banner": "https://picsum.photos/seed/zhihu-banner/800/300", "badge": "推荐好站", + "comment": "收录在本地导航里的常用站点,点开就能直达。", "views": 294, "likes": 73 }, { - "id": "bilibili", + "key": "bilibili", "name": "哔哩哔哩", - "desc": "弹幕视频分享网站", + "description": "弹幕视频分享网站", "icon": "B", "href": "#", "cover": "https://picsum.photos/seed/bilibili/200/200", - "hot": true, - "comment": "收录在本地导航里的常用站点,点开就能直达。", "banner": "https://picsum.photos/seed/bilibili-banner/800/300", "badge": "推荐好站", + "comment": "收录在本地导航里的常用站点,点开就能直达。", + "hot": true, "views": 280, "likes": 9 }, { - "id": "v2ex", + "key": "v2ex", "name": "V2EX", - "desc": "创意工作者讨论社区", + "description": "创意工作者讨论社区", "icon": "V", "href": "#", "cover": "https://picsum.photos/seed/v2ex/200/200", - "hot": false, - "comment": "收录在本地导航里的常用站点,点开就能直达。", "banner": "https://picsum.photos/seed/v2ex-banner/800/300", "badge": "推荐好站", + "comment": "收录在本地导航里的常用站点,点开就能直达。", "views": 387, "likes": 72 } ] }, { - "id": "ui", - "name": "UI 设计资源", + "key": "ui", + "title": "UI 设计资源", "sort": 2, - "items": [ + "entries": [ { - "id": "wallhaven", + "key": "wallhaven", "name": "Wallhaven", - "desc": "WallBase 创办人失踪后推出的精选壁纸站", + "description": "WallBase 创办人失踪后推出的精选壁纸站", "icon": "W", "href": "#", "cover": "https://picsum.photos/seed/wallhaven/200/200", - "hot": true, - "comment": "收录在本地导航里的常用站点,点开就能直达。", "banner": "https://picsum.photos/seed/wallhaven-banner/800/300", "badge": "推荐好站", + "comment": "收录在本地导航里的常用站点,点开就能直达。", + "hot": true, "views": 608, "likes": 51 }, { - "id": "500px", + "key": "500px", "name": "500px", - "desc": "有着世界上最好的照片分享社区", + "description": "有着世界上最好的照片分享社区", "icon": "5", "href": "#", "cover": "https://picsum.photos/seed/500px/200/200", - "hot": false, - "comment": "收录在本地导航里的常用站点,点开就能直达。", "banner": "https://picsum.photos/seed/500px-banner/800/300", "badge": "推荐好站", + "comment": "收录在本地导航里的常用站点,点开就能直达。", "views": 573, "likes": 68 }, { - "id": "chuangyiren", + "key": "chuangyiren", "name": "创意人", - "desc": "去掉了形式主义描述的原创社区", + "description": "去掉了形式主义描述的原创社区", "icon": "C", "href": "#", "cover": "https://picsum.photos/seed/chuangyiren/200/200", - "hot": false, - "comment": "收录在本地导航里的常用站点,点开就能直达。", "banner": "https://picsum.photos/seed/chuangyiren-banner/800/300", "badge": "推荐好站", + "comment": "收录在本地导航里的常用站点,点开就能直达。", "views": 364, "likes": 20 }, { - "id": "topys", + "key": "topys", "name": "顶尖文案", - "desc": "全球顶尖创意分享平台 OPEN YOUR EYES", + "description": "全球顶尖创意分享平台 OPEN YOUR EYES", "icon": "T", "href": "#", "cover": "https://picsum.photos/seed/topys/200/200", - "hot": false, - "comment": "收录在本地导航里的常用站点,点开就能直达。", "banner": "https://picsum.photos/seed/topys-banner/800/300", "badge": "推荐好站", + "comment": "收录在本地导航里的常用站点,点开就能直达。", "views": 745, "likes": 68 }, { - "id": "uichina", + "key": "uichina", "name": "UI中国", - "desc": "前身为 iconfans,国内专业的 UI 设计平台", + "description": "前身为 iconfans,国内专业的 UI 设计平台", "icon": "U", "href": "#", "cover": "https://picsum.photos/seed/uichina/200/200", - "hot": false, - "comment": "收录在本地导航里的常用站点,点开就能直达。", "banner": "https://picsum.photos/seed/uichina-banner/800/300", "badge": "推荐好站", + "comment": "收录在本地导航里的常用站点,点开就能直达。", "views": 743, "likes": 4 } ] }, { - "id": "vector", - "name": "矢量图标", + "key": "vector", + "title": "矢量图标", "sort": 3, - "items": [ + "entries": [ { - "id": "iconfont", + "key": "iconfont", "name": "Iconfont", - "desc": "阿里妈妈矢量图标库", + "description": "阿里妈妈矢量图标库", "icon": "I", "href": "#", "cover": "https://picsum.photos/seed/iconfont/200/200", - "hot": false, - "comment": "收录在本地导航里的常用站点,点开就能直达。", "banner": "https://picsum.photos/seed/iconfont-banner/800/300", "badge": "推荐好站", + "comment": "收录在本地导航里的常用站点,点开就能直达。", "views": 585, "likes": 41 }, { - "id": "feather", + "key": "feather", "name": "Feather Icons", - "desc": "简单漂亮的开源图标库", + "description": "简单漂亮的开源图标库", "icon": "F", "href": "#", "cover": "https://picsum.photos/seed/feather/200/200", - "hot": false, - "comment": "收录在本地导航里的常用站点,点开就能直达。", "banner": "https://picsum.photos/seed/feather-banner/800/300", "badge": "推荐好站", + "comment": "收录在本地导航里的常用站点,点开就能直达。", "views": 390, "likes": 48 }, { - "id": "fontawesome", + "key": "fontawesome", "name": "FontAwesome", - "desc": "图标字体与 CSS 框架", + "description": "图标字体与 CSS 框架", "icon": "A", "href": "#", "cover": "https://picsum.photos/seed/fontawesome/200/200", - "hot": false, - "comment": "收录在本地导航里的常用站点,点开就能直达。", "banner": "https://picsum.photos/seed/fontawesome-banner/800/300", "badge": "推荐好站", + "comment": "收录在本地导航里的常用站点,点开就能直达。", "views": 317, "likes": 17 } ] }, { - "id": "wallpaper", - "name": "免费壁纸", + "key": "wallpaper", + "title": "免费壁纸", "sort": 4, - "items": [ + "entries": [ { - "id": "unsplash", + "key": "unsplash", "name": "Unsplash", - "desc": "免费高清无版权摄影图片", + "description": "免费高清无版权摄影图片", "icon": "U", "href": "#", "cover": "https://picsum.photos/seed/unsplash/200/200", - "hot": false, - "comment": "收录在本地导航里的常用站点,点开就能直达。", "banner": "https://picsum.photos/seed/unsplash-banner/800/300", "badge": "推荐好站", + "comment": "收录在本地导航里的常用站点,点开就能直达。", "views": 807, "likes": 5 }, { - "id": "pexels", + "key": "pexels", "name": "Pexels", - "desc": "才华横溢的摄影师分享的素材", + "description": "才华横溢的摄影师分享的素材", "icon": "P", "href": "#", "cover": "https://picsum.photos/seed/pexels/200/200", - "hot": false, - "comment": "收录在本地导航里的常用站点,点开就能直达。", "banner": "https://picsum.photos/seed/pexels-banner/800/300", "badge": "推荐好站", + "comment": "收录在本地导航里的常用站点,点开就能直达。", "views": 380, "likes": 12 }, { - "id": "pixabay", + "key": "pixabay", "name": "Pixabay", - "desc": "超过 2.7 百万张优质图片和视频", + "description": "超过 2.7 百万张优质图片和视频", "icon": "X", "href": "#", "cover": "https://picsum.photos/seed/pixabay/200/200", - "hot": false, - "comment": "收录在本地导航里的常用站点,点开就能直达。", "banner": "https://picsum.photos/seed/pixabay-banner/800/300", "badge": "推荐好站", + "comment": "收录在本地导航里的常用站点,点开就能直达。", "views": 771, "likes": 83 } ] }, { - "id": "dev", - "name": "开发工具", + "key": "dev", + "title": "开发工具", "sort": 5, - "items": [ + "entries": [ { - "id": "codepen", + "key": "codepen", "name": "CodePen", - "desc": "前端在线代码演示和练习场", + "description": "前端在线代码演示和练习场", "icon": "C", "href": "#", "cover": "https://picsum.photos/seed/codepen/200/200", - "hot": false, - "comment": "收录在本地导航里的常用站点,点开就能直达。", "banner": "https://picsum.photos/seed/codepen-banner/800/300", "badge": "推荐好站", + "comment": "收录在本地导航里的常用站点,点开就能直达。", "views": 266, "likes": 13 }, { - "id": "stackoverflow", + "key": "stackoverflow", "name": "Stack Overflow", - "desc": "全球程序员技术问答社区", + "description": "全球程序员技术问答社区", "icon": "S", "href": "#", "cover": "https://picsum.photos/seed/stackoverflow/200/200", - "hot": false, - "comment": "收录在本地导航里的常用站点,点开就能直达。", "banner": "https://picsum.photos/seed/stackoverflow-banner/800/300", "badge": "推荐好站", + "comment": "收录在本地导航里的常用站点,点开就能直达。", "views": 112, "likes": 25 }, { - "id": "github-dev", + "key": "github-dev", "name": "GitHub", - "desc": "代码托管与开源协作社区", + "description": "代码托管与开源协作社区", "icon": "G", "href": "#", "cover": "https://picsum.photos/seed/github-dev/200/200", - "hot": false, - "comment": "收录在本地导航里的常用站点,点开就能直达。", "banner": "https://picsum.photos/seed/github-dev-banner/800/300", "badge": "推荐好站", + "comment": "收录在本地导航里的常用站点,点开就能直达。", "views": 587, "likes": 32 } @@ -298,689 +288,330 @@ ] }, { - "id": "movie", - "kind": "posters", + "key": "movie", + "view": "posters", "title": "推荐电影", "barColor": "#ff6034", "icon": "🎬", "sort": 2, - "items": [ + "block": { + "nodeKey": "movie" + }, + "entries": [ { - "id": "movie-1", + "key": "movie-1", "name": "千与千寻 千と千尋の神隠し (2001)", "cover": "https://picsum.photos/seed/m1/300/450", + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "少女千寻误入神灵世界,为救回变成猪的父母,在汤屋打工并逐渐学会担当。", + "href": "#", "tags": [ "推荐电影", "豆瓣电影 Top250" ], - "comment": "收藏在本地片单里,闲下来就重看一遍。", - "summary": "少女千寻误入神灵世界,为救回变成猪的父母,在汤屋打工并逐渐学会担当。", - "href": "#", "views": 896, "likes": 51 }, { - "id": "movie-2", + "key": "movie-2", "name": "美丽人生 La vita è bella (1997)", "cover": "https://picsum.photos/seed/m2/300/450", + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "二战集中营里,父亲用一场「游戏」保护儿子,让他相信世界仍然美好。", + "href": "#", "tags": [ "推荐电影", "豆瓣电影 Top250" ], - "comment": "收藏在本地片单里,闲下来就重看一遍。", - "summary": "二战集中营里,父亲用一场「游戏」保护儿子,让他相信世界仍然美好。", - "href": "#", "views": 897, "likes": 20 }, { - "id": "movie-3", + "key": "movie-3", "name": "这个杀手不太冷 Léon (1994)", "cover": "https://picsum.photos/seed/m3/300/450", + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "孤独的职业杀手莱昂收留了邻居少女玛蒂尔达,两个边缘人相互依靠。", + "href": "#", "tags": [ "推荐电影", "豆瓣电影 Top250" ], - "comment": "收藏在本地片单里,闲下来就重看一遍。", - "summary": "孤独的职业杀手莱昂收留了邻居少女玛蒂尔达,两个边缘人相互依靠。", - "href": "#", "views": 898, "likes": 55 }, { - "id": "movie-4", + "key": "movie-4", "name": "阿甘正传 Forrest Gump (1994)", "cover": "https://picsum.photos/seed/m4/300/450", + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "智商不高的阿甘凭着一股单纯的执着,意外见证并参与了几十年美国历史。", + "href": "#", "tags": [ "推荐电影", "豆瓣电影 Top250" ], - "comment": "收藏在本地片单里,闲下来就重看一遍。", - "summary": "智商不高的阿甘凭着一股单纯的执着,意外见证并参与了几十年美国历史。", - "href": "#", "views": 899, "likes": 32 }, { - "id": "movie-5", + "key": "movie-5", "name": "霸王别姬 (1993)", "cover": "https://picsum.photos/seed/m5/300/450", + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "两位京剧伶人半个世纪的悲欢离合,与时代洪流交织在一起。", + "href": "#", "tags": [ "推荐电影", "豆瓣电影 Top250" ], - "comment": "收藏在本地片单里,闲下来就重看一遍。", - "summary": "两位京剧伶人半个世纪的悲欢离合,与时代洪流交织在一起。", - "href": "#", "views": 900, "likes": 59 }, { - "id": "movie-6", + "key": "movie-6", "name": "肖申克的救赎 The Shawshank Redemption", "cover": "https://picsum.photos/seed/m6/300/450", + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "蒙冤入狱的银行家安迪,在漫长牢狱中守住希望并完成自救。", + "href": "#", "tags": [ "推荐电影", "豆瓣电影 Top250" ], - "comment": "收藏在本地片单里,闲下来就重看一遍。", - "summary": "蒙冤入狱的银行家安迪,在漫长牢狱中守住希望并完成自救。", - "href": "#", "views": 901, "likes": 36 }, { - "id": "movie-7", + "key": "movie-7", "name": "泰坦尼克号 Titanic (1997)", "cover": "https://picsum.photos/seed/m7/300/450", + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "沉船之夜,来自不同阶层的杰克与露丝相爱,也被人性的选择所考验。", + "href": "#", "tags": [ "推荐电影", "豆瓣电影 Top250" ], - "comment": "收藏在本地片单里,闲下来就重看一遍。", - "summary": "沉船之夜,来自不同阶层的杰克与露丝相爱,也被人性的选择所考验。", - "href": "#", "views": 902, "likes": 13 }, { - "id": "movie-8", + "key": "movie-8", "name": "盗梦空间 Inception (2010)", "cover": "https://picsum.photos/seed/m8/300/450", + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "盗梦团队潜入层层梦境,试图在目标的潜意识里植入一个念头。", + "href": "#", "tags": [ "推荐电影", "豆瓣电影 Top250" ], - "comment": "收藏在本地片单里,闲下来就重看一遍。", - "summary": "盗梦团队潜入层层梦境,试图在目标的潜意识里植入一个念头。", - "href": "#", "views": 903, "likes": 48 }, { - "id": "movie-9", + "key": "movie-9", "name": "星际穿越 Interstellar (2014)", "cover": "https://picsum.photos/seed/m9/300/450", + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "地球濒临枯竭,父亲穿越虫洞寻找新家园,与女儿跨越时间重逢。", + "href": "#", "tags": [ "推荐电影", "豆瓣电影 Top250" ], - "comment": "收藏在本地片单里,闲下来就重看一遍。", - "summary": "地球濒临枯竭,父亲穿越虫洞寻找新家园,与女儿跨越时间重逢。", - "href": "#", "views": 904, "likes": 17 }, { - "id": "movie-10", + "key": "movie-10", "name": "楚门的世界 The Truman Show (1998)", "cover": "https://picsum.photos/seed/m10/300/450", + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "楚门渐渐发现,自己的整个人生其实是一档被全世界围观的真人秀。", + "href": "#", "tags": [ "推荐电影", "豆瓣电影 Top250" ], - "comment": "收藏在本地片单里,闲下来就重看一遍。", - "summary": "楚门渐渐发现,自己的整个人生其实是一档被全世界围观的真人秀。", - "href": "#", "views": 608, "likes": 45 }, { - "id": "movie-11", + "key": "movie-11", "name": "疯狂动物城 Zootopia (2016)", "cover": "https://picsum.photos/seed/m11/300/450", + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "兔子警官与狐狸搭档,在动物城里侦破一桩失踪案,也打破偏见。", + "href": "#", "tags": [ "推荐电影", "豆瓣电影 Top250" ], - "comment": "收藏在本地片单里,闲下来就重看一遍。", - "summary": "兔子警官与狐狸搭档,在动物城里侦破一桩失踪案,也打破偏见。", - "href": "#", "views": 609, "likes": 22 }, { - "id": "movie-12", + "key": "movie-12", "name": "大话西游之大圣娶亲 (1995)", "cover": "https://picsum.photos/seed/m12/300/450", + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "至尊宝在爱情与宿命之间反复抉择,最终戴上金箍踏上取经路。", + "href": "#", "tags": [ "推荐电影", "豆瓣电影 Top250" ], - "comment": "收藏在本地片单里,闲下来就重看一遍。", - "summary": "至尊宝在爱情与宿命之间反复抉择,最终戴上金箍踏上取经路。", - "href": "#", "views": 610, "likes": 57 } ] }, { - "id": "ces", - "kind": "articles", + "key": "ces", + "view": "articles", "title": "CES 现场报道", "barColor": "#ffba3a", "icon": "🏷️", "sort": 3, - "items": [ + "block": { + "nodeKey": "ces" + }, + "entries": [ { - "id": "ces-1", + "key": "ces-1", "name": "可塑性极强的玻璃,在设计师手里有了更多可能 | 这个设计了不起", - "desc": "前沿科技", + "description": "前沿科技", "cover": "https://picsum.photos/seed/a1/400/250", "meta": [ "2 年前", "CES 现场报道" ], - "blocks": [ - { - "type": "paragraph", - "text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「前沿科技」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。" - }, - { - "type": "heading", - "text": "设计细节" - }, - { - "type": "paragraph", - "text": "《可塑性极强的玻璃,在设计师手里有了更多可能》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。" - }, - { - "type": "paragraph", - "text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。" - }, - { - "type": "image", - "src": "https://picsum.photos/seed/a1/400/250" - }, - { - "type": "paragraph", - "text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" - } - ] + "body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「前沿科技」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《可塑性极强的玻璃,在设计师手里有了更多可能》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a1/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" }, { - "id": "ces-2", + "key": "ces-2", "name": "废旧共享单车如何变成微型共享书屋? | 这个设计了不起", - "desc": "环保设计", + "description": "环保设计", "cover": "https://picsum.photos/seed/a2/400/250", "meta": [ "2 年前", "CES 现场报道" ], - "blocks": [ - { - "type": "paragraph", - "text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「环保设计」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。" - }, - { - "type": "heading", - "text": "设计细节" - }, - { - "type": "paragraph", - "text": "《废旧共享单车如何变成微型共享书屋?》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。" - }, - { - "type": "paragraph", - "text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。" - }, - { - "type": "image", - "src": "https://picsum.photos/seed/a2/400/250" - }, - { - "type": "paragraph", - "text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" - } - ] + "body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「环保设计」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《废旧共享单车如何变成微型共享书屋?》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a2/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" }, { - "id": "ces-3", + "key": "ces-3", "name": "荧光灯管与混凝土块的组合,可以玩出多少花样? | 这个设计了不起", - "desc": "材料探索", + "description": "材料探索", "cover": "https://picsum.photos/seed/a3/400/250", "meta": [ "2 年前", "CES 现场报道" ], - "blocks": [ - { - "type": "paragraph", - "text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「材料探索」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。" - }, - { - "type": "heading", - "text": "设计细节" - }, - { - "type": "paragraph", - "text": "《荧光灯管与混凝土块的组合,可以玩出多少花样?》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。" - }, - { - "type": "paragraph", - "text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。" - }, - { - "type": "image", - "src": "https://picsum.photos/seed/a3/400/250" - }, - { - "type": "paragraph", - "text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" - } - ] + "body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「材料探索」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《荧光灯管与混凝土块的组合,可以玩出多少花样?》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a3/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" }, { - "id": "ces-4", + "key": "ces-4", "name": "混凝土做的模块化收纳框,还能改成搁架用 | 这个设计了不起", - "desc": "家居模块", + "description": "家居模块", "cover": "https://picsum.photos/seed/a4/400/250", "meta": [ "2 年前", "CES 现场报道" ], - "blocks": [ - { - "type": "paragraph", - "text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「家居模块」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。" - }, - { - "type": "heading", - "text": "设计细节" - }, - { - "type": "paragraph", - "text": "《混凝土做的模块化收纳框,还能改成搁架用》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。" - }, - { - "type": "paragraph", - "text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。" - }, - { - "type": "image", - "src": "https://picsum.photos/seed/a4/400/250" - }, - { - "type": "paragraph", - "text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" - } - ] + "body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「家居模块」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《混凝土做的模块化收纳框,还能改成搁架用》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a4/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" }, { - "id": "ces-5", + "key": "ces-5", "name": "这个用拉伸材料做的头盔,还能帮你呼叫救护车 | 这个设计了不起", - "desc": "智能穿戴", + "description": "智能穿戴", "cover": "https://picsum.photos/seed/a5/400/250", "meta": [ "2 年前", "CES 现场报道" ], - "blocks": [ - { - "type": "paragraph", - "text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「智能穿戴」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。" - }, - { - "type": "heading", - "text": "设计细节" - }, - { - "type": "paragraph", - "text": "《这个用拉伸材料做的头盔,还能帮你呼叫救护车》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。" - }, - { - "type": "paragraph", - "text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。" - }, - { - "type": "image", - "src": "https://picsum.photos/seed/a5/400/250" - }, - { - "type": "paragraph", - "text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" - } - ] + "body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「智能穿戴」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《这个用拉伸材料做的头盔,还能帮你呼叫救护车》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a5/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" }, { - "id": "ces-6", + "key": "ces-6", "name": "你如何选择字体,简单易读还是好看有趣? | 这个设计了不起", - "desc": "视觉体验", + "description": "视觉体验", "cover": "https://picsum.photos/seed/a6/400/250", "meta": [ "2 年前", "CES 现场报道" ], - "blocks": [ - { - "type": "paragraph", - "text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「视觉体验」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。" - }, - { - "type": "heading", - "text": "设计细节" - }, - { - "type": "paragraph", - "text": "《你如何选择字体,简单易读还是好看有趣?》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。" - }, - { - "type": "paragraph", - "text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。" - }, - { - "type": "image", - "src": "https://picsum.photos/seed/a6/400/250" - }, - { - "type": "paragraph", - "text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" - } - ] + "body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「视觉体验」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《你如何选择字体,简单易读还是好看有趣?》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a6/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" }, { - "id": "ces-7", + "key": "ces-7", "name": "家里空间小?你需要几把可以悬挂在墙上的椅子 | 这个设计了不起", - "desc": "空间收纳", + "description": "空间收纳", "cover": "https://picsum.photos/seed/a7/400/250", "meta": [ "2 年前", "CES 现场报道" ], - "blocks": [ - { - "type": "paragraph", - "text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「空间收纳」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。" - }, - { - "type": "heading", - "text": "设计细节" - }, - { - "type": "paragraph", - "text": "《家里空间小?你需要几把可以悬挂在墙上的椅子》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。" - }, - { - "type": "paragraph", - "text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。" - }, - { - "type": "image", - "src": "https://picsum.photos/seed/a7/400/250" - }, - { - "type": "paragraph", - "text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" - } - ] + "body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「空间收纳」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《家里空间小?你需要几把可以悬挂在墙上的椅子》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a7/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" }, { - "id": "ces-8", + "key": "ces-8", "name": "宜家厨房家具又被改造了,色彩是亮点 | 这个设计了不起", - "desc": "色彩灵感", + "description": "色彩灵感", "cover": "https://picsum.photos/seed/a8/400/250", "meta": [ "2 年前", "CES 现场报道" ], - "blocks": [ - { - "type": "paragraph", - "text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「色彩灵感」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。" - }, - { - "type": "heading", - "text": "设计细节" - }, - { - "type": "paragraph", - "text": "《宜家厨房家具又被改造了,色彩是亮点》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。" - }, - { - "type": "paragraph", - "text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。" - }, - { - "type": "image", - "src": "https://picsum.photos/seed/a8/400/250" - }, - { - "type": "paragraph", - "text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" - } - ] + "body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「色彩灵感」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《宜家厨房家具又被改造了,色彩是亮点》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a8/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" }, { - "id": "ces-9", + "key": "ces-9", "name": "这款诞生 50 年的扶手椅,推出了特别版配色 | 这个设计了不起", - "desc": "经典产品", + "description": "经典产品", "cover": "https://picsum.photos/seed/a9/400/250", "meta": [ "2 年前", "CES 现场报道" ], - "blocks": [ - { - "type": "paragraph", - "text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「经典产品」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。" - }, - { - "type": "heading", - "text": "设计细节" - }, - { - "type": "paragraph", - "text": "《这款诞生 50 年的扶手椅,推出了特别版配色》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。" - }, - { - "type": "paragraph", - "text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。" - }, - { - "type": "image", - "src": "https://picsum.photos/seed/a9/400/250" - }, - { - "type": "paragraph", - "text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" - } - ] + "body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「经典产品」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《这款诞生 50 年的扶手椅,推出了特别版配色》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a9/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" }, { - "id": "ces-10", + "key": "ces-10", "name": "让家和办公室界限变得模糊的一组家具 | 这个设计了不起", - "desc": "办公家具", + "description": "办公家具", "cover": "https://picsum.photos/seed/a10/400/250", "meta": [ "2 年前", "CES 现场报道" ], - "blocks": [ - { - "type": "paragraph", - "text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「办公家具」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。" - }, - { - "type": "heading", - "text": "设计细节" - }, - { - "type": "paragraph", - "text": "《让家和办公室界限变得模糊的一组家具》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。" - }, - { - "type": "paragraph", - "text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。" - }, - { - "type": "image", - "src": "https://picsum.photos/seed/a10/400/250" - }, - { - "type": "paragraph", - "text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" - } - ] + "body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「办公家具」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《让家和办公室界限变得模糊的一组家具》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a10/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" }, { - "id": "ces-11", + "key": "ces-11", "name": "这个酒店设计奖,挖掘过去一年体验最好的酒店 | 这个设计了不起", - "desc": "空间建筑", + "description": "空间建筑", "cover": "https://picsum.photos/seed/a11/400/250", "meta": [ "2 年前", "CES 现场报道" ], - "blocks": [ - { - "type": "paragraph", - "text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「空间建筑」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。" - }, - { - "type": "heading", - "text": "设计细节" - }, - { - "type": "paragraph", - "text": "《这个酒店设计奖,挖掘过去一年体验最好的酒店》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。" - }, - { - "type": "paragraph", - "text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。" - }, - { - "type": "image", - "src": "https://picsum.photos/seed/a11/400/250" - }, - { - "type": "paragraph", - "text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" - } - ] + "body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「空间建筑」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《这个酒店设计奖,挖掘过去一年体验最好的酒店》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a11/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" }, { - "id": "ces-12", + "key": "ces-12", "name": "一款可移动办公桌,挂墙上当作白板就能写写画画 | 这个设计了不起", - "desc": "移动办公", + "description": "移动办公", "cover": "https://picsum.photos/seed/a12/400/250", "meta": [ "2 年前", "CES 现场报道" ], - "blocks": [ - { - "type": "paragraph", - "text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「移动办公」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。" - }, - { - "type": "heading", - "text": "设计细节" - }, - { - "type": "paragraph", - "text": "《一款可移动办公桌,挂墙上当作白板就能写写画画》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。" - }, - { - "type": "paragraph", - "text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。" - }, - { - "type": "image", - "src": "https://picsum.photos/seed/a12/400/250" - }, - { - "type": "paragraph", - "text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" - } - ] + "body": "开放与明亮的氛围,在室内空间中尤为注重。这条以「移动办公」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。\n\n## 设计细节\n\n《一款可移动办公桌,挂墙上当作白板就能写写画画》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。\n\n在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。\n\n![](https://picsum.photos/seed/a12/400/250)\n\n文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" } ] } - ], - "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" - } - }, - { - "id": "nav-vector", - "label": "矢量图标", - "target": { - "categoryId": "recommend", - "groupId": "vector" - } - }, - { - "id": "nav-wallpaper", - "label": "免费壁纸", - "target": { - "categoryId": "recommend", - "groupId": "wallpaper" - } - } - ] - }, - { - "id": "nav-tools", - "label": "开发工具", - "leaves": [ - { - "id": "nav-debug", - "label": "前端调试工具", - "target": { - "categoryId": "recommend", - "groupId": "dev" - } - }, - { - "id": "nav-editor", - "label": "在线编辑器", - "target": { - "categoryId": "recommend", - "groupId": "dev" - } - } - ] - } - ] - } + ] } diff --git a/src/server.rs b/src/server.rs index 15b7ef7..f566493 100644 --- a/src/server.rs +++ b/src/server.rs @@ -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) -> impl IntoResponse { })) } -/// API: 完整内容目录(分类 / 分组 / 条目 + 侧栏导航) +/// API: 完整内容目录(分区 / 分组 / 条目 + 首页展示块) /// /// 数据量很小(几十 KB),一次全量下发,前端搜索仍然在客户端做。 +/// 侧栏由这份数据派生,不再单独下发导航。 async fn catalog_api(State(state): State) -> Response { let db = state.db.clone(); @@ -151,7 +175,6 @@ async fn catalog_api(State(state): State) -> 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) -> 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 { if !config.root.exists() { return Err(StashError::InvalidDirectory( @@ -271,36 +335,7 @@ pub async fn start(config: Config) -> Result { 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 { 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"), + "demo", + ) + .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#"
"#), "应返回前端入口页"); + } + + /// 条目详情地址(含分组)同样回落:`/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#"
"#)); + + // 本地项目:条目 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("demo"), + "应直接返回项目入口页: {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")); + } +} diff --git a/stash-web/src/App.tsx b/stash-web/src/App.tsx index 93b2084..7b557c5 100644 --- a/stash-web/src/App.tsx +++ b/stash-web/src/App.tsx @@ -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({ type: 'home' }) + // 当前视图由地址栏决定:刷新 / 收藏 / 前进后退都回到同一页(见 useViewRoute.ts) + const { view, go, back } = useViewRoute() const [scrolled, setScrolled] = useState(false) const searchRef = useRef(null) const scrollRef = useRef(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

分类不存在

+ return ( +

+ {dataPending ? '正在读取内容库…' : '分类不存在'} +

+ ) } if (itemCategory.kind === 'articles') { const article = findArticleItem(itemCategory, itemView.itemId) if (article === undefined) { - return

条目不存在

+ return ( +

+ {dataPending ? '正在读取内容库…' : '条目不存在'} +

+ ) } return ( setView(itemView.returnTo)} + onBack={() => back(detailTargetFor(itemView))} /> ) } if (detailModel === undefined) { - return

条目不存在

+ return ( +

+ {dataPending ? '正在读取内容库…' : '条目不存在'} +

+ ) } 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() {
@@ -244,9 +321,7 @@ export default function App() { - 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 && ( -

分类不存在

+

+ {dataPending ? '正在读取内容库…' : '分类不存在'} +

)} {itemView !== undefined && renderItemView()} diff --git a/stash-web/src/api.ts b/stash-web/src/api.ts index 94b4fa1..71a70e3 100644 --- a/stash-web/src/api.ts +++ b/stash-web/src/api.ts @@ -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 { const response = await fetch(CATALOG_ENDPOINT, { @@ -116,7 +104,6 @@ export async function fetchCatalog(signal?: AbortSignal): Promise { return { categories: Array.isArray(payload.categories) ? payload.categories : [], - nav: normalizeNav(payload.nav), } } diff --git a/stash-web/src/components/common/Icon.tsx b/stash-web/src/components/common/Icon.tsx index 4753d76..38a32c9 100644 --- a/stash-web/src/components/common/Icon.tsx +++ b/stash-web/src/components/common/Icon.tsx @@ -58,10 +58,6 @@ export function ArrowUpIcon({ className }: IconProps) { return