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 ? `
+
+ | 页面 | PV | UV |
+
+ ${visitStats.top_pages.map((page) => `
+ | ${escapeHtml(page.path)} |
+ ${formatNumber(page.pv)} |
+ ${formatNumber(page.uv)} |
+
`).join("")}
+
+
+
` : `暂无访问记录。
`}
+ ` : "";
+
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, "暂无访问数据");
+ }
+ }
}