From 52a8d90c47a6f993e8688376c28bf9afe9e0b73b Mon Sep 17 00:00:00 2001 From: kaireewu Date: Sun, 1 Nov 2026 01:55:37 +0800 Subject: [PATCH] feat(web): use history routing and preserve scroll on filter changes --- reaction_board/web/server.py | 7 +- reaction_board/web/static/app.js | 2 +- reaction_board/web/static/components.js | 4 +- reaction_board/web/static/index.html | 2 +- reaction_board/web/static/router.js | 100 +++++++++++++++++++---- reaction_board/web/static/views/home.js | 14 ++-- reaction_board/web/static/views/up.js | 2 +- reaction_board/web/static/views/video.js | 6 +- tests/test_web_server.py | 9 ++ 9 files changed, 113 insertions(+), 33 deletions(-) diff --git a/reaction_board/web/server.py b/reaction_board/web/server.py index b47b7ff..220e2be 100644 --- a/reaction_board/web/server.py +++ b/reaction_board/web/server.py @@ -388,7 +388,12 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}} except ValueError: raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "文件不存在") else: - raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "页面不存在") + # 伪静态前端路由:/rank、/video/123、/up/123 等直接返回 index.html, + # 由前端根据 location.pathname 渲染。带扩展名的路径仍按文件处理并返回 404。 + last_segment = path.rstrip("/").rsplit("/", 1)[-1] + if "." in last_segment: + raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "文件不存在") + target = STATIC_ROOT / "index.html" if not target.is_file(): raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "页面不存在") content_type = mimetypes.guess_type(str(target))[0] or "application/octet-stream" diff --git a/reaction_board/web/static/app.js b/reaction_board/web/static/app.js index 9cf0050..3e1bf4e 100644 --- a/reaction_board/web/static/app.js +++ b/reaction_board/web/static/app.js @@ -43,7 +43,7 @@ function icon(name) { function renderNav() { const nav = document.querySelector("#bottom-nav"); nav.innerHTML = NAV_ITEMS.map((item) => ` - + ${icon(item.icon)}${item.label} `).join(""); } diff --git a/reaction_board/web/static/components.js b/reaction_board/web/static/components.js index 0b33ea8..da1c646 100644 --- a/reaction_board/web/static/components.js +++ b/reaction_board/web/static/components.js @@ -32,7 +32,7 @@ export function videoCard(item, { rank = null, primary = "view", showStatus = fa const status = showStatus && item.status ? `${escapeHtml(statusLabel(item.status))}` : ""; return ` - + ${rankBadge(rank)} ${cover(item, "video-thumb")}
@@ -65,7 +65,7 @@ export function upCard(item, { rank = null } = {}) { "video-thumb up-thumb", ); return ` - + ${rankBadge(rank)} ${displayCover}
diff --git a/reaction_board/web/static/index.html b/reaction_board/web/static/index.html index 12eb4b1..6847669 100644 --- a/reaction_board/web/static/index.html +++ b/reaction_board/web/static/index.html @@ -13,7 +13,7 @@
- + 咻 Reaction 看板 diff --git a/reaction_board/web/static/router.js b/reaction_board/web/static/router.js index d03da7a..0bcc757 100644 --- a/reaction_board/web/static/router.js +++ b/reaction_board/web/static/router.js @@ -5,15 +5,25 @@ let rootElement = null; let routeElement = null; let current = null; let routeTable = []; +let rendering = false; +let pendingRender = null; 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) }; + // 兼容旧的 #/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 }; } -export function navigate(path, query) { +function setUrl(path, query, { replace = false } = {}) { const params = new URLSearchParams(); if (query) { for (const [key, value] of Object.entries(query)) { @@ -22,7 +32,14 @@ export function navigate(path, query) { } } const text = params.toString(); - window.location.hash = `${path}${text ? `?${text}` : ""}`; + 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) { @@ -31,8 +48,9 @@ export function replaceQuery(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}` : ""}`; + const flat = Object.fromEntries(query.entries()); + setUrl(path, flat, { replace: true }); + render({ preserveScroll: true }); } export function onRoute(listener) { @@ -43,8 +61,37 @@ export function startRouter({ root, outlet, routes }) { rootElement = root; routeElement = outlet; routeTable = routes; - window.addEventListener("hashchange", render); - render(); + 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() { @@ -59,19 +106,23 @@ function matchRoute(path, routes) { return null; } -async function render() { - const { path, query } = parseLocation(); - if (!path || path === "/") { - navigate("/home"); +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 }; @@ -82,7 +133,9 @@ async function render() { // 导航状态更新不应阻断页面渲染 } } - window.scrollTo({ top: 0, behavior: "auto" }); + if (scrollTop && !preserveScroll) { + window.scrollTo({ top: 0, behavior: "auto" }); + } routeElement.innerHTML = `
`; try { @@ -100,7 +153,20 @@ async function render() {
`; - routeElement.querySelector("#retry-button")?.addEventListener("click", render); + 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); + } +} \ No newline at end of file diff --git a/reaction_board/web/static/views/home.js b/reaction_board/web/static/views/home.js index 61cc5df..7ca2bc4 100644 --- a/reaction_board/web/static/views/home.js +++ b/reaction_board/web/static/views/home.js @@ -17,7 +17,7 @@ function heatStrip(items) { if (!items.length) return emptyState("暂无热度数据", "完成一次快照后这里会出现榜单。"); return ` - ${pending ? ` + ${pending ? ` ${formatCount(pending)} 条候选待确认 请在 Telegram 中处理 → ` : ""} @@ -90,7 +90,7 @@ export async function renderHome(root) {

最新入库

- 查看全部 + 查看全部
${addedNote}
@@ -102,7 +102,7 @@ export async function renderHome(root) {

最近发布

- 查看全部 + 查看全部

按视频发布时间排序,与入库时间无关。

@@ -114,7 +114,7 @@ export async function renderHome(root) {

增长动态

- 增长榜 + 增长榜
${growth.available && growth.items.length ? `
${growth.items.map((item, index) => videoCard(item, { rank: index + 1 })).join("")}
` @@ -124,7 +124,7 @@ export async function renderHome(root) {

热度速览

- 完整榜单 + 完整榜单
${heatStrip(heat.items)}
@@ -141,7 +141,7 @@ export async function renderHome(root) {
最近快照${escapeHtml(formatDateTime(overview.tasks?.snapshot?.finished_at))}
最近发现${escapeHtml(formatDateTime(overview.tasks?.discovery?.finished_at))}
- 查看系统状态 → + 查看系统状态 →
`; diff --git a/reaction_board/web/static/views/up.js b/reaction_board/web/static/views/up.js index 8d0c438..497e43a 100644 --- a/reaction_board/web/static/views/up.js +++ b/reaction_board/web/static/views/up.js @@ -35,7 +35,7 @@ export async function renderUp(root, { params, query }) { root.innerHTML = `
- ← 返回 UP 榜 + ← 返回 UP 榜 B 站空间
diff --git a/reaction_board/web/static/views/video.js b/reaction_board/web/static/views/video.js index 78343cd..96eb114 100644 --- a/reaction_board/web/static/views/video.js +++ b/reaction_board/web/static/views/video.js @@ -46,7 +46,7 @@ export async function renderVideo(root, { params, query }) { root.innerHTML = `
- ← 返回榜单 + ← 返回榜单
${detail.url ? `打开 B 站` : ""}
@@ -57,7 +57,7 @@ export async function renderVideo(root, { params, query }) {

${escapeHtml(detail.title)}

- ${detail.up_mid ? `${escapeHtml(detail.up_name || "未知 UP")}` : escapeHtml(detail.up_name || "未知 UP")} + ${detail.up_mid ? `${escapeHtml(detail.up_name || "未知 UP")}` : escapeHtml(detail.up_name || "未知 UP")} ${escapeHtml(formatDate(detail.pubdate))} ${escapeHtml(formatDuration(detail.duration))} ${detail.folder_title ? badge(detail.folder_title) : ""} @@ -98,7 +98,7 @@ export async function renderVideo(root, { params, query }) { ${up ? `

该 UP 的其他视频

- 查看 UP 页 → + 查看 UP 页 →
${others.length ? others.map((item) => videoCard(item)).join("") : emptyState("没有其他视频")} diff --git a/tests/test_web_server.py b/tests/test_web_server.py index b4e1b83..2f512c4 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -84,6 +84,15 @@ def test_readonly_routes_and_static(): status, _, payload = running.request("/static/../config.json") assert status == 404 + # 伪静态前端路由:直接访问 /rank、/video/... 返回 index.html + for route_path in ("/rank", "/video/114775988111033", "/up/230020975", "/fans"): + status, route_headers, route_payload = running.request(route_path) + assert status == 200, route_path + assert "text/html" in route_headers["Content-Type"], route_path + assert b"