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>
This commit is contained in:
Blizzard
2026-07-17 17:05:08 +08:00
parent cc4aa647c5
commit 5adb3d621b
9 changed files with 260 additions and 2 deletions
+2
View File
@@ -5,6 +5,7 @@ import LoginPage from '@/pages/login'
import DashboardPage from '@/pages/dashboard'
import PostsListPage from '@/pages/posts-list'
import PostEditPage from '@/pages/post-edit'
import VisitsPage from '@/pages/visits'
function RequireAuth() {
if (!getToken()) return <Navigate to="/login" replace />
@@ -21,6 +22,7 @@ export default function App() {
<Route path="posts" element={<PostsListPage />} />
<Route path="posts/new" element={<PostEditPage />} />
<Route path="posts/:id" element={<PostEditPage />} />
<Route path="visits" element={<VisitsPage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
+20
View File
@@ -0,0 +1,20 @@
import { request } from '@/api/client'
export interface DailyVisit {
date: string
uv: number
pv: number
}
export interface VisitStats {
daily: DailyVisit[]
total_uv: number
total_pv: number
today_uv: number
today_pv: number
days: number
}
export function fetchVisits(days: number) {
return request<VisitStats>(`/api/admin/visits?days=${days}`)
}
+10 -1
View File
@@ -1,5 +1,13 @@
import { Link, NavLink, useNavigate } from 'react-router-dom'
import { FileText, LayoutDashboard, LogOut, Moon, Sun, X } from 'lucide-react'
import {
BarChart3,
FileText,
LayoutDashboard,
LogOut,
Moon,
Sun,
X,
} from 'lucide-react'
import { logout } from '@/api/auth'
import { useTheme } from '@/components/theme-provider'
import { LogoMark } from '@/components/logo'
@@ -8,6 +16,7 @@ import { cn } from '@/lib/utils'
const NAV = [
{ to: '/', label: '仪表盘', icon: LayoutDashboard, end: true },
{ to: '/posts', label: '文章', icon: FileText, end: false },
{ to: '/visits', label: '访问统计', icon: BarChart3, end: false },
]
interface Props {
+117
View File
@@ -0,0 +1,117 @@
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>
)
}