实时更新无处不在——比如体育比赛的实时比分、股票行情更新、聊天应用程序以及物联网数据面板。如果你想构建能够在数据发生变化的瞬间就将其推送给用户的系统,那么你就需要合适的工具。
消息队列遥测传输协议(MQTT)是一种轻量级的消息传递协议,在实现实时数据推送功能方面表现极为出色。结合像Mosquitto这样的代理服务器以及Express这样的Web框架,你完全可以在一个下午的时间内构建出一个可用于实际生产的实时系统。
在本教程中,你将从零开始搭建一个完整的实时足球比赛更新系统。你需要创建一个用于上传比分和比赛详情的管理界面,一个用于查看实时更新的观众界面,以及一个后端服务,该服务会利用MQTT协议立即将数据变化通知给所有连接的客户端。
读完本指南后,你将了解如何将MQTT与Express集成起来,如何配置Mosquitto代理服务器,以及如何使用“服务器发送事件”功能将实时数据传递给Web浏览器。最终,你将会拥有一个可以投入实际生产环境使用的完整系统。
目录
您将学到的内容
通过本教程,您将学习如何:
-
使用 MQTT.js 库将 Express 服务器连接到 MQTT 中间件
-
发布和订阅 MQTT 主题以实现实时消息传递
-
利用“服务器发送事件”功能将 MQTT 消息推送到浏览器中
-
构建用于比赛和比分管理的 REST API
-
创建一个简单的管理界面,用于上传比赛数据
-
开发一个能够实时更新信息且无需刷新页面的查看界面
先决条件
在开始之前,您需要满足以下要求:
-
您的计算机上已安装 Node.js 18 及更高版本
-
对 JavaScript、Express 和 HTML 有基本了解
- 拥有用于执行命令的终端或命令行工具
- 已安装 Docker(可选,用于在容器中运行 Mosquitto)
-
管理界面——用于创建比赛、更新比分以及添加进球、罚球等事件的网页。
-
Express 服务器——接收来自管理界面的 HTTP 请求,将数据发布到 MQTT 中间件,订阅相关主题,并通过“服务器发送事件”功能向查看者推送更新信息。
-
查看界面——与服务器连接并实时显示比分及各类事件的网页。
-
低开销:消息体积小且传输效率高,因此非常适合处理大量客户端的需求。
-
内置的服务质量保障机制:您可以指定消息的送达次数(最多一次、至少一次或恰好一次)。
-
基于主题的路由功能:您可以根据需要按主题组织消息(例如
sports/football/match/123),这样订阅者就能只接收他们所需的信息。 -
集中式管理机制:由中央中间件(如 Mosquitto)负责所有消息的分发,因此应用程序逻辑可以保持简洁。
-
sports/football/match/{id}:每场比赛对应一个主题。当你向该主题发布完整的比赛信息时,所有订阅者都会接收到这些数据。这样一来,日后添加新的比赛字段也不会改变主题结构。 -
sports/football/scores:用于发送与比分相关的通知。消息中会包含type字段(如match_created或score_update),这样订阅者就可以根据类型来处理这些信息。 -
sports/football/events:用于发布与比赛相关的事件信息,比如进球、罚球等。订阅者会收到格式为{ type: 'match_event', matchId, event }的消息。 -
服务器发送事件:单向通信(从服务器到客户端),基于HTTP协议实现。浏览器会自动重新连接,实现起来更为简单,且不需要额外的库文件。
-
WebSockets:双向通信,需要使用不同的协议。虽然灵活性更强,但实现起来也更加复杂。
如果您还没有安装 Node.js,可以从官方网站下载。
系统架构简介
该系统由三个主要部分组成:

