feat: 站点访问统计与可配置侧边栏组件

- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面
- 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件
- 新增 widget 嵌入代码生成(widgetCode)与 widgetApi
- 遥测逻辑迁移至 handler 层,删除 service/telemetry
- env 示例补充 Docker 部署可信代理 IP 说明
This commit is contained in:
2026-09-24 18:53:45 +08:00
parent 3e55b5d230
commit 7b327bc8cc
60 changed files with 6539 additions and 572 deletions

View 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>
);
}