引言:数字化时代的赛事评分革命

在现代体育赛事、技能竞赛和选秀活动中,现场评分大屏已经成为提升赛事专业度和观众体验的核心技术手段。传统的纸质评分或封闭式后台计算往往导致信息不透明、观众参与感低、甚至引发对公正性的质疑。而实时大屏显示系统通过技术手段将评分过程完全公开化,不仅解决了这些痛点,更将评分本身转化为一种观赏体验。

核心价值分析

  1. 透明度革命:实时显示每位评委的打分细节,消除”暗箱操作”的猜疑
  2. 即时反馈:观众和选手能第一时间看到得分变化,产生紧张刺激的期待感
  3. 互动升级:通过大屏展示观众投票、实时弹幕等互动数据
  4. 数据沉淀:完整的评分数据为后续分析和改进提供依据

系统架构设计与技术实现

基础架构组成

一个完整的现场评分大屏系统通常包含以下核心模块:

┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│   评委输入终端   │───▶│   数据处理中心   │───▶│   大屏显示终端   │
└─────────────────┘    └─────────────────┘    └─────────────────┘
         │                       │                       │
         ▼                       ▼                       ▼
┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│   观众互动接口   │    │   数据存储模块   │    │   移动端同步显示  │
└─────────────────┘    └─────────────────┘    └─────────────────┘

技术选型建议

前端显示层(大屏)

  • 推荐框架:Vue.js + ECharts 或 React + D3.js
  • 实时通信:WebSocket 或 Server-Sent Events (SSE)
  • 动画效果:CSS3动画 + Lottie动画库
  • 响应式布局:Flexbox/Grid布局适配不同分辨率

后端处理层

  • 推荐语言:Node.js (Express/Koa) 或 Python (FastAPI)
  • 数据库:Redis (缓存) + PostgreSQL (持久化)
  • 消息队列:RabbitMQ 或 Kafka (高并发场景)
  • 实时通信:Socket.io 或 ws 库

详细实现方案与代码示例

1. 后端数据处理服务(Node.js + Socket.io)

以下是一个完整的后端服务实现,包含评委打分、实时计算和广播功能:

// server.js - 核心后端服务
const express = require('express');
const http = require('http');
const socketIo = require('socket.io');
const Redis = require('ioredis');

const app = express();
const server = http.createServer(app);
const io = socketIo(server, {
  cors: { origin: "*" }
});

// Redis连接配置
const redis = new Redis({
  host: 'localhost',
  port: 6379,
  password: process.env.REDIS_PASSWORD
});

// 评分数据结构
const SCORE_TYPES = {
  TECHNICAL: 'technical',      // 技术分
  PERFORMANCE: 'performance',  // 表现分
  CREATIVE: 'creative',        // 创意分
  AUDIENCE: 'audience'         // 观众分
};

// 评委管理类
class JudgeManager {
  constructor() {
    this.judges = new Map();
    this.scores = new Map();
  }

  // 评委登录
  async judgeLogin(judgeId, name, eventCode) {
    const judgeData = {
      id: judgeId,
      name: name,
      eventCode: eventCode,
      loginTime: Date.now(),
      isActive: true
    };
    
    this.judges.set(judgeId, judgeData);
    
    // 存储到Redis
    await redis.hset(
      `event:${eventCode}:judges`,
      judgeId,
      JSON.stringify(judgeData)
    );
    
    return judgeData;
  }

  // 记录评分
  async recordScore(judgeId, participantId, scoreType, value, eventCode) {
    // 验证评分范围 (0-100)
    if (value < 0 || value > 100) {
      throw new Error('分数必须在0-100之间');
    }

    const scoreRecord = {
      judgeId,
      participantId,
      scoreType,
      value: parseFloat(value),
      timestamp: Date.now(),
      eventCode
    };

    // 存储到Redis列表(保留最近1000条)
    await redis.lpush(
      `event:${eventCode}:scores`,
      JSON.stringify(scoreRecord)
    );
    await redis.ltrim(`event:${eventCode}:scores`, 0, 999);

    // 更新选手当前得分
    await this.updateParticipantScore(participantId, scoreType, value, eventCode);

    return scoreRecord;
  }

  // 更新选手得分计算
  async updateParticipantScore(participantId, scoreType, value, eventCode) {
    const key = `event:${eventCode}:participant:${participantId}:scores`;
    
    // 使用Redis哈希存储各类型分数
    await redis.hset(key, scoreType, value);
    
    // 计算总分(可配置权重)
    const allScores = await redis.hgetall(key);
    const weights = {
      technical: 0.4,
      performance: 0.3,
      creative: 0.2,
      audience: 0.1
    };

    let totalScore = 0;
    let totalWeight = 0;

    for (const [type, val] of Object.entries(allScores)) {
      if (weights[type]) {
        totalScore += parseFloat(val) * weights[type];
        totalWeight += weights[type];
      }
    }

    // 存储最终得分
    const finalScore = totalWeight > 0 ? (totalScore / totalWeight).toFixed(2) : 0;
    await redis.hset(
      `event:${eventCode}:participants`,
      participantId,
      finalScore
    );

    return {
      participantId,
      finalScore,
      breakdown: allScores
    };
  }

  // 获取实时排名
  async getRealTimeRanking(eventCode, limit = 10) {
    const participants = await redis.hgetall(`event:${eventCode}:participants`);
    
    const ranking = Object.entries(participants)
      .map(([id, score]) => ({
        participantId: id,
        score: parseFloat(score)
      }))
      .sort((a, b) => b.score - a.score)
      .slice(0, limit);

    return ranking;
  }
}

