From 41fed5e64df8e12a1262428a54a9f0a56afc9983 Mon Sep 17 00:00:00 2001 From: CNWei Date: Fri, 23 Oct 2026 20:22:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(db):=20=E5=86=85=E5=AE=B9=E5=BA=93?= =?UTF-8?q?=E8=BF=81=E5=85=A5=20SQLite=EF=BC=8C=E5=88=86=E7=B1=BB=E4=B8=8E?= =?UTF-8?q?=E4=BE=A7=E6=A0=8F=E5=AF=BC=E8=88=AA=E6=94=B9=E4=B8=BA=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E9=A9=B1=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + ALRIGHT-DESIGN.md | 5 +- Cargo.lock | 128 +++ Cargo.toml | 12 + README.md | 160 ++- src/config.rs | 203 +++- src/db/catalog.rs | 321 ++++++ src/db/mod.rs | 301 ++++++ src/db/schema.rs | 361 +++++++ src/db/seed.json | 986 ++++++++++++++++++ src/error.rs | 5 + src/lib.rs | 2 + src/main.rs | 38 +- src/server.rs | 80 ++ stash-web/src/App.tsx | 26 +- stash-web/src/api.ts | 76 +- .../src/components/sidebar/CategoryNav.tsx | 14 +- stash-web/src/components/sidebar/Sidebar.tsx | 6 +- stash-web/src/data/catalog.ts | 267 ----- stash-web/src/data/local.ts | 61 ++ stash-web/src/data/nav.ts | 43 +- stash-web/src/useCatalog.ts | 85 ++ templates/index4.html | 2 +- 23 files changed, 2867 insertions(+), 319 deletions(-) create mode 100644 src/db/catalog.rs create mode 100644 src/db/mod.rs create mode 100644 src/db/schema.rs create mode 100644 src/db/seed.json delete mode 100644 stash-web/src/data/catalog.ts create mode 100644 stash-web/src/data/local.ts create mode 100644 stash-web/src/useCatalog.ts diff --git a/.gitignore b/.gitignore index 24f0845..f3784af 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,10 @@ repomix-output.xml *.iml .vscode/ +# ---------- 运行时数据 ---------- +# 内容库属于用户数据,不进版本库(默认生成在二进制同级,即 target/ 下) +/data + # ---------- 日志与系统文件 ---------- *.log .DS_Store diff --git a/ALRIGHT-DESIGN.md b/ALRIGHT-DESIGN.md index a10fd0e..364deb1 100644 --- a/ALRIGHT-DESIGN.md +++ b/ALRIGHT-DESIGN.md @@ -312,7 +312,8 @@ background: linear-gradient(to top, rgba(0,0,0,.85) 0%, rgba(0,0,0,.4) 60%, tran | --- | --- | | 改主色 / 灰阶 / 圆角 | [`stash-web/src/index.css`](stash-web/src/index.css) 的 `:root` | | 新增图标 | 把 codepoint 加进 [`stash-web/src/iconfont.css`](stash-web/src/iconfont.css),在 `Icon.tsx` 包一个组件 | -| 新增分类 | [`stash-web/src/data/catalog.ts`](stash-web/src/data/catalog.ts) 加一条 `Category`,首页与明细页自动适配 | +| 新增分类 | 内容库 `category` 表(首次启动前可改 [`src/db/seed.json`](src/db/seed.json)),首页与明细页自动适配 | +| 调整侧栏导航 | 内容库 `nav_entry` 表,标签 / 层级 / 跳转目标都可在库里改 | | 新增卡片类型 | 在 `data/types.ts` 增加 kind,`HomeView` / `DetailView` 各加一个分支 | | 调整分区样式 | [`stash-web/src/App.css`](stash-web/src/App.css) 的「分区卡片」段落 | | 换背景图 | 替换 `stash-web/src/assets/site-bg.jpg` | @@ -323,3 +324,5 @@ background: linear-gradient(to top, rgba(0,0,0,.85) 0%, rgba(0,0,0,.4) 60%, tran - 2026-10-22:按本规范完成整体换肤(配色、字体、图标、背景、应用条目、返回顶部), 并将侧栏导航箭头改为与主题一致的 CSS 箭头。 +- 2026-10-23:分类内容与侧栏导航迁入 SQLite 内容库(`data/stash.db`), + 前端静态数据下线;视觉与交互未变。 diff --git a/Cargo.lock b/Cargo.lock index 72a5ed9..00f74a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -159,6 +171,16 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -230,6 +252,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -278,6 +318,24 @@ dependencies = [ "slab", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + [[package]] name = "heck" version = "0.5.0" @@ -452,6 +510,17 @@ version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -619,6 +688,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -663,6 +738,20 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "ryu" version = "1.0.23" @@ -759,6 +848,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -803,6 +898,7 @@ dependencies = [ "include_dir", "mime_guess", "notify", + "rusqlite", "serde", "serde_json", "thiserror", @@ -1073,6 +1169,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -1187,6 +1295,26 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index eeeadcc..fec0f7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,18 @@ include_dir = "0.7" async-stream = "0.3" notify = "8" +# 内容库:SQLite 随二进制静态编译,运行时不需要任何外部 DLL +rusqlite = { version = "0.32", features = ["bundled"] } + [build-dependencies] # build.rs 用它递归复制前端产物到 $OUT_DIR/web walkdir = "2.4" + +# MSVC 的 /Z7(旧式调试信息)在 cc-rs 生成的命令行下会触发 +# "D8050: 无法执行 c1.dll" —— SQLite 是纯 C 依赖,调试它没有意义, +# 这里关掉该 crate 的调试信息,cc-rs 就不会再加 -Z7。 +[profile.dev.package.libsqlite3-sys] +debug = false + +[profile.test.package.libsqlite3-sys] +debug = false diff --git a/README.md b/README.md index bbc36ae..b855292 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000 ``` 启动后打开终端里打印的地址即可,例如 `http://127.0.0.1:8080/`。 -默认托管目录 `./public` 不存在时会自动创建。 +默认托管目录 `./public` 不存在时会自动创建;首次启动还会与二进制同级创建 `data/`, +并在其中生成内容库 `stash.db`(已内置示例内容,可以直接用 SQLite 工具修改)。 环境要求: @@ -56,15 +57,22 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000 ├── src/ # Rust 后端 │ ├── main.rs # CLI 参数、启动横幅、停机信号 │ ├── lib.rs # 库导出 -│ ├── config.rs # 服务配置 +│ ├── config.rs # 服务配置、数据目录与路径校验 │ ├── error.rs # 错误类型与 JSON 错误响应 │ ├── server.rs # 路由、内嵌静态资源、SSE、生命周期 │ ├── listing.rs # 托管目录扫描 -│ └── watcher.rs # 文件监听、防抖、快照广播 +│ ├── watcher.rs # 文件监听、防抖、快照广播 +│ └── db/ # SQLite 内容库 +│ ├── mod.rs # 打开、PRAGMA、种子导入时机 +│ ├── schema.rs # 建表语句与种子导入 +│ ├── catalog.rs # 查询结果组装成前端 Category[] +│ └── seed.json # 内嵌种子数据(编译进二进制) ├── stash-web/ # React + Vite + TypeScript 控制台 │ ├── package.json # 含 volta 版本固定 │ ├── vite.config.ts # 开发期 /api 代理到 127.0.0.1:8080 │ └── src/ +├── data/ # 内容库目录(与二进制同级,运行时生成) +│ └── stash.db # SQLite 内容库 └── public/ # 默认托管目录(可放多个前端项目) ``` @@ -82,6 +90,69 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000 `build.rs` 声明了 `rerun-if-changed`,改动 `dist` 后重新 `cargo build` 会自动重新嵌入。 +## 内容库(data/stash.db) + +控制台的分类 / 分组 / 条目不写死在前端代码里,而是存在一个 SQLite 文件中, +默认位置是**二进制同级**的 `data/stash.db`。首次启动时若文件不存在, +会用内嵌的 `src/db/seed.json` 建库并灌入示例内容;之后一直沿用该文件(不覆盖已有数据)。 + +用 `--data ` 可以把数据目录指到别处。 +**内容库不能放在 `--root` 托管目录内部**——那里的文件会被 `ServeDir` 当静态资源对外提供, +启动时会直接报错拒绝。 + +### 表结构 + +| 表 | 说明 | +| --- | --- | +| `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,后端会原样透传。 + +### 侧栏导航 + +侧栏的层级、标签和跳转目标都在 `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 可选 +``` + +两个细节是刻意设计的: + +- 分组名可以与分类名不同(「素材资源」并不对应任何分类); +- 多个叶子可以指向同一个目标(「前端调试工具」和「在线编辑器」都打开 `recommend` 的 `dev` 分组)。 + +删除分类时,指向它的导航叶子会被外键级联删掉,不会留下点了没反应的死链接。 + +### 自己改内容 + +1. 用任何 SQLite 工具(DB Browser for SQLite、`sqlite3` CLI、DBeaver…)打开 `data/stash.db`; +2. 分类内容改 `category` / `app_group` / `entry`,侧栏改 `nav_entry`; +3. 提交修改后,页面会在 **5 秒内**自动刷新——服务端轮询 `PRAGMA data_version` 检测外部写入; +4. 也可以点侧栏的「重新扫描」或直接刷新浏览器。 + +改坏 `data` 列(比如写成非法 JSON)时,接口会返回带「分类/条目 id」的错误信息, +页面顶部会显示出来,改回去即可。 + +### 重置 + +开发阶段表结构还会调整,**没有实现迁移**。要重置内容:关掉服务后删掉 `data/stash.db`, +下次启动会重新建表并灌入内嵌种子。 + +```bash +rm data/stash.db # Linux / macOS +Remove-Item data\stash.db # Windows PowerShell +``` + ## 前端开发 ```bash @@ -100,6 +171,7 @@ npm run lint # oxlint | 参数 | 默认值 | 说明 | | --- | --- | --- | | `-r, --root ` | `./public` | 静态页面托管根目录 | +| `--data ` | 二进制同级 `data/` | 内容库所在目录(存放 `stash.db`) | | `-p, --port ` | `8080` | 监听端口 | | `--host ` | `127.0.0.1` | 监听地址,`0.0.0.0` 供局域网访问 | | `--no-cors` | 关闭 | 关闭全局 CORS 标头注入 | @@ -152,13 +224,90 @@ data: {"projects":[{"name":"docs","route_path":"/docs/"}]} 服务端递归监听托管目录,把短时间内的连续事件合并成一次扫描,只在项目名称或访问路径真正变化时广播; 每 15 秒发送一次保活消息,浏览器断开后自动重连。 +### 内容目录 + +```http +GET /api/catalog +``` + +返回控制台需要的全部分类(结构见 [`types.ts`](stash-web/src/data/types.ts)), +数据来自 `data/stash.db`。一次全量下发,前端搜索在客户端完成: + +```json +{ + "categories": [ + { + "id": "recommend", + "kind": "apps", + "title": "常用推荐", + "barColor": "#3858f6", + "icon": "🔖", + "groups": [ + { + "id": "common", + "name": "常用站点", + "items": [ + { "id": "github", "name": "GitHub", "desc": "代码托管与开源社区", "views": 429 } + ] + } + ] + } + ] +} +``` + +「本地服务」分类**不在**这个接口里,它由 `/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" } } + ] + } + ] + } +} +``` + +### 订阅内容库变化 + +```http +GET /api/catalog/events +Accept: text/event-stream +``` + +当内容库被**其它连接**(例如用户用 DB Browser 改库)提交后推送一次,事件本身不带数据: + +```text +event: catalog +data: {"changed":true} +``` + +客户端收到后重新 `GET /api/catalog`。检测基于 SQLite 的 `PRAGMA data_version`,每 5 秒轮询一次。 + ## 测试 ```bash -cargo test # 目录扫描、隐藏目录过滤、排序、路径编码、文件监听广播 +cargo test # 目录扫描、隐藏目录过滤、排序、路径编码、文件监听广播、内容库 cd stash-web && npm run build # tsc -b + vite build,同时做类型检查 ``` +内容库相关测试全部使用**内存库**(`:memory:`),不写磁盘、不污染 `data/`。 +如果运行环境限制了系统临时目录(部分沙箱会这样),前几个测试会报 `PermissionDenied`, +把 `TEMP` / `TMP` 指到工作区内再跑即可: + +```powershell +$env:TEMP = "$PWD\target\test-tmp"; $env:TMP = $env:TEMP; cargo test +``` + ## 已知限制 - 控制台是单页应用但没有客户端路由,因此没有 SPA history 回退;刷新不存在的物理路径会返回 404。 @@ -167,4 +316,7 @@ cd stash-web && npm run build # tsc -b + vite build,同时做类型检查 托管目录里的项目统一走 `/<目录名>/`。 - 项目扫描只检查一级子目录,不递归查找更深层项目。 - 文件事件经过约 150ms 防抖,新项目出现在控制台可能有短暂延迟。 +- 内容库没有迁移机制:表结构变化时需要删掉 `data/stash.db` 重建(当前属于开发阶段)。 +- 内容库被外部工具修改后,页面最长有 5 秒延迟才自动刷新。 +- 内容库按单实例使用设计:多个进程同时写同一个库会在 `busy_timeout`(5 秒)后报错。 - 没有身份认证,默认只建议绑定 `127.0.0.1`;使用 `0.0.0.0` 暴露到局域网前请确认目录内容适合被其他设备访问。 diff --git a/src/config.rs b/src/config.rs index 8b311a6..db871db 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,9 +1,20 @@ -use std::path::PathBuf; +use std::path::{Component, Path, PathBuf}; + +use crate::error::StashError; + +/// 数据目录名:与二进制同级,存放可被用户直接修改的内容库。 +pub const DATA_DIR_NAME: &str = "data"; +/// 内容库文件名:固定为这个名字,一个实例一个库。 +pub const DB_FILE_NAME: &str = "stash.db"; #[derive(Debug, Clone)] pub struct Config { /// 静态页面托管根目录(存放一个或多个前端项目) pub root: PathBuf, + /// 数据目录:存放内容库与将来的备份 / 导出文件 + pub data_dir: PathBuf, + /// 内容库路径,固定为 `data/stash.db` + pub db_path: PathBuf, /// 监听端口 pub port: u16, /// 监听地址,默认 127.0.0.1 @@ -16,8 +27,13 @@ pub struct Config { impl Default for Config { fn default() -> Self { + // 默认值只在测试与兜底路径上使用,真实启动路径由 main.rs 解析 + let data_dir = PathBuf::from(DATA_DIR_NAME); + Self { root: PathBuf::from("../public"), + db_path: resolve_db_path(&data_dir), + data_dir, port: 8080, host: "127.0.0.1".to_string(), directory_listing: true, @@ -25,3 +41,188 @@ impl Default for Config { } } } + +/// 数据目录的默认位置:**二进制所在目录**下的 `data/`。 +/// +/// 之所以不用当前工作目录,是因为双击启动、快捷方式启动时 CWD 并不可控 +/// (可能落在 System32 这种没写权限的位置),而数据库属于程序自身状态, +/// 跟着 exe 走最稳。托管目录 `--root` 仍然保持 CWD 相对,行为不变。 +pub fn default_data_dir() -> PathBuf { + default_data_dir_from(std::env::current_exe().ok().as_deref()) +} + +/// `default_data_dir` 的纯函数版本,便于在测试里用假路径验证。 +fn default_data_dir_from(exe: Option<&Path>) -> PathBuf { + match exe.and_then(Path::parent) { + // 拿不到 exe 路径时退化成 CWD 相对目录,至少不会 panic + Some(dir) => dir.join(DATA_DIR_NAME), + None => PathBuf::from(DATA_DIR_NAME), + } +} + +/// 由数据目录推导出内容库路径。 +pub fn resolve_db_path(data_dir: &Path) -> PathBuf { + data_dir.join(DB_FILE_NAME) +} + +/// 校验内容库没有落在静态托管目录里。 +/// +/// `ServeDir` 会把 `--root` 下的所有文件当静态资源对外提供, +/// 一旦库文件在其中,任何人都能直接下载整个数据库,属于必须拦截的配置错误。 +pub fn validate_db_location(root: &Path, db_path: &Path) -> Result<(), StashError> { + let root = normalize(root); + let db = normalize(db_path); + + if db.starts_with(&root) { + return Err(StashError::InvalidDirectory(format!( + "内容库不能放在托管目录内部:库={},托管目录={}。请用 --data 指定其它位置。", + db.display(), + root.display() + ))); + } + + Ok(()) +} + +/// 去掉 Windows `canonicalize` 加上的 `\\?\` 前缀。 +/// +/// 该前缀本身合法,但会让 `Path::starts_with` 把它们当成不同的路径前缀: +/// `\\?\D:\sites` 与 `D:\sites` 的 `Prefix` 组件不相等, +/// 于是「库是否在托管目录内」的校验会被绕过。比较之前必须统一。 +pub fn strip_verbatim(path: &Path) -> PathBuf { + match path.as_os_str().to_string_lossy().strip_prefix(r"\\?\") { + Some(stripped) => PathBuf::from(stripped), + None => path.to_path_buf(), + } +} + +/// 展示用路径:去掉 `\\?\` 前缀后再转成字符串。 +/// +/// 前缀对程序无害,但打印在启动横幅和日志里很碍眼。 +pub fn display_path(path: &Path) -> String { + strip_verbatim(path).display().to_string() +} + +/// 把路径转成绝对路径,并消掉 `.` / `..` 与重复分隔符。/// +/// 这里刻意不做 `canonicalize`:校验发生在目录创建之前, +/// 目标文件往往还不存在,而 `canonicalize` 要求路径真实存在。 +/// 纯字符串层面的归一化已经足够判断「谁在谁内部」。 +fn normalize(path: &Path) -> PathBuf { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(path) + }; + + // `canonicalize` 产出的 `\\?\` 前缀会让后续 starts_with 比较失效,先统一掉 + let absolute = strip_verbatim(&absolute); + + let mut normalized = PathBuf::new(); + + for component in absolute.components() { + match component { + // 当前目录不产生任何变化 + Component::CurDir => {} + // 父目录回退一级;已经到根时 pop 是空操作,正好不会越界 + Component::ParentDir => { + normalized.pop(); + } + other => normalized.push(other.as_os_str()), + } + } + + normalized +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn data_dir_follows_the_binary_directory() { + let dir = default_data_dir_from(Some(Path::new(r"D:\apps\stash\stash.exe"))); + assert_eq!(dir, PathBuf::from(r"D:\apps\stash\data")); + } + + #[test] + fn data_dir_falls_back_to_relative_when_exe_path_is_unknown() { + assert_eq!(default_data_dir_from(None), PathBuf::from(DATA_DIR_NAME)); + } + + #[test] + fn db_path_is_fixed_inside_the_data_dir() { + assert_eq!( + resolve_db_path(Path::new(r"D:\apps\stash\data")), + PathBuf::from(r"D:\apps\stash\data\stash.db") + ); + } + + #[test] + fn rejects_database_nested_inside_the_hosted_root() { + let err = validate_db_location( + Path::new(r"D:\sites"), + Path::new(r"D:\sites\data\stash.db"), + ); + assert!(err.is_err(), "库在托管目录内时必须拒绝启动"); + } + + #[test] + fn accepts_database_beside_the_hosted_root() { + assert!( + validate_db_location( + Path::new(r"D:\stash\public"), + Path::new(r"D:\stash\data\stash.db"), + ) + .is_ok() + ); + } + + #[test] + fn location_check_survives_dot_segments() { + // `..` 归一化之后应认出这是同一个目录,而不是被绕过 + assert!( + validate_db_location( + Path::new(r"D:\stash\sites"), + Path::new(r"D:\stash\sites\..\sites\stash.db"), + ) + .is_err() + ); + } + + /// 回归测试:`canonicalize` 会给路径加 `\\?\` 前缀, + /// 若比较前不统一,库落在托管目录内这种配置错误会被漏过。 + #[test] + fn location_check_ignores_verbatim_prefix() { + assert!( + validate_db_location( + Path::new(r"\\?\D:\stash\sites"), + Path::new(r"D:\stash\sites\data\stash.db"), + ) + .is_err(), + "带 \\\\?\\ 前缀的托管目录也必须能拦住库" + ); + + assert!( + validate_db_location( + Path::new(r"D:\stash\sites"), + Path::new(r"\\?\D:\stash\sites\data\stash.db"), + ) + .is_err(), + "带 \\\\?\\ 前缀的库路径也必须能被识别" + ); + } + + #[test] + fn verbatim_prefix_is_stripped() { + assert_eq!( + strip_verbatim(Path::new(r"\\?\D:\apps\stash")), + PathBuf::from(r"D:\apps\stash") + ); + assert_eq!( + strip_verbatim(Path::new(r"D:\apps\stash")), + PathBuf::from(r"D:\apps\stash") + ); + } +} diff --git a/src/db/catalog.rs b/src/db/catalog.rs new file mode 100644 index 0000000..faf546c --- /dev/null +++ b/src/db/catalog.rs @@ -0,0 +1,321 @@ +//! 内容库的查询组装:把关系表还原成前端 `types.ts` 里的 `Category[]`。 +//! +//! 这里输出的是 `serde_json::Value` 而不是一堆 Rust DTO,理由是: +//! 条目的可变字段本来就存在 JSON 列里,直接用 Value 拼装可以保证 +//! 「用户往 JSON 里加什么字段,前端就能读到什么字段」,不需要每加一个 +//! 字段就改一次 Rust 结构体。 + +use rusqlite::Connection; +use serde_json::{Map, Value, json}; + +use crate::{ + db::{Catalog, map_error}, + error::StashError, +}; + +/// 分类行:与 `category` 表一一对应 +struct CategoryRow { + id: String, + kind: String, + title: String, + bar_color: String, + icon: String, + preview_limit: Option, + empty_text: Option, +} + +/// 导航行:侧栏自引用树的原始形态 +struct NavRow { + id: String, + parent_id: Option, + label: String, + category_id: Option, + group_id: Option, +} + +/// 条目行:`data` 里是尚未展开的 JSON 文本 +struct EntryRow { + id: String, + group_id: Option, + name: String, + views: i64, + likes: i64, + data: String, +} + +/// 读出整个内容目录:分类树 + 侧栏导航。 +pub fn load(conn: &Connection) -> Result { + Ok(Catalog { + categories: load_categories(conn)?, + nav: load_nav(conn)?, + }) +} + +/// 读出全部分类,按 `sort` 排序;顺序即首页分区顺序。 +fn load_categories(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", + ) + .map_err(map_error)?; + + let categories = stmt + .query_map([], |row| { + Ok(CategoryRow { + id: row.get(0)?, + kind: row.get(1)?, + title: row.get(2)?, + bar_color: row.get(3)?, + icon: row.get(4)?, + preview_limit: row.get(5)?, + empty_text: row.get(6)?, + }) + }) + .map_err(map_error)? + .collect::, _>>() + .map_err(map_error)?; + + // 先释放语句再发后续查询,避免同一连接上的借用冲突 + drop(stmt); + + categories + .iter() + .map(|category| build_category(conn, category)) + .collect() +} + +/// 按 kind 分派到不同的组装方式。 +fn build_category(conn: &Connection, category: &CategoryRow) -> Result { + 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)); + + // 可选字段:没配就不下发,交给前端用 `?? 默认值` 处理 + if let Some(limit) = category.preview_limit { + object.insert("previewLimit".into(), json!(limit)); + } + if let Some(empty_text) = &category.empty_text { + object.insert("emptyText".into(), json!(empty_text)); + } + + match category.kind.as_str() { + "apps" => object.insert("groups".into(), build_groups(conn, category)?), + "posters" | "articles" => object.insert("items".into(), build_items(conn, category)?), + other => { + return Err(StashError::Database(format!( + "分类 {} 的 kind 非法: {other}", + category.id + ))); + } + }; + + Ok(Value::Object(object)) +} + +/// 应用类:先取出分组,再把条目按 `group_id` 分派进去。 +/// +/// 只用两条查询(分组一条、条目一条)而不是每组一条,避免 N+1。 +fn build_groups(conn: &Connection, category: &CategoryRow) -> Result { + let mut stmt = conn + .prepare("SELECT id, name FROM app_group WHERE category_id = ?1 ORDER BY sort, id") + .map_err(map_error)?; + + let groups = stmt + .query_map([&category.id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(map_error)? + .collect::, _>>() + .map_err(map_error)?; + + drop(stmt); + + let entries = load_entries(conn, &category.id)?; + + let mut rendered_groups = Vec::with_capacity(groups.len()); + + for (group_id, group_name) in groups { + let mut items = Vec::new(); + + // 保持 entry.sort 决定的顺序;group_id 为空的条目不属于任何分组 + for entry in entries.iter().filter(|entry| { + entry.group_id.as_deref() == Some(group_id.as_str()) + }) { + items.push(Value::Object(entry_object(entry, &category.id, "name")?)); + } + + rendered_groups.push(json!({ + "id": group_id, + "name": group_name, + "items": items, + })); + } + + Ok(Value::Array(rendered_groups)) +} + +/// 海报 / 图文类:没有分组,直接输出条目数组。 +fn build_items(conn: &Connection, category: &CategoryRow) -> Result { + let entries = load_entries(conn, &category.id)?; + + let items = entries + .iter() + .map(|entry| entry_object(entry, &category.id, "title").map(Value::Object)) + .collect::, _>>()?; + + Ok(Value::Array(items)) +} + +/// 取某个分类下的全部条目,按 `sort` 升序。 +fn load_entries(conn: &Connection, category_id: &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", + ) + .map_err(map_error)?; + + let entries = stmt + .query_map([category_id], |row| { + Ok(EntryRow { + id: row.get(0)?, + group_id: row.get(1)?, + name: row.get(2)?, + views: row.get(3)?, + likes: row.get(4)?, + data: row.get(5)?, + }) + }) + .map_err(map_error)? + .collect::, _>>() + .map_err(map_error)?; + + 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 { + let mut stmt = conn + .prepare( + "SELECT id, parent_id, label, category_id, group_id + FROM nav_entry + ORDER BY sort, id", + ) + .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)?, + }) + }) + .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, + })); + } + + 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), + }) +} diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..bf4e950 --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,301 @@ +//! 内容库(SQLite)。 +//! +//! 三条设计约束: +//! +//! 1. **单文件**:库固定是 `data/stash.db`,用 `journal_mode=DELETE`, +//! 不会像 WAL 那样在磁盘上多出 `-wal` / `-shm` 两个旁文件。 +//! 2. **可编辑**:用户可以用任何 SQLite 工具直接改库;服务只读,不独占文件。 +//! 3. **开发期不做迁移**:表结构还在变,改结构就删掉 `data/stash.db` 重启, +//! 启动时会重新建表并灌入内嵌种子。 + +mod catalog; +mod schema; + +use std::sync::Mutex; + +use rusqlite::Connection; +use serde_json::Value; +use tracing::{info, warn}; + +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, +} + +/// 内容库句柄。 +/// +/// rusqlite 的连接是 `!Sync` 的同步对象,这里用一把互斥锁包起来: +/// 本地工具并发极低,单连接足够,没必要引入连接池。 +/// 所有耗时操作都应放在 `spawn_blocking` 里调用(见 server.rs)。 +pub struct Db { + conn: Mutex, +} + +impl Db { + /// 打开(必要时创建)内容库。 + /// + /// 顺序很重要:先建目录,再连库,再设 PRAGMA,最后建表 / 判断是否需要灌种子。 + pub fn open(config: &Config) -> Result { + std::fs::create_dir_all(&config.data_dir)?; + + let mut conn = Connection::open(&config.db_path).map_err(map_error)?; + + // busy_timeout 让用户用外部工具改库时的短暂写锁变成等待而不是立刻失败 + conn.execute_batch( + "PRAGMA journal_mode = DELETE; + PRAGMA synchronous = FULL; + PRAGMA foreign_keys = ON; + PRAGMA busy_timeout = 5000;", + ) + .map_err(map_error)?; + + schema::apply(&conn)?; + + // 结构版本:开发期只写不校验,等结构稳定后再拿它做迁移判断 + conn.pragma_update(None, "user_version", 1) + .map_err(map_error)?; + + // 空库才灌种子:用户删掉内置条目后重启不会被重新塞回来 + let existing: i64 = conn + .query_row("SELECT COUNT(*) FROM category", [], |row| row.get(0)) + .map_err(map_error)?; + + if existing == 0 { + let report = schema::seed(&mut conn)?; + info!( + "内容库为空,已导入内嵌种子:{} 分类 / {} 分组 / {} 条目 / {} 导航节点", + report.categories, report.groups, report.entries, report.nav_items + ); + } 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 { + conn: Mutex::new(conn), + }) + } + + /// 读出完整的分类树与侧栏导航,供 `/api/catalog` 直接序列化。 + pub fn load_catalog(&self) -> Result { + let conn = self.lock()?; + catalog::load(&conn) + } + + /// SQLite 的 `data_version`:**其它连接**提交后该值会变,本连接自己的写入不变。 + /// + /// 正好用来发现「用户用 DB Browser 改了库」,SSE 靠轮询它来推送刷新。 + pub fn data_version(&self) -> Result { + let conn = self.lock()?; + conn.pragma_query_value(None, "data_version", |row| row.get::<_, i64>(0)) + .map_err(map_error) + } + + /// 取连接;锁中毒说明此前某个线程 panic 过,此时继续用库并不安全。 + fn lock(&self) -> Result, StashError> { + self.conn + .lock() + .map_err(|_| StashError::Database("数据库连接锁已损坏,请重启进程".into())) + } +} + +/// 把 rusqlite 的错误统一收敛成 `StashError::Database`。 +pub(crate) fn map_error(error: rusqlite::Error) -> StashError { + StashError::Database(error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 建一个内存库并灌种子;内存库不落盘,测试不受临时目录权限影响。 + fn seeded() -> Connection { + let mut conn = Connection::open_in_memory().expect("内存库应能打开"); + conn.execute_batch("PRAGMA foreign_keys = ON;") + .expect("PRAGMA 应能设置"); + schema::apply(&conn).expect("建表应成功"); + schema::seed(&mut conn).expect("种子导入应成功"); + conn + } + + #[test] + fn seed_fills_every_category_group_and_entry() { + let conn = seeded(); + + let categories: i64 = conn + .query_row("SELECT COUNT(*) FROM category", [], |row| row.get(0)) + .expect("应能统计分类"); + let groups: i64 = conn + .query_row("SELECT COUNT(*) FROM app_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("应能统计导航节点"); + + assert_eq!(categories, 3, "种子应含 常用推荐 / 推荐电影 / CES 三个分类"); + assert_eq!(groups, 5, "常用推荐应有 5 个分组"); + assert_eq!(entries, 42, "18 应用 + 12 电影 + 12 图文"); + assert_eq!(nav_items, 8, "1 个顶层直达 + 2 个折叠分组 + 5 个叶子"); + } + + #[test] + fn catalog_matches_the_frontend_model() { + let conn = seeded(); + let catalog = catalog::load(&conn).expect("应能组装内容目录"); + let categories = &catalog.categories; + + assert_eq!(categories.len(), 3); + + let apps = &categories[0]; + assert_eq!(apps["id"], "recommend"); + assert_eq!(apps["kind"], "apps"); + assert_eq!(apps["barColor"], "#3858f6"); + assert_eq!(apps["groups"].as_array().expect("应有分组").len(), 5); + + let first_app = &apps["groups"][0]["items"][0]; + assert_eq!(first_app["name"], "GitHub"); + assert_eq!(first_app["hot"], true); + // 计数从 JSON 提到了独立列,这里确认读取时又合并回来了 + assert_eq!(first_app["views"], 429); + + let posters = &categories[1]; + assert_eq!(posters["kind"], "posters"); + // 海报与图文用 title 而不是 name + assert_eq!( + posters["items"][0].get("name"), + None, + "海报不应同时下发 name 字段" + ); + assert!(posters["items"][0]["title"].is_string()); + + 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"); + assert!( + top_level[0]["target"].get("groupId").is_none(), + "整分类直达时不应下发 groupId" + ); + + 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"] + .as_array() + .expect("应有顶层数组") + .is_empty(), + "指向已删分类的顶层链接应被级联删除" + ); + } + + #[test] + fn broken_json_names_the_offending_entry() { + let conn = seeded(); + conn.execute( + "UPDATE entry SET data = '{oops' WHERE category_id = 'recommend' AND id = 'github'", + [], + ) + .expect("应能写入坏数据"); + + let error = catalog::load(&conn).expect_err("坏 JSON 应报错"); + let message = error.to_string(); + + assert!(message.contains("recommend/github"), "报错要能定位到具体条目: {message}"); + } + + #[test] + fn data_version_is_readable() { + let db = Db { + conn: Mutex::new(seeded()), + }; + assert!(db.data_version().expect("应能读取 data_version") >= 0); + } + + /// 回归测试:外部连接提交后,服务端连接读到的 data_version 必须变化。 + /// + /// 这是「用户用 DB Browser 改库,页面自动刷新」整条链路的地基; + /// 用文件库而不是内存库,因为内存库无法被第二个连接看到。 + #[test] + fn data_version_changes_when_another_connection_commits() { + use crate::config::Config; + + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/db-version"); + std::fs::create_dir_all(&dir).expect("应能创建测试目录"); + let path = dir.join("probe.db"); + let _ = std::fs::remove_file(&path); + + let config = Config { + root: dir.clone(), + data_dir: dir.clone(), + db_path: path.clone(), + ..Config::default() + }; + + let db = Db::open(&config).expect("应能打开文件库"); + let before = db.data_version().expect("应能读取初始版本"); + + // 模拟外部工具:另开一个连接提交一次写事务 + let external = Connection::open(&path).expect("外部连接应能打开"); + external + .execute( + "UPDATE entry SET name = '改过的名字' WHERE category_id = 'recommend' AND id = 'github'", + [], + ) + .expect("外部写入应成功"); + drop(external); + + let after = db.data_version().expect("应能读取新版本"); + assert_ne!(before, after, "外部提交后 data_version 必须变化"); + } +} diff --git a/src/db/schema.rs b/src/db/schema.rs new file mode 100644 index 0000000..2230c63 --- /dev/null +++ b/src/db/schema.rs @@ -0,0 +1,361 @@ +//! 内容库的表结构与种子导入。 +//! +//! 表结构刻意保持「薄」:分类和分组是关系表,条目除 id / 排序 / 计数外的 +//! 全部字段都塞进 `entry.data` 的 JSON 列。原因是前端 `types.ts` 用的是 +//! 可辨识联合(apps / posters / articles),三种形态字段差异很大, +//! 拆成三张表会让查询和后续加字段都变啰嗦;JSON 列则天然与前端结构 1:1。 + +use rusqlite::{Connection, params}; +use serde::Deserialize; +use serde_json::{Map, Value}; + +use crate::{db::map_error, error::StashError}; + +/// 种子文件在编译期内嵌,分发时不需要额外带数据文件。 +const SEED_JSON: &str = include_str!("seed.json"); + +/// 建表语句。 +/// +/// 开发阶段结构还会调整,所以这里只做 `IF NOT EXISTS` 式的«建库», +/// 不实现版本迁移:需要改结构时删掉 `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 +); + +-- 应用类分类的分组:明细页按组展示,首页把各组前几项拼成预览 +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) +); + +-- 条目:应用 / 海报 / 图文共用一张表 +-- 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) +); + +CREATE INDEX IF NOT EXISTS entry_group_idx ON entry(category_id, group_id, 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 +); +"#; + +/// 种子导入结果,仅用于启动日志。 +#[derive(Debug, Default, PartialEq, Eq)] +pub struct SeedReport { + pub categories: usize, + pub groups: usize, + pub entries: usize, + /// 侧栏导航节点数(顶层项 + 叶子) + pub nav_items: usize, +} + +/// 建表(幂等):每次启动都跑一遍,缺表就补,已有数据不动。 +pub fn apply(conn: &Connection) -> Result<(), StashError> { + conn.execute_batch(SCHEMA_SQL).map_err(map_error) +} + +/// 把内嵌种子写进空库。 +/// +/// 只在数据库文件是本次启动新建时调用,所以不存在「覆盖用户数据」的问题; +/// 用户删掉库以后重启会重新得到一份干净的内置内容,这也是开发期的重置手段。 +pub fn seed(conn: &mut Connection) -> Result { + let seed: SeedFile = serde_json::from_str(SEED_JSON) + .map_err(|error| StashError::Database(format!("种子文件不是合法 JSON: {error}")))?; + + let mut report = SeedReport::default(); + // 单事务写入:避免中途失败留下半个内容库 + let tx = conn.transaction().map_err(map_error)?; + + for category in &seed.categories { + let kind = category.kind.as_str(); + + // 只认识前端支持的三种形态,拼错时立刻报错而不是静默写脏数据 + if !matches!(kind, "apps" | "posters" | "articles") { + return Err(StashError::Database(format!( + "种子分类 {} 的 kind 非法: {kind}", + category.id + ))); + } + + 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)", + params![ + category.id, + kind, + category.title, + category.bar_color, + category.icon, + category.preview_limit, + category.empty_text, + category.sort, + ], + ) + .map_err(map_error)?; + report.categories += 1; + + if kind == "apps" { + for group in &category.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], + ) + .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; + } + } + continue; + } + + // 海报与图文没有分组,直接挂在分类下 + for (index, item) in category.items.iter().enumerate() { + insert_entry(&tx, &category.id, None, index, item)?; + report.entries += 1; + } + } + + // 侧栏导航必须放在分类之后插入:叶子对 category(id) 有外键依赖 + report.nav_items = seed_nav(&tx, &seed.nav)?; + + tx.commit().map_err(map_error)?; + + Ok(report) +} + +/// 导入侧栏导航。 +/// +/// 先插顶层项(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。 +fn insert_entry( + tx: &rusqlite::Transaction<'_>, + category_id: &str, + group_id: Option<&str>, + index: usize, + item: &SeedItem, +) -> 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)", + params![ + category_id, + item.id, + group_id, + item.name, + views, + likes, + index as i64, + data, + ], + ) + .map_err(map_error)?; + + Ok(()) +} + +/// 从 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, + } +} + +// --------------------------------------------------------------------------- +// 种子文件的反序列化结构 +// --------------------------------------------------------------------------- + +/// 种子文件根节点 +#[derive(Debug, Deserialize)] +struct SeedFile { + categories: Vec, + /// 侧栏导航;缺省时侧栏为空,但不影响分类内容 + #[serde(default)] + nav: SeedNav, +} + +/// 侧栏导航:顶层直达项 + 可折叠分组 +#[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, + title: String, + bar_color: String, + icon: String, + sort: i64, + #[serde(default)] + preview_limit: Option, + #[serde(default)] + empty_text: Option, + #[serde(default)] + groups: Vec, + #[serde(default)] + items: Vec, +} + +/// 应用类分类下的一个分组 +#[derive(Debug, Deserialize)] +struct SeedGroup { + id: String, + name: String, + sort: i64, + items: Vec, +} + +/// 一条内容;除 id / name 外全部字段原样落进 JSON 列。 +/// +/// 用 `flatten` 收集剩余键,这样种子文件里新增字段不需要同步改 Rust 结构。 +#[derive(Debug, Deserialize)] +struct SeedItem { + id: String, + name: String, + #[serde(flatten)] + extra: Map, +} diff --git a/src/db/seed.json b/src/db/seed.json new file mode 100644 index 0000000..7c14143 --- /dev/null +++ b/src/db/seed.json @@ -0,0 +1,986 @@ +{ + "categories": [ + { + "id": "recommend", + "kind": "apps", + "title": "常用推荐", + "barColor": "#3858f6", + "icon": "🔖", + "sort": 1, + "groups": [ + { + "id": "common", + "name": "常用站点", + "sort": 1, + "items": [ + { + "id": "github", + "name": "GitHub", + "desc": "代码托管与开源社区", + "icon": "G", + "href": "#", + "cover": "https://picsum.photos/seed/github/200/200", + "hot": true, + "comment": "收录在本地导航里的常用站点,点开就能直达。", + "banner": "https://picsum.photos/seed/github-banner/800/300", + "badge": "推荐好站", + "views": 429, + "likes": 38 + }, + { + "id": "zhihu", + "name": "知乎", + "desc": "深度内容问答社区", + "icon": "Z", + "href": "#", + "cover": "https://picsum.photos/seed/zhihu/200/200", + "hot": false, + "comment": "收录在本地导航里的常用站点,点开就能直达。", + "banner": "https://picsum.photos/seed/zhihu-banner/800/300", + "badge": "推荐好站", + "views": 294, + "likes": 73 + }, + { + "id": "bilibili", + "name": "哔哩哔哩", + "desc": "弹幕视频分享网站", + "icon": "B", + "href": "#", + "cover": "https://picsum.photos/seed/bilibili/200/200", + "hot": true, + "comment": "收录在本地导航里的常用站点,点开就能直达。", + "banner": "https://picsum.photos/seed/bilibili-banner/800/300", + "badge": "推荐好站", + "views": 280, + "likes": 9 + }, + { + "id": "v2ex", + "name": "V2EX", + "desc": "创意工作者讨论社区", + "icon": "V", + "href": "#", + "cover": "https://picsum.photos/seed/v2ex/200/200", + "hot": false, + "comment": "收录在本地导航里的常用站点,点开就能直达。", + "banner": "https://picsum.photos/seed/v2ex-banner/800/300", + "badge": "推荐好站", + "views": 387, + "likes": 72 + } + ] + }, + { + "id": "ui", + "name": "UI 设计资源", + "sort": 2, + "items": [ + { + "id": "wallhaven", + "name": "Wallhaven", + "desc": "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": "推荐好站", + "views": 608, + "likes": 51 + }, + { + "id": "500px", + "name": "500px", + "desc": "有着世界上最好的照片分享社区", + "icon": "5", + "href": "#", + "cover": "https://picsum.photos/seed/500px/200/200", + "hot": false, + "comment": "收录在本地导航里的常用站点,点开就能直达。", + "banner": "https://picsum.photos/seed/500px-banner/800/300", + "badge": "推荐好站", + "views": 573, + "likes": 68 + }, + { + "id": "chuangyiren", + "name": "创意人", + "desc": "去掉了形式主义描述的原创社区", + "icon": "C", + "href": "#", + "cover": "https://picsum.photos/seed/chuangyiren/200/200", + "hot": false, + "comment": "收录在本地导航里的常用站点,点开就能直达。", + "banner": "https://picsum.photos/seed/chuangyiren-banner/800/300", + "badge": "推荐好站", + "views": 364, + "likes": 20 + }, + { + "id": "topys", + "name": "顶尖文案", + "desc": "全球顶尖创意分享平台 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": "推荐好站", + "views": 745, + "likes": 68 + }, + { + "id": "uichina", + "name": "UI中国", + "desc": "前身为 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": "推荐好站", + "views": 743, + "likes": 4 + } + ] + }, + { + "id": "vector", + "name": "矢量图标", + "sort": 3, + "items": [ + { + "id": "iconfont", + "name": "Iconfont", + "desc": "阿里妈妈矢量图标库", + "icon": "I", + "href": "#", + "cover": "https://picsum.photos/seed/iconfont/200/200", + "hot": false, + "comment": "收录在本地导航里的常用站点,点开就能直达。", + "banner": "https://picsum.photos/seed/iconfont-banner/800/300", + "badge": "推荐好站", + "views": 585, + "likes": 41 + }, + { + "id": "feather", + "name": "Feather Icons", + "desc": "简单漂亮的开源图标库", + "icon": "F", + "href": "#", + "cover": "https://picsum.photos/seed/feather/200/200", + "hot": false, + "comment": "收录在本地导航里的常用站点,点开就能直达。", + "banner": "https://picsum.photos/seed/feather-banner/800/300", + "badge": "推荐好站", + "views": 390, + "likes": 48 + }, + { + "id": "fontawesome", + "name": "FontAwesome", + "desc": "图标字体与 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": "推荐好站", + "views": 317, + "likes": 17 + } + ] + }, + { + "id": "wallpaper", + "name": "免费壁纸", + "sort": 4, + "items": [ + { + "id": "unsplash", + "name": "Unsplash", + "desc": "免费高清无版权摄影图片", + "icon": "U", + "href": "#", + "cover": "https://picsum.photos/seed/unsplash/200/200", + "hot": false, + "comment": "收录在本地导航里的常用站点,点开就能直达。", + "banner": "https://picsum.photos/seed/unsplash-banner/800/300", + "badge": "推荐好站", + "views": 807, + "likes": 5 + }, + { + "id": "pexels", + "name": "Pexels", + "desc": "才华横溢的摄影师分享的素材", + "icon": "P", + "href": "#", + "cover": "https://picsum.photos/seed/pexels/200/200", + "hot": false, + "comment": "收录在本地导航里的常用站点,点开就能直达。", + "banner": "https://picsum.photos/seed/pexels-banner/800/300", + "badge": "推荐好站", + "views": 380, + "likes": 12 + }, + { + "id": "pixabay", + "name": "Pixabay", + "desc": "超过 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": "推荐好站", + "views": 771, + "likes": 83 + } + ] + }, + { + "id": "dev", + "name": "开发工具", + "sort": 5, + "items": [ + { + "id": "codepen", + "name": "CodePen", + "desc": "前端在线代码演示和练习场", + "icon": "C", + "href": "#", + "cover": "https://picsum.photos/seed/codepen/200/200", + "hot": false, + "comment": "收录在本地导航里的常用站点,点开就能直达。", + "banner": "https://picsum.photos/seed/codepen-banner/800/300", + "badge": "推荐好站", + "views": 266, + "likes": 13 + }, + { + "id": "stackoverflow", + "name": "Stack Overflow", + "desc": "全球程序员技术问答社区", + "icon": "S", + "href": "#", + "cover": "https://picsum.photos/seed/stackoverflow/200/200", + "hot": false, + "comment": "收录在本地导航里的常用站点,点开就能直达。", + "banner": "https://picsum.photos/seed/stackoverflow-banner/800/300", + "badge": "推荐好站", + "views": 112, + "likes": 25 + }, + { + "id": "github-dev", + "name": "GitHub", + "desc": "代码托管与开源协作社区", + "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": "推荐好站", + "views": 587, + "likes": 32 + } + ] + } + ] + }, + { + "id": "movie", + "kind": "posters", + "title": "推荐电影", + "barColor": "#ff6034", + "icon": "🎬", + "sort": 2, + "items": [ + { + "id": "movie-1", + "name": "千与千寻 千と千尋の神隠し (2001)", + "cover": "https://picsum.photos/seed/m1/300/450", + "tags": [ + "推荐电影", + "豆瓣电影 Top250" + ], + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "少女千寻误入神灵世界,为救回变成猪的父母,在汤屋打工并逐渐学会担当。", + "href": "#", + "views": 896, + "likes": 51 + }, + { + "id": "movie-2", + "name": "美丽人生 La vita è bella (1997)", + "cover": "https://picsum.photos/seed/m2/300/450", + "tags": [ + "推荐电影", + "豆瓣电影 Top250" + ], + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "二战集中营里,父亲用一场「游戏」保护儿子,让他相信世界仍然美好。", + "href": "#", + "views": 897, + "likes": 20 + }, + { + "id": "movie-3", + "name": "这个杀手不太冷 Léon (1994)", + "cover": "https://picsum.photos/seed/m3/300/450", + "tags": [ + "推荐电影", + "豆瓣电影 Top250" + ], + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "孤独的职业杀手莱昂收留了邻居少女玛蒂尔达,两个边缘人相互依靠。", + "href": "#", + "views": 898, + "likes": 55 + }, + { + "id": "movie-4", + "name": "阿甘正传 Forrest Gump (1994)", + "cover": "https://picsum.photos/seed/m4/300/450", + "tags": [ + "推荐电影", + "豆瓣电影 Top250" + ], + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "智商不高的阿甘凭着一股单纯的执着,意外见证并参与了几十年美国历史。", + "href": "#", + "views": 899, + "likes": 32 + }, + { + "id": "movie-5", + "name": "霸王别姬 (1993)", + "cover": "https://picsum.photos/seed/m5/300/450", + "tags": [ + "推荐电影", + "豆瓣电影 Top250" + ], + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "两位京剧伶人半个世纪的悲欢离合,与时代洪流交织在一起。", + "href": "#", + "views": 900, + "likes": 59 + }, + { + "id": "movie-6", + "name": "肖申克的救赎 The Shawshank Redemption", + "cover": "https://picsum.photos/seed/m6/300/450", + "tags": [ + "推荐电影", + "豆瓣电影 Top250" + ], + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "蒙冤入狱的银行家安迪,在漫长牢狱中守住希望并完成自救。", + "href": "#", + "views": 901, + "likes": 36 + }, + { + "id": "movie-7", + "name": "泰坦尼克号 Titanic (1997)", + "cover": "https://picsum.photos/seed/m7/300/450", + "tags": [ + "推荐电影", + "豆瓣电影 Top250" + ], + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "沉船之夜,来自不同阶层的杰克与露丝相爱,也被人性的选择所考验。", + "href": "#", + "views": 902, + "likes": 13 + }, + { + "id": "movie-8", + "name": "盗梦空间 Inception (2010)", + "cover": "https://picsum.photos/seed/m8/300/450", + "tags": [ + "推荐电影", + "豆瓣电影 Top250" + ], + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "盗梦团队潜入层层梦境,试图在目标的潜意识里植入一个念头。", + "href": "#", + "views": 903, + "likes": 48 + }, + { + "id": "movie-9", + "name": "星际穿越 Interstellar (2014)", + "cover": "https://picsum.photos/seed/m9/300/450", + "tags": [ + "推荐电影", + "豆瓣电影 Top250" + ], + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "地球濒临枯竭,父亲穿越虫洞寻找新家园,与女儿跨越时间重逢。", + "href": "#", + "views": 904, + "likes": 17 + }, + { + "id": "movie-10", + "name": "楚门的世界 The Truman Show (1998)", + "cover": "https://picsum.photos/seed/m10/300/450", + "tags": [ + "推荐电影", + "豆瓣电影 Top250" + ], + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "楚门渐渐发现,自己的整个人生其实是一档被全世界围观的真人秀。", + "href": "#", + "views": 608, + "likes": 45 + }, + { + "id": "movie-11", + "name": "疯狂动物城 Zootopia (2016)", + "cover": "https://picsum.photos/seed/m11/300/450", + "tags": [ + "推荐电影", + "豆瓣电影 Top250" + ], + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "兔子警官与狐狸搭档,在动物城里侦破一桩失踪案,也打破偏见。", + "href": "#", + "views": 609, + "likes": 22 + }, + { + "id": "movie-12", + "name": "大话西游之大圣娶亲 (1995)", + "cover": "https://picsum.photos/seed/m12/300/450", + "tags": [ + "推荐电影", + "豆瓣电影 Top250" + ], + "comment": "收藏在本地片单里,闲下来就重看一遍。", + "summary": "至尊宝在爱情与宿命之间反复抉择,最终戴上金箍踏上取经路。", + "href": "#", + "views": 610, + "likes": 57 + } + ] + }, + { + "id": "ces", + "kind": "articles", + "title": "CES 现场报道", + "barColor": "#ffba3a", + "icon": "🏷️", + "sort": 3, + "items": [ + { + "id": "ces-1", + "name": "可塑性极强的玻璃,在设计师手里有了更多可能 | 这个设计了不起", + "desc": "前沿科技", + "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": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" + } + ] + }, + { + "id": "ces-2", + "name": "废旧共享单车如何变成微型共享书屋? | 这个设计了不起", + "desc": "环保设计", + "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": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" + } + ] + }, + { + "id": "ces-3", + "name": "荧光灯管与混凝土块的组合,可以玩出多少花样? | 这个设计了不起", + "desc": "材料探索", + "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": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" + } + ] + }, + { + "id": "ces-4", + "name": "混凝土做的模块化收纳框,还能改成搁架用 | 这个设计了不起", + "desc": "家居模块", + "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": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" + } + ] + }, + { + "id": "ces-5", + "name": "这个用拉伸材料做的头盔,还能帮你呼叫救护车 | 这个设计了不起", + "desc": "智能穿戴", + "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": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" + } + ] + }, + { + "id": "ces-6", + "name": "你如何选择字体,简单易读还是好看有趣? | 这个设计了不起", + "desc": "视觉体验", + "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": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" + } + ] + }, + { + "id": "ces-7", + "name": "家里空间小?你需要几把可以悬挂在墙上的椅子 | 这个设计了不起", + "desc": "空间收纳", + "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": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" + } + ] + }, + { + "id": "ces-8", + "name": "宜家厨房家具又被改造了,色彩是亮点 | 这个设计了不起", + "desc": "色彩灵感", + "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": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" + } + ] + }, + { + "id": "ces-9", + "name": "这款诞生 50 年的扶手椅,推出了特别版配色 | 这个设计了不起", + "desc": "经典产品", + "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": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" + } + ] + }, + { + "id": "ces-10", + "name": "让家和办公室界限变得模糊的一组家具 | 这个设计了不起", + "desc": "办公家具", + "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": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" + } + ] + }, + { + "id": "ces-11", + "name": "这个酒店设计奖,挖掘过去一年体验最好的酒店 | 这个设计了不起", + "desc": "空间建筑", + "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": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" + } + ] + }, + { + "id": "ces-12", + "name": "一款可移动办公桌,挂墙上当作白板就能写写画画 | 这个设计了不起", + "desc": "移动办公", + "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": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。" + } + ] + } + ] + } + ], + "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/error.rs b/src/error.rs index 33ada44..9691753 100644 --- a/src/error.rs +++ b/src/error.rs @@ -25,6 +25,11 @@ pub enum StashError { #[error("未找到资源: {0}")] NotFound(String), + + /// 数据库相关失败:打开、建表、种子导入、查询。 + /// 统一收敛成一个变体,避免把 rusqlite 的类型泄漏到库的公开 API。 + #[error("数据库错误: {0}")] + Database(String), } impl IntoResponse for StashError { diff --git a/src/lib.rs b/src/lib.rs index 83319d4..ad82fac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,12 @@ pub mod config; +pub mod db; pub mod error; pub mod listing; pub mod server; mod watcher; pub use config::Config; +pub use db::Db; pub use error::StashError; pub use listing::{AppProject, scan_projects}; pub use server::{ServerHandle, start}; diff --git a/src/main.rs b/src/main.rs index b9534d9..2c2f37b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,11 @@ use std::path::PathBuf; use clap::Parser; use colored::*; -use stash::{Config, start}; +use stash::{ + Config, + config::{default_data_dir, display_path, resolve_db_path, validate_db_location}, + start, +}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[derive(Parser, Debug)] @@ -17,6 +21,10 @@ struct Cli { #[arg(short, long, default_value = "./public", value_name = "DIR")] root: PathBuf, + /// 数据目录(默认是与二进制同级的 data/,用于存放 stash.db) + #[arg(long, value_name = "DIR")] + data: Option, + /// 监听端口 #[arg(short, long, default_value_t = 8080, value_name = "PORT")] port: u16, @@ -69,9 +77,28 @@ async fn main() -> anyhow::Result<()> { let abs_root = cli.root.canonicalize().unwrap_or_else(|_| cli.root.clone()); - // 3. 构造 stash-core 配置并启动 + // 3. 解析数据目录:默认跟随二进制(双击启动时 CWD 不可控),可用 --data 覆盖 + let data_dir = match cli.data { + Some(dir) => dir, + None => default_data_dir(), + }; + + // 先校验再建目录:配置错了就不该在磁盘上留下任何痕迹 + let db_path = resolve_db_path(&data_dir); + if let Err(error) = validate_db_location(&abs_root, &db_path) { + eprintln!("{} {}", "[ERROR]".red().bold(), error); + std::process::exit(1); + } + + std::fs::create_dir_all(&data_dir)?; + let data_dir = data_dir.canonicalize().unwrap_or(data_dir); + let db_path = resolve_db_path(&data_dir); + + // 4. 构造配置并启动 let config = Config { root: abs_root.clone(), + data_dir: data_dir.clone(), + db_path: db_path.clone(), port: cli.port, host: cli.host, directory_listing: !cli.no_listing, @@ -86,20 +113,21 @@ async fn main() -> anyhow::Result<()> { } }; - // 4. 打印启动成功 Banner + // 5. 打印启动成功 Banner println!(); println!("{}", "==================================================".bright_blue()); println!(" ✨ {} {}", "Stash Server".green().bold(), format!("v{}", env!("CARGO_PKG_VERSION")).dimmed()); println!("{}", "==================================================".bright_blue()); println!(" ➜ 本地访问: {}", format!("http://{}", handle.local_addr).cyan().bold().underline()); - println!(" ➜ 托管路径: {}", abs_root.display().to_string().white()); + println!(" ➜ 托管路径: {}", display_path(&abs_root).white()); + println!(" ➜ 内容库: {}", display_path(&db_path).white()); println!(" ➜ 前端资源: {}", "已内嵌 (stash-web/dist)".white()); println!(" ➜ 接口列表: {}", format!("http://{}/api/projects", handle.local_addr).dimmed()); println!("{}", "--------------------------------------------------".bright_blue()); println!(" 按 {} 停止服务", "Ctrl+C".yellow().bold()); println!(); - // 5. 跨平台捕获退出信号 + // 6. 跨平台捕获退出信号 wait_for_shutdown_signal().await; println!("\n{} 正在停止服务并释放端口...", "[SYS]".yellow()); diff --git a/src/server.rs b/src/server.rs index 197a89f..15b7ef7 100644 --- a/src/server.rs +++ b/src/server.rs @@ -17,6 +17,7 @@ use tower_http::{ use tracing::{info, warn}; use crate::config::Config; +use crate::db::Db; use crate::error::StashError; use crate::listing::{AppProject, scan_projects}; use crate::watcher; @@ -33,6 +34,8 @@ const HTML_CACHE: &str = "no-cache"; pub struct AppState { pub config: Arc, pub project_updates: broadcast::Sender>, + /// 内容库:分类 / 分组 / 条目的唯一数据源 + pub db: Arc, } pub struct ServerHandle { @@ -138,6 +141,77 @@ async fn list_projects_api(State(state): State) -> impl IntoResponse { })) } +/// API: 完整内容目录(分类 / 分组 / 条目 + 侧栏导航) +/// +/// 数据量很小(几十 KB),一次全量下发,前端搜索仍然在客户端做。 +async fn catalog_api(State(state): State) -> Response { + let db = state.db.clone(); + + // rusqlite 是同步的,查询扔进阻塞线程池,别占住 async 执行器 + 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)) => { + // 典型原因是用户手改坏了 JSON,错误信息里已带条目 id + warn!("读取内容库失败: {error}"); + error.into_response() + } + Err(error) => { + warn!("内容库查询任务崩溃: {error}"); + StashError::Database(format!("内容库查询任务崩溃: {error}")).into_response() + } + } +} + +/// SSE:内容库被外部修改时通知前端重新拉取。 +/// +/// 这里轮询 `PRAGMA data_version` 而不是监听数据库文件: +/// SQLite 在 journal 模式下会产生临时文件的频繁读写,文件监听会误报, +/// 而 data_version 恰好在「其它连接提交了事务」时才变化,语义正好对上。 +async fn catalog_events(State(state): State) -> impl IntoResponse { + let db = state.db.clone(); + + let stream = async_stream::stream! { + let mut last = db.data_version().unwrap_or(0); + let mut ticker = tokio::time::interval(Duration::from_secs(5)); + + loop { + ticker.tick().await; + + let current = match db.data_version() { + Ok(version) => version, + Err(error) => { + warn!("读取内容库版本失败: {error}"); + continue; + } + }; + + // 只在其它连接提交之后才推送。 + // 当前服务对内容库是只读的;将来若加入写接口,必须在那里主动广播, + // 因为 data_version 不会因本连接自己的提交而变化。 + tracing::debug!(last, current, "内容库版本检查"); + if current == last { + continue; + } + last = current; + + // 只告诉前端「变了」,具体内容由它重新 GET /api/catalog + yield Ok::( + Event::default().event("catalog").data(r#"{"changed":true}"#), + ); + } + }; + + Sse::new(stream).keep_alive( + KeepAlive::new() + .interval(Duration::from_secs(15)) + .text("keep-alive"), + ) +} + /// SSE:先发送当前快照,再持续推送文件监听产生的项目列表变化。 async fn project_events(State(state): State) -> impl IntoResponse { let root = state.config.root.clone(); @@ -188,9 +262,13 @@ pub async fn start(config: Config) -> Result { let (project_updates, _) = broadcast::channel(32); watcher::start(config.root.clone(), project_updates.clone())?; + // 内容库在启动时打开:建库 / 建表 / 空库时灌种子都在这里完成 + let db = Arc::new(Db::open(&config)?); + let shared_state = AppState { config: Arc::new(config.clone()), project_updates, + db, }; // 托管目录里找不到的路径,交给 404 页面而不是 SPA 回落: @@ -205,6 +283,8 @@ pub async fn start(config: Config) -> Result { .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)) diff --git a/stash-web/src/App.tsx b/stash-web/src/App.tsx index c8c6297..93b2084 100644 --- a/stash-web/src/App.tsx +++ b/stash-web/src/App.tsx @@ -7,7 +7,7 @@ import { ItemDetailView } from './components/content/ItemDetailView' import { SiteHeader } from './components/header/SiteHeader' import { SearchBar } from './components/search/SearchBar' import { Sidebar } from './components/sidebar/Sidebar' -import { buildCategories } from './data/catalog' +import { withLocalCategory } from './data/local' import { appDetailModel, posterDetailModel, @@ -24,6 +24,7 @@ import { relatedPosterItems, } from './data/types' import { useProjects } from './useProjects' +import { useCatalog } from './useCatalog' import './App.css' /** @@ -39,13 +40,18 @@ type 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' }) const [scrolled, setScrolled] = useState(false) const searchRef = useRef(null) const scrollRef = useRef(null) - const categories = useMemo(() => buildCategories(projects), [projects]) + const categories = useMemo( + () => withLocalCategory(projects, catalog.categories), + [projects, catalog.categories], + ) const visibleCategories = useMemo( () => filterCategories(categories, keyword), [categories, keyword], @@ -184,6 +190,7 @@ export default function App() {
)} + {/* 内容库读取失败通常是用户改坏了 data 列里的 JSON,错误信息会带条目 id */} + {catalog.status === 'error' && ( +
+ 内容库读取失败 +

{catalog.error}

+ +
+ )} + {detailView === undefined && itemView === undefined && ( <> + {catalog.status === 'loading' && ( +

正在读取内容库…

+ )} + { const response = await fetch(PROJECTS_ENDPOINT, { @@ -67,3 +69,75 @@ export function subscribeProjects(handlers: ProjectEventsHandlers): () => void { source.close() } } + +// --------------------------------------------------------------------------- +// 内容目录:数据来自后端的 SQLite 内容库(data/stash.db),不再是前端静态数组 +// --------------------------------------------------------------------------- + +const CATALOG_ENDPOINT = '/api/catalog' +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, { + signal, + headers: { Accept: 'application/json' }, + }) + + if (!response.ok) { + throw new Error(`请求 ${CATALOG_ENDPOINT} 失败:HTTP ${response.status}`) + } + + const payload = (await response.json()) as CatalogPayload + + return { + categories: Array.isArray(payload.categories) ? payload.categories : [], + nav: normalizeNav(payload.nav), + } +} + +/** + * 订阅内容库变更。 + * + * 服务端用 SQLite 的 data_version 检测「外部工具改了库」,事件里不带数据, + * 只表示「该重新拉一次了」,避免两套数据结构需要保持同步。 + */ +export function subscribeCatalog(onChange: () => void): () => void { + if (typeof EventSource === 'undefined') { + return () => {} + } + + const source = new EventSource(CATALOG_EVENTS_ENDPOINT) + const handleChange = () => onChange() + + source.addEventListener('catalog', handleChange) + + return () => { + source.removeEventListener('catalog', handleChange) + source.close() + } +} diff --git a/stash-web/src/components/sidebar/CategoryNav.tsx b/stash-web/src/components/sidebar/CategoryNav.tsx index 9f27439..0118e77 100644 --- a/stash-web/src/components/sidebar/CategoryNav.tsx +++ b/stash-web/src/components/sidebar/CategoryNav.tsx @@ -1,19 +1,23 @@ import { useState } from 'react' -import { NAV_GROUPS, NAV_TOP_LEVEL, type NavTarget } from '../../data/nav' +import type { NavModel, NavTarget } from '../../data/nav' /** * 分类导航树:顶层直达链接 + 可折叠分组。 - * 点击叶子节点不跳转页面,而是把目标(分类 + 可选分组)交给父组件的 onNavigate, + * + * 导航内容来自内容库(`nav_entry` 表),由父组件透传进来; + * 点击叶子节点不跳转页面,而是把目标(分类 + 可选分组)交给 onNavigate, * 由父组件切换右侧的明细视图。 */ type CategoryNavProps = { + /** 侧栏导航数据;内容库为空时是空模型 */ + nav: NavModel /** 当前明细视图的分类 / 分组,用于高亮 */ activeCategoryId?: string activeGroupId?: string onNavigate: (target: NavTarget) => void } -export function CategoryNav({ activeCategoryId, activeGroupId, onNavigate }: CategoryNavProps) { +export function CategoryNav({ nav, activeCategoryId, activeGroupId, onNavigate }: CategoryNavProps) { const [openGroups, setOpenGroups] = useState([]) const toggle = (id: string) => { @@ -27,7 +31,7 @@ export function CategoryNav({ activeCategoryId, activeGroupId, onNavigate }: Cat return (