Files
jiang13-bbs/frontend/app/admin/analytics/details/page.tsx
freefire 46e0cdc0b3 feat: 媒体库 blob 去重存储与品牌/注册等前端调整
新增:
- blob_store 二进制去重存储 + blob_dedup 测试

其它:
- 后端 settings/auth/media_library/upload/operations 服务与 handler 调整
- 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
2026-10-03 02:38:11 +08:00

262 lines
10 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 DateField({
label,
value,
onChange,
ariaLabel,
}: {
label: string;
value: string;
onChange: (v: string) => void;
ariaLabel: string;
}) {
return (
<label className="admin-field-shell admin-search-shell w-full sm:w-44">
<span className="shrink-0 text-[12px]" style={{ color: "var(--ink-3)" }}>
{label}
</span>
<input
type="date"
value={value}
onChange={(e) => onChange(e.target.value)}
className="admin-field-input"
aria-label={ariaLabel}
/>
</label>
);
}
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);
const hasFilter = Boolean(kind || path || ip || bot || start || end);
const resetAll = () => {
setKind("");
setPath("");
setIp("");
setBot("");
setStart("");
setEnd("");
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); resetPage(); }}
onSubmit={resetPage}
onClear={() => { setPath(""); resetPage(); }}
placeholder="路径模糊匹配,回车应用"
ariaLabel="按路径筛选"
/>
</div>
<div className="w-full sm:w-48">
<AdminSearchField
value={ip}
onChange={(v) => { setIp(v); resetPage(); }}
onSubmit={resetPage}
onClear={() => { setIp(""); resetPage(); }}
placeholder="按 IP 精确筛选"
ariaLabel="按 IP 筛选"
/>
</div>
<div className="w-full sm:w-44">
<AdminSearchField
value={bot}
onChange={(v) => { setBot(v); resetPage(); }}
onSubmit={resetPage}
onClear={() => { setBot(""); resetPage(); }}
placeholder="按爬虫名精确筛选"
ariaLabel="按爬虫名筛选"
/>
</div>
<DateField label="从" value={start} onChange={(v) => { setStart(v); resetPage(); }} ariaLabel="开始日期" />
<DateField label="至" value={end} onChange={(v) => { setEnd(v); resetPage(); }} ariaLabel="结束日期" />
{hasFilter ? (
<button
type="button"
onClick={resetAll}
className="admin-field-shell admin-search-shell w-full sm:w-auto justify-center text-[13px] cursor-pointer transition-colors hover:bg-[var(--panel-2)]"
style={{ color: "var(--ink-2)" }}
>
重置筛选
</button>
) : null}
</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>
);
}