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,74 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useRef, type KeyboardEvent, type ReactNode } from "react";
import { BarChart3 } from "lucide-react";
import { AdminPageHeader } from "@/components/admin";
export const ANALYTICS_TABS: { href: string; label: string }[] = [
{ href: "/admin/analytics", label: "概览" },
{ href: "/admin/analytics/realtime", label: "实时" },
{ href: "/admin/analytics/sources", label: "来源分析" },
{ href: "/admin/analytics/pages", label: "受访页面" },
{ href: "/admin/analytics/visitors", label: "访客画像" },
{ href: "/admin/analytics/security", label: "爬虫与安全" },
{ href: "/admin/analytics/details", label: "访问明细" },
{ href: "/admin/analytics/settings", label: "设置" },
];
export default function AnalyticsShell({ children }: { children: ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const navRefs = useRef<(HTMLAnchorElement | null)[]>([]);
// ←→/Home/End 键盘导航,范式同 SettingsShell(纵向改横向)
const onNavKeyDown = (e: KeyboardEvent<HTMLAnchorElement>, i: number) => {
if (
e.key !== "ArrowRight" &&
e.key !== "ArrowLeft" &&
e.key !== "Home" &&
e.key !== "End"
) {
return;
}
e.preventDefault();
let next = i;
if (e.key === "ArrowRight") next = (i + 1) % ANALYTICS_TABS.length;
else if (e.key === "ArrowLeft") next = (i + ANALYTICS_TABS.length - 1) % ANALYTICS_TABS.length;
else if (e.key === "Home") next = 0;
else next = ANALYTICS_TABS.length - 1;
navRefs.current[next]?.focus();
router.push(ANALYTICS_TABS[next].href);
};
return (
<div className="admin-analytics-page">
<AdminPageHeader
icon={BarChart3}
title="访问统计"
description="站点流量、来源、访客与安全事件的采集与分析"
/>
<nav className="admin-analytics-tabs" aria-label="访问统计分类">
{ANALYTICS_TABS.map((item, i) => {
const active = pathname === item.href;
return (
<Link
key={item.href}
ref={(el) => {
navRefs.current[i] = el;
}}
href={item.href}
className="admin-analytics-tab"
aria-current={active ? "page" : undefined}
onKeyDown={(e) => onNavKeyDown(e, i)}
>
{item.label}
</Link>
);
})}
</nav>
{children}
</div>
);
}

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

View File

@@ -0,0 +1,232 @@
"use client";
import { Suspense, useCallback, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { AdminPager, AdminSegmented } from "@/components/admin";
import { AdminChartCard } from "@/components/admin/charts";
import AdminSearchField from "@/components/admin/AdminSearchField";
import { apiStatsEvents, type VisitEventItem, type VisitEventsResult } from "@/lib/api";
import { StatsState, TableEmpty, deviceLabel, kindLabel, refKindLabel } from "../ui";
const PAGE_SIZE = 50;
const KIND_OPTIONS = [
{ key: "", label: "全部" },
{ key: "pageview", label: "浏览" },
{ key: "bot", label: "爬虫" },
{ key: "probe", label: "探测" },
];
/** 本地日期(yyyy-mm-dd)→ RFC3339,覆盖全天 */
function dayToRFC3339(date: string, endOfDay = false): string {
if (!date) return "";
return new Date(`${date}T${endOfDay ? "23:59:59" : "00:00:00"}`).toISOString();
}
function kindBadge(kind: string) {
const map: Record<string, { color: string; soft: string }> = {
pageview: { color: "var(--accent)", soft: "var(--accent-soft)" },
bot: { color: "var(--gold)", soft: "var(--gold-soft)" },
probe: { color: "var(--danger)", soft: "var(--danger-soft, var(--panel-2))" },
};
const s = map[kind] ?? { color: "var(--ink-3)", soft: "var(--panel-2)" };
return (
<span
className="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium shrink-0"
style={{ color: s.color, background: s.soft }}
>
{kindLabel(kind)}
</span>
);
}
function DetailsInner() {
const searchParams = useSearchParams();
// 初始筛选只读一次(如从「爬虫与安全」跳转 ?ip=x.x.x.x)
const [initialIp] = useState(() => searchParams.get("ip") ?? "");
const [kind, setKind] = useState("");
const [path, setPath] = useState("");
const [ip, setIp] = useState(initialIp);
const [bot, setBot] = useState("");
const [start, setStart] = useState("");
const [end, setEnd] = useState("");
const [page, setPage] = useState(1);
const [data, setData] = useState<VisitEventsResult | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = useCallback(
(f: { kind: string; path: string; ip: string; bot: string; start: string; end: string }, p: number) => {
setLoading(true);
apiStatsEvents({
kind: f.kind || undefined,
path: f.path || undefined,
ip: f.ip || undefined,
bot: f.bot || undefined,
start: dayToRFC3339(f.start) || undefined,
end: dayToRFC3339(f.end, true) || undefined,
page: p,
size: PAGE_SIZE,
})
.then((d) => {
setData(d);
setError("");
})
.catch(() => setError("加载失败,请稍后重试"))
.finally(() => setLoading(false));
},
[]
);
useEffect(() => {
load({ kind, path, ip, bot, start, end }, page);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [load, kind, path, ip, bot, start, end, page]);
const resetPage = () => setPage(1);
return (
<div className="flex flex-col gap-4">
<p className="meta text-[12.5px]">
记录含浏览、爬虫与异常请求;访客标识仅存哈希,IP 与登录日志口径一致,仅超管可见。
</p>
<AdminChartCard title="访问明细" hint={`共 ${data ? data.total.toLocaleString("zh-CN") : "…"} 条记录`}>
<div className="flex flex-col gap-2.5 mb-4">
<AdminSegmented options={KIND_OPTIONS} value={kind} onChange={(k) => { setKind(k); resetPage(); }} ariaLabel="事件类型筛选" role="group" size="sm" />
<div className="flex items-center gap-2 flex-wrap">
<div className="w-full sm:w-56">
<AdminSearchField
value={path}
onChange={(v) => { setPath(v); }}
onSubmit={resetPage}
onClear={() => { setPath(""); resetPage(); }}
placeholder="路径模糊匹配,回车应用"
ariaLabel="按路径筛选"
/>
</div>
<div className="w-full sm:w-44">
<AdminSearchField
value={ip}
onChange={setIp}
onSubmit={resetPage}
onClear={() => { setIp(""); resetPage(); }}
placeholder="按 IP 筛选,回车应用"
ariaLabel="按 IP 筛选"
/>
</div>
<div className="w-full sm:w-40">
<AdminSearchField
value={bot}
onChange={setBot}
onSubmit={resetPage}
onClear={() => { setBot(""); resetPage(); }}
placeholder="按爬虫名筛选"
ariaLabel="按爬虫名筛选"
/>
</div>
<label className="flex items-center gap-1.5 text-[12px]" style={{ color: "var(--ink-3)" }}>
从
<input
type="date"
value={start}
onChange={(e) => { setStart(e.target.value); resetPage(); }}
className="admin-field-input"
style={{ width: 150 }}
aria-label="开始日期"
/>
</label>
<label className="flex items-center gap-1.5 text-[12px]" style={{ color: "var(--ink-3)" }}>
至
<input
type="date"
value={end}
onChange={(e) => { setEnd(e.target.value); resetPage(); }}
className="admin-field-input"
style={{ width: 150 }}
aria-label="结束日期"
/>
</label>
</div>
</div>
{error ? <StatsState error={error} /> : null}
{data && data.items.length ? (
<>
<div className="overflow-x-auto">
<table className="w-full text-[12.5px]" style={{ minWidth: 860 }}>
<thead>
<tr className="text-left" style={{ color: "var(--ink-3)" }}>
<th className="py-2 pr-3 font-medium whitespace-nowrap">时间</th>
<th className="py-2 pr-3 font-medium">类型</th>
<th className="py-2 pr-3 font-medium">路径</th>
<th className="py-2 pr-3 font-medium">来源</th>
<th className="py-2 pr-3 font-medium whitespace-nowrap">设备 / 系统 / 浏览器</th>
<th className="py-2 pr-3 font-medium">用户</th>
<th className="py-2 pr-3 font-medium">IP</th>
<th className="py-2 font-medium text-right">状态</th>
</tr>
</thead>
<tbody>
{data.items.map((e: VisitEventItem) => (
<tr key={e.id} style={{ borderTop: "1px solid var(--line)" }}>
<td className="py-2 pr-3 whitespace-nowrap tabular-nums" style={{ color: "var(--ink-3)" }}>
{new Date(e.created_at).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" })}
</td>
<td className="py-2 pr-3">
{kindBadge(e.kind)}
{e.bot ? (
<span className="ml-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
{e.bot}
</span>
) : null}
</td>
<td className="py-2 pr-3 max-w-[220px] truncate" style={{ color: "var(--ink)" }} title={e.path}>
{e.path || "/"}
</td>
<td className="py-2 pr-3 whitespace-nowrap" style={{ color: "var(--ink-3)" }} title={e.ref_host}>
{e.ref_host ? (
<>
{e.ref_host}
<span className="ml-1 text-[11px]">{refKindLabel(e.ref_kind)}</span>
</>
) : (
refKindLabel(e.ref_kind)
)}
</td>
<td className="py-2 pr-3 whitespace-nowrap" style={{ color: "var(--ink-2)" }}>
{deviceLabel(e.device)} · {e.os || "?"} · {e.browser || "?"}
</td>
<td className="py-2 pr-3 whitespace-nowrap" style={{ color: "var(--ink-2)" }}>
{e.username || <span style={{ color: "var(--ink-3)" }}>游客</span>}
</td>
<td className="py-2 pr-3 tabular-nums" style={{ color: "var(--ink-2)" }}>
{e.ip || "—"}
</td>
<td className="py-2 text-right tabular-nums" style={{ color: e.status >= 400 ? "var(--danger)" : "var(--ink-3)" }}>
{e.status > 0 ? e.status : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
<AdminPager page={page} total={data.total} pageSize={PAGE_SIZE} onChange={setPage} loading={loading} />
</>
) : (
<TableEmpty text={loading ? "正在加载…" : "没有匹配的记录"} />
)}
</AdminChartCard>
</div>
);
}
export default function DetailsPage() {
return (
<Suspense fallback={<StatsState loading />}>
<DetailsInner />
</Suspense>
);
}

View File

@@ -0,0 +1,15 @@
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { isSuperOrOwner } from "@/lib/roles";
import Forbidden from "../Forbidden";
import AnalyticsShell from "./AnalyticsShell";
export default async function AnalyticsLayout({ children }: { children: React.ReactNode }) {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isSuperOrOwner(me.user.role)) {
return <Forbidden text="访问统计仅超级管理员和站长可查看。" />;
}
return <AnalyticsShell>{children}</AnalyticsShell>;
}

View File

@@ -0,0 +1,17 @@
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { fetchStatsOverview, type VisitOverview } from "@/lib/api";
import OverviewClient from "./OverviewClient";
export const dynamic = "force-dynamic";
export default async function AnalyticsOverviewPage() {
const cookie = authCookieHeader(await cookies());
let initial: VisitOverview | null = null;
try {
initial = await fetchStatsOverview(cookie || undefined, "7d");
} catch {
initial = null;
}
return <OverviewClient initial={initial} />;
}

View File

@@ -0,0 +1,120 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { AdminPager } from "@/components/admin";
import { AdminChartCard } from "@/components/admin/charts";
import AdminSearchField from "@/components/admin/AdminSearchField";
import { apiStatsPages, type VisitPages } from "@/lib/api";
import { RangeSegmented, StatsState, TableEmpty, formatInt } from "../ui";
const PAGE_SIZE = 20;
export default function PagesPage() {
const [range, setRange] = useState<"7d" | "30d">("7d");
const [q, setQ] = useState("");
const [applied, setApplied] = useState("");
const [page, setPage] = useState(1);
const [data, setData] = useState<VisitPages | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = useCallback(
(r: "7d" | "30d", query: string, p: number) => {
setLoading(true);
apiStatsPages(r, query, p, PAGE_SIZE)
.then((d) => {
setData(d);
setError("");
})
.catch(() => setError("加载失败,请稍后重试"))
.finally(() => setLoading(false));
},
[]
);
useEffect(() => {
load(range, applied, page);
}, [load, range, applied, page]);
const switchRange = (r: "7d" | "30d") => {
setRange(r);
setPage(1);
};
const submitSearch = () => {
setApplied(q.trim());
setPage(1);
};
const clearSearch = () => {
setQ("");
setApplied("");
setPage(1);
};
if (!data) return <StatsState loading={loading} error={error || undefined} />;
const maxPv = data.items[0]?.pv ?? 1;
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]">
共 {formatInt(data.total)} 个不同路径被访问(近 {data.range_days} 日)
</p>
<RangeSegmented value={range} onChange={switchRange} />
</div>
{error ? <StatsState error={error} /> : null}
<AdminChartCard
title="受访页面"
hint="按 PV 降序;搜索支持路径模糊匹配"
actions={
<div className="w-full sm:w-64">
<AdminSearchField
value={q}
onChange={setQ}
onSubmit={submitSearch}
onClear={clearSearch}
placeholder="搜索路径…"
ariaLabel="搜索受访路径"
/>
</div>
}
>
{data.items.length ? (
<>
<div className="flex flex-col divide-y" style={{ borderColor: "var(--line)" }}>
{data.items.map((p) => (
<div key={p.path} className="py-2.5 flex items-center gap-3 text-[13px]">
<span className="flex-1 min-w-0 truncate font-medium" style={{ color: "var(--ink)" }} title={p.path}>
{p.path || "/"}
</span>
<span className="hidden sm:block w-36 h-1.5 rounded-full overflow-hidden shrink-0" style={{ background: "var(--panel-2)" }}>
<span
className="block h-full rounded-full"
style={{ width: `${Math.max(2, (p.pv / maxPv) * 100).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-16 text-right tabular-nums" style={{ color: "var(--ink-3)" }}>
UV {formatInt(p.uv)}
</span>
<span className="w-12 text-right tabular-nums text-[11.5px]" style={{ color: "var(--ink-3)" }}>
{p.share.toFixed(1)}%
</span>
</div>
))}
</div>
<AdminPager page={page} total={data.total} pageSize={PAGE_SIZE} onChange={setPage} loading={loading} />
</>
) : (
<TableEmpty text={applied ? "没有匹配的页面" : "暂无浏览数据"} />
)}
</AdminChartCard>
</div>
);
}

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

View File

@@ -0,0 +1,127 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Bot, ShieldAlert, KeyRound, Radar } from "lucide-react";
import { AdminStatCard } from "@/components/admin";
import { AdminChartCard, NamedBarChart, SingleAreaChart } from "@/components/admin/charts";
import { apiStatsSecurity, type VisitSecurity } from "@/lib/api";
import { RangeSegmented, StatsState, TableEmpty, formatInt } from "../ui";
export default function SecurityPage() {
const [range, setRange] = useState<"7d" | "30d">("7d");
const [data, setData] = useState<VisitSecurity | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let alive = true;
setLoading(true);
apiStatsSecurity(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 botTrend = data.bot_trend.map((d) => ({ date: d.date, pv: d.pv }));
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/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="爬虫请求" value={formatInt(data.bots.reduce((s, b) => s + b.count, 0))} icon={<Bot size={17} />} />
<AdminStatCard
label="异常探测"
value={formatInt(data.probe_status.reduce((s, b) => s + b.count, 0))}
hint="状态码 ≥400 的请求"
icon={<Radar size={17} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
<AdminStatCard
label="登录失败"
value={formatInt(data.login_failed)}
sub={`${formatInt(data.failed_users)} 个账号`}
icon={<KeyRound size={17} />}
color="var(--ink-2)"
soft="var(--panel-2)"
/>
<AdminStatCard
label="可疑 IP"
value={formatInt(data.probe_ips.length)}
hint="按探测次数排序"
icon={<ShieldAlert size={17} />}
color="var(--danger)"
soft="var(--danger-soft, var(--panel-2))"
/>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<AdminChartCard title="爬虫分布" hint={`近 ${data.range_days} 日各爬虫请求量`}>
{data.bots.length ? (
<NamedBarChart data={data.bots.map((b) => ({ label: b.key, count: b.count }))} />
) : (
<TableEmpty text="暂无爬虫记录" />
)}
</AdminChartCard>
<AdminChartCard title="爬虫趋势" hint="每日爬虫请求量">
{botTrend.length ? (
<SingleAreaChart data={botTrend} dataKey="pv" name="爬虫请求" color="var(--gold)" />
) : (
<TableEmpty />
)}
</AdminChartCard>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<AdminChartCard title="探测状态码分布" hint="状态码 ≥400 的请求">
{data.probe_status.length ? (
<NamedBarChart data={data.probe_status.map((s) => ({ label: s.key, count: s.count }))} />
) : (
<TableEmpty text="暂无异常探测" />
)}
</AdminChartCard>
<AdminChartCard title="可疑 IP Top 20" hint="点击「查明细」查看该 IP 全部访问记录">
{data.probe_ips.length ? (
<ul className="flex flex-col divide-y max-h-[360px] overflow-y-auto" style={{ borderColor: "var(--line)" }}>
{data.probe_ips.map((ip) => (
<li key={ip.key} className="py-2 flex items-center gap-3 text-[13px]">
<span className="flex-1 min-w-0 truncate tabular-nums" style={{ color: "var(--ink)" }}>
{ip.key}
</span>
<span className="tabular-nums shrink-0" style={{ color: "var(--ink-2)" }}>
{formatInt(ip.count)}
</span>
<Link
href={`/admin/analytics/details?ip=${encodeURIComponent(ip.key)}`}
className="btn btn-line btn-sm shrink-0"
>
查明细
</Link>
</li>
))}
</ul>
) : (
<TableEmpty text="暂无可疑 IP" />
)}
</AdminChartCard>
</div>
</div>
);
}

View File

@@ -0,0 +1,196 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Database, HardDrive, Trash2, Activity } from "lucide-react";
import { AdminSettingRow, AdminStatCard, AdminSwitch } from "@/components/admin";
import ConfirmDialog from "@/components/ConfirmDialog";
import { apiStatsPurge, apiStatsSaveSettings, apiStatsSettings, type VisitSettingsPayload } from "@/lib/api";
import { StatsState, formatBytes, formatInt } from "../ui";
/** 保留天数输入边界(与后端 service 一致) */
const RETENTION_MIN = 7;
const RETENTION_MAX = 365;
export default function SettingsPage() {
const [data, setData] = useState<VisitSettingsPayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [enabled, setEnabled] = useState(true);
const [retention, setRetention] = useState(90);
const [saving, setSaving] = useState(false);
const [savedMsg, setSavedMsg] = useState("");
// 立即清理:天数输入 + 确认弹窗 + 受理提示
const [purgeDays, setPurgeDays] = useState(90);
const [purgeOpen, setPurgeOpen] = useState(false);
const [purging, setPurging] = useState(false);
const [purgeMsg, setPurgeMsg] = useState("");
const load = useCallback(() => {
apiStatsSettings()
.then((d) => {
setData(d);
setEnabled(d.enabled);
setRetention(d.retention_days);
setPurgeDays(d.retention_days);
setError("");
})
.catch(() => setError("加载失败,请稍后重试"))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
load();
}, [load]);
const save = (nextEnabled: boolean, nextRetention: number) => {
setSaving(true);
setSavedMsg("");
apiStatsSaveSettings(nextEnabled, nextRetention)
.then((d) => {
setData(d);
setEnabled(d.enabled);
setRetention(d.retention_days);
setSavedMsg("已保存");
setTimeout(() => setSavedMsg(""), 2000);
})
.catch(() => setError("保存失败,请稍后重试"))
.finally(() => setSaving(false));
};
const toggleEnabled = () => {
if (saving) return;
save(!enabled, retention);
};
const submitRetention = () => {
if (saving) return;
const v = Math.min(RETENTION_MAX, Math.max(RETENTION_MIN, Math.round(Number(retention) || 90)));
setRetention(v);
save(enabled, v);
};
const doPurge = () => {
setPurging(true);
apiStatsPurge(purgeDays)
.then(() => {
setPurgeMsg(`清理任务已受理(删除 ${purgeDays} 天前的明细),稍后刷新本页查看容量变化。`);
setTimeout(load, 3000);
})
.catch(() => setPurgeMsg("清理请求失败,请稍后重试。"))
.finally(() => {
setPurging(false);
setPurgeOpen(false);
});
};
if (!data) return <StatsState loading={loading} error={error || undefined} />;
const clampPurge = (v: number) =>
Math.min(RETENTION_MAX, Math.max(1, Math.round(Number(v) || 1)));
return (
<div className="flex flex-col gap-4">
{error ? <StatsState error={error} /> : null}
{purgeMsg ? (
<div className="panel p-4 text-[13px]" style={{ color: "var(--ink-2)" }}>
{purgeMsg}
</div>
) : null}
<div className="grid gap-3 grid-cols-2 xl:grid-cols-4">
<AdminStatCard label="明细行数" value={formatInt(data.usage.rows)} icon={<Database size={17} />} />
<AdminStatCard
label="磁盘占用"
value={formatBytes(data.usage.bytes)}
hint="含索引(pg_total_relation_size)"
icon={<HardDrive size={17} />}
color="var(--ok)"
soft="var(--ok-soft, var(--panel-2))"
/>
<AdminStatCard label="保留天数" value={`${data.retention_days} 天`} hint="超期明细每日 04:30 自动清理" icon={<Activity size={17} />} color="var(--gold)" soft="var(--gold-soft)" />
<AdminStatCard
label="采集丢弃"
value={formatInt(data.dropped)}
hint="写入队列满时丢弃的事件数(进程启动以来)"
icon={<Trash2 size={17} />}
color="var(--ink-2)"
soft="var(--panel-2)"
/>
</div>
<AdminSettingRow
title="访问统计采集"
description="关闭后前端埋点与后端捕获中间件立即停止写入(返回 204),已有数据保留。"
control={<AdminSwitch checked={enabled} disabled={saving} onChange={toggleEnabled} label="访问统计采集开关" />}
/>
<AdminSettingRow
title="明细保留期"
description={`超过保留期的访问明细每日 04:30 自动分批清理;日聚合趋势不受影响。范围 ${RETENTION_MIN}–${RETENTION_MAX} 天。`}
>
<div className="flex items-center gap-2 flex-wrap">
<input
type="number"
min={RETENTION_MIN}
max={RETENTION_MAX}
value={retention}
onChange={(e) => setRetention(Number(e.target.value))}
onBlur={submitRetention}
onKeyDown={(e) => e.key === "Enter" && submitRetention()}
className="admin-field-input"
style={{ width: 120 }}
aria-label="明细保留天数"
/>
<span className="text-[13px]" style={{ color: "var(--ink-3)" }}>
天
</span>
{saving ? <span className="meta text-[12px]">保存中…</span> : null}
{savedMsg ? (
<span className="text-[12px]" style={{ color: "var(--ok)" }}>
{savedMsg}
</span>
) : null}
</div>
</AdminSettingRow>
<AdminSettingRow
title="立即清理"
description="手动删除指定天数之前的明细记录,异步执行,不影响正常访问。"
>
<div className="flex items-center gap-2 flex-wrap">
<input
type="number"
min={1}
max={RETENTION_MAX}
value={purgeDays}
onChange={(e) => setPurgeDays(clampPurge(Number(e.target.value)))}
className="admin-field-input"
style={{ width: 120 }}
aria-label="清理多少天前的明细"
/>
<span className="text-[13px]" style={{ color: "var(--ink-3)" }}>
天前的明细
</span>
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setPurgeOpen(true)}
disabled={purging}
>
<Trash2 size={14} /> 立即清理
</button>
</div>
</AdminSettingRow>
<ConfirmDialog
open={purgeOpen}
title="确认立即清理"
message={`将删除 ${purgeDays} 天前的全部访问明细(当前共 ${formatInt(data.usage.rows)} 行)。该操作不可恢复,日聚合趋势不受影响。确认执行?`}
confirmLabel="确认清理"
danger
busy={purging}
onConfirm={doPurge}
onCancel={() => setPurgeOpen(false)}
/>
</div>
);
}

View File

@@ -0,0 +1,111 @@
"use client";
import { useEffect, useState } from "react";
import { AdminChartCard, NamedBarChart, NamedPieChart } from "@/components/admin/charts";
import { apiStatsSources, type VisitSources } from "@/lib/api";
import { RangeSegmented, StatsState, TableEmpty, refKindLabel } from "../ui";
/** 来源按日表:固定展示四类主要来源列 */
const TREND_KINDS = ["direct", "search", "social", "external"] as const;
export default function SourcesPage() {
const [range, setRange] = useState<"7d" | "30d">("7d");
const [data, setData] = useState<VisitSources | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let alive = true;
setLoading(true);
apiStatsSources(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 kinds = data.kinds.map((k) => ({ label: refKindLabel(k.key), count: k.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]">按浏览来源(Referer)自动分类;爬虫与探测请求不计入。</p>
<RangeSegmented value={range} onChange={setRange} />
</div>
{error ? <StatsState error={error} /> : null}
<AdminChartCard title="来源类别占比" hint={`近 ${data.range_days} 日浏览`}>
{kinds.length ? <NamedPieChart data={kinds} /> : <TableEmpty />}
</AdminChartCard>
<div className="grid gap-4 lg:grid-cols-2">
<AdminChartCard title="搜索引擎明细" hint="来自搜索结果页的浏览 Top 10">
{data.search_hosts.length ? (
<NamedBarChart data={data.search_hosts.map((h) => ({ label: h.key, count: h.count }))} />
) : (
<TableEmpty text="暂无搜索引擎来源" />
)}
</AdminChartCard>
<AdminChartCard title="外部链接明细" hint="其他站点跳转来源 Top 10">
{data.external.length ? (
<NamedBarChart data={data.external.map((h) => ({ label: h.key, count: h.count }))} />
) : (
<TableEmpty text="暂无外链来源" />
)}
</AdminChartCard>
</div>
<AdminChartCard title="来源按日分布" hint="各来源类别每日浏览量">
{data.trend.length ? (
<div className="overflow-x-auto">
<table className="w-full text-[13px]">
<thead>
<tr className="text-left" style={{ color: "var(--ink-3)" }}>
<th className="py-2 pr-3 font-medium">日期</th>
{TREND_KINDS.map((k) => (
<th key={k} className="py-2 pr-3 font-medium text-right">
{refKindLabel(k)}
</th>
))}
</tr>
</thead>
<tbody>
{data.trend.map((row) => {
const byKey = new Map(row.items.map((i) => [i.key, i.count]));
return (
<tr key={row.date} style={{ borderTop: "1px solid var(--line)" }}>
<td className="py-2 pr-3 tabular-nums" style={{ color: "var(--ink-2)" }}>
{row.date.slice(0, 10)}
</td>
{TREND_KINDS.map((k) => (
<td key={k} className="py-2 pr-3 text-right tabular-nums" style={{ color: "var(--ink)" }}>
{(byKey.get(k) ?? 0) === 0 ? (
<span style={{ color: "var(--ink-3)" }}>—</span>
) : (
(byKey.get(k) ?? 0).toLocaleString("zh-CN")
)}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<TableEmpty />
)}
</AdminChartCard>
</div>
);
}

View File

@@ -0,0 +1,166 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
/** 事件类型中文标签 */
export function kindLabel(kind: string): string {
switch (kind) {
case "pageview":
return "浏览";
case "bot":
return "爬虫";
case "probe":
return "探测";
default:
return kind || "-";
}
}
/** 来源类别中文标签 */
export function refKindLabel(kind: string): string {
switch (kind) {
case "direct":
return "直接访问";
case "internal":
return "站内";
case "search":
return "搜索引擎";
case "social":
return "社交";
case "external":
return "外部链接";
default:
return kind || "-";
}
}
export function deviceLabel(d: string): string {
switch (d) {
case "computer":
return "电脑";
case "mobile":
return "手机";
case "tablet":
return "平板";
default:
return d || "未知";
}
}
export function formatBytes(n: number): string {
if (!Number.isFinite(n) || n <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let v = n;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
}
export function formatInt(n: number): string {
return new Intl.NumberFormat("zh-CN").format(n);
}
/** 简单加载/错误占位(居中卡片内文本) */
export function StatsState({ loading, error }: { loading?: boolean; error?: string }) {
if (loading) {
return (
<div className="panel p-10 text-center text-[13px]" style={{ color: "var(--ink-3)" }}>
正在加载…
</div>
);
}
if (error) {
return (
<div className="panel p-10 text-center text-[13px]" style={{ color: "var(--danger)" }}>
{error}
</div>
);
}
return null;
}
export function TableEmpty({ text = "暂无数据" }: { text?: string }) {
return (
<p className="py-8 text-center text-[13px]" style={{ color: "var(--ink-3)" }}>
{text}
</p>
);
}
/** 15 秒轮询 + 回前台即拉;返回手动 reload */
export function usePolling(load: () => void, intervalMs = 15_000) {
const [, setTick] = useState(0);
useEffect(() => {
load();
const timer = setInterval(load, intervalMs);
const off = onDocumentVisible(load);
return () => {
clearInterval(timer);
off();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [intervalMs]);
return () => setTick((t) => t + 1);
}
/** 7/30 日区间切换段控件(各 Tab 复用) */
export function RangeSegmented({
value,
onChange,
}: {
value: "7d" | "30d";
onChange: (v: "7d" | "30d") => void;
}) {
return (
<div className="flex items-center gap-1.5" role="tablist" aria-label="统计区间">
{(
[
{ key: "7d", label: "近 7 日" },
{ key: "30d", label: "近 30 日" },
] as const
).map((o) => (
<button
key={o.key}
type="button"
role="tab"
aria-selected={value === o.key}
onClick={() => value !== o.key && onChange(o.key)}
className="admin-seg"
>
{o.label}
</button>
))}
</div>
);
}
export function CardShell({
title,
hint,
actions,
children,
}: {
title: string;
hint?: string;
actions?: ReactNode;
children: ReactNode;
}) {
return (
<div className="panel p-4 sm:p-5">
<div className="flex items-start justify-between gap-2 mb-3">
<div className="min-w-0">
<h3 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
{title}
</h3>
{hint ? <p className="meta text-[11.5px] mt-0.5">{hint}</p> : null}
</div>
{actions}
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,111 @@
"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>
);
}