Files
jiang13-bbs/frontend/app/admin/analytics/visitors/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

112 lines
4.1 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 { useEffect, useState } from "react";
import { Users, UserPlus, Repeat, LogIn } from "lucide-react";
import { AdminStatCard } from "@/components/admin";
import { AdminChartCard, NamedBarChart, NamedPieChart } from "@/components/admin/charts";
import { apiStatsVisitors, type VisitVisitors } from "@/lib/api";
import { RangeSegmented, StatsState, TableEmpty, formatInt } from "../ui";
export default function VisitorsPage() {
const [range, setRange] = useState<"7d" | "30d">("7d");
const [data, setData] = useState<VisitVisitors | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let alive = true;
setLoading(true);
apiStatsVisitors(range)
.then((d) => {
if (alive) {
setData(d);
setError("");
}
})
.catch(() => alive && setError("加载失败,请稍后重试"))
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
}, [range]);
if (!data) return <StatsState loading={loading} error={error || undefined} />;
const devices = data.devices.map((d) => ({ label: d.label || "未知", count: d.count }));
const oses = data.oses.map((d) => ({ label: d.label || "未知", count: d.count }));
const browsers = data.browsers.map((d) => ({ label: d.label || "未知", count: d.count }));
const hourly = data.hourly.map((h) => ({ label: `${h.key}时`, count: h.count }));
const totalPv = data.logged_in_pv + data.guest_pv;
const totalVisitors = data.new_visitors + data.returning;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-2 flex-wrap">
<p className="meta text-[12.5px]">基于匿名访客标识(仅存哈希)与登录用户统计。</p>
<RangeSegmented value={range} onChange={setRange} />
</div>
{error ? <StatsState error={error} /> : null}
<div className="grid gap-3 grid-cols-2 xl:grid-cols-4">
<AdminStatCard label="登录用户浏览" value={formatInt(data.logged_in_pv)} icon={<LogIn size={17} />} />
<AdminStatCard
label="游客浏览"
value={formatInt(data.guest_pv)}
icon={<Users size={17} />}
color="var(--ok)"
soft="var(--ok-soft, var(--panel-2))"
/>
<AdminStatCard
label="新访客"
value={formatInt(data.new_visitors)}
sub={totalVisitors > 0 ? `${Math.round((data.new_visitors / totalVisitors) * 100)}%` : undefined}
hint="统计期内首次出现"
icon={<UserPlus size={17} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
<AdminStatCard
label="回访"
value={formatInt(data.returning)}
sub={totalVisitors > 0 ? `${Math.round((data.returning / totalVisitors) * 100)}%` : undefined}
icon={<Repeat size={17} />}
color="var(--ink-2)"
soft="var(--panel-2)"
/>
</div>
{totalPv > 0 ? (
<AdminChartCard title="登录 vs 游客" hint={`近 ${data.range_days} 日浏览占比`}>
<NamedPieChart
data={[
{ label: "登录用户", count: data.logged_in_pv },
{ label: "游客", count: data.guest_pv },
]}
/>
</AdminChartCard>
) : null}
<div className="grid gap-4 lg:grid-cols-3">
<AdminChartCard title="设备分布">
{devices.length ? <NamedPieChart data={devices} /> : <TableEmpty />}
</AdminChartCard>
<AdminChartCard title="操作系统 Top 8">
{oses.length ? <NamedPieChart data={oses} /> : <TableEmpty />}
</AdminChartCard>
<AdminChartCard title="浏览器 Top 8">
{browsers.length ? <NamedPieChart data={browsers} /> : <TableEmpty />}
</AdminChartCard>
</div>
<AdminChartCard title="24 小时活跃分布" hint="各小时浏览量(本地时区)">
{hourly.length ? (
<NamedBarChart data={hourly} />
) : (
<TableEmpty />
)}
</AdminChartCard>
</div>
);
}