feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面 - 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件 - 新增 widget 嵌入代码生成(widgetCode)与 widgetApi - 遥测逻辑迁移至 handler 层,删除 service/telemetry - env 示例补充 Docker 部署可信代理 IP 说明
This commit is contained in:
118
frontend/app/admin/analytics/realtime/page.tsx
Normal file
118
frontend/app/admin/analytics/realtime/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { Activity, Zap, Users, Clock } from "lucide-react";
|
||||
import { AdminStatCard } from "@/components/admin";
|
||||
import { AdminChartCard, SingleAreaChart } from "@/components/admin/charts";
|
||||
import { apiStatsRealtime, type VisitRealtime } from "@/lib/api";
|
||||
import { CardShell, StatsState, TableEmpty, deviceLabel, formatInt, kindLabel, usePolling } from "../ui";
|
||||
|
||||
export default function RealtimePage() {
|
||||
const [data, setData] = useState<VisitRealtime | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(() => {
|
||||
apiStatsRealtime()
|
||||
.then((d) => {
|
||||
setData(d);
|
||||
setError("");
|
||||
})
|
||||
.catch(() => setError("加载失败,请稍后重试"));
|
||||
}, []);
|
||||
usePolling(load, 15_000);
|
||||
|
||||
if (!data) return <StatsState loading={!error} error={error || undefined} />;
|
||||
|
||||
// 兜底:旧后端可能返回 null(Go nil 切片)
|
||||
const minutes = (data.minutes ?? []).map((m) => ({
|
||||
// SingleAreaChart 取 date 字段画 X 轴:只留 HH:MM
|
||||
date: m.minute.length >= 16 ? m.minute.slice(11) : m.minute,
|
||||
pv: m.pv,
|
||||
}));
|
||||
const topPages = data.top_pages ?? [];
|
||||
const recent = data.recent ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="meta text-[12.5px]">每 15 秒自动刷新;切回前台标签页时立即刷新。</p>
|
||||
{error ? <StatsState error={error} /> : null}
|
||||
|
||||
<div className="grid gap-3 grid-cols-2 xl:grid-cols-4">
|
||||
<AdminStatCard label="当前在线" value={formatInt(data.online)} hint="近 5 分钟活跃用户" icon={<Users size={17} />} />
|
||||
<AdminStatCard
|
||||
label="近 5 分钟 PV"
|
||||
value={formatInt(data.pv_5min)}
|
||||
icon={<Zap size={17} />}
|
||||
color="var(--ok)"
|
||||
soft="var(--ok-soft, var(--panel-2))"
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="近 30 分钟 PV"
|
||||
value={formatInt(data.pv_30min)}
|
||||
icon={<Activity size={17} />}
|
||||
color="var(--gold)"
|
||||
soft="var(--gold-soft)"
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="数据延迟"
|
||||
value="< 3s"
|
||||
hint="事件攒批落库周期"
|
||||
icon={<Clock size={17} />}
|
||||
color="var(--ink-2)"
|
||||
soft="var(--panel-2)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminChartCard title="近 60 分钟浏览" hint="分钟级 PV">
|
||||
{minutes.length ? (
|
||||
<SingleAreaChart data={minutes} dataKey="pv" name="PV" />
|
||||
) : (
|
||||
<TableEmpty text="暂无浏览数据" />
|
||||
)}
|
||||
</AdminChartCard>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<CardShell title="近 5 分钟热门路径">
|
||||
{topPages.length ? (
|
||||
<ul className="flex flex-col divide-y" style={{ borderColor: "var(--line)" }}>
|
||||
{topPages.map((p) => (
|
||||
<li key={p.key} className="py-2 flex items-center gap-3 text-[13px]">
|
||||
<span className="flex-1 min-w-0 truncate" style={{ color: "var(--ink)" }}>
|
||||
{p.key || "/"}
|
||||
</span>
|
||||
<span className="tabular-nums shrink-0" style={{ color: "var(--ink-2)" }}>
|
||||
{formatInt(p.count)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<TableEmpty />
|
||||
)}
|
||||
</CardShell>
|
||||
|
||||
<CardShell title="最新浏览" hint="最多 20 条">
|
||||
{recent.length ? (
|
||||
<ul className="flex flex-col divide-y max-h-[420px] overflow-y-auto" style={{ borderColor: "var(--line)" }}>
|
||||
{recent.map((e) => (
|
||||
<li key={e.id} className="py-2 flex items-center gap-2.5 text-[13px]">
|
||||
<span className="w-[4.5rem] shrink-0 tabular-nums text-[11.5px]" style={{ color: "var(--ink-3)" }}>
|
||||
{new Date(e.created_at).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate" style={{ color: "var(--ink)" }} title={e.path}>
|
||||
{e.path || "/"}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11.5px]" style={{ color: "var(--ink-3)" }}>
|
||||
{deviceLabel(e.device)} · {kindLabel(e.kind)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<TableEmpty />
|
||||
)}
|
||||
</CardShell>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user