Files
jiang13-bbs/frontend/app/admin/analytics/pages/page.tsx
freefire 7b327bc8cc feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面
- 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件
- 新增 widget 嵌入代码生成(widgetCode)与 widgetApi
- 遥测逻辑迁移至 handler 层,删除 service/telemetry
- env 示例补充 Docker 部署可信代理 IP 说明
2026-09-24 18:53:45 +08:00

121 lines
4.1 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 { 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>
);
}