diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 11e20e8..deb7db5 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -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 @@ -21,6 +22,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/admin/src/api/visits.ts b/admin/src/api/visits.ts new file mode 100644 index 0000000..468e39d --- /dev/null +++ b/admin/src/api/visits.ts @@ -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(`/api/admin/visits?days=${days}`) +} diff --git a/admin/src/components/admin-sidebar.tsx b/admin/src/components/admin-sidebar.tsx index dc5777d..1762740 100644 --- a/admin/src/components/admin-sidebar.tsx +++ b/admin/src/components/admin-sidebar.tsx @@ -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 { diff --git a/admin/src/pages/visits.tsx b/admin/src/pages/visits.tsx new file mode 100644 index 0000000..6af6c7c --- /dev/null +++ b/admin/src/pages/visits.tsx @@ -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(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 ( +
+
+
+

访问统计

+

+ 用户端访问足迹,按 IP 去重 +

+
+
+ {RANGES.map((r) => ( + + ))} +
+
+ + {error && ( +

+ {error} +

+ )} + +
+ {cards.map((c) => ( +
+
+ {c.label} + +
+
+ {c.value ?? '—'} +
+
+ ))} +
+ +
+
+

每日访客趋势

+ UV +
+
+ {series.map((s) => ( +
+ ))} +
+
+ {series[0]?.date.slice(5)} + {series[series.length - 1]?.date.slice(5)} +
+
+
+ ) +} diff --git a/server/internal/handler/visit.go b/server/internal/handler/visit.go new file mode 100644 index 0000000..5932198 --- /dev/null +++ b/server/internal/handler/visit.go @@ -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, + }) +} diff --git a/server/internal/model/visit.go b/server/internal/model/visit.go new file mode 100644 index 0000000..da8eddf --- /dev/null +++ b/server/internal/model/visit.go @@ -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"` +} diff --git a/server/internal/router/router.go b/server/internal/router/router.go index 736fdd6..098376e 100644 --- a/server/internal/router/router.go +++ b/server/internal/router/router.go @@ -30,6 +30,8 @@ func New(db *gorm.DB) (*gin.Engine, error) { api.GET("/posts/:slug", posts.Get) api.GET("/releases/latest", handler.NewReleaseHandler().Latest) + + api.POST("/track", handler.NewVisitHandler(db).Track) } // RSS 订阅源(页脚 RSS 链接指向这里) @@ -43,6 +45,7 @@ func New(db *gorm.DB) (*gin.Engine, error) { { posts := handler.NewAdminPostHandler(db) adminAPI.GET("/stats", posts.Stats) + adminAPI.GET("/visits", handler.NewVisitHandler(db).Stats) adminAPI.GET("/posts", posts.List) adminAPI.POST("/posts", posts.Create) adminAPI.GET("/posts/:id", posts.Get) diff --git a/server/internal/store/db.go b/server/internal/store/db.go index fac58df..81e04ca 100644 --- a/server/internal/store/db.go +++ b/server/internal/store/db.go @@ -34,7 +34,7 @@ func Open(dsn string) (*gorm.DB, error) { if err != nil { return nil, err } - if err := db.AutoMigrate(&model.Post{}); err != nil { + if err := db.AutoMigrate(&model.Post{}, &model.Visit{}); err != nil { return nil, err } if err := seed(db); err != nil { diff --git a/web/src/App.tsx b/web/src/App.tsx index 1216b6a..70159b3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react' import { Route, Routes } from 'react-router-dom' import { SiteLayout } from '@/components/layout/site-layout' import HomePage from '@/pages/home' @@ -7,6 +8,13 @@ import DownloadPage from '@/pages/download' import NotFoundPage from '@/pages/not-found' export default function App() { + // 访问埋点:每个会话上报一次(按 IP 在后端去重) + useEffect(() => { + if (sessionStorage.getItem('sundynix-tracked')) return + sessionStorage.setItem('sundynix-tracked', '1') + fetch('/api/track', { method: 'POST' }).catch(() => {}) + }, []) + return ( }>