feat(web): add mobile-first read-only dashboard frontend
This commit is contained in:
1 parent
257fbe009b
commit
cbba405271
18 files changed
+2628
-2
No files matched your search
@@ -0,0 +1,106 @@
|
||||
import { escapeHtml } from "./format.js";
|
||||
|
||||
const listeners = new Set();
|
||||
let rootElement = null;
|
||||
let routeElement = null;
|
||||
let current = null;
|
||||
let routeTable = [];
|
||||
|
||||
export function parseLocation() {
|
||||
const raw = window.location.hash.replace(/^#/, "") || "/home";
|
||||
const [pathPart, queryPart = ""] = raw.split("?");
|
||||
const path = pathPart.startsWith("/") ? pathPart : `/${pathPart}`;
|
||||
return { path, query: new URLSearchParams(queryPart) };
|
||||
}
|
||||
|
||||
export function navigate(path, query) {
|
||||
const params = new URLSearchParams();
|
||||
if (query) {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === null || value === undefined || value === "") continue;
|
||||
params.set(key, String(value));
|
||||
}
|
||||
}
|
||||
const text = params.toString();
|
||||
window.location.hash = `${path}${text ? `?${text}` : ""}`;
|
||||
}
|
||||
|
||||
export function replaceQuery(changes) {
|
||||
const { path, query } = parseLocation();
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (value === null || value === undefined || value === "") query.delete(key);
|
||||
else query.set(key, String(value));
|
||||
}
|
||||
const text = query.toString();
|
||||
window.location.hash = `${path}${text ? `?${text}` : ""}`;
|
||||
}
|
||||
|
||||
export function onRoute(listener) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
export function startRouter({ root, outlet, routes }) {
|
||||
rootElement = root;
|
||||
routeElement = outlet;
|
||||
routeTable = routes;
|
||||
window.addEventListener("hashchange", render);
|
||||
render();
|
||||
}
|
||||
|
||||
export function currentRoute() {
|
||||
return current;
|
||||
}
|
||||
|
||||
function matchRoute(path, routes) {
|
||||
for (const route of routes) {
|
||||
const match = route.pattern.exec(path);
|
||||
if (match) return { route, match };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const { path, query } = parseLocation();
|
||||
if (!path || path === "/") {
|
||||
navigate("/home");
|
||||
return;
|
||||
}
|
||||
const matched = matchRoute(path, routeTable);
|
||||
if (!matched) {
|
||||
routeElement.innerHTML = `
|
||||
<section class="page">
|
||||
<div class="empty-state"><strong>页面不存在</strong>
|
||||
<p>${escapeHtml(path)}</p><a class="button" href="#/home">回到首页</a></div>
|
||||
</section>`;
|
||||
return;
|
||||
}
|
||||
current = { path, query, route: matched.route };
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(current);
|
||||
} catch {
|
||||
// 导航状态更新不应阻断页面渲染
|
||||
}
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: "instant" });
|
||||
routeElement.innerHTML = `<div class="page-loading"><div class="skeleton skeleton-title"></div>
|
||||
<div class="skeleton skeleton-card"></div><div class="skeleton skeleton-card"></div></div>`;
|
||||
try {
|
||||
await matched.route.view(routeElement, {
|
||||
params: matched.match.slice(1),
|
||||
query,
|
||||
path,
|
||||
});
|
||||
} catch (error) {
|
||||
routeElement.innerHTML = `
|
||||
<section class="page">
|
||||
<div class="empty-state error-state">
|
||||
<strong>加载失败</strong>
|
||||
<p>${escapeHtml(error.message || "未知错误")}</p>
|
||||
<button class="button" id="retry-button" type="button">重试</button>
|
||||
</div>
|
||||
</section>`;
|
||||
routeElement.querySelector("#retry-button")?.addEventListener("click", render);
|
||||
}
|
||||
rootElement?.classList.remove("is-loading");
|
||||
}
|
||||
Reference in new issue
Block a user