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