工作原理
当您在管理界面提交比分更新请求时,Express 服务器会向 MQTT 中间件发送消息,并同时订阅这些主题。一旦有新消息到达,服务器会通过“服务器发送事件”功能将其转发给所有已连接的查看者,从而使他们无需刷新页面即可看到最新信息。
什么是 MQTT?为何要使用它?
MQTT 即消息队列遥测传输协议。这是一种轻量级的发布-订阅消息传递机制,专为低带宽和不可靠的网络环境设计。它在物联网应用中得到了广泛使用,但同样适用于任何需要向大量订阅者发送实时更新信息的系统。
以下是选择 MQTT 用于体育赛事更新系统的几个原因:
Mosquitto是一款受欢迎的开源MQTT代理,安装和配置都非常简单。
MQTT主题设计
MQTT采用分层化的主题结构。对于这个项目来说,所使用的主题如下:
在订阅时使用通配符#表示“订阅当前层级及所有下级层级”。因此,sports/football/#会订阅sports/football下的所有主题,包括sports/football/match/abc123和sports/football/scores。而通配符+则表示只订阅恰好一个层级。例如,sports/+/match/#会订阅所有与足球比赛相关的主题,而不仅仅是足球这一项目。
为什么选择使用服务器发送事件而不是WebSockets?
你可能会疑惑,为什么本教程选用服务器发送事件(SSE)而非WebSockets。实际上这两种技术都可以将数据推送到浏览器端。它们的主要区别在于:
对于一个用于查看体育比赛比分的应用来说,只需要从服务器向客户端发送更新信息即可。观众永远不会通过同一通道向服务器发送消息,因此服务器发送事件这种机制更为适用。如果以后需要客户端能够发送命令(比如按联赛来筛选数据),这时可以添加专门的HTTP API,或者改用WebSockets。
项目设置
首先为你的项目创建一个新文件夹,然后使用npm进行初始化。mkdir命令用于创建目录,cd命令用于进入该目录,而npm init -y则会生成一份包含默认值的package.json文件,无需用户手动输入任何信息。
mkdir mqtt-football-scores
cd mqtt-football-scores
npm init -y
接下来安装所需的依赖包。每个包在应用程序中都扮演着特定的角色。请在项目根目录下运行以下命令:
npm install express cors mqtt uuid
-
express:用于构建HTTP服务器和API的Web框架,提供了路由处理、中间件功能以及静态文件服务。 -
cors:允许跨源资源共享,这样前端应用就可以从不同的域名或端口调用后端API。 -
mqtt:Node.js版本的MQTT客户端库,负责处理连接、发送消息、订阅数据、重新连接以及服务质量控制等相关操作。 -
uuid:用于生成唯一标识符,确保每场比赛和事件的信息在所有系统中都是唯一的。
接下来,创建如下的文件夹结构。server文件夹用于存放Node.js后端代码:public文件夹则包含浏览器需要加载的HTML、CSS文件以及客户端JavaScript代码。routes子文件夹将与路由处理相关的文件与主服务器文件分开存储。
mqtt-football-scores/
├── server/
│ ├── index.js
│ ├── sse.js
│ └── routes/
│ └── matches.js
├── public/
│ ├── index.html
│ ├── admin.html
│ └── viewer.html
├── mosquitto.conf
├── docker-compose.yml
└── package.json
在package.json文件中添加"type": "module"这一条,这样你就可以使用JavaScript模块了(包括导入和导出功能)。type字段告诉Node.js将.js文件视为ES模块,这样一来,你就可以使用import和export语法,而不用CommonJS的require和module.exports了。
{
"name": "mqtt-football-scores",
"version": "1.0.0",
"type": "module",
"main": "server/index.js"
}
如何配置MQTT代理服务器
在服务器启动之前,必须先运行一个MQTT代理服务器。你有三种选择。
选项1:使用Docker(推荐方案)
创建一个docker-compose.yml文件。该文件定义了一个名为mosquitto的服务,该服务会运行Eclipse Mosquitto 2镜像。ports指令将主机上的1883端口映射到容器内的1883端口,这样你的Express服务器就可以连接到该代理服务器了。volumes指令会将本地的mosquitto.conf配置文件挂载到容器中,从而使代理服务器使用你的配置设置。restart: unless-stopped选项确保如果容器崩溃或你的机器重新启动时,容器能够自动重启。
version: "3.8"
services:
mosquitto:
image: eclipse-mosquitto:2
container_name: mqtt-football-mosquitto
ports:
- "1883:1883"
volumes:
- ./mosquitto.conf:/mosquitto/config/mosquitto.conf
restart: unless-stopped
创建一个mosquitto.conf文件。listener 1883指令告诉Mosquitto在1883端口上监听MQTT连接请求。protocol mqtt指定了使用标准的MQTT协议。allow_anonymous true选项允许无需用户名和密码即可建立连接,这种设置适合本地开发环境,但在生产环境中应该禁用。log_dest stdout和log_type all指令会将所有的日志输出显示到控制台上,这样你就可以方便地排查连接问题了。
listener 1883
protocol mqtt
allow_anonymous true
log.dest stdout
log_type all
启动代理服务器:
docker-compose up -d
选项2:本地安装
在macOS系统中,如果使用了Homebrew,可以按照以下步骤进行配置:
brew install mosquitto
mosquitto -c mosquitto.conf
在 Ubuntu 或 Debian 上:
sudo apt install mosquitto mosquitto-clients
sudo systemctl start mosquitto
选项 3:公共测试代理服务器
您可以直接使用 test.mosquitto.org 上的公共测试代理服务器,而无需进行任何安装操作。在启动服务器时,请设置以下环境变量:
MQTT_BROKER=mqtt://test.mosquitto.org npm start
注意:这个公共代理服务器是共享的,不适合用于生产环境,仅适用于开发和测试用途。
如何构建 Express 服务器
在这一步中,您将创建负责运行实时应用程序的主服务器。Express 服务器会处理 HTTP 请求,提供 HTML、JavaScript 等静态文件,并充当浏览器与 MQTT 代理服务器之间的桥梁。此外,它还提供了用于实现实时数据发送和接收的接口。从本质上讲,这个服务器是整个应用程序的核心,它确保了所有组件之间能够进行有效的通信。
首先,在 server/index.js 文件中创建主服务器代码:
import express from 'express';
import cors from 'cors';
import mqtt from 'mqtt';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { v4 as uuidv4 } from 'uuid';
import { matchRoutes } from './routes/matches.js';
import { setupSSE, addSSEClient } from './sse.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const MQTT_BROKER = process.env.MQTT_BROKER || 'mqtt://localhost:1883';
const PORT = process.env.PORT || 3000;
const app = express();
app.use(cors());
app.use(express.json());
app.use(express.static(join(__dirname, '../public');
let mqttClient = null;
function connectMQTT() {
mqttClient = mqtt.connect(MQTT_BROKER, {
clientId: `football-scores-${uuidv4().slice(0, 8)}`,
reconnectPeriod: 3000,
connectTimeout: 10000,
});
mqttClient.on('connect', () => {
console.log('已连接到 MQTT 代理服务器:', MQTT_BROKER);
mqttClient.subscribe('sports/football/#', { qos: 1 }, (err) => {
if (err) console.error('订阅失败:', err);
});
});
mqttClient.on('error', (err) => {
console.error('MQTT 连接出现错误:', err.message);
});
mqttClient.on('close', () => {
console.log('MQTT 连接已关闭');
});
mqttClient.on('reconnect', () => {
console.log('正在重新连接 MQTT 代理服务器...');
});
return mqttClient;
}
const mqttClientInstance = connectMQTT();
const { publishMatch, publishScoreUpdate, publishEvent, getMatches } = matchRoutes(mqttClientInstance);
setupSSE(mqttClientInstance);
app.get('/api/events', (req, res) => addSSEClient(res));
app.post('/api/matches', publishMatch);
app.patch('/api/matches/:id/score', publishScoreUpdate);
app.post('/api/matches/:id/events', publishEvent);
app.get('/api/matches', getMatches);
app.listen(PORT, () => {
console.log(`足球比分服务器运行地址:http://localhost:${PORT}`);
console.log(`管理员管理界面:http://localhost:${PORT}/admin.html`);
console.log(`浏览器端访问地址:http://localhost:${PORT}/viewer.html`);
});
以下是各部分的功能:
-
导入模块:`fileURLToPath`和`dirname`这两个工具函数用于复制CommonJS提供的`__dirname`变量,因为ES模块本身并不包含这个变量。你需要`__dirname`来构建通往`public`文件夹的路径。
-
环境变量设置:`MQTT_BROKER`的环境变量默认值为`mqtt://localhost:1883`,因此服务器会连接到本地的Mosquitto实例。如果你使用Docker或远程代理服务器,可以修改这个值;`PORT`的环境变量默认值为3000。
-
中间件功能:`cors()`中间件允许来自任何来源的请求,这在开发过程中非常有用;`express.json()`用于解析JSON格式的请求体;`express.static()`则用于提供`public`文件夹中的文件内容,因此用户可以通过`/admin.html`和`/viewer.html`等路径访问这些页面。
-
连接MQTT服务器:会创建一个MQTT客户端,该客户端具有唯一的标识符、3秒的重新连接间隔以及10秒的超时设置。连接成功后,它会订阅`sports/football/#`这个主题,QoS等级为1。这里的`#`通配符表示“sports/football”目录下的所有主题。
-
处理匹配相关路由:这些路由处理器负责创建比赛记录、更新比分以及添加事件信息。每个处理器都会通过MQTT发送数据,并以JSON格式返回响应。
-
配置Server-Sent Events:会在MQTT客户端上注册一个监听器,当有消息到达时,该监听器会将消息内容转发给所有已连接的Server-Sent Events客户端。
-
添加SSE客户端:当有用户访问`/api/events`路径时,会执行此操作。它负责设置Server-Sent Events的响应头信息,确保连接保持开启状态,并将响应对象添加到活跃客户端的列表中。
-
路由配置:GET请求的`/api/events`路径用于建立Server-Sent Events数据流;而POST、PATCH和GET请求相关的路由处理则由`matchRoutes`中的处理器来完成。
-
publish:这是一个辅助函数,用于在发布消息之前检查MQTT客户端是否已连接。如果代理服务器处于关闭状态,该函数会记录警告信息而不会抛出异常。
retain: false选项表示代理服务器不会为新订阅者保存最后一条消息。 -
publishMatch:验证
homeTeam和awayTeam字段是否存在。使用默认值创建一个比赛对象,包括联赛、场地、开球时间、状态等信息,然后将其存储在Map中,同时将相关信息发布到对应的主题以及比分主题上,最后返回状态为201(“已创建”)的比赛对象。 -
publishScoreUpdate:根据ID查找相应的比赛记录。如果未找到,则返回404错误码。仅更新提供的字段值(使用
!== undefined进行判断,因此可以将比分设置为0),然后发布完整的比赛信息以及比分更新通知。 -
publishEvent:创建一个带有唯一短ID的事件对象,并将其添加到比赛的事件数组中。如果事件类型为
goal,则根据相关队伍的情况更新主队或客队的得分。随后发布更新后的比赛信息以及事件通知。 -
getMatches:将Map中的数据转换为数组,按照
createdAt字段降序排序,最后以JSON格式返回结果列表。 -
sports/football/match/{id}:用于存储完整的比赛状态信息。在比赛创建或更新时使用该主题。 -
sports/football/scores:用于发送比分变化通知。适用于比赛创建和比分更新场景。 -
sports/football/events:用于发布与比赛相关的事件信息,如进球、罚球等。
服务器会从`public`文件夹中提供静态文件,因此你的HTML页面可以放在根目录下直接访问。
如何实现匹配相关路由
在这一步中,你需要创建用于管理比赛、比分和事件信息的路由处理器。这些路由使得服务器能够接收来自管理员界面的请求,更新比赛数据,并将实时更新信息发送到MQTT服务器。同时,这些路由还会将比赛信息存储在内存中,以便在会话期间随时检索或修改这些数据。
首先,在`server/routes/matches.js`文件中开始编写相关代码吧:
import { v4 as uuidv4 } from 'uuid';
const TOPIC_MATCH = 'sports/football/match';
const TOPIC_SCORES = 'sports/football/scores';
const TOPIC_events = 'sports/football/events';
const matches = new Map();
function publish(client, topic, payload, qos = 1) {
if (!client?.connected) {
console.warn('未连接到MQTT服务器,因此无法发布消息');
return false;
}
client.publish(topic, JSON.stringify(payload), { qos, retain: false });
return true;
}
export function matchRoutes(mqttClient) {
return {
publishMatch: (req, res) => {
const { homeTeam, awayTeam, league, venue, kickoff } = req.body;
if (!homeTeam || !awayTeam) {
return res.status(400).json({ error: '必须提供主队和客队信息' });
}
const match = {
id: uuidv4(),
homeTeam,
awayTeam,
homeScore: 0,
awayScore: 0,
league: league || 'Premier League',
venue: venue || '待确定',
kickoff: kickoff || new Date().toISOString(),
status: 'scheduled',
minute: 0,
events: [],
createdAt: new Date().toISOString(),
};
matches.set(match.id, match);
const topic = `\({TOPIC_MATCH}/\){match.id}`;
publish(mqttClient, topic, match);
publish(mqttClient, TOPIC_SCORES, { type: 'match_created', match });
res.status(201).json(match);
},
publishScoreUpdate: (req, res) => {
const { id } = req.params;
const { homeScore, awayScore, minute, status } = req.body;
const match = matches.get(id);
if (!match) {
return res.status(404).json({ error: '未找到对应的比赛' });
}
if (homeScore !== undefined) match.homeScore = homeScore;
if (awayScore !== undefined) match.awayScore = awayScore;
if (minute !== undefined) match.minute = minute;
if (status !== undefined) match.status = status;
const topic = `\({TOPIC_MATCH}/\){id}`;
publish(mqttClient, topic, match);
publish(mqttClient, TOPICSCORES, {
type: 'score_update',
matchId: id,
homeScore: match.homeScore,
awayScore: match.awayScore,
minute: match.minute,
status: match.status,
});
res.json(match);
},
publishEvent: (req, res) => {
const { id } = req.params;
const { type, team, player, minute, description } = req.body;
const match = matches.get(id);
if (!match) {
return res.status(404).json({ error: '未找到对应的比赛' });
}
const event = {
id: uuidv4().slice(0, 8),
type: type || 'goal',
team,
player: player || 'Unknown',
minute: minute ?? matchminute,
description: description || `\({type}: \){player}`,
timestamp: new Date().toISOString(),
};
match.events.push(event);
if (type === 'goal') {
if (team === match.homeTeam) match.homeScore++;
else if (team === match.awayTeam) match.awayScore++;
}
const topic = `\({TOPIC_MATCH}/\){id}`;
publish(mqttClient, topic, match);
publish(mqttClient, TOPIC_events, { type: 'match_event', matchId: id, event });
res.status(201).json({ match, event });
},
getMatches: (req, res) => {
const list = Array.from(matches.values()).sort(
(a, b) => new Date(b.createdAt) - new Date(acreatedAt)
);
res.json(list);
},
};
}
这些路由使用内存中的Map来存储匹配结果。在生产环境中,应该用PostgreSQL或MongoDB这样的数据库来替代它。
关键逻辑说明:
所使用的MQTT主题:
publish函数会使用JavaScript对象表示法(JSON)格式的数据,并设置QoS等级为1(确保消息至少会被发送一次)。MQTT定义了三种QoS等级:0(最多发送一次)、1(至少发送一次)和2(恰好发送一次)。QoS等级1能够保证代理服务器会不断重试,直到订阅者确认收到消息,从而减少因连接短暂中断而导致比分更新信息丢失的情况。
如何利用“服务器发送事件”技术将MQTT与浏览器连接起来
在这一步中,我们将创建一个“服务器发送事件”模块,用于将MQTT消息传递给浏览器。由于MQTT是基于TCP协议的,而浏览器无法直接与之建立连接,因此我们使用基于HTTP的流式传输技术——SSE来实现这一功能。SSE能够保持连接状态,使服务器能够实时向已连接的浏览器推送更新信息,这样观众就可以在不刷新页面的情况下立即看到比赛动态。
现在请在server/sse.js文件中编写相关代码:
const clients = new Set();
export function setupSSE(mqttClient) {
if (!mqttClient) return;
mqttClient.on('message', (topic, message) => {
try {
const payload = JSON.parse(message.toString());
const data = JSON.stringify({ topic, ...payload });
clients.forEach((res) => {
try {
res.write(`data: ${data}\n\n`);
} catch (e) {
clients.delete(res);
}
});
} catch (e) {
console.error('SSE解析错误:', e.message);
}
});
export function addSSEClient(res) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
clients.add(res);
res.on('close', () => {
clients.delete(res);
});
}
}
各部分的说明:
-
客户端集合:集合用于存储所有活跃的“服务器发送的事件”响应对象。使用集合可以方便地添加或删除客户端,同时避免重复。
-
setupSSE:将一个
消息监听器绑定到MQTT客户端上。当有任何消息到达已订阅的主题时,就会触发回调函数。该函数会解析消息的内容(格式为JSON),然后将主题信息以{ topic, ...payload }的形式合并到消息中,并将处理后的结果发送给所有客户端。“服务器发送的事件”格式要求每条消息都必须采用data: {content}\n\n这种格式(即包含两行换行符)。forEach循环会捕获任何写入错误(例如当有客户端断开连接时),并将该客户端从集合中移除。 -
addSSEClient:将
Content-Type头设置为text/event-stream,这样浏览器就会将响应内容视为事件流来处理。设置Cache-Control: no-cache和Connection: keep-alive头可以防止浏览器或代理服务器缓存数据或关闭连接。X-Accel-Buffering: no头则会禁用Nginx的缓冲功能,因为这种缓冲机制可能会导致“服务器发送的事件”传输延迟或被阻塞。flushHeaders方法会立即发送这些头部信息,从而确保连接能够正常建立。当有客户端断开连接时,close事件处理程序会将该客户端从集合中移除。
“服务器发送的事件”是一种单向通信机制(仅从服务器发送到客户端)。对于这种使用场景来说,这样的设计已经足够了,因为观众只需要接收更新信息,而不需要通过同一通道发送消息回服务器。
如何构建管理员上传接口
管理员界面是一个简单的HTML页面,通过这个页面,赛事创建者可以新建比赛、更新比分,以及添加进球、罚牌等事件信息。该界面使用了标准的HTML表单,因此用户可以将数据提交到服务器,而JavaScript会负责处理表单提交逻辑及动态更新效果。所有的标记、样式和脚本代码都保存在public/admin.html文件中,Express服务器会将这个文件作为静态页面进行提供。
管理员界面的HTML结构与样式
该页面以标准的HTML5模板开始编写。charset和viewport元标签确保了正确的字符编码以及移动设备上的响应式布局。页面还会从Google Fonts中加载“Outfit”字体,从而呈现出干净、现代的外观。
层叠样式表(CSS)在:root块中使用了自定义属性(变量),这样就可以在一个地方统一修改整个界面的颜色方案。--bg变量用于设置深色背景,--surface用于卡片背景,用于绿色强调色,--text-muted则用于次要文本的颜色。.grid类为表单字段创建了两列布局;当屏幕宽度小于600像素时,这些字段会自动缩成一列。.toast类会将通知框放置在页面的右下角,并且当添加.show类时,会通过transform和opacity>属性实现滑入动画效果。
现在在public/admin.html路径下创建这个文件:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
管理员界面 - 足球比分上传
:root {
--bg: #0f1419;
--surface: #1a2332;
--surface-hover: #243044;
--accent: #00d26a;
--accent-dim: #00a854;
--text: #e8edf2;
--text-muted: #8b9aab;
--border: #2d3a4d;
--danger: #ff4757;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Outfit', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 2rem;
}
.container { max-width: 900px; margin: 0 auto; }
header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
section {
background: var(--surface);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid var(--border);
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 600px) { .grid { grid-template-columns: 1fr; } }
.form-group { display: flex; flex-direction: column; gap: 0.4rem; }
.form-group.full { grid-column: 1 / -1; }
input, select {
padding: 0.65rem 1rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font-family: inherit;
}
button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-family: inherit;
font-weight: 600;
cursor: pointer;
}
.btn-primary { background: var(--accent); color: var(--bg); }
.btn-secondary { background: var(--surface-hover); color: var (--text); border: 1px solid var(--border); }
.actions { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 1rem; }
.badge { background: var(--accent); color: var(--bg); font-size: 0.75rem; padding: 0.25rem 0.6rem; border-radius: 999px; font-weight: 600; }
.viewer-link { margin-left: auto; color: var(--accent); text-decoration: none; font-weight: 500; }
section h2 { font-size: 1rem; font-weight: 600; margin-bottom: 1rem; color: var(--text-muted); }
label { font-size: 0.85rem; font-weight: 500; color: var(--text-muted); }
.toast {
position: fixed;
bottom: 2rem;
right: 2rem;
padding: 1rem 1.5rem;
border-radius: 8px;
font-weight: 500;
color: var(--bg);
background: var(--accent);
transform: translateY(100px);
opacity: 0;
transition: transform 0.3s, opacity 0.3s;
z-index: 100;
}
.toast.show { transform: translateY(0); opacity: 1; }
.toast.error { background: var(--danger); }
.match-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
background: var(--bg);
border-radius: 8px;
margin-bottom: 0.5rem;
border: 1px solid var(--border);
}
.match-score { font-size: 1.5rem; font-weight: 700; color: var(--accent); margin: 0 1rem; }
.match-info { flex: 1; }
.match-teams { font-weight: 600; font-size: 1rem; }
.match-meta { font-size: 0.8rem; color: var(--text-muted); margin-top: 0.25rem; }
.match-actions { display: flex; gap: 0.5rem; }
.matchActions button { padding: 0.5rem 1rem; font-size: 0.85rem; }
.match-list { margin-top: 1rem; }
</style>
</head>>
<body>
⚽ 足球比分
管理员
→ 打开查看器
创建新比赛