init: 本地静态页面托管服务

- 初始化
This commit is contained in:
2026-10-16 13:46:20 +08:00
commit 2e4f0e947b
13 changed files with 1791 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/target
.idea
Generated
+1009
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
[workspace]
resolver = "2"
members = [
"stash-core",
"stash-cli",
]
[workspace.package]
version = "0.1.0"
edition = "2024"
license = "MIT OR Apache-2.0"
[workspace.dependencies]
tokio = { version = "1.38", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
thiserror = "1.0"
anyhow = "1.0"
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "stash-cli"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
[[bin]]
name = "stash"
path = "src/main.rs"
[dependencies]
stash-core = { path = "../stash-core",version = "0.1.0" }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
clap = { version = "4.6.7", features = ["derive"] }
anyhow = { workspace = true }
colored = "3.1.1"
+138
View File
@@ -0,0 +1,138 @@
use std::path::PathBuf;
use clap::Parser;
use colored::*;
use stash_core::{Config, start};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[derive(Parser, Debug)]
#[command(
name = "stash",
author = "stash developers",
version,
about = "🚀 本地静态页面托管与多项目管理工具",
long_about = "支持本地静态页面按目录隔离托管,支持前端直接调用同源接口,提供清爽的项目导航列表。"
)]
struct Cli {
/// 静态页面托管根目录(支持相对路径和绝对路径)
#[arg(short, long, default_value = "./public", value_name = "DIR")]
root: PathBuf,
/// 监听端口
#[arg(short, long, default_value_t = 8080, value_name = "PORT")]
port: u16,
/// 绑定监听地址(默认 127.0.0.1,如需局域网访问可传 0.0.0.0)
#[arg(long, default_value = "127.0.0.1", value_name = "HOST")]
host: String,
/// 关闭根目录项目导航列表功能(仅托管静态文件)
#[arg(long, default_value_t = false)]
no_listing: bool,
/// 关闭 CORS 跨域标头注入
#[arg(long, default_value_t = false)]
no_cors: bool,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 1. 初始化 tracing 日志
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
// 2. 解析命令行输入
let cli = Cli::parse();
// 检查目录是否存在,不存在则友好提示并自动创建默认 public
if !cli.root.exists() {
if cli.root == PathBuf::from("./public") {
println!(
"{} 默认托管目录 {} 不存在,正在自动创建...",
"[INFO]".cyan(),
"./public".yellow()
);
std::fs::create_dir_all(&cli.root)?;
} else {
eprintln!(
"{} 指定的托管目录不存在: {}",
"[ERROR]".red().bold(),
cli.root.display().to_string().yellow()
);
std::process::exit(1);
}
}
let abs_root = cli.root.canonicalize().unwrap_or_else(|_| cli.root.clone());
// 3. 构造 stash-core 配置并启动
let config = Config {
root: abs_root.clone(),
port: cli.port,
host: cli.host,
directory_listing: !cli.no_listing,
enable_cors: !cli.no_cors,
};
let handle = match start(config).await {
Ok(h) => h,
Err(err) => {
eprintln!("{} 启动失败: {}", "[FATAL]".red().bold(), err);
std::process::exit(1);
}
};
// 4. 打印启动成功 Banner
println!();
println!("{}", "==================================================".bright_blue());
println!(" ✨ {} {}", "Stash Server".green().bold(), format!("v{}", env!("CARGO_PKG_VERSION")).dimmed());
println!("{}", "==================================================".bright_blue());
println!(" ➜ 本地访问: {}", format!("http://{}", handle.local_addr).cyan().bold().underline());
println!(" ➜ 托管路径: {}", abs_root.display().to_string().white());
println!(" ➜ 目录导航页: {}", if !cli.no_listing { "开启".green() } else { "关闭".red() });
println!(" ➜ 接口列表: {}", format!("http://{}/api/pages", handle.local_addr).dimmed());
println!("{}", "--------------------------------------------------".bright_blue());
println!(" 按 {} 停止服务", "Ctrl+C".yellow().bold());
println!();
// 5. 跨平台捕获退出信号
wait_for_shutdown_signal().await;
println!("\n{} 正在停止服务并释放端口...", "[SYS]".yellow());
handle.stop();
// 留出时间让 tokio 优雅关闭 socket 句柄
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
println!("{} 服务已安全退出。\n", "[DONE]".green().bold());
Ok(())
}
/// 监听停机信号(支持 Windows Ctrl+C 以及 Linux/macOS SIGINT/SIGTERM)
async fn wait_for_shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("无法注册 Ctrl+C 信号处理器");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("无法注册 SIGTERM 信号处理器")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "stash-core"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
[dependencies]
tokio = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
# Web 核心与静态托管
axum = "0.8.9"
tower-http = { version = "0.7.1", features = ["fs", "cors", "trace"] }
tower = { version = "0.4", features = ["util"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
walkdir = "2.4"
mime_guess = "2.0"
+86
View File
@@ -0,0 +1,86 @@
<!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>
+212
View File
@@ -0,0 +1,212 @@
<!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>
+27
View File
@@ -0,0 +1,27 @@
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct Config {
/// 静态页面托管根目录(存放一个或多个前端项目)
pub root: PathBuf,
/// 监听端口
pub port: u16,
/// 监听地址,默认 127.0.0.1
pub host: String,
/// 是否在根路径没有 index.html 时展示项目仪表盘
pub directory_listing: bool,
/// 是否注入 CORS 标头
pub enable_cors: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
root: PathBuf::from("./public"),
port: 8080,
host: "127.0.0.1".to_string(),
directory_listing: true,
enable_cors: true,
}
}
}
+42
View File
@@ -0,0 +1,42 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum StashError {
#[error("IO 错误: {0}")]
Io(#[from] std::io::Error),
#[error("目录不存在或非法: {0}")]
InvalidDirectory(String),
#[error("网络地址解析失败: {0}")]
AddrParse(#[from] std::net::AddrParseError),
#[error("端口绑定失败: {0}")]
ServerBind(String),
#[error("未找到资源: {0}")]
NotFound(String),
}
impl IntoResponse for StashError {
fn into_response(self) -> Response {
let (status, err_msg) = match self {
StashError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
StashError::InvalidDirectory(msg) => (StatusCode::BAD_REQUEST, msg),
_ => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
};
let body = Json(json!({
"error": err_msg,
"status": status.as_u16(),
}));
(status, body).into_response()
}
}
+9
View File
@@ -0,0 +1,9 @@
pub mod config;
pub mod error;
pub mod listing;
pub mod server;
pub use config::Config;
pub use error::StashError;
pub use listing::{scan_projects, AppProject};
pub use server::{start, ServerHandle};
+75
View File
@@ -0,0 +1,75 @@
use serde::Serialize;
use std::path::Path;
const INDEX_TEMPLATE: &str = include_str!("../assets/index.html");
const NOT_FOUND_TEMPLATE: &str = include_str!("../assets/404.html");
#[derive(Debug, Serialize, Clone)]
pub struct AppProject {
pub name: String,
pub route_path: String,
}
pub fn scan_projects(root: &Path) -> Vec<AppProject> {
let mut projects = Vec::new();
let Ok(read_dir) = std::fs::read_dir(root) else {
return projects;
};
for item in read_dir.flatten() {
let path = item.path();
let name = item.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
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);
projects.push(AppProject { name, route_path });
}
}
}
projects.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
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_not_found(path: &str) -> String {
NOT_FOUND_TEMPLATE.replace("__PATH__", path)
}
+135
View File
@@ -0,0 +1,135 @@
use std::net::SocketAddr;
use std::sync::Arc;
use axum::{
extract::{OriginalUri, State},
http::{header, StatusCode},
response::{Html, IntoResponse, Response},
routing::get,
Json, Router,
};
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};
#[derive(Clone)]
pub struct AppState {
pub config: Arc<Config>,
}
pub struct ServerHandle {
shutdown_tx: oneshot::Sender<()>,
pub local_addr: SocketAddr,
}
impl ServerHandle {
pub fn stop(self) {
let _ = self.shutdown_tx.send(());
}
}
/// 根路径处理器
async fn root_handler(State(state): State<AppState>) -> Result<Response, StashError> {
let index_file = state.config.root.join("index.html");
// 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());
}
// 2. 否则展示筛选后的可用前端应用大厅
let projects = scan_projects(&state.config.root);
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))
}
/// 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
}))
}
pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
if !config.root.exists() {
return Err(StashError::InvalidDirectory(
config.root.display().to_string(),
));
}
let shared_state = AppState {
config: Arc::new(config.clone()),
};
// 静态文件服务:找不到静态资源时 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));
let mut app = Router::new()
.route("/api/health", get(health_check))
.route("/api/projects", get(list_projects_api))
.route("/", get(root_handler))
.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,
})
}