辣评仪表盘与数据分析系统(十)

 

仪表盘系统概述

仪表盘是辣评平台的数据中心,为管理员和用户提供直观的数据统计和分析功能。系统需要支持多维度的数据展示、届数筛选、图表可视化等功能。

系统目标

  • 提供全面的数据统计功能
  • 支持灵活的届数筛选
  • 实现丰富的图表可视化
  • 优化数据查询性能
  • 提升用户体验

仪表盘架构设计

前端架构

admin-vue/src/views/admin/
├── Dashboard.vue           # 仪表盘主页面
├── DashboardOverview.vue   # 概览页面
├── DashboardAnalysis.vue   # 数据分析页面
└── components/
    ├── StatCard.vue        # 统计卡片组件
    ├── ChartCard.vue       # 图表卡片组件
    └── FilterBar.vue       # 筛选栏组件

后端架构

cmd/server/
├── handlers/
   └── dashboard_handler.go    # 仪表盘接口
├── services/
   └── statistics_service.go   # 统计服务
└── models/
    └── statistics.go           # 统计模型

统计图表实现

1. 统计卡片组件

<template>
  <el-card class="stat-card" :class="cardClass">
    <div class="stat-header">
      <span class="stat-label"></span>
      <el-icon v-if="icon" :class="iconClass">
        <component :is="icon" />
      </el-icon>
    </div>
    <div class="stat-content">
      <div class="stat-value"></div>
      <div v-if="trend" class="stat-trend" :class="trendClass">
        <el-icon>
          <ArrowUp v-if="trend > 0" />
          <ArrowDown v-else />
        </el-icon>
        <span>%</span>
      </div>
    </div>
    <div v-if="description" class="stat-description">
      
    </div>
  </el-card>
</template>

<script setup>
import { computed } from 'vue'
import { ArrowUp, ArrowDown } from '@element-plus/icons-vue'

const props = defineProps({
  label: String,
  value: [Number, String],
  icon: Object,
  trend: Number,
  description: String,
  type: {
    type: String,
    default: 'default'
  }
})

const formattedValue = computed(() => {
  if (typeof props.value === 'number') {
    return props.value.toLocaleString()
  }
  return props.value
})

const cardClass = computed(() => `stat-card--${props.type}`)
const iconClass = computed(() => `stat-icon stat-icon--${props.type}`)
const trendClass = computed(() => props.trend > 0 ? 'trend-up' : 'trend-down')
</script>

<style scoped>
.stat-card {
  height: 100%;
  transition: all 0.3s;
}

