新增域名统计功能,包括模型、服务和路由的实现,优化管理后台以支持域名访问统计的获取。

This commit is contained in:
wood chen 2025-06-19 19:59:58 +08:00
parent 3a25300b10
commit d944c11afb
14 changed files with 767 additions and 27 deletions

View File

@ -82,6 +82,8 @@ func autoMigrate() error {
&model.DataSource{},
&model.URLReplaceRule{},
&model.Config{},
&model.DomainStats{},
&model.DailyDomainStats{},
)
}

View File

@ -1123,3 +1123,36 @@ func (h *AdminHandler) DeleteConfigByKey(w http.ResponseWriter, r *http.Request)
"message": "Config deleted successfully",
})
}
// GetDomainStats 获取域名访问统计
func (h *AdminHandler) GetDomainStats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
domainStatsService := service.GetDomainStatsService()
// 获取24小时内统计
top24Hours, err := domainStatsService.GetTop24HourDomains()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to get 24-hour domain stats: %v", err), http.StatusInternalServerError)
return
}
// 获取总统计
topTotal, err := domainStatsService.GetTopTotalDomains()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to get total domain stats: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": map[string]interface{}{
"top_24_hours": top24Hours,
"top_total": topTotal,
},
})
}

View File

@ -14,23 +14,27 @@ func InitData() error {
log.Println("开始初始化应用数据...")
start := time.Now()
// 1. 初始化端点服务(这会启动预加载器)
// 1. 初始化域名统计服务
_ = service.GetDomainStatsService()
log.Println("✓ 域名统计服务已初始化")
// 2. 初始化端点服务(这会启动预加载器)
endpointService := service.GetEndpointService()
log.Println("✓ 端点服务已初始化")
// 2. 暂停预加载器的定期刷新,避免与初始化冲突
// 3. 暂停预加载器的定期刷新,避免与初始化冲突
preloader := endpointService.GetPreloader()
preloader.PausePeriodicRefresh()
log.Println("✓ 已暂停预加载器定期刷新")
// 3. 获取所有活跃的端点和数据源
// 4. 获取所有活跃的端点和数据源
endpoints, err := endpointService.ListEndpoints()
if err != nil {
log.Printf("获取端点列表失败: %v", err)
return err
}
// 4. 统计需要预加载的数据源
// 5. 统计需要预加载的数据源
var activeDataSources []model.DataSource
var totalDataSources, disabledDataSources, apiDataSources int
@ -69,7 +73,7 @@ func InitData() error {
return nil
}
// 5. 并发预加载所有数据源
// 6. 并发预加载所有数据源
var wg sync.WaitGroup
var successCount, failCount int
var mutex sync.Mutex
@ -108,7 +112,7 @@ func InitData() error {
log.Printf("✓ 数据源预加载完成: 成功 %d 个,失败 %d 个", successCount, failCount)
// 6. 预热URL统计缓存
// 7. 预热URL统计缓存
log.Println("预热URL统计缓存...")
if err := preloadURLStats(endpointService, endpoints); err != nil {
log.Printf("预热URL统计缓存失败: %v", err)
@ -116,12 +120,12 @@ func InitData() error {
log.Println("✓ URL统计缓存预热完成")
}
// 7. 预加载配置
// 8. 预加载配置
log.Println("预加载系统配置...")
preloadConfigs()
log.Println("✓ 系统配置预加载完成")
// 8. 恢复预加载器定期刷新
// 9. 恢复预加载器定期刷新
preloader.ResumePeriodicRefresh()
log.Println("✓ 已恢复预加载器定期刷新")

View File

@ -3,9 +3,10 @@ package middleware
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
)
// UserInfo OAuth用户信息结构
@ -17,12 +18,23 @@ type UserInfo struct {
Avatar string `json:"avatar"`
}
// TokenCache token缓存项
type TokenCache struct {
UserInfo *UserInfo
ExpiresAt time.Time
}
// AuthMiddleware 认证中间件
type AuthMiddleware struct{}
type AuthMiddleware struct {
tokenCache sync.Map // map[string]*TokenCache
cacheTTL time.Duration
}
// NewAuthMiddleware 创建新的认证中间件
func NewAuthMiddleware() *AuthMiddleware {
return &AuthMiddleware{}
return &AuthMiddleware{
cacheTTL: 30 * time.Minute, // token缓存30分钟
}
}
// RequireAuth 认证中间件,验证 OAuth 令牌
@ -47,16 +59,34 @@ func (am *AuthMiddleware) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
return
}
// 验证令牌(通过调用用户信息接口)
// 先检查缓存
if cached, ok := am.tokenCache.Load(token); ok {
tokenCache := cached.(*TokenCache)
// 检查缓存是否过期
if time.Now().Before(tokenCache.ExpiresAt) {
// 缓存有效,直接通过
next(w, r)
return
} else {
// 缓存过期,删除
am.tokenCache.Delete(token)
}
}
// 缓存未命中或已过期,验证令牌
userInfo, err := am.getUserInfo(token)
if err != nil {
log.Printf("Token validation failed: %v", err)
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
// 令牌有效,继续处理请求
log.Printf("Authenticated user: %s (%s)", userInfo.Username, userInfo.Email)
// 将结果缓存
am.tokenCache.Store(token, &TokenCache{
UserInfo: userInfo,
ExpiresAt: time.Now().Add(am.cacheTTL),
})
// 验证成功,继续处理请求
next(w, r)
}
}
@ -70,7 +100,9 @@ func (am *AuthMiddleware) getUserInfo(accessToken string) (*UserInfo, error) {
req.Header.Set("Authorization", "Bearer "+accessToken)
client := &http.Client{}
client := &http.Client{
Timeout: 10 * time.Second, // 添加超时时间
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get user info: %w", err)
@ -88,3 +120,22 @@ func (am *AuthMiddleware) getUserInfo(accessToken string) (*UserInfo, error) {
return &userInfo, nil
}
// InvalidateToken 使token缓存失效用于登出等场景
func (am *AuthMiddleware) InvalidateToken(token string) {
am.tokenCache.Delete(token)
}
// GetCacheStats 获取缓存统计信息(用于监控)
func (am *AuthMiddleware) GetCacheStats() map[string]interface{} {
count := 0
am.tokenCache.Range(func(key, value interface{}) bool {
count++
return true
})
return map[string]interface{}{
"cached_tokens": count,
"cache_ttl": am.cacheTTL.String(),
}
}

View File

@ -3,7 +3,9 @@ package middleware
import (
"net/http"
"random-api-go/monitoring"
"random-api-go/service"
"random-api-go/utils"
"strings"
"time"
"golang.org/x/time/rate"
@ -32,6 +34,13 @@ func MetricsMiddleware(next http.Handler) http.Handler {
IP: utils.GetRealIP(r),
Referer: r.Referer(),
})
// 对于管理后台API请求跳过域名统计以提升性能
if !strings.HasPrefix(r.URL.Path, "/api/admin/") {
// 记录域名统计
domainStatsService := service.GetDomainStatsService()
domainStatsService.RecordRequest(r.URL.Path, r.Referer())
}
})
}

