feat(db): 内容库迁入 SQLite,分类与侧栏导航改为数据库驱动

This commit is contained in:
2026-10-23 20:22:23 +08:00
parent 386c0f77c7
commit 41fed5e64d
23 changed files with 2867 additions and 319 deletions
+4
View File
@@ -17,6 +17,10 @@ repomix-output.xml
*.iml
.vscode/
# ---------- 运行时数据 ----------
# 内容库属于用户数据,不进版本库(默认生成在二进制同级,即 target/ 下)
/data
# ---------- 日志与系统文件 ----------
*.log
.DS_Store
+4 -1
View File
@@ -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`),
前端静态数据下线;视觉与交互未变。
Generated
+128
View File
@@ -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"
+12
View File
@@ -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
+156 -4
View File
@@ -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 <DIR>` 可以把数据目录指到别处。
**内容库不能放在 `--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 <DIR>` | `./public` | 静态页面托管根目录 |
| `--data <DIR>` | 二进制同级 `data/` | 内容库所在目录(存放 `stash.db`) |
| `-p, --port <PORT>` | `8080` | 监听端口 |
| `--host <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` 暴露到局域网前请确认目录内容适合被其他设备访问。
+202 -1
View File
@@ -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")
);
}
}
+321
View File
@@ -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<i64>,
empty_text: Option<String>,
}
/// 导航行:侧栏自引用树的原始形态
struct NavRow {
id: String,
parent_id: Option<String>,
label: String,
category_id: Option<String>,
group_id: Option<String>,
}
/// 条目行:`data` 里是尚未展开的 JSON 文本
struct EntryRow {
id: String,
group_id: Option<String>,
name: String,
views: i64,
likes: i64,
data: String,
}
/// 读出整个内容目录:分类树 + 侧栏导航。
pub fn load(conn: &Connection) -> Result<Catalog, StashError> {
Ok(Catalog {
categories: load_categories(conn)?,
nav: load_nav(conn)?,
})
}
/// 读出全部分类,按 `sort` 排序;顺序即首页分区顺序。
fn load_categories(conn: &Connection) -> Result<Vec<Value>, StashError> {
let mut stmt = conn
.prepare(
"SELECT id, kind, title, bar_color, icon, preview_limit, empty_text
FROM category
ORDER BY sort, id",
)
.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::<Result<Vec<_>, _>>()
.map_err(map_error)?;
// 先释放语句再发后续查询,避免同一连接上的借用冲突
drop(stmt);
categories
.iter()
.map(|category| build_category(conn, category))
.collect()
}
/// 按 kind 分派到不同的组装方式。
fn build_category(conn: &Connection, category: &CategoryRow) -> Result<Value, StashError> {
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<Value, StashError> {
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::<Result<Vec<_>, _>>()
.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<Value, StashError> {
let entries = load_entries(conn, &category.id)?;
let items = entries
.iter()
.map(|entry| entry_object(entry, &category.id, "title").map(Value::Object))
.collect::<Result<Vec<_>, _>>()?;
Ok(Value::Array(items))
}
/// 取某个分类下的全部条目,按 `sort` 升序。
fn load_entries(conn: &Connection, category_id: &str) -> Result<Vec<EntryRow>, StashError> {
let mut stmt = conn
.prepare(
"SELECT id, group_id, name, views, likes, data
FROM entry
WHERE category_id = ?1
ORDER BY sort, id",
)
.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::<Result<Vec<_>, _>>()
.map_err(map_error)?;
Ok(entries)
}
/// 把一条条目展开成对象。
///
/// `name_key` 决定展示名落在哪个字段:应用用 `name`,海报与图文用 `title`。
/// 数据库列优先于 JSON,用户即便在 `data` 里写了同名键也不会顶掉真实值。
fn entry_object(
entry: &EntryRow,
category_id: &str,
name_key: &str,
) -> Result<Map<String, Value>, StashError> {
let parsed: Value = serde_json::from_str(&entry.data).map_err(|error| {
// 把位置写进报错里,用户改坏 JSON 时能立刻定位到是哪一行
StashError::Database(format!(
"条目 {category_id}/{} 的 data 列不是合法 JSON: {error}",
entry.id
))
})?;
let Value::Object(mut object) = parsed else {
return Err(StashError::Database(format!(
"条目 {category_id}/{} 的 data 列必须是 JSON 对象",
entry.id
)));
};
object.insert("id".into(), json!(entry.id));
object.insert(name_key.into(), json!(entry.name));
object.insert("views".into(), json!(entry.views));
object.insert("likes".into(), json!(entry.likes));
Ok(object)
}
// ---------------------------------------------------------------------------
// 侧栏导航
// ---------------------------------------------------------------------------
/// 读出侧栏导航,组装成 `{ topLevel, groups }`,结构与 frontend 的 `nav.ts` 一致。
///
/// 侧栏只有两层(顶层项 / 分组内的叶子),所以不需要通用递归:
/// 顶层里带 `category_id` 的是直达链接,不带的是可折叠分组,其子节点即叶子。
fn load_nav(conn: &Connection) -> Result<Value, StashError> {
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::<Result<Vec<_>, _>>()
.map_err(map_error)?;
drop(stmt);
let mut top_level = Vec::new();
let mut groups = Vec::new();
for row in &rows {
// 有 parent_id 的都是分组内的叶子,在这里由分组统一收集
if row.parent_id.is_some() {
continue;
}
if row.category_id.is_some() {
// 顶层直达项
top_level.push(nav_leaf(row));
continue;
}
// 可折叠分组:收集它的子节点
let leaves = rows
.iter()
.filter(|child| child.parent_id.as_deref() == Some(row.id.as_str()))
.filter(|child| child.category_id.is_some())
.map(nav_leaf)
.collect::<Vec<_>>();
groups.push(json!({
"id": row.id,
"label": row.label,
"leaves": leaves,
}));
}
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),
})
}
+301
View File
@@ -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<Value>,
/// 侧栏导航,结构对应前端 `nav.ts` 的 `{ topLevel, groups }`
pub nav: Value,
}
/// 内容库句柄。
///
/// rusqlite 的连接是 `!Sync` 的同步对象,这里用一把互斥锁包起来:
/// 本地工具并发极低,单连接足够,没必要引入连接池。
/// 所有耗时操作都应放在 `spawn_blocking` 里调用(见 server.rs)。
pub struct Db {
conn: Mutex<Connection>,
}
impl Db {
/// 打开(必要时创建)内容库。
///
/// 顺序很重要:先建目录,再连库,再设 PRAGMA,最后建表 / 判断是否需要灌种子。
pub fn open(config: &Config) -> Result<Self, StashError> {
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<Catalog, StashError> {
let conn = self.lock()?;
catalog::load(&conn)
}
/// SQLite 的 `data_version`:**其它连接**提交后该值会变,本连接自己的写入不变。
///
/// 正好用来发现「用户用 DB Browser 改了库」,SSE 靠轮询它来推送刷新。
pub fn data_version(&self) -> Result<i64, StashError> {
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<std::sync::MutexGuard<'_, Connection>, 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 必须变化");
}
}
+361
View File
@@ -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<SeedReport, StashError> {
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<usize, StashError> {
let mut inserted = 0usize;
// 顶层直达项与折叠分组是同一层的兄弟节点,必须共用一个递增序号,
// 否则两组序号都从 0 开始、互相撞号,最终顺序会退化成按 id 排序。
let mut top_sort = 0usize;
// 顶层直达项:有 category_id,渲染成不带展开箭头的链接
for leaf in &nav.top_level {
insert_nav_node(tx, leaf, None, top_sort)?;
top_sort += 1;
inserted += 1;
}
// 折叠分组:本身没有 category_id,子节点才是真正的链接
for group in &nav.groups {
tx.execute(
"INSERT INTO nav_entry (id, parent_id, label, category_id, group_id, sort)
VALUES (?1, NULL, ?2, NULL, NULL, ?3)",
params![group.id, group.label, top_sort as i64],
)
.map_err(map_error)?;
top_sort += 1;
inserted += 1;
// 组内叶子单独编号:它们是另一个层级,序号从 0 起不会和顶层冲突
for (leaf_index, leaf) in group.leaves.iter().enumerate() {
insert_nav_node(tx, leaf, Some(&group.id), leaf_index)?;
inserted += 1;
}
}
Ok(inserted)
}
/// 写入一个导航叶子节点,指向「分类 + 可选分组」。
fn insert_nav_node(
tx: &rusqlite::Transaction<'_>,
leaf: &SeedNavLeaf,
parent_id: Option<&str>,
sort: usize,
) -> Result<(), StashError> {
tx.execute(
"INSERT INTO nav_entry (id, parent_id, label, category_id, group_id, sort)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
leaf.id,
parent_id,
leaf.label,
leaf.target.category_id,
leaf.target.group_id,
sort as i64,
],
)
.map_err(|error| {
// 外键失败通常意味着种子里的 target 写错了分类 id,把节点名带进报错方便定位
StashError::Database(format!("写入导航节点 {} 失败: {error}", leaf.id))
})?;
Ok(())
}
/// 写入一个条目,并把 `views` / `likes` 从 JSON 里提到独立列。
///
/// 计数放列上是为了让用户用任何 SQLite 工具都能直接改数,
/// 也避免将来自增时重写整个 JSON。
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<String, Value>, 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<SeedCategory>,
/// 侧栏导航;缺省时侧栏为空,但不影响分类内容
#[serde(default)]
nav: SeedNav,
}
/// 侧栏导航:顶层直达项 + 可折叠分组
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedNav {
#[serde(default)]
top_level: Vec<SeedNavLeaf>,
#[serde(default)]
groups: Vec<SeedNavGroup>,
}
/// 一个可折叠分组,本身不可点击,只负责收纳叶子
#[derive(Debug, Deserialize)]
struct SeedNavGroup {
id: String,
label: String,
#[serde(default)]
leaves: Vec<SeedNavLeaf>,
}
/// 导航叶子:点击后切到 `target` 指定的分类 / 分组
#[derive(Debug, Deserialize)]
struct SeedNavLeaf {
id: String,
label: String,
target: SeedNavTarget,
}
/// 导航目标;`group_id` 为空表示直接打开整个分类
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedNavTarget {
category_id: String,
#[serde(default)]
group_id: Option<String>,
}
/// 一个分类;`groups` 与 `items` 按 kind 二者取一
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedCategory {
id: String,
kind: String,
title: String,
bar_color: String,
icon: String,
sort: i64,
#[serde(default)]
preview_limit: Option<i64>,
#[serde(default)]
empty_text: Option<String>,
#[serde(default)]
groups: Vec<SeedGroup>,
#[serde(default)]
items: Vec<SeedItem>,
}
/// 应用类分类下的一个分组
#[derive(Debug, Deserialize)]
struct SeedGroup {
id: String,
name: String,
sort: i64,
items: Vec<SeedItem>,
}
/// 一条内容;除 id / name 外全部字段原样落进 JSON 列。
///
/// 用 `flatten` 收集剩余键,这样种子文件里新增字段不需要同步改 Rust 结构。
#[derive(Debug, Deserialize)]
struct SeedItem {
id: String,
name: String,
#[serde(flatten)]
extra: Map<String, Value>,
}
+986
View File
@@ -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"
}
}
]
}
]
}
}
+5
View File
@@ -25,6 +25,11 @@ pub enum StashError {
#[error("未找到资源: {0}")]
NotFound(String),
/// 数据库相关失败:打开、建表、种子导入、查询。
/// 统一收敛成一个变体,避免把 rusqlite 的类型泄漏到库的公开 API。
#[error("数据库错误: {0}")]
Database(String),
}
impl IntoResponse for StashError {
+2
View File
@@ -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};
+33 -5
View File
@@ -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<PathBuf>,
/// 监听端口
#[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());
+80
View File
@@ -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<Config>,
pub project_updates: broadcast::Sender<Vec<AppProject>>,
/// 内容库:分类 / 分组 / 条目的唯一数据源
pub db: Arc<Db>,
}
pub struct ServerHandle {
@@ -138,6 +141,77 @@ async fn list_projects_api(State(state): State<AppState>) -> impl IntoResponse {
}))
}
/// API: 完整内容目录(分类 / 分组 / 条目 + 侧栏导航)
///
/// 数据量很小(几十 KB),一次全量下发,前端搜索仍然在客户端做。
async fn catalog_api(State(state): State<AppState>) -> 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<AppState>) -> 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, Infallible>(
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<AppState>) -> impl IntoResponse {
let root = state.config.root.clone();
@@ -188,9 +262,13 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
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<ServerHandle, StashError> {
.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))
+24 -2
View File
@@ -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<View>({ type: 'home' })
const [scrolled, setScrolled] = useState(false)
const searchRef = useRef<HTMLInputElement>(null)
const scrollRef = useRef<HTMLDivElement>(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() {
<div className="layout">
<Sidebar
projects={projects}
nav={catalog.nav}
live={live}
refreshing={refreshing}
onRefresh={refresh}
@@ -217,8 +224,23 @@ export default function App() {
</div>
)}
{/* 内容库读取失败通常是用户改坏了 data 列里的 JSON,错误信息会带条目 id */}
{catalog.status === 'error' && (
<div className="notice notice-error" role="alert">
<strong>内容库读取失败</strong>
<p>{catalog.error}</p>
<button type="button" className="notice-action" onClick={catalog.reload}>
重试
</button>
</div>
)}
{detailView === undefined && itemView === undefined && (
<>
{catalog.status === 'loading' && (
<p className="section-empty">正在读取内容库…</p>
)}
<HomeView
categories={visibleCategories}
onOpenDetail={openDetail}
+75 -1
View File
@@ -1,3 +1,6 @@
import type { NavModel } from './data/nav'
import type { Category } from './data/types'
export type Project = {
name: string
route_path: string
@@ -9,7 +12,6 @@ type ProjectsPayload = {
const PROJECTS_ENDPOINT = '/api/projects'
const EVENTS_ENDPOINT = '/api/projects/events'
/** 读取服务端扫描出的项目列表。 */
export async function fetchProjects(signal?: AbortSignal): Promise<Project[]> {
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<Catalog> {
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()
}
}
@@ -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<string[]>([])
const toggle = (id: string) => {
@@ -27,7 +31,7 @@ export function CategoryNav({ activeCategoryId, activeGroupId, onNavigate }: Cat
return (
<nav className="nav-card card-box" aria-label="站点分类">
<ul className="nav-group">
{NAV_TOP_LEVEL.map((leaf) => (
{nav.topLevel.map((leaf) => (
<li className="nav-item" key={leaf.id}>
<button
type="button"
@@ -43,7 +47,7 @@ export function CategoryNav({ activeCategoryId, activeGroupId, onNavigate }: Cat
</li>
))}
{NAV_GROUPS.map((group) => (
{nav.groups.map((group) => (
<li className={`nav-item${isOpen(group.id) ? ' open' : ''}`} key={group.id}>
<button
type="button"
+5 -1
View File
@@ -1,5 +1,5 @@
import type { Project } from '../../api'
import type { NavTarget } from '../../data/nav'
import type { NavModel, NavTarget } from '../../data/nav'
import { CategoryNav } from './CategoryNav'
import { ProfileCard } from './ProfileCard'
import { ServicePanel } from './ServicePanel'
@@ -10,6 +10,8 @@ import { ServicePanel } from './ServicePanel'
*/
type SidebarProps = {
projects: Project[]
/** 侧栏导航数据,来自内容库 */
nav: NavModel
live: boolean
refreshing: boolean
onRefresh: () => void
@@ -20,6 +22,7 @@ type SidebarProps = {
export function Sidebar({
projects,
nav,
live,
refreshing,
onRefresh,
@@ -39,6 +42,7 @@ export function Sidebar({
/>
<CategoryNav
nav={nav}
activeCategoryId={activeCategoryId}
activeGroupId={activeGroupId}
onNavigate={onNavigate}
-267
View File
@@ -1,267 +0,0 @@
import type {
AppCategory,
AppGroup,
AppItem,
ArticleBlock,
ArticleCategory,
Category,
PosterCategory,
} from './types'
/**
* 占位内容:沿用 index4.html 模板里的示例数据。
*
* 除「本地服务」由后端项目列表动态生成外,其余分类都是静态占位,
* 等后端提供分类 / 收藏接口后再替换。
*/
/**
* 详情页占位统计:按 id 生成稳定的数值,
* 保证同一站点每次打开看到的是同一组数字,而不是随机跳变。
*/
function stat(seed: string, min: number, max: number): number {
let hash = 0
for (const char of seed) {
hash = (hash * 31 + char.charCodeAt(0)) % 100_000
}
return min + (hash % (max - min + 1))
}
/** 只有少数条目带 HOT 徽标 —— 主题里也不是每个作者都带 */
const HOT_IDS = new Set(['github', 'bilibili', 'wallhaven'])
/** 生成一条占位站点,详情页需要的头像 / 横幅 / 评价也一并补齐 */
const app = (id: string, name: string, desc: string, icon: string, href = '#'): AppItem => ({
id,
name,
desc,
icon,
href,
cover: `https://picsum.photos/seed/${id}/200/200`,
banner: `https://picsum.photos/seed/${id}-banner/800/300`,
comment: '收录在本地导航里的常用站点,点开就能直达。',
badge: '推荐好站',
hot: HOT_IDS.has(id),
views: stat(id, 60, 980),
likes: stat(`${id}-like`, 3, 88),
})
/** 常用推荐:第一个分组是首页预览展示的四项 */
const RECOMMEND_GROUPS: AppGroup[] = [
{
id: 'common',
name: '常用站点',
items: [
app('github', 'GitHub', '代码托管与开源社区', 'G'),
app('zhihu', '知乎', '深度内容问答社区', 'Z'),
app('bilibili', '哔哩哔哩', '弹幕视频分享网站', 'B'),
app('v2ex', 'V2EX', '创意工作者讨论社区', 'V'),
],
},
{
id: 'ui',
name: 'UI 设计资源',
items: [
app('wallhaven', 'Wallhaven', 'WallBase 创办人失踪后推出的精选壁纸站', 'W'),
app('500px', '500px', '有着世界上最好的照片分享社区', '5'),
app('chuangyiren', '创意人', '去掉了形式主义描述的原创社区', 'C'),
app('topys', '顶尖文案', '全球顶尖创意分享平台 OPEN YOUR EYES', 'T'),
app('uichina', 'UI中国', '前身为 iconfans,国内专业的 UI 设计平台', 'U'),
],
},
{
id: 'vector',
name: '矢量图标',
items: [
app('iconfont', 'Iconfont', '阿里妈妈矢量图标库', 'I'),
app('feather', 'Feather Icons', '简单漂亮的开源图标库', 'F'),
app('fontawesome', 'FontAwesome', '图标字体与 CSS 框架', 'A'),
],
},
{
id: 'wallpaper',
name: '免费壁纸',
items: [
app('unsplash', 'Unsplash', '免费高清无版权摄影图片', 'U'),
app('pexels', 'Pexels', '才华横溢的摄影师分享的素材', 'P'),
app('pixabay', 'Pixabay', '超过 2.7 百万张优质图片和视频', 'X'),
],
},
{
id: 'dev',
name: '开发工具',
items: [
app('codepen', 'CodePen', '前端在线代码演示和练习场', 'C'),
app('stackoverflow', 'Stack Overflow', '全球程序员技术问答社区', 'S'),
app('github-dev', 'GitHub', '代码托管与开源协作社区', 'G'),
],
},
]
const RECOMMEND: AppCategory = {
id: 'recommend',
kind: 'apps',
title: '常用推荐',
barColor: '#3858f6',
icon: '🔖',
groups: RECOMMEND_GROUPS,
}
/** 电影占位数据:标题 + 一句简介,简介同时用作详情页正文 */
const MOVIE_SEEDS: [string, string][] = [
[
'千与千寻 千と千尋の神隠し (2001)',
'少女千寻误入神灵世界,为救回变成猪的父母,在汤屋打工并逐渐学会担当。',
],
[
'美丽人生 La vita è bella (1997)',
'二战集中营里,父亲用一场「游戏」保护儿子,让他相信世界仍然美好。',
],
[
'这个杀手不太冷 Léon (1994)',
'孤独的职业杀手莱昂收留了邻居少女玛蒂尔达,两个边缘人相互依靠。',
],
[
'阿甘正传 Forrest Gump (1994)',
'智商不高的阿甘凭着一股单纯的执着,意外见证并参与了几十年美国历史。',
],
['霸王别姬 (1993)', '两位京剧伶人半个世纪的悲欢离合,与时代洪流交织在一起。'],
[
'肖申克的救赎 The Shawshank Redemption',
'蒙冤入狱的银行家安迪,在漫长牢狱中守住希望并完成自救。',
],
[
'泰坦尼克号 Titanic (1997)',
'沉船之夜,来自不同阶层的杰克与露丝相爱,也被人性的选择所考验。',
],
['盗梦空间 Inception (2010)', '盗梦团队潜入层层梦境,试图在目标的潜意识里植入一个念头。'],
[
'星际穿越 Interstellar (2014)',
'地球濒临枯竭,父亲穿越虫洞寻找新家园,与女儿跨越时间重逢。',
],
[
'楚门的世界 The Truman Show (1998)',
'楚门渐渐发现,自己的整个人生其实是一档被全世界围观的真人秀。',
],
['疯狂动物城 Zootopia (2016)', '兔子警官与狐狸搭档,在动物城里侦破一桩失踪案,也打破偏见。'],
[
'大话西游之大圣娶亲 (1995)',
'至尊宝在爱情与宿命之间反复抉择,最终戴上金箍踏上取经路。',
],
]
const MOVIES: PosterCategory = {
id: 'movie',
kind: 'posters',
title: '推荐电影',
barColor: '#ff6034',
icon: '🎬',
items: MOVIE_SEEDS.map(([title, summary], index) => {
const id = `movie-${index + 1}`
return {
id,
title,
cover: `https://picsum.photos/seed/m${index + 1}/300/450`,
tags: ['推荐电影', '豆瓣电影 Top250'],
comment: '收藏在本地片单里,闲下来就重看一遍。',
summary,
href: '#',
views: stat(id, 300, 1500),
likes: stat(`${id}-like`, 3, 60),
}
}),
}
const ARTICLE_SEEDS = [
['可塑性极强的玻璃,在设计师手里有了更多可能', '前沿科技'],
['废旧共享单车如何变成微型共享书屋?', '环保设计'],
['荧光灯管与混凝土块的组合,可以玩出多少花样?', '材料探索'],
['混凝土做的模块化收纳框,还能改成搁架用', '家居模块'],
['这个用拉伸材料做的头盔,还能帮你呼叫救护车', '智能穿戴'],
['你如何选择字体,简单易读还是好看有趣?', '视觉体验'],
['家里空间小?你需要几把可以悬挂在墙上的椅子', '空间收纳'],
['宜家厨房家具又被改造了,色彩是亮点', '色彩灵感'],
['这款诞生 50 年的扶手椅,推出了特别版配色', '经典产品'],
['让家和办公室界限变得模糊的一组家具', '办公家具'],
['这个酒店设计奖,挖掘过去一年体验最好的酒店', '空间建筑'],
['一款可移动办公桌,挂墙上当作白板就能写写画画', '移动办公'],
]
/** 生成演示用正文;接入真实数据后由后端提供 blocks */
function buildArticleBlocks(title: string, desc: string, cover: string): ArticleBlock[] {
return [
{
type: 'paragraph',
text: `开放与明亮的氛围,在室内空间中尤为注重。这条以「${desc}」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。`,
},
{ type: 'heading', text: '设计细节' },
{
type: 'paragraph',
text: `《${title}》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。`,
},
{
type: 'paragraph',
text: '在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。',
},
{ type: 'image', src: cover },
{
type: 'paragraph',
text: '文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。',
},
]
}
const CES: ArticleCategory = {
id: 'ces',
kind: 'articles',
title: 'CES 现场报道',
barColor: '#ffba3a',
icon: '🏷️',
items: ARTICLE_SEEDS.map(([title, desc], index) => {
const cover = `https://picsum.photos/seed/a${index + 1}/400/250`
return {
id: `ces-${index + 1}`,
title: `${title} | 这个设计了不起`,
desc,
cover,
meta: ['2 年前', 'CES 现场报道'],
blocks: buildArticleBlocks(title, desc, cover),
}
}),
}
/**
* 把后端项目列表转成「本地服务」分类。
* href 用真实路由,详情页的「跳转」按钮直接打开对应静态站点。
*/
function buildLocalCategory(items: AppItem[]): AppCategory {
return {
id: 'local',
kind: 'apps',
title: '本地服务',
barColor: '#3858f6',
icon: '🖥️',
groups: items.length > 0 ? [{ id: 'local-all', name: '全部项目', items }] : [],
previewLimit: 8,
emptyText: '还没有项目:在托管目录下建一个子目录并放入 index.html,刷新后就会出现在这里。',
}
}
/** 组装首页 + 明细所需的全部分类,本地服务始终排第一 */
export function buildCategories(projects: { name: string; route_path: string }[]): Category[] {
const localItems: AppItem[] = projects.map((project) => ({
id: project.route_path,
name: project.name,
desc: project.route_path,
icon: project.name.slice(0, 1).toUpperCase(),
href: project.route_path,
// 本地项目没有封面图,详情页退化为字符头像
comment: '本地静态站点,由 Stash 直接托管。',
badge: '本地项目',
views: 0,
likes: 0,
}))
return [buildLocalCategory(localItems), RECOMMEND, MOVIES, CES]
}
+61
View File
@@ -0,0 +1,61 @@
import type { AppCategory, AppItem, Category } from './types'
/**
* 「本地服务」分类的组装逻辑。
*
* 这一块不来自内容库:托管目录里有哪些项目是由文件系统决定的,
* 后端每次扫描后通过 `/api/projects` 推送,所以本地分类始终在前端拼出来,
* 内容库只负责其余的分类(常用推荐 / 推荐电影 / CES 现场报道)。
*/
/** 后端 `/api/projects` 返回的项目结构 */
export type HostedProject = {
name: string
route_path: string
}
/**
* 把托管项目映射成应用条目。
*
* id 直接用路由地址,保证同一项目在前端各处(首页卡片、明细、详情)唯一。
* 本地项目没有封面图,详情页会退化成字符头像。
*/
function toLocalItem(project: HostedProject): AppItem {
return {
id: project.route_path,
name: project.name,
desc: project.route_path,
icon: project.name.slice(0, 1).toUpperCase(),
href: project.route_path,
comment: '本地静态站点,由 Stash 直接托管。',
badge: '本地项目',
views: 0,
likes: 0,
}
}
/** 生成「本地服务」分类;没有项目时给出一句引导文案 */
export function buildLocalCategory(projects: HostedProject[]): AppCategory {
const items = projects.map(toLocalItem)
return {
id: 'local',
kind: 'apps',
title: '本地服务',
barColor: '#3858f6',
icon: '🖥️',
groups: items.length > 0 ? [{ id: 'local-all', name: '全部项目', items }] : [],
previewLimit: 8,
emptyText: '还没有项目:在托管目录下建一个子目录并放入 index.html,刷新后就会出现在这里。',
}
}
/**
* 拼接首页全部分类:本地服务永远排第一,其后跟进内容库返回的分类。
*/
export function withLocalCategory(
projects: HostedProject[],
categories: Category[],
): Category[] {
return [buildLocalCategory(projects), ...categories]
}
+12 -31
View File
@@ -1,8 +1,8 @@
/**
* 侧栏分类导航的数据定义。
* 侧栏分类导航的数据结构。
*
* 叶子节点通过 target 指向「分类 + 可选分组」,点击后右侧切换到对应明细。
* 标签保持与现有侧栏一致,避免改动已定稿的侧栏。
* 导航数据存在内容库的 `nav_entry` 表里(自引用树),随 `/api/catalog` 一起下发;
* 这里只保留类型定义和空模型兜底,不再有写死的导航常量。
*/
export type NavTarget = {
@@ -11,44 +11,25 @@ export type NavTarget = {
groupId?: string
}
/** 一个可点击的导航叶子 */
export type NavLeaf = {
id: string
label: string
target: NavTarget
}
/** 可折叠分组:本身不可点击,只负责收纳叶子 */
export type NavGroupNode = {
id: string
label: string
leaves: NavLeaf[]
}
/** 顶层直达链接 */
export const NAV_TOP_LEVEL: NavLeaf[] = [
{ id: 'recommend', label: '常用推荐', target: { categoryId: 'recommend' } },
]
/** 侧栏的完整数据模型:顶层直达项 + 可折叠分组 */
export type NavModel = {
topLevel: NavLeaf[]
groups: NavGroupNode[]
}
/** 可折叠分组 */
export const NAV_GROUPS: NavGroupNode[] = [
{
id: 'assets',
label: '素材资源',
leaves: [
{ id: 'ui', label: 'UI 设计资源', target: { categoryId: 'recommend', groupId: 'ui' } },
{ id: 'vector', label: '矢量图标', target: { categoryId: 'recommend', groupId: 'vector' } },
{
id: 'wallpaper',
label: '免费壁纸',
target: { categoryId: 'recommend', groupId: 'wallpaper' },
},
],
},
{
id: 'tools',
label: '开发工具',
leaves: [
{ id: 'dev', label: '前端调试工具', target: { categoryId: 'recommend', groupId: 'dev' } },
{ id: 'code', label: '在线编辑器', target: { categoryId: 'recommend', groupId: 'dev' } },
],
},
]
/** 内容库还没有导航数据时的兜底,避免渲染时到处判空 */
export const EMPTY_NAV: NavModel = { topLevel: [], groups: [] }
+85
View File
@@ -0,0 +1,85 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { fetchCatalog, subscribeCatalog } from './api'
import { EMPTY_NAV, type NavModel } from './data/nav'
import type { Category } from './data/types'
export type CatalogStatus = 'loading' | 'ready' | 'error'
export type CatalogState = {
status: CatalogStatus
categories: Category[]
/** 侧栏导航;与分类同一次请求下发 */
nav: NavModel
error: string | null
}
export type CatalogController = CatalogState & {
/** 手动重新拉取;内容库被外部修改后也会自动调用 */
reload: () => void
}
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/**
* 内容目录:首屏拉取 + 监听服务端推送 + 手动重载。
*
* 与 `useProjects` 的区别是失败策略:项目列表失败会保留旧数据,
* 内容目录同样保留旧数据 —— 用户正在改库时偶尔读到坏 JSON 不应该把页面清空。
*/
export function useCatalog(): CatalogController {
const [state, setState] = useState<CatalogState>({
status: 'loading',
categories: [],
nav: EMPTY_NAV,
error: null,
})
const controllerRef = useRef<AbortController | null>(null)
/**
* 拉一次内容目录。
*
* 用 ref 保存当前的 AbortController:外部改动可能连续触发多次刷新,
* 只保留最后一次请求,避免旧响应把新数据覆盖回去。
*/
const load = useCallback(() => {
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
fetchCatalog(controller.signal)
.then((catalog) => {
if (!controller.signal.aborted) {
setState({
status: 'ready',
categories: catalog.categories,
nav: catalog.nav,
error: null,
})
}
})
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setState((previous) => ({
// 已经有内容时只提示错误,不清空列表
status: previous.categories.length > 0 ? 'ready' : 'error',
categories: previous.categories,
nav: previous.nav,
error: toErrorMessage(error),
}))
}
})
}, [])
// 首屏加载:setState 只发生在 Promise 回调里,避免 effect 内同步 setState
useEffect(() => {
load()
return () => controllerRef.current?.abort()
}, [load])
// 内容库被外部修改时,服务端推送一次「变了」,这里重新拉取
useEffect(() => subscribeCatalog(load), [load])
return { ...state, reload: load }
}
+1 -1
View File
@@ -1108,7 +1108,7 @@
<div id="detail-view" class="detail-view">
<div class="detail-header-nav">
<div class="back-btn" onclick="showHomeView()">← 返回首页</div>
<div class="back-btn" onclick="showHomeView()">返回首页</div>
<div style="font-size: 12px; color: #94a3b8;" id="detail-breadcrumb">首页 / 分类明细</div>
</div>