實時更新無處不在——比如體育比賽的實時比分、股票行情更新、聊天應用程序以及物聯網數據面板。如果你想構建能夠在數據發生變化的瞬間就將其推送給用戶的系統,那么你就需要合適的工具。
消息隊列遙測傳輸協議(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>
? 足球比分
管理員
→ 打開查看器
創建新比賽