- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面 - 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件 - 新增 widget 嵌入代码生成(widgetCode)与 widgetApi - 遥测逻辑迁移至 handler 层,删除 service/telemetry - env 示例补充 Docker 部署可信代理 IP 说明
121 lines
4.1 KiB
TypeScript
121 lines
4.1 KiB
TypeScript
"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>
|
||
);
|
||
}
|