Files
jiang13-bbs/frontend/app/admin/analytics/realtime/page.tsx
freefire 7b327bc8cc feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面
- 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件
- 新增 widget 嵌入代码生成(widgetCode)与 widgetApi
- 遥测逻辑迁移至 handler 层,删除 service/telemetry
- env 示例补充 Docker 部署可信代理 IP 说明
2026-09-24 18:53:45 +08:00

119 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}