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
+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");
});
})();