新增: - blob_store 二进制去重存储 + blob_dedup 测试 其它: - 后端 settings/auth/media_library/upload/operations 服务与 handler 调整 - 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
262 lines
10 KiB
TypeScript
262 lines
10 KiB
TypeScript
"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>
|
||
);
|
||
}
|