引言:数字化时代的赛事评分革命
在现代体育赛事、技能竞赛和选秀活动中,现场评分大屏已经成为提升赛事专业度和观众体验的核心技术手段。传统的纸质评分或封闭式后台计算往往导致信息不透明、观众参与感低、甚至引发对公正性的质疑。而实时大屏显示系统通过技术手段将评分过程完全公开化,不仅解决了这些痛点,更将评分本身转化为一种观赏体验。
核心价值分析
- 透明度革命:实时显示每位评委的打分细节,消除”暗箱操作”的猜疑
- 即时反馈:观众和选手能第一时间看到得分变化,产生紧张刺激的期待感
- 互动升级:通过大屏展示观众投票、实时弹幕等互动数据
- 数据沉淀:完整的评分数据为后续分析和改进提供依据
系统架构设计与技术实现
基础架构组成
一个完整的现场评分大屏系统通常包含以下核心模块:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 评委输入终端 │───▶│ 数据处理中心 │───▶│ 大屏显示终端 │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 观众互动接口 │ │ 数据存储模块 │ │ 移动端同步显示 │
└─────────────────┘ └─────────────────┘ └─────────────────┘
技术选型建议
前端显示层(大屏)
- 推荐框架: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]
);
}
}
总结与最佳实践
成功实施的关键要素
技术选型要匹配场景
- 小型活动:Node.js + Redis + 简单前端
- 大型赛事:微服务架构 + 消息队列 + 分布式缓存
用户体验优先
- 大屏显示要清晰易读,字体足够大
- 评委界面要简洁,防止误操作
- 提供离线缓存和断线重连机制
数据安全不可忽视
- 所有评分操作必须可审计
- 实施防作弊和防刷分机制
- 定期备份数据
可扩展性设计
- 预留API接口供第三方集成
- 支持多种评分规则配置
- 考虑多赛事并行场景
通过以上完整的技术方案和代码实现,您可以构建一个专业、透明、公正且互动性强的现场评分大屏系统,显著提升赛事的公信力和观众的参与体验。
