feat(db): 内容库迁入 SQLite,分类与侧栏导航改为数据库驱动
This commit is contained in:
@@ -17,6 +17,10 @@ repomix-output.xml
|
|||||||
*.iml
|
*.iml
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
# ---------- 运行时数据 ----------
|
||||||
|
# 内容库属于用户数据,不进版本库(默认生成在二进制同级,即 target/ 下)
|
||||||
|
/data
|
||||||
|
|
||||||
# ---------- 日志与系统文件 ----------
|
# ---------- 日志与系统文件 ----------
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
+4
-1
@@ -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` |
|
| 改主色 / 灰阶 / 圆角 | [`stash-web/src/index.css`](stash-web/src/index.css) 的 `:root` |
|
||||||
| 新增图标 | 把 codepoint 加进 [`stash-web/src/iconfont.css`](stash-web/src/iconfont.css),在 `Icon.tsx` 包一个组件 |
|
| 新增图标 | 把 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` 各加一个分支 |
|
| 新增卡片类型 | 在 `data/types.ts` 增加 kind,`HomeView` / `DetailView` 各加一个分支 |
|
||||||
| 调整分区样式 | [`stash-web/src/App.css`](stash-web/src/App.css) 的「分区卡片」段落 |
|
| 调整分区样式 | [`stash-web/src/App.css`](stash-web/src/App.css) 的「分区卡片」段落 |
|
||||||
| 换背景图 | 替换 `stash-web/src/assets/site-bg.jpg` |
|
| 换背景图 | 替换 `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:按本规范完成整体换肤(配色、字体、图标、背景、应用条目、返回顶部),
|
- 2026-10-22:按本规范完成整体换肤(配色、字体、图标、背景、应用条目、返回顶部),
|
||||||
并将侧栏导航箭头改为与主题一致的 CSS 箭头。
|
并将侧栏导航箭头改为与主题一致的 CSS 箭头。
|
||||||
|
- 2026-10-23:分类内容与侧栏导航迁入 SQLite 内容库(`data/stash.db`),
|
||||||
|
前端静态数据下线;视觉与交互未变。
|
||||||
|
|||||||
Generated
+128
@@ -2,6 +2,18 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
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]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.5"
|
version = "1.1.5"
|
||||||
@@ -159,6 +171,16 @@ version = "1.12.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
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]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -230,6 +252,24 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -278,6 +318,24 @@ dependencies = [
|
|||||||
"slab",
|
"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]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -452,6 +510,17 @@ version = "0.2.191"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
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]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -619,6 +688,12 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkg-config"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.107"
|
version = "1.0.107"
|
||||||
@@ -663,6 +738,20 @@ version = "0.8.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
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]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
@@ -759,6 +848,12 @@ dependencies = [
|
|||||||
"lazy_static",
|
"lazy_static",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signal-hook-registry"
|
name = "signal-hook-registry"
|
||||||
version = "1.4.8"
|
version = "1.4.8"
|
||||||
@@ -803,6 +898,7 @@ dependencies = [
|
|||||||
"include_dir",
|
"include_dir",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"notify",
|
"notify",
|
||||||
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
@@ -1073,6 +1169,18 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
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]]
|
[[package]]
|
||||||
name = "walkdir"
|
name = "walkdir"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -1187,6 +1295,26 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
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]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
|
|||||||
+12
@@ -25,6 +25,18 @@ include_dir = "0.7"
|
|||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
notify = "8"
|
notify = "8"
|
||||||
|
|
||||||
|
# 内容库:SQLite 随二进制静态编译,运行时不需要任何外部 DLL
|
||||||
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
# build.rs 用它递归复制前端产物到 $OUT_DIR/web
|
# build.rs 用它递归复制前端产物到 $OUT_DIR/web
|
||||||
walkdir = "2.4"
|
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
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
|
|||||||
```
|
```
|
||||||
|
|
||||||
启动后打开终端里打印的地址即可,例如 `http://127.0.0.1:8080/`。
|
启动后打开终端里打印的地址即可,例如 `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 后端
|
├── src/ # Rust 后端
|
||||||
│ ├── main.rs # CLI 参数、启动横幅、停机信号
|
│ ├── main.rs # CLI 参数、启动横幅、停机信号
|
||||||
│ ├── lib.rs # 库导出
|
│ ├── lib.rs # 库导出
|
||||||
│ ├── config.rs # 服务配置
|
│ ├── config.rs # 服务配置、数据目录与路径校验
|
||||||
│ ├── error.rs # 错误类型与 JSON 错误响应
|
│ ├── error.rs # 错误类型与 JSON 错误响应
|
||||||
│ ├── server.rs # 路由、内嵌静态资源、SSE、生命周期
|
│ ├── server.rs # 路由、内嵌静态资源、SSE、生命周期
|
||||||
│ ├── listing.rs # 托管目录扫描
|
│ ├── listing.rs # 托管目录扫描
|
||||||
│ └── watcher.rs # 文件监听、防抖、快照广播
|
│ ├── watcher.rs # 文件监听、防抖、快照广播
|
||||||
|
│ └── db/ # SQLite 内容库
|
||||||
|
│ ├── mod.rs # 打开、PRAGMA、种子导入时机
|
||||||
|
│ ├── schema.rs # 建表语句与种子导入
|
||||||
|
│ ├── catalog.rs # 查询结果组装成前端 Category[]
|
||||||
|
│ └── seed.json # 内嵌种子数据(编译进二进制)
|
||||||
├── stash-web/ # React + Vite + TypeScript 控制台
|
├── stash-web/ # React + Vite + TypeScript 控制台
|
||||||
│ ├── package.json # 含 volta 版本固定
|
│ ├── package.json # 含 volta 版本固定
|
||||||
│ ├── vite.config.ts # 开发期 /api 代理到 127.0.0.1:8080
|
│ ├── vite.config.ts # 开发期 /api 代理到 127.0.0.1:8080
|
||||||
│ └── src/
|
│ └── src/
|
||||||
|
├── data/ # 内容库目录(与二进制同级,运行时生成)
|
||||||
|
│ └── stash.db # SQLite 内容库
|
||||||
└── public/ # 默认托管目录(可放多个前端项目)
|
└── public/ # 默认托管目录(可放多个前端项目)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -82,6 +90,69 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
|
|||||||
|
|
||||||
`build.rs` 声明了 `rerun-if-changed`,改动 `dist` 后重新 `cargo build` 会自动重新嵌入。
|
`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
|
```bash
|
||||||
@@ -100,6 +171,7 @@ npm run lint # oxlint
|
|||||||
| 参数 | 默认值 | 说明 |
|
| 参数 | 默认值 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `-r, --root <DIR>` | `./public` | 静态页面托管根目录 |
|
| `-r, --root <DIR>` | `./public` | 静态页面托管根目录 |
|
||||||
|
| `--data <DIR>` | 二进制同级 `data/` | 内容库所在目录(存放 `stash.db`) |
|
||||||
| `-p, --port <PORT>` | `8080` | 监听端口 |
|
| `-p, --port <PORT>` | `8080` | 监听端口 |
|
||||||
| `--host <HOST>` | `127.0.0.1` | 监听地址,`0.0.0.0` 供局域网访问 |
|
| `--host <HOST>` | `127.0.0.1` | 监听地址,`0.0.0.0` 供局域网访问 |
|
||||||
| `--no-cors` | 关闭 | 关闭全局 CORS 标头注入 |
|
| `--no-cors` | 关闭 | 关闭全局 CORS 标头注入 |
|
||||||
@@ -152,13 +224,90 @@ data: {"projects":[{"name":"docs","route_path":"/docs/"}]}
|
|||||||
服务端递归监听托管目录,把短时间内的连续事件合并成一次扫描,只在项目名称或访问路径真正变化时广播;
|
服务端递归监听托管目录,把短时间内的连续事件合并成一次扫描,只在项目名称或访问路径真正变化时广播;
|
||||||
每 15 秒发送一次保活消息,浏览器断开后自动重连。
|
每 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
|
```bash
|
||||||
cargo test # 目录扫描、隐藏目录过滤、排序、路径编码、文件监听广播
|
cargo test # 目录扫描、隐藏目录过滤、排序、路径编码、文件监听广播、内容库
|
||||||
cd stash-web && npm run build # tsc -b + vite build,同时做类型检查
|
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。
|
- 控制台是单页应用但没有客户端路由,因此没有 SPA history 回退;刷新不存在的物理路径会返回 404。
|
||||||
@@ -167,4 +316,7 @@ cd stash-web && npm run build # tsc -b + vite build,同时做类型检查
|
|||||||
托管目录里的项目统一走 `/<目录名>/`。
|
托管目录里的项目统一走 `/<目录名>/`。
|
||||||
- 项目扫描只检查一级子目录,不递归查找更深层项目。
|
- 项目扫描只检查一级子目录,不递归查找更深层项目。
|
||||||
- 文件事件经过约 150ms 防抖,新项目出现在控制台可能有短暂延迟。
|
- 文件事件经过约 150ms 防抖,新项目出现在控制台可能有短暂延迟。
|
||||||
|
- 内容库没有迁移机制:表结构变化时需要删掉 `data/stash.db` 重建(当前属于开发阶段)。
|
||||||
|
- 内容库被外部工具修改后,页面最长有 5 秒延迟才自动刷新。
|
||||||
|
- 内容库按单实例使用设计:多个进程同时写同一个库会在 `busy_timeout`(5 秒)后报错。
|
||||||
- 没有身份认证,默认只建议绑定 `127.0.0.1`;使用 `0.0.0.0` 暴露到局域网前请确认目录内容适合被其他设备访问。
|
- 没有身份认证,默认只建议绑定 `127.0.0.1`;使用 `0.0.0.0` 暴露到局域网前请确认目录内容适合被其他设备访问。
|
||||||
|
|||||||
+202
-1
@@ -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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
/// 静态页面托管根目录(存放一个或多个前端项目)
|
/// 静态页面托管根目录(存放一个或多个前端项目)
|
||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
|
/// 数据目录:存放内容库与将来的备份 / 导出文件
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
/// 内容库路径,固定为 `data/stash.db`
|
||||||
|
pub db_path: PathBuf,
|
||||||
/// 监听端口
|
/// 监听端口
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
/// 监听地址,默认 127.0.0.1
|
/// 监听地址,默认 127.0.0.1
|
||||||
@@ -16,8 +27,13 @@ pub struct Config {
|
|||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
|
// 默认值只在测试与兜底路径上使用,真实启动路径由 main.rs 解析
|
||||||
|
let data_dir = PathBuf::from(DATA_DIR_NAME);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
root: PathBuf::from("../public"),
|
root: PathBuf::from("../public"),
|
||||||
|
db_path: resolve_db_path(&data_dir),
|
||||||
|
data_dir,
|
||||||
port: 8080,
|
port: 8080,
|
||||||
host: "127.0.0.1".to_string(),
|
host: "127.0.0.1".to_string(),
|
||||||
directory_listing: true,
|
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 必须变化");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,11 @@ pub enum StashError {
|
|||||||
|
|
||||||
#[error("未找到资源: {0}")]
|
#[error("未找到资源: {0}")]
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
|
|
||||||
|
/// 数据库相关失败:打开、建表、种子导入、查询。
|
||||||
|
/// 统一收敛成一个变体,避免把 rusqlite 的类型泄漏到库的公开 API。
|
||||||
|
#[error("数据库错误: {0}")]
|
||||||
|
Database(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for StashError {
|
impl IntoResponse for StashError {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod db;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod listing;
|
pub mod listing;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
mod watcher;
|
mod watcher;
|
||||||
|
|
||||||
pub use config::Config;
|
pub use config::Config;
|
||||||
|
pub use db::Db;
|
||||||
pub use error::StashError;
|
pub use error::StashError;
|
||||||
pub use listing::{AppProject, scan_projects};
|
pub use listing::{AppProject, scan_projects};
|
||||||
pub use server::{ServerHandle, start};
|
pub use server::{ServerHandle, start};
|
||||||
|
|||||||
+33
-5
@@ -1,7 +1,11 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use colored::*;
|
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};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
@@ -17,6 +21,10 @@ struct Cli {
|
|||||||
#[arg(short, long, default_value = "./public", value_name = "DIR")]
|
#[arg(short, long, default_value = "./public", value_name = "DIR")]
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
|
|
||||||
|
/// 数据目录(默认是与二进制同级的 data/,用于存放 stash.db)
|
||||||
|
#[arg(long, value_name = "DIR")]
|
||||||
|
data: Option<PathBuf>,
|
||||||
|
|
||||||
/// 监听端口
|
/// 监听端口
|
||||||
#[arg(short, long, default_value_t = 8080, value_name = "PORT")]
|
#[arg(short, long, default_value_t = 8080, value_name = "PORT")]
|
||||||
port: u16,
|
port: u16,
|
||||||
@@ -69,9 +77,28 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let abs_root = cli.root.canonicalize().unwrap_or_else(|_| cli.root.clone());
|
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 {
|
let config = Config {
|
||||||
root: abs_root.clone(),
|
root: abs_root.clone(),
|
||||||
|
data_dir: data_dir.clone(),
|
||||||
|
db_path: db_path.clone(),
|
||||||
port: cli.port,
|
port: cli.port,
|
||||||
host: cli.host,
|
host: cli.host,
|
||||||
directory_listing: !cli.no_listing,
|
directory_listing: !cli.no_listing,
|
||||||
@@ -86,20 +113,21 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 4. 打印启动成功 Banner
|
// 5. 打印启动成功 Banner
|
||||||
println!();
|
println!();
|
||||||
println!("{}", "==================================================".bright_blue());
|
println!("{}", "==================================================".bright_blue());
|
||||||
println!(" ✨ {} {}", "Stash Server".green().bold(), format!("v{}", env!("CARGO_PKG_VERSION")).dimmed());
|
println!(" ✨ {} {}", "Stash Server".green().bold(), format!("v{}", env!("CARGO_PKG_VERSION")).dimmed());
|
||||||
println!("{}", "==================================================".bright_blue());
|
println!("{}", "==================================================".bright_blue());
|
||||||
println!(" ➜ 本地访问: {}", format!("http://{}", handle.local_addr).cyan().bold().underline());
|
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!(" ➜ 前端资源: {}", "已内嵌 (stash-web/dist)".white());
|
||||||
println!(" ➜ 接口列表: {}", format!("http://{}/api/projects", handle.local_addr).dimmed());
|
println!(" ➜ 接口列表: {}", format!("http://{}/api/projects", handle.local_addr).dimmed());
|
||||||
println!("{}", "--------------------------------------------------".bright_blue());
|
println!("{}", "--------------------------------------------------".bright_blue());
|
||||||
println!(" 按 {} 停止服务", "Ctrl+C".yellow().bold());
|
println!(" 按 {} 停止服务", "Ctrl+C".yellow().bold());
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
// 5. 跨平台捕获退出信号
|
// 6. 跨平台捕获退出信号
|
||||||
wait_for_shutdown_signal().await;
|
wait_for_shutdown_signal().await;
|
||||||
|
|
||||||
println!("\n{} 正在停止服务并释放端口...", "[SYS]".yellow());
|
println!("\n{} 正在停止服务并释放端口...", "[SYS]".yellow());
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use tower_http::{
|
|||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
use crate::db::Db;
|
||||||
use crate::error::StashError;
|
use crate::error::StashError;
|
||||||
use crate::listing::{AppProject, scan_projects};
|
use crate::listing::{AppProject, scan_projects};
|
||||||
use crate::watcher;
|
use crate::watcher;
|
||||||
@@ -33,6 +34,8 @@ const HTML_CACHE: &str = "no-cache";
|
|||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub config: Arc<Config>,
|
pub config: Arc<Config>,
|
||||||
pub project_updates: broadcast::Sender<Vec<AppProject>>,
|
pub project_updates: broadcast::Sender<Vec<AppProject>>,
|
||||||
|
/// 内容库:分类 / 分组 / 条目的唯一数据源
|
||||||
|
pub db: Arc<Db>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ServerHandle {
|
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:先发送当前快照,再持续推送文件监听产生的项目列表变化。
|
/// SSE:先发送当前快照,再持续推送文件监听产生的项目列表变化。
|
||||||
async fn project_events(State(state): State<AppState>) -> impl IntoResponse {
|
async fn project_events(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
let root = state.config.root.clone();
|
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);
|
let (project_updates, _) = broadcast::channel(32);
|
||||||
watcher::start(config.root.clone(), project_updates.clone())?;
|
watcher::start(config.root.clone(), project_updates.clone())?;
|
||||||
|
|
||||||
|
// 内容库在启动时打开:建库 / 建表 / 空库时灌种子都在这里完成
|
||||||
|
let db = Arc::new(Db::open(&config)?);
|
||||||
|
|
||||||
let shared_state = AppState {
|
let shared_state = AppState {
|
||||||
config: Arc::new(config.clone()),
|
config: Arc::new(config.clone()),
|
||||||
project_updates,
|
project_updates,
|
||||||
|
db,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 托管目录里找不到的路径,交给 404 页面而不是 SPA 回落:
|
// 托管目录里找不到的路径,交给 404 页面而不是 SPA 回落:
|
||||||
@@ -205,6 +283,8 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
|
|||||||
.route("/api/health", get(health_check))
|
.route("/api/health", get(health_check))
|
||||||
.route("/api/projects", get(list_projects_api))
|
.route("/api/projects", get(list_projects_api))
|
||||||
.route("/api/projects/events", get(project_events))
|
.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("/", get(web_index))
|
||||||
.route("/index.html", get(web_index))
|
.route("/index.html", get(web_index))
|
||||||
.route("/favicon.svg", get(web_root_file))
|
.route("/favicon.svg", get(web_root_file))
|
||||||
|
|||||||
+24
-2
@@ -7,7 +7,7 @@ import { ItemDetailView } from './components/content/ItemDetailView'
|
|||||||
import { SiteHeader } from './components/header/SiteHeader'
|
import { SiteHeader } from './components/header/SiteHeader'
|
||||||
import { SearchBar } from './components/search/SearchBar'
|
import { SearchBar } from './components/search/SearchBar'
|
||||||
import { Sidebar } from './components/sidebar/Sidebar'
|
import { Sidebar } from './components/sidebar/Sidebar'
|
||||||
import { buildCategories } from './data/catalog'
|
import { withLocalCategory } from './data/local'
|
||||||
import {
|
import {
|
||||||
appDetailModel,
|
appDetailModel,
|
||||||
posterDetailModel,
|
posterDetailModel,
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
relatedPosterItems,
|
relatedPosterItems,
|
||||||
} from './data/types'
|
} from './data/types'
|
||||||
import { useProjects } from './useProjects'
|
import { useProjects } from './useProjects'
|
||||||
|
import { useCatalog } from './useCatalog'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,13 +40,18 @@ type View =
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { status, projects, error, live, refreshing, refresh } = useProjects()
|
const { status, projects, error, live, refreshing, refresh } = useProjects()
|
||||||
|
// 内容目录来自后端 SQLite 内容库;本地服务分类仍由托管项目动态生成
|
||||||
|
const catalog = useCatalog()
|
||||||
const [keyword, setKeyword] = useState('')
|
const [keyword, setKeyword] = useState('')
|
||||||
const [view, setView] = useState<View>({ type: 'home' })
|
const [view, setView] = useState<View>({ type: 'home' })
|
||||||
const [scrolled, setScrolled] = useState(false)
|
const [scrolled, setScrolled] = useState(false)
|
||||||
const searchRef = useRef<HTMLInputElement>(null)
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
const scrollRef = useRef<HTMLDivElement>(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(
|
const visibleCategories = useMemo(
|
||||||
() => filterCategories(categories, keyword),
|
() => filterCategories(categories, keyword),
|
||||||
[categories, keyword],
|
[categories, keyword],
|
||||||
@@ -184,6 +190,7 @@ export default function App() {
|
|||||||
<div className="layout">
|
<div className="layout">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
projects={projects}
|
projects={projects}
|
||||||
|
nav={catalog.nav}
|
||||||
live={live}
|
live={live}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
@@ -217,8 +224,23 @@ export default function App() {
|
|||||||
</div>
|
</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 && (
|
{detailView === undefined && itemView === undefined && (
|
||||||
<>
|
<>
|
||||||
|
{catalog.status === 'loading' && (
|
||||||
|
<p className="section-empty">正在读取内容库…</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<HomeView
|
<HomeView
|
||||||
categories={visibleCategories}
|
categories={visibleCategories}
|
||||||
onOpenDetail={openDetail}
|
onOpenDetail={openDetail}
|
||||||
|
|||||||
+75
-1
@@ -1,3 +1,6 @@
|
|||||||
|
import type { NavModel } from './data/nav'
|
||||||
|
import type { Category } from './data/types'
|
||||||
|
|
||||||
export type Project = {
|
export type Project = {
|
||||||
name: string
|
name: string
|
||||||
route_path: string
|
route_path: string
|
||||||
@@ -9,7 +12,6 @@ type ProjectsPayload = {
|
|||||||
|
|
||||||
const PROJECTS_ENDPOINT = '/api/projects'
|
const PROJECTS_ENDPOINT = '/api/projects'
|
||||||
const EVENTS_ENDPOINT = '/api/projects/events'
|
const EVENTS_ENDPOINT = '/api/projects/events'
|
||||||
|
|
||||||
/** 读取服务端扫描出的项目列表。 */
|
/** 读取服务端扫描出的项目列表。 */
|
||||||
export async function fetchProjects(signal?: AbortSignal): Promise<Project[]> {
|
export async function fetchProjects(signal?: AbortSignal): Promise<Project[]> {
|
||||||
const response = await fetch(PROJECTS_ENDPOINT, {
|
const response = await fetch(PROJECTS_ENDPOINT, {
|
||||||
@@ -67,3 +69,75 @@ export function subscribeProjects(handlers: ProjectEventsHandlers): () => void {
|
|||||||
source.close()
|
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 { 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 = {
|
type CategoryNavProps = {
|
||||||
|
/** 侧栏导航数据;内容库为空时是空模型 */
|
||||||
|
nav: NavModel
|
||||||
/** 当前明细视图的分类 / 分组,用于高亮 */
|
/** 当前明细视图的分类 / 分组,用于高亮 */
|
||||||
activeCategoryId?: string
|
activeCategoryId?: string
|
||||||
activeGroupId?: string
|
activeGroupId?: string
|
||||||
onNavigate: (target: NavTarget) => void
|
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 [openGroups, setOpenGroups] = useState<string[]>([])
|
||||||
|
|
||||||
const toggle = (id: string) => {
|
const toggle = (id: string) => {
|
||||||
@@ -27,7 +31,7 @@ export function CategoryNav({ activeCategoryId, activeGroupId, onNavigate }: Cat
|
|||||||
return (
|
return (
|
||||||
<nav className="nav-card card-box" aria-label="站点分类">
|
<nav className="nav-card card-box" aria-label="站点分类">
|
||||||
<ul className="nav-group">
|
<ul className="nav-group">
|
||||||
{NAV_TOP_LEVEL.map((leaf) => (
|
{nav.topLevel.map((leaf) => (
|
||||||
<li className="nav-item" key={leaf.id}>
|
<li className="nav-item" key={leaf.id}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -43,7 +47,7 @@ export function CategoryNav({ activeCategoryId, activeGroupId, onNavigate }: Cat
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{NAV_GROUPS.map((group) => (
|
{nav.groups.map((group) => (
|
||||||
<li className={`nav-item${isOpen(group.id) ? ' open' : ''}`} key={group.id}>
|
<li className={`nav-item${isOpen(group.id) ? ' open' : ''}`} key={group.id}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Project } from '../../api'
|
import type { Project } from '../../api'
|
||||||
import type { NavTarget } from '../../data/nav'
|
import type { NavModel, NavTarget } from '../../data/nav'
|
||||||
import { CategoryNav } from './CategoryNav'
|
import { CategoryNav } from './CategoryNav'
|
||||||
import { ProfileCard } from './ProfileCard'
|
import { ProfileCard } from './ProfileCard'
|
||||||
import { ServicePanel } from './ServicePanel'
|
import { ServicePanel } from './ServicePanel'
|
||||||
@@ -10,6 +10,8 @@ import { ServicePanel } from './ServicePanel'
|
|||||||
*/
|
*/
|
||||||
type SidebarProps = {
|
type SidebarProps = {
|
||||||
projects: Project[]
|
projects: Project[]
|
||||||
|
/** 侧栏导航数据,来自内容库 */
|
||||||
|
nav: NavModel
|
||||||
live: boolean
|
live: boolean
|
||||||
refreshing: boolean
|
refreshing: boolean
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
@@ -20,6 +22,7 @@ type SidebarProps = {
|
|||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
projects,
|
projects,
|
||||||
|
nav,
|
||||||
live,
|
live,
|
||||||
refreshing,
|
refreshing,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
@@ -39,6 +42,7 @@ export function Sidebar({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<CategoryNav
|
<CategoryNav
|
||||||
|
nav={nav}
|
||||||
activeCategoryId={activeCategoryId}
|
activeCategoryId={activeCategoryId}
|
||||||
activeGroupId={activeGroupId}
|
activeGroupId={activeGroupId}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
|
|||||||
@@ -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]
|
|
||||||
}
|
|
||||||
@@ -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
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* 侧栏分类导航的数据定义。
|
* 侧栏分类导航的数据结构。
|
||||||
*
|
*
|
||||||
* 叶子节点通过 target 指向「分类 + 可选分组」,点击后右侧切换到对应明细。
|
* 导航数据存在内容库的 `nav_entry` 表里(自引用树),随 `/api/catalog` 一起下发;
|
||||||
* 标签保持与现有侧栏一致,避免改动已定稿的侧栏。
|
* 这里只保留类型定义和空模型兜底,不再有写死的导航常量。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type NavTarget = {
|
export type NavTarget = {
|
||||||
@@ -11,44 +11,25 @@ export type NavTarget = {
|
|||||||
groupId?: string
|
groupId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 一个可点击的导航叶子 */
|
||||||
export type NavLeaf = {
|
export type NavLeaf = {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
target: NavTarget
|
target: NavTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 可折叠分组:本身不可点击,只负责收纳叶子 */
|
||||||
export type NavGroupNode = {
|
export type NavGroupNode = {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
leaves: NavLeaf[]
|
leaves: NavLeaf[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 顶层直达链接 */
|
/** 侧栏的完整数据模型:顶层直达项 + 可折叠分组 */
|
||||||
export const NAV_TOP_LEVEL: NavLeaf[] = [
|
export type NavModel = {
|
||||||
{ id: 'recommend', label: '常用推荐', target: { categoryId: 'recommend' } },
|
topLevel: NavLeaf[]
|
||||||
]
|
groups: NavGroupNode[]
|
||||||
|
}
|
||||||
|
|
||||||
/** 可折叠分组 */
|
/** 内容库还没有导航数据时的兜底,避免渲染时到处判空 */
|
||||||
export const NAV_GROUPS: NavGroupNode[] = [
|
export const EMPTY_NAV: NavModel = { topLevel: [], groups: [] }
|
||||||
{
|
|
||||||
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' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -1108,7 +1108,7 @@
|
|||||||
<div id="detail-view" class="detail-view">
|
<div id="detail-view" class="detail-view">
|
||||||
|
|
||||||
<div class="detail-header-nav">
|
<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 style="font-size: 12px; color: #94a3b8;" id="detail-breadcrumb">首页 / 分类明细</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user