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 = `
页面不存在

${escapeHtml(path)}

回到首页
`; 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 = `
`; try { await matched.route.view(routeElement, { params: matched.match.slice(1), query, path, }); } catch (error) { routeElement.innerHTML = `
加载失败

${escapeHtml(error.message || "未知错误")}

`; 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); } }