Files
stash/README.md
T
CNWei 4621f8cb22 chore: 清理无引用的前端与旧模板资源
- 删除 13 个旧 App 时代的前端组件/数据层文件(无 import 可达)
- 删除 templates/ 6 个旧 Askama 原型页与 assets/dashboard.{css,js}
- README/设计文档同步当前路由与图标实现,移除失效引用
- stash-web 构建通过,cargo test 59 passed
2026-11-02 20:10:48 +08:00

28 KiB
Raw Blame History

Stash

Stash 是一个用 Rust 编写的本地静态页面托管工具。编译后的 React 控制台会被内嵌进二进制, 所以发布物只有一个 stash 可执行文件,不需要额外部署前端静态资源。

它能做两件事:托管目录下的多个静态前端项目、给控制台提供同源 JSON / SSE 接口。

快速开始

组件库 stash-ui 以 file: 方式挂在 stash-web 上, 所以两个仓库要放在同一级目录:

<任意目录>/
├── stash/        # 本仓库
└── stash-ui/     # 组件库,必须先构建出 dist
# 1. 先构建组件库(stash-web 依赖它的 dist)
cd stash-ui
npm install && npm run build
cd ../stash

# 2. 再构建前端(dist 会被内嵌,缺了它 cargo build 会直接失败)
cd stash-web
npm install
npm run build
cd ..

# 3. 编译并运行
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

改了组件库之后要重新 npm run build(库)→ npm run build(stash-web)→ cargo build, 顺序不能反:cargo build 内嵌的是 stash-web/dist。

启动后打开终端里打印的地址即可,例如 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,左侧导航 + 右侧内容:
    • 分组管理:树形表格,可建根 / 子分组、图标(字符或 iconfont 图标名)、排序权重、启停、删除;
    • 数据管理:选分组后列出条目(含子分组),每条可单独设格式,可新建 / 编辑 / 删除;
    • 首页管理(子项):页头(站点信息 / 操作工具 / 滚动信息三个页签)、侧边栏(三块侧栏卡片显隐)、内容卡片(左侧可拖拽排序的卡片列表 + 右侧分组表格:状态、预览条数、顺序、展示);
    • 资料管理与系统管理(改密码、下载整库备份);
  • 分组的「状态」是唯一可见性来源:停用的分组在侧栏和卡片里都不再出现,层层往下生效;
  • 详情页的 [编辑] 只在登录后显示;保存后局部刷新,updated_at 乐观锁;
  • /admin 的「系统管理」页可以改密码、下载整库备份(VACUUM INTO 快照);
  • 密码用 argon2id 哈希存在 data/stash.db 的 admin 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天);
  • 读接口公开(本地工具可只读使用),写接口(分组、条目、卡片、设置、改密)要求登录并校验同源 Origin;
  • 明暗配色:站点默认值在「页头 → 配色」(跟随系统 / 浅色 / 深色),前台页头的月亮按钮写的是本机偏好(localStorage),不需要登录;
  • 完整接口清单可以直接开站内页面 /interface 查看;
  • 忘记密码:删除 admin 表里的那一行(或整个库)后重启,会回到「设置密码」;
  • 方案与后续阶段见 docs/ADMIN-PLAN.md。

工作方式

请求按下面的顺序命中:

