feat: 重构导航页并增加项目实时推送
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -24,4 +24,4 @@ impl Default for Config {
|
||||
enable_cors: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
["<", "<", "<", "<"]
|
||||
.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("<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
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: ¬ify::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/");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user