172 lines
5.3 KiB
JavaScript
172 lines
5.3 KiB
JavaScript
import { escapeHtml } from "./format.js";
|
|
|
|
const listeners = new Set();
|
|
let rootElement = null;
|
|
let routeElement = null;
|
|
let current = null;
|
|
let routeTable = [];
|
|
let rendering = false;
|
|
let pendingRender = null;
|
|
|
|
export function parseLocation() {
|
|
// 兼容旧的 #/home 链接:读取后由 startRouter 替换成干净的路径。
|
|
if (window.location.hash.startsWith("#/")) {
|
|
const raw = window.location.hash.slice(1);
|
|
const [pathPart, queryPart = ""] = raw.split("?");
|
|
return {
|
|
path: pathPart.startsWith("/") ? pathPart : `/${pathPart}`,
|
|
query: new URLSearchParams(queryPart),
|
|
legacyHash: true,
|
|
};
|
|
}
|
|
const path = window.location.pathname === "/" ? "/home" : window.location.pathname;
|
|
return { path, query: new URLSearchParams(window.location.search), legacyHash: false };
|
|
}
|
|
|
|
function setUrl(path, query, { replace = false } = {}) {
|
|
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();
|
|
const url = `${path}${text ? `?${text}` : ""}`;
|
|
if (replace) window.history.replaceState({ path }, "", url);
|
|
else window.history.pushState({ path }, "", url);
|
|
}
|
|
|
|
export function navigate(path, query) {
|
|
setUrl(path, query);
|
|
render({ scrollTop: true });
|
|
}
|
|
|
|
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 flat = Object.fromEntries(query.entries());
|
|
setUrl(path, flat, { replace: true });
|
|
render({ preserveScroll: true });
|
|
}
|
|
|
|
export function onRoute(listener) {
|
|
listeners.add(listener);
|
|
}
|
|
|
|
export function startRouter({ root, outlet, routes }) {
|
|
rootElement = root;
|
|
routeElement = outlet;
|
|
routeTable = routes;
|
|
if ("scrollRestoration" in window.history) {
|
|
window.history.scrollRestoration = "manual";
|
|
}
|
|
window.addEventListener("popstate", () => render({ scrollTop: false }));
|
|
document.addEventListener("click", handleClick);
|
|
const initial = parseLocation();
|
|
if (initial.legacyHash) {
|
|
setUrl(initial.path, Object.fromEntries(initial.query.entries()), { replace: true });
|
|
} else if (window.location.pathname === "/") {
|
|
setUrl("/home", null, { replace: true });
|
|
}
|
|
render({ scrollTop: true });
|
|
}
|
|
|
|
function handleClick(event) {
|
|
if (event.defaultPrevented || event.button !== 0) return;
|
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
const anchor = event.target.closest("a[href]");
|
|
if (!anchor || anchor.target || anchor.hasAttribute("download")) return;
|
|
const url = new URL(anchor.href, window.location.origin);
|
|
if (url.origin !== window.location.origin) return;
|
|
if (
|
|
url.pathname.startsWith("/api/") ||
|
|
url.pathname.startsWith("/static/") ||
|
|
url.pathname === "/login" ||
|
|
url.pathname === "/logout"
|
|
) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
navigate(url.pathname, Object.fromEntries(url.searchParams.entries()));
|
|
}
|
|
|
|
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(options = {}) {
|
|
if (rendering) {
|
|
pendingRender = options;
|
|
return;
|
|
}
|
|
rendering = true;
|
|
const { scrollTop = false, preserveScroll = false } = options;
|
|
const previousScroll = window.scrollY;
|
|
const { path, query } = parseLocation();
|
|
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>`;
|
|
finishRender();
|
|
return;
|
|
}
|
|
current = { path, query, route: matched.route };
|
|
for (const listener of listeners) {
|
|
try {
|
|
listener(current);
|
|
} catch {
|
|
// 导航状态更新不应阻断页面渲染
|
|
}
|
|
}
|
|
if (scrollTop && !preserveScroll) {
|
|
window.scrollTo({ top: 0, behavior: "auto" });
|
|
}
|
|
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({ scrollTop: true }));
|
|
}
|
|
if (preserveScroll) {
|
|
window.scrollTo({ top: previousScroll, behavior: "auto" });
|
|
}
|
|
rootElement?.classList.remove("is-loading");
|
|
finishRender();
|
|
}
|
|
|
|
function finishRender() {
|
|
rendering = false;
|
|
if (pendingRender) {
|
|
const next = pendingRender;
|
|
pendingRender = null;
|
|
render(next);
|
|
}
|
|
} |