Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1effbea3dd | ||
|
|
259eb20411 | ||
|
|
b1fd82a0b6 | ||
|
|
6e773374bb | ||
|
|
4ae69b29fe | ||
|
|
a67ab7bfe0 | ||
|
|
883504222b | ||
|
|
27a28ab583 | ||
|
|
036551ee09 | ||
|
|
0375669a6e | ||
|
|
10b653e30f | ||
|
|
8271adc307 | ||
|
|
3f00a64b3b | ||
|
|
a7391d7b1a | ||
|
|
30baa96237 | ||
|
|
d0a79283ed | ||
|
|
54d2025a6e | ||
|
|
784a7c54ac | ||
|
|
923903b4b2 | ||
|
|
84fc148a39 | ||
|
|
5c170f8819 | ||
|
|
18e9543607 | ||
|
|
2f2ceb9ea0 | ||
|
|
8a782099f5 | ||
|
|
59fd61d832 |
+11
@@ -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 已从「只读本地导航站」升级为**可登录、可配置、可编辑内容的本地发布台**:
|
||||
|
||||
@@ -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
@@ -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. **撤销**:误删条目能否恢复(软删除 + 回收站)。
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ pub struct HeaderSettings {
|
||||
pub enabled: bool,
|
||||
/// 滚动一轮的秒数
|
||||
pub speed: u32,
|
||||
/// 信息条目之间的间距(px),默认 26 ≈ 两个中文字宽
|
||||
pub gap: u32,
|
||||
pub items: Vec<HeaderItem>,
|
||||
}
|
||||
|
||||
@@ -58,6 +60,7 @@ impl Default for HeaderSettings {
|
||||
Self {
|
||||
enabled: true,
|
||||
speed: 24,
|
||||
gap: 26,
|
||||
items: vec![
|
||||
HeaderItem {
|
||||
text: "共 {count} 个本地应用".into(),
|
||||
@@ -88,6 +91,7 @@ impl Default for SidebarSettings {
|
||||
Self {
|
||||
show_profile: true,
|
||||
show_service: true,
|
||||
show_nav: true,
|
||||
hidden: Vec::new(),
|
||||
order: Vec::new(),
|
||||
hidden_groups: Vec::new(),
|
||||
@@ -108,6 +112,9 @@ pub struct SidebarSettings {
|
||||
/// 是否显示「本地服务」面板
|
||||
#[serde(default = "default_true")]
|
||||
pub show_service: bool,
|
||||
/// 是否显示「分组导航」卡片(首页 / 分组树)
|
||||
#[serde(default = "default_true")]
|
||||
pub show_nav: bool,
|
||||
/// 隐藏的分类 id
|
||||
pub hidden: Vec<String>,
|
||||
/// 分类显示顺序(id);未列出的排在后面,保持原有顺序
|
||||
@@ -160,6 +167,88 @@ impl Default for ProfileSettings {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct BrandSettings {
|
||||
/// 品牌文字(页头左侧,可留空只显示 logo)
|
||||
pub name: String,
|
||||
/// 没有 logo 图片时,圆形标记里显示的首字
|
||||
pub mark: String,
|
||||
/// logo 图片地址;空字符串表示用首字
|
||||
pub logo: String,
|
||||
}
|
||||
|
||||
impl Default for BrandSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: "STASH".into(),
|
||||
mark: "S".into(),
|
||||
logo: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct ActionSettings {
|
||||
/// 明暗切换图标
|
||||
#[serde(default = "default_true")]
|
||||
pub theme: bool,
|
||||
/// 刷新图标(重扫本地项目并重拉内容库)
|
||||
pub refresh: bool,
|
||||
/// 站点设置图标(仅登录后出现)
|
||||
pub settings: bool,
|
||||
/// 登录 / 退出登录
|
||||
pub session: bool,
|
||||
}
|
||||
|
||||
impl Default for ActionSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme: true,
|
||||
refresh: true,
|
||||
settings: true,
|
||||
session: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 配色:浅色 / 深色 / 跟随系统。
|
||||
/// 落到 `<html data-scheme>` 上,深色由组件库的令牌块接管,页面不用改组件。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ThemeSetting {
|
||||
Light,
|
||||
Dark,
|
||||
#[default]
|
||||
Auto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct SiteMetaSettings {
|
||||
/// 浏览器标签标题
|
||||
pub title: String,
|
||||
/// favicon 地址;空字符串表示用内置图标
|
||||
pub favicon: String,
|
||||
/// 配色
|
||||
pub theme: ThemeSetting,
|
||||
pub brand: BrandSettings,
|
||||
pub actions: ActionSettings,
|
||||
}
|
||||
|
||||
impl Default for SiteMetaSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
title: "Stash · 本地应用工作区".into(),
|
||||
favicon: String::new(),
|
||||
theme: ThemeSetting::Auto,
|
||||
brand: BrandSettings::default(),
|
||||
actions: ActionSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
@@ -167,6 +256,7 @@ pub struct Settings {
|
||||
pub sidebar: SidebarSettings,
|
||||
pub sections: SectionsSettings,
|
||||
pub profile: ProfileSettings,
|
||||
pub site: SiteMetaSettings,
|
||||
}
|
||||
|
||||
/// 读到/保存后的完整状态:设置本体 + 版本号(前端提交时要带回来)。
|
||||
@@ -191,6 +281,9 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> {
|
||||
"滚动速度需在 {MIN_SPEED}–{MAX_SPEED} 秒之间"
|
||||
)));
|
||||
}
|
||||
if settings.header.gap > 120 {
|
||||
return Err(StashError::InvalidDirectory("信息间距最多 120".into()));
|
||||
}
|
||||
for item in &settings.header.items {
|
||||
let text = item.text.trim();
|
||||
if text.is_empty() {
|
||||
@@ -244,6 +337,21 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> {
|
||||
if settings.profile.location.chars().count() > 40 {
|
||||
return Err(StashError::InvalidDirectory("位置最多 40 个字".into()));
|
||||
}
|
||||
if settings.site.title.chars().count() > 120 {
|
||||
return Err(StashError::InvalidDirectory("页面标题最多 120 个字".into()));
|
||||
}
|
||||
if settings.site.favicon.chars().count() > 500 {
|
||||
return Err(StashError::InvalidDirectory("favicon 地址最多 500 个字符".into()));
|
||||
}
|
||||
if settings.site.brand.name.chars().count() > 20 {
|
||||
return Err(StashError::InvalidDirectory("品牌文字最多 20 个字".into()));
|
||||
}
|
||||
if settings.site.brand.mark.chars().count() > 4 {
|
||||
return Err(StashError::InvalidDirectory("logo 标记最多 4 个字".into()));
|
||||
}
|
||||
if settings.site.brand.logo.chars().count() > 500 {
|
||||
return Err(StashError::InvalidDirectory("logo 地址最多 500 个字符".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+59
-17
@@ -1,7 +1,8 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{OriginalUri, Path, State},
|
||||
extract::{OriginalUri, Path, Request, State},
|
||||
http::{HeaderMap, HeaderValue, Method, StatusCode, header},
|
||||
middleware::{self, Next},
|
||||
response::sse::{Event, KeepAlive, Sse},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::{get, patch, post, put},
|
||||
@@ -36,6 +37,9 @@ pub struct AppState {
|
||||
pub project_updates: broadcast::Sender<Vec<AppProject>>,
|
||||
/// 内容库:分类 / 分组 / 条目的唯一数据源
|
||||
pub db: Arc<Db>,
|
||||
/// 内容库写入后的广播:SSE 用它对所有打开的页面推「该重拉」。
|
||||
/// 不能只靠 `PRAGMA data_version`——服务自己的连接写完不会让它变化。
|
||||
pub catalog_updates: broadcast::Sender<()>,
|
||||
/// 登录失败计数(单用户本地场景,一个进程一份就够)
|
||||
pub login_guard: Arc<std::sync::Mutex<LoginGuard>>,
|
||||
}
|
||||
@@ -160,7 +164,7 @@ fn not_found_page(path: &str) -> Response {
|
||||
const SPA_PREFIXES: &[&str] = &["/c/", "/g/"];
|
||||
|
||||
/// 精确匹配的前端页面(没有实体文件,返回入口页)
|
||||
const SPA_PAGES: &[&str] = &["/login", "/admin"];
|
||||
const SPA_PAGES: &[&str] = &["/login", "/admin", "/interface"];
|
||||
|
||||
/// 是否为前端路由路径(没有实体文件,应该返回入口页)。
|
||||
fn is_spa_path(path: &str) -> bool {
|
||||
@@ -234,30 +238,29 @@ async fn catalog_api(State(state): State<AppState>) -> Response {
|
||||
/// 而 data_version 恰好在「其它连接提交了事务」时才变化,语义正好对上。
|
||||
async fn catalog_events(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let db = state.db.clone();
|
||||
let mut updates = state.catalog_updates.subscribe();
|
||||
|
||||
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 轮询兜底(外部工具直接改库时用)。
|
||||
let changed = tokio::select! {
|
||||
_ = ticker.tick() => match db.data_version() {
|
||||
Ok(current) if current != last => {
|
||||
last = current;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
received = updates.recv() => received.is_ok(),
|
||||
};
|
||||
|
||||
// 只在其它连接提交之后才推送。
|
||||
// 当前服务对内容库是只读的;将来若加入写接口,必须在那里主动广播,
|
||||
// 因为 data_version 不会因本连接自己的提交而变化。
|
||||
tracing::debug!(last, current, "内容库版本检查");
|
||||
if current == last {
|
||||
if !changed {
|
||||
continue;
|
||||
}
|
||||
last = current;
|
||||
|
||||
// 只告诉前端「变了」,具体内容由它重新 GET /api/catalog
|
||||
yield Ok::<Event, Infallible>(
|
||||
@@ -273,6 +276,36 @@ async fn catalog_events(State(state): State<AppState>) -> impl IntoResponse {
|
||||
)
|
||||
}
|
||||
|
||||
/// 任何写请求成功后,通知所有打开的页面「内容库变了」。
|
||||
///
|
||||
/// 放在中间件里而不是逐个 handler 调:以后新增写接口不用再记得挂号。
|
||||
/// 登录相关接口不涉及内容库,跳过以免无谓刷新。
|
||||
async fn notify_catalog_writes(
|
||||
State(state): State<AppState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let method = request.method().clone();
|
||||
let path = request.uri().path().to_owned();
|
||||
|
||||
let response = next.run(request).await;
|
||||
|
||||
let mutating = matches!(
|
||||
method,
|
||||
Method::POST | Method::PATCH | Method::PUT | Method::DELETE
|
||||
);
|
||||
if mutating
|
||||
&& path.starts_with("/api/")
|
||||
&& !path.starts_with("/api/auth/")
|
||||
&& response.status().is_success()
|
||||
{
|
||||
// 没人订阅(没有开着的页面)时 send 会失败,忽略即可
|
||||
let _ = state.catalog_updates.send(());
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// SSE:先发送当前快照,再持续推送文件监听产生的项目列表变化。
|
||||
async fn project_events(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let root = state.config.root.clone();
|
||||
@@ -1146,7 +1179,12 @@ fn build_router(state: AppState) -> Router {
|
||||
.route("/favicon.svg", get(web_root_file))
|
||||
.route("/assets/{*path}", get(web_asset))
|
||||
.fallback_service(serve_dir)
|
||||
.with_state(state)
|
||||
.with_state(state.clone())
|
||||
// 写接口成功后广播「内容库变了」,让所有打开的页面自己重拉
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state,
|
||||
notify_catalog_writes,
|
||||
))
|
||||
.layer(TraceLayer::new_for_http());
|
||||
|
||||
if enable_cors {
|
||||
@@ -1170,6 +1208,7 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
|
||||
|
||||
let (project_updates, _) = broadcast::channel(32);
|
||||
watcher::start(config.root.clone(), project_updates.clone())?;
|
||||
let (catalog_updates, _) = broadcast::channel(16);
|
||||
|
||||
// 内容库在启动时打开:建库 / 建表 / 空库时灌种子都在这里完成
|
||||
let db = Arc::new(Db::open(&config)?);
|
||||
@@ -1178,6 +1217,7 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
|
||||
config: Arc::new(config.clone()),
|
||||
project_updates,
|
||||
db,
|
||||
catalog_updates,
|
||||
login_guard: Arc::new(std::sync::Mutex::new(LoginGuard::default())),
|
||||
};
|
||||
|
||||
@@ -1240,11 +1280,13 @@ mod tests {
|
||||
config.enable_cors = false;
|
||||
|
||||
let (project_updates, _) = broadcast::channel(4);
|
||||
let (catalog_updates, _) = broadcast::channel(4);
|
||||
|
||||
build_router(AppState {
|
||||
config: Arc::new(config),
|
||||
project_updates,
|
||||
db: Arc::new(Db::in_memory()),
|
||||
catalog_updates,
|
||||
login_guard: Arc::new(std::sync::Mutex::new(LoginGuard::default())),
|
||||
})
|
||||
}
|
||||
|
||||
+25
-28
@@ -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% 由库组件组装)。
|
||||
|
||||
+337
-428
File diff suppressed because it is too large
Load Diff
@@ -1,436 +0,0 @@
|
||||
import { Empty, Layout, Notice, Toaster } from '@stash/ui'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BackToTop } from './components/common/BackToTop'
|
||||
import { ArticleDetailView } from './components/content/ArticleDetailView'
|
||||
import { DetailView } from './components/content/DetailView'
|
||||
import { HomeView } from './components/content/HomeView'
|
||||
import { ItemDetailView } from './components/content/ItemDetailView'
|
||||
import { SiteHeader } from './components/header/SiteHeader'
|
||||
import { SearchBar } from './components/search/SearchBar'
|
||||
import { Sidebar } from './components/sidebar/Sidebar'
|
||||
import { withLocalCategory } from './data/local'
|
||||
import {
|
||||
appDetailModel,
|
||||
posterDetailModel,
|
||||
posterToRelatedItem,
|
||||
toRelatedItem,
|
||||
} from './data/detail'
|
||||
import { filterCategories } from './data/filter'
|
||||
import type { NavTarget } from './data/nav'
|
||||
import {
|
||||
type Category,
|
||||
findAppItem,
|
||||
findArticleItem,
|
||||
findPosterItem,
|
||||
relatedAppItems,
|
||||
relatedPosterItems,
|
||||
} from './data/types'
|
||||
import { useProjects } from './useProjects'
|
||||
import { useCatalog } from './useCatalog'
|
||||
import { useViewRoute } from './useViewRoute'
|
||||
import { fetchSession, logout as logoutRequest, type SessionState } from './auth'
|
||||
import {
|
||||
applySidebarGroups,
|
||||
applySidebarSettings,
|
||||
defaultSettings,
|
||||
fetchSettings,
|
||||
type SettingsDocument,
|
||||
} from './settings'
|
||||
import { LoginDialog } from './components/auth/LoginDialog'
|
||||
import { ItemEditor } from './components/content/ItemEditor'
|
||||
import { parseViewPath, viewPath, type View } from './data/view'
|
||||
import './App.css'
|
||||
|
||||
export default function App() {
|
||||
const { status, projects, error, live, refreshing, refresh } = useProjects()
|
||||
// 内容目录来自后端 SQLite 内容库;本地服务分类仍由托管项目动态生成
|
||||
const catalog = useCatalog()
|
||||
const [keyword, setKeyword] = useState('')
|
||||
// 当前视图由地址栏决定:刷新 / 收藏 / 前进后退都回到同一页(见 useViewRoute.ts)
|
||||
const { view, go } = useViewRoute()
|
||||
const [session, setSession] = useState<SessionState | null>(null)
|
||||
const [settings, setSettings] = useState<SettingsDocument>({
|
||||
version: 0,
|
||||
settings: defaultSettings(),
|
||||
})
|
||||
const [loginOpen, setLoginOpen] = useState(false)
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// 登录态:只用来切换页头按钮与后续的编辑入口
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
fetchSession().then((next) => {
|
||||
if (alive) setSession(next)
|
||||
})
|
||||
fetchSettings().then((next) => {
|
||||
if (alive) setSettings(next)
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const categories = useMemo(
|
||||
() => withLocalCategory(projects, catalog.categories),
|
||||
[projects, catalog.categories],
|
||||
)
|
||||
const visibleCategories = useMemo(
|
||||
() => filterCategories(categories, keyword),
|
||||
[categories, keyword],
|
||||
)
|
||||
|
||||
// 侧栏不显示「本地服务」,也不显示被标记 show_in_sidebar = 0 的分区;
|
||||
// 再按 /admin 里的顺序、显隐与分组设置重排一遍
|
||||
const sidebarCategories = useMemo(
|
||||
() =>
|
||||
applySidebarGroups(
|
||||
applySidebarSettings(
|
||||
categories.filter(
|
||||
(category) => category.id !== 'local' && category.showInSidebar !== false,
|
||||
),
|
||||
settings.settings.sidebar,
|
||||
),
|
||||
settings.settings.sidebar,
|
||||
),
|
||||
[categories, settings.settings.sidebar],
|
||||
)
|
||||
|
||||
const detailView = view.type === 'detail' ? view : undefined
|
||||
const itemView = view.type === 'item' ? view : undefined
|
||||
|
||||
const detailCategory =
|
||||
detailView !== undefined
|
||||
? categories.find((category) => category.id === detailView.categoryId)
|
||||
: undefined
|
||||
|
||||
const itemCategory =
|
||||
itemView !== undefined
|
||||
? categories.find((category) => category.id === itemView.categoryId)
|
||||
: undefined
|
||||
|
||||
// 应用与电影共用一个详情模型,这里按分类类型做映射
|
||||
const detailModel = useMemo(() => {
|
||||
if (itemView === undefined || itemCategory === undefined) return undefined
|
||||
|
||||
if (itemCategory.kind === 'apps') {
|
||||
const item = findAppItem(itemCategory, itemView.itemId)
|
||||
return item !== undefined ? appDetailModel(item) : undefined
|
||||
}
|
||||
|
||||
if (itemCategory.kind === 'posters') {
|
||||
const poster = findPosterItem(itemCategory, itemView.itemId)
|
||||
return poster !== undefined ? posterDetailModel(poster) : undefined
|
||||
}
|
||||
|
||||
return undefined
|
||||
}, [itemView, itemCategory])
|
||||
|
||||
const relatedItems = useMemo(() => {
|
||||
if (itemView === undefined || itemCategory === undefined) return []
|
||||
|
||||
if (itemCategory.kind === 'apps') {
|
||||
return relatedAppItems(itemCategory, itemView.itemId, 4).map(toRelatedItem)
|
||||
}
|
||||
|
||||
if (itemCategory.kind === 'posters') {
|
||||
return relatedPosterItems(itemCategory, itemView.itemId, 4).map(posterToRelatedItem)
|
||||
}
|
||||
|
||||
return []
|
||||
}, [itemView, itemCategory])
|
||||
|
||||
// 切换视图时把内容区(内部滚动)和窗口都回到顶部
|
||||
useEffect(() => {
|
||||
const scroller = document.getElementById('content-scroll')
|
||||
scroller?.scrollTo({ top: 0 })
|
||||
window.scrollTo({ top: 0 })
|
||||
}, [view])
|
||||
|
||||
/**
|
||||
* 滚动状态同时驱动「顶部渐隐」和「回到顶部」。
|
||||
* 桌面端滚的是内容容器(scroll 事件不冒泡),所以用捕获阶段监听。
|
||||
*/
|
||||
useEffect(() => {
|
||||
const sync = () => {
|
||||
const inner = document.getElementById('content-scroll')?.scrollTop ?? 0
|
||||
const next = Math.max(inner, window.scrollY) > 4
|
||||
setScrolled((current) => (current === next ? current : next))
|
||||
}
|
||||
|
||||
sync()
|
||||
window.addEventListener('scroll', sync, { passive: true })
|
||||
document.addEventListener('scroll', sync, { passive: true, capture: true })
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', sync)
|
||||
document.removeEventListener('scroll', sync, { capture: true })
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 「打开这个分区」应该落到哪个视图。
|
||||
*
|
||||
* 应用类分区配了首页展示块时,直接落到那个分组;其余情况打开整个分区。
|
||||
*/
|
||||
const detailViewFor = (categoryId: string): View => {
|
||||
const category = categories.find((item) => item.id === categoryId)
|
||||
|
||||
if (category?.block !== undefined) {
|
||||
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
|
||||
if (group !== undefined) {
|
||||
return { type: 'detail', categoryId, groupId: group.id }
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'detail', categoryId }
|
||||
}
|
||||
|
||||
const openDetail = (categoryId: string) => go(detailViewFor(categoryId))
|
||||
|
||||
/** 条目所在的分组:应用 / 海报 / 图文都可能有分组 */
|
||||
const groupOfItem = (category: Category, itemId: string): string | undefined => {
|
||||
return category.groups.find((group) => group.items.some((item) => item.id === itemId))?.id
|
||||
}
|
||||
|
||||
const openItem = (categoryId: string, itemId: string) => {
|
||||
const category = categories.find((item) => item.id === categoryId)
|
||||
|
||||
go({
|
||||
type: 'item',
|
||||
categoryId,
|
||||
itemId,
|
||||
// 地址里带上条目自己的分组,刷新后「返回」才有确定的落点
|
||||
groupId: category === undefined ? undefined : groupOfItem(category, itemId),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧栏导航:带分组时直接打开那个分组;
|
||||
* 点分区本身时,交给 openDetail 按展示块落到对应节点。
|
||||
*/
|
||||
const navigate = (target: NavTarget) => {
|
||||
if (target.groupId === undefined) {
|
||||
openDetail(target.categoryId)
|
||||
return
|
||||
}
|
||||
|
||||
go({ type: 'detail', categoryId: target.categoryId, groupId: target.groupId })
|
||||
}
|
||||
|
||||
const goHome = () => go({ type: 'home' })
|
||||
|
||||
/** 面包屑点击:把 href 解析回视图再跳转(保持 SPA 不刷新) */
|
||||
const navigateHref = (href: string) => go(parseViewPath(href))
|
||||
|
||||
const searching = keyword.trim() !== ''
|
||||
|
||||
const viewCategoryId = detailView?.categoryId ?? itemView?.categoryId
|
||||
|
||||
/**
|
||||
* 首屏数据还没回来时,地址指向的分类 / 条目只是「暂时查不到」,
|
||||
* 不能直接报「不存在」——刷新详情页时最容易被这个误导。
|
||||
*/
|
||||
const dataPending =
|
||||
viewCategoryId !== undefined &&
|
||||
(catalog.status === 'loading' || (viewCategoryId === 'local' && status === 'loading'))
|
||||
|
||||
/**
|
||||
* 条目详情按分类类型分流:
|
||||
* 图文走长文版式,应用 / 电影走通用的条目版式。
|
||||
*/
|
||||
function renderItemView() {
|
||||
if (itemView === undefined) return null
|
||||
if (itemCategory === undefined) {
|
||||
return <Empty text={dataPending ? '正在读取内容库…' : '分类不存在'} />
|
||||
}
|
||||
|
||||
if (itemCategory.kind === 'articles') {
|
||||
const article = findArticleItem(itemCategory, itemView.itemId)
|
||||
if (article === undefined) {
|
||||
return <Empty text={dataPending ? '正在读取内容库…' : '条目不存在'} />
|
||||
}
|
||||
|
||||
return (
|
||||
<ArticleDetailView
|
||||
article={article}
|
||||
categoryId={itemCategory.id}
|
||||
categoryTitle={itemCategory.title}
|
||||
onNavigate={navigateHref}
|
||||
onEdit={() => setEditingKey(itemView.itemId)}
|
||||
canEdit={session?.authenticated === true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (detailModel === undefined) {
|
||||
return <Empty text={dataPending ? '正在读取内容库…' : '条目不存在'} />
|
||||
}
|
||||
|
||||
return (
|
||||
<ItemDetailView
|
||||
itemKey={itemView.itemId}
|
||||
crumbs={[
|
||||
{ label: '首页', href: '/' },
|
||||
{ label: itemCategory.title, href: viewPath({ type: 'detail', categoryId: itemCategory.id }) },
|
||||
{ label: detailModel.title },
|
||||
]}
|
||||
model={detailModel}
|
||||
related={relatedItems}
|
||||
onNavigate={navigateHref}
|
||||
onOpenItem={(itemId) => openItem(itemCategory.id, itemId)}
|
||||
onEdit={() => setEditingKey(itemView.itemId)}
|
||||
canEdit={session?.authenticated === true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
void logoutRequest().then(() => {
|
||||
setSession({ authenticated: false, has_admin: true })
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster />
|
||||
|
||||
{/* 登录弹窗:成功后直接把会话状态写回,不刷新页面 */}
|
||||
<LoginDialog
|
||||
open={loginOpen}
|
||||
hasAdmin={session?.has_admin ?? false}
|
||||
onClose={() => setLoginOpen(false)}
|
||||
onSuccess={(next) => {
|
||||
setSession(next)
|
||||
setLoginOpen(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
<ItemEditor
|
||||
open={editingKey !== null}
|
||||
itemKey={editingKey}
|
||||
onClose={() => setEditingKey(null)}
|
||||
onSaved={() => catalog.reload()}
|
||||
/>
|
||||
|
||||
{/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}
|
||||
<div className="site-background" aria-hidden="true" />
|
||||
|
||||
<Layout
|
||||
contained
|
||||
maxWidth={1380}
|
||||
gutter={20}
|
||||
siderWidth={240}
|
||||
gap={20}
|
||||
headerHeight={60}
|
||||
headerVariant="solid"
|
||||
siderBordered={false}
|
||||
contentPadding="none"
|
||||
contentOffset={88}
|
||||
height="screen"
|
||||
scrollable
|
||||
hideScrollbar
|
||||
contentId="content-scroll"
|
||||
className="app"
|
||||
header={
|
||||
<SiteHeader
|
||||
projectCount={projects.length}
|
||||
sampleProject={projects[0]?.name}
|
||||
live={live}
|
||||
headerItems={settings.settings.header.items}
|
||||
headerEnabled={settings.settings.header.enabled}
|
||||
headerSpeed={settings.settings.header.speed}
|
||||
authenticated={session?.authenticated === true}
|
||||
username={session?.username ?? undefined}
|
||||
onFocusSearch={() => searchRef.current?.focus()}
|
||||
onLogin={() => setLoginOpen(true)}
|
||||
onLogout={handleLogout}
|
||||
onSettings={() => window.location.assign('/admin')}
|
||||
/>
|
||||
}
|
||||
sider={
|
||||
<Sidebar
|
||||
projects={projects}
|
||||
categories={sidebarCategories}
|
||||
profile={settings.settings.profile}
|
||||
live={live}
|
||||
refreshing={refreshing}
|
||||
onRefresh={refresh}
|
||||
activeCategoryId={viewCategoryId}
|
||||
activeGroupId={detailView?.groupId}
|
||||
homeActive={view.type === 'home'}
|
||||
onNavigate={navigate}
|
||||
onHome={goHome}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="content">
|
||||
{/* 滚动后淡入的顶部遮罩,避免内容在容器顶边被硬切 */}
|
||||
<div className={`content-veil${scrolled ? ' is-visible' : ''}`} aria-hidden="true" />
|
||||
|
||||
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
|
||||
|
||||
<h1 className="sr-only">Stash 本地应用工作区</h1>
|
||||
|
||||
{status === 'error' && (
|
||||
<Notice
|
||||
tone="error"
|
||||
title="连不上 Stash 服务"
|
||||
action={{ label: '重试', onClick: refresh }}
|
||||
>
|
||||
{error}
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{/* 内容库读取失败通常是用户改坏了 data 列里的 JSON,错误信息会带条目 id */}
|
||||
{catalog.status === 'error' && (
|
||||
<Notice
|
||||
tone="error"
|
||||
title="内容库读取失败"
|
||||
action={{ label: '重试', onClick: catalog.reload }}
|
||||
>
|
||||
{catalog.error}
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{detailView === undefined && itemView === undefined && (
|
||||
<>
|
||||
{catalog.status === 'loading' && <Empty text="正在读取内容库…" />}
|
||||
|
||||
<HomeView
|
||||
categories={visibleCategories}
|
||||
sections={settings.settings.sections}
|
||||
onOpenDetail={openDetail}
|
||||
onOpenItem={openItem}
|
||||
/>
|
||||
|
||||
{searching && visibleCategories.length === 0 && (
|
||||
<Notice title={`没有匹配「${keyword.trim()}」的内容`}>
|
||||
换个关键字试试,或清空搜索查看全部。
|
||||
</Notice>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{detailView !== undefined && detailCategory !== undefined && (
|
||||
<DetailView
|
||||
category={detailCategory}
|
||||
groupId={detailView.groupId}
|
||||
onNavigate={navigateHref}
|
||||
onOpenItem={(itemId) => openItem(detailCategory.id, itemId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailView !== undefined && detailCategory === undefined && (
|
||||
<Empty text={dataPending ? '正在读取内容库…' : '分类不存在'} />
|
||||
)}
|
||||
|
||||
{itemView !== undefined && renderItemView()}
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<BackToTop visible={scrolled} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
+171
-105
@@ -3,6 +3,7 @@ import {
|
||||
Layout,
|
||||
Menu,
|
||||
Notice,
|
||||
SearchField,
|
||||
Space,
|
||||
Toaster,
|
||||
type MenuItem,
|
||||
@@ -17,7 +18,8 @@ import { ItemEditor } from './components/content/ItemEditor'
|
||||
import { PosterGrid } from './components/content/PosterGrid'
|
||||
import { SectionCard } from './components/content/SectionCard'
|
||||
import { SiteHeader } from './components/header/SiteHeader'
|
||||
import { SearchBar } from './components/search/SearchBar'
|
||||
import { ApiDocs } from './components/api/ApiDocs'
|
||||
import { GroupMark } from './components/common/GroupMark'
|
||||
import { LoginDialog } from './components/auth/LoginDialog'
|
||||
import { ProfileCard } from './components/sidebar/ProfileCard'
|
||||
import { ServicePanel } from './components/sidebar/ServicePanel'
|
||||
@@ -28,6 +30,12 @@ import {
|
||||
type SettingsDocument,
|
||||
type SiteSettings,
|
||||
} from './settings'
|
||||
import {
|
||||
isDarkScheme,
|
||||
readThemeOverride,
|
||||
writeThemeOverride,
|
||||
type ThemeChoice,
|
||||
} from './theme'
|
||||
import {
|
||||
appDetailModel,
|
||||
posterDetailModel,
|
||||
@@ -52,9 +60,11 @@ type Route =
|
||||
| { kind: 'home' }
|
||||
| { kind: 'group'; key: string }
|
||||
| { kind: 'item'; key: string; itemId: string }
|
||||
| { kind: 'api' }
|
||||
|
||||
function parseRoute(pathname: string): Route {
|
||||
const segments = pathname.split('/').filter(Boolean)
|
||||
if (segments[0] === 'interface') return { kind: 'api' }
|
||||
if (
|
||||
(segments[0] === 'g' || segments[0] === 'c') &&
|
||||
segments[1] !== undefined
|
||||
@@ -132,14 +142,24 @@ function findEntry(
|
||||
}
|
||||
|
||||
function groupEntryCount(group: GroupNode): number {
|
||||
return group.entries.length + group.children.reduce((total, child) => total + groupEntryCount(child), 0)
|
||||
const children = group.children.filter((child) => child.enabled !== false)
|
||||
return group.entries.length + children.reduce((total, child) => total + groupEntryCount(child), 0)
|
||||
}
|
||||
|
||||
/** 分组自身与所有祖先都启用时才可见:停用父级会连带停用整棵子树 */
|
||||
function isGroupVisible(groups: GroupNode[], key: string): boolean {
|
||||
const path = findPath(groups, key)
|
||||
if (path === undefined) return false
|
||||
return path.every((node) => node.enabled !== false)
|
||||
}
|
||||
|
||||
/** 分组子树里的全部条目(格式沿根分组继承,所以可以放在同一个网格里) */
|
||||
function collectEntries(group: GroupNode): Record<string, unknown>[] {
|
||||
return [
|
||||
...(group.entries as unknown as Record<string, unknown>[]),
|
||||
...group.children.flatMap((child) => collectEntries(child)),
|
||||
...group.children
|
||||
.filter((child) => child.enabled !== false)
|
||||
.flatMap((child) => collectEntries(child)),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -161,8 +181,6 @@ function matches(entry: Record<string, unknown>, keyword: string): boolean {
|
||||
return text.includes(keyword)
|
||||
}
|
||||
|
||||
const TOP_OFFSET = 104
|
||||
|
||||
export default function AppNext() {
|
||||
const { status, projects, error, live, refreshing, refresh } = useProjects()
|
||||
const { route, navigate } = useRoute()
|
||||
@@ -189,7 +207,14 @@ export default function AppNext() {
|
||||
void fetchSettings().then(setSettings)
|
||||
|
||||
const source = new EventSource('/api/catalog/events')
|
||||
source.onmessage = () => load()
|
||||
// 服务端推的是具名事件 `catalog`(不是默认的 message),两种都接;
|
||||
// 收到后连站点设置一起重拉,改完设置不用手动刷新页面。
|
||||
const onCatalogChange = () => {
|
||||
load()
|
||||
void fetchSettings().then(setSettings)
|
||||
}
|
||||
source.onmessage = onCatalogChange
|
||||
source.addEventListener('catalog', onCatalogChange)
|
||||
return () => source.close()
|
||||
}, [])
|
||||
|
||||
@@ -205,77 +230,51 @@ export default function AppNext() {
|
||||
return
|
||||
}
|
||||
|
||||
// 左右两栏放进同一个滚动容器:滚动区域从搜索框下方开始,两栏位移完全一致
|
||||
const sider = body.querySelector('aside')
|
||||
// 左右两栏放进同一个滚动容器:滚动区域从搜索条下方开始,两栏位移完全一致。
|
||||
// 左栏的吸顶交给 CSS position: sticky(App.css 里 #page-scroll > aside):
|
||||
// 之前用 scroll 事件改 transform,主线程永远落后平滑滚动一帧,看起来就在抖。
|
||||
// 注意别再给 aside 设内联 overflow,否则会盖掉 CSS 里的 overflow-y: auto,
|
||||
// 侧栏比视口高时就没法在栏内滚动了。
|
||||
body.id = 'page-scroll'
|
||||
body.style.overflowY = 'auto'
|
||||
body.style.overflowX = 'hidden'
|
||||
body.style.marginTop = `${TOP_OFFSET}px`
|
||||
body.style.height = `calc(100% - ${TOP_OFFSET}px)`
|
||||
content.style.overflow = 'visible'
|
||||
content.style.marginTop = '0'
|
||||
main.style.height = 'auto'
|
||||
if (sider instanceof HTMLElement) {
|
||||
sider.style.overflow = 'visible'
|
||||
sider.style.height = 'auto'
|
||||
sider.style.alignSelf = 'flex-start'
|
||||
}
|
||||
|
||||
const onScroll = () => setScrolled(body.scrollTop > 4)
|
||||
|
||||
// 顶部软虚化层:覆盖搜索区域与内容顶部,边缘用 mask 渐隐,避免硬分割线
|
||||
const blur = document.createElement('div')
|
||||
blur.className = 'top-blur'
|
||||
blur.setAttribute('aria-hidden', 'true')
|
||||
const appRoot = document.querySelector('.app')
|
||||
appRoot?.appendChild(blur)
|
||||
|
||||
const updateSticky = () => {
|
||||
const viewport = body.clientHeight
|
||||
const maxLeft = Math.max(0, sider instanceof HTMLElement ? sider.offsetHeight - viewport : 0)
|
||||
const offset = Math.max(0, body.scrollTop - maxLeft)
|
||||
if (sider instanceof HTMLElement) {
|
||||
sider.style.transform = offset > 0 ? `translateY(${offset}px)` : ''
|
||||
}
|
||||
// 滚动容器只覆盖 1380 的两栏,窗口两侧的空白、页头与搜索条都在它外面,
|
||||
// 滚轮落在这些地方时浏览器找不到可滚动元素。统一转发给内容区。
|
||||
const forwardWheel = (event: WheelEvent) => {
|
||||
if (event.target instanceof Node && body.contains(event.target)) return
|
||||
// deltaMode 1 = 按行(Firefox 鼠标滚轮),换算成像素
|
||||
body.scrollTop += event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY
|
||||
event.preventDefault()
|
||||
}
|
||||
window.addEventListener('wheel', forwardWheel, { passive: false })
|
||||
|
||||
body.addEventListener('scroll', onScroll, { passive: true })
|
||||
body.addEventListener('scroll', updateSticky, { passive: true })
|
||||
window.addEventListener('resize', updateSticky)
|
||||
const observer = new ResizeObserver(updateSticky)
|
||||
observer.observe(content)
|
||||
if (sider instanceof HTMLElement) observer.observe(sider)
|
||||
onScroll()
|
||||
updateSticky()
|
||||
|
||||
return () => {
|
||||
body.removeEventListener('scroll', onScroll)
|
||||
body.removeEventListener('scroll', updateSticky)
|
||||
window.removeEventListener('resize', updateSticky)
|
||||
observer.disconnect()
|
||||
blur.remove()
|
||||
window.removeEventListener('wheel', forwardWheel)
|
||||
body.id = ''
|
||||
body.style.overflowY = ''
|
||||
body.style.overflowX = ''
|
||||
body.style.marginTop = ''
|
||||
body.style.height = ''
|
||||
content.style.overflow = ''
|
||||
content.style.marginTop = ''
|
||||
main.style.height = ''
|
||||
if (sider instanceof HTMLElement) {
|
||||
sider.style.overflow = ''
|
||||
sider.style.height = ''
|
||||
sider.style.alignSelf = ''
|
||||
sider.style.transform = ''
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const site: SiteSettings = settings?.settings ?? {
|
||||
header: { enabled: true, speed: 24, items: [] },
|
||||
header: { enabled: true, speed: 24, gap: 26, items: [] },
|
||||
sidebar: {
|
||||
show_profile: true,
|
||||
show_service: true,
|
||||
show_nav: true,
|
||||
hidden: [],
|
||||
order: [],
|
||||
hidden_groups: [],
|
||||
@@ -292,44 +291,77 @@ export default function AppNext() {
|
||||
location: '',
|
||||
avatar: '',
|
||||
},
|
||||
site: {
|
||||
title: 'Stash · 本地应用工作区',
|
||||
favicon: '',
|
||||
theme: 'auto',
|
||||
brand: { name: 'STASH', mark: 'S', logo: '' },
|
||||
actions: { theme: true, refresh: true, settings: true, session: true },
|
||||
},
|
||||
}
|
||||
|
||||
const keywordLower = keyword.trim().toLowerCase()
|
||||
|
||||
const sidebarItems: MenuItem[] = useMemo(() => {
|
||||
const hidden = new Set(site.sidebar.tree_hidden)
|
||||
// 配色:本机偏好优先于站点默认,写在 <html data-scheme> 上,
|
||||
// 深色由组件库的令牌块接管,页面与组件都不用改
|
||||
const [themeOverride, setThemeOverride] = useState<ThemeChoice | null>(() =>
|
||||
readThemeOverride(),
|
||||
)
|
||||
const effectiveTheme = themeOverride ?? site.site.theme
|
||||
|
||||
const sortNodes = (nodes: GroupNode[], parentKey: string): GroupNode[] => {
|
||||
const ranks = new Map(
|
||||
(site.sidebar.tree_order[parentKey] ?? []).map((key, index) => [key, index] as const),
|
||||
)
|
||||
return [...nodes].sort((a, b) => {
|
||||
const left = ranks.get(a.key) ?? Number.MAX_SAFE_INTEGER
|
||||
const right = ranks.get(b.key) ?? Number.MAX_SAFE_INTEGER
|
||||
if (left === right) return a.sort - b.sort
|
||||
return left - right
|
||||
})
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.scheme = effectiveTheme
|
||||
}, [effectiveTheme])
|
||||
|
||||
/** 当前实际是深色吗(auto 时按系统偏好算) */
|
||||
const isDark = isDarkScheme(effectiveTheme)
|
||||
|
||||
/** 页头的明暗切换:只写本机偏好,不需要登录;站点默认值在后台改 */
|
||||
const toggleTheme = () => {
|
||||
const next: ThemeChoice = isDark ? 'light' : 'dark'
|
||||
setThemeOverride(next)
|
||||
writeThemeOverride(next)
|
||||
}
|
||||
|
||||
// 站点级设置:浏览器标签标题与 favicon(空值回落到内置)
|
||||
useEffect(() => {
|
||||
const title = site.site.title.trim()
|
||||
if (title !== '') document.title = title
|
||||
|
||||
const href = site.site.favicon.trim() === '' ? '/favicon.svg' : site.site.favicon.trim()
|
||||
let link = document.head.querySelector<HTMLLinkElement>('link[rel="icon"]')
|
||||
if (link === null) {
|
||||
link = document.createElement('link')
|
||||
link.rel = 'icon'
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
link.href = href
|
||||
link.type = href.endsWith('.svg') ? 'image/svg+xml' : ''
|
||||
}, [site.site.title, site.site.favicon])
|
||||
|
||||
const build = (nodes: GroupNode[], parentKey: string): MenuItem[] =>
|
||||
sortNodes(nodes, parentKey).flatMap((node) => {
|
||||
if (node.enabled === false) return []
|
||||
if (hidden.has(node.key)) return build(node.children, node.key)
|
||||
const children = build(node.children, node.key)
|
||||
return [
|
||||
{
|
||||
key: `g:${node.key}`,
|
||||
label: node.title,
|
||||
children: children.length > 0 ? children : undefined,
|
||||
},
|
||||
]
|
||||
})
|
||||
const sidebarItems: MenuItem[] = useMemo(() => {
|
||||
// 侧边栏的显隐只认「分组管理 → 状态」:停用的分组整棵子树都不进导航,
|
||||
// 顺序按分组自己的排序权重,不再有侧栏专属的隐藏/排序状态。
|
||||
const build = (nodes: GroupNode[]): MenuItem[] =>
|
||||
[...nodes]
|
||||
.sort((a, b) => a.sort - b.sort)
|
||||
.flatMap((node) => {
|
||||
if (node.enabled === false) return []
|
||||
const children = build(node.children)
|
||||
return [
|
||||
{
|
||||
key: `g:${node.key}`,
|
||||
label: node.title,
|
||||
children: children.length > 0 ? children : undefined,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
return [{ key: 'home', label: '首页' }, ...build(manage.groups, '')]
|
||||
}, [manage.groups, site.sidebar.tree_hidden, site.sidebar.tree_order])
|
||||
return [{ key: 'home', label: '首页' }, ...build(manage.groups)]
|
||||
}, [manage.groups])
|
||||
|
||||
const activeKey =
|
||||
route.kind === 'home'
|
||||
route.kind === 'home' || route.kind === 'api'
|
||||
? 'home'
|
||||
: route.kind === 'group'
|
||||
? `g:${route.key}`
|
||||
@@ -367,11 +399,16 @@ export default function AppNext() {
|
||||
}
|
||||
|
||||
function renderGroupBlock(group: GroupNode, limit: number, showTitle: boolean) {
|
||||
const all = collectEntries(group).filter((entry) => matches(entry, keywordLower))
|
||||
// 根分组(分区)只展示直接挂在它下面的条目,子分组各自成块,避免同一批内容出现两次
|
||||
const rootGroup = group.parentKey === undefined || group.parentKey === null
|
||||
const source = rootGroup
|
||||
? (group.entries as unknown as Record<string, unknown>[])
|
||||
: collectEntries(group)
|
||||
const all = source.filter((entry) => matches(entry, keywordLower))
|
||||
if (all.length === 0) return null
|
||||
return (
|
||||
<div key={group.key} className="card-group-block">
|
||||
{showTitle && <h3 className="card-group-title">{group.title}</h3>}
|
||||
{showTitle && !rootGroup && <h3 className="card-group-title">{group.title}</h3>}
|
||||
{renderEntries(group, all, { limit })}
|
||||
</div>
|
||||
)
|
||||
@@ -406,7 +443,7 @@ export default function AppNext() {
|
||||
.map((ref) => ({ ref, group: findGroup(manage.groups, ref.groupKey) }))
|
||||
.filter(
|
||||
(item): item is { ref: typeof item.ref; group: GroupNode } =>
|
||||
item.group !== undefined && item.group.enabled !== false,
|
||||
item.group !== undefined && isGroupVisible(manage.groups, item.ref.groupKey),
|
||||
)
|
||||
|
||||
const total = resolved.reduce((sum, item) => sum + groupEntryCount(item.group), 0)
|
||||
@@ -445,11 +482,13 @@ export default function AppNext() {
|
||||
|
||||
function renderGroupPage(key: string) {
|
||||
const group = findGroup(manage.groups, key)
|
||||
if (group === undefined) {
|
||||
if (group === undefined || !isGroupVisible(manage.groups, key)) {
|
||||
return <Empty text={loading ? '正在读取内容库…' : '分组不存在'} />
|
||||
}
|
||||
|
||||
const trail = findPath(manage.groups, key) ?? [group]
|
||||
// 根分组页就是「这个分区的全部内容」:所有启用的子分组整段铺开,不再需要「查看全部」
|
||||
const rootGroup = group.parentKey === undefined || group.parentKey === null
|
||||
|
||||
return (
|
||||
<div className="detail-view">
|
||||
@@ -473,7 +512,7 @@ export default function AppNext() {
|
||||
|
||||
<div className="detail-hero">
|
||||
<span className="detail-hero-icon" aria-hidden="true">
|
||||
{group.icon || '•'}
|
||||
<GroupMark icon={group.icon} fallback="•" />
|
||||
</span>
|
||||
<h2 className="detail-hero-title">{group.title}</h2>
|
||||
<span className="detail-hero-count">[{groupEntryCount(group)}]</span>
|
||||
@@ -486,7 +525,9 @@ export default function AppNext() {
|
||||
)}
|
||||
|
||||
<Space direction="column" gap="lg" align="stretch">
|
||||
{group.children.map((child) => (
|
||||
{group.children
|
||||
.filter((child) => child.enabled !== false)
|
||||
.map((child) => (
|
||||
<section key={child.key} className="group-child-block">
|
||||
<div className="group-title">
|
||||
<span
|
||||
@@ -495,16 +536,18 @@ export default function AppNext() {
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3>{child.title}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="link-button"
|
||||
onClick={() => navigate(`/g/${encodeURIComponent(child.key)}`)}
|
||||
>
|
||||
查看全部
|
||||
</button>
|
||||
{!rootGroup && (
|
||||
<button
|
||||
type="button"
|
||||
className="link-button"
|
||||
onClick={() => navigate(`/g/${encodeURIComponent(child.key)}`)}
|
||||
>
|
||||
查看全部
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{child.entries.length > 0 ? (
|
||||
renderEntries(child, child.entries, { limit: 12 })
|
||||
renderEntries(child, child.entries, rootGroup ? {} : { limit: 12 })
|
||||
) : (
|
||||
<Empty text="暂无内容" />
|
||||
)}
|
||||
@@ -517,7 +560,7 @@ export default function AppNext() {
|
||||
|
||||
function renderItemPage(key: string, itemId: string) {
|
||||
const found = findEntry(manage.groups, key, itemId)
|
||||
if (found === undefined) {
|
||||
if (found === undefined || !isGroupVisible(manage.groups, found.group.key)) {
|
||||
return <Empty text={loading ? '正在读取内容库…' : '条目不存在'} />
|
||||
}
|
||||
|
||||
@@ -613,7 +656,6 @@ export default function AppNext() {
|
||||
headerVariant="solid"
|
||||
siderBordered={false}
|
||||
contentPadding="none"
|
||||
contentOffset={TOP_OFFSET}
|
||||
height="screen"
|
||||
scrollable
|
||||
hideScrollbar
|
||||
@@ -627,9 +669,17 @@ export default function AppNext() {
|
||||
headerItems={site.header.items}
|
||||
headerEnabled={site.header.enabled}
|
||||
headerSpeed={site.header.speed}
|
||||
headerGap={site.header.gap}
|
||||
site={site.site}
|
||||
authenticated={session?.authenticated === true}
|
||||
username={session?.username ?? undefined}
|
||||
onFocusSearch={() => searchRef.current?.focus()}
|
||||
refreshing={refreshing}
|
||||
dark={isDark}
|
||||
onToggleTheme={() => void toggleTheme()}
|
||||
onRefresh={() => {
|
||||
// 刷新 = 整页重载:项目扫描、内容库、设置、主题都重新走一遍
|
||||
window.location.reload()
|
||||
}}
|
||||
onLogin={() => setLoginOpen(true)}
|
||||
onLogout={() => {
|
||||
void logout().then(() => setSession({ authenticated: false, has_admin: true }))
|
||||
@@ -637,6 +687,19 @@ export default function AppNext() {
|
||||
onSettings={() => window.location.assign('/admin')}
|
||||
/>
|
||||
}
|
||||
toolbar={
|
||||
<div className="search-toolbar">
|
||||
<SearchField
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
inputRef={searchRef}
|
||||
placeholder="搜索本地项目…"
|
||||
label="搜索本地项目"
|
||||
variant="card"
|
||||
hotkey="k"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
sider={
|
||||
<div className="sidebar-wrap">
|
||||
<div className="sidebar sidebar-home">
|
||||
@@ -647,25 +710,27 @@ export default function AppNext() {
|
||||
live={live}
|
||||
refreshing={refreshing}
|
||||
onRefresh={refresh}
|
||||
onOpenApi={() => navigate('/interface')}
|
||||
/>
|
||||
)}
|
||||
<div className="nav-card">
|
||||
<Menu
|
||||
items={sidebarItems}
|
||||
activeKey={activeKey}
|
||||
itemHeight={39}
|
||||
onSelect={(key) => {
|
||||
if (key === 'home') navigate('/')
|
||||
else navigate(`/g/${encodeURIComponent(key.slice(2))}`)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{site.sidebar.show_nav !== false && (
|
||||
<div className="nav-card">
|
||||
<Menu
|
||||
items={sidebarItems}
|
||||
activeKey={activeKey}
|
||||
itemHeight={39}
|
||||
onSelect={(key) => {
|
||||
if (key === 'home') navigate('/')
|
||||
else navigate(`/g/${encodeURIComponent(key.slice(2))}`)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="content">
|
||||
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
|
||||
<h1 className="sr-only">Stash 本地应用工作区</h1>
|
||||
|
||||
{status === 'error' && (
|
||||
@@ -679,6 +744,7 @@ export default function AppNext() {
|
||||
)}
|
||||
|
||||
{route.kind === 'home' && renderHome()}
|
||||
{route.kind === 'api' && <ApiDocs />}
|
||||
{route.kind === 'group' && renderGroupPage(route.key)}
|
||||
{route.kind === 'item' && renderItemPage(route.key, route.itemId)}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
||||
import { Card, Space, Table, Tag, Text, Title } from '@stash/ui'
|
||||
|
||||
/**
|
||||
* 接口一览:把 `/api/*` 按用途分组列出来。
|
||||
*
|
||||
* 页面只做展示,不请求后端——清单跟 `src/server.rs` 的路由表人工对齐,
|
||||
* 改接口时顺手改这里(文件顶部有分组与权限说明)。
|
||||
*/
|
||||
type Endpoint = {
|
||||
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
||||
path: string
|
||||
summary: string
|
||||
/** 需要登录会话 */
|
||||
auth?: boolean
|
||||
}
|
||||
|
||||
const GROUPS: { title: string; desc: string; items: Endpoint[] }[] = [
|
||||
{
|
||||
title: '健康与项目',
|
||||
desc: '托管目录的扫描结果与实时推送。',
|
||||
items: [
|
||||
{ method: 'GET', path: '/api/health', summary: '存活检查,返回服务名与状态' },
|
||||
{ method: 'GET', path: '/api/projects', summary: '扫描托管目录(每次请求都重新扫描)' },
|
||||
{ method: 'GET', path: '/api/projects/events', summary: 'SSE:目录变化时推送最新项目列表' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '内容库',
|
||||
desc: '分区 / 分组 / 条目 / 卡片。写操作都需要登录会话与同源 Origin。',
|
||||
items: [
|
||||
{ method: 'GET', path: '/api/catalog', summary: '旧版完整目录(兼容保留)' },
|
||||
{ method: 'GET', path: '/api/catalog2', summary: '分组树 + 卡片,后台配置用' },
|
||||
{ method: 'GET', path: '/api/catalog/events', summary: 'SSE:内容库变更时推送「该重拉」' },
|
||||
{ method: 'POST', path: '/api/nodes', summary: '新建分区 / 分组', auth: true },
|
||||
{ method: 'PATCH', path: '/api/nodes/{key}', summary: '改标题、所属、图标、排序、启停', auth: true },
|
||||
{ method: 'DELETE', path: '/api/nodes/{key}', summary: '删除分区 / 分组(级联子节点)', auth: true },
|
||||
{ method: 'GET', path: '/api/items/{key}', summary: '条目详情' },
|
||||
{ method: 'POST', path: '/api/items', summary: '新建条目', auth: true },
|
||||
{ method: 'PATCH', path: '/api/items/{key}', summary: '就地编辑(带 updatedAt 乐观锁)', auth: true },
|
||||
{ method: 'DELETE', path: '/api/items/{key}', summary: '删除条目', auth: true },
|
||||
{ method: 'POST', path: '/api/items/{key}/view', summary: '浏览数 +1' },
|
||||
{ method: 'POST', path: '/api/items/{key}/like', summary: '点赞 / 取消点赞' },
|
||||
{ method: 'POST', path: '/api/cards', summary: '新建首页卡片', auth: true },
|
||||
{ method: 'PATCH', path: '/api/cards/{id}', summary: '改卡片标题 / 顺序', auth: true },
|
||||
{ method: 'DELETE', path: '/api/cards/{id}', summary: '删除卡片', auth: true },
|
||||
{ method: 'PUT', path: '/api/cards/{id}/groups', summary: '整组替换卡片包含的分组与顺序', auth: true },
|
||||
{ method: 'POST', path: '/api/categories', summary: '旧版:新建分类', auth: true },
|
||||
{ method: 'PATCH', path: '/api/categories/{id}', summary: '旧版:改分类', auth: true },
|
||||
{ method: 'DELETE', path: '/api/categories/{id}', summary: '旧版:删分类', auth: true },
|
||||
{ method: 'POST', path: '/api/categories/{id}/groups', summary: '旧版:新建分组', auth: true },
|
||||
{ method: 'PATCH', path: '/api/groups/{id}', summary: '旧版:改分组', auth: true },
|
||||
{ method: 'DELETE', path: '/api/groups/{id}', summary: '旧版:删分组', auth: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '站点设置',
|
||||
desc: '页头、侧边栏、卡片、资料、站点信息等整份 JSON。',
|
||||
items: [
|
||||
{ method: 'GET', path: '/api/settings', summary: '读取设置(含 version 乐观锁)' },
|
||||
{ method: 'PATCH', path: '/api/settings', summary: '整份保存设置', auth: true },
|
||||
{ method: 'GET', path: '/api/backup', summary: '下载 SQLite 快照', auth: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '认证',
|
||||
desc: '单管理员账号;会话 token 只存 sha256,Cookie 为 HttpOnly + SameSite=Strict。',
|
||||
items: [
|
||||
{ method: 'GET', path: '/api/auth/session', summary: '当前会话状态(是否登录、是否已设密码)' },
|
||||
{ method: 'POST', path: '/api/auth/setup', summary: '首次设置管理员密码(仅空库可用)' },
|
||||
{ method: 'POST', path: '/api/auth/login', summary: '登录;连续失败 5 次锁 60 秒' },
|
||||
{ method: 'POST', path: '/api/auth/logout', summary: '退出并清掉会话' },
|
||||
{ method: 'PATCH', path: '/api/auth/password', summary: '改密码(需当前密码)', auth: true },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const METHOD_TONE = {
|
||||
GET: 'neutral',
|
||||
POST: 'primary',
|
||||
PATCH: 'warning',
|
||||
PUT: 'warning',
|
||||
DELETE: 'danger',
|
||||
} as const
|
||||
|
||||
export function ApiDocs() {
|
||||
return (
|
||||
<div className="api-docs">
|
||||
<Space direction="column" gap="sm" align="stretch">
|
||||
<Title level={2}>接口一览</Title>
|
||||
<Text size="sm" tone="muted">
|
||||
说明:这是 Stash 自带的 HTTP 接口清单,全部挂在同一个端口上。读接口公开,写接口需要登录会话并校验同源
|
||||
Origin;带 <Tag tone="primary">需登录</Tag> 标记的即为写操作。
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
{GROUPS.map((group) => (
|
||||
<Card key={group.title} padding="lg">
|
||||
<Space direction="column" gap="sm" align="stretch">
|
||||
<Space gap="sm" align="center">
|
||||
<Title level={4} as="h2">
|
||||
{group.title}
|
||||
</Title>
|
||||
<Text size="sm" tone="muted">
|
||||
{group.items.length} 个
|
||||
</Text>
|
||||
</Space>
|
||||
<Text size="sm" tone="muted">
|
||||
{group.desc}
|
||||
</Text>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={[
|
||||
{ key: 'method', title: '方法', width: 90 },
|
||||
{ key: 'path', title: '路径', width: 280 },
|
||||
{ key: 'summary', title: '说明' },
|
||||
{ key: 'auth', title: '权限', width: 100 },
|
||||
]}
|
||||
rows={group.items.map((item) => ({
|
||||
id: `${item.method} ${item.path}`,
|
||||
method: <Tag tone={METHOD_TONE[item.method]}>{item.method}</Tag>,
|
||||
path: <code className="api-path">{item.path}</code>,
|
||||
summary: item.summary,
|
||||
auth: item.auth === true ? <Tag tone="primary">需登录</Tag> : <Text tone="muted">公开</Text>,
|
||||
}))}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Icon, ICON_NAMES, type IconName } from '@stash/ui'
|
||||
|
||||
/**
|
||||
* 分组图标:能对上 iconfont 名字就渲染图标,否则按字符渲染。
|
||||
*
|
||||
* 旧数据里存的是 emoji / 任意字符,新数据存图标名(如 `folder_2_line`);
|
||||
* 两种都要能显示,所以这里做一次判定,前后台共用同一套规则。
|
||||
*/
|
||||
export function GroupMark({ icon, fallback = '' }: { icon: string; fallback?: string }) {
|
||||
const value = icon.trim()
|
||||
if (value === '') return fallback === '' ? null : <>{fallback}</>
|
||||
if (ICON_NAMES.includes(value as IconName)) return <Icon name={value as IconName} />
|
||||
return <>{value}</>
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Avatar, ItemBadge, Space, Text } from '@stash/ui'
|
||||
import { EntryRow, ItemBadge } from '@stash/ui'
|
||||
import type { AppItem } from '../../data/types'
|
||||
|
||||
/**
|
||||
* 应用卡片:头像 + 名称(热门带 HOT 角标)+ 单行描述。
|
||||
* 点击进入条目详情;按住修饰键时保持浏览器默认行为(新标签打开原始链接)。
|
||||
* 版式与点击语义都在库里的 EntryRow,这里只做数据映射。
|
||||
*/
|
||||
type AppCardProps = {
|
||||
item: AppItem
|
||||
@@ -12,37 +12,15 @@ type AppCardProps = {
|
||||
|
||||
export function AppCard({ item, onOpen }: AppCardProps) {
|
||||
return (
|
||||
<a
|
||||
className="app-card"
|
||||
href={item.href}
|
||||
<EntryRow
|
||||
title={item.name}
|
||||
onClick={(event) => {
|
||||
if (onOpen === undefined) return
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
||||
event.preventDefault()
|
||||
onOpen(item.id)
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
size="lg"
|
||||
tone="primary"
|
||||
className="app-card-avatar"
|
||||
src={item.cover}
|
||||
fallback={item.icon}
|
||||
verified={item.hot === true}
|
||||
/>
|
||||
|
||||
<div className="app-card-info">
|
||||
<Space gap="xs">
|
||||
<Text strong truncate className="app-card-name">
|
||||
{item.name}
|
||||
</Text>
|
||||
{item.hot === true && <ItemBadge tone="hot">HOT</ItemBadge>}
|
||||
</Space>
|
||||
<Text size="xs" tone="muted" truncate>
|
||||
{item.desc}
|
||||
</Text>
|
||||
</div>
|
||||
</a>
|
||||
description={item.desc}
|
||||
href={item.href}
|
||||
avatar={item.cover}
|
||||
fallback={item.icon}
|
||||
verified={item.hot === true}
|
||||
badge={item.hot === true ? <ItemBadge tone="hot">HOT</ItemBadge> : undefined}
|
||||
onOpen={onOpen === undefined ? undefined : () => onOpen(item.id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Card, Cover, Space, Text } from '@stash/ui'
|
||||
import { EntryTile } from '@stash/ui'
|
||||
import type { ArticleItem } from '../../data/types'
|
||||
|
||||
/**
|
||||
* 明细页图文卡:上图下文的白卡。
|
||||
* 传入 onOpen 时整张卡变成按钮,点击进入文章详情。
|
||||
* 版式与点击语义都在库里的 EntryTile,这里只做数据映射。
|
||||
*/
|
||||
type CesCardProps = {
|
||||
item: ArticleItem
|
||||
@@ -11,38 +11,12 @@ type CesCardProps = {
|
||||
}
|
||||
|
||||
export function CesCard({ item, onOpen }: CesCardProps) {
|
||||
const body = (
|
||||
<Space direction="column" gap="xs" align="stretch">
|
||||
<Text strong truncate>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text size="xs" tone="muted" truncate>
|
||||
{item.desc}
|
||||
</Text>
|
||||
</Space>
|
||||
)
|
||||
|
||||
return (
|
||||
<Card padding="none" hoverable className="ces-card">
|
||||
<Cover
|
||||
src={item.cover}
|
||||
alt={item.title}
|
||||
ratio={16 / 10}
|
||||
radius="none"
|
||||
onClick={onOpen === undefined ? undefined : () => onOpen(item.id)}
|
||||
/>
|
||||
{onOpen === undefined ? (
|
||||
<div className="ces-body">{body}</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="ces-body ces-body--button"
|
||||
title={item.title}
|
||||
onClick={() => onOpen(item.id)}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
)}
|
||||
</Card>
|
||||
<EntryTile
|
||||
title={item.title}
|
||||
description={item.desc}
|
||||
cover={item.cover}
|
||||
onOpen={onOpen === undefined ? undefined : () => onOpen(item.id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { Icon, IconButton, Tag } from '@stash/ui'
|
||||
import { fillHeaderText, type HeaderItem, type SettingsTone } from '../../settings'
|
||||
import { Brand, Icon, IconButton, Tag, Ticker } from '@stash/ui'
|
||||
import {
|
||||
fillHeaderText,
|
||||
type HeaderItem,
|
||||
type SettingsTone,
|
||||
type SiteMetaSettings,
|
||||
} from '../../settings'
|
||||
|
||||
/**
|
||||
* 顶部栏:左品牌、中间滚动信息(可在 /admin 配置)、右侧操作图标。
|
||||
* 外层 <header> 由 Layout 提供,这里只渲染栏内的内容。
|
||||
* 品牌与操作图标是否出现,都来自「页头」Tab 的站点设置。
|
||||
* 外壳(品牌、跑马灯)都用组件库,这里只拼内容。
|
||||
*/
|
||||
type SiteHeaderProps = {
|
||||
projectCount: number
|
||||
@@ -15,10 +21,19 @@ type SiteHeaderProps = {
|
||||
headerEnabled: boolean
|
||||
/** 滚动一轮的秒数 */
|
||||
headerSpeed: number
|
||||
/** 信息条目之间的间距(px) */
|
||||
headerGap?: number
|
||||
/** 品牌与操作工具配置 */
|
||||
site: SiteMetaSettings
|
||||
/** 是否已登录:决定右侧是「登录」还是「退出登录」 */
|
||||
authenticated: boolean
|
||||
username?: string
|
||||
onFocusSearch: () => void
|
||||
/** 刷新中:刷新按钮转圈 */
|
||||
refreshing?: boolean
|
||||
/** 当前是否为深色,决定明暗按钮的图标与文案 */
|
||||
dark?: boolean
|
||||
onRefresh: () => void
|
||||
onToggleTheme: () => void
|
||||
onLogin: () => void
|
||||
onLogout: () => void
|
||||
onSettings: () => void
|
||||
@@ -31,40 +46,6 @@ const tagTone: Record<SettingsTone, 'neutral' | 'primary' | 'success'> = {
|
||||
success: 'success',
|
||||
}
|
||||
|
||||
type InfoGroupProps = {
|
||||
items: HeaderItem[]
|
||||
projectCount: number
|
||||
sampleProject?: string
|
||||
live: boolean
|
||||
'aria-hidden'?: boolean
|
||||
}
|
||||
|
||||
/** 运行信息:同一份内容渲染三遍,轨道平移 1/3 形成无缝滚动 */
|
||||
function InfoGroup({
|
||||
items,
|
||||
projectCount,
|
||||
sampleProject,
|
||||
live,
|
||||
'aria-hidden': ariaHidden,
|
||||
}: InfoGroupProps) {
|
||||
const context = { count: projectCount, project: sampleProject, live }
|
||||
|
||||
return (
|
||||
<span className="header-ticker-group" aria-hidden={ariaHidden}>
|
||||
{items.map((item, index) => (
|
||||
<span className="header-ticker-item" key={`${index}-${item.text}`}>
|
||||
{item.tag !== undefined && item.tag !== null && item.tag !== '' && (
|
||||
<Tag tone={tagTone[item.tone]} shape="rounded">
|
||||
{item.tag}
|
||||
</Tag>
|
||||
)}
|
||||
{fillHeaderText(item.text, context)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function SiteHeader({
|
||||
projectCount,
|
||||
sampleProject,
|
||||
@@ -72,71 +53,73 @@ export function SiteHeader({
|
||||
headerItems,
|
||||
headerEnabled,
|
||||
headerSpeed,
|
||||
headerGap,
|
||||
site,
|
||||
authenticated,
|
||||
username,
|
||||
onFocusSearch,
|
||||
refreshing = false,
|
||||
dark = false,
|
||||
onRefresh,
|
||||
onToggleTheme,
|
||||
onLogin,
|
||||
onLogout,
|
||||
onSettings,
|
||||
}: SiteHeaderProps) {
|
||||
const context = { count: projectCount, project: sampleProject, live }
|
||||
|
||||
return (
|
||||
<div className="site-header-inner">
|
||||
<a className="site-brand" href="/" aria-label="Stash 首页">
|
||||
<span className="site-brand-mark" aria-hidden="true">
|
||||
S
|
||||
</span>
|
||||
<span className="site-brand-name">STASH</span>
|
||||
</a>
|
||||
<Brand
|
||||
href="/"
|
||||
name={site.brand.name}
|
||||
mark={site.brand.mark}
|
||||
logo={site.brand.logo}
|
||||
/>
|
||||
|
||||
{headerEnabled && (
|
||||
<div className="header-ticker" role="status" aria-label="运行状态">
|
||||
<div
|
||||
className="header-ticker-track"
|
||||
style={{ animationDuration: `${headerSpeed}s` }}
|
||||
>
|
||||
<InfoGroup
|
||||
items={headerItems}
|
||||
projectCount={projectCount}
|
||||
sampleProject={sampleProject}
|
||||
live={live}
|
||||
/>
|
||||
<InfoGroup
|
||||
items={headerItems}
|
||||
projectCount={projectCount}
|
||||
sampleProject={sampleProject}
|
||||
live={live}
|
||||
// 后两份只用于视觉无缝,读屏忽略
|
||||
aria-hidden
|
||||
/>
|
||||
<InfoGroup
|
||||
items={headerItems}
|
||||
projectCount={projectCount}
|
||||
sampleProject={sampleProject}
|
||||
live={live}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Ticker speed={headerSpeed} gap={headerGap} label="运行状态" className="header-ticker">
|
||||
{headerItems.map((item, index) => (
|
||||
<span className="header-ticker-item" key={`${index}-${item.text}`}>
|
||||
{item.tag !== undefined && item.tag !== null && item.tag !== '' && (
|
||||
<Tag tone={tagTone[item.tone]} shape="rounded">
|
||||
{item.tag}
|
||||
</Tag>
|
||||
)}
|
||||
{fillHeaderText(item.text, context)}
|
||||
</span>
|
||||
))}
|
||||
</Ticker>
|
||||
)}
|
||||
|
||||
<div className="header-actions">
|
||||
<IconButton
|
||||
label="聚焦搜索框(Ctrl+K)"
|
||||
icon={<Icon name="search_3_line" />}
|
||||
onClick={onFocusSearch}
|
||||
/>
|
||||
{authenticated && (
|
||||
{site.actions.theme && (
|
||||
<IconButton
|
||||
label={dark ? '切换到浅色' : '切换到深色'}
|
||||
icon={<Icon name={dark ? 'sun_line' : 'moon_line'} />}
|
||||
onClick={onToggleTheme}
|
||||
/>
|
||||
)}
|
||||
{site.actions.refresh && (
|
||||
<IconButton
|
||||
label="刷新(重扫项目并重拉内容)"
|
||||
icon={<Icon name="lightning_line" />}
|
||||
spinning={refreshing}
|
||||
onClick={onRefresh}
|
||||
/>
|
||||
)}
|
||||
{site.actions.settings && authenticated && (
|
||||
<IconButton label="站点设置" icon={<Icon name="settings_1_line" />} onClick={onSettings} />
|
||||
)}
|
||||
{authenticated ? (
|
||||
<IconButton
|
||||
label={username === undefined ? '退出登录' : `退出登录(${username})`}
|
||||
icon={<Icon name="exit_line" />}
|
||||
onClick={onLogout}
|
||||
/>
|
||||
) : (
|
||||
<IconButton label="登录" icon={<Icon name="user_3_line" />} onClick={onLogin} />
|
||||
)}
|
||||
{site.actions.session &&
|
||||
(authenticated ? (
|
||||
<IconButton
|
||||
label={username === undefined ? '退出登录' : `退出登录(${username})`}
|
||||
icon={<Icon name="exit_line" />}
|
||||
onClick={onLogout}
|
||||
/>
|
||||
) : (
|
||||
<IconButton label="登录" icon={<Icon name="user_3_line" />} onClick={onLogin} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Input } from '@stash/ui'
|
||||
import { useEffect, type RefObject } from 'react'
|
||||
|
||||
/**
|
||||
* 全局搜索框:吸在内容区顶部。
|
||||
* 支持 Ctrl/Cmd+K 聚焦、Esc 清空,输入内容由父组件接管过滤逻辑。
|
||||
*/
|
||||
type SearchBarProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
inputRef: RefObject<HTMLInputElement | null>
|
||||
}
|
||||
|
||||
export function SearchBar({ value, onChange, inputRef }: SearchBarProps) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault()
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [inputRef])
|
||||
|
||||
return (
|
||||
<div className="search-row">
|
||||
<Input
|
||||
inputRef={inputRef}
|
||||
size="lg"
|
||||
icon="search_3_line"
|
||||
value={value}
|
||||
placeholder="搜索本地项目…"
|
||||
aria-label="搜索本地项目"
|
||||
clearable
|
||||
onClear={() => onChange('')}
|
||||
spellCheck={false}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') onChange('')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Card, Menu, type MenuItem } from '@stash/ui'
|
||||
import type { NavTarget } from '../../data/nav'
|
||||
import type { Category } from '../../data/types'
|
||||
|
||||
/**
|
||||
* 分类导航树:由内容树派生。
|
||||
*
|
||||
* - 有分组的分类(apps)是折叠节点,子项是分组(分组可用 show_in_sidebar 单独隐藏);
|
||||
* - 没有分组、或分组全被隐藏的分类,就是一个普通菜单项。
|
||||
*
|
||||
* 折叠状态由组件库的 Menu 内部维护;这里只负责把内容树翻译成 items。
|
||||
*/
|
||||
type CategoryNavProps = {
|
||||
/** 内容目录;顺序即侧栏顺序 */
|
||||
categories: Category[]
|
||||
/** 当前明细视图的分类 / 分组,用于高亮 */
|
||||
activeCategoryId?: string
|
||||
activeGroupId?: string
|
||||
/** 是否在首页(高亮「首页」项) */
|
||||
homeActive?: boolean
|
||||
onNavigate: (target: NavTarget) => void
|
||||
/** 点击「首页」 */
|
||||
onHome: () => void
|
||||
}
|
||||
|
||||
const HOME_KEY = 'home'
|
||||
const categoryKey = (id: string) => `cat:${id}`
|
||||
const groupKey = (categoryId: string, groupId: string) => `group:${categoryId}:${groupId}`
|
||||
|
||||
export function CategoryNav({
|
||||
categories,
|
||||
activeCategoryId,
|
||||
activeGroupId,
|
||||
homeActive = false,
|
||||
onNavigate,
|
||||
onHome,
|
||||
}: CategoryNavProps) {
|
||||
const items: MenuItem[] = [
|
||||
{ key: HOME_KEY, label: '首页' },
|
||||
...categories.map((category) => {
|
||||
// 「允许子分组」的分区才有 groups;空分组不出现在侧栏(后台仍保留)
|
||||
const groups = category.groups.filter((group) => group.items.length > 0)
|
||||
|
||||
return {
|
||||
key: categoryKey(category.id),
|
||||
label: category.title,
|
||||
children:
|
||||
groups.length > 0
|
||||
? groups.map((group) => ({
|
||||
key: groupKey(category.id, group.id),
|
||||
label: group.name,
|
||||
}))
|
||||
: undefined,
|
||||
}
|
||||
}),
|
||||
]
|
||||
|
||||
const activeKey = homeActive
|
||||
? HOME_KEY
|
||||
: activeCategoryId === undefined
|
||||
? undefined
|
||||
: activeGroupId !== undefined
|
||||
? groupKey(activeCategoryId, activeGroupId)
|
||||
: categoryKey(activeCategoryId)
|
||||
|
||||
const handleSelect = (key: string) => {
|
||||
if (key === HOME_KEY) {
|
||||
onHome()
|
||||
return
|
||||
}
|
||||
|
||||
if (key.startsWith('group:')) {
|
||||
const [, categoryId, groupId] = key.split(':')
|
||||
onNavigate({ categoryId, groupId })
|
||||
return
|
||||
}
|
||||
|
||||
onNavigate({ categoryId: key.slice('cat:'.length) })
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding="none" className="nav-card">
|
||||
<Menu items={items} activeKey={activeKey} onSelect={handleSelect} itemHeight={39} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -10,9 +10,17 @@ type ServicePanelProps = {
|
||||
live: boolean
|
||||
refreshing: boolean
|
||||
onRefresh: () => void
|
||||
/** 打开接口一览:走站内路由,不整页刷新 */
|
||||
onOpenApi: () => void
|
||||
}
|
||||
|
||||
export function ServicePanel({ projects, live, refreshing, onRefresh }: ServicePanelProps) {
|
||||
export function ServicePanel({
|
||||
projects,
|
||||
live,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
onOpenApi,
|
||||
}: ServicePanelProps) {
|
||||
const host = typeof window === 'undefined' ? '-' : window.location.host
|
||||
|
||||
return (
|
||||
@@ -56,7 +64,16 @@ export function ServicePanel({ projects, live, refreshing, onRefresh }: ServiceP
|
||||
重新扫描
|
||||
</Button>
|
||||
|
||||
<a className="link-button" href="/api/projects" target="_blank" rel="noreferrer">
|
||||
<a
|
||||
className="link-button"
|
||||
href="/interface"
|
||||
onClick={(event) => {
|
||||
// 无修饰键时接管点击,走站内路由,只换右侧内容;按住修饰键仍可新标签打开
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
||||
event.preventDefault()
|
||||
onOpenApi()
|
||||
}}
|
||||
>
|
||||
<Icon name="link_3_line" />
|
||||
查看接口
|
||||
</a>
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { Project } from '../../api'
|
||||
import type { NavTarget } from '../../data/nav'
|
||||
import type { Category } from '../../data/types'
|
||||
import type { ProfileSettings } from '../../settings'
|
||||
import { CategoryNav } from './CategoryNav'
|
||||
import { ProfileCard } from './ProfileCard'
|
||||
import { ServicePanel } from './ServicePanel'
|
||||
|
||||
/**
|
||||
* 左侧栏容器:用户资料 + 本地服务面板 + 分类导航。
|
||||
* 只负责布局与数据透传,具体内容由三个子组件分别渲染。
|
||||
*/
|
||||
type SidebarProps = {
|
||||
projects: Project[]
|
||||
/** 内容目录;侧栏直接由它派生 */
|
||||
categories: Category[]
|
||||
profile: ProfileSettings
|
||||
live: boolean
|
||||
refreshing: boolean
|
||||
onRefresh: () => void
|
||||
activeCategoryId?: string
|
||||
activeGroupId?: string
|
||||
/** 是否在首页 */
|
||||
homeActive?: boolean
|
||||
onNavigate: (target: NavTarget) => void
|
||||
/** 点击「首页」 */
|
||||
onHome: () => void
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
projects,
|
||||
categories,
|
||||
profile,
|
||||
live,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
activeCategoryId,
|
||||
activeGroupId,
|
||||
homeActive = false,
|
||||
onNavigate,
|
||||
onHome,
|
||||
}: SidebarProps) {
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<ProfileCard profile={profile} />
|
||||
|
||||
<ServicePanel
|
||||
projects={projects}
|
||||
live={live}
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
|
||||
<CategoryNav
|
||||
categories={categories}
|
||||
activeCategoryId={activeCategoryId}
|
||||
activeGroupId={activeGroupId}
|
||||
homeActive={homeActive}
|
||||
onNavigate={onNavigate}
|
||||
onHome={onHome}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -189,9 +189,15 @@ export async function setCardGroups(
|
||||
export function flattenGroups(
|
||||
groups: GroupNode[],
|
||||
depth = 0,
|
||||
): { key: string; title: string; depth: number; format: GroupFormat }[] {
|
||||
): { key: string; title: string; depth: number; format: GroupFormat; enabled: boolean }[] {
|
||||
return groups.flatMap((group) => [
|
||||
{ key: group.key, title: group.title, depth, format: group.format },
|
||||
{
|
||||
key: group.key,
|
||||
title: group.title,
|
||||
depth,
|
||||
format: group.format,
|
||||
enabled: group.enabled !== false,
|
||||
},
|
||||
...flattenGroups(group.children, depth + 1),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ export type SiteSettings = {
|
||||
enabled: boolean
|
||||
/** 滚动一轮的秒数(8–120) */
|
||||
speed: number
|
||||
/** 信息条目之间的间距(px),默认 26 ≈ 两个中文字宽 */
|
||||
gap: number
|
||||
items: HeaderItem[]
|
||||
}
|
||||
sidebar: {
|
||||
@@ -43,6 +45,8 @@ export type SiteSettings = {
|
||||
show_profile: boolean
|
||||
/** 是否显示「本地服务」面板 */
|
||||
show_service: boolean
|
||||
/** 是否显示「分组导航」卡片(首页 / 分组树) */
|
||||
show_nav: boolean
|
||||
hidden: string[]
|
||||
order: string[]
|
||||
/** 隐藏的分组:`分区id/分组id` */
|
||||
@@ -58,6 +62,37 @@ export type SiteSettings = {
|
||||
}
|
||||
sections: SectionSettings
|
||||
profile: ProfileSettings
|
||||
/** 站点级:标签标题 / favicon / 品牌 / 页头操作工具 */
|
||||
site: SiteMetaSettings
|
||||
}
|
||||
|
||||
export type SiteTheme = 'light' | 'dark' | 'auto'
|
||||
|
||||
export type SiteMetaSettings = {
|
||||
/** 浏览器标签标题 */
|
||||
title: string
|
||||
/** favicon 地址;空表示用内置图标 */
|
||||
favicon: string
|
||||
/** 配色:浅色 / 深色 / 跟随系统 */
|
||||
theme: SiteTheme
|
||||
brand: {
|
||||
/** 页头品牌文字,可留空 */
|
||||
name: string
|
||||
/** 没有图片时圆形标记里的首字 */
|
||||
mark: string
|
||||
/** logo 图片地址;空表示用首字 */
|
||||
logo: string
|
||||
}
|
||||
actions: {
|
||||
/** 明暗切换 */
|
||||
theme: boolean
|
||||
/** 刷新图标(重扫本地项目并重拉内容库) */
|
||||
refresh: boolean
|
||||
/** 站点设置图标 */
|
||||
settings: boolean
|
||||
/** 登录 / 退出登录 */
|
||||
session: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type SettingsDocument = {
|
||||
@@ -81,6 +116,7 @@ export function defaultSettings(): SiteSettings {
|
||||
header: {
|
||||
enabled: true,
|
||||
speed: 24,
|
||||
gap: 26,
|
||||
items: [
|
||||
{ text: '共 {count} 个本地应用', tone: 'plain' },
|
||||
{ text: '{project}', tag: '本地', tone: 'primary' },
|
||||
@@ -90,6 +126,7 @@ export function defaultSettings(): SiteSettings {
|
||||
sidebar: {
|
||||
show_profile: true,
|
||||
show_service: true,
|
||||
show_nav: true,
|
||||
hidden: [],
|
||||
order: [],
|
||||
hidden_groups: [],
|
||||
@@ -106,6 +143,13 @@ export function defaultSettings(): SiteSettings {
|
||||
location: 'IP属地:局域网',
|
||||
avatar: '',
|
||||
},
|
||||
site: {
|
||||
title: 'Stash · 本地应用工作区',
|
||||
favicon: '',
|
||||
theme: 'auto',
|
||||
brand: { name: 'STASH', mark: 'S', logo: '' },
|
||||
actions: { theme: true, refresh: true, settings: true, session: true },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 明暗主题的「本机偏好」。
|
||||
*
|
||||
* 站点设置里的 `site.theme` 是**所有人**的默认值;这里的 localStorage 是**这台浏览器**
|
||||
* 的选择,读到时优先于默认值。前台页头的切换按钮只改本机偏好,不需要登录;
|
||||
* 后台「页头 → 配色」改的是站点默认值。
|
||||
*/
|
||||
export type ThemeChoice = 'light' | 'dark'
|
||||
|
||||
const STORAGE_KEY = 'stash-theme'
|
||||
|
||||
export function readThemeOverride(): ThemeChoice | null {
|
||||
try {
|
||||
const value = window.localStorage.getItem(STORAGE_KEY)
|
||||
return value === 'light' || value === 'dark' ? value : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeThemeOverride(value: ThemeChoice | null): void {
|
||||
try {
|
||||
if (value === null) window.localStorage.removeItem(STORAGE_KEY)
|
||||
else window.localStorage.setItem(STORAGE_KEY, value)
|
||||
} catch {
|
||||
// 隐私模式下 localStorage 可能不可写,忽略即可
|
||||
}
|
||||
}
|
||||
|
||||
/** `light` / `dark` / `auto` 里,当前实际是不是深色 */
|
||||
export function isDarkScheme(scheme: string): boolean {
|
||||
if (scheme === 'dark') return true
|
||||
if (scheme === 'light') return false
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
Reference in New Issue
Block a user