feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面 - 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件 - 新增 widget 嵌入代码生成(widgetCode)与 widgetApi - 遥测逻辑迁移至 handler 层,删除 service/telemetry - env 示例补充 Docker 部署可信代理 IP 说明
This commit is contained in:
127
frontend/app/admin/analytics/security/page.tsx
Normal file
127
frontend/app/admin/analytics/security/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user