init: 本地静态页面托管服务
- 初始化
This commit is contained in:
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user