.stat-card:hover {
  transform: translateY(-4px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.stat-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 16px;
}

.stat-label {
  font-size: 14px;
  color: #909399;
}

.stat-icon {
  font-size: 24px;
}

.stat-icon--primary { color: #409eff; }
.stat-icon--success { color: #67c23a; }
.stat-icon--warning { color: #e6a23c; }
.stat-icon--danger { color: #f56c6c; }

.stat-content {
  display: flex;
  align-items: baseline;
  gap: 12px;
}

.stat-value {
  font-size: 28px;
  font-weight: bold;
  color: #303133;
}

.stat-trend {
  display: flex;
  align-items: center;
  gap: 4px;
  font-size: 14px;
}

.trend-up { color: #67c23a; }
.trend-down { color: #f56c6c; }

.stat-description {
  margin-top: 8px;
  font-size: 12px;
  color: #909399;
}
</style>

2. 图表卡片组件

<template>
  <el-card class="chart-card">
    <template #header>
      <div class="chart-header">
        <span class="chart-title"></span>
        <el-button-group v-if="showTypeSwitch">
          <el-button
            size="small"
            :type="chartType === 'line' ? 'primary' : ''"
            @click="chartType = 'line'"
          >
            折线图
          </el-button>
          <el-button
            size="small"
            :type="chartType === 'bar' ? 'primary' : ''"
            @click="chartType = 'bar'"
          >
            柱状图
          </el-button>
        </el-button-group>
      </div>
    </template>
    <div ref="chartRef" class="chart-container"></div>
  </el-card>
</template>

<script setup>
import { ref, onMounted, watch } from 'vue'
import * as echarts from 'echarts'

const props = defineProps({
  title: String,
  data: Object,
  showTypeSwitch: Boolean
})

const chartRef = ref(null)
const chartType = ref('line')
let chartInstance = null

const initChart = () => {
  if (!chartRef.value) return

  chartInstance = echarts.init(chartRef.value)
  updateChart()
}

const updateChart = () => {
  if (!chartInstance || !props.data) return

  const option = {
    tooltip: {
      trigger: 'axis'
    },
    legend: {
      data: props.data.series.map(s => s.name)
    },
    xAxis: {
      type: 'category',
      data: props.data.xAxis
    },
    yAxis: {
      type: 'value'
    },
    series: props.data.series.map(s => ({
      name: s.name,
      type: chartType.value,
      data: s.data,
      smooth: true
    }))
  }

  chartInstance.setOption(option)
}

watch(() => props.data, updateChart, { deep: true })
watch(chartType, updateChart)

onMounted(() => {
  initChart()
  window.addEventListener('resize', () => {
    chartInstance?.resize()
  })
})
</script>

<style scoped>
.chart-card {
  height: 100%;
}

.chart-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.chart-title {
  font-size: 16px;
  font-weight: bold;
}

.chart-container {
  height: 300px;
}
</style>

数据聚合与计算

统计服务实现

// cmd/server/services/statistics_service.go
package services

import (
    "context"
    "time"
    "gorm.io/gorm"
    "github.com/yourusername/laping/cmd/server/models"
)

type StatisticsService struct {
    db *gorm.DB
}

// DashboardStats 仪表盘统计数据
type DashboardStats struct {
    TotalUsers          int64   `json:"totalUsers"`
    TotalSubmissions    int64   `json:"totalSubmissions"`
    TotalComments       int64   `json:"totalComments"`
    QualifiedAuthors    int64   `json:"qualifiedAuthors"`
    QualifiedReaders    int64   `json:"qualifiedReaders"`
    AverageScore        float64 `json:"averageScore"`
    CompletionRate      float64 `json:"completionRate"`
}

// GetDashboardStats 获取仪表盘统计数据
func (s *StatisticsService) GetDashboardStats(ctx context.Context, competitionID uint) (*DashboardStats, error) {
    stats := &DashboardStats{}

    // 统计用户总数
    if err := s.db.Model(&models.User{}).Count(&stats.TotalUsers).Error; err != nil {
        return nil, err
    }

    // 统计投稿总数
    query := s.db.Model(&models.Submission{})
    if competitionID > 0 {
        query = query.Where("competition_id = ?", competitionID)
    }
    if err := query.Count(&stats.TotalSubmissions).Error; err != nil {
        return nil, err
    }

    // 统计评论总数
    query = s.db.Model(&models.Comment{})
    if competitionID > 0 {
        query = query.Where("competition_id = ?", competitionID)
    }
    if err := query.Count(&stats.TotalComments).Error; err != nil {
        return nil, err
    }

    // 统计合格作者数
    query = s.db.Model(&models.QualificationStatistics{})
    if competitionID > 0 {
        query = query.Where("competition_id = ?", competitionID)
    }
    if err := query.Where("is_author = ?", true).Count(&stats.QualifiedAuthors).Error; err != nil {
        return nil, err
    }

    // 统计合格读者数
    query = s.db.Model(&models.QualificationStatistics{})
    if competitionID > 0 {
        query = query.Where("competition_id = ?", competitionID)
    }
    if err := query.Where("is_reader = ?", true).Count(&stats.QualifiedReaders).Error; err != nil {
        return nil, err
    }

    // 计算平均评分
    query = s.db.Model(&models.Comment{})
    if competitionID > 0 {
        query = query.Where("competition_id = ?", competitionID)
    }
    query.Select("AVG(score)").Row().Scan(&stats.AverageScore)

    // 计算任务完成率
    if stats.TotalUsers > 0 {
        stats.CompletionRate = float64(stats.QualifiedReaders) / float64(stats.TotalUsers) * 100
    }

    return stats, nil
}

// GetTrendData 获取趋势数据
func (s *StatisticsService) GetTrendData(ctx context.Context, competitionID uint, days int) (map[string]interface{}, error) {
    startDate := time.Now().AddDate(0, 0, -days)

    // 查询每日投稿数
    var submissionTrend []struct {
        Date  string
        Count int64
    }

    query := s.db.Model(&models.Submission{}).
        Select("DATE(created_at) as date, COUNT(*) as count").
        Where("created_at >= ?", startDate)

    if competitionID > 0 {
        query = query.Where("competition_id = ?", competitionID)
    }

    query.Group("DATE(created_at)").
        Order("date ASC").
        Scan(&submissionTrend)

    // 查询每日评论数
    var commentTrend []struct {
        Date  string
        Count int64
    }

    query = s.db.Model(&models.Comment{}).
        Select("DATE(created_at) as date, COUNT(*) as count").
        Where("created_at >= ?", startDate)

    if competitionID > 0 {
        query = query.Where("competition_id = ?", competitionID)
    }

    query.Group("DATE(created_at)").
        Order("date ASC").
        Scan(&commentTrend)

    return map[string]interface{}{
        "submissions": submissionTrend,
        "comments":    commentTrend,
    }, nil
}

届数筛选功能

前端筛选组件

<template>
  <div class="filter-bar">
    <el-form :inline="true" :model="filters">
      <el-form-item label="比赛届次">
        <el-select
          v-model="filters.competitionId"
          placeholder="选择届次"
          @change="handleFilterChange"
          clearable
        >
          <el-option label="全部届次" :value="0"></el-option>
          <el-option
            v-for="comp in competitions"
            :key="comp.id"
            :label="`第${comp.number}届`"
            :value="comp.id"
          ></el-option>
        </el-select>
      </el-form-item>

      <el-form-item label="时间范围">
        <el-date-picker
          v-model="filters.dateRange"
          type="daterange"
          range-separator="至"
          start-placeholder="开始日期"
          end-placeholder="结束日期"
          @change="handleFilterChange"
        />
      </el-form-item>

      <el-form-item>
        <el-button type="primary" @click="handleRefresh">
          <el-icon><Refresh /></el-icon>
          刷新数据
        </el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { getCompetitions } from '@/api/competition'

const emit = defineEmits(['filter-change', 'refresh'])

const filters = ref({
  competitionId: 0,
  dateRange: null
})

const competitions = ref([])

const loadCompetitions = async () => {
  try {
    const res = await getCompetitions()
    competitions.value = res.data

    // 默认选择最新届次
    if (competitions.value.length > 0) {
      const latest = competitions.value.reduce((max, comp) =>
        comp.number > max.number ? comp : max
      )
      filters.value.competitionId = latest.id
      handleFilterChange()
    }
  } catch (error) {
    console.error('加载比赛列表失败:', error)
  }
}

const handleFilterChange = () => {
  emit('filter-change', filters.value)
}

const handleRefresh = () => {
  emit('refresh')
}

onMounted(() => {
  loadCompetitions()
})
</script>

<style scoped>
.filter-bar {
  margin-bottom: 20px;
  padding: 16px;
  background-color: #fff;
  border-radius: 4px;
}
</style>

性能优化

1. 数据缓存策略

// 使用内存缓存减少数据库查询
type CachedStats struct {
    Data      *DashboardStats
    ExpiresAt time.Time
}

var statsCache = make(map[uint]*CachedStats)

func (s *StatisticsService) GetDashboardStatsWithCache(ctx context.Context, competitionID uint) (*DashboardStats, error) {
    // 检查缓存
    if cached, ok := statsCache[competitionID]; ok {
        if time.Now().Before(cached.ExpiresAt) {
            return cached.Data, nil
        }
    }

    // 从数据库查询
    stats, err := s.GetDashboardStats(ctx, competitionID)
    if err != nil {
        return nil, err
    }

    // 更新缓存(5分钟过期)
    statsCache[competitionID] = &CachedStats{
        Data:      stats,
        ExpiresAt: time.Now().Add(5 * time.Minute),
    }

    return stats, nil
}

2. 查询优化

// 使用索引优化查询
func (s *StatisticsService) GetUserActivityStats(ctx context.Context, competitionID uint) ([]UserActivity, error) {
    var activities []UserActivity

    // 使用联合查询减少数据库往返
    err := s.db.Table("users").
        Select(`
            users.id,
            users.username,
            COUNT(DISTINCT submissions.id) as submission_count,
            COUNT(DISTINCT comments.id) as comment_count,
            AVG(comments.score) as avg_score
        `).
        Joins("LEFT JOIN submissions ON users.id = submissions.user_id AND submissions.competition_id = ?", competitionID).
        Joins("LEFT JOIN comments ON users.id = comments.user_id AND comments.competition_id = ?", competitionID).
        Group("users.id").
        Having("submission_count > 0 OR comment_count > 0").
        Scan(&activities).Error

    return activities, err
}

API 接口设计

GET /api/admin/dashboard/stats?competition_id=1
GET /api/admin/dashboard/trend?competition_id=1&days=30
GET /api/admin/dashboard/user-activity?competition_id=1
GET /api/admin/dashboard/submission-stats?competition_id=1
GET /api/admin/dashboard/comment-stats?competition_id=1

总结

仪表盘与数据分析系统为辣评平台提供了强大的数据洞察能力,通过直观的图表和统计数据,帮助管理员和用户更好地了解平台运营状况。

关键特性

  • 全面的数据统计
  • 灵活的届数筛选
  • 丰富的图表可视化
  • 高效的数据缓存
  • 优化的查询性能

后续优化方向

  • 实现实时数据更新
  • 添加更多维度的数据分析
  • 支持自定义报表
  • 实现数据导出功能
  • 优化大数据量下的性能

本文遵守 Attribution-NonCommercial 4.0 International 许可协议。 Attribution-NonCommercial 4.0 International