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

128 lines
4.8 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 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>
);
}