feat(web): report page views via sendBeacon and show stats on system page
This commit is contained in:
1 parent
3c16caac1a
commit
b215f8eb09
4 files changed
+154
-3
No files matched your search
@@ -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(() => {});
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, "暂无访问数据");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user