feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面 - 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件 - 新增 widget 嵌入代码生成(widgetCode)与 widgetApi - 遥测逻辑迁移至 handler 层,删除 service/telemetry - env 示例补充 Docker 部署可信代理 IP 说明
This commit is contained in:
137
frontend/app/admin/analytics/OverviewClient.tsx
Normal file
137
frontend/app/admin/analytics/OverviewClient.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Eye, Users, LogIn, CalendarDays } from "lucide-react";
|
||||
import { AdminStatCard } from "@/components/admin";
|
||||
import { AdminChartCard, NamedBarChart, NamedPieChart, VisitTrendChart } from "@/components/admin/charts";
|
||||
import { apiStatsOverview, type VisitOverview, type VisitPageRow } from "@/lib/api";
|
||||
import { formatInt, RangeSegmented, StatsState, TableEmpty, deviceLabel, refKindLabel } from "./ui";
|
||||
|
||||
export default function OverviewClient({ initial }: { initial: VisitOverview | null }) {
|
||||
const [range, setRange] = useState<"7d" | "30d">("7d");
|
||||
const [data, setData] = useState<VisitOverview | null>(initial);
|
||||
const [loading, setLoading] = useState(!initial);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
apiStatsOverview(range)
|
||||
.then((d) => {
|
||||
if (alive) {
|
||||
setData(d);
|
||||
setError("");
|
||||
}
|
||||
})
|
||||
.catch(() => alive && setError("加载失败,请稍后重试"))
|
||||
.finally(() => alive && setLoading(false));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [range]);
|
||||
|
||||
const delta = (today: number, yesterday: number) => {
|
||||
if (yesterday <= 0) return today > 0 ? "+100%" : "—";
|
||||
const pct = Math.round(((today - yesterday) / yesterday) * 100);
|
||||
return `${pct >= 0 ? "+" : ""}${pct}%`;
|
||||
};
|
||||
|
||||
if (!data) return <StatsState loading={loading} error={error || undefined} />;
|
||||
|
||||
const devices = data.devices.map((d) => ({ label: deviceLabel(d.key), count: d.count }));
|
||||
const sources = data.sources.map((d) => ({ label: refKindLabel(d.key), count: d.count }));
|
||||
|
||||
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]">
|
||||
今日 PV {formatInt(data.today_pv)} · UV {formatInt(data.today_uv)}
|
||||
</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="今日 PV"
|
||||
value={formatInt(data.today_pv)}
|
||||
sub={`${delta(data.today_pv, data.yesterday_pv)} vs 昨日`}
|
||||
icon={<Eye size={17} />}
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="今日 UV"
|
||||
value={formatInt(data.today_uv)}
|
||||
sub={`${delta(data.today_uv, data.yesterday_uv)} vs 昨日`}
|
||||
icon={<Users size={17} />}
|
||||
color="var(--ok)"
|
||||
soft="var(--ok-soft, var(--panel-2))"
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="今日登录浏览"
|
||||
value={formatInt(data.logged_in_pv)}
|
||||
hint={
|
||||
data.today_pv > 0
|
||||
? `占今日 PV ${Math.round((data.logged_in_pv / data.today_pv) * 100)}%`
|
||||
: undefined
|
||||
}
|
||||
icon={<LogIn size={17} />}
|
||||
color="var(--gold)"
|
||||
soft="var(--gold-soft)"
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="昨日 PV"
|
||||
value={formatInt(data.yesterday_pv)}
|
||||
hint={`昨日 UV ${formatInt(data.yesterday_uv)}`}
|
||||
icon={<CalendarDays size={17} />}
|
||||
color="var(--ink-2)"
|
||||
soft="var(--panel-2)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminChartCard title="PV / UV 趋势" hint="来自日聚合表,历史数据不随明细清理丢失">
|
||||
<VisitTrendChart data={data.trend} />
|
||||
</AdminChartCard>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<AdminChartCard title="设备分布" hint={`近 ${data.range_days} 日浏览`}>
|
||||
{devices.length ? <NamedPieChart data={devices} /> : <TableEmpty />}
|
||||
</AdminChartCard>
|
||||
<AdminChartCard title="来源占比" hint={`近 ${data.range_days} 日浏览`}>
|
||||
{sources.length ? <NamedBarChart data={sources} /> : <TableEmpty />}
|
||||
</AdminChartCard>
|
||||
</div>
|
||||
|
||||
<AdminChartCard title="热门页面 Top 10" hint={`近 ${data.range_days} 日`}>
|
||||
{data.top_pages.length ? (
|
||||
<div className="flex flex-col divide-y" style={{ borderColor: "var(--line)" }}>
|
||||
{data.top_pages.map((p: VisitPageRow, i) => (
|
||||
<div key={p.path} className="py-2 flex items-center gap-3 text-[13px]">
|
||||
<span className="w-5 text-right tabular-nums shrink-0" style={{ color: "var(--ink-3)" }}>
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate" style={{ color: "var(--ink)" }} title={p.path}>
|
||||
{p.path || "/"}
|
||||
</span>
|
||||
<span className="hidden sm:block w-32 h-1.5 rounded-full overflow-hidden shrink-0" style={{ background: "var(--panel-2)" }}>
|
||||
<span
|
||||
className="block h-full rounded-full"
|
||||
style={{ width: `${Math.min(100, p.share).toFixed(1)}%`, background: "var(--accent)" }}
|
||||
/>
|
||||
</span>
|
||||
<span className="w-16 text-right tabular-nums" style={{ color: "var(--ink-2)" }}>
|
||||
{formatInt(p.pv)}
|
||||
</span>
|
||||
<span className="w-14 text-right tabular-nums" style={{ color: "var(--ink-3)" }}>
|
||||
UV {formatInt(p.uv)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<TableEmpty text="暂无浏览数据" />
|
||||
)}
|
||||
</AdminChartCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user