feat(web): use history routing and preserve scroll on filter changes
This commit is contained in:
1 parent
1b08854b25
commit
52a8d90c47
9 files changed
+113
-33
No files matched your search
@@ -388,7 +388,12 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}}
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "文件不存在")
|
raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "文件不存在")
|
||||||
else:
|
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():
|
if not target.is_file():
|
||||||
raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "页面不存在")
|
raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "页面不存在")
|
||||||
content_type = mimetypes.guess_type(str(target))[0] or "application/octet-stream"
|
content_type = mimetypes.guess_type(str(target))[0] or "application/octet-stream"
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function icon(name) {
|
|||||||
function renderNav() {
|
function renderNav() {
|
||||||
const nav = document.querySelector("#bottom-nav");
|
const nav = document.querySelector("#bottom-nav");
|
||||||
nav.innerHTML = NAV_ITEMS.map((item) => `
|
nav.innerHTML = NAV_ITEMS.map((item) => `
|
||||||
<a class="nav-item" href="#${item.path}" data-nav="${item.path}">
|
<a class="nav-item" href="${item.path}" data-nav="${item.path}">
|
||||||
${icon(item.icon)}<span class="nav-label">${item.label}</span>
|
${icon(item.icon)}<span class="nav-label">${item.label}</span>
|
||||||
</a>`).join("");
|
</a>`).join("");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function videoCard(item, { rank = null, primary = "view", showStatus = fa
|
|||||||
const status = showStatus && item.status
|
const status = showStatus && item.status
|
||||||
? `<span class="badge status-${escapeAttr(item.status)}">${escapeHtml(statusLabel(item.status))}</span>` : "";
|
? `<span class="badge status-${escapeAttr(item.status)}">${escapeHtml(statusLabel(item.status))}</span>` : "";
|
||||||
return `
|
return `
|
||||||
<a class="video-card${rank ? "" : " no-rank"}" href="#/video/${item.aid}" data-aid="${item.aid}">
|
<a class="video-card${rank ? "" : " no-rank"}" href="/video/${item.aid}" data-aid="${item.aid}">
|
||||||
${rankBadge(rank)}
|
${rankBadge(rank)}
|
||||||
${cover(item, "video-thumb")}
|
${cover(item, "video-thumb")}
|
||||||
<div class="video-card-body">
|
<div class="video-card-body">
|
||||||
@@ -65,7 +65,7 @@ export function upCard(item, { rank = null } = {}) {
|
|||||||
"video-thumb up-thumb",
|
"video-thumb up-thumb",
|
||||||
);
|
);
|
||||||
return `
|
return `
|
||||||
<a class="up-card" href="#/up/${item.up_mid}">
|
<a class="up-card" href="/up/${item.up_mid}">
|
||||||
${rankBadge(rank)}
|
${rankBadge(rank)}
|
||||||
${displayCover}
|
${displayCover}
|
||||||
<div class="up-card-body">
|
<div class="up-card-body">
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="app" id="app">
|
<div class="app" id="app">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a class="brand" href="#/home">
|
<a class="brand" href="/home">
|
||||||
<span class="brand-mark">咻</span>
|
<span class="brand-mark">咻</span>
|
||||||
<span class="brand-text">
|
<span class="brand-text">
|
||||||
<strong>Reaction 看板</strong>
|
<strong>Reaction 看板</strong>
|
||||||
|
|||||||
@@ -5,15 +5,25 @@ let rootElement = null;
|
|||||||
let routeElement = null;
|
let routeElement = null;
|
||||||
let current = null;
|
let current = null;
|
||||||
let routeTable = [];
|
let routeTable = [];
|
||||||
|
let rendering = false;
|
||||||
|
let pendingRender = null;
|
||||||
|
|
||||||
export function parseLocation() {
|
export function parseLocation() {
|
||||||
const raw = window.location.hash.replace(/^#/, "") || "/home";
|
// 兼容旧的 #/home 链接:读取后由 startRouter 替换成干净的路径。
|
||||||
const [pathPart, queryPart = ""] = raw.split("?");
|
if (window.location.hash.startsWith("#/")) {
|
||||||
const path = pathPart.startsWith("/") ? pathPart : `/${pathPart}`;
|
const raw = window.location.hash.slice(1);
|
||||||
return { path, query: new URLSearchParams(queryPart) };
|
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();
|
const params = new URLSearchParams();
|
||||||
if (query) {
|
if (query) {
|
||||||
for (const [key, value] of Object.entries(query)) {
|
for (const [key, value] of Object.entries(query)) {
|
||||||
@@ -22,7 +32,14 @@ export function navigate(path, query) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const text = params.toString();
|
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) {
|
export function replaceQuery(changes) {
|
||||||
@@ -31,8 +48,9 @@ export function replaceQuery(changes) {
|
|||||||
if (value === null || value === undefined || value === "") query.delete(key);
|
if (value === null || value === undefined || value === "") query.delete(key);
|
||||||
else query.set(key, String(value));
|
else query.set(key, String(value));
|
||||||
}
|
}
|
||||||
const text = query.toString();
|
const flat = Object.fromEntries(query.entries());
|
||||||
window.location.hash = `${path}${text ? `?${text}` : ""}`;
|
setUrl(path, flat, { replace: true });
|
||||||
|
render({ preserveScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onRoute(listener) {
|
export function onRoute(listener) {
|
||||||
@@ -43,8 +61,37 @@ export function startRouter({ root, outlet, routes }) {
|
|||||||
rootElement = root;
|
rootElement = root;
|
||||||
routeElement = outlet;
|
routeElement = outlet;
|
||||||
routeTable = routes;
|
routeTable = routes;
|
||||||
window.addEventListener("hashchange", render);
|
if ("scrollRestoration" in window.history) {
|
||||||
render();
|
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() {
|
export function currentRoute() {
|
||||||
@@ -59,19 +106,23 @@ function matchRoute(path, routes) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function render() {
|
async function render(options = {}) {
|
||||||
const { path, query } = parseLocation();
|
if (rendering) {
|
||||||
if (!path || path === "/") {
|
pendingRender = options;
|
||||||
navigate("/home");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
rendering = true;
|
||||||
|
const { scrollTop = false, preserveScroll = false } = options;
|
||||||
|
const previousScroll = window.scrollY;
|
||||||
|
const { path, query } = parseLocation();
|
||||||
const matched = matchRoute(path, routeTable);
|
const matched = matchRoute(path, routeTable);
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
routeElement.innerHTML = `
|
routeElement.innerHTML = `
|
||||||
<section class="page">
|
<section class="page">
|
||||||
<div class="empty-state"><strong>页面不存在</strong>
|
<div class="empty-state"><strong>页面不存在</strong>
|
||||||
<p>${escapeHtml(path)}</p><a class="button" href="#/home">回到首页</a></div>
|
<p>${escapeHtml(path)}</p><a class="button" href="/home">回到首页</a></div>
|
||||||
</section>`;
|
</section>`;
|
||||||
|
finishRender();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
current = { path, query, route: matched.route };
|
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 = `<div class="page-loading"><div class="skeleton skeleton-title"></div>
|
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>`;
|
<div class="skeleton skeleton-card"></div><div class="skeleton skeleton-card"></div></div>`;
|
||||||
try {
|
try {
|
||||||
@@ -100,7 +153,20 @@ async function render() {
|
|||||||
<button class="button" id="retry-button" type="button">重试</button>
|
<button class="button" id="retry-button" type="button">重试</button>
|
||||||
</div>
|
</div>
|
||||||
</section>`;
|
</section>`;
|
||||||
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");
|
rootElement?.classList.remove("is-loading");
|
||||||
|
finishRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishRender() {
|
||||||
|
rendering = false;
|
||||||
|
if (pendingRender) {
|
||||||
|
const next = pendingRender;
|
||||||
|
pendingRender = null;
|
||||||
|
render(next);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,7 @@ function heatStrip(items) {
|
|||||||
if (!items.length) return emptyState("暂无热度数据", "完成一次快照后这里会出现榜单。");
|
if (!items.length) return emptyState("暂无热度数据", "完成一次快照后这里会出现榜单。");
|
||||||
return `<div class="heat-strip">
|
return `<div class="heat-strip">
|
||||||
${items.map((item, index) => `
|
${items.map((item, index) => `
|
||||||
<a class="heat-card" href="#/video/${item.aid}">
|
<a class="heat-card" href="/video/${item.aid}">
|
||||||
<span class="heat-rank">${index + 1}</span>
|
<span class="heat-rank">${index + 1}</span>
|
||||||
${cover(item, "heat-thumb")}
|
${cover(item, "heat-thumb")}
|
||||||
<strong class="heat-title">${escapeHtml(item.title)}</strong>
|
<strong class="heat-title">${escapeHtml(item.title)}</strong>
|
||||||
@@ -82,7 +82,7 @@ export async function renderHome(root) {
|
|||||||
${statChip("待确认候选", formatCount(pending), pending ? "warning" : "")}
|
${statChip("待确认候选", formatCount(pending), pending ? "warning" : "")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${pending ? `<a class="notice-row" href="#/candidates">
|
${pending ? `<a class="notice-row" href="/candidates">
|
||||||
<strong>${formatCount(pending)} 条候选待确认</strong>
|
<strong>${formatCount(pending)} 条候选待确认</strong>
|
||||||
<span>请在 Telegram 中处理 →</span>
|
<span>请在 Telegram 中处理 →</span>
|
||||||
</a>` : ""}
|
</a>` : ""}
|
||||||
@@ -90,7 +90,7 @@ export async function renderHome(root) {
|
|||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>最新入库</h2>
|
<h2>最新入库</h2>
|
||||||
<a class="text-link" href="#/rank?scope=video&sort=added&within_days=7">查看全部</a>
|
<a class="text-link" href="/rank?scope=video&sort=added&within_days=7">查看全部</a>
|
||||||
</div>
|
</div>
|
||||||
${addedNote}
|
${addedNote}
|
||||||
<div class="video-list">
|
<div class="video-list">
|
||||||
@@ -102,7 +102,7 @@ export async function renderHome(root) {
|
|||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>最近发布</h2>
|
<h2>最近发布</h2>
|
||||||
<a class="text-link" href="#/rank?scope=video&sort=published&within_days=14">查看全部</a>
|
<a class="text-link" href="/rank?scope=video&sort=published&within_days=14">查看全部</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="section-note">按视频发布时间排序,与入库时间无关。</p>
|
<p class="section-note">按视频发布时间排序,与入库时间无关。</p>
|
||||||
<div class="video-list">
|
<div class="video-list">
|
||||||
@@ -114,7 +114,7 @@ export async function renderHome(root) {
|
|||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>增长动态</h2>
|
<h2>增长动态</h2>
|
||||||
<a class="text-link" href="#/rank?scope=video&sort=growth&growth_days=7">增长榜</a>
|
<a class="text-link" href="/rank?scope=video&sort=growth&growth_days=7">增长榜</a>
|
||||||
</div>
|
</div>
|
||||||
${growth.available && growth.items.length
|
${growth.available && growth.items.length
|
||||||
? `<div class="video-list">${growth.items.map((item, index) => videoCard(item, { rank: index + 1 })).join("")}</div>`
|
? `<div class="video-list">${growth.items.map((item, index) => videoCard(item, { rank: index + 1 })).join("")}</div>`
|
||||||
@@ -124,7 +124,7 @@ export async function renderHome(root) {
|
|||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>热度速览</h2>
|
<h2>热度速览</h2>
|
||||||
<a class="text-link" href="#/rank?scope=video&sort=heat">完整榜单</a>
|
<a class="text-link" href="/rank?scope=video&sort=heat">完整榜单</a>
|
||||||
</div>
|
</div>
|
||||||
${heatStrip(heat.items)}
|
${heatStrip(heat.items)}
|
||||||
</section>
|
</section>
|
||||||
@@ -141,7 +141,7 @@ export async function renderHome(root) {
|
|||||||
<div><span>最近快照</span><strong>${escapeHtml(formatDateTime(overview.tasks?.snapshot?.finished_at))}</strong></div>
|
<div><span>最近快照</span><strong>${escapeHtml(formatDateTime(overview.tasks?.snapshot?.finished_at))}</strong></div>
|
||||||
<div><span>最近发现</span><strong>${escapeHtml(formatDateTime(overview.tasks?.discovery?.finished_at))}</strong></div>
|
<div><span>最近发现</span><strong>${escapeHtml(formatDateTime(overview.tasks?.discovery?.finished_at))}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<a class="text-link" href="#/system">查看系统状态 →</a>
|
<a class="text-link" href="/system">查看系统状态 →</a>
|
||||||
</section>
|
</section>
|
||||||
</section>`;
|
</section>`;
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export async function renderUp(root, { params, query }) {
|
|||||||
|
|
||||||
root.innerHTML = `<section class="page up-page">
|
root.innerHTML = `<section class="page up-page">
|
||||||
<header class="page-head">
|
<header class="page-head">
|
||||||
<a class="back-link" href="#/rank?scope=up&sort=avg_views">← 返回 UP 榜</a>
|
<a class="back-link" href="/rank?scope=up&sort=avg_views">← 返回 UP 榜</a>
|
||||||
<a class="button ghost" href="${escapeAttr(up.space_url)}" target="_blank" rel="noreferrer">B 站空间</a>
|
<a class="button ghost" href="${escapeAttr(up.space_url)}" target="_blank" rel="noreferrer">B 站空间</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export async function renderVideo(root, { params, query }) {
|
|||||||
|
|
||||||
root.innerHTML = `<section class="page video-page">
|
root.innerHTML = `<section class="page video-page">
|
||||||
<header class="page-head">
|
<header class="page-head">
|
||||||
<a class="back-link" href="#/rank?scope=video&sort=heat">← 返回榜单</a>
|
<a class="back-link" href="/rank?scope=video&sort=heat">← 返回榜单</a>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
${detail.url ? `<a class="button ghost" href="${escapeAttr(detail.url)}" target="_blank" rel="noreferrer">打开 B 站</a>` : ""}
|
${detail.url ? `<a class="button ghost" href="${escapeAttr(detail.url)}" target="_blank" rel="noreferrer">打开 B 站</a>` : ""}
|
||||||
</div>
|
</div>
|
||||||
@@ -57,7 +57,7 @@ export async function renderVideo(root, { params, query }) {
|
|||||||
<div class="detail-hero-body">
|
<div class="detail-hero-body">
|
||||||
<h1>${escapeHtml(detail.title)}</h1>
|
<h1>${escapeHtml(detail.title)}</h1>
|
||||||
<div class="detail-meta">
|
<div class="detail-meta">
|
||||||
${detail.up_mid ? `<a class="inline-link" href="#/up/${detail.up_mid}">${escapeHtml(detail.up_name || "未知 UP")}</a>` : escapeHtml(detail.up_name || "未知 UP")}
|
${detail.up_mid ? `<a class="inline-link" href="/up/${detail.up_mid}">${escapeHtml(detail.up_name || "未知 UP")}</a>` : escapeHtml(detail.up_name || "未知 UP")}
|
||||||
<span>${escapeHtml(formatDate(detail.pubdate))}</span>
|
<span>${escapeHtml(formatDate(detail.pubdate))}</span>
|
||||||
<span>${escapeHtml(formatDuration(detail.duration))}</span>
|
<span>${escapeHtml(formatDuration(detail.duration))}</span>
|
||||||
${detail.folder_title ? badge(detail.folder_title) : ""}
|
${detail.folder_title ? badge(detail.folder_title) : ""}
|
||||||
@@ -98,7 +98,7 @@ export async function renderVideo(root, { params, query }) {
|
|||||||
${up ? `<section class="panel">
|
${up ? `<section class="panel">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>该 UP 的其他视频</h2>
|
<h2>该 UP 的其他视频</h2>
|
||||||
<a class="text-link" href="#/up/${detail.up_mid}">查看 UP 页 →</a>
|
<a class="text-link" href="/up/${detail.up_mid}">查看 UP 页 →</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="video-list">
|
<div class="video-list">
|
||||||
${others.length ? others.map((item) => videoCard(item)).join("") : emptyState("没有其他视频")}
|
${others.length ? others.map((item) => videoCard(item)).join("") : emptyState("没有其他视频")}
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ def test_readonly_routes_and_static():
|
|||||||
status, _, payload = running.request("/static/../config.json")
|
status, _, payload = running.request("/static/../config.json")
|
||||||
assert status == 404
|
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"<html" in route_payload.lower(), route_path
|
||||||
|
status, _, _ = running.request("/nope.js")
|
||||||
|
assert status == 404
|
||||||
|
|
||||||
status, _, payload = running.request("/api/videos")
|
status, _, payload = running.request("/api/videos")
|
||||||
assert status == 200
|
assert status == 200
|
||||||
items = json.loads(payload)["data"]["items"]
|
items = json.loads(payload)["data"]["items"]
|
||||||
|
|||||||
Reference in new issue
Block a user