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:
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");
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user