feat(web): report page views via sendBeacon and show stats on system page

This commit is contained in:
kairee committed 2026-11-01 15:36:42 +08:00
1 parent 3c16caac1a
commit b215f8eb09
4 files changed
+154 -3

No files matched your search

+24
View File
@@ -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(() => {});
}
+7 -1
View File
@@ -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"),
+72
View File
@@ -189,3 +189,75 @@ export function emptyChart(element, message = "暂无数据") {
if (!element) return;
element.innerHTML = `<div class="chart-empty">${message}</div>`;
}
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);
}
+51 -2
View File
@@ -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
? `<span class="section-note">近 ${visitStats.days} 天 · ${visitStats.pending ? "含未落库缓冲" : "已落库"}</span>`
: "";
const visitPanel = visitStats ? `
<section class="panel chart-panel">
${sectionHeader("访问统计", visitExtra)}
<div class="mini-stats">
<div><span>今日 PV</span><strong>${formatNumber(visitStats.today_pv)}</strong></div>
<div><span>今日 UV</span><strong>${formatNumber(visitStats.today_uv)}</strong></div>
<div><span>${visitStats.days} 天 PV</span><strong>${formatNumber(visitStats.total_pv)}</strong></div>
<div><span>${visitStats.days} 天 UV</span><strong>${formatNumber(visitStats.total_uv)}</strong></div>
</div>
<div class="chart" id="visit-chart"></div>
<p class="section-note">柱状为每日 PV,折线为每日 UV(按客户端 IP 去重)。数据来自前端埋点,先落内存、每分钟批量入库;仅统计页面访问,不含 API 与静态资源请求。</p>
${(visitStats.top_pages || []).length ? `<div class="table-wrap">
<table class="data-table">
<thead><tr><th>页面</th><th>PV</th><th>UV</th></tr></thead>
<tbody>
${visitStats.top_pages.map((page) => `<tr>
<td>${escapeHtml(page.path)}</td>
<td>${formatNumber(page.pv)}</td>
<td>${formatNumber(page.uv)}</td>
</tr>`).join("")}
</tbody>
</table>
</div>` : `<p class="section-note">暂无访问记录。</p>`}
</section>` : "";
root.innerHTML = `<section class="page system-page">
<header class="page-head">
<div>
@@ -51,6 +83,8 @@ export async function renderSystem(root) {
<div><span>已移出库</span><strong>${formatCount(counts.dropped)}</strong></div>
</div>
${visitPanel}
<section class="panel">
${sectionHeader("最近任务")}
${taskRows(system.tasks)}
@@ -119,4 +153,19 @@ export async function renderSystem(root) {
</div>` : emptyState("黑名单为空")}
</section>
</section>`;
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, "暂无访问数据");
}
}
}