仪表盘系统概述
仪表盘是辣评平台的数据中心,为管理员和用户提供直观的数据统计和分析功能。系统需要支持多维度的数据展示、届数筛选、图表可视化等功能。
系统目标
- 提供全面的数据统计功能
- 支持灵活的届数筛选
- 实现丰富的图表可视化
- 优化数据查询性能
- 提升用户体验
仪表盘架构设计
前端架构
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
总结
仪表盘与数据分析系统为辣评平台提供了强大的数据洞察能力,通过直观的图表和统计数据,帮助管理员和用户更好地了解平台运营状况。
关键特性
- 全面的数据统计
- 灵活的届数筛选
- 丰富的图表可视化
- 高效的数据缓存
- 优化的查询性能
后续优化方向
- 实现实时数据更新
- 添加更多维度的数据分析
- 支持自定义报表
- 实现数据导出功能
- 优化大数据量下的性能