feat: 站点访问统计与可配置侧边栏组件

- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面
- 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件
- 新增 widget 嵌入代码生成(widgetCode)与 widgetApi
- 遥测逻辑迁移至 handler 层,删除 service/telemetry
- env 示例补充 Docker 部署可信代理 IP 说明
This commit is contained in:
2026-09-24 18:53:45 +08:00
parent 3e55b5d230
commit 7b327bc8cc
60 changed files with 6539 additions and 572 deletions

View File

@@ -0,0 +1,232 @@
"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>
);
}