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

233 lines
9.5 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 { 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>
);
}