diff --git a/Cargo.lock b/Cargo.lock index 4db409c..3f49ee9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..123903d --- /dev/null +++ b/README.md @@ -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 ` | `./public` | 静态页面托管根目录,支持相对路径和绝对路径 | +| `-p, --port ` | `8080` | HTTP 服务监听端口 | +| `--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` 暴露到局域网前,应确认目录内容适合被其他设备访问。 diff --git a/stash-core/Cargo.toml b/stash-core/Cargo.toml index cdcb6db..022b009 100644 --- a/stash-core/Cargo.toml +++ b/stash-core/Cargo.toml @@ -17,4 +17,7 @@ tower = { version = "0.4", features = ["util"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" walkdir = "2.4" -mime_guess = "2.0" \ No newline at end of file +mime_guess = "2.0" +askama = "0.14" +async-stream = "0.3" +notify = "8" diff --git a/stash-core/assets/404.html b/stash-core/assets/404.html deleted file mode 100644 index a5135a8..0000000 --- a/stash-core/assets/404.html +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - 404 - 资源未找到 - - - -
-
404
-

资源或页面不存在

-

请求的路径 __PATH__ 不存在或缺少 index.html

- 返回应用大厅 -
- - \ No newline at end of file diff --git a/stash-core/assets/dashboard.css b/stash-core/assets/dashboard.css new file mode 100644 index 0000000..f7c67c8 --- /dev/null +++ b/stash-core/assets/dashboard.css @@ -0,0 +1,1018 @@ +:root { + --bg: #f7f8fa; + --surface: #ffffff; + --surface-soft: #fbfbfd; + --ink: #1d2129; + --text: #2c343c; + --text-secondary: #4e5969; + --muted: #86909c; + --faint: #c9cdd4; + --border: #eef0f3; + --border-strong: #e6e8ec; + --primary: #3858f6; + --primary-dark: #2947db; + --primary-soft: #eef1ff; + --primary-wash: rgba(56, 88, 246, 0.09); + --warm: #ffba3a; + --warm-soft: #fff7df; + --success: #4abd86; + --danger: #ff3e3e; + --shadow-card: 0 12px 38px rgba(27, 26, 49, 0.045); + --shadow-hover: 0 20px 52px rgba(27, 26, 49, 0.09); + --radius-sm: 10px; + --radius-md: 16px; + --radius-lg: 24px; + --ease: cubic-bezier(0.22, 1, 0.36, 1); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + min-width: 320px; + background: var(--bg); +} + +body { + position: relative; + min-height: 100vh; + overflow-x: hidden; + background: + radial-gradient(circle at -5% 0%, rgba(255, 186, 58, 0.12), transparent 28rem), + radial-gradient(circle at 104% 7%, rgba(56, 88, 246, 0.1), transparent 34rem), + var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +a { + color: inherit; +} + +button, +input { + font: inherit; +} + +.ambient { + position: fixed; + z-index: -1; + border-radius: 999px; + pointer-events: none; + filter: blur(1px); +} + +.ambient-blue { + top: -120px; + right: 8%; + width: 360px; + height: 360px; + border: 1px solid rgba(56, 88, 246, 0.07); + background: rgba(56, 88, 246, 0.035); +} + +.ambient-warm { + bottom: -170px; + left: -130px; + width: 320px; + height: 320px; + background: rgba(255, 186, 58, 0.045); +} + +.workspace { + display: grid; + grid-template-columns: 286px minmax(0, 1fr); + gap: 28px; + width: min(1320px, calc(100% - 64px)); + min-width: 0; + margin: 0 auto; + padding: 32px 0; +} + +.sidebar { + position: sticky; + top: 32px; + display: flex; + flex-direction: column; + align-self: start; + min-width: 0; + min-height: calc(100vh - 64px); + padding: 26px; + border: 1px solid rgba(255, 255, 255, 0.9); + border-radius: var(--radius-lg); + background: rgba(255, 255, 255, 0.96); + box-shadow: var(--shadow-card); + backdrop-filter: blur(16px); +} + +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 10px; + color: var(--ink); + text-decoration: none; +} + +.brand-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border-radius: 12px; + background: var(--ink); + color: #ffffff; + box-shadow: 0 8px 20px rgba(29, 33, 41, 0.16); +} + +.brand-name { + font-size: 23px; + font-weight: 750; + letter-spacing: -1.1px; +} + +.brand-name span { + color: var(--primary); +} + +.local-badge { + padding: 6px 9px; + border-radius: 999px; + background: var(--primary-soft); + color: var(--primary); + font-size: 9px; + font-weight: 800; + letter-spacing: 1.4px; +} + +.sidebar-intro { + margin-top: 58px; +} + +.eyebrow { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 10px; + font-weight: 750; + letter-spacing: 1.8px; + line-height: 1.4; + text-transform: uppercase; +} + +.eyebrow-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--warm); + box-shadow: 0 0 0 4px var(--warm-soft); +} + +.sidebar-intro h1 { + margin-top: 16px; + color: var(--ink); + font-size: clamp(34px, 3vw, 42px); + font-weight: 760; + letter-spacing: -2px; + line-height: 1.16; +} + +.sidebar-lead { + margin-top: 18px; + color: var(--muted); + font-size: 13px; + line-height: 1.85; +} + +.overview-card { + min-width: 0; + margin-top: 34px; + padding: 19px 20px 18px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: + radial-gradient(circle at 100% 0%, rgba(56, 88, 246, 0.09), transparent 9rem), + var(--surface-soft); +} + +.overview-label { + color: var(--muted); + font-size: 11px; + font-weight: 600; +} + +.project-total { + display: flex; + align-items: baseline; + gap: 8px; + margin-top: 8px; +} + +.project-total strong { + color: var(--primary); + font-size: 52px; + font-weight: 760; + letter-spacing: -3px; + line-height: 1; +} + +.project-total span { + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; +} + +.service-status { + display: flex; + align-items: center; + gap: 9px; + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid var(--border); + color: var(--text-secondary); + font-size: 11px; +} + +.status-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--success); + box-shadow: 0 0 0 5px rgba(74, 189, 134, 0.12); +} + +.sidebar-actions { + margin-top: 18px; +} + +.refresh-button { + align-items: center; + justify-content: center; + gap: 9px; + width: 100%; + padding: 12px 16px; + border: 0; + border-radius: 12px; + background: var(--primary); + box-shadow: 0 9px 20px rgba(56, 88, 246, 0.2); + color: #ffffff; + font-size: 12px; + font-weight: 700; + cursor: pointer; + transition: background-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s var(--ease); +} + +.refresh-button:hover { + background: var(--primary-dark); + box-shadow: 0 12px 24px rgba(56, 88, 246, 0.26); + transform: translateY(-1px); +} + +.refresh-button:focus-visible { + outline: 3px solid rgba(56, 88, 246, 0.24); + outline-offset: 3px; +} + +.refresh-button:disabled { + cursor: wait; + opacity: 0.7; + transform: none; +} + +.refresh-button.is-loading svg { + animation: spin 0.8s linear infinite; +} + +.js-only { + display: none; +} + +.is-enhanced .js-only { + display: flex; +} + +.sidebar-hint { + margin-top: 12px; + color: var(--muted); + font-size: 10px; + line-height: 1.65; + text-align: center; +} + +.sidebar-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: auto; + padding-top: 22px; + border-top: 1px solid var(--border); + color: var(--faint); + font-size: 8px; + font-weight: 750; + letter-spacing: 1.3px; +} + +.footer-ready { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-secondary); +} + +.footer-ready i { + display: inline-block; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--success); +} + +.content { + min-width: 0; + padding: 13px 2px 28px; +} + +.content-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 28px; + margin: 0 4px 28px; +} + +.content-heading h2 { + margin-top: 9px; + color: var(--ink); + font-size: clamp(29px, 3vw, 36px); + font-weight: 760; + letter-spacing: -1.6px; + line-height: 1.15; +} + +.content-heading > p:last-child { + margin-top: 9px; + color: var(--muted); + font-size: 12px; + line-height: 1.7; +} + +.content-meta { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + padding-bottom: 3px; +} + +.live-pill, +.count-pill { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 32px; + padding: 7px 12px; + border: 1px solid rgba(255, 255, 255, 0.9); + border-radius: 999px; + background: rgba(255, 255, 255, 0.84); + box-shadow: 0 4px 16px rgba(27, 26, 49, 0.035); + color: var(--text-secondary); + font-size: 10px; + font-weight: 650; + backdrop-filter: blur(10px); +} + +.live-pill i { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--success); + box-shadow: 0 0 0 4px rgba(74, 189, 134, 0.11); +} + +.live-pill.is-live i { + animation: live-pulse 2.2s ease-out infinite; +} + +.live-pill.is-reconnecting i { + background: var(--warm); + box-shadow: 0 0 0 4px rgba(255, 186, 58, 0.13); + animation: none; +} + +.count-pill strong { + color: var(--primary); + font-size: 13px; + font-weight: 800; +} + +.site-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(255px, 1fr)); + gap: 18px; +} + +.site-item { + position: relative; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 212px; + overflow: hidden; + padding: 20px; + border: 1px solid rgba(255, 255, 255, 0.94); + border-radius: 18px; + background: rgba(255, 255, 255, 0.96); + box-shadow: var(--shadow-card); + text-decoration: none; + opacity: 0; + transform: translateY(10px); + animation: card-arrive 0.55s var(--ease) forwards; + transition: border-color 0.25s ease, box-shadow 0.25s ease, transform 0.25s var(--ease); +} + +.site-item::before { + position: absolute; + top: 0; + right: 24px; + left: 24px; + height: 2px; + border-radius: 0 0 4px 4px; + background: linear-gradient(90deg, transparent, var(--primary), transparent); + content: ""; + opacity: 0; + transition: opacity 0.25s ease; +} + +.site-item:hover { + border-color: rgba(56, 88, 246, 0.16); + box-shadow: var(--shadow-hover); + transform: translateY(-4px); +} + +.site-item:hover::before { + opacity: 1; +} + +.site-item:focus-visible { + outline: 3px solid rgba(56, 88, 246, 0.22); + outline-offset: 3px; +} + +.site-item:nth-child(2) { + animation-delay: 45ms; +} + +.site-item:nth-child(3) { + animation-delay: 90ms; +} + +.site-item:nth-child(4) { + animation-delay: 135ms; +} + +.site-item:nth-child(5) { + animation-delay: 180ms; +} + +.site-item:nth-child(6) { + animation-delay: 225ms; +} + +.site-item:nth-child(n + 7) { + animation-delay: 270ms; +} + +.site-card-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.site-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border: 1px solid rgba(56, 88, 246, 0.08); + border-radius: 15px; + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.6), transparent), + var(--primary-soft); + color: var(--primary); + font-size: 19px; + font-weight: 780; +} + +.site-open { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 50%; + background: var(--bg); + color: var(--muted); + transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s var(--ease); +} + +.site-item:hover .site-open { + background: var(--primary); + color: #ffffff; + transform: translate(2px, -2px); +} + +.site-info { + margin-top: 25px; + min-width: 0; +} + +.site-name { + overflow: hidden; + color: var(--ink); + font-size: 18px; + font-weight: 720; + letter-spacing: -0.45px; + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; +} + +.site-desc { + display: block; + margin-top: 5px; + color: var(--muted); + font-size: 11px; +} + +.site-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: auto; + padding-top: 17px; + border-top: 1px solid var(--border); +} + +.site-path { + max-width: calc(100% - 62px); + overflow: hidden; + color: var(--text-secondary); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 9.5px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.site-ready { + flex-shrink: 0; + color: var(--primary); + font-size: 8px; + font-weight: 800; + letter-spacing: 1.1px; +} + +.empty-state { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 430px; + padding: 54px 28px; + border: 1px solid rgba(255, 255, 255, 0.94); + border-radius: 22px; + background: rgba(255, 255, 255, 0.88); + box-shadow: var(--shadow-card); + text-align: center; + animation: card-arrive 0.5s var(--ease) both; +} + +.empty-visual { + display: inline-flex; + align-items: center; + justify-content: center; + width: 78px; + height: 78px; + border: 1px solid rgba(56, 88, 246, 0.09); + border-radius: 24px; + background: var(--primary-soft); + color: var(--primary); + transform: rotate(-4deg); +} + +.empty-state h3 { + margin-top: 24px; + color: var(--ink); + font-size: 20px; + font-weight: 730; + letter-spacing: -0.5px; +} + +.empty-state p { + max-width: 410px; + margin-top: 9px; + color: var(--muted); + font-size: 12px; + line-height: 1.8; +} + +.empty-state code { + padding: 2px 6px; + border-radius: 5px; + background: var(--bg); + color: var(--text-secondary); + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 10px; +} + +.refresh-status { + min-height: 18px; + margin-top: 14px; + padding-right: 3px; + color: var(--muted); + font-size: 10px; + text-align: right; +} + +.error-page { + display: grid; + place-items: center; + padding: 28px; +} + +.error-page::before { + position: fixed; + top: 15%; + right: 13%; + width: 240px; + height: 240px; + border-radius: 50%; + background: rgba(56, 88, 246, 0.065); + content: ""; + filter: blur(2px); +} + +.error-box { + position: relative; + width: min(100%, 480px); + overflow: hidden; + padding: 46px 42px 42px; + border: 1px solid rgba(255, 255, 255, 0.94); + border-radius: 26px; + background: rgba(255, 255, 255, 0.96); + box-shadow: var(--shadow-card); + text-align: center; +} + +.error-box::before { + display: block; + margin-bottom: 30px; + color: var(--muted); + content: "STASH · 404"; + font-size: 9px; + font-weight: 800; + letter-spacing: 2.2px; +} + +.error-box::after { + position: absolute; + top: 0; + right: 25%; + left: 25%; + height: 3px; + border-radius: 0 0 4px 4px; + background: linear-gradient(90deg, transparent, var(--primary), transparent); + content: ""; +} + +.error-code { + margin-bottom: 15px; + background: linear-gradient(135deg, var(--primary), #6f88ff); + background-clip: text; + color: transparent; + font-size: 86px; + font-weight: 780; + letter-spacing: -6px; + line-height: 0.92; +} + +.error-box h1 { + color: var(--ink); + font-size: 23px; + font-weight: 740; + letter-spacing: -0.7px; +} + +.error-box p { + margin-top: 12px; + margin-bottom: 28px; + color: var(--muted); + font-size: 12px; + line-height: 1.75; + overflow-wrap: anywhere; +} + +.error-box code { + padding: 3px 7px; + border-radius: 6px; + background: var(--bg); + color: var(--text-secondary); + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 10px; +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 42px; + padding: 10px 24px; + border-radius: 12px; + background: var(--primary); + box-shadow: 0 9px 20px rgba(56, 88, 246, 0.2); + color: #ffffff; + font-size: 12px; + font-weight: 700; + text-decoration: none; + transition: background-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s var(--ease); +} + +.button:hover { + background: var(--primary-dark); + box-shadow: 0 12px 24px rgba(56, 88, 246, 0.26); + transform: translateY(-1px); +} + +.button:focus-visible { + outline: 3px solid rgba(56, 88, 246, 0.24); + outline-offset: 3px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes card-arrive { + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes live-pulse { + 0%, + 100% { + box-shadow: 0 0 0 4px rgba(74, 189, 134, 0.11); + } + + 50% { + box-shadow: 0 0 0 7px rgba(74, 189, 134, 0.04); + } +} + +@media (max-width: 1080px) { + .workspace { + grid-template-columns: 252px minmax(0, 1fr); + gap: 22px; + width: min(100% - 40px, 1180px); + } + + .sidebar { + padding: 22px; + } + + .sidebar-intro { + margin-top: 44px; + } + + .sidebar-intro h1 { + font-size: 34px; + } + + .site-grid { + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 15px; + } +} + +@media (max-width: 820px) { + .workspace { + grid-template-columns: minmax(0, 1fr); + width: min(100% - 36px, 720px); + padding: 20px 0 28px; + } + + .sidebar { + position: static; + min-height: 0; + padding: 22px; + } + + .sidebar-intro { + margin-top: 34px; + } + + .sidebar-intro h1 { + font-size: 36px; + } + + .sidebar-lead { + max-width: 470px; + } + + .overview-card { + margin-top: 24px; + } + + .sidebar-actions { + display: flex; + align-items: center; + gap: 16px; + } + + .refresh-button { + width: auto; + min-width: 142px; + flex-shrink: 0; + } + + .sidebar-hint { + margin-top: 0; + text-align: left; + } + + .sidebar-footer { + margin-top: 24px; + } + + .content { + padding-top: 8px; + } +} + +@media (max-width: 560px) { + body { + background: + radial-gradient(circle at 100% 0%, rgba(56, 88, 246, 0.09), transparent 23rem), + var(--bg); + } + + .workspace { + width: calc(100% - 24px); + padding-top: 12px; + } + + .sidebar { + padding: 18px; + border-radius: 20px; + } + + .brand-mark { + width: 35px; + height: 35px; + border-radius: 11px; + } + + .brand-name { + font-size: 21px; + } + + .sidebar-intro { + margin-top: 24px; + } + + .sidebar-intro h1 { + margin-top: 12px; + font-size: 29px; + letter-spacing: -1.4px; + } + + .sidebar-intro h1 br { + display: none; + } + + .sidebar-lead { + display: -webkit-box; + margin-top: 11px; + overflow: hidden; + font-size: 11.5px; + line-height: 1.65; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + + .overview-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 2px 16px; + margin-top: 16px; + padding: 13px 15px; + } + + .overview-label { + grid-column: 1; + font-size: 10px; + } + + .project-total { + grid-column: 1; + margin-top: 2px; + } + + .project-total strong { + font-size: 38px; + } + + .service-status { + grid-column: 2; + grid-row: 1 / 3; + margin-top: 0; + padding-top: 0; + border-top: 0; + font-size: 10px; + white-space: nowrap; + } + + .sidebar-actions { + display: block; + margin-top: 12px; + } + + .refresh-button { + width: 100%; + } + + .sidebar-hint { + display: none; + } + + .sidebar-footer { + display: none; + } + + .content-header { + flex-direction: column; + align-items: flex-start; + gap: 16px; + margin-bottom: 20px; + } + + .content-meta { + justify-content: flex-start; + } + + .site-grid { + grid-template-columns: minmax(0, 1fr); + gap: 13px; + } + + .site-item { + min-height: 200px; + } + + .empty-state { + min-height: 380px; + } + + .error-page { + padding: 14px; + } + + .error-box { + padding: 38px 24px 34px; + border-radius: 22px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/stash-core/assets/dashboard.js b/stash-core/assets/dashboard.js new file mode 100644 index 0000000..988d76f --- /dev/null +++ b/stash-core/assets/dashboard.js @@ -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 = ` + + + + `; + 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 = ` + +

