feat: 重构导航页并增加项目实时推送

This commit is contained in:
2026-10-18 15:06:51 +08:00
parent 2e4f0e947b
commit fc346c3e9b
15 changed files with 2289 additions and 370 deletions
Generated
+251 -9
View File
@@ -47,7 +47,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [ dependencies = [
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -58,7 +58,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [ dependencies = [
"anstyle", "anstyle",
"once_cell_polyfill", "once_cell_polyfill",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -67,6 +67,70 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "askama"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4"
dependencies = [
"askama_derive",
"itoa",
"percent-encoding",
"serde",
"serde_json",
]
[[package]]
name = "askama_derive"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f"
dependencies = [
"askama_parser",
"basic-toml",
"memchr",
"proc-macro2",
"quote",
"rustc-hash",
"serde",
"serde_derive",
"syn 2.0.119",
]
[[package]]
name = "askama_parser"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358"
dependencies = [
"memchr",
"serde",
"serde_derive",
"winnow",
]
[[package]]
name = "async-stream"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
dependencies = [
"async-stream-impl",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-stream-impl"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -125,6 +189,15 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "basic-toml"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.2" version = "2.13.2"
@@ -195,7 +268,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [ dependencies = [
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -205,7 +278,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -217,6 +290,15 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.34" version = "0.3.34"
@@ -338,6 +420,26 @@ dependencies = [
"tower-service", "tower-service",
] ]
[[package]]
name = "inotify"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592"
dependencies = [
"bitflags",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.2" version = "1.70.2"
@@ -350,6 +452,26 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "kqueue"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
dependencies = [
"bitflags",
"libc",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.1" version = "1.5.1"
@@ -421,8 +543,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [ dependencies = [
"libc", "libc",
"log",
"wasi", "wasi",
"windows-sys", "windows-sys 0.61.2",
]
[[package]]
name = "notify"
version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
"bitflags",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio",
"notify-types",
"walkdir",
"windows-sys 0.60.2",
]
[[package]]
name = "notify-types"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
dependencies = [
"bitflags",
] ]
[[package]] [[package]]
@@ -431,7 +581,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -545,6 +695,12 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]] [[package]]
name = "ryu" name = "ryu"
version = "1.0.23" version = "1.0.23"
@@ -670,7 +826,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -691,8 +847,11 @@ name = "stash-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"askama",
"async-stream",
"axum", "axum",
"mime_guess", "mime_guess",
"notify",
"serde", "serde",
"serde_json", "serde_json",
"thiserror", "thiserror",
@@ -780,7 +939,7 @@ dependencies = [
"signal-hook-registry", "signal-hook-registry",
"socket2", "socket2",
"tokio-macros", "tokio-macros",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -984,7 +1143,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -993,6 +1152,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@@ -1002,6 +1170,80 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.23" version = "1.0.23"
+255
View File
@@ -0,0 +1,255 @@
# Stash
Stash 是一个基于 Rust、Tokio 和 Axum 实现的本地静态页面托管工具。它既可以直接托管一个静态站点,也可以扫描托管目录下的多个前端项目,并通过内置导航页统一访问。
## 核心功能
### 单站点托管
当托管根目录中存在 `index.html` 时,访问 `/` 会直接返回该页面。页面依赖的 JavaScript、CSS、图片、字体等资源继续通过静态文件服务加载,因此可以直接运行常见的纯静态前端项目。
### 多项目导航
当根目录没有 `index.html` 时,Stash 会扫描根目录下的一级子目录,并将包含 `index.html` 或 `index.htm` 的目录识别为前端项目。
- 自动排除以 `.` 开头的隐藏目录。
- 项目按名称进行不区分大小写的升序排列。
- 每个项目生成一个卡片链接,地址格式为 `/<项目目录名>/`。
- 项目名称首字符会作为圆形标记显示。
- 没有识别到项目时显示空状态提示。
- 导航页采用 4 列、2 列、1 列的响应式布局,分别适配桌面、平板和手机。
- 项目和目录列表在每次请求时重新扫描,新增项目通常无需重启服务。
- 文件监听会在项目快照发生变化后主动通知已打开的导航页,无需手动刷新。
### 静态文件服务
Stash 使用 `tower-http` 的 `ServeDir` 托管根目录内容:
- 自动为目录追加 `index.html`。
- 根据文件扩展名返回对应的 MIME 类型。
- 未找到的路径会返回内置的中文 404 页面,并显示请求路径。
- 每次 HTTP 请求都会经过 Trace 层,可通过调整日志级别观察请求日志。
- 内置控制台资源通过保留路径 `/__stash__/` 提供,避免与用户托管的项目文件冲突。
### 内置控制台
- 使用 Askama 在编译期渲染项目导航页和 404 页面。
- 页面 HTML、CSS 和 JavaScript 分别维护,不再使用字符串替换拼接卡片。
- 项目名称和请求路径由模板引擎自动进行 HTML 转义。
- 项目目录名会进行 URL 路径段编码,再生成项目访问地址。
- 少量 JavaScript 通过 SSE 订阅项目变化,并在不刷新整个页面的情况下更新卡片;脚本不可用时,服务端渲染的导航和手动 API 仍可正常使用。
### 同源 API
服务提供两个只读 JSON 接口和一个 SSE 推送接口,可供导航页或同源前端调用。
### CORS
默认允许任意来源、任意请求方法和任意请求头访问服务,适合本地联调。通过 `--no-cors` 可以关闭 CORS 标头注入。
### 服务管理
- 默认监听 `127.0.0.1:8080`。
- 支持自定义绑定地址、端口和托管目录。
- 默认托管目录 `./public` 不存在时会自动创建。
- 用户显式指定的托管目录不存在时,会输出错误并退出。
- 端口绑定失败、目录无效等错误会以清晰的错误信息返回。
- 支持分别通过 `Ctrl+C` 以及 Unix 系统的 `SIGTERM` 触发优雅停机。
### 可复用核心库
`stash-core` 同时作为库导出 `Config`、`StashError`、`start`、`ServerHandle`、`scan_projects` 和 `AppProject`,既可由 `stash-cli` 使用,也可以被其他 Rust 程序嵌入或复用。
## 快速开始
### 环境要求
- Rust 工具链,需支持 Rust 2024 Edition。
- Cargo。
### 启动示例
```bash
# 托管默认目录 ./public,监听 127.0.0.1:8080
cargo run -p stash-cli
# 托管指定目录并修改端口
cargo run -p stash-cli -- --root ./sites --port 3000
# 允许局域网设备访问
cargo run -p stash-cli -- --root ./sites --host 0.0.0.0 --port 3000
# 关闭 CORS
cargo run -p stash-cli -- --root ./sites --no-cors
```
启动成功后访问终端中显示的地址即可,例如:
```text
http://127.0.0.1:8080/
```
### 构建发布版本
```bash
cargo build --release -p stash-cli
```
构建产物为 `target/release/stash`;Windows 下为 `target/release/stash.exe`。
## 托管目录示例
### 单个静态站点
```text
public/
├── index.html
├── assets/
│ ├── app.js
│ └── style.css
└── images/
└── logo.png
```
此时访问 `/` 会直接展示 `public/index.html`。
### 多个前端项目
```text
public/
├── admin-console/
│ └── index.html
├── docs/
│ └── index.html
└── landing-page/
└── index.html
```
由于根目录没有 `index.html`,访问 `/` 时会展示项目导航页。对应访问地址为:
```text
/admin-console/
/docs/
/landing-page/
```
## 命令行参数
| 参数 | 默认值 | 说明 |
| --- | --- | --- |
| `-r, --root <DIR>` | `./public` | 静态页面托管根目录,支持相对路径和绝对路径 |
| `-p, --port <PORT>` | `8080` | HTTP 服务监听端口 |
| `--host <HOST>` | `127.0.0.1` | 监听地址;使用 `0.0.0.0` 可供局域网访问 |
| `--no-listing` | 关闭 | 设计用于关闭根目录项目导航页 |
| `--no-cors` | 关闭 | 关闭全局 CORS 标头注入 |
| `-h, --help` | - | 查看命令行帮助 |
| `-V, --version` | - | 查看程序版本 |
注意:`--no-listing` 当前已由命令行解析并写入 `Config.directory_listing`,但根路径处理器尚未读取该配置。因此根目录没有 `index.html` 时,导航页目前仍会显示。
## HTTP API
### 健康检查
```http
GET /api/health
```
响应:
```json
{
"status": "ok",
"service": "stash"
}
```
### 获取项目列表
```http
GET /api/projects
```
响应示例:
```json
{
"projects": [
{
"name": "admin-console",
"route_path": "/admin-console/"
},
{
"name": "docs",
"route_path": "/docs/"
}
]
}
```
该接口与导航页使用相同的项目扫描规则。即使托管根目录包含 `index.html`,接口仍会返回当前根目录下可识别的项目列表。
### 订阅项目变化
```http
GET /api/projects/events
Accept: text/event-stream
```
连接建立后,服务会立即发送当前项目快照,随后在文件监听检测到项目列表变化时推送新快照:
```text
event: projects
data: {"projects":[{"name":"docs","route_path":"/docs/"}]}
```
服务端会递归监听托管目录,将短时间内的连续文件事件合并为一次扫描,并且只在项目名称或访问路径实际变化时广播。SSE 连接每 15 秒发送一次保活消息,浏览器断开后会自动重连。
## 项目结构
```text
.
├── Cargo.toml # Rust workspace 配置
├── Cargo.lock # 依赖锁定文件
├── README.md
├── stash-cli/
│ ├── Cargo.toml
│ └── src/
│ └── main.rs # CLI 参数解析、启动信息和停机信号
└── stash-core/
├── Cargo.toml
├── assets/
│ ├── dashboard.css # 控制台与 404 页面样式
│ └── dashboard.js # 项目列表刷新增强
├── src/
│ ├── config.rs # 服务配置
│ ├── error.rs # 错误类型与 HTTP 错误响应
│ ├── lib.rs # 核心库导出
│ ├── listing.rs # 项目扫描与页面渲染
│ ├── server.rs # Axum 路由、SSE、静态托管与服务器生命周期
│ └── watcher.rs # 文件监听、事件防抖和项目快照广播
└── templates/
├── index.html # Askama 多项目导航页模板
└── 404.html # Askama 404 页面模板
```
## 测试
运行核心库测试:
```bash
cargo test -p stash-core
```
当前测试覆盖项目目录扫描、隐藏目录过滤、一级目录限制、项目排序、项目路径编码、空状态渲染、项目内容 HTML 转义、404 页面渲染与路径转义,以及新增项目后的文件监听广播。
## 已知限制
- 项目扫描只检查托管根目录的一级子目录,不递归查找更深层项目。
- 根目录存在 `index.html` 时会优先进入单站点模式,不再展示项目导航页。
- 静态服务只为目录自动追加 `index.html`;导航扫描虽然也识别 `index.htm`,但建议项目统一使用 `index.html`。
- 未配置 SPA History API 回退。刷新前端路由等不存在的物理路径时,会返回 404 页面。
- SSE 仅用于服务端向浏览器推送项目快照,不提供浏览器向服务端发送命令的双向通道。
- 文件事件会经过约 150ms 防抖后再扫描,因此项目出现在导航页中可能有短暂延迟。
- 启动横幅中显示的接口地址为 `/api/pages`,实际实现的项目列表接口是 `/api/projects`。
- 服务没有身份认证机制,默认仅建议绑定 `127.0.0.1`。使用 `0.0.0.0` 暴露到局域网前,应确认目录内容适合被其他设备访问。
+3
View File
@@ -18,3 +18,6 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
walkdir = "2.4" walkdir = "2.4"
mime_guess = "2.0" mime_guess = "2.0"
askama = "0.14"
async-stream = "0.3"
notify = "8"
-86
View File
@@ -1,86 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - 资源未找到</title>
<style>
:root {
--bg: #f4f6f9;
--container-bg: #ffffff;
--primary: #ff5c38;
--title-color: #2b2f36;
--desc-color: #8a92a3;
--border-color: #edf2f7;
--shadow-sm: 0 4px 16px rgba(0, 0, 0, 0.05);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--bg);
color: var(--title-color);
padding: 20px;
box-sizing: border-box;
}
.box {
text-align: center;
max-width: 440px;
width: 100%;
background: var(--container-bg);
padding: 44px 32px;
border-radius: 16px;
box-shadow: var(--shadow-sm);
}
.code {
font-size: 64px;
font-weight: 800;
color: var(--primary);
line-height: 1;
margin-bottom: 12px;
}
h2 {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 700;
}
p {
color: var(--desc-color);
font-size: 13px;
margin: 0 0 24px 0;
word-break: break-all;
}
code {
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
color: var(--title-color);
}
.btn {
display: inline-block;
background: var(--primary);
color: #ffffff;
font-weight: 600;
font-size: 14px;
padding: 10px 24px;
border-radius: 8px;
text-decoration: none;
transition: opacity 0.15s ease;
}
.btn:hover {
opacity: 0.88;
}
</style>
</head>
<body>
<div class="box">
<div class="code">404</div>
<h2>资源或页面不存在</h2>
<p>请求的路径 <code>__PATH__</code> 不存在或缺少 index.html</p>
<a href="/" class="btn">返回应用大厅</a>
</div>
</body>
</html>
File diff suppressed because it is too large Load Diff
+198
View File
@@ -0,0 +1,198 @@
(() => {
"use strict";
const grid = document.querySelector("#project-grid");
const refreshButton = document.querySelector("#refresh-projects");
const status = document.querySelector("#refresh-status");
const projectCounts = document.querySelectorAll("[data-project-count]");
const liveStatus = document.querySelector("#live-status");
const liveStatusLabel = document.querySelector("#live-status-label");
if (!grid || !refreshButton || !status || !liveStatus || !liveStatusLabel) {
return;
}
document.body.classList.add("is-enhanced");
const createProjectCard = (project) => {
if (
typeof project.name !== "string" ||
typeof project.route_path !== "string" ||
!project.route_path.startsWith("/") ||
project.route_path.startsWith("//")
) {
return null;
}
const link = document.createElement("a");
link.className = "site-item";
link.href = project.route_path;
link.title = project.name;
const cardTop = document.createElement("div");
cardTop.className = "site-card-top";
const avatar = document.createElement("span");
avatar.className = "site-avatar";
avatar.setAttribute("aria-hidden", "true");
avatar.textContent = Array.from(project.name)[0]?.toUpperCase() || "A";
const open = document.createElement("span");
open.className = "site-open";
open.setAttribute("aria-hidden", "true");
open.innerHTML = `
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 17 17 7M8 7h9v9"></path>
</svg>
`;
cardTop.append(avatar, open);
const info = document.createElement("div");
info.className = "site-info";
const name = document.createElement("h3");
name.className = "site-name";
name.textContent = project.name;
const description = document.createElement("span");
description.className = "site-desc";
description.textContent = "本地静态应用";
info.append(name, description);
const footer = document.createElement("div");
footer.className = "site-card-footer";
const path = document.createElement("span");
path.className = "site-path";
path.textContent = project.route_path;
const ready = document.createElement("span");
ready.className = "site-ready";
ready.textContent = "READY";
footer.append(path, ready);
link.append(cardTop, info, footer);
return link;
};
let renderedSignature = JSON.stringify(
Array.from(grid.querySelectorAll(".site-item")).map((link) => [
link.title,
link.getAttribute("href"),
]),
);
const projectsSignature = (projects) =>
JSON.stringify(
projects.map((project) => [project.name, project.route_path]),
);
const renderProjects = (projects) => {
const nextSignature = projectsSignature(projects);
if (nextSignature === renderedSignature) {
return false;
}
grid.replaceChildren();
if (projects.length === 0) {
const emptyState = document.createElement("div");
emptyState.className = "empty-state";
emptyState.innerHTML = `
<span class="empty-visual" aria-hidden="true">
<svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>
<path d="M8 13h8M8 16h5"></path>
</svg>
</span>
<h3>这里还没有项目</h3>
<p>在托管根目录下创建子目录,并放入 <code>index.html</code>,刷新后即可在这里看到它。</p>
`;
grid.append(emptyState);
renderedSignature = nextSignature;
return true;
}
const cards = projects.map(createProjectCard).filter(Boolean);
grid.append(...cards);
renderedSignature = nextSignature;
return true;
};
const updateProjectCounts = (count) => {
projectCounts.forEach((element) => {
element.textContent = String(count);
});
};
refreshButton.addEventListener("click", async () => {
refreshButton.disabled = true;
refreshButton.classList.add("is-loading");
grid.setAttribute("aria-busy", "true");
status.textContent = "正在重新扫描项目...";
try {
const response = await fetch("/api/projects", {
cache: "no-store",
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
if (!Array.isArray(payload.projects)) {
throw new Error("项目列表格式无效");
}
const changed = renderProjects(payload.projects);
updateProjectCounts(payload.projects.length);
status.textContent = changed ? "项目列表已刷新" : "项目列表已是最新";
} catch (error) {
console.error("刷新项目列表失败", error);
status.textContent = "刷新失败,请稍后重试";
} finally {
refreshButton.disabled = false;
refreshButton.classList.remove("is-loading");
grid.removeAttribute("aria-busy");
}
});
if (!("EventSource" in window)) {
liveStatusLabel.textContent = "手动刷新";
return;
}
const eventSource = new EventSource("/api/projects/events");
eventSource.addEventListener("open", () => {
liveStatusLabel.textContent = "实时同步";
liveStatus.classList.add("is-live");
liveStatus.classList.remove("is-reconnecting");
});
eventSource.addEventListener("projects", (event) => {
try {
const payload = JSON.parse(event.data);
if (!Array.isArray(payload.projects)) {
throw new Error("项目列表格式无效");
}
const changed = renderProjects(payload.projects);
updateProjectCounts(payload.projects.length);
if (changed) {
status.textContent = "检测到目录变化,项目列表已自动更新";
}
} catch (error) {
console.error("处理项目更新事件失败", error);
status.textContent = "收到项目更新,但无法解析";
}
});
eventSource.addEventListener("error", () => {
liveStatusLabel.textContent = "同步重连中";
liveStatus.classList.remove("is-live");
liveStatus.classList.add("is-reconnecting");
});
})();
-212
View File
@@ -1,212 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stash 本地应用控制台</title>
<style>
:root {
/* suxing.me 经典轻量博客/导航设计配色体系 */
--bg: #f4f6f9;
--container-bg: #ffffff;
--primary: #ff5c38;
--primary-light: rgba(255, 92, 56, 0.08);
--title-color: #2b2f36;
--desc-color: #8a92a3;
--border-color: #edf2f7;
--card-hover-bg: #f8fafc;
--shadow-sm: 0 2px 12px rgba(0, 0, 0, 0.04);
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.08);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background-color: var(--bg);
color: var(--title-color);
padding: 48px 20px;
display: flex;
justify-content: center;
min-height: 100vh;
}
.wrapper {
max-width: 1080px;
width: 100%;
}
/* 主容器卡片化,呈现图中的圆角纯白面板 */
.panel {
background: var(--container-bg);
border-radius: 16px;
padding: 24px 28px 32px;
box-shadow: var(--shadow-sm);
}
/* 顶部标题区(参考图中的左侧圆环图标+加粗标题) */
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
padding-bottom: 14px;
border-bottom: 1px solid var(--border-color);
}
.header-title-group {
display: flex;
align-items: center;
gap: 10px;
}
.header-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
color: var(--title-color);
}
.panel-header h2 {
font-size: 18px;
font-weight: 700;
color: var(--title-color);
letter-spacing: -0.2px;
}
.panel-action {
display: flex;
align-items: center;
gap: 4px;
color: #b1b9c7;
font-size: 18px;
cursor: pointer;
user-select: none;
}
/* 4列自适应网格,复刻参考图结构 */
.site-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px 24px;
}
@media (max-width: 992px) {
.site-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 560px) {
.site-grid {
grid-template-columns: 1fr;
}
}
/* 每一个站点项目的水平卡片 */
.site-item {
display: flex;
align-items: center;
text-decoration: none;
padding: 10px 12px;
border-radius: 12px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
background: transparent;
}
.site-item:hover {
background: var(--card-hover-bg);
transform: translateY(-2px);
}
.site-item:hover .site-name {
color: var(--primary);
}
/* 圆形小头像/图标 */
.site-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
background: var(--primary-light);
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
font-size: 17px;
font-weight: 700;
flex-shrink: 0;
margin-right: 12px;
box-shadow: 0 2px 6px rgba(255, 92, 56, 0.12);
text-transform: uppercase;
}
.site-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.site-name {
font-size: 15px;
font-weight: 600;
color: var(--title-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 3px;
transition: color 0.15s ease;
}
.site-desc {
font-size: 12px;
color: var(--desc-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: inherit;
}
/* 空状态占位 */
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 60px 0;
color: var(--desc-color);
font-size: 14px;
}
</style>
</head>
<body>
<div class="wrapper">
<div class="panel">
<div class="panel-header">
<div class="header-title-group">
<span class="header-icon">
<!-- 类似参考图中的网球/球体圆环图标 -->
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"></path>
<path d="M2 12h20"></path>
</svg>
</span>
<h2>本地前端应用</h2>
</div>
<div class="panel-action">•••</div>
</div>
<div class="site-grid">
__CARDS__
</div>
</div>
</div>
</body>
</html>
+7 -1
View File
@@ -1,7 +1,7 @@
use axum::{ use axum::{
Json,
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Json,
}; };
use serde_json::json; use serde_json::json;
use thiserror::Error; use thiserror::Error;
@@ -11,6 +11,12 @@ pub enum StashError {
#[error("IO 错误: {0}")] #[error("IO 错误: {0}")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[error("模板渲染失败: {0}")]
Template(#[from] askama::Error),
#[error("文件监听失败: {0}")]
Watch(#[from] notify::Error),
#[error("目录不存在或非法: {0}")] #[error("目录不存在或非法: {0}")]
InvalidDirectory(String), InvalidDirectory(String),
+3 -2
View File
@@ -2,8 +2,9 @@ pub mod config;
pub mod error; pub mod error;
pub mod listing; pub mod listing;
pub mod server; pub mod server;
mod watcher;
pub use config::Config; pub use config::Config;
pub use error::StashError; pub use error::StashError;
pub use listing::{scan_projects, AppProject}; pub use listing::{AppProject, scan_projects};
pub use server::{start, ServerHandle}; pub use server::{ServerHandle, start};
+195 -33
View File
@@ -1,15 +1,38 @@
use askama::Template;
use serde::Serialize; use serde::Serialize;
use std::path::Path; use std::{fmt::Write as _, path::Path};
const INDEX_TEMPLATE: &str = include_str!("../assets/index.html"); use crate::error::StashError;
const NOT_FOUND_TEMPLATE: &str = include_str!("../assets/404.html");
#[derive(Debug, Serialize, Clone)] #[derive(Debug, Serialize, Clone, PartialEq, Eq)]
pub struct AppProject { pub struct AppProject {
pub name: String, pub name: String,
pub route_path: String, pub route_path: String,
} }
impl AppProject {
pub fn initial(&self) -> String {
self.name
.chars()
.next()
.unwrap_or('A')
.to_uppercase()
.to_string()
}
}
#[derive(Template)]
#[template(path = "index.html")]
struct DashboardTemplate<'a> {
projects: &'a [AppProject],
}
#[derive(Template)]
#[template(path = "404.html")]
struct NotFoundTemplate<'a> {
path: &'a str,
}
pub fn scan_projects(root: &Path) -> Vec<AppProject> { pub fn scan_projects(root: &Path) -> Vec<AppProject> {
let mut projects = Vec::new(); let mut projects = Vec::new();
@@ -28,48 +51,187 @@ pub fn scan_projects(root: &Path) -> Vec<AppProject> {
if path.is_dir() { if path.is_dir() {
let has_index = path.join("index.html").exists() || path.join("index.htm").exists(); let has_index = path.join("index.html").exists() || path.join("index.htm").exists();
if has_index { if has_index {
let route_path = format!("/{}/", name); let route_path = format!("/{}/", encode_path_segment(&name));
projects.push(AppProject { name, route_path }); projects.push(AppProject { name, route_path });
} }
} }
} }
projects.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); projects.sort_by(|a, b| {
a.name
.to_lowercase()
.cmp(&b.name.to_lowercase())
.then_with(|| a.name.cmp(&b.name))
});
projects projects
} }
pub fn render_dashboard(projects: &[AppProject]) -> String { pub fn render_dashboard(projects: &[AppProject]) -> Result<String, StashError> {
let mut cards_html = String::new(); Ok(DashboardTemplate { projects }.render()?)
}
for proj in projects { pub fn render_not_found(path: &str) -> Result<String, StashError> {
// 提取首字符作为圆形头像字母 Ok(NotFoundTemplate { path }.render()?)
let initial = proj.name.chars().next().unwrap_or('A').to_uppercase().to_string(); }
cards_html.push_str(&format!( fn encode_path_segment(segment: &str) -> String {
r#" let mut encoded = String::with_capacity(segment.len());
<a class="site-item" href="{url}" title="{name}">
<div class="site-avatar">{initial}</div> for byte in segment.bytes() {
<div class="site-info"> if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
<span class="site-name">{name}</span> encoded.push(char::from(byte));
<span class="site-desc">本地托管应用 • {url}</span> } else {
</div> write!(&mut encoded, "%{byte:02X}").expect("写入字符串不会失败");
</a> }
"#,
url = proj.route_path,
name = proj.name,
initial = initial
));
} }
let final_cards = if cards_html.is_empty() { encoded
r#"<div class="empty-state">当前目录下暂无含有 index.html 的前端项目</div>"#.to_string() }
} else {
cards_html #[cfg(test)]
mod tests {
use super::*;
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicU64, Ordering},
}; };
INDEX_TEMPLATE.replace("__CARDS__", &final_cards) static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
}
pub fn render_not_found(path: &str) -> String { struct TestDir {
NOT_FOUND_TEMPLATE.replace("__PATH__", path) path: PathBuf,
}
impl TestDir {
fn new() -> Self {
let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("stash-core-test-{}-{id}", std::process::id()));
fs::create_dir_all(&path).expect("应能创建测试目录");
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
fn add_project(&self, name: &str, index_file: &str) {
let project = self.path.join(name);
fs::create_dir_all(&project).expect("应能创建项目目录");
fs::write(project.join(index_file), "<!doctype html>").expect("应能创建入口文件");
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
fn contains_escaped_less_than(html: &str) -> bool {
["&lt;", "&#60;", "&#x3C;", "&#x3c;"]
.iter()
.any(|entity| html.contains(entity))
}
fn contains_escaped_ampersand(html: &str) -> bool {
["&amp;", "&#38;", "&#x26;", "&#X26;"]
.iter()
.any(|entity| html.contains(entity))
}
#[test]
fn scan_projects_detects_only_immediate_visible_projects() {
let root = TestDir::new();
root.add_project("with-html", "index.html");
root.add_project("with-htm", "index.htm");
root.add_project(".hidden", "index.html");
root.add_project("no-index", "readme.txt");
root.add_project("parent/child", "index.html");
fs::create_dir_all(root.path().join("plain-directory")).expect("应能创建普通目录");
let names: Vec<_> = scan_projects(root.path())
.into_iter()
.map(|project| project.name)
.collect();
assert_eq!(names, vec!["with-htm", "with-html"]);
}
#[test]
fn scan_projects_sorts_names_case_insensitively() {
let root = TestDir::new();
root.add_project("zeta", "index.html");
root.add_project("ALPHA", "index.html");
root.add_project("middle", "index.html");
let names: Vec<_> = scan_projects(root.path())
.into_iter()
.map(|project| project.name)
.collect();
assert_eq!(names, vec!["ALPHA", "middle", "zeta"]);
}
#[test]
fn scan_projects_url_encodes_route_segments() {
let root = TestDir::new();
root.add_project("space name", "index.html");
root.add_project("中文", "index.html");
let projects = scan_projects(root.path());
assert_eq!(projects[0].name, "space name");
assert_eq!(projects[0].route_path, "/space%20name/");
assert_eq!(projects[1].name, "中文");
assert_eq!(projects[1].route_path, "/%E4%B8%AD%E6%96%87/");
}
#[test]
fn path_segment_encoding_preserves_unreserved_characters() {
assert_eq!(encode_path_segment("aZ09-_.~/?#%"), "aZ09-_.~%2F%3F%23%25");
}
#[test]
fn dashboard_renders_empty_state() {
let html = render_dashboard(&[]).expect("空项目列表应能正常渲染");
assert!(html.contains("这里还没有项目"));
assert!(html.contains("<code>index.html</code>"));
assert!(!html.contains("class=\"site-item\""));
}
#[test]
fn dashboard_escapes_project_content() {
let projects = vec![AppProject {
name: "<项目 & \"测试\">".to_string(),
route_path: "/safe%20path/".to_string(),
}];
let html = render_dashboard(&projects).expect("项目卡片应能正常渲染");
assert!(
contains_escaped_less_than(&html),
"项目名称中的 < 应被 HTML 转义,实际输出: {html}"
);
assert!(
contains_escaped_ampersand(&html),
"项目名称中的 & 应被 HTML 转义,实际输出: {html}"
);
assert!(!html.contains("<项目 & \"测试\">"));
}
#[test]
fn not_found_page_renders_and_escapes_request_path() {
let html =
render_not_found("/missing/<script>alert(1)</script>").expect("404 页面应能正常渲染");
assert!(html.contains("资源或页面不存在"));
assert!(
contains_escaped_less_than(&html),
"请求路径中的 < 应被 HTML 转义,实际输出: {html}"
);
assert!(!html.contains("<script>alert(1)</script>"));
}
} }
+90 -17
View File
@@ -1,27 +1,32 @@
use std::net::SocketAddr;
use std::sync::Arc;
use axum::{ use axum::{
Json, Router,
extract::{OriginalUri, State}, extract::{OriginalUri, State},
http::{header, StatusCode}, http::{StatusCode, header},
response::sse::{Event, KeepAlive, Sse},
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
routing::get, routing::get,
Json, Router,
}; };
use std::{convert::Infallible, net::SocketAddr, sync::Arc, time::Duration};
use tokio::sync::{broadcast, oneshot};
use tower_http::{ use tower_http::{
cors::{Any, CorsLayer}, cors::{Any, CorsLayer},
services::ServeDir, services::ServeDir,
trace::TraceLayer, trace::TraceLayer,
}; };
use tokio::sync::oneshot;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::config::Config; use crate::config::Config;
use crate::error::StashError; use crate::error::StashError;
use crate::listing::{render_dashboard, render_not_found, scan_projects}; use crate::listing::{AppProject, render_dashboard, render_not_found, scan_projects};
use crate::watcher;
const DASHBOARD_CSS: &str = include_str!("../assets/dashboard.css");
const DASHBOARD_JS: &str = include_str!("../assets/dashboard.js");
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub config: Arc<Config>, pub config: Arc<Config>,
pub project_updates: broadcast::Sender<Vec<AppProject>>,
} }
pub struct ServerHandle { pub struct ServerHandle {
@@ -42,19 +47,39 @@ async fn root_handler(State(state): State<AppState>) -> Result<Response, StashEr
// 1. 如果托管目录本身就是一个完整的前端项目(含 index.html),优先直接展示 // 1. 如果托管目录本身就是一个完整的前端项目(含 index.html),优先直接展示
if index_file.exists() { if index_file.exists() {
let content = tokio::fs::read_to_string(&index_file).await?; let content = tokio::fs::read_to_string(&index_file).await?;
return Ok(([(header::CONTENT_TYPE, "text/html; charset=utf-8")], Html(content)).into_response()); return Ok((
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
Html(content),
)
.into_response());
} }
// 2. 否则展示筛选后的可用前端应用大厅 // 2. 否则展示筛选后的可用前端应用大厅
let projects = scan_projects(&state.config.root); let projects = scan_projects(&state.config.root);
let html = render_dashboard(&projects); let html = render_dashboard(&projects)?;
Ok(Html(html).into_response()) Ok(Html(html).into_response())
} }
/// 全局 404 自定义页面处理器 /// 全局 404 自定义页面处理器
async fn not_found_handler(OriginalUri(uri): OriginalUri) -> impl IntoResponse { async fn not_found_handler(
let html = render_not_found(&uri.path()); OriginalUri(uri): OriginalUri,
(StatusCode::NOT_FOUND, Html(html)) ) -> Result<(StatusCode, Html<String>), StashError> {
let html = render_not_found(uri.path())?;
Ok((StatusCode::NOT_FOUND, Html(html)))
}
async fn dashboard_css() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "text/css; charset=utf-8")],
DASHBOARD_CSS,
)
}
async fn dashboard_js() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "text/javascript; charset=utf-8")],
DASHBOARD_JS,
)
} }
/// API: 健康检查 /// API: 健康检查
@@ -73,6 +98,46 @@ async fn list_projects_api(State(state): State<AppState>) -> impl IntoResponse {
})) }))
} }
/// SSE:先发送当前快照,再持续推送文件监听产生的项目列表变化。
async fn project_events(State(state): State<AppState>) -> impl IntoResponse {
let root = state.config.root.clone();
let initial_projects = scan_projects(&root);
let mut updates = state.project_updates.subscribe();
let stream = async_stream::stream! {
yield Ok::<Event, Infallible>(project_event(initial_projects));
loop {
match updates.recv().await {
Ok(projects) => yield Ok(project_event(projects)),
Err(broadcast::error::RecvError::Lagged(skipped)) => {
warn!("项目事件订阅落后 {skipped} 条,正在重新扫描");
yield Ok(project_event(scan_projects(&root)));
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
};
Sse::new(stream).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(15))
.text("keep-alive"),
)
}
fn project_event(projects: Vec<AppProject>) -> Event {
Event::default()
.event("projects")
.json_data(serde_json::json!({ "projects": projects }))
.unwrap_or_else(|_| {
warn!("序列化项目事件失败,将发送空项目列表");
Event::default()
.event("projects")
.data(r#"{"projects":[]}"#)
})
}
pub async fn start(config: Config) -> Result<ServerHandle, StashError> { pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
if !config.root.exists() { if !config.root.exists() {
return Err(StashError::InvalidDirectory( return Err(StashError::InvalidDirectory(
@@ -80,18 +145,27 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
)); ));
} }
let (project_updates, _) = broadcast::channel(32);
watcher::start(config.root.clone(), project_updates.clone())?;
let shared_state = AppState { let shared_state = AppState {
config: Arc::new(config.clone()), config: Arc::new(config.clone()),
project_updates,
}; };
// 静态文件服务:找不到静态资源时 fallback 到自定义 not_found_handler // 静态文件服务:找不到静态资源时 fallback 到自定义 not_found_handler
let serve_dir = ServeDir::new(&config.root) let serve_dir = ServeDir::new(&config.root)
.append_index_html_on_directories(true) .append_index_html_on_directories(true)
.fallback(axum::handler::HandlerWithoutStateExt::into_service(not_found_handler)); .fallback(axum::handler::HandlerWithoutStateExt::into_service(
not_found_handler,
));
let mut app = Router::new() let mut app = Router::new()
.route("/api/health", get(health_check)) .route("/api/health", get(health_check))
.route("/api/projects", get(list_projects_api)) .route("/api/projects", get(list_projects_api))
.route("/api/projects/events", get(project_events))
.route("/__stash__/dashboard.css", get(dashboard_css))
.route("/__stash__/dashboard.js", get(dashboard_js))
.route("/", get(root_handler)) .route("/", get(root_handler))
.fallback_service(serve_dir) .fallback_service(serve_dir)
.with_state(shared_state) .with_state(shared_state)
@@ -117,11 +191,10 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
tokio::spawn(async move { tokio::spawn(async move {
let server = axum::serve(listener, app) let server = axum::serve(listener, app).with_graceful_shutdown(async move {
.with_graceful_shutdown(async move { let _ = shutdown_rx.await;
let _ = shutdown_rx.await; info!("Stash 优雅停机...");
info!("Stash 优雅停机..."); });
});
if let Err(err) = server.await { if let Err(err) = server.await {
warn!("服务异常退出: {}", err); warn!("服务异常退出: {}", err);
+122
View File
@@ -0,0 +1,122 @@
use crate::{AppProject, error::StashError, scan_projects};
use notify::{Event, EventKind, RecursiveMode, Watcher};
use std::{path::PathBuf, time::Duration};
use tokio::sync::{broadcast, mpsc};
use tracing::warn;
const EVENT_DEBOUNCE: Duration = Duration::from_millis(150);
pub fn start(root: PathBuf, updates: broadcast::Sender<Vec<AppProject>>) -> Result<(), StashError> {
let mut previous_projects = scan_projects(&root);
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
let mut watcher = notify::recommended_watcher(move |result| {
let _ = event_tx.send(result);
})?;
watcher.watch(&root, RecursiveMode::Recursive)?;
tokio::spawn(async move {
let _watcher = watcher;
while let Some(event) = event_rx.recv().await {
if !should_rescan(&event) {
continue;
}
// Editors and build tools often emit a burst of file events for one change.
tokio::time::sleep(EVENT_DEBOUNCE).await;
while event_rx.try_recv().is_ok() {}
let projects = scan_projects(&root);
if projects == previous_projects {
continue;
}
previous_projects = projects.clone();
let _ = updates.send(projects);
}
});
Ok(())
}
fn should_rescan(event: &notify::Result<Event>) -> bool {
match event {
Ok(event) => !matches!(event.kind, EventKind::Access(_)),
Err(error) => {
warn!("文件监听事件出错,将重新扫描项目: {error}");
true
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use notify::{
Event,
event::{AccessKind, CreateKind, EventKind},
};
use std::{
fs,
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
time::Duration,
};
static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
struct TestDir {
path: PathBuf,
}
impl TestDir {
fn new() -> Self {
let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir()
.join(format!("stash-watcher-test-{}-{id}", std::process::id()));
fs::create_dir_all(&path).expect("应能创建测试目录");
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
#[test]
fn access_events_do_not_trigger_project_scans() {
let access = Event::new(EventKind::Access(AccessKind::Read));
let create = Event::new(EventKind::Create(CreateKind::Folder));
assert!(!should_rescan(&Ok(access)));
assert!(should_rescan(&Ok(create)));
}
#[tokio::test]
async fn broadcasts_when_a_project_is_added() {
let root = TestDir::new();
let (updates, _) = broadcast::channel(8);
let mut receiver = updates.subscribe();
start(root.path().to_path_buf(), updates).expect("应能启动文件监听");
let project = root.path().join("new-project");
fs::create_dir_all(&project).expect("应能创建项目目录");
fs::write(project.join("index.html"), "<!doctype html>").expect("应能创建入口文件");
let projects = tokio::time::timeout(Duration::from_secs(5), receiver.recv())
.await
.expect("应在超时前收到项目更新")
.expect("项目更新通道不应关闭");
assert_eq!(projects.len(), 1);
assert_eq!(projects[0].name, "new-project");
assert_eq!(projects[0].route_path, "/new-project/");
}
}
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - 资源未找到</title>
<link rel="stylesheet" href="/__stash__/dashboard.css">
</head>
<body class="error-page">
<main class="error-box">
<div class="error-code">404</div>
<h1>资源或页面不存在</h1>
<p>请求的路径 <code>{{ path }}</code> 不存在或缺少 index.html</p>
<a href="/" class="button">返回应用大厅</a>
</main>
</body>
</html>
+120
View File
@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light">
<meta name="theme-color" content="#f7f8fa">
<title>Stash · 本地应用工作区</title>
<link rel="stylesheet" href="/__stash__/dashboard.css">
<script src="/__stash__/dashboard.js" defer></script>
</head>
<body class="dashboard-page">
<div class="ambient ambient-blue" aria-hidden="true"></div>
<div class="ambient ambient-warm" aria-hidden="true"></div>
<div class="workspace">
<aside class="sidebar">
<header class="sidebar-header">
<a class="brand" href="/" aria-label="Stash 首页">
<span class="brand-mark" aria-hidden="true">
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="2"></rect>
<rect x="14" y="3" width="7" height="7" rx="2"></rect>
<rect x="3" y="14" width="7" height="7" rx="2"></rect>
<path d="M17.5 14v7M14 17.5h7"></path>
</svg>
</span>
<span class="brand-name">stash<span>.</span></span>
</a>
<span class="local-badge">LOCAL</span>
</header>
<div class="sidebar-intro">
<p class="eyebrow"><span class="eyebrow-dot"></span>YOUR WORKSPACE</p>
<h1>把项目,<br>放在手边。</h1>
<p class="sidebar-lead">Stash 正在整理当前目录下的静态前端应用,让每次预览都从一个清爽入口开始。</p>
</div>
<section class="overview-card" aria-label="工作区概览">
<div class="overview-label">项目概览</div>
<div class="project-total">
<strong data-project-count>{{ projects.len() }}</strong>
<span>个应用</span>
</div>
<div class="service-status">
<span class="status-dot"></span>
<span>本地服务运行中</span>
</div>
</section>
<div class="sidebar-actions">
<button class="refresh-button js-only" id="refresh-projects" type="button" aria-label="重新扫描项目">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 11a8.1 8.1 0 0 0-15.5-2M4 4v5h5"></path>
<path d="M4 13a8.1 8.1 0 0 0 15.5 2M20 20v-5h-5"></path>
</svg>
<span>重新扫描</span>
</button>
<p class="sidebar-hint">目录变化会自动同步,也可以手动重新扫描。</p>
</div>
<footer class="sidebar-footer">
<span>STATIC HOSTING</span>
<span class="footer-ready"><i></i>READY</span>
</footer>
</aside>
<main class="content">
<header class="content-header">
<div class="content-heading">
<p class="eyebrow">PROJECT LIBRARY</p>
<h2>本地应用</h2>
<p>一个入口,快速抵达每个正在开发或预览的页面。</p>
</div>
<div class="content-meta">
<span class="live-pill" id="live-status"><i></i><span id="live-status-label">服务在线</span></span>
<span class="count-pill"><strong data-project-count>{{ projects.len() }}</strong> 个项目</span>
</div>
</header>
<section class="site-grid" id="project-grid" aria-live="polite">
{% if projects.is_empty() %}
<div class="empty-state">
<span class="empty-visual" aria-hidden="true">
<svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>
<path d="M8 13h8M8 16h5"></path>
</svg>
</span>
<h3>这里还没有项目</h3>
<p>在托管根目录下创建子目录,并放入 <code>index.html</code>,刷新后即可在这里看到它。</p>
</div>
{% else %}
{% for project in projects %}
<a class="site-item" href="{{ project.route_path }}" title="{{ project.name }}">
<div class="site-card-top">
<span class="site-avatar" aria-hidden="true">{{ project.initial() }}</span>
<span class="site-open" aria-hidden="true">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 17 17 7M8 7h9v9"></path>
</svg>
</span>
</div>
<div class="site-info">
<h3 class="site-name">{{ project.name }}</h3>
<span class="site-desc">本地静态应用</span>
</div>
<div class="site-card-footer">
<span class="site-path">{{ project.route_path }}</span>
<span class="site-ready">READY</span>
</div>
</a>
{% endfor %}
{% endif %}
</section>
<p class="refresh-status" id="refresh-status" role="status" aria-live="polite"></p>
</main>
</div>
</body>
</html>