View File

@ -129,3 +129,31 @@ type S3Config struct {
IncludeSubfolders bool `json:"include_subfolders"` // 是否提取所有子文件夹
FileExtensions []string `json:"file_extensions"` // 提取的文件格式后缀,支持正则匹配
}
// DomainStats 域名访问统计模型
type DomainStats struct {
ID uint `json:"id" gorm:"primaryKey"`
Domain string `json:"domain" gorm:"index;not null"` // 来源域名
Count uint64 `json:"count" gorm:"default:0"` // 访问次数
LastSeen time.Time `json:"last_seen"` // 最后访问时间
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
// DailyDomainStats 每日域名访问统计模型
type DailyDomainStats struct {
ID uint `json:"id" gorm:"primaryKey"`
Domain string `json:"domain" gorm:"index;not null"` // 来源域名
Date time.Time `json:"date" gorm:"index;not null"` // 统计日期
Count uint64 `json:"count" gorm:"default:0"` // 当日访问次数
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
// DomainStatsResult 域名统计结果
type DomainStatsResult struct {
Domain string `json:"domain"`
Count uint64 `json:"count"`
}

View File

@ -10,6 +10,7 @@ type Router struct {
mux *http.ServeMux
staticHandler StaticHandler
authMiddleware *middleware.AuthMiddleware
middlewares []func(http.Handler) http.Handler
}
// Handler 接口定义处理器需要的方法
@ -64,12 +65,19 @@ type AdminHandler interface {
ListConfigs(w http.ResponseWriter, r *http.Request)
CreateOrUpdateConfig(w http.ResponseWriter, r *http.Request)
DeleteConfigByKey(w http.ResponseWriter, r *http.Request)
// 域名统计
GetDomainStats(w http.ResponseWriter, r *http.Request)
}
func New() *Router {
return &Router{
mux: http.NewServeMux(),
authMiddleware: middleware.NewAuthMiddleware(),
middlewares: []func(http.Handler) http.Handler{
middleware.MetricsMiddleware,
middleware.RateLimiter,
},
}
}
@ -162,6 +170,9 @@ func (r *Router) setupAdminRoutes(adminHandler AdminHandler) {
adminHandler.CreateOrUpdateConfig(w, r)
}
}))
// 域名统计路由 - 需要认证
r.HandleFunc("/api/admin/domain-stats", r.authMiddleware.RequireAuth(adminHandler.GetDomainStats))
}
func (r *Router) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) {
@ -175,8 +186,15 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
return
}
// 否则使用默认的路由处理
r.mux.ServeHTTP(w, req)
// 应用中间件链,然后使用路由处理
handler := http.Handler(r.mux)
// 反向应用中间件(因为要从最外层开始包装)
for i := len(r.middlewares) - 1; i >= 0; i-- {
handler = r.middlewares[i](handler)
}
handler.ServeHTTP(w, req)
}
// shouldServeStatic 判断是否应该由静态文件处理器处理