这里还没有项目

+

在托管根目录下创建子目录,并放入 index.html,刷新后即可在这里看到它。

+ `; + 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"); + }); +})(); diff --git a/stash-core/assets/index.html b/stash-core/assets/index.html deleted file mode 100644 index 719c485..0000000 --- a/stash-core/assets/index.html +++ /dev/null @@ -1,212 +0,0 @@ - - - - - - Stash 本地应用控制台 - - - -
-
-
-
- - - - - - - - -

本地前端应用

-
-
•••
-
- -
- __CARDS__ -
-
-
- - \ No newline at end of file diff --git a/stash-core/src/config.rs b/stash-core/src/config.rs index 4669ea4..51a4019 100644 --- a/stash-core/src/config.rs +++ b/stash-core/src/config.rs @@ -24,4 +24,4 @@ impl Default for Config { enable_cors: true, } } -} \ No newline at end of file +} diff --git a/stash-core/src/error.rs b/stash-core/src/error.rs index e80faf4..f66ea14 100644 --- a/stash-core/src/error.rs +++ b/stash-core/src/error.rs @@ -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), @@ -39,4 +45,4 @@ impl IntoResponse for StashError { (status, body).into_response() } -} \ No newline at end of file +} diff --git a/stash-core/src/lib.rs b/stash-core/src/lib.rs index 0e43e3d..83319d4 100644 --- a/stash-core/src/lib.rs +++ b/stash-core/src/lib.rs @@ -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}; \ No newline at end of file +pub use listing::{AppProject, scan_projects}; +pub use server::{ServerHandle, start}; diff --git a/stash-core/src/listing.rs b/stash-core/src/listing.rs index a630b94..17aaa90 100644 --- a/stash-core/src/listing.rs +++ b/stash-core/src/listing.rs @@ -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 { let mut projects = Vec::new(); @@ -28,48 +51,187 @@ pub fn scan_projects(root: &Path) -> Vec { 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(); - - for proj in projects { - // 提取首字符作为圆形头像字母 - let initial = proj.name.chars().next().unwrap_or('A').to_uppercase().to_string(); - - cards_html.push_str(&format!( - r#" - -
{initial}
-
- {name} - 本地托管应用 • {url} -
-
- "#, - url = proj.route_path, - name = proj.name, - initial = initial - )); - } - - let final_cards = if cards_html.is_empty() { - r#"
当前目录下暂无含有 index.html 的前端项目
"#.to_string() - } else { - cards_html - }; - - INDEX_TEMPLATE.replace("__CARDS__", &final_cards) +pub fn render_dashboard(projects: &[AppProject]) -> Result { + Ok(DashboardTemplate { projects }.render()?) } -pub fn render_not_found(path: &str) -> String { - NOT_FOUND_TEMPLATE.replace("__PATH__", path) -} \ No newline at end of file +pub fn render_not_found(path: &str) -> Result { + Ok(NotFoundTemplate { path }.render()?) +} + +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("写入字符串不会失败"); + } + } + + encoded +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + fs, + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, + }; + + 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-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), "").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("index.html")); + 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/").expect("404 页面应能正常渲染"); + + assert!(html.contains("资源或页面不存在")); + assert!( + contains_escaped_less_than(&html), + "请求路径中的 < 应被 HTML 转义,实际输出: {html}" + ); + assert!(!html.contains("")); + } +} diff --git a/stash-core/src/server.rs b/stash-core/src/server.rs index 1b12bdd..672405e 100644 --- a/stash-core/src/server.rs +++ b/stash-core/src/server.rs @@ -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, + pub project_updates: broadcast::Sender>, } pub struct ServerHandle { @@ -42,19 +47,39 @@ async fn root_handler(State(state): State) -> Result 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), 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) -> impl IntoResponse { })) } +/// SSE:先发送当前快照,再持续推送文件监听产生的项目列表变化。 +async fn project_events(State(state): State) -> 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::(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) -> 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 { if !config.root.exists() { return Err(StashError::InvalidDirectory( @@ -80,18 +145,27 @@ pub async fn start(config: Config) -> Result { )); } + 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 { 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); @@ -132,4 +205,4 @@ pub async fn start(config: Config) -> Result { shutdown_tx, local_addr, }) -} \ No newline at end of file +} diff --git a/stash-core/src/watcher.rs b/stash-core/src/watcher.rs new file mode 100644 index 0000000..cea0ec9 --- /dev/null +++ b/stash-core/src/watcher.rs @@ -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>) -> 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) -> 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"), "").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/"); + } +} diff --git a/stash-core/templates/404.html b/stash-core/templates/404.html new file mode 100644 index 0000000..c2441a5 --- /dev/null +++ b/stash-core/templates/404.html @@ -0,0 +1,17 @@ + + + + + + 404 - 资源未找到 + + + +
+
404
+

资源或页面不存在

+

请求的路径 {{ path }} 不存在或缺少 index.html

+ 返回应用大厅 +
+ + diff --git a/stash-core/templates/index.html b/stash-core/templates/index.html new file mode 100644 index 0000000..edbe1c6 --- /dev/null +++ b/stash-core/templates/index.html @@ -0,0 +1,120 @@ + + + + + + + + Stash · 本地应用工作区 + + + + + + + +
+ + +
+
+
+

PROJECT LIBRARY

+

本地应用

+

一个入口,快速抵达每个正在开发或预览的页面。

+
+ +
+ +
+ {% if projects.is_empty() %} +
+ +

这里还没有项目

+

在托管根目录下创建子目录,并放入 index.html,刷新后即可在这里看到它。

+
+ {% else %} + {% for project in projects %} + +
+ + +
+
+

{{ project.name }}

+ 本地静态应用 +
+ +
+ {% endfor %} + {% endif %} +
+

+
+
+ +