feat: 重构导航页并增加项目实时推送
This commit is contained in:
Generated
+251
-9
@@ -47,7 +47,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -58,7 +58,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -67,6 +67,70 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -125,6 +189,15 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "basic-toml"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
@@ -195,7 +268,7 @@ version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -205,7 +278,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -217,6 +290,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
@@ -338,6 +420,26 @@ dependencies = [
|
||||
"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]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -350,6 +452,26 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.1"
|
||||
@@ -421,8 +543,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"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]]
|
||||
@@ -431,7 +581,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -545,6 +695,12 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
@@ -670,7 +826,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -691,8 +847,11 @@ name = "stash-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"askama",
|
||||
"async-stream",
|
||||
"axum",
|
||||
"mime_guess",
|
||||
"notify",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
@@ -780,7 +939,7 @@ dependencies = [
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -984,7 +1143,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -993,6 +1152,15 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -1002,6 +1170,80 @@ dependencies = [
|
||||
"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]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
|
||||
@@ -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` 暴露到局域网前,应确认目录内容适合被其他设备访问。
|
||||
@@ -18,3 +18,6 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
walkdir = "2.4"
|
||||
mime_guess = "2.0"
|
||||
askama = "0.14"
|
||||
async-stream = "0.3"
|
||||
notify = "8"
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -1,7 +1,7 @@
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
@@ -11,6 +11,12 @@ pub enum StashError {
|
||||
#[error("IO 错误: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("模板渲染失败: {0}")]
|
||||
Template(#[from] askama::Error),
|
||||
|
||||
#[error("文件监听失败: {0}")]
|
||||
Watch(#[from] notify::Error),
|
||||
|
||||
#[error("目录不存在或非法: {0}")]
|
||||
InvalidDirectory(String),
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ pub mod config;
|
||||
pub mod error;
|
||||
pub mod listing;
|
||||
pub mod server;
|
||||
mod watcher;
|
||||
|
||||
pub use config::Config;
|
||||
pub use error::StashError;
|
||||
pub use listing::{scan_projects, AppProject};
|
||||
pub use server::{start, ServerHandle};
|
||||
pub use listing::{AppProject, scan_projects};
|
||||
pub use server::{ServerHandle, start};
|
||||
|
||||
+195
-33
@@ -1,15 +1,38 @@
|
||||
use askama::Template;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::{fmt::Write as _, path::Path};
|
||||
|
||||
const INDEX_TEMPLATE: &str = include_str!("../assets/index.html");
|
||||
const NOT_FOUND_TEMPLATE: &str = include_str!("../assets/404.html");
|
||||
use crate::error::StashError;
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
|
||||
pub struct AppProject {
|
||||
pub name: 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> {
|
||||
let mut projects = Vec::new();
|
||||
|
||||
@@ -28,48 +51,187 @@ pub fn scan_projects(root: &Path) -> Vec<AppProject> {
|
||||
if path.is_dir() {
|
||||
let has_index = path.join("index.html").exists() || path.join("index.htm").exists();
|
||||
if has_index {
|
||||
let route_path = format!("/{}/", name);
|
||||
let route_path = format!("/{}/", encode_path_segment(&name));
|
||||
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
|
||||
}
|
||||
|
||||
pub fn render_dashboard(projects: &[AppProject]) -> String {
|
||||
let mut cards_html = String::new();
|
||||
pub fn render_dashboard(projects: &[AppProject]) -> Result<String, StashError> {
|
||||
Ok(DashboardTemplate { projects }.render()?)
|
||||
}
|
||||
|
||||
for proj in projects {
|
||||
// 提取首字符作为圆形头像字母
|
||||
let initial = proj.name.chars().next().unwrap_or('A').to_uppercase().to_string();
|
||||
pub fn render_not_found(path: &str) -> Result<String, StashError> {
|
||||
Ok(NotFoundTemplate { path }.render()?)
|
||||
}
|
||||
|
||||
cards_html.push_str(&format!(
|
||||
r#"
|
||||
<a class="site-item" href="{url}" title="{name}">
|
||||
<div class="site-avatar">{initial}</div>
|
||||
<div class="site-info">
|
||||
<span class="site-name">{name}</span>
|
||||
<span class="site-desc">本地托管应用 • {url}</span>
|
||||
</div>
|
||||
</a>
|
||||
"#,
|
||||
url = proj.route_path,
|
||||
name = proj.name,
|
||||
initial = initial
|
||||
));
|
||||
fn encode_path_segment(segment: &str) -> String {
|
||||
let mut encoded = String::with_capacity(segment.len());
|
||||
|
||||
for byte in segment.bytes() {
|
||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
|
||||
encoded.push(char::from(byte));
|
||||
} else {
|
||||
write!(&mut encoded, "%{byte:02X}").expect("写入字符串不会失败");
|
||||
}
|
||||
}
|
||||
|
||||
let final_cards = if cards_html.is_empty() {
|
||||
r#"<div class="empty-state">当前目录下暂无含有 index.html 的前端项目</div>"#.to_string()
|
||||
} else {
|
||||
cards_html
|
||||
encoded
|
||||
}
|
||||
|
||||
#[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 {
|
||||
NOT_FOUND_TEMPLATE.replace("__PATH__", path)
|
||||
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-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 {
|
||||
["<", "<", "<", "<"]
|
||||
.iter()
|
||||
.any(|entity| html.contains(entity))
|
||||
}
|
||||
|
||||
fn contains_escaped_ampersand(html: &str) -> bool {
|
||||
["&", "&", "&", "&"]
|
||||
.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
@@ -1,27 +1,32 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{OriginalUri, State},
|
||||
http::{header, StatusCode},
|
||||
http::{StatusCode, header},
|
||||
response::sse::{Event, KeepAlive, Sse},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use std::{convert::Infallible, net::SocketAddr, sync::Arc, time::Duration};
|
||||
use tokio::sync::{broadcast, oneshot};
|
||||
use tower_http::{
|
||||
cors::{Any, CorsLayer},
|
||||
services::ServeDir,
|
||||
trace::TraceLayer,
|
||||
};
|
||||
use tokio::sync::oneshot;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
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)]
|
||||
pub struct AppState {
|
||||
pub config: Arc<Config>,
|
||||
pub project_updates: broadcast::Sender<Vec<AppProject>>,
|
||||
}
|
||||
|
||||
pub struct ServerHandle {
|
||||
@@ -42,19 +47,39 @@ async fn root_handler(State(state): State<AppState>) -> Result<Response, StashEr
|
||||
// 1. 如果托管目录本身就是一个完整的前端项目(含 index.html),优先直接展示
|
||||
if index_file.exists() {
|
||||
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. 否则展示筛选后的可用前端应用大厅
|
||||
let projects = scan_projects(&state.config.root);
|
||||
let html = render_dashboard(&projects);
|
||||
let html = render_dashboard(&projects)?;
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
/// 全局 404 自定义页面处理器
|
||||
async fn not_found_handler(OriginalUri(uri): OriginalUri) -> impl IntoResponse {
|
||||
let html = render_not_found(&uri.path());
|
||||
(StatusCode::NOT_FOUND, Html(html))
|
||||
async fn not_found_handler(
|
||||
OriginalUri(uri): OriginalUri,
|
||||
) -> 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: 健康检查
|
||||
@@ -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> {
|
||||
if !config.root.exists() {
|
||||
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 {
|
||||
config: Arc::new(config.clone()),
|
||||
project_updates,
|
||||
};
|
||||
|
||||
// 静态文件服务:找不到静态资源时 fallback 到自定义 not_found_handler
|
||||
let serve_dir = ServeDir::new(&config.root)
|
||||
.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()
|
||||
.route("/api/health", get(health_check))
|
||||
.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))
|
||||
.fallback_service(serve_dir)
|
||||
.with_state(shared_state)
|
||||
@@ -117,11 +191,10 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let server = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async move {
|
||||
let _ = shutdown_rx.await;
|
||||
info!("Stash 优雅停机...");
|
||||
});
|
||||
let server = axum::serve(listener, app).with_graceful_shutdown(async move {
|
||||
let _ = shutdown_rx.await;
|
||||
info!("Stash 优雅停机...");
|
||||
});
|
||||
|
||||
if let Err(err) = server.await {
|
||||
warn!("服务异常退出: {}", err);
|
||||
|
||||
@@ -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: ¬ify::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/");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user