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

This commit is contained in:
2026-10-18 15:06:51 +08:00
parent 2e4f0e947b
commit fc346c3e9b
15 changed files with 2289 additions and 370 deletions
+4 -1
View File
@@ -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"
mime_guess = "2.0"
askama = "0.14"
async-stream = "0.3"
notify = "8"
-86
View File
@@ -1,86 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - 资源未找到</title>
<style>
:root {
--bg: #f4f6f9;
--container-bg: #ffffff;
--primary: #ff5c38;
--title-color: #2b2f36;
--desc-color: #8a92a3;
--border-color: #edf2f7;
--shadow-sm: 0 4px 16px rgba(0, 0, 0, 0.05);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--bg);
color: var(--title-color);
padding: 20px;
box-sizing: border-box;
}
.box {
text-align: center;
max-width: 440px;
width: 100%;
background: var(--container-bg);
padding: 44px 32px;
border-radius: 16px;
box-shadow: var(--shadow-sm);
}
.code {
font-size: 64px;
font-weight: 800;
color: var(--primary);
line-height: 1;
margin-bottom: 12px;
}
h2 {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 700;
}
p {
color: var(--desc-color);
font-size: 13px;
margin: 0 0 24px 0;
word-break: break-all;
}
code {
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
color: var(--title-color);
}
.btn {
display: inline-block;
background: var(--primary);
color: #ffffff;
font-weight: 600;
font-size: 14px;
padding: 10px 24px;
border-radius: 8px;
text-decoration: none;
transition: opacity 0.15s ease;
}
.btn:hover {
opacity: 0.88;
}
</style>
</head>
<body>
<div class="box">
<div class="code">404</div>
<h2>资源或页面不存在</h2>
<p>请求的路径 <code>__PATH__</code> 不存在或缺少 index.html</p>
<a href="/" class="btn">返回应用大厅</a>
</div>
</body>
</html>
File diff suppressed because it is too large Load Diff
+198
View File
@@ -0,0 +1,198 @@
(() => {
"use strict";
const grid = document.querySelector("#project-grid");
const refreshButton = document.querySelector("#refresh-projects");
const status = document.querySelector("#refresh-status");
const projectCounts = document.querySelectorAll("[data-project-count]");
const liveStatus = document.querySelector("#live-status");
const liveStatusLabel = document.querySelector("#live-status-label");
if (!grid || !refreshButton || !status || !liveStatus || !liveStatusLabel) {
return;
}
document.body.classList.add("is-enhanced");
const createProjectCard = (project) => {
if (
typeof project.name !== "string" ||
typeof project.route_path !== "string" ||
!project.route_path.startsWith("/") ||
project.route_path.startsWith("//")
) {
return null;
}
const link = document.createElement("a");
link.className = "site-item";
link.href = project.route_path;
link.title = project.name;
const cardTop = document.createElement("div");
cardTop.className = "site-card-top";
const avatar = document.createElement("span");
avatar.className = "site-avatar";
avatar.setAttribute("aria-hidden", "true");
avatar.textContent = Array.from(project.name)[0]?.toUpperCase() || "A";
const open = document.createElement("span");
open.className = "site-open";
open.setAttribute("aria-hidden", "true");
open.innerHTML = `
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 17 17 7M8 7h9v9"></path>
</svg>
`;
cardTop.append(avatar, open);
const info = document.createElement("div");
info.className = "site-info";
const name = document.createElement("h3");
name.className = "site-name";
name.textContent = project.name;
const description = document.createElement("span");
description.className = "site-desc";
description.textContent = "本地静态应用";
info.append(name, description);
const footer = document.createElement("div");
footer.className = "site-card-footer";
const path = document.createElement("span");
path.className = "site-path";
path.textContent = project.route_path;
const ready = document.createElement("span");
ready.className = "site-ready";
ready.textContent = "READY";
footer.append(path, ready);
link.append(cardTop, info, footer);
return link;
};
let renderedSignature = JSON.stringify(
Array.from(grid.querySelectorAll(".site-item")).map((link) => [
link.title,
link.getAttribute("href"),
]),
);
const projectsSignature = (projects) =>
JSON.stringify(
projects.map((project) => [project.name, project.route_path]),
);
const renderProjects = (projects) => {
const nextSignature = projectsSignature(projects);
if (nextSignature === renderedSignature) {
return false;
}
grid.replaceChildren();
if (projects.length === 0) {
const emptyState = document.createElement("div");
emptyState.className = "empty-state";
emptyState.innerHTML = `
<span class="empty-visual" aria-hidden="true">
<svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>
<path d="M8 13h8M8 16h5"></path>
</svg>
</span>
<h3>这里还没有项目</h3>
<p>在托管根目录下创建子目录,并放入 <code>index.html</code>,刷新后即可在这里看到它。</p>
`;
grid.append(emptyState);
renderedSignature = nextSignature;
return true;
}
const cards = projects.map(createProjectCard).filter(Boolean);
grid.append(...cards);
renderedSignature = nextSignature;
return true;
};
const updateProjectCounts = (count) => {
projectCounts.forEach((element) => {
element.textContent = String(count);
});
};
refreshButton.addEventListener("click", async () => {
refreshButton.disabled = true;
refreshButton.classList.add("is-loading");
grid.setAttribute("aria-busy", "true");
status.textContent = "正在重新扫描项目...";
try {
const response = await fetch("/api/projects", {
cache: "no-store",
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
if (!Array.isArray(payload.projects)) {
throw new Error("项目列表格式无效");
}
const changed = renderProjects(payload.projects);
updateProjectCounts(payload.projects.length);
status.textContent = changed ? "项目列表已刷新" : "项目列表已是最新";
} catch (error) {
console.error("刷新项目列表失败", error);
status.textContent = "刷新失败,请稍后重试";
} finally {
refreshButton.disabled = false;
refreshButton.classList.remove("is-loading");
grid.removeAttribute("aria-busy");
}
});
if (!("EventSource" in window)) {
liveStatusLabel.textContent = "手动刷新";
return;
}
const eventSource = new EventSource("/api/projects/events");
eventSource.addEventListener("open", () => {
liveStatusLabel.textContent = "实时同步";
liveStatus.classList.add("is-live");
liveStatus.classList.remove("is-reconnecting");
});
eventSource.addEventListener("projects", (event) => {
try {
const payload = JSON.parse(event.data);
if (!Array.isArray(payload.projects)) {
throw new Error("项目列表格式无效");
}
const changed = renderProjects(payload.projects);
updateProjectCounts(payload.projects.length);
if (changed) {
status.textContent = "检测到目录变化,项目列表已自动更新";
}
} catch (error) {
console.error("处理项目更新事件失败", error);
status.textContent = "收到项目更新,但无法解析";
}
});
eventSource.addEventListener("error", () => {
liveStatusLabel.textContent = "同步重连中";
liveStatus.classList.remove("is-live");
liveStatus.classList.add("is-reconnecting");
});
})();
-212
View File
@@ -1,212 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stash 本地应用控制台</title>
<style>
:root {
/* suxing.me 经典轻量博客/导航设计配色体系 */
--bg: #f4f6f9;
--container-bg: #ffffff;
--primary: #ff5c38;
--primary-light: rgba(255, 92, 56, 0.08);
--title-color: #2b2f36;
--desc-color: #8a92a3;
--border-color: #edf2f7;
--card-hover-bg: #f8fafc;
--shadow-sm: 0 2px 12px rgba(0, 0, 0, 0.04);
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.08);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background-color: var(--bg);
color: var(--title-color);
padding: 48px 20px;
display: flex;
justify-content: center;
min-height: 100vh;
}
.wrapper {
max-width: 1080px;
width: 100%;
}
/* 主容器卡片化,呈现图中的圆角纯白面板 */
.panel {
background: var(--container-bg);
border-radius: 16px;
padding: 24px 28px 32px;
box-shadow: var(--shadow-sm);
}
/* 顶部标题区(参考图中的左侧圆环图标+加粗标题) */
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
padding-bottom: 14px;
border-bottom: 1px solid var(--border-color);
}
.header-title-group {
display: flex;
align-items: center;
gap: 10px;
}
.header-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
color: var(--title-color);
}
.panel-header h2 {
font-size: 18px;
font-weight: 700;
color: var(--title-color);
letter-spacing: -0.2px;
}
.panel-action {
display: flex;
align-items: center;
gap: 4px;
color: #b1b9c7;
font-size: 18px;
cursor: pointer;
user-select: none;
}
/* 4列自适应网格,复刻参考图结构 */
.site-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px 24px;
}
@media (max-width: 992px) {
.site-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 560px) {
.site-grid {
grid-template-columns: 1fr;
}
}
/* 每一个站点项目的水平卡片 */
.site-item {
display: flex;
align-items: center;
text-decoration: none;
padding: 10px 12px;
border-radius: 12px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
background: transparent;
}
.site-item:hover {
background: var(--card-hover-bg);
transform: translateY(-2px);
}
.site-item:hover .site-name {
color: var(--primary);
}
/* 圆形小头像/图标 */
.site-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
background: var(--primary-light);
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
font-size: 17px;
font-weight: 700;
flex-shrink: 0;
margin-right: 12px;
box-shadow: 0 2px 6px rgba(255, 92, 56, 0.12);
text-transform: uppercase;
}
.site-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.site-name {
font-size: 15px;
font-weight: 600;
color: var(--title-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 3px;
transition: color 0.15s ease;
}
.site-desc {
font-size: 12px;
color: var(--desc-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: inherit;
}
/* 空状态占位 */
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 60px 0;
color: var(--desc-color);
font-size: 14px;
}
</style>
</head>
<body>
<div class="wrapper">
<div class="panel">
<div class="panel-header">
<div class="header-title-group">
<span class="header-icon">
<!-- 类似参考图中的网球/球体圆环图标 -->
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"></path>
<path d="M2 12h20"></path>
</svg>
</span>
<h2>本地前端应用</h2>
</div>
<div class="panel-action">•••</div>
</div>
<div class="site-grid">
__CARDS__
</div>
</div>
</div>
</body>
</html>
+1 -1
View File
@@ -24,4 +24,4 @@ impl Default for Config {
enable_cors: true,
}
}
}
}
+8 -2
View File
@@ -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()
}
}
}
+3 -2
View File
@@ -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};
+201 -39
View File
@@ -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();
for proj in projects {
// 提取首字符作为圆形头像字母
let initial = proj.name.chars().next().unwrap_or('A').to_uppercase().to_string();
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
));
}
let final_cards = if cards_html.is_empty() {
r#"<div class="empty-state">当前目录下暂无含有 index.html 的前端项目</div>"#.to_string()
} else {
cards_html
};
INDEX_TEMPLATE.replace("__CARDS__", &final_cards)
pub fn render_dashboard(projects: &[AppProject]) -> Result<String, StashError> {
Ok(DashboardTemplate { projects }.render()?)
}
pub fn render_not_found(path: &str) -> String {
NOT_FOUND_TEMPLATE.replace("__PATH__", path)
}
pub fn render_not_found(path: &str) -> Result<String, StashError> {
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), "<!doctype html>").expect("应能创建入口文件");
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
fn contains_escaped_less_than(html: &str) -> bool {
["&lt;", "&#60;", "&#x3C;", "&#x3c;"]
.iter()
.any(|entity| html.contains(entity))
}
fn contains_escaped_ampersand(html: &str) -> bool {
["&amp;", "&#38;", "&#x26;", "&#X26;"]
.iter()
.any(|entity| html.contains(entity))
}
#[test]
fn scan_projects_detects_only_immediate_visible_projects() {
let root = TestDir::new();
root.add_project("with-html", "index.html");
root.add_project("with-htm", "index.htm");
root.add_project(".hidden", "index.html");
root.add_project("no-index", "readme.txt");
root.add_project("parent/child", "index.html");
fs::create_dir_all(root.path().join("plain-directory")).expect("应能创建普通目录");
let names: Vec<_> = scan_projects(root.path())
.into_iter()
.map(|project| project.name)
.collect();
assert_eq!(names, vec!["with-htm", "with-html"]);
}
#[test]
fn scan_projects_sorts_names_case_insensitively() {
let root = TestDir::new();
root.add_project("zeta", "index.html");
root.add_project("ALPHA", "index.html");
root.add_project("middle", "index.html");
let names: Vec<_> = scan_projects(root.path())
.into_iter()
.map(|project| project.name)
.collect();
assert_eq!(names, vec!["ALPHA", "middle", "zeta"]);
}
#[test]
fn scan_projects_url_encodes_route_segments() {
let root = TestDir::new();
root.add_project("space name", "index.html");
root.add_project("中文", "index.html");
let projects = scan_projects(root.path());
assert_eq!(projects[0].name, "space name");
assert_eq!(projects[0].route_path, "/space%20name/");
assert_eq!(projects[1].name, "中文");
assert_eq!(projects[1].route_path, "/%E4%B8%AD%E6%96%87/");
}
#[test]
fn path_segment_encoding_preserves_unreserved_characters() {
assert_eq!(encode_path_segment("aZ09-_.~/?#%"), "aZ09-_.~%2F%3F%23%25");
}
#[test]
fn dashboard_renders_empty_state() {
let html = render_dashboard(&[]).expect("空项目列表应能正常渲染");
assert!(html.contains("这里还没有项目"));
assert!(html.contains("<code>index.html</code>"));
assert!(!html.contains("class=\"site-item\""));
}
#[test]
fn dashboard_escapes_project_content() {
let projects = vec![AppProject {
name: "<项目 & \"测试\">".to_string(),
route_path: "/safe%20path/".to_string(),
}];
let html = render_dashboard(&projects).expect("项目卡片应能正常渲染");
assert!(
contains_escaped_less_than(&html),
"项目名称中的 < 应被 HTML 转义,实际输出: {html}"
);
assert!(
contains_escaped_ampersand(&html),
"项目名称中的 & 应被 HTML 转义,实际输出: {html}"
);
assert!(!html.contains("<项目 & \"测试\">"));
}
#[test]
fn not_found_page_renders_and_escapes_request_path() {
let html =
render_not_found("/missing/<script>alert(1)</script>").expect("404 页面应能正常渲染");
assert!(html.contains("资源或页面不存在"));
assert!(
contains_escaped_less_than(&html),
"请求路径中的 < 应被 HTML 转义,实际输出: {html}"
);
assert!(!html.contains("<script>alert(1)</script>"));
}
}
+91 -18
View File
@@ -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);
@@ -132,4 +205,4 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
shutdown_tx,
local_addr,
})
}
}
+122
View File
@@ -0,0 +1,122 @@
use crate::{AppProject, error::StashError, scan_projects};
use notify::{Event, EventKind, RecursiveMode, Watcher};
use std::{path::PathBuf, time::Duration};
use tokio::sync::{broadcast, mpsc};
use tracing::warn;
const EVENT_DEBOUNCE: Duration = Duration::from_millis(150);
pub fn start(root: PathBuf, updates: broadcast::Sender<Vec<AppProject>>) -> Result<(), StashError> {
let mut previous_projects = scan_projects(&root);
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
let mut watcher = notify::recommended_watcher(move |result| {
let _ = event_tx.send(result);
})?;
watcher.watch(&root, RecursiveMode::Recursive)?;
tokio::spawn(async move {
let _watcher = watcher;
while let Some(event) = event_rx.recv().await {
if !should_rescan(&event) {
continue;
}
// Editors and build tools often emit a burst of file events for one change.
tokio::time::sleep(EVENT_DEBOUNCE).await;
while event_rx.try_recv().is_ok() {}
let projects = scan_projects(&root);
if projects == previous_projects {
continue;
}
previous_projects = projects.clone();
let _ = updates.send(projects);
}
});
Ok(())
}
fn should_rescan(event: &notify::Result<Event>) -> bool {
match event {
Ok(event) => !matches!(event.kind, EventKind::Access(_)),
Err(error) => {
warn!("文件监听事件出错,将重新扫描项目: {error}");
true
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use notify::{
Event,
event::{AccessKind, CreateKind, EventKind},
};
use std::{
fs,
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
time::Duration,
};
static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
struct TestDir {
path: PathBuf,
}
impl TestDir {
fn new() -> Self {
let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir()
.join(format!("stash-watcher-test-{}-{id}", std::process::id()));
fs::create_dir_all(&path).expect("应能创建测试目录");
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
#[test]
fn access_events_do_not_trigger_project_scans() {
let access = Event::new(EventKind::Access(AccessKind::Read));
let create = Event::new(EventKind::Create(CreateKind::Folder));
assert!(!should_rescan(&Ok(access)));
assert!(should_rescan(&Ok(create)));
}
#[tokio::test]
async fn broadcasts_when_a_project_is_added() {
let root = TestDir::new();
let (updates, _) = broadcast::channel(8);
let mut receiver = updates.subscribe();
start(root.path().to_path_buf(), updates).expect("应能启动文件监听");
let project = root.path().join("new-project");
fs::create_dir_all(&project).expect("应能创建项目目录");
fs::write(project.join("index.html"), "<!doctype html>").expect("应能创建入口文件");
let projects = tokio::time::timeout(Duration::from_secs(5), receiver.recv())
.await
.expect("应在超时前收到项目更新")
.expect("项目更新通道不应关闭");
assert_eq!(projects.len(), 1);
assert_eq!(projects[0].name, "new-project");
assert_eq!(projects[0].route_path, "/new-project/");
}
}
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - 资源未找到</title>
<link rel="stylesheet" href="/__stash__/dashboard.css">
</head>
<body class="error-page">
<main class="error-box">
<div class="error-code">404</div>
<h1>资源或页面不存在</h1>
<p>请求的路径 <code>{{ path }}</code> 不存在或缺少 index.html</p>
<a href="/" class="button">返回应用大厅</a>
</main>
</body>
</html>
+120
View File
@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light">
<meta name="theme-color" content="#f7f8fa">
<title>Stash · 本地应用工作区</title>
<link rel="stylesheet" href="/__stash__/dashboard.css">
<script src="/__stash__/dashboard.js" defer></script>
</head>
<body class="dashboard-page">
<div class="ambient ambient-blue" aria-hidden="true"></div>
<div class="ambient ambient-warm" aria-hidden="true"></div>
<div class="workspace">
<aside class="sidebar">
<header class="sidebar-header">
<a class="brand" href="/" aria-label="Stash 首页">
<span class="brand-mark" aria-hidden="true">
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="2"></rect>
<rect x="14" y="3" width="7" height="7" rx="2"></rect>
<rect x="3" y="14" width="7" height="7" rx="2"></rect>
<path d="M17.5 14v7M14 17.5h7"></path>
</svg>
</span>
<span class="brand-name">stash<span>.</span></span>
</a>
<span class="local-badge">LOCAL</span>
</header>
<div class="sidebar-intro">
<p class="eyebrow"><span class="eyebrow-dot"></span>YOUR WORKSPACE</p>
<h1>把项目,<br>放在手边。</h1>
<p class="sidebar-lead">Stash 正在整理当前目录下的静态前端应用,让每次预览都从一个清爽入口开始。</p>
</div>
<section class="overview-card" aria-label="工作区概览">
<div class="overview-label">项目概览</div>
<div class="project-total">
<strong data-project-count>{{ projects.len() }}</strong>
<span>个应用</span>
</div>
<div class="service-status">
<span class="status-dot"></span>
<span>本地服务运行中</span>
</div>
</section>
<div class="sidebar-actions">
<button class="refresh-button js-only" id="refresh-projects" type="button" aria-label="重新扫描项目">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 11a8.1 8.1 0 0 0-15.5-2M4 4v5h5"></path>
<path d="M4 13a8.1 8.1 0 0 0 15.5 2M20 20v-5h-5"></path>
</svg>
<span>重新扫描</span>
</button>
<p class="sidebar-hint">目录变化会自动同步,也可以手动重新扫描。</p>
</div>
<footer class="sidebar-footer">
<span>STATIC HOSTING</span>
<span class="footer-ready"><i></i>READY</span>
</footer>
</aside>
<main class="content">
<header class="content-header">
<div class="content-heading">
<p class="eyebrow">PROJECT LIBRARY</p>
<h2>本地应用</h2>
<p>一个入口,快速抵达每个正在开发或预览的页面。</p>
</div>
<div class="content-meta">
<span class="live-pill" id="live-status"><i></i><span id="live-status-label">服务在线</span></span>
<span class="count-pill"><strong data-project-count>{{ projects.len() }}</strong> 个项目</span>
</div>
</header>
<section class="site-grid" id="project-grid" aria-live="polite">
{% if projects.is_empty() %}
<div class="empty-state">
<span class="empty-visual" aria-hidden="true">
<svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>
<path d="M8 13h8M8 16h5"></path>
</svg>
</span>
<h3>这里还没有项目</h3>
<p>在托管根目录下创建子目录,并放入 <code>index.html</code>,刷新后即可在这里看到它。</p>
</div>
{% else %}
{% for project in projects %}
<a class="site-item" href="{{ project.route_path }}" title="{{ project.name }}">
<div class="site-card-top">
<span class="site-avatar" aria-hidden="true">{{ project.initial() }}</span>
<span class="site-open" aria-hidden="true">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 17 17 7M8 7h9v9"></path>
</svg>
</span>
</div>
<div class="site-info">
<h3 class="site-name">{{ project.name }}</h3>
<span class="site-desc">本地静态应用</span>
</div>
<div class="site-card-footer">
<span class="site-path">{{ project.route_path }}</span>
<span class="site-ready">READY</span>
</div>
</a>
{% endfor %}
{% endif %}
</section>
<p class="refresh-status" id="refresh-status" role="status" aria-live="polite"></p>
</main>
</div>
</body>
</html>