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
-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>