// 初始化评委管理器
const judgeManager = new JudgeManager();

// Socket.io 连接处理
io.on('connection', (socket) => {
  console.log(`客户端连接: ${socket.id}`);

  // 评委登录
  socket.on('judge:login', async (data) => {
    try {
      const { judgeId, name, eventCode } = data;
      const judgeData = await judgeManager.judgeLogin(judgeId, name, eventCode);
      
      socket.join(`event:${eventCode}`);
      socket.emit('login:success', judgeData);
      
      // 通知其他评委和观众
      io.to(`event:${eventCode}`).emit('system:notice', {
        type: 'judge_login',
        message: `评委 ${name} 已上线`
      });
    } catch (error) {
      socket.emit('login:error', { message: error.message });
    }
  });

  // 接收评分
  socket.on('score:submit', async (data) => {
    try {
      const { judgeId, participantId, scoreType, value, eventCode } = data;
      
      // 记录评分
      const scoreRecord = await judgeManager.recordScore(
        judgeId,
        participantId,
        scoreType,
        value,
        eventCode
      );

      // 广播新评分(不包含评委ID,保护隐私)
      io.to(`event:${eventCode}`).emit('score:update', {
        participantId,
        scoreType,
        value,
        timestamp: scoreRecord.timestamp
      });

      // 计算并广播实时排名
      const ranking = await judgeManager.getRealTimeRanking(eventCode);
      io.to(`event:${eventCode}`).emit('ranking:update', ranking);

    } catch (error) {
      socket.emit('score:error', { message: error.message });
    }
  });

  // 观众投票
  socket.on('audience:vote', async (data) => {
    const { participantId, eventCode } = data;
    const key = `event:${eventCode}:audience_votes:${participantId}`;
    
    // 原子性增加投票数
    const voteCount = await redis.incr(key);
    
    // 广播投票更新
    io.to(`event:${eventCode}`).emit('audience:update', {
      participantId,
      voteCount
    });
  });

  // 断开连接
  socket.on('disconnect', () => {
    console.log(`客户端断开: ${socket.id}`);
  });
});

