docs: 按当前实现刷新 README / HANDOFF / ADMIN-PLAN

README:
- 快速开始补上 stash-ui 必须先构建(file: 依赖),目录结构补 stash-ui 与新的前端目录
- 登录与设置改写为新的后台结构(左侧导航 + 页头/侧边栏/内容卡片子项)、明暗主题与 /interface
- 前端路由补 /interface、/admin 与 /c/ 前缀;服务端回落说明改成 SPA_PREFIXES + SPA_PAGES
- 表结构:node 增补 allow_groups / enabled,section_block 章节改为 card / card_group,
  说明 user_version=22 与自动迁移
- HTTP API:内容目录改为 /api/catalog2,新增「其它接口」汇总表(与 /interface 页一致)
- 已知限制:删掉「没有迁移」「没有身份认证」「只有 /c/** 一个前缀」等过时结论

HANDOFF.md 顶部加历史快照说明与变更摘要;ADMIN-PLAN.md 的待办标注已完成项;
stash-web/README.md 从 Vite 模板换成真实说明
This commit is contained in:
2026-11-01 22:42:05 +08:00
parent 259eb20411
commit 1effbea3dd
4 changed files with 215 additions and 139 deletions
+11
View File
@@ -2,6 +2,17 @@
> 写于 2026-10-29。新会话第一件事:读这份 + `docs/ADMIN-PLAN.md` + `stash-ui/README.md`。
> **⚠️ 这是一份历史快照,只保留当时的决策背景。** 2026-11-01 之后又做了这些改动,
> 下面正文里提到的文件名与页面结构可能已经不同,以 `README.md` 为准:
>
> - 前端 `App.tsx` → `AppNext.tsx`,后台 `admin/AdminPage.tsx` → `admin/AdminNext.tsx`(旧文件已删除);
> - 后台从横向 Tabs 改成**左侧导航 + 右侧内容**,页头归到「首页管理」下,
> Tab 名改为 分组管理 / 数据管理 / 首页管理(页头·侧边栏·内容卡片)/ 资料管理 / 系统管理;
> - 新增 `/interface` 接口一览页;侧栏「查看接口」走站内路由,不再直接开 `/api/projects`;
> - 接入 `@stash/ui` 组件库:前后台所有卡片、表格、开关、文字按钮都来自库里,
> 明暗主题(浅色 / 深色 / 跟随系统)由令牌接管;
> - 内容库表结构升到 v22 并**有了自动迁移**,卡片模型是 `card` + `card_group`(`section_block` 仅作旧库迁移来源)。
## 0. 一句话现状
Stash 已从「只读本地导航站」升级为**可登录、可配置、可编辑内容的本地发布台**:
+174 -106
View File
@@ -7,14 +7,28 @@ Stash 是一个用 Rust 编写的本地静态页面托管工具。编译后的 R
## 快速开始
组件库 [`stash-ui`](https://gitea.ngmao.com/CNWei/stash-ui) 以 `file:` 方式挂在 `stash-web` 上,
所以**两个仓库要放在同一级目录**:
```text
<任意目录>/
├── stash/ # 本仓库
└── stash-ui/ # 组件库,必须先构建出 dist
```
```bash
# 1. 先构建前端(dist 会被内嵌,缺了它 cargo build 会直接失败)
# 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 ..
# 2. 编译并运行
# 3. 编译并运行
cargo run # 托管 ./public,监听 127.0.0.1:8080
# 自定义目录 / 端口 / 对外暴露
@@ -22,6 +36,9 @@ 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 工具修改)。
@@ -34,11 +51,18 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
### 登录与设置(v0.1.1 起)
- 登录是**页内弹窗**:点右上角用户图标;首次使用弹窗里是「设置密码 + 确认」(至少 8 位);
- 登录后点「站点设置」进入 `/admin`:分组管理(树形表格,可建根/子分组、图标可空、排序权重、启停、删除)、内容(选分组以列表查看/编辑条目,每条可单独设格式,主分组含子分组)、侧边栏(侧栏卡片显隐 + 分组导航勾选)、首页卡片(本地服务只读表 + 左侧卡片列表 + 分组表格,可拖动排序),以及页头滚动信息、资料与系统设置;
- 登录后点「站点设置」进入 `/admin`,左侧导航 + 右侧内容:
- **分组管理**:树形表格,可建根 / 子分组、图标(字符或 iconfont 图标名)、排序权重、启停、删除;
- **数据管理**:选分组后列出条目(含子分组),每条可单独设格式,可新建 / 编辑 / 删除;
- **首页管理**(子项):**页头**(站点信息 / 操作工具 / 滚动信息三个页签)、**侧边栏**(三块侧栏卡片显隐)、**内容卡片**(左侧可拖拽排序的卡片列表 + 右侧分组表格:状态、预览条数、顺序、展示);
- **资料管理**与**系统管理**(改密码、下载整库备份);
- 分组的「状态」是**唯一可见性来源**:停用的分组在侧栏和卡片里都不再出现,层层往下生效;
- 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁;
- `/admin` 的「系统」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
- `/admin` 的「系统管理」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
- 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天);
- 读接口公开(本地工具可只读使用),写接口(设置、改密等)要求登录;
- 读接口公开(本地工具可只读使用),写接口(分组、条目、卡片、设置、改密)要求登录并校验同源 `Origin`;
- 明暗配色:站点默认值在「页头 → 配色」(跟随系统 / 浅色 / 深色),前台页头的月亮按钮写的是**本机偏好**(localStorage),不需要登录;
- 完整接口清单可以直接开站内页面 `/interface` 查看;
- 忘记密码:删除 `admin` 表里的那一行(或整个库)后重启,会回到「设置密码」;
- 方案与后续阶段见 [`docs/ADMIN-PLAN.md`](docs/ADMIN-PLAN.md)。
@@ -50,7 +74,8 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
| --- | --- |
| `/api/*` | JSON / SSE 接口 |
| `/`、`/index.html` | 内嵌前端的 `index.html`(`no-cache`) |
| `/c/**` | 前端路由地址(分类明细 / 条目详情),没有实体文件时同样返回 `index.html` |
| `/c/**`、`/g/**` | 前端路由地址(分组页 / 条目详情),没有实体文件时同样返回 `index.html` |
| `/admin`、`/login`、`/interface` | 固定页面地址,同样返回 `index.html` |
| `/assets/**` | 内嵌前端的静态资源(文件名带 hash,长缓存) |
| `/favicon.svg` | 内嵌前端根目录文件 |
| 其他路径 | `ServeDir` 托管 `--root` 目录,目录自动补 `index.html` |
@@ -66,8 +91,10 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
| 地址 | 页面 |
| --- | --- |
| `/` | 首页卡片(按后台勾选的分组组合) |
| `/g/<分组>` | 分组页(自身内容 + 子分组) |
| `/g/<分组>`、`/c/<分组>` | 分组页(自身内容 + 子分组) |
| `/g/<分组>/i/<条目>` | 条目详情 |
| `/interface` | 接口一览(站内页面,侧边栏「查看接口」进入) |
| `/admin` | 站点设置(左侧导航 + 右侧内容) |
地址里的每一段都是**业务键**(也就是接口 JSON 里的 `id`):库里是 `node.key` / `entry.key`,
本地项目则是目录名(访问地址仍然是 `route_path`,两者不再混用)。
@@ -78,12 +105,13 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
仍然落回条目所属的分组,而不是去猜一个返回目标;
- 「返回」按钮在应用内有上一页时交给浏览器(`history.back()`),
直接打开地址(刷新 / 收藏,应用内没有上一页)时退到上面的推导结果;
- 服务端只把 `/c/**` 回落到入口页(`src/server.rs` 的 `SPA_PREFIXES`),
托管目录里写错的地址仍然是 404 页面——不做「所有未命中都返回 200」的万能回落,
否则分不清是路由拼错还是文件真的不存在;
- 服务端只把 `/c/`、`/g/` 两个前缀和 `/admin`、`/login`、`/interface` 三个固定地址回落到入口页
(`src/server.rs` 的 `SPA_PREFIXES` / `SPA_PAGES`),托管目录里写错的地址仍然是 404 页面——
不做「所有未命中都返回 200」的万能回落,否则分不清是路由拼错还是文件真的不存在;
- 地址转换规则在 [`stash-web/src/data/view.ts`](stash-web/src/data/view.ts),
与 history 打交道的部分在 [`stash-web/src/useViewRoute.ts`](stash-web/src/useViewRoute.ts)。
新增一类页面时两处都要改:前端加前缀,服务端把它加进 `SPA_PREFIXES`。
新增一类页面时两处都要改:前端加前缀,服务端把它加进 `SPA_PREFIXES` 或 `SPA_PAGES`;
- 站内跳转统一走 `navigate()`(`AppNext.tsx`),不整页刷新;按住 Ctrl/Cmd 点卡片或链接仍可新标签打开。
## 目录结构
@@ -102,21 +130,35 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
│ ├── watcher.rs # 文件监听、防抖、快照广播
│ └── db/ # SQLite 内容库
│ ├── mod.rs # 打开、PRAGMA、种子导入时机
│ ├── schema.rs # 建表语句与种子导入
│ ├── schema.rs # 建表语句、版本迁移、种子导入
│ ├── catalog.rs # 查询结果组装成前端 Category[]
│ ├── settings.rs # 站点设置的 JSON 结构与校验
│ └── seed.json # 内嵌种子数据(编译进二进制)
├── stash-web/ # React + Vite + TypeScript 控制台
│ ├── package.json # 含 volta 版本固定
│ ├── 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 组件库(令牌 / 组件 / 展示站)
```
`templates/` 与 `assets/` 是上一版 Askama 控制台留下的文件,当前已无任何代码引用,
可以直接删除。
`templates/` 与 `assets/` 是上一版 Askama 控制台留下的文件,当前已无任何代码引用,
可以直接删除。
## 前端怎么被内嵌
1. `npm run build` 产出 `stash-web/dist`;
@@ -140,17 +182,22 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
### 表结构
三张表 + 一张展示配置表:
七张表:
- **分区与分组是同一张自引用树**——它们都是「装条目的容器」,只是层级不同;
- **侧栏直接由这棵树派生**(分区下挂分组),不再有独立的导航表;
- **首页分区展示什么由 `section_block` 决定**,卡片不再混排所有分组。
- **分组(`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 —— 分区与分组(一棵容器树)
| 字段 | 类型 | 说明 |
@@ -162,10 +209,12 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
| `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? | 仅分区:分区为空时的提示文案 |
| `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 | 同级顺序,越小越靠前 |
两条约束写在表上:
@@ -241,21 +290,32 @@ WHERE COALESCE(p.parent_key, p.key) = 'recommend';
主键 `(entry_key, sort)`。海报的 `tags` 与文章的 `meta` 都存这里——它们都是「有序字符串列表」,
前端只是在不同版式里渲染。
#### section_block —— 首页卡片的展示配置
#### card / card_group —— 首页卡片
| 字段 | 类型 | 说明 / 前端用途 |
````text
card 一张卡片 = 标题 + 顺序(内容由 card_group 勾选,卡片本身不存条目)
card_group 卡片包含哪些分组 + 每个分组的预览条数与先后
````
| `card` 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | INTEGER | 主键,库内唯一标识(自动分配) |
| `node_key` | TEXT | 要展示的节点编码,外键 → `node.key`;通常是分组,电影/文章填分区自己 |
| `title` | TEXT? | 块标题;为空时用节点 `title` |
| `preview_limit` | INTEGER? | 卡片显示几条;为空时用分类默认值 |
| `sort` | INTEGER | 卡片顺序 |
| `id` | INTEGER | 主键(AUTOINCREMENT) |
| `key` | TEXT | 业务键,唯一;`local-service` 是内置的本地服务卡片 |
| `title` | TEXT | 卡片标题 |
| `sort` | INTEGER | 卡片在首页的先后 |
**不存所属分区**:一张卡片属于哪个分区,由 `node_key` 推导——节点是分组就取 `parent_key`,
节点是分区就取它自己。所以这一列没必要重复存。
| `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` 的来源,新代码不再读写它。
### 表关系
@@ -264,10 +324,9 @@ node (kind=section) 1 ──── n node (kind=group)
│ │
└────────── n entry ───────┘
│
├──── n entry_tag (标签 / 元信息)
└──── n section_block (首页卡片,指向节点)
└──── n entry_tag (标签 / 元信息)
section_block ──── 1 node(分区由 node 自己推导,不单独存)
card 1 ──── n card_group ──── 1 node(勾选的分组 + 每个分组的预览条数)
```
| 关系 | 外键 | 级联 |
@@ -275,83 +334,82 @@ 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` | 删被展示的节点连带删该卡片 |
| 卡片 → 分组 | `card_group.card_id → card.id`、`card_group.group_key → node.key` | 删卡片或删分组都会清掉这一行 |
几条约束值得记住:
- **API 只暴露业务编码**:响应里的 `id` 都是 `key`,数字主键只在库内使用;
- `entry.node_key`:应用指向分组,海报与图文直接指向分区;
- **API 只暴露业务编码**:响应里的 `id` 都是 `key`,数字主键只在库内使用(卡片例外,用数字 id);
- `entry.node_key`:应用指向分组,海报与图文也指向分组(分组可以是根分组);
- 条目 `key` 是主键,全局唯一;
- **侧栏就是内容树**:分区展开是它的分组,名称取自 `node.title`,不再有别名或跨区编排;
- 首页卡片展示哪个分组由 `section_block.node_key` 决定,改一行即可换内容。
- **侧栏就是内容树**:分组按 `parent_key` 展开,名称取自 `node.title`,没有别名或跨区编排;
- **首页卡片 = 勾选的分组集合**:`card_group.sort` 决定分组在卡片里的先后,`preview_limit` 决定这个分组预览几条。
### 前端怎么用这些表
| 界面 | 用到的表 | 关键字段 |
| --- | --- | --- |
| 首页分区卡片 | `node`(section) + `section_block` + `entry` | 卡片标题用展示块标题(默认被展示节点的 `title`),色条用分区 `bar_color`;预览条目取该节点下的前 N 条 |
| 分类明细页 | `node`(section/group) + `entry` | `icon`(hero 图标)、分区 `title` 或分组 `title`、条目卡片字段 |
| 首页卡片 | `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` 的节点跳过;「本地服务」由前端排除 |
| 侧栏导航 | `node` | 走 `parent_key` 的分组树,`show_in_sidebar = 0` 与 `enabled = 0` 的节点跳过;「本地服务」由前端排除 |
| 本地服务 | 不在库里 | 由 `/api/projects` 扫描托管目录后在前端拼接 |
### 侧栏导航
侧栏不再是单独一层数据,**直接由内容树派生**:
侧栏不再是单独一层数据,**直接由分组树派生**:
```text
常用推荐(section,可折叠)
├── 常用站点(group → 打开该分组)
常用推荐(根分组,可折叠)
├── 常用站点(子分组 → 打开该分组)
├── UI 设计资源
├── 矢量图标
├── 免费壁纸
└── 开发工具
推荐电影(section,无分组 → 直接打开)
CES 现场报道(section,无分组 → 直接打开)
推荐电影(根分组,无子分组 → 直接打开)
CES 现场报道(根分组,无子分组 → 直接打开)
```
规则很简单:
- 分区有分组时渲染成可折叠节点,子项就是分组;
- 分区没有分组时(电影 / 文章)整行是链接;
- 有子分组时渲染成可折叠节点,子项就是子分组;
- 没有子分组时整行是链接;
- 名字一律用 `node.title`,没有别名,也没有跨分区的收纳层;
- `show_in_sidebar = 0` 的节点不出现(种子把「常用站点」设成了 0,因为它已经是首页卡片);
- 「本地服务」是前端拼出来的分类,侧栏按 id 直接排除。
- `show_in_sidebar = 0` 的节点不出现;`enabled = 0` 的节点连同整棵子树都不出现;
- 顺序取 `node.sort`(在「分组管理」里改「排序权重」);
- 「本地服务」是前端拼出来的分类,侧栏按 key 直接排除。
### 首页展示块
### 首页卡片
首页卡片展示什么由 `section_block` 决定,不再把分区下所有分组混排:
一张卡片 = 一个标题 + 一组勾选的分组,内容不混排:
```sql
-- 常用推荐卡片改成展示「UI 设计资源」分组,最多 6 条
UPDATE section_block
SET node_key = 'ui', preview_limit = 6
WHERE node_key = 'common';
-- 看看「常用推荐」这张卡片勾了哪些分组、各自几条
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;
```
每张卡片一行,`node_key` 指向要展示的节点;它属于哪个分区由节点自己决定。
点卡片标题或「···」都会打开这个节点;电影 / 文章这类没有分组的,`node_key` 填分区自己的编码。
**卡片标题跟着展示内容走**:配了展示块时用块标题(没配则用节点 `title`),
所以「常用推荐」这张卡片会显示成「常用站点」——它实际展示的就是这个分组。
需要单独起名时,写 `section_block.title` 即可。
需要调整时就改 `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`,首页展示改 `section_block`;
2. 内容改 `node`(分组树)与 `entry`(文章正文就是 `entry.body` 里的 Markdown),标签改 `entry_tag`,首页卡片改 `card` / `card_group`;
3. 提交修改后,页面会在 **5 秒内**自动刷新——服务端轮询 `PRAGMA data_version` 检测外部写入;
4. 也可以点侧栏的「重新扫描」或直接刷新浏览器。
新增条目时 `id` 不用填,`key` 必须写(全局唯一);`node_key` 填直接所在的分组编码
(没有分组就填分区编码),所属分区由它推导。
新增条目时 `id` 不用填,`key` 必须写(全局唯一);`node_key` 填直接所在的分组编码。
先 `SELECT key, kind, parent_key FROM node` 看一眼现有编码即可。
### 重置
开发阶段表结构还会调整,**没有实现迁移**。要重置内容:关掉服务后删掉 `data/stash.db`,
表结构有版本号(`PRAGMA user_version`,当前 22),启动时会**自动逐级迁移**旧库,
正常升级不用删库。只有在想回到出厂内容时才需要重置:关掉服务后删掉 `data/stash.db`,
下次启动会重新建表并灌入内嵌种子。
```bash
@@ -359,19 +417,12 @@ rm data/stash.db # Linux / macOS
Remove-Item data\stash.db # Windows PowerShell
```
库结构版本记在 `PRAGMA user_version` 里(当前为 `15`)。新版本二进制遇到旧库时会**直接拒绝启动**
并提示删库,而不是拿旧表执行新查询:
```text
[FATAL] 启动失败: 数据库错误: 内容库结构版本为 14,当前程序需要 15。
开发阶段不做自动迁移,请删除数据库后重启(种子会自动重新导入)。
```
`SCHEMA_VERSION` 定义在 [`src/db/schema.rs`](src/db/schema.rs),改动表结构时把它 +1。
`SCHEMA_VERSION` 定义在 [`src/db/schema.rs`](src/db/schema.rs),改动表结构时把它 +1 并补一段迁移。
## 前端开发
```bash
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
@@ -379,8 +430,9 @@ npm run build # 产物输出到 stash-web/dist
npm run lint # oxlint
```
开发时前后端分开跑:一个终端 `cargo run`,另一个终端 `npm run dev`。
控制台的用户资料目前是占位数据(见 `stash-web/src/profile.ts`),后端尚未提供鉴权接口。
开发时前后端分开跑:一个终端 `cargo run`,另一个终端(stash-web 目录下)`npm run dev`。
改了 `@stash/ui` 里的组件要重新 `npm run build`(库)才会被 stash-web 用到;
`npm run dev` 只会热更 stash-web 自己的代码。
## 命令行参数
@@ -443,41 +495,41 @@ data: {"projects":[{"name":"docs","route_path":"/docs/"}]}
### 内容目录
```http
GET /api/catalog
GET /api/catalog2
```
返回控制台需要的全部分类(结构见 [`types.ts`](stash-web/src/data/types.ts)),
数据来自 `data/stash.db`。一次全量下发,前端搜索在客户端完成:
返回分组树 + 首页卡片 + 站点设置(结构见 [`types.ts`](stash-web/src/data/types.ts)),
数据来自 `data/stash.db`,一次全量下发,前端搜索在客户端完成:
```json
{
"categories": [
"groups": [
{
"id": "recommend",
"kind": "apps",
"key": "pin",
"title": "常用推荐",
"barColor": "#3858f6",
"icon": "🔖",
"block": { "nodeKey": "common", "previewLimit": 4 },
"groups": [
{
"id": "common",
"name": "常用站点",
"items": [
{ "id": "github", "name": "GitHub", "desc": "代码托管与开源社区", "views": 429 }
]
}
]
"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` 扫描托管目录后在前端拼接。
「本地服务」卡片**不在**这个接口里,它由 `/api/projects` 扫描托管目录后在前端拼接。
侧栏不再单独下发,前端直接用 `groups` 派生。
每个分类上的 `block` 就是首页卡片的展示配置:`nodeKey` 指向要展示的节点(通常是分组),
`previewLimit` 是卡片里显示几条。侧栏不再单独下发,前端直接用 `categories` 里的
分区与分组渲染。
`GET /api/catalog` 是旧版结构(`categories` 数组),仅为兼容保留。
### 订阅内容库变化
@@ -486,14 +538,28 @@ GET /api/catalog/events
Accept: text/event-stream
```
当内容库被**其它连接**(例如用户用 DB Browser 改库)提交后推送一次,事件本身不带数据:
当内容库变化后推送一次(自己写入和其它连接写入都会触发),事件本身不带数据:
```text
event: catalog
data: {"changed":true}
```
客户端收到后重新 `GET /api/catalog`。检测基于 SQLite 的 `PRAGMA data_version`,每 5 秒轮询一次。
客户端收到后重新 `GET /api/catalog2`。检测基于 `PRAGMA data_version` 轮询 + 写接口主动广播。
### 其它接口
完整清单(方法 / 路径 / 权限)可以直接开站内页面 [`/interface`](http://127.0.0.1:8080/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` 与本站同源。
## 测试
@@ -512,15 +578,17 @@ $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 页面。
- 前端路由前缀是 `/c/`、`/g/`,固定页面是 `/admin`、`/login`、`/interface`;新增一类页面时,
`stash-web/src/data/view.ts` 与 `src/server.rs` 的 `SPA_PREFIXES` / `SPA_PAGES` 必须一起改,
否则刷新那条地址会拿到 404 页面。
- 前进 / 后退能回到对应页面,但不恢复滚动位置:切换视图统一滚回内容区顶部。
- 内嵌资源占用了 `/assets/` 前缀,托管目录下如果存在同名目录会被遮蔽。
- `/` 固定由控制台占用,上一版「根目录有 `index.html` 就直接当单站点展示」的行为已取消;
托管目录里的项目统一走 `/<目录名>/`。
- 项目扫描只检查一级子目录,不递归查找更深层项目。
- 文件事件经过约 150ms 防抖,新项目出现在控制台可能有短暂延迟。
- 内容库没有迁移机制:表结构变化时需要删掉 `data/stash.db` 重建(当前属于开发阶段)。
- 内容库被外部工具修改后,页面最长有 5 秒延迟才自动刷新。
- 内容库被其它工具修改后,页面最长有 5 秒延迟才自动刷新;自己的写操作会立即广播。
- 内容库按单实例使用设计:多个进程同时写同一个库会在 `busy_timeout`(5 秒)后报错。
- 没有身份认证,默认只建议绑定 `127.0.0.1`;使用 `0.0.0.0` 暴露到局域网前请确认目录内容适合被其他设备访问。
- 单管理员账号:只有一套密码,没有多用户 / 多角色;默认只建议绑定 `127.0.0.1`,
用 `0.0.0.0` 暴露到局域网前请确认目录内容适合被其它设备访问。
- 主题偏好按浏览器存(localStorage),换设备不会跟随;站点默认值在后台改。
+5 -5
View File
@@ -93,11 +93,11 @@ DELETE /api/items/:id
6. **CSP / 安全头**:内嵌页面与 iconfont 的 CSP 白名单。
### 数据与迁移
7. **迁移机制**:现在没有迁移,`user_version` 只是标记;加表必须写迁移 + 升级测试。
8. **默认值与校验**:设置 JSON 的 schema 校验、缺字段回退默认、坏 JSON 不能白屏。
9. **备份/恢复**:编辑前自动备份(`stash.db.bak`)、一键导出/导入。
10. **审计**:谁改了什么(单用户也需要排查)。
11. **乐观锁/冲突**:多标签页同时编辑。
7. ~~**迁移机制**:现在没有迁移,`user_version` 只是标记~~ → **已完成**:`SCHEMA_VERSION = 22`,启动时按版本逐级迁移(`src/db/schema.rs`),加表要连迁移一起写。
8. **默认值与校验**:设置 JSON 的 schema 校验、缺字段回退默认、坏 JSON 不能白屏。(已做:`src/db/settings.rs` 校验 + 前端默认值合并)
9. **备份/恢复**:编辑前自动备份(`stash.db.bak`)、一键导出/导入。(已做:`GET /api/backup` 导出快照)
10. **审计**:谁改了什么(单用户也需要排查)。(已做:`audit` 表)
11. **乐观锁/冲突**:多标签页同时编辑。(已做:`settings.version` 与 `entry.updated_at`)
12. **草稿/发布**:编辑后立即生效,还是保存草稿再发布?(内容库现在没有状态字段)
13. **撤销**:误删条目能否恢复(软删除 + 回收站)。
+25 -28
View File
@@ -1,32 +1,29 @@
# React + TypeScript + Vite
# stash-web
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Stash 的前端控制台:React 19 + Vite + TypeScript。组件与样式全部来自
[`@stash/ui`](../stash-ui)(`file:` 依赖,需要先在同级目录构建出它的 `dist`)。
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```bash
npm install
npm run dev # http://127.0.0.1:5173,/api 代理到后端 8080
npm run build # 产物到 dist,随后会被 cargo build 内嵌进二进制
npm run lint # oxlint
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
## 目录
| 路径 | 内容 |
| --- | --- |
| `src/AppNext.tsx` | 前台外壳:路由、卡片、侧栏、搜索、主题应用 |
| `src/components/admin/` | 后台(`AdminNext.tsx`:左侧导航 + 分组 / 数据 / 首页 / 资料 / 系统面板) |
| `src/components/api/` | `/interface` 接口一览页 |
| `src/components/header/`、`sidebar/` | 页头(品牌、滚动信息、操作图标)与侧栏卡片 |
| `src/components/content/` | 卡片、网格、详情视图 |
| `src/data/view.ts`、`src/useViewRoute.ts` | 地址 ↔ 视图的路由规则(与服务端 `SPA_PREFIXES` / `SPA_PAGES` 对应) |
| `src/theme.ts` | 明暗主题:本机偏好(localStorage)优先于站点默认 |
## 约定
- 页面里不写死颜色 / 字号 / 间距,一律用 `@stash/ui` 的 `--su-*` 令牌;
- 站内跳转走 `navigate()`,不要用 `<a href>` 触发整页刷新;需要「新标签打开」时保留 `href` 并放行修饰键点击;
- 组件库里没有的东西优先补进 `stash-ui`,而不是在这里再手写一遍(后台已经是 100% 由库组件组装)。