feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面 - 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件 - 新增 widget 嵌入代码生成(widgetCode)与 widgetApi - 遥测逻辑迁移至 handler 层,删除 service/telemetry - env 示例补充 Docker 部署可信代理 IP 说明
This commit is contained in:
120
frontend/app/admin/analytics/pages/page.tsx
Normal file
120
frontend/app/admin/analytics/pages/page.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { AdminPager } from "@/components/admin";
|
||||
import { AdminChartCard } from "@/components/admin/charts";
|
||||
import AdminSearchField from "@/components/admin/AdminSearchField";
|
||||
import { apiStatsPages, type VisitPages } from "@/lib/api";
|
||||
import { RangeSegmented, StatsState, TableEmpty, formatInt } from "../ui";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function PagesPage() {
|
||||
const [range, setRange] = useState<"7d" | "30d">("7d");
|
||||
const [q, setQ] = useState("");
|
||||
const [applied, setApplied] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [data, setData] = useState<VisitPages | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(
|
||||
(r: "7d" | "30d", query: string, p: number) => {
|
||||
setLoading(true);
|
||||
apiStatsPages(r, query, p, PAGE_SIZE)
|
||||
.then((d) => {
|
||||
setData(d);
|
||||
setError("");
|
||||
})
|
||||
.catch(() => setError("加载失败,请稍后重试"))
|
||||
.finally(() => setLoading(false));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
load(range, applied, page);
|
||||
}, [load, range, applied, page]);
|
||||
|
||||
const switchRange = (r: "7d" | "30d") => {
|
||||
setRange(r);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const submitSearch = () => {
|
||||
setApplied(q.trim());
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setQ("");
|
||||
setApplied("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
if (!data) return <StatsState loading={loading} error={error || undefined} />;
|
||||
|
||||
const maxPv = data.items[0]?.pv ?? 1;
|
||||
|
||||
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]">
|
||||
共 {formatInt(data.total)} 个不同路径被访问(近 {data.range_days} 日)
|
||||
</p>
|
||||
<RangeSegmented value={range} onChange={switchRange} />
|
||||
</div>
|
||||
|
||||
{error ? <StatsState error={error} /> : null}
|
||||
|
||||
<AdminChartCard
|
||||
title="受访页面"
|
||||
hint="按 PV 降序;搜索支持路径模糊匹配"
|
||||
actions={
|
||||
<div className="w-full sm:w-64">
|
||||
<AdminSearchField
|
||||
value={q}
|
||||
onChange={setQ}
|
||||
onSubmit={submitSearch}
|
||||
onClear={clearSearch}
|
||||
placeholder="搜索路径…"
|
||||
ariaLabel="搜索受访路径"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{data.items.length ? (
|
||||
<>
|
||||
<div className="flex flex-col divide-y" style={{ borderColor: "var(--line)" }}>
|
||||
{data.items.map((p) => (
|
||||
<div key={p.path} className="py-2.5 flex items-center gap-3 text-[13px]">
|
||||
<span className="flex-1 min-w-0 truncate font-medium" style={{ color: "var(--ink)" }} title={p.path}>
|
||||
{p.path || "/"}
|
||||
</span>
|
||||
<span className="hidden sm:block w-36 h-1.5 rounded-full overflow-hidden shrink-0" style={{ background: "var(--panel-2)" }}>
|
||||
<span
|
||||
className="block h-full rounded-full"
|
||||
style={{ width: `${Math.max(2, (p.pv / maxPv) * 100).toFixed(1)}%`, background: "var(--accent)" }}
|
||||
/>
|
||||
</span>
|
||||
<span className="w-16 text-right tabular-nums" style={{ color: "var(--ink-2)" }}>
|
||||
{formatInt(p.pv)}
|
||||
</span>
|
||||
<span className="w-16 text-right tabular-nums" style={{ color: "var(--ink-3)" }}>
|
||||
UV {formatInt(p.uv)}
|
||||
</span>
|
||||
<span className="w-12 text-right tabular-nums text-[11.5px]" style={{ color: "var(--ink-3)" }}>
|
||||
{p.share.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<AdminPager page={page} total={data.total} pageSize={PAGE_SIZE} onChange={setPage} loading={loading} />
|
||||
</>
|
||||
) : (
|
||||
<TableEmpty text={applied ? "没有匹配的页面" : "暂无浏览数据"} />
|
||||
)}
|
||||
</AdminChartCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user