View File

@ -68,7 +68,6 @@ func (dsf *DataSourceFetcher) FetchURLs(dataSource *model.DataSource) ([]string,
func (dsf *DataSourceFetcher) FetchURLsWithOptions(dataSource *model.DataSource, skipCache bool) ([]string, error) {
// API类型的数据源直接实时请求不使用缓存
if dataSource.Type == "api_get" || dataSource.Type == "api_post" {
log.Printf("实时请求API数据源 (类型: %s, ID: %d)", dataSource.Type, dataSource.ID)
return dsf.fetchAPIURLs(dataSource)
}
@ -78,11 +77,8 @@ func (dsf *DataSourceFetcher) FetchURLsWithOptions(dataSource *model.DataSource,
// 如果不跳过缓存,先检查内存缓存
if !skipCache {
if cachedURLs, exists := dsf.cacheManager.GetFromMemoryCache(cacheKey); exists && len(cachedURLs) > 0 {
log.Printf("从内存缓存获取到 %d 个URL (数据源ID: %d)", len(cachedURLs), dataSource.ID)
return cachedURLs, nil
}
} else {
log.Printf("跳过缓存,强制从数据源获取最新数据 (数据源ID: %d)", dataSource.ID)
}
var urls []string

View File

@ -0,0 +1,369 @@
package service
import (
"log"
"net/url"
"strings"
"sync"
"time"
"random-api-go/database"
"random-api-go/model"
"gorm.io/gorm"
)
// DomainStatsService 域名统计服务
type DomainStatsService struct {
mu sync.RWMutex
memoryStats map[string]*DomainStatsBatch // 内存中的批量统计
}
// DomainStatsBatch 批量统计数据
type DomainStatsBatch struct {
Count uint64
LastSeen time.Time
}
var (
domainStatsService *DomainStatsService
domainStatsOnce sync.Once
)
// GetDomainStatsService 获取域名统计服务实例
func GetDomainStatsService() *DomainStatsService {
domainStatsOnce.Do(func() {
domainStatsService = &DomainStatsService{
memoryStats: make(map[string]*DomainStatsBatch),
}
// 启动定期保存任务
go domainStatsService.startPeriodicSave()
})
return domainStatsService
}
// extractDomain 从Referer中提取域名
func (s *DomainStatsService) extractDomain(referer string) string {
if referer == "" {
return "direct" // 直接访问
}
parsedURL, err := url.Parse(referer)
if err != nil {
return "unknown"
}
domain := parsedURL.Hostname()
if domain == "" {
return "unknown"
}
return domain
}
// RecordRequest 记录请求(忽略静态文件和管理后台)
func (s *DomainStatsService) RecordRequest(path, referer string) {
// 忽略的路径模式
if s.shouldIgnorePath(path) {
return
}
domain := s.extractDomain(referer)
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
if batch, exists := s.memoryStats[domain]; exists {
batch.Count++
batch.LastSeen = now
} else {
s.memoryStats[domain] = &DomainStatsBatch{
Count: 1,
LastSeen: now,
}
}
}
// shouldIgnorePath 判断是否应该忽略此路径的统计
func (s *DomainStatsService) shouldIgnorePath(path string) bool {
// 忽略管理后台API
if strings.HasPrefix(path, "/api/admin/") {
return true
}
// 忽略系统API
if strings.HasPrefix(path, "/api") {
return true
}
// 忽略静态文件路径
if strings.HasPrefix(path, "/_next/") ||
strings.HasPrefix(path, "/static/") ||
strings.HasPrefix(path, "/favicon.ico") ||
s.hasFileExtension(path) {
return true
}
// 忽略前端路由
if strings.HasPrefix(path, "/admin") {
return true
}
// 忽略根路径(通常是前端首页)
if path == "/" {
return true
}
// 其他路径都统计包括所有API端点
return false
}
// hasFileExtension 检查路径是否包含文件扩展名
func (s *DomainStatsService) hasFileExtension(path string) bool {
// 获取路径的最后一部分
parts := strings.Split(path, "/")
if len(parts) == 0 {
return false
}
lastPart := parts[len(parts)-1]
// 检查是否包含点号且不是隐藏文件
if strings.Contains(lastPart, ".") && !strings.HasPrefix(lastPart, ".") {
// 常见的文件扩展名
commonExts := []string{
".html", ".css", ".js", ".json", ".png", ".jpg", ".jpeg",
".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".eot",
".txt", ".xml", ".pdf", ".zip", ".mp4", ".mp3", ".webp",
}
for _, ext := range commonExts {
if strings.HasSuffix(strings.ToLower(lastPart), ext) {
return true
}
}
}
return false
}
// startPeriodicSave 启动定期保存任务每5分钟保存一次
func (s *DomainStatsService) startPeriodicSave() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
// 定期清理任务(每天执行一次)
cleanupTicker := time.NewTicker(24 * time.Hour)
defer cleanupTicker.Stop()
for {
select {
case <-ticker.C:
if err := s.flushToDatabase(); err != nil {
log.Printf("Failed to flush domain stats to database: %v", err)
}
case <-cleanupTicker.C:
if err := s.CleanupOldStats(); err != nil {
log.Printf("Failed to cleanup old domain stats: %v", err)
} else {
log.Println("Domain stats cleanup completed successfully")
}
}
}
}
// flushToDatabase 将内存中的统计数据保存到数据库
func (s *DomainStatsService) flushToDatabase() error {
s.mu.Lock()
currentStats := s.memoryStats
s.memoryStats = make(map[string]*DomainStatsBatch) // 重置内存统计
s.mu.Unlock()
if len(currentStats) == 0 {
return nil
}
return database.DB.Transaction(func(tx *gorm.DB) error {
today := time.Now().Truncate(24 * time.Hour)
for domain, batch := range currentStats {
// 更新总统计
var domainStats model.DomainStats
err := tx.Where("domain = ?", domain).First(&domainStats).Error
if err == gorm.ErrRecordNotFound {
// 创建新记录
domainStats = model.DomainStats{
Domain: domain,
Count: batch.Count,
LastSeen: batch.LastSeen,
}
if err := tx.Create(&domainStats).Error; err != nil {
return err
}
} else if err != nil {
return err
} else {
// 更新现有记录
domainStats.Count += batch.Count
domainStats.LastSeen = batch.LastSeen
if err := tx.Save(&domainStats).Error; err != nil {
return err
}
}
// 更新每日统计
var dailyStats model.DailyDomainStats
err = tx.Where("domain = ? AND date = ?", domain, today).First(&dailyStats).Error
if err == gorm.ErrRecordNotFound {
// 创建新记录
dailyStats = model.DailyDomainStats{
Domain: domain,
Date: today,
Count: batch.Count,
}
if err := tx.Create(&dailyStats).Error; err != nil {
return err
}
} else if err != nil {
return err
} else {
// 更新现有记录
dailyStats.Count += batch.Count
if err := tx.Save(&dailyStats).Error; err != nil {
return err
}
}
}
return nil
})
}
// GetTop24HourDomains 获取24小时内访问最多的前20个域名
func (s *DomainStatsService) GetTop24HourDomains() ([]model.DomainStatsResult, error) {
yesterday := time.Now().AddDate(0, 0, -1).Truncate(24 * time.Hour)
today := time.Now().Truncate(24 * time.Hour)
// 从数据库获取数据
var dbResults []model.DomainStatsResult
err := database.DB.Model(&model.DailyDomainStats{}).
Select("domain, SUM(count) as count").
Where("date >= ? AND date <= ?", yesterday, today).
Group("domain").
Scan(&dbResults).Error
if err != nil {
return nil, err
}
// 合并内存数据
s.mu.RLock()
memoryStats := make(map[string]uint64)
for domain, batch := range s.memoryStats {
memoryStats[domain] = batch.Count
}
s.mu.RUnlock()
// 创建域名计数映射
domainCounts := make(map[string]uint64)
// 添加数据库数据
for _, result := range dbResults {
domainCounts[result.Domain] += result.Count
}
// 添加内存数据
for domain, count := range memoryStats {
domainCounts[domain] += count
}
// 转换为结果切片并排序
var results []model.DomainStatsResult
for domain, count := range domainCounts {
results = append(results, model.DomainStatsResult{
Domain: domain,
Count: count,
})
}
// 按访问次数降序排序
for i := 0; i < len(results)-1; i++ {
for j := i + 1; j < len(results); j++ {
if results[i].Count < results[j].Count {
results[i], results[j] = results[j], results[i]
}
}
}
// 限制返回前20个
if len(results) > 30 {
results = results[:30]
}
return results, nil
}
// GetTopTotalDomains 获取总访问最多的前20个域名
func (s *DomainStatsService) GetTopTotalDomains() ([]model.DomainStatsResult, error) {
// 从数据库获取数据
var dbResults []model.DomainStatsResult
err := database.DB.Model(&model.DomainStats{}).
Select("domain, count").
Scan(&dbResults).Error
if err != nil {
return nil, err
}
// 合并内存数据
s.mu.RLock()
memoryStats := make(map[string]uint64)
for domain, batch := range s.memoryStats {
memoryStats[domain] = batch.Count
}
s.mu.RUnlock()
// 创建域名计数映射
domainCounts := make(map[string]uint64)
// 添加数据库数据
for _, result := range dbResults {
domainCounts[result.Domain] += result.Count
}
// 添加内存数据
for domain, count := range memoryStats {
domainCounts[domain] += count
}
// 转换为结果切片并排序
var results []model.DomainStatsResult
for domain, count := range domainCounts {
results = append(results, model.DomainStatsResult{
Domain: domain,
Count: count,
})
}
// 按访问次数降序排序
for i := 0; i < len(results)-1; i++ {
for j := i + 1; j < len(results); j++ {
if results[i].Count < results[j].Count {
results[i], results[j] = results[j], results[i]
}
}
}
// 限制返回前20个
if len(results) > 30 {
results = results[:30]
}
return results, nil
}
// CleanupOldStats 清理旧的统计数据保留最近30天的每日统计
func (s *DomainStatsService) CleanupOldStats() error {
cutoffDate := time.Now().AddDate(0, 0, -30).Truncate(24 * time.Hour)
return database.DB.Where("date < ?", cutoffDate).Delete(&model.DailyDomainStats{}).Error
}

View File

@ -2,7 +2,6 @@ package service
import (
"fmt"
"log"
"math/rand"
"random-api-go/database"
"random-api-go/model"
@ -174,7 +173,6 @@ func (s *EndpointService) GetRandomURL(url string) (string, error) {
// 如果包含实时数据源,不使用内存缓存,直接实时获取
if hasRealtimeDataSource {
log.Printf("端点包含实时数据源,使用实时请求模式: %s", url)
return s.getRandomURLRealtime(endpoint)
}
@ -198,7 +196,6 @@ func (s *EndpointService) getRandomURLRealtime(endpoint *model.APIEndpoint) (str
// 先随机选择一个数据源
selectedDataSource := activeDataSources[rand.Intn(len(activeDataSources))]
log.Printf("随机选择数据源: %s (ID: %d)", selectedDataSource.Type, selectedDataSource.ID)
// 只从选中的数据源获取URL
urls, err := s.dataSourceFetcher.FetchURLs(&selectedDataSource)
@ -256,7 +253,6 @@ func (s *EndpointService) getRandomURLWithCache(endpoint *model.APIEndpoint) (st
// 先随机选择一个数据源
selectedDataSource := activeDataSources[rand.Intn(len(activeDataSources))]
log.Printf("随机选择数据源: %s (ID: %d)", selectedDataSource.Type, selectedDataSource.ID)
// 从选中的数据源获取URL会使用缓存
urls, err := s.dataSourceFetcher.FetchURLs(&selectedDataSource)
@ -303,7 +299,6 @@ func (s *EndpointService) applyURLReplaceRules(url, endpointURL string) string {
// 获取端点的替换规则
endpoint, err := s.GetEndpointByURL(endpointURL)
if err != nil {
log.Printf("Failed to get endpoint for URL replacement: %v", err)
return url
}

View File

@ -16,6 +16,7 @@ const navItems = [
{ key: 'endpoints', label: 'API端点', href: '/admin' },
{ key: 'rules', label: 'URL替换规则', href: '/admin/rules' },
{ key: 'home', label: '首页配置', href: '/admin/home' },
{ key: 'stats', label: '域名统计', href: '/admin/stats' },
]
export default function AdminLayout({

View File

@ -0,0 +1,5 @@
import DomainStatsTab from '@/components/admin/DomainStatsTab'
export default function StatsPage() {
return <DomainStatsTab />
}

View File

@ -0,0 +1,219 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Switch } from '@/components/ui/switch'
import { Button } from '@/components/ui/button'
import { authenticatedFetch } from '@/lib/auth'
import type { DomainStatsResult } from '@/types/admin'
// 表格组件,用于显示域名统计数据
const DomainStatsTable = ({
title,
data,
loading
}: {
title: string;
data: DomainStatsResult[] | null;
loading: boolean
}) => {
const formatNumber = (num: number) => {
return num.toLocaleString()
}
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex justify-center items-center py-8">
<div className="text-gray-500">...</div>
</div>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
{!data || data.length === 0 ? (
<p className="text-gray-500 text-center py-4"></p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12"></TableHead>
<TableHead></TableHead>
<TableHead className="text-right">访</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((item, index) => (
<TableRow key={`${item.domain}-${item.count}`}>
<TableCell className="font-medium">{index + 1}</TableCell>
<TableCell>
<span className="font-mono">
{item.domain === 'direct' ? '直接访问' :
item.domain === 'unknown' ? '未知来源' :
item.domain}
</span>
</TableCell>
<TableCell className="text-right">
{formatNumber(item.count)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)
}
export default function DomainStatsTab() {
// 状态管理
const [loading, setLoading] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [stats24h, setStats24h] = useState<DomainStatsResult[] | null>(null)
const [statsTotal, setStatsTotal] = useState<DomainStatsResult[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [autoRefresh, setAutoRefresh] = useState(true)
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null)
const intervalRef = useRef<NodeJS.Timeout | null>(null)
// 加载域名统计数据
const loadDomainStats = useCallback(async (isInitialLoad = false) => {
try {
if (isInitialLoad) {
setLoading(true)
} else {
setRefreshing(true)
}
setError(null)
const response = await authenticatedFetch('/api/admin/domain-stats')
if (response.ok) {
const data = await response.json()
if (data.data) {
setStats24h(data.data.top_24_hours || [])
setStatsTotal(data.data.top_total || [])
setLastUpdateTime(new Date())
}
} else {
throw new Error('获取域名统计失败')
}
} catch (error) {
console.error('Failed to load domain stats:', error)
setError('获取域名统计失败')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
// 初始加载
useEffect(() => {
loadDomainStats(true)
}, [loadDomainStats])
// 自动刷新设置
useEffect(() => {
if (autoRefresh) {
// 设置自动刷新定时器
intervalRef.current = setInterval(() => {
loadDomainStats(false)
}, 5000) // 每5秒刷新一次
} else {
// 清除定时器
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
// 清理函数
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}
}, [autoRefresh, loadDomainStats])
// 格式化更新时间
const formatUpdateTime = (time: Date | null) => {
if (!time) return ''
return time.toLocaleTimeString()
}
// 显示错误状态
if (error && !stats24h && !statsTotal) {
return (
<div className="flex flex-col items-center justify-center py-8 space-y-4">
<div className="text-red-500">{error}</div>
<Button
onClick={() => loadDomainStats(true)}
variant="default"
>
</Button>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold">访</h2>
{lastUpdateTime && (
<p className="text-sm text-gray-500 mt-1">
: {formatUpdateTime(lastUpdateTime)}
{refreshing && <span className="ml-2 animate-pulse">...</span>}
</p>
)}
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Switch
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
id="auto-refresh"
/>
<label htmlFor="auto-refresh" className="text-sm">
(5)
</label>
</div>
<Button
onClick={() => loadDomainStats(false)}
disabled={refreshing}
variant="default"
>
{refreshing ? '刷新中...' : '手动刷新'}
</Button>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<DomainStatsTable
title="24小时内访问最多的域名 (前30)"
data={stats24h}
loading={loading && !stats24h}
/>
<DomainStatsTable
title="总访问最多的域名 (前30)"
data={statsTotal}
loading={loading && !statsTotal}
/>
</div>
</div>
)
}

View File

@ -45,3 +45,13 @@ export interface OAuthConfig {
client_id: string
base_url: string
}
export interface DomainStatsResult {
domain: string
count: number
}
export interface DomainStatsData {
top_24_hours: DomainStatsResult[]
top_total: DomainStatsResult[]
}