Files
jiang13-bbs/frontend/app/admin/analytics/sources/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

112 lines
4.2 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 { useEffect, useState } from "react";
import { AdminChartCard, NamedBarChart, NamedPieChart } from "@/components/admin/charts";
import { apiStatsSources, type VisitSources } from "@/lib/api";
import { RangeSegmented, StatsState, TableEmpty, refKindLabel } from "../ui";
/** 来源按日表:固定展示四类主要来源列 */
const TREND_KINDS = ["direct", "search", "social", "external"] as const;
export default function SourcesPage() {
const [range, setRange] = useState<"7d" | "30d">("7d");
const [data, setData] = useState<VisitSources | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let alive = true;
setLoading(true);
apiStatsSources(range)
.then((d) => {
if (alive) {
setData(d);
setError("");
}
})
.catch(() => alive && setError("加载失败,请稍后重试"))
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
}, [range]);
if (!data) return <StatsState loading={loading} error={error || undefined} />;
const kinds = data.kinds.map((k) => ({ label: refKindLabel(k.key), count: k.count }));
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]">按浏览来源(Referer)自动分类;爬虫与探测请求不计入。</p>
<RangeSegmented value={range} onChange={setRange} />
</div>
{error ? <StatsState error={error} /> : null}
<AdminChartCard title="来源类别占比" hint={`近 ${data.range_days} 日浏览`}>
{kinds.length ? <NamedPieChart data={kinds} /> : <TableEmpty />}
</AdminChartCard>
<div className="grid gap-4 lg:grid-cols-2">
<AdminChartCard title="搜索引擎明细" hint="来自搜索结果页的浏览 Top 10">
{data.search_hosts.length ? (
<NamedBarChart data={data.search_hosts.map((h) => ({ label: h.key, count: h.count }))} />
) : (
<TableEmpty text="暂无搜索引擎来源" />
)}
</AdminChartCard>
<AdminChartCard title="外部链接明细" hint="其他站点跳转来源 Top 10">
{data.external.length ? (
<NamedBarChart data={data.external.map((h) => ({ label: h.key, count: h.count }))} />
) : (
<TableEmpty text="暂无外链来源" />
)}
</AdminChartCard>
</div>
<AdminChartCard title="来源按日分布" hint="各来源类别每日浏览量">
{data.trend.length ? (
<div className="overflow-x-auto">
<table className="w-full text-[13px]">
<thead>
<tr className="text-left" style={{ color: "var(--ink-3)" }}>
<th className="py-2 pr-3 font-medium">日期</th>
{TREND_KINDS.map((k) => (
<th key={k} className="py-2 pr-3 font-medium text-right">
{refKindLabel(k)}
</th>
))}
</tr>
</thead>
<tbody>
{data.trend.map((row) => {
const byKey = new Map(row.items.map((i) => [i.key, i.count]));
return (
<tr key={row.date} style={{ borderTop: "1px solid var(--line)" }}>
<td className="py-2 pr-3 tabular-nums" style={{ color: "var(--ink-2)" }}>
{row.date.slice(0, 10)}
</td>
{TREND_KINDS.map((k) => (
<td key={k} className="py-2 pr-3 text-right tabular-nums" style={{ color: "var(--ink)" }}>
{(byKey.get(k) ?? 0) === 0 ? (
<span style={{ color: "var(--ink-3)" }}>—</span>
) : (
(byKey.get(k) ?? 0).toLocaleString("zh-CN")
)}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<TableEmpty />
)}
</AdminChartCard>
</div>
);
}