// REST API 端点(用于数据查询和管理)
app.get('/api/event/:eventCode/ranking', async (req, res) => {
  try {
    const { eventCode } = req.params;
    const ranking = await judgeManager.getRealTimeRanking(eventCode);
    res.json(ranking);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

app.get('/api/event/:eventCode/scores/:participantId', async (req, res) => {
  try {
    const { eventCode, participantId } = req.params;
    const key = `event:${eventCode}:participant:${participantId}:scores`;
    const scores = await redis.hgetall(key);
    res.json(scores);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// 启动服务
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
  console.log(`评分系统后端服务运行在端口 ${PORT}`);
});

2. 大屏前端显示组件(Vue 3 + ECharts)

<!-- Scoreboard.vue - 主计分板组件 -->
<template>
  <div class="scoreboard-container">
    <!-- 头部:赛事标题和实时时间 -->
    <header class="header">
      <h1 class="event-title">{{ eventInfo.name }}</h1>
      <div class="live-indicator">
        <span class="live-dot"></span>
        LIVE
      </div>
      <div class="current-time">{{ currentTime }}</div>
    </header>

    <!-- 主要内容区域 -->
    <div class="main-content">
      <!-- 左侧:实时排名 -->
      <div class="ranking-panel">
        <h2>实时排名</h2>
        <div class="ranking-list">
          <div 
            v-for="(item, index) in ranking" 
            :key="item.participantId"
            class="ranking-item"
            :class="{ 'top-three': index < 3 }"
          >
            <div class="rank-badge">{{ index + 1 }}</div>
            <div class="participant-name">{{ getParticipantName(item.participantId) }}</div>
            <div class="score-display">{{ item.score.toFixed(2) }}</div>
          </div>
        </div>
      </div>

      <!-- 中间:选手详细得分 -->
      <div class="center-panel">
        <div class="current-focus" v-if="currentParticipant">
          <h3>当前选手:{{ currentParticipant.name }}</h3>
          <div class="score-breakdown">
            <div 
              v-for="(value, type) in currentParticipant.scores" 
              :key="type"
              class="score-item"
            >
              <span class="score-label">{{ getScoreTypeLabel(type) }}</span>
              <div class="score-bar-container">
                <div 
                  class="score-bar" 
                  :style="{ width: value + '%' }"
                  :class="getScoreClass(value)"
                ></div>
              </div>
              <span class="score-value">{{ value }}</span>
            </div>
          </div>
          <div class="total-score">
            总分:<span>{{ currentParticipant.total }}</span>
          </div>
        </div>
      </div>

      <!-- 右侧:观众互动数据 -->
      <div class="interaction-panel">
        <h2>观众投票</h2>
        <div class="vote-chart" ref="voteChart"></div>
        <div class="live-comments">
          <div 
            v-for="comment in liveComments" 
            :key="comment.id"
            class="comment-item"
          >
            <span class="comment-user">{{ comment.user }}</span>
            <span class="comment-text">{{ comment.text }}</span>
          </div>
        </div>
      </div>
    </div>

    <!-- 底部:动画通知区域 -->
    <div class="notification-area" v-if="notification">
      <div class="notification-content">
        <span class="notification-icon">🔔</span>
        {{ notification }}
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue';
import * as echarts from 'echarts';
import { io } from 'socket.io-client';

// 响应式数据
const eventInfo = ref({ name: '2024年度技能大赛' });
const currentTime = ref('');
const ranking = ref([]);
const currentParticipant = ref(null);
const liveComments = ref([]);
const notification = ref(null);

// ECharts实例
const voteChart = ref(null);
let chartInstance = null;

// Socket连接
let socket = null;

// 获取当前时间
const updateTime = () => {
  const now = new Date();
  currentTime.value = now.toLocaleTimeString('zh-CN', { 
    hour12: false,
    hour: '2-digit',
    minute: '2-digit',
    second: '2-digit'
  });
};

// 获取选手名称(模拟数据)
const getParticipantName = (id) => {
  const names = {
    'p001': '张三',
    'p002': '李四',
    'p003': '王五',
    'p004': '赵六',
    'p005': '钱七'
  };
  return names[id] || `选手${id}`;
};

// 获取评分类型标签
const getScoreTypeLabel = (type) => {
  const labels = {
    technical: '技术分',
    performance: '表现分',
    creative: '创意分',
    audience: '观众分'
  };
  return labels[type] || type;
};

// 根据分数返回样式类
const getScoreClass = (value) => {
  if (value >= 90) return 'score-high';
  if (value >= 75) return 'score-medium';
  return 'score-low';
};

// 初始化ECharts图表
const initChart = () => {
  if (!voteChart.value) return;
  
  chartInstance = echarts.init(voteChart.value);
  const option = {
    tooltip: {
      trigger: 'item'
    },
    legend: {
      top: '5%',
      left: 'center',
      textStyle: { color: '#fff' }
    },
    series: [{
      name: '投票数',
      type: 'pie',
      radius: ['40%', '70%'],
      avoidLabelOverlap: false,
      itemStyle: {
        borderRadius: 10,
        borderColor: '#1a1a2e',
        borderWidth: 2
      },
      label: {
        show: false,
        position: 'center'
      },
      emphasis: {
        label: {
          show: true,
          fontSize: 20,
          fontWeight: 'bold',
          color: '#fff'
        }
      },
      labelLine: { show: false },
      data: [
        { value: 0, name: '选手1', itemStyle: { color: '#FF6B6B' } },
        { value: 0, name: '选手2', itemStyle: { color: '#4ECDC4' } },
        { value: 0, name: '选手3', itemStyle: { color: '#45B7D1' } },
        { value: 0, name: '选手4', itemStyle: { color: '#96CEB4' } },
        { value: 0, name: '选手5', itemStyle: { color: '#FFEAA7' } }
      ]
    }]
  };
  chartInstance.setOption(option);
};

// 更新图表数据
const updateChart = (voteData) => {
  if (!chartInstance) return;
  
  const data = voteData.map((item, index) => ({
    value: item.voteCount,
    name: `选手${index + 1}`,
    itemStyle: {
      color: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7'][index % 5]
    }
  }));

  chartInstance.setOption({
    series: [{ data }]
  });
};

// 处理新通知
const showNotification = (message) => {
  notification.value = message;
  setTimeout(() => {
    notification.value = null;
  }, 3000);
};

// 模拟实时评论(实际项目中来自WebSocket)
const simulateLiveComments = () => {
  const comments = [
    { user: '观众A', text: '太精彩了!' },
    { user: '观众B', text: '这个分数实至名归!' },
    { user: '观众C', text: '期待下一位选手' },
    { user: '观众D', text: '专业评审,公正透明' }
  ];
  
  setInterval(() => {
    const randomComment = comments[Math.floor(Math.random() * comments.length)];
    liveComments.value.unshift({
      id: Date.now(),
      ...randomComment
    });
    
    // 保持最多5条评论
    if (liveComments.value.length > 5) {
      liveComments.value.pop();
    }
  }, 5000);
};

// 连接WebSocket
const connectWebSocket = () => {
  // 实际部署时替换为真实服务器地址
  socket = io('http://localhost:3000');

  // 连接成功
  socket.on('connect', () => {
    console.log('WebSocket连接成功');
    // 加入赛事房间
    socket.emit('join:event', { eventCode: 'event_2024' });
  });

  // 接收排名更新
  socket.on('ranking:update', (data) => {
    ranking.value = data;
    
    // 如果有新第一名,显示通知
    if (data.length > 0 && data[0].participantId !== currentParticipant.value?.id) {
      const name = getParticipantName(data[0].participantId);
      showNotification(`🎉 新的第一名:${name}!`);
    }
  });

  // 接收分数更新
  socket.on('score:update', (data) => {
    // 更新当前选手详细信息
    if (currentParticipant.value && currentParticipant.value.id === data.participantId) {
      if (!currentParticipant.value.scores) {
        currentParticipant.value.scores = {};
      }
      currentParticipant.value.scores[data.scoreType] = data.value;
      
      // 重新计算总分
      const scores = currentParticipant.value.scores;
      const weights = { technical: 0.4, performance: 0.3, creative: 0.2, audience: 0.1 };
      let total = 0;
      let totalWeight = 0;
      
      for (const [type, val] of Object.entries(scores)) {
        if (weights[type]) {
          total += val * weights[type];
          totalWeight += weights[type];
        }
      }
      
      currentParticipant.value.total = totalWeight > 0 ? (total / totalWeight).toFixed(2) : 0;
    }
  });

  // 接收观众投票更新
  socket.on('audience:update', (data) => {
    // 更新图表数据(模拟)
    const currentData = chartInstance?.getOption().series[0].data || [];
    const index = ['p001', 'p002', 'p003', 'p004', 'p005'].indexOf(data.participantId);
    if (index >= 0 && currentData[index]) {
      currentData[index].value = data.voteCount;
      updateChart(currentData.map(d => ({ voteCount: d.value })));
    }
  });

  // 系统通知
  socket.on('system:notice', (data) => {
    showNotification(data.message);
  });
};

// 生命周期钩子
onMounted(() => {
  // 启动时间更新
  setInterval(updateTime, 1000);
  updateTime();

  // 初始化图表
  setTimeout(() => {
    initChart();
  }, 100);

  // 连接WebSocket
  connectWebSocket();

  // 模拟当前选手(实际从路由参数获取)
  currentParticipant.value = {
    id: 'p001',
    name: '张三',
    scores: { technical: 85, performance: 88, creative: 90, audience: 87 },
    total: '87.10'
  };

  // 模拟实时评论
  simulateLiveComments();

  // 窗口大小改变时重绘图表
  window.addEventListener('resize', () => {
    chartInstance?.resize();
  });
});

onUnmounted(() => {
  if (socket) {
    socket.disconnect();
  }
  if (chartInstance) {
    chartInstance.dispose();
  }
});
</script>

<style scoped>
.scoreboard-container {
  width: 100vw;
  height: 100vh;
  background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
  color: #fff;
  overflow: hidden;
  font-family: 'Microsoft YaHei', sans-serif;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 20px 40px;
  background: rgba(0, 0, 0, 0.3);
  border-bottom: 2px solid #00d4ff;
}

.event-title {
  font-size: 2.5em;
  margin: 0;
  background: linear-gradient(45deg, #00d4ff, #0099ff);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  text-shadow: 0 0 30px rgba(0, 212, 255, 0.5);
}

.live-indicator {
  display: flex;
  align-items: center;
  gap: 8px;
  font-weight: bold;
  color: #ff4757;
  font-size: 1.2em;
}

.live-dot {
  width: 12px;
  height: 12px;
  background: #ff4757;
  border-radius: 50%;
  animation: pulse 1.5s infinite;
}

@keyframes pulse {
  0%, 100% { opacity: 1; transform: scale(1); }
  50% { opacity: 0.5; transform: scale(1.2); }
}

.current-time {
  font-size: 1.2em;
  color: #00d4ff;
  font-family: 'Courier New', monospace;
}

.main-content {
  display: grid;
  grid-template-columns: 1fr 1.5fr 1fr;
  gap: 20px;
  padding: 20px;
  height: calc(100vh - 120px);
}

.ranking-panel, .center-panel, .interaction-panel {
  background: rgba(255, 255, 255, 0.05);
  border-radius: 15px;
  padding: 20px;
  backdrop-filter: blur(10px);
  border: 1px solid rgba(255, 255, 255, 0.1);
  overflow-y: auto;
}

.ranking-panel h2, .interaction-panel h2 {
  color: #00d4ff;
  border-bottom: 2px solid #00d4ff;
  padding-bottom: 10px;
  margin-bottom: 15px;
}

.ranking-list {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.ranking-item {
  display: flex;
  align-items: center;
  padding: 12px;
  background: rgba(255, 255, 255, 0.05);
  border-radius: 8px;
  transition: all 0.3s ease;
}

.ranking-item:hover {
  background: rgba(255, 255, 255, 0.1);
  transform: translateX(5px);
}

.ranking-item.top-three {
  background: linear-gradient(90deg, rgba(255, 215, 0, 0.2), transparent);
  border-left: 4px solid #ffd700;
}

.rank-badge {
  width: 30px;
  height: 30px;
  background: #00d4ff;
  color: #1a1a2e;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: bold;
  margin-right: 15px;
}

.ranking-item.top-three .rank-badge {
  background: #ffd700;
}

.participant-name {
  flex: 1;
  font-size: 1.1em;
  font-weight: 500;
}

.score-display {
  font-size: 1.3em;
  font-weight: bold;
  color: #00d4ff;
}

.current-focus {
  text-align: center;
}

.current-focus h3 {
  font-size: 1.8em;
  color: #00d4ff;
  margin-bottom: 20px;
}

.score-breakdown {
  display: flex;
  flex-direction: column;
  gap: 15px;
  margin: 20px 0;
}

.score-item {
  display: flex;
  align-items: center;
  gap: 10px;
}

.score-label {
  width: 80px;
  text-align: right;
  font-weight: bold;
  color: #a0a0a0;
}

.score-bar-container {
  flex: 1;
  height: 25px;
  background: rgba(255, 255, 255, 0.1);
  border-radius: 12px;
  overflow: hidden;
  position: relative;
}

.score-bar {
  height: 100%;
  border-radius: 12px;
  transition: width 0.5s ease;
  box-shadow: 0 0 10px currentColor;
}

.score-high {
  background: linear-gradient(90deg, #00d4ff, #0099ff);
  color: #00d4ff;
}

.score-medium {
  background: linear-gradient(90deg, #ffd700, #ffaa00);
  color: #ffd700;
}

.score-low {
  background: linear-gradient(90deg, #ff4757, #ff6b81);
  color: #ff4757;
}

.score-value {
  width: 50px;
  text-align: left;
  font-weight: bold;
  font-size: 1.1em;
}

.total-score {
  margin-top: 20px;
  font-size: 2em;
  color: #00d4ff;
  font-weight: bold;
}

.total-score span {
  font-size: 1.2em;
  color: #ffd700;
}

.vote-chart {
  width: 100%;
  height: 250px;
  margin-bottom: 20px;
}

.live-comments {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.comment-item {
  background: rgba(255, 255, 255, 0.05);
  padding: 8px 12px;
  border-radius: 6px;
  font-size: 0.9em;
  animation: slideIn 0.3s ease;
}

@keyframes slideIn {
  from {
    opacity: 0;
    transform: translateY(-10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.comment-user {
  color: #00d4ff;
  font-weight: bold;
  margin-right: 8px;
}

.comment-text {
  color: #e0e0e0;
}

.notification-area {
  position: fixed;
  top: 100px;
  left: 50%;
  transform: translateX(-50%);
  background: rgba(0, 212, 255, 0.9);
  color: #1a1a2e;
  padding: 15px 30px;
  border-radius: 30px;
  font-weight: bold;
  font-size: 1.2em;
  z-index: 1000;
  animation: bounceIn 0.5s ease;
  box-shadow: 0 5px 20px rgba(0, 212, 255, 0.5);
}

@keyframes bounceIn {
  0% { transform: translateX(-50%) scale(0.5); opacity: 0; }
  50% { transform: translateX(-50%) scale(1.1); }
  100% { transform: translateX(-50%) scale(1); opacity: 1; }
}

.notification-icon {
  margin-right: 8px;
}

/* 滚动条样式 */
::-webkit-scrollbar {
  width: 8px;
}

::-webkit-scrollbar-track {
  background: rgba(255, 255, 255, 0.05);
  border-radius: 4px;
}

::-webkit-scrollbar-thumb {
  background: rgba(0, 212, 255, 0.5);
  border-radius: 4px;
}

::-webkit-scrollbar-thumb:hover {
  background: rgba(0, 212, 255, 0.8);
}

/* 响应式调整 */
@media (max-width: 1400px) {
  .main-content {
    grid-template-columns: 1fr;
    grid-template-rows: auto auto auto;
    gap: 15px;
    height: auto;
  }
  
  .header {
    flex-direction: column;
    gap: 10px;
    text-align: center;
  }
}
</style>

3. 评委打分界面(移动端适配)

<!-- JudgePanel.vue - 评委打分面板 -->
<template>
  <div class="judge-panel">
    <div class="judge-header">
      <div class="judge-info">
        <span class="judge-name">{{ judgeName }}</span>
        <span class="judge-id">ID: {{ judgeId }}</span>
      </div>
      <div class="connection-status" :class="{ connected: isConnected }">
        {{ isConnected ? '已连接' : '未连接' }}
      </div>
    </div>

    <div class="participant-selector">
      <h3>选择选手</h3>
      <div class="participant-list">
        <button 
          v-for="p in participants" 
          :key="p.id"
          class="participant-btn"
          :class="{ active: selectedParticipant === p.id }"
          @click="selectParticipant(p.id)"
        >
          {{ p.name }}
        </button>
      </div>
    </div>

    <div class="score-input-area" v-if="selectedParticipant">
      <h3>评分项</h3>
      <div 
        v-for="type in scoreTypes" 
        :key="type.key"
        class="score-input-group"
      >
        <label>{{ type.label }}</label>
        <div class="input-controls">
          <button class="btn-minus" @click="adjustScore(type.key, -1)">-</button>
          <input 
            type="number" 
            v-model.number="scores[type.key]"
            min="0"
            max="100"
            @input="validateScore(type.key)"
          />
          <button class="btn-plus" @click="adjustScore(type.key, 1)">+</button>
        </div>
        <input 
          type="range" 
          v-model.number="scores[type.key]"
          min="0"
          max="100"
          class="slider"
        />
      </div>

      <div class="total-preview">
        预估总分: <span>{{ calculatedTotal }}</span>
      </div>

      <button 
        class="submit-btn" 
        :disabled="!isFormValid"
        @click="submitScores"
      >
        提交评分
      </button>
    </div>

    <div class="history-log" v-if="history.length > 0">
      <h3>评分记录</h3>
      <div class="log-list">
        <div v-for="item in history" :key="item.timestamp" class="log-item">
          <span class="log-time">{{ formatTime(item.timestamp) }}</span>
          <span class="log-participant">{{ item.participantName }}</span>
          <span class="log-score">{{ item.total }}</span>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { io } from 'socket.io-client';

// 评委信息
const judgeId = ref('J' + Math.random().toString(36).substr(2, 6).toUpperCase());
const judgeName = ref('评委' + judgeId.value.substr(-3));
const isConnected = ref(false);

// 选手数据
const participants = ref([
  { id: 'p001', name: '张三' },
  { id: 'p002', name: '李四' },
  { id: 'p003', name: '王五' },
  { id: 'p004', name: '赵六' },
  { id: 'p005', name: '钱七' }
]);

// 评分类型
const scoreTypes = ref([
  { key: 'technical', label: '技术分 (40%)' },
  { key: 'performance', label: '表现分 (30%)' },
  { key: 'creative', label: '创意分 (20%)' },
  { key: 'audience', label: '观众分 (10%)' }
]);

// 评分数据
const selectedParticipant = ref(null);
const scores = ref({
  technical: 0,
  performance: 0,
  creative: 0,
  audience: 0
});

// 历史记录
const history = ref([]);

// Socket连接
let socket = null;

// 计算总分
const calculatedTotal = computed(() => {
  const weights = { technical: 0.4, performance: 0.3, creative: 0.2, audience: 0.1 };
  let total = 0;
  let totalWeight = 0;

  for (const [type, value] of Object.entries(scores.value)) {
    if (weights[type]) {
      total += value * weights[type];
      totalWeight += weights[type];
    }
  }

  return totalWeight > 0 ? (total / totalWeight).toFixed(2) : 0;
});

// 表单验证
const isFormValid = computed(() => {
  return selectedParticipant.value && 
         Object.values(scores.value).every(v => v >= 0 && v <= 100);
});

// 选择选手
const selectParticipant = (id) => {
  selectedParticipant.value = id;
  // 重置分数
  scores.value = { technical: 0, performance: 0, creative: 0, audience: 0 };
};

// 调整分数
const adjustScore = (type, delta) => {
  const newValue = scores.value[type] + delta;
  if (newValue >= 0 && newValue <= 100) {
    scores.value[type] = newValue;
  }
};

// 验证分数范围
const validateScore = (type) => {
  if (scores.value[type] < 0) scores.value[type] = 0;
  if (scores.value[type] > 100) scores.value[type] = 100;
};

// 提交评分
const submitScores = () => {
  if (!isFormValid.value) return;

  const eventData = {
    judgeId: judgeId.value,
    participantId: selectedParticipant.value,
    eventCode: 'event_2024',
    scores: { ...scores.value },
    timestamp: Date.now()
  };

  // 发送每个评分类型
  Object.entries(scores.value).forEach(([type, value]) => {
    socket.emit('score:submit', {
      judgeId: judgeId.value,
      participantId: selectedParticipant.value,
      scoreType: type,
      value: value,
      eventCode: 'event_2024'
    });
  });

  // 添加到历史记录
  const participantName = participants.value.find(p => p.id === selectedParticipant.value)?.name || '';
  history.value.unshift({
    participantId: selectedParticipant.value,
    participantName,
    scores: { ...scores.value },
    total: calculatedTotal.value,
    timestamp: Date.now()
  });

  // 限制历史记录数量
  if (history.value.length > 10) {
    history.value.pop();
  }

  // 重置选择
  selectedParticipant.value = null;
  
  // 显示成功提示
  alert('评分提交成功!');
};

// 格式化时间
const formatTime = (timestamp) => {
  const date = new Date(timestamp);
  return date.toLocaleTimeString('zh-CN', { 
    hour: '2-digit', 
    minute: '2-digit',
    second: '2-digit'
  });
};

// 连接WebSocket
const connectWebSocket = () => {
  socket = io('http://localhost:3000');

  socket.on('connect', () => {
    isConnected.value = true;
    // 评委登录
    socket.emit('judge:login', {
      judgeId: judgeId.value,
      name: judgeName.value,
      eventCode: 'event_2024'
    });
  });

  socket.on('disconnect', () => {
    isConnected.value = false;
  });

  socket.on('login:success', (data) => {
    console.log('登录成功', data);
  });

  socket.on('login:error', (data) => {
    alert('登录失败:' + data.message);
  });

  socket.on('score:error', (data) => {
    alert('评分失败:' + data.message);
  });
};

// 生命周期
onMounted(() => {
  connectWebSocket();
});

onUnmounted(() => {
  if (socket) {
    socket.disconnect();
  }
});
</script>

<style scoped>
.judge-panel {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
  background: #f5f5f5;
  min-height: 100vh;
}

.judge-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
  padding: 15px;
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.judge-info {
  display: flex;
  flex-direction: column;
}

.judge-name {
  font-weight: bold;
  font-size: 1.1em;
  color: #333;
}

.judge-id {
  font-size: 0.8em;
  color: #666;
}

.connection-status {
  padding: 5px 10px;
  border-radius: 15px;
  background: #ff4757;
  color: white;
  font-size: 0.8em;
  font-weight: bold;
}

.connection-status.connected {
  background: #2ed573;
}

.participant-selector, .score-input-area, .history-log {
  background: white;
  padding: 15px;
  margin-bottom: 15px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

h3 {
  margin: 0 0 15px 0;
  color: #333;
  font-size: 1.1em;
}

.participant-list {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
  gap: 8px;
}

.participant-btn {
  padding: 10px;
  border: 2px solid #ddd;
  background: white;
  border-radius: 6px;
  cursor: pointer;
  font-weight: 500;
  transition: all 0.2s;
}

.participant-btn:hover {
  border-color: #00d4ff;
  background: #f0f8ff;
}

.participant-btn.active {
  background: #00d4ff;
  color: white;
  border-color: #00d4ff;
}

.score-input-group {
  margin-bottom: 15px;
}

.score-input-group label {
  display: block;
  margin-bottom: 5px;
  font-weight: bold;
  color: #555;
}

.input-controls {
  display: flex;
  gap: 8px;
  align-items: center;
  margin-bottom: 8px;
}

.input-controls input[type="number"] {
  flex: 1;
  padding: 8px;
  border: 2px solid #ddd;
  border-radius: 6px;
  text-align: center;
  font-size: 1.1em;
  font-weight: bold;
}

.btn-minus, .btn-plus {
  width: 40px;
  height: 40px;
  border: none;
  border-radius: 6px;
  font-size: 1.2em;
  font-weight: bold;
  cursor: pointer;
  color: white;
}

.btn-minus {
  background: #ff4757;
}

.btn-plus {
  background: #2ed573;
}

.slider {
  width: 100%;
  height: 8px;
  border-radius: 4px;
  background: #ddd;
  outline: none;
  -webkit-appearance: none;
}

.slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: #00d4ff;
  cursor: pointer;
}

.total-preview {
  text-align: center;
  font-size: 1.5em;
  margin: 15px 0;
  color: #333;
}

.total-preview span {
  color: #00d4ff;
  font-weight: bold;
}

.submit-btn {
  width: 100%;
  padding: 15px;
  background: #00d4ff;
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 1.1em;
  font-weight: bold;
  cursor: pointer;
  transition: all 0.2s;
}

.submit-btn:hover:not(:disabled) {
  background: #0099ff;
  transform: translateY(-2px);
  box-shadow: 0 4px 8px rgba(0, 212, 255, 0.3);
}

.submit-btn:disabled {
  background: #ccc;
  cursor: not-allowed;
}

.history-log {
  max-height: 300px;
  overflow-y: auto;
}

.log-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.log-item {
  display: flex;
  justify-content: space-between;
  padding: 8px;
  background: #f9f9f9;
  border-radius: 4px;
  font-size: 0.9em;
}

.log-time {
  color: #666;
  font-family: monospace;
}

.log-participant {
  font-weight: bold;
  color: #333;
}

.log-score {
  color: #00d4ff;
  font-weight: bold;
}
</style>

数据库设计与持久化方案

Redis数据结构设计

// Redis键设计规范
// 1. 事件基本信息
event:{eventCode}:info          // Hash: 事件名称、时间、地点等
event:{eventCode}:judges        // Hash: 评委信息 {judgeId: JSON.stringify(judgeData)}
event:{eventCode}:participants  // Hash: 选手信息 {participantId: finalScore}

// 2. 实时评分数据
event:{eventCode}:scores        // List: 最近1000条评分记录
event:{eventCode}:participant:{participantId}:scores  // Hash: 各类型分数
event:{eventCode}:audience_votes:{participantId}     // String: 观众投票数

// 3. 排行榜(有序集合)
event:{eventCode}:ranking       // ZSET: 实时排名,score为最终得分

// 4. 连接管理
event:{eventCode}:connections   // Set: 当前连接的socketId

PostgreSQL持久化表结构

-- 评委表
CREATE TABLE judges (
    id VARCHAR(50) PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    event_code VARCHAR(50) NOT NULL,
    login_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    is_active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 选手表
CREATE TABLE participants (
    id VARCHAR(50) PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    event_code VARCHAR(50) NOT NULL,
    description TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 评分记录表
CREATE TABLE scores (
    id SERIAL PRIMARY KEY,
    judge_id VARCHAR(50) REFERENCES judges(id),
    participant_id VARCHAR(50) REFERENCES participants(id),
    score_type VARCHAR(50) NOT NULL,
    value DECIMAL(5,2) NOT NULL,
    event_code VARCHAR(50) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT score_range CHECK (value >= 0 AND value <= 100)
);

-- 观众投票表
CREATE TABLE audience_votes (
    id SERIAL PRIMARY KEY,
    participant_id VARCHAR(50) REFERENCES participants(id),
    event_code VARCHAR(50) NOT NULL,
    ip_address VARCHAR(45),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 索引优化
CREATE INDEX idx_scores_event_participant ON scores(event_code, participant_id);
CREATE INDEX idx_scores_created_at ON scores(created_at);
CREATE INDEX idx_votes_event_participant ON audience_votes(event_code, participant_id);

安全性与防作弊机制

1. 评委身份验证

// 使用JWT令牌验证评委身份
const jwt = require('jsonwebtoken');

// 生成评委令牌
function generateJudgeToken(judgeId, eventCode) {
  return jwt.sign(
    { judgeId, eventCode, role: 'judge' },
    process.env.JWT_SECRET,
    { expiresIn: '8h' }
  );
}

// 验证中间件
function authenticateJudge(socket, next) {
  const token = socket.handshake.auth.token;
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    socket.judgeData = decoded;
    next();
  } catch (err) {
    next(new Error('身份验证失败'));
  }
}

2. 评分频率限制

// 防止刷分,限制评分频率
const rateLimit = require('express-rate-limit');

const scoreRateLimit = rateLimit({
  windowMs: 60 * 1000, // 1分钟
  max: 30, // 最多30次评分
  message: '评分过于频繁,请稍后再试',
  standardHeaders: true,
  legacyHeaders: false,
});

3. 数据完整性校验

// 评分数据校验
function validateScoreData(data) {
  // 1. 检查评委是否在线
  if (!judgeManager.judges.has(data.judgeId)) {
    throw new Error('评委未登录或已离线');
  }

  // 2. 检查分数范围
  if (data.value < 0 || data.value > 100) {
    throw new Error('分数必须在0-100之间');
  }

  // 3. 检查是否已评分(防止重复)
  const hasScored = await redis.sismember(
    `event:${data.eventCode}:scored:${data.participantId}`,
    data.judgeId
  );
  
  if (hasScored) {
    throw new Error('您已对该选手评分');
  }

  // 4. 标记已评分
  await redis.sadd(
    `event:${data.eventCode}:scored:${data.participantId}`,
    data.judgeId
  );
}

部署与运维建议

Docker部署配置

# docker-compose.yml
version: '3.8'

services:
  # 后端服务
  scoring-backend:
    build: ./backend
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - REDIS_HOST=redis
      - DB_HOST=postgres
      - JWT_SECRET=${JWT_SECRET}
    depends_on:
      - redis
      - postgres
    restart: unless-stopped

  # Redis缓存
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    command: redis-server --appendonly yes

  # PostgreSQL数据库
  postgres:
    image: postgres:15
    environment:
      - POSTGRES_DB=scoring_system
      - POSTGRES_USER=scoring_user
      - POSTGRES_PASSWORD=${DB_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  # 大屏前端(Nginx服务)
  scoring-frontend:
    build: ./frontend
    ports:
      - "80:80"
    depends_on:
      - scoring-backend
    restart: unless-stopped

volumes:
  redis_data:
  postgres_data:

Nginx配置(前端部署)

# /etc/nginx/sites-available/scoring-system
server {
    listen 80;
    server_name scoring.yourdomain.com;

    # 大屏显示页面
    location / {
        root /var/www/scoring-system/frontend/dist;
        index index.html;
        try_files $uri $uri/ /index.html;
    }

    # API代理
    location /api/ {
        proxy_pass http://localhost:3000/;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }

    # WebSocket代理
    location /socket.io/ {
        proxy_pass http://localhost:3000/socket.io/;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # 性能优化
    gzip on;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
    gzip_min_length 1000;
    gzip_comp_level 6;

    # 缓存策略
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

性能优化与扩展性考虑

1. 高并发场景优化

// 使用Redis集群应对高并发
const Redis = require('ioredis');
const redisCluster = new Redis.Cluster([
  { host: 'redis-node1', port: 6379 },
  { host: 'redis-node2', port: 6379 },
  { host: 'redis-node3', port: 6379 }
]);

// 使用PM2进行进程管理
// ecosystem.config.js
module.exports = {
  apps: [{
    name: 'scoring-backend',
    script: './server.js',
    instances: 'max', // 使用所有CPU核心
    exec_mode: 'cluster',
    env: {
      NODE_ENV: 'production'
    }
  }]
};

2. 消息队列处理(削峰填谷)

// 使用RabbitMQ处理大量评分请求
const amqp = require('amqplib');

async function setupMessageQueue() {
  const connection = await amqp.connect('amqp://localhost');
  const channel = await connection.createChannel();
  
  const queue = 'score_processing';
  await channel.assertQueue(queue, { durable: true });
  
  // 消费者处理评分
  channel.consume(queue, async (msg) => {
    const scoreData = JSON.parse(msg.content.toString());
    
    try {
      // 处理评分逻辑
      await processScore(scoreData);
      channel.ack(msg);
    } catch (error) {
      console.error('处理失败:', error);
      // 可以选择重试或死信队列
      channel.nack(msg, false, false);
    }
  }, { noAck: false });
}

// 生产者发送评分
async function sendScoreToQueue(scoreData) {
  const connection = await amqp.connect('amqp://localhost');
  const channel = await connection.createChannel();
  
  const queue = 'score_processing';
  await channel.assertQueue(queue, { durable: true });
  
  channel.sendToQueue(
    queue,
    Buffer.from(JSON.stringify(scoreData)),
    { persistent: true }
  );
}

观众互动功能扩展

1. 实时弹幕系统

// 弹幕服务
class DanmakuService {
  constructor(io) {
    this.io = io;
    this.danmakuPool = []; // 存储最近100条弹幕
  }

  // 发送弹幕
  async sendDanmaku(data) {
    const danmaku = {
      id: Date.now() + Math.random(),
      user: data.user || `观众${Math.floor(Math.random() * 1000)}`,
      text: data.text,
      color: data.color || '#ffffff',
      timestamp: Date.now()
    };

    // 存储到Redis(最近100条)
    await redis.lpush('danmaku:global', JSON.stringify(danmaku));
    await redis.ltrim('danmaku:global', 0, 99);

    // 广播给所有客户端
    this.io.emit('danmaku:new', danmaku);
  }

  // 获取历史弹幕
  async getHistory() {
    const list = await redis.lrange('danmaku:global', 0, 99);
    return list.map(item => JSON.parse(item)).reverse();
  }
}

2. 观众投票系统

// 投票防刷机制
class VoteAntiCheat {
  constructor() {
    this.voteWindow = 60000; // 60秒窗口
    this.maxVotesPerWindow = 5; // 每个窗口最多5票
  }

  async checkVoteLimit(ip, participantId, eventCode) {
    const key = `vote_limit:${eventCode}:${ip}`;
    const voteCount = await redis.incr(key);
    
    // 设置过期时间
    if (voteCount === 1) {
      await redis.expire(key, this.voteWindow / 1000);
    }

    if (voteCount > this.maxVotesPerWindow) {
      throw new Error('投票过于频繁,请稍后再试');
    }

    return true;
  }

  async recordVote(ip, participantId, eventCode) {
    // 检查限制
    await this.checkVoteLimit(ip, participantId, eventCode);

    // 记录投票
    const voteKey = `event:${eventCode}:audience_votes:${participantId}`;
    await redis.incr(voteKey);

    // 记录到数据库用于审计
    await db.query(
      'INSERT INTO audience_votes (participant_id, event_code, ip_address) VALUES ($1, $2, $3)',
      [participantId, eventCode, ip]
    );
  }
}

总结与最佳实践

成功实施的关键要素

  1. 技术选型要匹配场景

    • 小型活动:Node.js + Redis + 简单前端
    • 大型赛事:微服务架构 + 消息队列 + 分布式缓存
  2. 用户体验优先

    • 大屏显示要清晰易读,字体足够大
    • 评委界面要简洁,防止误操作
    • 提供离线缓存和断线重连机制
  3. 数据安全不可忽视

    • 所有评分操作必须可审计
    • 实施防作弊和防刷分机制
    • 定期备份数据
  4. 可扩展性设计

    • 预留API接口供第三方集成
    • 支持多种评分规则配置
    • 考虑多赛事并行场景

通过以上完整的技术方案和代码实现,您可以构建一个专业、透明、公正且互动性强的现场评分大屏系统,显著提升赛事的公信力和观众的参与体验。