refactor: 合并为单 crate,前端改为内嵌 React 控制台

合并 stash-cli / stash-core 为根目录单 crate(lib + bin),新增 React + Vite + TS 控制台替换 Askama 模板。
build.rs 用 walkdir 把 stash-web/dist 同步到 $OUT_DIR/web,由 include_dir! 编译期内嵌,产出单文件二进制。
路由改为 / 与 /assets/** 走内嵌前端,其余交给 ServeDir,缺失路径返回内置 404。同步更新依赖、.gitignore 与 README。
This commit is contained in:
2026-10-21 17:59:08 +08:00
parent 92c510e959
commit e522a98f6f
48 changed files with 5113 additions and 273 deletions
+250
View File
@@ -0,0 +1,250 @@
use axum::{
Json, Router,
extract::{OriginalUri, Path, State},
http::{HeaderValue, StatusCode, header},
response::sse::{Event, KeepAlive, Sse},
response::{Html, IntoResponse, Response},
routing::get,
};
use include_dir::{Dir, include_dir};
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 tracing::{info, warn};
use crate::config::Config;
use crate::error::StashError;
use crate::listing::{AppProject, scan_projects};
use crate::watcher;
/// build.rs 会把 `stash-web/dist` 同步到 `$OUT_DIR/web`,
/// 这里把整棵目录树在编译期内嵌,最终产出带前端的单文件二进制。
static WEB: Dir<'static> = include_dir!("$OUT_DIR/web");
/// Vite 产物文件名带内容 hash,可以长期强缓存;HTML 每次都要回源校验。
const HASHED_ASSET_CACHE: &str = "public, max-age=31536000, immutable";
const HTML_CACHE: &str = "no-cache";
#[derive(Clone)]
pub struct AppState {
pub config: Arc<Config>,
pub project_updates: broadcast::Sender<Vec<AppProject>>,
}
pub struct ServerHandle {
shutdown_tx: oneshot::Sender<()>,
pub local_addr: SocketAddr,
}
impl ServerHandle {
pub fn stop(self) {
let _ = self.shutdown_tx.send(());
}
}
/// 根路径:返回内嵌前端的入口页。
async fn web_index() -> Response {
embedded_file("index.html").unwrap_or_else(|| not_found_page("/"))
}
/// `/assets/**`:Vite 产出的 CSS / JS / 图片。
async fn web_asset(OriginalUri(uri): OriginalUri, Path(path): Path<String>) -> Response {
let file = format!("assets/{path}");
embedded_file(&file).unwrap_or_else(|| not_found_page(uri.path()))
}
/// 前端放在 dist 根目录的其他文件(favicon 等)。
async fn web_root_file(OriginalUri(uri): OriginalUri) -> Response {
let path = uri.path().trim_start_matches('/');
embedded_file(path).unwrap_or_else(|| not_found_page(uri.path()))
}
/// 从内嵌目录取文件并组装响应;找不到返回 `None`。
fn embedded_file(path: &str) -> Option<Response> {
let file = WEB.get_file(path)?;
let mime = mime_guess::from_path(path).first_or_octet_stream();
let content_type = HeaderValue::from_str(mime.as_ref())
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"));
let cache = if path.starts_with("assets/") {
HeaderValue::from_static(HASHED_ASSET_CACHE)
} else {
HeaderValue::from_static(HTML_CACHE)
};
Some(
(
StatusCode::OK,
[
(header::CONTENT_TYPE, content_type),
(header::CACHE_CONTROL, cache),
],
file.contents(),
)
.into_response(),
)
}
/// 统一 404:内嵌前端没有这个文件,托管目录里也没有这个路径。
fn not_found_page(path: &str) -> Response {
let body = format!(
r#"<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>404 · 页面不存在</title></head>
<body style="margin:0;display:grid;place-items:center;min-height:100vh;background:#f7f9fb;color:#1e293b;font-family:system-ui,-apple-system,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif">
<main style="text-align:center;padding:24px">
<p style="margin:0;font-size:64px;font-weight:800;line-height:1;color:#cbd5e1">404</p>
<h1 style="margin:12px 0 8px;font-size:18px;font-weight:600">资源或页面不存在</h1>
<p style="margin:0 0 20px;color:#64748b"><code style="font-family:ui-monospace,Consolas,monospace;font-size:13px">{}</code></p>
<a href="/" style="display:inline-block;padding:8px 16px;border-radius:8px;background:#3b82f6;color:#fff;text-decoration:none;font-size:14px">返回本地应用</a>
</main>
</body>
</html>"#,
escape_html(path)
);
(StatusCode::NOT_FOUND, Html(body)).into_response()
}
async fn not_found_handler(OriginalUri(uri): OriginalUri) -> Response {
not_found_page(uri.path())
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
/// API: 健康检查
async fn health_check() -> impl IntoResponse {
Json(serde_json::json!({
"status": "ok",
"service": "stash"
}))
}
/// API: 项目列表
async fn list_projects_api(State(state): State<AppState>) -> impl IntoResponse {
let projects = scan_projects(&state.config.root);
Json(serde_json::json!({
"projects": projects
}))
}
/// 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(
config.root.display().to_string(),
));
}
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,
};
// 托管目录里找不到的路径,交给 404 页面而不是 SPA 回落:
// 前端目前没有客户端路由,把 404 冒充 200 只会让排错更难。
let serve_dir = ServeDir::new(&config.root)
.append_index_html_on_directories(true)
.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("/", get(web_index))
.route("/index.html", get(web_index))
.route("/favicon.svg", get(web_root_file))
.route("/assets/{*path}", get(web_asset))
.fallback_service(serve_dir)
.with_state(shared_state)
.layer(TraceLayer::new_for_http());
if config.enable_cors {
app = app.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any),
);
}
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
let listener = tokio::net::TcpListener::bind(addr)
.await
.map_err(|e| StashError::ServerBind(format!("端口 {} 绑定失败: {}", config.port, e)))?;
let local_addr = listener.local_addr()?;
info!("Stash 服务运行于: http://{}", local_addr);
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 优雅停机...");
});
if let Err(err) = server.await {
warn!("服务异常退出: {}", err);
}
});
Ok(ServerHandle {
shutdown_tx,
local_addr,
})
}