From b215f8eb097392c99c31230777a13ad7bf00d44d Mon Sep 17 00:00:00 2001 From: kaireewu Date: Sun, 1 Nov 2026 15:36:42 +0800 Subject: [PATCH] feat(web): report page views via sendBeacon and show stats on system page --- reaction_board/web/static/api.js | 24 ++++++++ reaction_board/web/static/app.js | 8 ++- reaction_board/web/static/charts.js | 72 +++++++++++++++++++++++ reaction_board/web/static/views/system.js | 53 ++++++++++++++++- 4 files changed, 154 insertions(+), 3 deletions(-) diff --git a/reaction_board/web/static/api.js b/reaction_board/web/static/api.js index b25f170..d1781e9 100644 --- a/reaction_board/web/static/api.js +++ b/reaction_board/web/static/api.js @@ -57,3 +57,27 @@ export async function session() { const payload = await response.json(); return payload?.data ?? payload; } + +export async function stats(params) { + return api("/api/stats", params); +} + +export function visit(path) { + // 访问统计埋点:优先 sendBeacon(不阻塞导航),失败时用 keepalive fetch 兜底。 + const body = JSON.stringify({ path }); + try { + if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") { + const blob = new Blob([body], { type: "application/json" }); + if (navigator.sendBeacon("/api/visit", blob)) return; + } + } catch { + // 忽略,走 fetch 兜底 + } + fetch("/api/visit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + credentials: "same-origin", + keepalive: true, + }).catch(() => {}); +} diff --git a/reaction_board/web/static/app.js b/reaction_board/web/static/app.js index 7441acf..1d6fc13 100644 --- a/reaction_board/web/static/app.js +++ b/reaction_board/web/static/app.js @@ -1,4 +1,4 @@ -import { session } from "./api.js"; +import { session, visit } from "./api.js"; import { disposeAll } from "./charts.js"; import { onRoute, parseLocation, startRouter } from "./router.js"; import { renderCandidates } from "./views/candidates.js"; @@ -153,9 +153,15 @@ async function boot() { toast("无法连接看板服务", "error"); } renderNav(); + let lastVisitPath = null; onRoute(({ path, route }) => { disposeAll(); setTitle(route, path); + // 访问统计:仅在路由路径变化时上报(排序/筛选等同路径重渲染不计 PV)。 + if (path !== lastVisitPath) { + lastVisitPath = path; + visit(path); + } }); startRouter({ root: document.querySelector("#app"), diff --git a/reaction_board/web/static/charts.js b/reaction_board/web/static/charts.js index 6f5b313..4c28d19 100644 --- a/reaction_board/web/static/charts.js +++ b/reaction_board/web/static/charts.js @@ -189,3 +189,75 @@ export function emptyChart(element, message = "暂无数据") { if (!element) return; element.innerHTML = `
${message}
`; } + +export function mixedChart(element, { dates, bars, line, barName = "PV", lineName = "UV" }) { + if (!element) return null; + const theme = colors(); + const option = { + animationDuration: 300, + backgroundColor: "transparent", + grid: { left: 8, right: 12, top: 34, bottom: 8, containLabel: true }, + legend: { + data: [barName, lineName], + top: 0, + textStyle: { color: theme.muted, fontSize: 11 }, + }, + tooltip: { + trigger: "axis", + axisPointer: { type: "shadow" }, + backgroundColor: theme.surface, + borderColor: theme.grid, + textStyle: { color: theme.text, fontSize: 12 }, + }, + xAxis: { + type: "category", + data: dates, + axisLine: { lineStyle: { color: theme.grid } }, + axisTick: { show: false }, + axisLabel: { + color: theme.muted, + fontSize: 11, + formatter: (value) => formatDay(value), + hideOverlap: true, + }, + }, + yAxis: [ + { + type: "value", + name: barName, + minInterval: 1, + splitLine: { lineStyle: { color: theme.grid, type: "dashed" } }, + axisLabel: { color: theme.muted, fontSize: 11, formatter: (value) => formatAxis(value) }, + }, + { + type: "value", + name: lineName, + minInterval: 1, + splitLine: { show: false }, + axisLabel: { color: theme.muted, fontSize: 11, formatter: (value) => formatAxis(value) }, + }, + ], + series: [ + { + name: barName, + type: "bar", + data: bars, + barMaxWidth: 22, + itemStyle: { color: theme.blue, borderRadius: [4, 4, 0, 0] }, + }, + { + name: lineName, + type: "line", + yAxisIndex: 1, + data: line, + smooth: false, + connectNulls: false, + symbol: "circle", + symbolSize: 5, + lineStyle: { width: 2.2, color: theme.accent }, + itemStyle: { color: theme.accent }, + }, + ], + }; + return mount(element, option); +} diff --git a/reaction_board/web/static/views/system.js b/reaction_board/web/static/views/system.js index 858471d..c3343da 100644 --- a/reaction_board/web/static/views/system.js +++ b/reaction_board/web/static/views/system.js @@ -1,4 +1,5 @@ -import { api } from "../api.js"; +import { api, stats } from "../api.js"; +import { emptyChart, mixedChart } from "../charts.js"; import { emptyState, sectionHeader } from "../components.js"; import { escapeHtml, formatCount, formatDateTime, formatNumber, @@ -30,11 +31,42 @@ function taskRows(tasks) { } export async function renderSystem(root) { - const system = await api("/api/system"); + const [system, visitStats] = await Promise.all([ + api("/api/system"), + stats({ days: 30 }).catch(() => null), + ]); const health = system.snapshot_health || { total: 0, ok: 0, failed: 0 }; const counts = system.counts || {}; const next = system.next_runs || {}; + const visitExtra = visitStats + ? `近 ${visitStats.days} 天 · ${visitStats.pending ? "含未落库缓冲" : "已落库"}` + : ""; + const visitPanel = visitStats ? ` +
+ ${sectionHeader("访问统计", visitExtra)} +
+
今日 PV${formatNumber(visitStats.today_pv)}
+
今日 UV${formatNumber(visitStats.today_uv)}
+
${visitStats.days} 天 PV${formatNumber(visitStats.total_pv)}
+
${visitStats.days} 天 UV${formatNumber(visitStats.total_uv)}
+
+
+

柱状为每日 PV,折线为每日 UV(按客户端 IP 去重)。数据来自前端埋点,先落内存、每分钟批量入库;仅统计页面访问,不含 API 与静态资源请求。

+ ${(visitStats.top_pages || []).length ? `
+ + + + ${visitStats.top_pages.map((page) => ` + + + + `).join("")} + +
页面PVUV
${escapeHtml(page.path)}${formatNumber(page.pv)}${formatNumber(page.uv)}
+
` : `

暂无访问记录。

`} +
` : ""; + root.innerHTML = `
@@ -51,6 +83,8 @@ export async function renderSystem(root) {
已移出库${formatCount(counts.dropped)}
+ ${visitPanel} +
${sectionHeader("最近任务")} ${taskRows(system.tasks)} @@ -119,4 +153,19 @@ export async function renderSystem(root) { ` : emptyState("黑名单为空")}
`; + + const visitChart = root.querySelector("#visit-chart"); + if (visitChart) { + if (visitStats && (visitStats.total_pv || visitStats.total_uv)) { + mixedChart(visitChart, { + dates: visitStats.series.dates, + bars: visitStats.series.pv, + line: visitStats.series.uv, + barName: "PV", + lineName: "UV", + }); + } else { + emptyChart(visitChart, "暂无访问数据"); + } + } }