路径 处理方式
/api/* JSON / SSE 接口
/、/index.html 内嵌前端的 index.html(no-cache)
/c/**、/g/** 前端路由地址(分组页 / 条目详情),没有实体文件时同样返回 index.html
/admin、/login、/interface 固定页面地址,同样返回 index.html
/assets/** 内嵌前端的静态资源(文件名带 hash,长缓存)
/favicon.svg 内嵌前端根目录文件
其他路径 ServeDir 托管 --root 目录,目录自动补 index.html
仍未命中 内置 404 页面(显示请求路径)

托管目录下的每个一级子目录,只要包含 index.html 或 index.htm,就会被识别为一个项目, 通过 /<目录名>/ 访问。目录名会做 URL 路径段编码。

前端路由

控制台是单页应用,但「当前在哪一页」写在地址栏里,所以刷新、收藏、前进后退都不会丢上下文:

地址 页面
/ 首页卡片(按后台勾选的分组组合)
/g/<分组>、/c/<分组> 分组页(自身内容 + 子分组)
/g/<分组>/i/<条目> 条目详情
/interface 接口一览(站内页面,侧边栏「查看接口」进入)
/admin 站点设置(左侧导航 + 右侧内容)

地址里的每一段都是业务键(也就是接口 JSON 里的 id):库里是 node.key / entry.key, 本地项目则是目录名(访问地址仍然是 route_path,两者不再混用)。

几条约定:

  • 条目地址带上它自己所在的分组,所以一个条目只有一个地址;刷新之后点「返回」 仍然落回条目所属的分组,而不是去猜一个返回目标;
  • 「返回」按钮在应用内有上一页时交给浏览器(history.back()), 直接打开地址(刷新 / 收藏,应用内没有上一页)时退到上面的推导结果;
  • 服务端只把 /c/、/g/ 两个前缀和 /admin、/login、/interface 三个固定地址回落到入口页 (src/server.rs 的 SPA_PREFIXES / SPA_PAGES),托管目录里写错的地址仍然是 404 页面—— 不做「所有未命中都返回 200」的万能回落,否则分不清是路由拼错还是文件真的不存在;
  • 地址转换规则在 stash-web/src/data/view.ts, 实际解析与跳转在 stash-web/src/AppNext.tsx 的 useRoute / navigate。 新增一类页面时两处都要改:前端加前缀,服务端把它加进 SPA_PREFIXES 或 SPA_PAGES;
  • 站内跳转统一走 navigate()(AppNext.tsx),不整页刷新;按住 Ctrl/Cmd 点卡片或链接仍可新标签打开。

目录结构

.
├── 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[]
│       ├── settings.rs     # 站点设置的 JSON 结构与校验
│       └── seed.json       # 内嵌种子数据(编译进二进制)
├── stash-web/              # React + Vite + TypeScript 控制台
│   ├── package.json        # 含 volta 版本固定;@stash/ui 用 file:../../stash-ui 引入
│   ├── vite.config.ts      # 开发期 /api 代理到 127.0.0.1:8080
│   └── src/
│       ├── AppNext.tsx     # 前台外壳:路由、卡片、侧栏、搜索
│       ├── theme.ts        # 明暗主题的本机偏好(localStorage)
│       ├── settings.ts     # 站点设置的读写与默认值
│       └── components/
│           ├── admin/      # 后台(AdminNext:左侧导航 + 各面板)
│           ├── api/        # 接口一览页
│           ├── header/     # 页头(品牌、滚动信息、操作图标)
│           └── sidebar/    # 侧栏卡片与本地服务面板
├── data/                   # 内容库目录(与二进制同级,运行时生成)
│   └── stash.db            # SQLite 内容库
└── public/                 # 默认托管目录(可放多个前端项目)

../stash-ui/                # 同级目录:@stash/ui 组件库(令牌 / 组件 / 展示站)

前端怎么被内嵌

  1. npm run build 产出 stash-web/dist;
  2. build.rs 在编译 Rust 之前把 dist 复制到 $OUT_DIR/web(先清空,避免旧 hash 文件残留);
  3. src/server.rs 用 include_dir!("$OUT_DIR/web") 在编译期把整棵目录树嵌进二进制;
  4. 二进制运行时不再读取任何前端文件。

构建顺序不能颠倒: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 当静态资源对外提供, 启动时会直接报错拒绝。

表结构

七张表:

  • 分组(node)是唯一容器——分区与分组是同一张自引用树,只是层级不同;
  • 内容(entry)只挂在分组上,不直接挂在分区;
  • 首页卡片由 card + card_group 决定:卡片只存标题与顺序,勾选哪些分组、每个分组预览几条写在 card_group;
  • 侧栏直接由分组树派生(enabled 与 sort),不再有独立的导航表;
  • 站点设置整份存在 setting(JSON + version 乐观锁),admin / session 存管理员与会话,audit 记操作。

文章正文是一整段 Markdown,直接存在 entry.body 里(一篇文章一行),不再拆成正文块表。

主键统一是 id INTEGER PRIMARY KEY(SQLite rowid,自动分配,不用 AUTOINCREMENT), 另有一列 key 作为唯一业务编码;外键一律写成 <目标>_key,指向对方的 key。

表结构有版本号:PRAGMA user_version 记录当前版本(src/db/schema.rs 的 SCHEMA_VERSION, 当前 22),启动时逐级迁移旧库,不需要删库重建。

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? 分组图标:可以是字符(🎬),也可以是 iconfont 图标名(film_line)
preview_limit INTEGER? 分组在卡片里的默认预览条数;为空时按类型取默认
empty_text TEXT? 分组为空时的提示文案
show_in_sidebar INTEGER 是否出现在侧栏,0 / 1;0 表示只当首页卡片/内容,不进导航
allow_groups INTEGER 是否允许新建子分组,0 / 1;与类型无关,后台开关
enabled INTEGER 是否启用,0 / 1;唯一可见性来源,停用后侧栏与卡片都不再出现该子树
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,渲染层只支持正文实际用到的子集:

写法 效果
空行分段 一个段落
## 小标题 正文小标题(#~###### 都渲染成同级)
单独一行 ![说明](url) 配图
**粗体**、`代码`、[文字](链接) 行内格式

列表、表格、引用块暂不支持;需要完整语法时把 ArticleBody.tsx 换成 react-markdown 即可,渲染层是隔离的。

entry_tag —— 行内文本列表(标签 / 元信息)

字段 类型 说明 / 前端用途
entry_key TEXT 所属条目编码,外键 → entry.key
sort INTEGER 展示顺序
label TEXT 标签文字(如「推荐电影」「2 年前」)

主键 (entry_key, sort)。海报的 tags 与文章的 meta 都存这里——它们都是「有序字符串列表」, 前端只是在不同版式里渲染。

card / card_group —— 首页卡片

card         一张卡片 = 标题 + 顺序(内容由 card_group 勾选,卡片本身不存条目)
card_group   卡片包含哪些分组 + 每个分组的预览条数与先后
card 字段 类型 说明
id INTEGER 主键(AUTOINCREMENT)
key TEXT 业务键,唯一;local-service 是内置的本地服务卡片
title TEXT 卡片标题
sort INTEGER 卡片在首页的先后
card_group 字段 类型 说明
card_id INTEGER 外键 → card.id,删卡片级联
group_key TEXT 外键 → node.key,删分组级联
sort INTEGER 分组在这张卡片里的先后
preview_limit INTEGER? 这个分组预览几条;为空用分组自己的默认值

主键 (card_id, group_key):同一张卡片里一个分组只出现一次。 一张卡片可以混合展示多个分组(不同格式也能混),顺序与条数各自独立。

旧版的 section_block 表仍保留在库里(CREATE TABLE IF NOT EXISTS 不会删表), 但只作为从旧库迁移到 card / card_group 的来源,新代码不再读写它。

表关系

node (kind=section) 1 ──── n node (kind=group)
         │                          │
         └────────── n entry ───────┘
                        │
                        └──── n entry_tag       (标签 / 元信息)

card 1 ──── n card_group ──── 1 node(勾选的分组 + 每个分组的预览条数)
关系 外键 级联
分区 → 分组 node.parent_key → node.key 删分区连带删分组
节点 → 条目 entry.node_key → node.key 删分区或分组连带删条目;改编码时条目自动跟随
条目 → 标签 entry_tag.entry_key → entry.key 删条目删标签
卡片 → 分组 card_group.card_id → card.id、card_group.group_key → node.key 删卡片或删分组都会清掉这一行

几条约束值得记住:

  • API 只暴露业务编码:响应里的 id 都是 key,数字主键只在库内使用(卡片例外,用数字 id);
  • entry.node_key:应用指向分组,海报与图文也指向分组(分组可以是根分组);
  • 条目 key 是主键,全局唯一;
  • 侧栏就是内容树:分组按 parent_key 展开,名称取自 node.title,没有别名或跨区编排;
  • 首页卡片 = 勾选的分组集合:card_group.sort 决定分组在卡片里的先后,preview_limit 决定这个分组预览几条。

前端怎么用这些表

界面 用到的表 关键字段
首页卡片 card + card_group + node + entry 卡片标题用 card.title;色条取该卡片第一个分组的 bar_color;每个分组按 card_group.sort 排序、按 preview_limit 取前 N 条
分组页 node + entry icon(hero 图标)、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 与 enabled = 0 的节点跳过;「本地服务」由前端排除
本地服务 不在库里 由 /api/projects 扫描托管目录后在前端拼接

侧栏导航

侧栏不再是单独一层数据,直接由分组树派生:

常用推荐(根分组,可折叠)
├── 常用站点(子分组 → 打开该分组)
├── UI 设计资源
├── 矢量图标
├── 免费壁纸
└── 开发工具
推荐电影(根分组,无子分组 → 直接打开)
CES 现场报道(根分组,无子分组 → 直接打开)

规则很简单:

  • 有子分组时渲染成可折叠节点,子项就是子分组;
  • 没有子分组时整行是链接;
  • 名字一律用 node.title,没有别名,也没有跨分区的收纳层;
  • show_in_sidebar = 0 的节点不出现;enabled = 0 的节点连同整棵子树都不出现;
  • 顺序取 node.sort(在「分组管理」里改「排序权重」);
  • 「本地服务」是前端拼出来的分类,侧栏按 key 直接排除。

首页卡片

一张卡片 = 一个标题 + 一组勾选的分组,内容不混排:

-- 看看「常用推荐」这张卡片勾了哪些分组、各自几条
SELECT cg.group_key, n.title, cg.sort, cg.preview_limit
FROM card_group cg
JOIN node n ON n.key = cg.group_key
WHERE cg.card_id = (SELECT id FROM card WHERE key = 'card-common')
ORDER BY cg.sort;

需要调整时就改 card_group:sort 是分组在卡片里的先后,preview_limit 是预览条数(空表示用分组自己的默认值)。 这些在后台的「首页管理 → 内容卡片」里都能点点鼠标改完,改完即时保存。

自己改内容

  1. 用任何 SQLite 工具(DB Browser for SQLite、sqlite3 CLI、DBeaver…)打开 data/stash.db;
  2. 内容改 node(分组树)与 entry(文章正文就是 entry.body 里的 Markdown),标签改 entry_tag,首页卡片改 card / card_group;
  3. 提交修改后,页面会在 5 秒内自动刷新——服务端轮询 PRAGMA data_version 检测外部写入;
  4. 也可以点侧栏的「重新扫描」或直接刷新浏览器。

新增条目时 id 不用填,key 必须写(全局唯一);node_key 填直接所在的分组编码。 先 SELECT key, kind, parent_key FROM node 看一眼现有编码即可。

重置

表结构有版本号(PRAGMA user_version,当前 22),启动时会自动逐级迁移旧库, 正常升级不用删库。只有在想回到出厂内容时才需要重置:关掉服务后删掉 data/stash.db, 下次启动会重新建表并灌入内嵌种子。

rm data/stash.db                        # Linux / macOS
Remove-Item data\stash.db               # Windows PowerShell

SCHEMA_VERSION 定义在 src/db/schema.rs,改动表结构时把它 +1 并补一段迁移。

前端开发

cd stash-ui      && npm install && npm run dev   # 组件库文档站 http://127.0.0.1:5199
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,另一个终端(stash-web 目录下)npm run dev。 改了 @stash/ui 里的组件要重新 npm run build(库)才会被 stash-web 用到; npm run dev 只会热更 stash-web 自己的代码。

命令行参数

参数 默认值 说明
-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/catalog2

返回分组树 + 首页卡片 + 站点设置(结构见 types.ts), 数据来自 data/stash.db,一次全量下发,前端搜索在客户端完成:

{
  "groups": [
    {
      "key": "pin",
      "title": "常用推荐",
      "icon": "🔖",
      "format": "apps",
      "enabled": true,
      "sort": 1,
      "entries": [{ "id": "1", "name": "1", "format": "apps" }],
      "children": [{ "key": "common", "title": "常用站点", "entries": [] }]
    }
  ],
  "cards": [
    {
      "id": 1,
      "key": "card-pin",
      "title": "常用推荐",
      "groups": [{ "groupKey": "pin", "previewLimit": 4 }]
    }
  ]
}

「本地服务」卡片不在这个接口里,它由 /api/projects 扫描托管目录后在前端拼接。 侧栏不再单独下发,前端直接用 groups 派生。

GET /api/catalog 是旧版结构(categories 数组),仅为兼容保留。

订阅内容库变化

GET /api/catalog/events
Accept: text/event-stream

当内容库变化后推送一次(自己写入和其它连接写入都会触发),事件本身不带数据:

event: catalog
data: {"changed":true}

客户端收到后重新 GET /api/catalog2。检测基于 PRAGMA data_version 轮询 + 写接口主动广播。

其它接口

完整清单(方法 / 路径 / 权限)可以直接开站内页面 /interface 查看, 摘要如下:

分组 接口
健康与项目 GET /api/health、GET /api/projects、GET /api/projects/events(SSE)
内容库 GET /api/catalog2、GET /api/catalog/events(SSE)、POST /api/nodes、PATCH/DELETE /api/nodes/{key}、GET /api/items/{key}、POST /api/items、PATCH/DELETE /api/items/{key}、POST /api/items/{key}/view、POST /api/items/{key}/like、POST /api/cards、PATCH/DELETE /api/cards/{id}、PUT /api/cards/{id}/groups
站点设置 GET/PATCH /api/settings、GET /api/backup
认证 GET /api/auth/session、POST /api/auth/setup、POST /api/auth/login、POST /api/auth/logout、PATCH /api/auth/password

写接口(POST / PATCH / PUT / DELETE)都要求登录会话,并校验请求的 Origin 与本站同源。

测试

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/、/g/,固定页面是 /admin、/login、/interface;新增一类页面时, stash-web/src/data/view.ts 与 src/server.rs 的 SPA_PREFIXES / SPA_PAGES 必须一起改, 否则刷新那条地址会拿到 404 页面。
  • 前进 / 后退能回到对应页面,但不恢复滚动位置:切换视图统一滚回内容区顶部。
  • 内嵌资源占用了 /assets/ 前缀,托管目录下如果存在同名目录会被遮蔽。
  • / 固定由控制台占用,上一版「根目录有 index.html 就直接当单站点展示」的行为已取消; 托管目录里的项目统一走 /<目录名>/。
  • 项目扫描只检查一级子目录,不递归查找更深层项目。
  • 文件事件经过约 150ms 防抖,新项目出现在控制台可能有短暂延迟。
  • 内容库被其它工具修改后,页面最长有 5 秒延迟才自动刷新;自己的写操作会立即广播。
  • 内容库按单实例使用设计:多个进程同时写同一个库会在 busy_timeout(5 秒)后报错。
  • 单管理员账号:只有一套密码,没有多用户 / 多角色;默认只建议绑定 127.0.0.1, 用 0.0.0.0 暴露到局域网前请确认目录内容适合被其它设备访问。
  • 主题偏好按浏览器存(localStorage),换设备不会跟随;站点默认值在后台改。