Files
sundynix-site/admin/src/pages/visits.tsx
T
Blizzard 5adb3d621b feat: 用户端访问足迹统计(IP 去重 UV/PV)
- 后端 Visit 模型(date+ip 唯一索引):POST /api/track 埋点(ClientIP upsert,pv+1),GET /api/admin/visits 每日 UV/PV 统计
- web 每会话上报一次访问(sessionStorage 防重),IP 由后端去重
- admin 访问统计页:侧边栏入口 + 今日/近N天 UV·PV 卡片 + 每日柱状趋势(自绘无依赖) + 7/30/90 天切换

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 17:35:05 +08:00

118 lines
3.9 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.
import { useEffect, useState } from 'react'
import { Eye, Users } from 'lucide-react'
import { fetchVisits, type VisitStats } from '@/api/visits'
import { cn } from '@/lib/utils'
const RANGES = [7, 30, 90]
export default function VisitsPage() {
const [days, setDays] = useState(30)
const [data, setData] = useState<VisitStats | null>(null)
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
fetchVisits(days)
.then((d) => {
if (!cancelled) setData(d)
})
.catch((e: Error) => {
if (!cancelled) setError(e.message)
})
return () => {
cancelled = true
}
}, [days])
// 补全连续日期,缺失天补 0,趋势图才连贯
const map = new Map((data?.daily ?? []).map((r) => [r.date, r]))
const series = Array.from({ length: days }, (_, i) => {
const d = new Date()
d.setDate(d.getDate() - (days - 1 - i))
const date = d.toISOString().slice(0, 10)
return map.get(date) ?? { date, uv: 0, pv: 0 }
})
const maxUv = Math.max(1, ...series.map((s) => s.uv))
const cards = [
{ label: '今日访客 UV', value: data?.today_uv, icon: Users },
{ label: '今日访问 PV', value: data?.today_pv, icon: Eye },
{ label: `${days} 天访客`, value: data?.total_uv, icon: Users },
{ label: `${days} 天访问`, value: data?.total_pv, icon: Eye },
]
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-[22px] font-[650] tracking-[-0.01em]">访</h1>
<p className="mt-1 text-[13.5px] text-ink-2">
访 IP
</p>
</div>
<div className="flex items-center gap-0.5 rounded-lg border border-hairline-strong p-0.5">
{RANGES.map((r) => (
<button
key={r}
type="button"
onClick={() => setDays(r)}
className={cn(
'rounded-md px-3 py-1.5 text-[13px] transition-colors',
days === r
? 'bg-accent text-ground'
: 'text-ink-2 hover:text-ink',
)}
>
{r}
</button>
))}
</div>
</div>
{error && (
<p className="mb-4 rounded-lg border border-hairline bg-surface px-4 py-3 text-[13.5px] text-ink-2">
{error}
</p>
)}
<div className="mb-6 grid grid-cols-2 gap-4 lg:grid-cols-4">
{cards.map((c) => (
<div
key={c.label}
className="rounded-xl border border-hairline bg-surface p-5"
>
<div className="mb-3 flex items-center justify-between">
<span className="text-[12.5px] text-ink-2">{c.label}</span>
<c.icon className="size-[16px] text-ink-3" />
</div>
<div className="text-[26px] font-[650] tabular-nums">
{c.value ?? '—'}
</div>
</div>
))}
</div>
<div className="rounded-xl border border-hairline bg-surface p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-[14px] font-semibold">访</h2>
<span className="font-mono text-[11.5px] text-ink-3">UV</span>
</div>
<div className="flex h-[180px] items-end gap-[3px]">
{series.map((s) => (
<div
key={s.date}
title={`${s.date} · UV ${s.uv} · PV ${s.pv}`}
className="flex-1 rounded-t bg-accent-soft transition-colors hover:bg-accent"
style={{ height: `${Math.max(2, (s.uv / maxUv) * 100)}%` }}
/>
))}
</div>
<div className="mt-2 flex justify-between font-mono text-[11px] text-ink-3">
<span>{series[0]?.date.slice(5)}</span>
<span>{series[series.length - 1]?.date.slice(5)}</span>
</div>
</div>
</div>
)
}