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:
@@ -5,6 +5,7 @@ import LoginPage from '@/pages/login'
|
|||||||
import DashboardPage from '@/pages/dashboard'
|
import DashboardPage from '@/pages/dashboard'
|
||||||
import PostsListPage from '@/pages/posts-list'
|
import PostsListPage from '@/pages/posts-list'
|
||||||
import PostEditPage from '@/pages/post-edit'
|
import PostEditPage from '@/pages/post-edit'
|
||||||
|
import VisitsPage from '@/pages/visits'
|
||||||
|
|
||||||
function RequireAuth() {
|
function RequireAuth() {
|
||||||
if (!getToken()) return <Navigate to="/login" replace />
|
if (!getToken()) return <Navigate to="/login" replace />
|
||||||
@@ -21,6 +22,7 @@ export default function App() {
|
|||||||
<Route path="posts" element={<PostsListPage />} />
|
<Route path="posts" element={<PostsListPage />} />
|
||||||
<Route path="posts/new" element={<PostEditPage />} />
|
<Route path="posts/new" element={<PostEditPage />} />
|
||||||
<Route path="posts/:id" element={<PostEditPage />} />
|
<Route path="posts/:id" element={<PostEditPage />} />
|
||||||
|
<Route path="visits" element={<VisitsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
import { Link, NavLink, useNavigate } from 'react-router-dom'
|
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 { logout } from '@/api/auth'
|
||||||
import { useTheme } from '@/components/theme-provider'
|
import { useTheme } from '@/components/theme-provider'
|
||||||
import { LogoMark } from '@/components/logo'
|
import { LogoMark } from '@/components/logo'
|
||||||
@@ -8,6 +16,7 @@ import { cn } from '@/lib/utils'
|
|||||||
const NAV = [
|
const NAV = [
|
||||||
{ to: '/', label: '仪表盘', icon: LayoutDashboard, end: true },
|
{ to: '/', label: '仪表盘', icon: LayoutDashboard, end: true },
|
||||||
{ to: '/posts', label: '文章', icon: FileText, end: false },
|
{ to: '/posts', label: '文章', icon: FileText, end: false },
|
||||||
|
{ to: '/visits', label: '访问统计', icon: BarChart3, end: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
|
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/model"
|
||||||
|
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/resp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VisitHandler 访问足迹:用户端埋点 + 管理端统计。
|
||||||
|
type VisitHandler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVisitHandler(db *gorm.DB) *VisitHandler {
|
||||||
|
return &VisitHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track POST /api/track — 用户端埋点上报(免登录)。
|
||||||
|
// 按 (今天, ClientIP) upsert:已存在则 pv+1,否则插入 pv=1。
|
||||||
|
func (h *VisitHandler) Track(c *gin.Context) {
|
||||||
|
ip := c.ClientIP()
|
||||||
|
if ip == "" {
|
||||||
|
ip = "unknown"
|
||||||
|
}
|
||||||
|
visit := model.Visit{
|
||||||
|
Date: time.Now().Format("2006-01-02"),
|
||||||
|
IP: ip,
|
||||||
|
PV: 1,
|
||||||
|
}
|
||||||
|
h.db.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "date"}, {Name: "ip"}},
|
||||||
|
DoUpdates: clause.Assignments(map[string]any{
|
||||||
|
"pv": gorm.Expr("pv + 1"),
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
}),
|
||||||
|
}).Create(&visit)
|
||||||
|
resp.OK(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
type dailyRow struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
UV int64 `json:"uv"`
|
||||||
|
PV int64 `json:"pv"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats GET /api/admin/visits?days=30 — 近 N 天每日 UV/PV。
|
||||||
|
func (h *VisitHandler) Stats(c *gin.Context) {
|
||||||
|
days := 30
|
||||||
|
if v := c.Query("days"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 365 {
|
||||||
|
days = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
since := time.Now().AddDate(0, 0, -(days - 1)).Format("2006-01-02")
|
||||||
|
today := time.Now().Format("2006-01-02")
|
||||||
|
|
||||||
|
var rows []dailyRow
|
||||||
|
h.db.Model(&model.Visit{}).
|
||||||
|
Select("date, count(*) as uv, coalesce(sum(pv), 0) as pv").
|
||||||
|
Where("date >= ?", since).
|
||||||
|
Group("date").
|
||||||
|
Order("date").
|
||||||
|
Scan(&rows)
|
||||||
|
|
||||||
|
var totalUV, totalPV, todayUV, todayPV int64
|
||||||
|
for _, r := range rows {
|
||||||
|
totalUV += r.UV
|
||||||
|
totalPV += r.PV
|
||||||
|
if r.Date == today {
|
||||||
|
todayUV = r.UV
|
||||||
|
todayPV = r.PV
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.OK(c, gin.H{
|
||||||
|
"daily": rows,
|
||||||
|
"total_uv": totalUV,
|
||||||
|
"total_pv": totalPV,
|
||||||
|
"today_uv": todayUV,
|
||||||
|
"today_pv": todayPV,
|
||||||
|
"days": days,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// Visit 用户端访问记录,按 (date, ip) 去重:每行代表某 IP 某天来过(UV);
|
||||||
|
// pv 记该 IP 当天访问次数。表名 sundynix_visit。
|
||||||
|
type Visit struct {
|
||||||
|
BaseModel
|
||||||
|
Date string `gorm:"size:10;uniqueIndex:uk_date_ip" json:"date"`
|
||||||
|
IP string `gorm:"size:64;uniqueIndex:uk_date_ip" json:"ip"`
|
||||||
|
PV int64 `json:"pv"`
|
||||||
|
}
|
||||||
@@ -30,6 +30,8 @@ func New(db *gorm.DB) (*gin.Engine, error) {
|
|||||||
api.GET("/posts/:slug", posts.Get)
|
api.GET("/posts/:slug", posts.Get)
|
||||||
|
|
||||||
api.GET("/releases/latest", handler.NewReleaseHandler().Latest)
|
api.GET("/releases/latest", handler.NewReleaseHandler().Latest)
|
||||||
|
|
||||||
|
api.POST("/track", handler.NewVisitHandler(db).Track)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RSS 订阅源(页脚 RSS 链接指向这里)
|
// RSS 订阅源(页脚 RSS 链接指向这里)
|
||||||
@@ -43,6 +45,7 @@ func New(db *gorm.DB) (*gin.Engine, error) {
|
|||||||
{
|
{
|
||||||
posts := handler.NewAdminPostHandler(db)
|
posts := handler.NewAdminPostHandler(db)
|
||||||
adminAPI.GET("/stats", posts.Stats)
|
adminAPI.GET("/stats", posts.Stats)
|
||||||
|
adminAPI.GET("/visits", handler.NewVisitHandler(db).Stats)
|
||||||
adminAPI.GET("/posts", posts.List)
|
adminAPI.GET("/posts", posts.List)
|
||||||
adminAPI.POST("/posts", posts.Create)
|
adminAPI.POST("/posts", posts.Create)
|
||||||
adminAPI.GET("/posts/:id", posts.Get)
|
adminAPI.GET("/posts/:id", posts.Get)
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func Open(dsn string) (*gorm.DB, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&model.Post{}); err != nil {
|
if err := db.AutoMigrate(&model.Post{}, &model.Visit{}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := seed(db); err != nil {
|
if err := seed(db); err != nil {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
import { Route, Routes } from 'react-router-dom'
|
import { Route, Routes } from 'react-router-dom'
|
||||||
import { SiteLayout } from '@/components/layout/site-layout'
|
import { SiteLayout } from '@/components/layout/site-layout'
|
||||||
import HomePage from '@/pages/home'
|
import HomePage from '@/pages/home'
|
||||||
@@ -7,6 +8,13 @@ import DownloadPage from '@/pages/download'
|
|||||||
import NotFoundPage from '@/pages/not-found'
|
import NotFoundPage from '@/pages/not-found'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
// 访问埋点:每个会话上报一次(按 IP 在后端去重)
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionStorage.getItem('sundynix-tracked')) return
|
||||||
|
sessionStorage.setItem('sundynix-tracked', '1')
|
||||||
|
fetch('/api/track', { method: 'POST' }).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<SiteLayout />}>
|
<Route element={<SiteLayout />}>
|
||||||
|
|||||||
Reference in New Issue
Block a user