- 登录:LoginDialog(Modal)替代 /login 页面;/login 重定向回首页 - 设置接口:GET /api/settings 公开读、PATCH 需登录 + version 乐观锁(409/400) - setting 表存 site JSON:页头(启用/速度/条目+标签+色调)、侧栏(hidden/order) - /admin:Tabs 页头/侧栏,条目增删与上下移,保存后首页生效 - 首页应用设置:ticker 文本占位符与速度、侧栏顺序与显隐 - 测试 42 passed(settings 5 例)
24 KiB
Stash
Stash 是一个用 Rust 编写的本地静态页面托管工具。编译后的 React 控制台会被内嵌进二进制,
所以发布物只有一个 stash 可执行文件,不需要额外部署前端静态资源。
它能做两件事:托管目录下的多个静态前端项目、给控制台提供同源 JSON / SSE 接口。
快速开始
# 1. 先构建前端(dist 会被内嵌,缺了它 cargo build 会直接失败)
cd stash-web
npm install
npm run build
cd ..
# 2. 编译并运行
cargo run # 托管 ./public,监听 127.0.0.1:8080
# 自定义目录 / 端口 / 对外暴露
cargo run -- --root ./sites --port 3000
cargo run -- --root ./sites --host 0.0.0.0 --port 3000
启动后打开终端里打印的地址即可,例如 http://127.0.0.1:8080/。
默认托管目录 ./public 不存在时会自动创建;首次启动还会与二进制同级创建 data/,
并在其中生成内容库 stash.db(已内置示例内容,可以直接用 SQLite 工具修改)。
环境要求:
- Rust 工具链(2024 Edition)
- Node.js 24,推荐用 Volta 管理;
stash-web/package.json里已用volta字段固定版本
登录与设置(v0.1.1 起)
- 登录是页内弹窗:点右上角用户图标;首次使用弹窗里是「设置密码 + 确认」(至少 8 位);
- 登录后点「站点设置」进入
/admin,可编辑页头滚动信息(启用 / 速度 / 条目)与侧栏顺序、显隐; - 密码用 argon2id 哈希存在
data/stash.db的admin表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天); - 读接口公开(本地工具可只读使用),写接口(设置、改密等)要求登录;
- 忘记密码:删除
admin表里的那一行(或整个库)后重启,会回到「设置密码」; - 方案与后续阶段见
docs/ADMIN-PLAN.md。
工作方式
请求按下面的顺序命中:
| 路径 | 处理方式 |
|---|---|
/api/* |
JSON / SSE 接口 |
/、/index.html |
内嵌前端的 index.html(no-cache) |
/c/** |
前端路由地址(分类明细 / 条目详情),没有实体文件时同样返回 index.html |
/assets/** |
内嵌前端的静态资源(文件名带 hash,长缓存) |
/favicon.svg |
内嵌前端根目录文件 |
| 其他路径 | ServeDir 托管 --root 目录,目录自动补 index.html |
| 仍未命中 | 内置 404 页面(显示请求路径) |
托管目录下的每个一级子目录,只要包含 index.html 或 index.htm,就会被识别为一个项目,
通过 /<目录名>/ 访问。目录名会做 URL 路径段编码。
前端路由
控制台是单页应用,但「当前在哪一页」写在地址栏里,所以刷新、收藏、前进后退都不会丢上下文:
| 地址 | 页面 |
|---|---|
/ |
首页分区卡片 |
/c/<分区> |
分类明细 |
/c/<分区>/<分组> |
只展示该分组 |
/c/<分区>/i/<条目> |
条目详情,返回 → 分类明细 |
/c/<分区>/<分组>/i/<条目> |
条目详情,返回 → 该分组 |
地址里的每一段都是业务键(也就是接口 JSON 里的 id):库里是 node.key / entry.key,
本地项目则是目录名(访问地址仍然是 route_path,两者不再混用)。
几条约定:
- 条目地址带上它自己所在的分组,所以一个条目只有一个地址;刷新之后点「返回」 仍然落回条目所属的分组,而不是去猜一个返回目标;
- 「返回」按钮在应用内有上一页时交给浏览器(
history.back()), 直接打开地址(刷新 / 收藏,应用内没有上一页)时退到上面的推导结果; - 服务端只把
/c/**回落到入口页(src/server.rs的SPA_PREFIXES), 托管目录里写错的地址仍然是 404 页面——不做「所有未命中都返回 200」的万能回落, 否则分不清是路由拼错还是文件真的不存在; - 地址转换规则在
stash-web/src/data/view.ts, 与 history 打交道的部分在stash-web/src/useViewRoute.ts。 新增一类页面时两处都要改:前端加前缀,服务端把它加进SPA_PREFIXES。
目录结构
.
├── Cargo.toml # 单 crate(lib + bin),依赖清单
├── Cargo.lock
├── build.rs # 把 stash-web/dist 同步到 $OUT_DIR/web
├── src/ # Rust 后端
│ ├── main.rs # CLI 参数、启动横幅、停机信号
│ ├── lib.rs # 库导出
│ ├── config.rs # 服务配置、数据目录与路径校验
│ ├── error.rs # 错误类型与 JSON 错误响应
│ ├── server.rs # 路由、内嵌静态资源、SSE、生命周期
│ ├── listing.rs # 托管目录扫描
│ ├── watcher.rs # 文件监听、防抖、快照广播
│ └── db/ # SQLite 内容库
│ ├── mod.rs # 打开、PRAGMA、种子导入时机
│ ├── schema.rs # 建表语句与种子导入
│ ├── catalog.rs # 查询结果组装成前端 Category[]
│ └── seed.json # 内嵌种子数据(编译进二进制)
├── stash-web/ # React + Vite + TypeScript 控制台
│ ├── package.json # 含 volta 版本固定
│ ├── vite.config.ts # 开发期 /api 代理到 127.0.0.1:8080
│ └── src/
├── data/ # 内容库目录(与二进制同级,运行时生成)
│ └── stash.db # SQLite 内容库
└── public/ # 默认托管目录(可放多个前端项目)
templates/ 与 assets/ 是上一版 Askama 控制台留下的文件,当前已无任何代码引用,
可以直接删除。
前端怎么被内嵌
npm run build产出stash-web/dist;build.rs在编译 Rust 之前把dist复制到$OUT_DIR/web(先清空,避免旧 hash 文件残留);src/server.rs用include_dir!("$OUT_DIR/web")在编译期把整棵目录树嵌进二进制;- 二进制运行时不再读取任何前端文件。
构建顺序不能颠倒:dist 不存在时 build.rs 会带着提示信息直接失败,而不是产出一个没有界面的二进制。
build.rs 声明了 rerun-if-changed,改动 dist 后重新 cargo build 会自动重新嵌入。
内容库(data/stash.db)
控制台的分类 / 分组 / 条目不写死在前端代码里,而是存在一个 SQLite 文件中,
默认位置是二进制同级的 data/stash.db。首次启动时若文件不存在,
会用内嵌的 src/db/seed.json 建库并灌入示例内容;之后一直沿用该文件(不覆盖已有数据)。
用 --data <DIR> 可以把数据目录指到别处。
内容库不能放在 --root 托管目录内部——那里的文件会被 ServeDir 当静态资源对外提供,
启动时会直接报错拒绝。
表结构
三张表 + 一张展示配置表:
- 分区与分组是同一张自引用树——它们都是「装条目的容器」,只是层级不同;
- 侧栏直接由这棵树派生(分区下挂分组),不再有独立的导航表;
- 首页分区展示什么由
section_block决定,卡片不再混排所有分组。
文章正文是一整段 Markdown,直接存在 entry.body 里(一篇文章一行),不再拆成正文块表。
主键统一是 id INTEGER PRIMARY KEY(SQLite rowid,自动分配,不用 AUTOINCREMENT),
另有一列 key 作为唯一业务编码;外键一律写成 <目标>_key,指向对方的 key。
node —— 分区与分组(一棵容器树)
| 字段 | 类型 | 说明 |
|---|---|---|
id |
INTEGER | 主键,库内唯一标识(自动分配) |
key |
TEXT | 业务编码,全局唯一(如 recommend / ui) |
parent_key |
TEXT? | 父节点编码:分组指向所属分区,分区为 NULL |
title |
TEXT | 展示名:分区标题 / 分组小标题 |
kind |
TEXT | section 顶层分区 / group 分区下的分组 |
view_mode |
TEXT? | 仅分区:apps / posters / articles,决定渲染形态 |
bar_color |
TEXT? | 仅分区:标题左侧 4×16 色条颜色(如 #3858f6) |
icon |
TEXT? | 仅分区:明细页 hero 左侧的字符图标 |
preview_limit |
INTEGER? | 仅分区:首页预览条数;为空时按类型取默认 |
empty_text |
TEXT? | 仅分区:分区为空时的提示文案 |
show_in_sidebar |
INTEGER | 是否出现在侧栏,0 / 1;0 表示只当首页卡片/内容,不进导航 |
sort |
INTEGER | 同级顺序,越小越靠前 |
两条约束写在表上:
CHECK:section必须parent_key IS NULL且有view_mode;group必须有父节点且无view_mode;key唯一(UNIQUE),全局不重复。
entry —— 内容条目(网站 / 电影 / 文章共用)
| 字段 | 类型 | 说明 / 前端用途 |
|---|---|---|
id |
INTEGER | 主键,库内唯一标识(自动分配) |
key |
TEXT | 业务编码,全局唯一(如 github / movie-1);详情页按它查找 |
node_key |
TEXT | 直接所属容器,外键 → node.key:应用是分组,海报 / 图文是分区本身 |
name |
TEXT | 名称:网站名 / 电影名 / 文章标题 |
description |
TEXT? | 一句话简介,列表卡片的副标题 |
icon |
TEXT? | 字符图标,封面缺失时的占位 |
href |
TEXT? | 点击后打开的页面;空或 # 表示暂无可跳转地址 |
cover |
TEXT? | 封面图:应用头像 / 电影海报 / 文章配图 |
banner |
TEXT? | 详情页横幅图 |
badge |
TEXT? | 详情页标签行上的一句话标记(如「推荐好站」) |
comment |
TEXT? | 详情页的一句话评价(引用块) |
summary |
TEXT? | 详情页正文段落(电影简介这类长文本) |
body |
TEXT? | 文章正文,一整段 Markdown;一篇一行,不拆块 |
hot |
INTEGER | 是否热门,0 / 1;为 1 时列表显示 HOT 徽标 |
views |
INTEGER | 浏览量,详情页操作条「眼睛」后的数字 |
likes |
INTEGER | 点赞数,详情页操作条「赞」后的数字 |
sort |
INTEGER | 条目顺序(组内或分区内),越小越靠前 |
条目只存 node_key,所属分区由它推导:父节点是分组就取该分组的 parent_key,
父节点是分区就取它自己。查询「整个分区」时用:
SELECT e.*
FROM entry e
JOIN node p ON p.key = e.node_key
WHERE COALESCE(p.parent_key, p.key) = 'recommend';
好处是没有冗余列可以写错——条目不会出现「声称属于 A 区、实际挂在 B 区分组下」的情况;
node_key 还有 ON UPDATE CASCADE,改编码时条目自动跟随。
三种内容共用一张表,只是用到的字段不同,形态由所属分区的 view_mode 决定:
| 类型 | 常用字段 |
|---|---|
| 应用 | name description icon href cover badge banner hot |
| 电影 | name(前端叫 title)cover summary comment href + entry_tag |
| 文章 | name(前端叫 title)description cover body(Markdown)+ entry_tag |
正文格式
entry.body 存的是 Markdown,渲染层只支持正文实际用到的子集:
| 写法 | 效果 |
|---|---|
| 空行分段 | 一个段落 |
## 小标题 |
正文小标题(#~###### 都渲染成同级) |
单独一行  |
配图 |
**粗体**、`代码`、[文字](链接) |
行内格式 |
列表、表格、引用块暂不支持;需要完整语法时把 ArticleBody.tsx
换成 react-markdown 即可,渲染层是隔离的。
entry_tag —— 行内文本列表(标签 / 元信息)
| 字段 | 类型 | 说明 / 前端用途 |
|---|---|---|
entry_key |
TEXT | 所属条目编码,外键 → entry.key |
sort |
INTEGER | 展示顺序 |
label |
TEXT | 标签文字(如「推荐电影」「2 年前」) |
主键 (entry_key, sort)。海报的 tags 与文章的 meta 都存这里——它们都是「有序字符串列表」,
前端只是在不同版式里渲染。
section_block —— 首页卡片的展示配置
| 字段 | 类型 | 说明 / 前端用途 |
|---|---|---|
id |
INTEGER | 主键,库内唯一标识(自动分配) |
node_key |
TEXT | 要展示的节点编码,外键 → node.key;通常是分组,电影/文章填分区自己 |
title |
TEXT? | 块标题;为空时用节点 title |
preview_limit |
INTEGER? | 卡片显示几条;为空时用分类默认值 |
sort |
INTEGER | 卡片顺序 |
不存所属分区:一张卡片属于哪个分区,由 node_key 推导——节点是分组就取 parent_key,
节点是分区就取它自己。所以这一列没必要重复存。
一张卡片展示「你选定的那个分组」,而不是把分区下所有分组混在一起; 点卡片标题或「···」都打开这个块指向的节点。
表关系
node (kind=section) 1 ──── n node (kind=group)
│ │
└────────── n entry ───────┘
│
├──── n entry_tag (标签 / 元信息)
└──── n section_block (首页卡片,指向节点)
section_block ──── 1 node(分区由 node 自己推导,不单独存)
| 关系 | 外键 | 级联 |
|---|---|---|
| 分区 → 分组 | node.parent_key → node.key |
删分区连带删分组 |
| 节点 → 条目 | entry.node_key → node.key |
删分区或分组连带删条目;改编码时条目自动跟随 |
| 条目 → 标签 | entry_tag.entry_key → entry.key |
删条目删标签 |
| 展示块 → 节点 | section_block.node_key → node.key |
删被展示的节点连带删该卡片 |
几条约束值得记住:
- API 只暴露业务编码:响应里的
id都是key,数字主键只在库内使用; entry.node_key:应用指向分组,海报与图文直接指向分区;- 条目
key是主键,全局唯一; - 侧栏就是内容树:分区展开是它的分组,名称取自
node.title,不再有别名或跨区编排; - 首页卡片展示哪个分组由
section_block.node_key决定,改一行即可换内容。
前端怎么用这些表
| 界面 | 用到的表 | 关键字段 |
|---|---|---|
| 首页分区卡片 | node(section) + section_block + entry |
卡片标题用展示块标题(默认被展示节点的 title),色条用分区 bar_color;预览条目取该节点下的前 N 条 |
| 分类明细页 | node(section/group) + entry |
icon(hero 图标)、分区 title 或分组 title、条目卡片字段 |
| 应用详情 | entry(apps) |
name description icon href cover banner badge comment views likes |
| 海报详情 | entry + entry_tag |
name(→title)cover comment summary href + tags |
| 图文详情 | entry + entry_tag |
name(→title)description cover body + meta |
| 侧栏导航 | node |
走 parent_key 的分区 + 分组,show_in_sidebar = 0 的节点跳过;「本地服务」由前端排除 |
| 本地服务 | 不在库里 | 由 /api/projects 扫描托管目录后在前端拼接 |
侧栏导航
侧栏不再是单独一层数据,直接由内容树派生:
常用推荐(section,可折叠)
├── 常用站点(group → 打开该分组)
├── UI 设计资源
├── 矢量图标
├── 免费壁纸
└── 开发工具
推荐电影(section,无分组 → 直接打开)
CES 现场报道(section,无分组 → 直接打开)
规则很简单:
- 分区有分组时渲染成可折叠节点,子项就是分组;
- 分区没有分组时(电影 / 文章)整行是链接;
- 名字一律用
node.title,没有别名,也没有跨分区的收纳层; show_in_sidebar = 0的节点不出现(种子把「常用站点」设成了 0,因为它已经是首页卡片);- 「本地服务」是前端拼出来的分类,侧栏按 id 直接排除。
首页展示块
首页卡片展示什么由 section_block 决定,不再把分区下所有分组混排:
-- 常用推荐卡片改成展示「UI 设计资源」分组,最多 6 条
UPDATE section_block
SET node_key = 'ui', preview_limit = 6
WHERE node_key = 'common';
每张卡片一行,node_key 指向要展示的节点;它属于哪个分区由节点自己决定。
点卡片标题或「···」都会打开这个节点;电影 / 文章这类没有分组的,node_key 填分区自己的编码。
卡片标题跟着展示内容走:配了展示块时用块标题(没配则用节点 title),
所以「常用推荐」这张卡片会显示成「常用站点」——它实际展示的就是这个分组。
需要单独起名时,写 section_block.title 即可。
自己改内容
- 用任何 SQLite 工具(DB Browser for SQLite、
sqlite3CLI、DBeaver…)打开data/stash.db; - 内容改
node(分区与分组)与entry(文章正文就是entry.body里的 Markdown),标签改entry_tag,首页展示改section_block; - 提交修改后,页面会在 5 秒内自动刷新——服务端轮询
PRAGMA data_version检测外部写入; - 也可以点侧栏的「重新扫描」或直接刷新浏览器。
新增条目时 id 不用填,key 必须写(全局唯一);node_key 填直接所在的分组编码
(没有分组就填分区编码),所属分区由它推导。
先 SELECT key, kind, parent_key FROM node 看一眼现有编码即可。
重置
开发阶段表结构还会调整,没有实现迁移。要重置内容:关掉服务后删掉 data/stash.db,
下次启动会重新建表并灌入内嵌种子。
rm data/stash.db # Linux / macOS
Remove-Item data\stash.db # Windows PowerShell
库结构版本记在 PRAGMA user_version 里(当前为 15)。新版本二进制遇到旧库时会直接拒绝启动
并提示删库,而不是拿旧表执行新查询:
[FATAL] 启动失败: 数据库错误: 内容库结构版本为 14,当前程序需要 15。
开发阶段不做自动迁移,请删除数据库后重启(种子会自动重新导入)。
SCHEMA_VERSION 定义在 src/db/schema.rs,改动表结构时把它 +1。
前端开发
cd stash-web
npm install
npm run dev # http://127.0.0.1:5173,/api 自动代理到后端 8080
npm run build # 产物输出到 stash-web/dist
npm run lint # oxlint
开发时前后端分开跑:一个终端 cargo run,另一个终端 npm run dev。
控制台的用户资料目前是占位数据(见 stash-web/src/profile.ts),后端尚未提供鉴权接口。
命令行参数
| 参数 | 默认值 | 说明 |
|---|---|---|
-r, --root <DIR> |
./public |
静态页面托管根目录 |
--data <DIR> |
二进制同级 data/ |
内容库所在目录(存放 stash.db) |
-p, --port <PORT> |
8080 |
监听端口 |
--host <HOST> |
127.0.0.1 |
监听地址,0.0.0.0 供局域网访问 |
--no-cors |
关闭 | 关闭全局 CORS 标头注入 |
-h, --help |
- | 帮助 |
-V, --version |
- | 版本 |
--no-listing 参数目前仍能被解析,但由于控制台已改为内嵌前端,它不再影响任何行为。
HTTP API
健康检查
GET /api/health
{ "status": "ok", "service": "stash" }
项目列表
GET /api/projects
{
"projects": [
{ "name": "admin-console", "route_path": "/admin-console/" },
{ "name": "docs", "route_path": "/docs/" }
]
}
订阅项目变化
GET /api/projects/events
Accept: text/event-stream
连接建立后立即推送一次当前快照,之后在项目列表变化时推送:
event: projects
data: {"projects":[{"name":"docs","route_path":"/docs/"}]}
服务端递归监听托管目录,把短时间内的连续事件合并成一次扫描,只在项目名称或访问路径真正变化时广播; 每 15 秒发送一次保活消息,浏览器断开后自动重连。
内容目录
GET /api/catalog
返回控制台需要的全部分类(结构见 types.ts),
数据来自 data/stash.db。一次全量下发,前端搜索在客户端完成:
{
"categories": [
{
"id": "recommend",
"kind": "apps",
"title": "常用推荐",
"barColor": "#3858f6",
"icon": "🔖",
"block": { "nodeKey": "common", "previewLimit": 4 },
"groups": [
{
"id": "common",
"name": "常用站点",
"items": [
{ "id": "github", "name": "GitHub", "desc": "代码托管与开源社区", "views": 429 }
]
}
]
}
]
}
「本地服务」分类不在这个接口里,它由 /api/projects 扫描托管目录后在前端拼接。
每个分类上的 block 就是首页卡片的展示配置:nodeKey 指向要展示的节点(通常是分组),
previewLimit 是卡片里显示几条。侧栏不再单独下发,前端直接用 categories 里的
分区与分组渲染。
订阅内容库变化
GET /api/catalog/events
Accept: text/event-stream
当内容库被其它连接(例如用户用 DB Browser 改库)提交后推送一次,事件本身不带数据:
event: catalog
data: {"changed":true}
客户端收到后重新 GET /api/catalog。检测基于 SQLite 的 PRAGMA data_version,每 5 秒轮询一次。
测试
cargo test # 目录扫描、隐藏目录过滤、排序、路径编码、文件监听广播、内容库、SPA 回落
cd stash-web && npm run build # tsc -b + vite build,同时做类型检查
内容库相关测试全部使用内存库(:memory:),不写磁盘、不污染 data/。
如果运行环境限制了系统临时目录(部分沙箱会这样),前几个测试会报 PermissionDenied,
把 TEMP / TMP 指到工作区内再跑即可:
$env:TEMP = "$PWD\target\test-tmp"; $env:TMP = $env:TEMP; cargo test
已知限制
- 前端路由只有
/c/**一个前缀;新增一类页面时,stash-web/src/data/view.ts与src/server.rs的SPA_PREFIXES必须一起改,否则刷新那条地址会拿到 404 页面。 - 前进 / 后退能回到对应页面,但不恢复滚动位置:切换视图统一滚回内容区顶部。
- 内嵌资源占用了
/assets/前缀,托管目录下如果存在同名目录会被遮蔽。 /固定由控制台占用,上一版「根目录有index.html就直接当单站点展示」的行为已取消; 托管目录里的项目统一走/<目录名>/。- 项目扫描只检查一级子目录,不递归查找更深层项目。
- 文件事件经过约 150ms 防抖,新项目出现在控制台可能有短暂延迟。
- 内容库没有迁移机制:表结构变化时需要删掉
data/stash.db重建(当前属于开发阶段)。 - 内容库被外部工具修改后,页面最长有 5 秒延迟才自动刷新。
- 内容库按单实例使用设计:多个进程同时写同一个库会在
busy_timeout(5 秒)后报错。 - 没有身份认证,默认只建议绑定
127.0.0.1;使用0.0.0.0暴露到局域网前请确认目录内容适合被其他设备访问。