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();
|
const payload = await response.json();
|
||||||
return payload?.data ?? payload;
|
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 { disposeAll } from "./charts.js";
|
||||||
import { onRoute, parseLocation, startRouter } from "./router.js";
|
import { onRoute, parseLocation, startRouter } from "./router.js";
|
||||||
import { renderCandidates } from "./views/candidates.js";
|
import { renderCandidates } from "./views/candidates.js";
|
||||||
@@ -153,9 +153,15 @@ async function boot() {
|
|||||||
toast("无法连接看板服务", "error");
|
toast("无法连接看板服务", "error");
|
||||||
}
|
}
|
||||||
renderNav();
|
renderNav();
|
||||||
|
let lastVisitPath = null;
|
||||||
onRoute(({ path, route }) => {
|
onRoute(({ path, route }) => {
|
||||||
disposeAll();
|
disposeAll();
|
||||||
setTitle(route, path);
|
setTitle(route, path);
|
||||||
|
// 访问统计:仅在路由路径变化时上报(排序/筛选等同路径重渲染不计 PV)。
|
||||||
|
if (path !== lastVisitPath) {
|
||||||
|
lastVisitPath = path;
|
||||||
|
visit(path);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
startRouter({
|
startRouter({
|
||||||
root: document.querySelector("#app"),
|
root: document.querySelector("#app"),
|
||||||
|
|||||||
@@ -189,3 +189,75 @@ export function emptyChart(element, message = "暂无数据") {
|
|||||||
if (!element) return;
|
if (!element) return;
|
||||||
element.innerHTML = `<div class="chart-empty">${message}</div>`;
|
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 { emptyState, sectionHeader } from "../components.js";
|
||||||
import {
|
import {
|
||||||
escapeHtml, formatCount, formatDateTime, formatNumber,
|
escapeHtml, formatCount, formatDateTime, formatNumber,
|
||||||
@@ -30,11 +31,42 @@ function taskRows(tasks) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function renderSystem(root) {
|
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 health = system.snapshot_health || { total: 0, ok: 0, failed: 0 };
|
||||||
const counts = system.counts || {};
|
const counts = system.counts || {};
|
||||||
const next = system.next_runs || {};
|
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">
|
root.innerHTML = `<section class="page system-page">
|
||||||
<header class="page-head">
|
<header class="page-head">
|
||||||
<div>
|
<div>
|
||||||
@@ -51,6 +83,8 @@ export async function renderSystem(root) {
|
|||||||
<div><span>已移出库</span><strong>${formatCount(counts.dropped)}</strong></div>
|
<div><span>已移出库</span><strong>${formatCount(counts.dropped)}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${visitPanel}
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
${sectionHeader("最近任务")}
|
${sectionHeader("最近任务")}
|
||||||
${taskRows(system.tasks)}
|
${taskRows(system.tasks)}
|
||||||
@@ -119,4 +153,19 @@ export async function renderSystem(root) {
|
|||||||
</div>` : emptyState("黑名单为空")}
|
</div>` : emptyState("黑名单为空")}
|
||||||
</section>
|
</section>
|
||||||
</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