From 896c24547b30684173778cff6842c311030f38fb Mon Sep 17 00:00:00 2001 From: JianMiau Date: Sun, 19 Apr 2026 12:46:59 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8D=B3=E6=99=82=E8=A7=80?= =?UTF-8?q?=E6=88=B0=E6=88=BF=E9=96=93=E4=B8=A6=E6=95=B4=E7=90=86=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 128 ++++++----- server/data/live-rooms.json | 1 + server/server.mjs | 382 +++++++++++++++++++++++++++++++- src/App.css | 126 +++++++++++ src/App.tsx | 227 ++++++++++++++++++- src/lib/api.ts | 163 +++++++++++++- src/pages/RoomListPage.tsx | 94 ++++++++ src/pages/RoomSpectatorPage.tsx | 175 +++++++++++++++ src/pages/ScoreboardPage.tsx | 4 + src/types.ts | 44 ++++ 10 files changed, 1283 insertions(+), 61 deletions(-) create mode 100644 server/data/live-rooms.json create mode 100644 src/pages/RoomListPage.tsx create mode 100644 src/pages/RoomSpectatorPage.tsx diff --git a/README.md b/README.md index 077e20c..d547c7d 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,60 @@ -# 羽球記分板 +# 羽毛球記分板 -使用 `Vite + React + TypeScript + Node.js` 製作的羽毛球記分板,支援選隊伍、計分板、歷史戰績、語音播報、PWA 安裝與 Docker / NAS 部署。 +使用 `Vite + React + TypeScript + Node.js` 製作的羽毛球記分板,支援選隊伍、即時記分、歷史戰績、PWA 安裝,以及即時房間觀戰。 ## 功能 - 選隊伍 - - 可依指定日期從資料庫讀取分組資料。 - - 若當天沒有資料,可手動輸入 A、B 區名單產生分組。 - - 每組可直接進入記分板,不需額外再點選這組。 -- 計分板 - - 設定隊伍彈窗支援逐一選人。 - - 依選取順序自動成隊:`1、2` 一隊,`3、4` 一隊。 - - 右側可快速選擇預設隊伍。 - - 可設定本場幾分獲勝,預設 `21` 分。 - - 需先指定先攻,之後點擊分數即可直接加分。 - - 第一分記錄後,右側 `設定隊伍` 會切成 `上一步`。 - - 可交換上下隊伍位置,也可交換同隊左右球員位置。 - - `比賽結算` 為防誤觸設計,需長按 `1 秒` 才會觸發。 - - 比分仍是 `0:0` 時,不會啟動比賽結算長按。 - - 長按 `比賽結算` 時,按鈕下方會顯示進度條,按鈕本身也會有亮起與下壓的視覺回饋。 - - 手機長按 `比賽結算` 不會再觸發文字選取。 - - 連勝會出現特效提示: - - `3 連勝`:`大殺特殺` - - `4 連勝`:`暴走` - - `5 連勝`:`無人能擋` - - `6 連勝`:`主宰比賽` - - `7 連勝`:`像神一般的` - - `8 連勝`:`成為傳說` + - 可依指定日期從資料庫載入分組資料。 + - 若資料庫沒有當天資料,可手動輸入 A、B 區名單產生分組。 + - 點選分組後可直接進入記分板。 +- 記分板 + - 隊伍名稱只顯示在最上方與最下方。 + - 可在設定隊伍面板中逐一選人,也可快速套用預設隊伍。 + - 先選到的 `1、2` 為一隊,`3、4` 為另一隊。 + - 可設定本場幾分獲勝,預設為 `21` 分。 + - 必須先設定先攻,才能開始記分。 + - 點擊分數直接加分,不提供加一減一按鈕。 + - 第一分記下後,`設定隊伍` 會切換成 `上一步`。 + - 可交換上下兩隊位置,也可交換同隊左右站位。 + - `比賽結算` 需長按 `1 秒` 才會觸發。 + - 比分 `0:0` 時不允許觸發結算。 +- 語音播報 + - 可設定是否播報得分者。 + - 可設定是否播報下一位發球者。 + - 可調整語速,最高支援到 `10x`。 + - `RURU` 會以大小寫不敏感方式播報成「嚕嚕」。 +- 動畫提示 + - 先攻未設定時,`先攻` 文字會有提示動畫。 + - 選定先攻後,會顯示打勾讓使用者更容易辨識。 + - 連勝特效: + - `3 連勝`:大殺特殺 + - `4 連勝`:暴走 + - `5 連勝`:無人能擋 + - `6 連勝`:主宰比賽 + - `7 連勝`:像神一般的 + - `8 連勝`:成為傳說 - 達到目標分數時會顯示獲勝動畫。 - - 內建免費瀏覽器 TTS。 - - 可設定是否播報得分者、是否播報發球者、以及語速。 - - `RURU` 已支援不分大小寫的發音別名,會念成 `嚕嚕`。 - 歷史戰績 - - 直接從資料庫 `history` 表讀取列表。 - - 點擊單筆戰績可開啟得分紀錄彈窗。 - - 彈窗支援右上角 `X` 關閉按鈕。 - - 每筆資料可直接刪除,刪除前會跳一次確認提示。 + - 比賽結算後可選擇是否上傳戰績到資料庫。 + - 歷史戰績列表直接從資料庫 `history` 表讀取。 + - 可點開查看每球得分紀錄。 + - 手機上彈窗有 `X` 可快速關閉。 + - 每筆戰績可刪除,刪除前會確認一次。 +- 即時房間 / 觀戰 + - 只要帶入隊伍進入記分板,就會自動建立一個房間。 + - 記分板右側會顯示房號。 + - `房間列表` 只顯示房號、隊伍、目標分數與最後更新時間,不顯示比分。 + - 觀戰者進入房間後可即時看到比分,不能操作。 + - 觀戰同步使用 `SSE + 輪詢備援`,降低漏分風險。 + - 房主重整、離開記分板或換隊伍時,未結束房間會自動清掉。 + - 達到目標分數後房間會標記結束,觀戰者會看到獲勝彈窗,按確定後返回房間列表。 - PWA - 可加入手機主畫面,像 App 一樣開啟。 - - 支援 `manifest`、`service worker`、主畫面 icon。 - - 網頁 favicon 與 PWA icon 已改用 `ICON.png` 產生的 PNG 圖示。 - - 偵測到新版本時,畫面底部會顯示更新提示,可一鍵重新整理套用新版。 - - 前端會定期輪詢 `/api/version`,只要重新部署並重建 app container,就能偵測到新版本。 + - 支援自訂網站 icon / PWA icon。 + - 新版本部署後會顯示更新提示,可直接重新整理套用新版。 -## 執行環境 +## 開發環境 ### Port @@ -56,18 +67,18 @@ npm install ``` -### 開發模式 +### 啟動開發模式 ```bash npm run dev ``` -啟動後會同時開兩個服務: +啟動後: - 前端:`http://localhost:3501` - API:`http://localhost:8788` -### 檢查 +### 建置與檢查 ```bash npm run lint @@ -91,24 +102,24 @@ PORT=8788 ## Docker / NAS 部署 -正式部署時目前是雙容器架構: +正式部署時: -- App 內部服務:`8788` -- Nginx SSL 對外入口:`3501` +- App 內部服務 port:`8788` +- 對外 HTTPS 入口:`3501` -啟動指令: +部署指令: ```bash sudo docker compose up -d --build ``` -部署完成後,對外入口: +部署完成後,對外入口為: ```text https://你的網域或 NAS IP:3501 ``` -每次執行 `sudo docker compose up -d --build` 重建 app container 時,後端都會帶新的容器啟動版號;前端輪詢發現版號不同後,就會跳出更新提示。 +每次執行 `sudo docker compose up -d --build`,容器都會更新啟動版號,已安裝 PWA 的裝置會在偵測到新版本後顯示更新提示。 ## SSL 憑證目錄 @@ -118,17 +129,17 @@ Docker Compose 會直接掛載 NAS 上的憑證目錄: /volume1/homes/JianMiau/www/certificate/ ``` -目前預設使用這三個檔名: +預設使用以下檔案: - `RSA-cert.pem` - `RSA-chain.pem` - `RSA-privkey.pem` -更新憑證時,只要更新上述目錄內的檔案,再重新啟動容器即可。 +之後只要更新這個資料夾內的憑證檔即可,不需要重建 image。 -## history 資料表格式 +## 資料表格式 -`history` 表目前使用以下欄位: +### `history` - `id` - `time` @@ -139,16 +150,25 @@ Docker Compose 會直接掛載 NAS 上的憑證目錄: - `0`:雙打 - `1`:單打 - `players` - - 依 `1 ~ 4` 順序排序的玩家名稱 + - 依 `1 ~ 4` 編號排序的玩家陣列 - `team` - - `1、2` 一隊 - - `3、4` 一隊 + - `1、2` 為一隊 + - `3、4` 為一隊 - `scoreList` - 格式:`[round, 發球者編號, 連勝次數, 得分隊伍(0 或 1)]` -## Git 中文顯示 +## PWA 圖示 -若要讓 commit 與 log 正常顯示中文,可設定: +目前網站 icon 與 PWA icon 來源為: + +- `public/favicon.png` +- `public/apple-touch-icon.png` +- `public/pwa-192.png` +- `public/pwa-512.png` + +## Git 中文設定 + +建議設定 git 使用 UTF-8,避免中文 commit 或 log 顯示異常: ```bash git config i18n.commitEncoding utf-8 diff --git a/server/data/live-rooms.json b/server/data/live-rooms.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/server/data/live-rooms.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/server/server.mjs b/server/server.mjs index 6a0f161..78ea39d 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -2,7 +2,7 @@ import 'dotenv/config' import express from 'express' import mysql from 'mysql2/promise' import path from 'node:path' -import { existsSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' const app = express() @@ -16,6 +16,8 @@ const currentFilePath = fileURLToPath(import.meta.url) const currentDir = path.dirname(currentFilePath) const projectRoot = path.resolve(currentDir, '..') const distDir = path.join(projectRoot, 'dist') +const roomDataDir = path.join(projectRoot, 'server', 'data') +const roomsFilePath = path.join(roomDataDir, 'live-rooms.json') const distReady = existsSync(path.join(distDir, 'index.html')) const requiredEnv = ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_DATABASE'] @@ -35,6 +37,9 @@ const pool = }) : null +const rooms = loadPersistedRooms() +const roomListClients = new Set() + app.use(express.json()) app.get('/api/health', (_request, response) => { @@ -65,6 +70,198 @@ app.get('/api/version', (_request, response) => { }) }) +app.get('/api/rooms', (_request, response) => { + response.json({ + ok: true, + data: getLiveRoomSummaries(), + }) +}) + +app.get('/api/rooms/stream', (request, response) => { + setupSse(response) + roomListClients.add(response) + sendSse(response, 'rooms', getLiveRoomSummaries()) + + request.on('close', () => { + roomListClients.delete(response) + }) +}) + +app.post('/api/rooms', (request, response) => { + const payload = normalizeRoomPayload(request.body) + + if (!payload.ok) { + response.status(400).json({ + ok: false, + message: payload.message, + }) + return + } + + const roomId = createRoomId() + const hostToken = createHostToken() + const now = new Date().toISOString() + const room = { + ...payload.value, + clients: new Set(), + createdAt: now, + hostToken, + roomId, + status: 'live', + updatedAt: now, + winnerTeamName: null, + } + + rooms.set(roomId, room) + persistRooms() + broadcastRoom(room) + broadcastRoomList() + + response.json({ + ok: true, + data: { + hostToken, + roomId, + status: room.status, + }, + }) +}) + +app.post('/api/rooms/:roomId/release', (request, response) => { + const room = rooms.get(request.params.roomId) + + if (!room) { + response.json({ + ok: true, + }) + return + } + + const { hostToken } = request.body ?? {} + + if (typeof hostToken !== 'string' || hostToken !== room.hostToken) { + response.status(403).json({ + ok: false, + message: '沒有權限清除此房間。', + }) + return + } + + if (room.status === 'finished') { + response.json({ + ok: true, + }) + return + } + + room.clients.forEach((client) => { + sendSse(client, 'room-closed', { + roomId: room.roomId, + status: 'released', + }) + client.end() + }) + + rooms.delete(room.roomId) + persistRooms() + broadcastRoomList() + + response.json({ + ok: true, + }) +}) + +app.get('/api/rooms/:roomId', (request, response) => { + const room = rooms.get(request.params.roomId) + + if (!room) { + response.status(404).json({ + ok: false, + message: '找不到這個房間。', + }) + return + } + + response.json({ + ok: true, + data: serializeRoom(room), + }) +}) + +app.get('/api/rooms/:roomId/stream', (request, response) => { + const room = rooms.get(request.params.roomId) + + if (!room) { + response.status(404).json({ + ok: false, + message: '找不到這個房間。', + }) + return + } + + setupSse(response) + room.clients.add(response) + sendSse(response, 'room', serializeRoom(room)) + + request.on('close', () => { + room.clients.delete(response) + }) +}) + +app.put('/api/rooms/:roomId', (request, response) => { + const room = rooms.get(request.params.roomId) + + if (!room) { + response.status(404).json({ + ok: false, + message: '找不到這個房間。', + }) + return + } + + const { hostToken, status, winnerTeamName } = request.body ?? {} + + if (typeof hostToken !== 'string' || hostToken !== room.hostToken) { + response.status(403).json({ + ok: false, + message: '沒有更新這個房間的權限。', + }) + return + } + + const payload = normalizeRoomPayload(request.body) + + if (!payload.ok) { + response.status(400).json({ + ok: false, + message: payload.message, + }) + return + } + + room.groupId = payload.value.groupId + room.leftTeamName = payload.value.leftTeamName + room.matchupLabel = payload.value.matchupLabel + room.pointLog = payload.value.pointLog + room.rightTeamName = payload.value.rightTeamName + room.scoreState = payload.value.scoreState + room.targetDate = payload.value.targetDate + room.status = status === 'finished' ? 'finished' : 'live' + room.updatedAt = new Date().toISOString() + room.winnerTeamName = room.status === 'finished' && typeof winnerTeamName === 'string' + ? winnerTeamName + : null + + persistRooms() + broadcastRoom(room) + broadcastRoomList() + + response.json({ + ok: true, + data: serializeRoom(room), + }) +}) + app.get('/api/match-results/:time', async (request, response) => { if (!pool) { response.status(500).json({ @@ -282,6 +479,189 @@ app.listen(port, () => { } }) +function createHostToken() { + return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}` +} + +function createRoomId() { + let roomId = '' + + do { + roomId = String(Math.floor(100000 + Math.random() * 900000)) + } while (rooms.has(roomId)) + + return roomId +} + +function loadPersistedRooms() { + const nextRooms = new Map() + + try { + if (!existsSync(roomsFilePath)) { + return nextRooms + } + + const raw = readFileSync(roomsFilePath, 'utf8') + + if (!raw.trim()) { + return nextRooms + } + + const savedRooms = JSON.parse(raw) + + if (!Array.isArray(savedRooms)) { + return nextRooms + } + + savedRooms.forEach((savedRoom) => { + if (!savedRoom || typeof savedRoom !== 'object' || typeof savedRoom.roomId !== 'string') { + return + } + + nextRooms.set(savedRoom.roomId, { + ...savedRoom, + clients: new Set(), + }) + }) + } catch (error) { + console.error('load persisted rooms error:', error) + } + + return nextRooms +} + +function persistRooms() { + try { + mkdirSync(roomDataDir, { recursive: true }) + writeFileSync( + roomsFilePath, + JSON.stringify( + Array.from(rooms.values()).map((room) => ({ + createdAt: room.createdAt, + groupId: room.groupId, + hostToken: room.hostToken, + leftTeamName: room.leftTeamName, + matchupLabel: room.matchupLabel, + pointLog: room.pointLog, + rightTeamName: room.rightTeamName, + roomId: room.roomId, + scoreState: room.scoreState, + status: room.status, + targetDate: room.targetDate, + updatedAt: room.updatedAt, + winnerTeamName: room.winnerTeamName, + })), + null, + 2, + ), + 'utf8', + ) + } catch (error) { + console.error('persist rooms error:', error) + } +} + +function normalizeRoomPayload(value) { + const { + groupId, + leftTeamName, + matchupLabel, + pointLog, + rightTeamName, + scoreState, + targetDate, + } = value ?? {} + + if ( + typeof leftTeamName !== 'string' || + typeof rightTeamName !== 'string' || + typeof matchupLabel !== 'string' || + typeof targetDate !== 'string' || + !Array.isArray(pointLog) || + !scoreState || + typeof scoreState !== 'object' + ) { + return { + ok: false, + message: '房間資料格式不正確。', + } + } + + return { + ok: true, + value: { + groupId: typeof groupId === 'number' ? groupId : null, + leftTeamName, + matchupLabel, + pointLog, + rightTeamName, + scoreState, + targetDate, + }, + } +} + +function serializeRoom(room) { + return { + roomId: room.roomId, + groupId: room.groupId, + leftTeamName: room.leftTeamName, + matchupLabel: room.matchupLabel, + pointLog: room.pointLog, + rightTeamName: room.rightTeamName, + scoreState: room.scoreState, + status: room.status, + targetDate: room.targetDate, + createdAt: room.createdAt, + updatedAt: room.updatedAt, + winnerTeamName: room.winnerTeamName, + } +} + +function getLiveRoomSummaries() { + return Array.from(rooms.values()) + .filter((room) => room.status === 'live') + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) + .map((room) => ({ + roomId: room.roomId, + createdAt: room.createdAt, + leftTeamName: room.leftTeamName, + rightTeamName: room.rightTeamName, + scoreLeft: room.scoreState.scoreLeft, + scoreRight: room.scoreState.scoreRight, + status: room.status, + targetScore: room.scoreState.targetScore, + updatedAt: room.updatedAt, + })) +} + +function setupSse(response) { + response.writeHead(200, { + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'Content-Type': 'text/event-stream', + }) +} + +function sendSse(response, eventName, payload) { + response.write(`event: ${eventName}\n`) + response.write(`data: ${JSON.stringify(payload)}\n\n`) +} + +function broadcastRoom(room) { + const payload = serializeRoom(room) + room.clients.forEach((client) => { + sendSse(client, 'room', payload) + }) +} + +function broadcastRoomList() { + const payload = getLiveRoomSummaries() + roomListClients.forEach((client) => { + sendSse(client, 'rooms', payload) + }) +} + async function ensureMatchTable(poolInstance, currentTableName) { await poolInstance.execute(` CREATE TABLE IF NOT EXISTS \`${currentTableName}\` ( diff --git a/src/App.css b/src/App.css index c5ce49f..fa33737 100644 --- a/src/App.css +++ b/src/App.css @@ -943,6 +943,15 @@ linear-gradient(145deg, rgba(8, 47, 73, 0.97), rgba(10, 96, 84, 0.93)); } +.rail-room-id { + padding: 10px 12px; + border-radius: 14px; + text-align: center; + color: #f7fff8; + background: rgba(8, 47, 73, 0.72); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12); +} + .rail-pill { border: 0; border-radius: 999px; @@ -1964,6 +1973,11 @@ font-size: 1.1rem; } + .rail-room-id { + padding: 8px 10px; + font-size: 0.86rem; + } + .rail-pill { padding: 10px 8px; font-size: 0.92rem; @@ -2030,6 +2044,16 @@ width: 100%; } + .room-card { + padding: 14px; + border-radius: 16px; + } + + .room-card-score strong, + .room-watch-team strong { + font-size: clamp(1.7rem, 12vw, 2.6rem); + } + .team-picker-ribbon { left: 18px; right: 90px; @@ -2146,3 +2170,105 @@ font-size: 0.92rem; } } + +.room-list-grid { + display: grid; + gap: 16px; +} + +.room-card { + display: grid; + gap: 14px; + padding: 18px; + border-radius: 20px; + color: inherit; + text-decoration: none; + background: rgba(255, 255, 255, 0.84); + box-shadow: 0 18px 28px rgba(8, 47, 73, 0.08); + transition: transform 0.16s ease, box-shadow 0.16s ease; +} + +.room-card:hover { + transform: translateY(-2px); + box-shadow: 0 22px 34px rgba(8, 47, 73, 0.12); +} + +.room-card-head, +.room-watch-meta { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 10px; +} + +.room-card-head span, +.room-watch-meta span, +.room-card-updated { + color: var(--panel-soft); + font-size: 0.9rem; +} + +.room-card-score, +.room-watch-scoreboard { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + gap: 12px; +} + +.room-card-matchup { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + gap: 12px; +} + +.room-card-score div, +.room-watch-team { + display: grid; + gap: 8px; + justify-items: center; + text-align: center; + padding: 16px 12px; + border-radius: 18px; + background: rgba(244, 236, 216, 0.8); +} + +.room-card-matchup strong { + display: block; + min-width: 0; + padding: 16px 12px; + border-radius: 18px; + text-align: center; + background: rgba(244, 236, 216, 0.8); + color: var(--panel-strong); +} + +.room-card-matchup span { + font-size: 1rem; + font-weight: 700; + color: var(--panel-soft); +} + +.room-card-score small, +.room-watch-team small { + color: var(--panel-soft); +} + +.room-card-score strong, +.room-watch-team strong { + font-size: clamp(2rem, 8vw, 3.4rem); + line-height: 1; +} + +.room-card-score span, +.room-watch-divider { + font-size: 2rem; + font-weight: 700; + color: var(--panel-strong); +} + +.room-watch-panel { + display: grid; + gap: 18px; +} diff --git a/src/App.tsx b/src/App.tsx index 5048c98..58dfb80 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,13 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { NavLink, Route, Routes, useLocation } from 'react-router-dom' +import { NavLink, Route, Routes, useLocation, useNavigate } from 'react-router-dom' import './App.css' -import { loadMatchResults, saveMatchHistory } from './lib/api' +import { + createLiveRoom, + loadMatchResults, + releaseLiveRoom, + saveMatchHistory, + updateLiveRoom, +} from './lib/api' import { buildManualGroups, convertDateToKey, @@ -14,12 +20,15 @@ import { swapCourtPositions, } from './lib/match' import { HistoryPage } from './pages/HistoryPage' +import { RoomListPage } from './pages/RoomListPage' +import { RoomSpectatorPage } from './pages/RoomSpectatorPage' import { ScoreboardPage } from './pages/ScoreboardPage' import { TeamSelectionPage } from './pages/TeamSelectionPage' import type { ActiveMatchup, GroupTeam, HistoryUploadPayload, + LiveRoomSession, LoadStatus, MatchHistoryItem, PointHistoryEntry, @@ -84,6 +93,7 @@ const APP_VERSION_POLL_MS = 30000 function App() { const location = useLocation() + const navigate = useNavigate() const isScoreboardRoute = location.pathname === '/scoreboard' const [targetDate, setTargetDate] = useState(() => @@ -118,13 +128,17 @@ function App() { const [streakAnnouncement, setStreakAnnouncement] = useState(null) const [victoryAnnouncement, setVictoryAnnouncement] = useState(null) const [pwaUpdateReady, setPwaUpdateReady] = useState(false) + const [liveRoomSession, setLiveRoomSession] = useState(null) const currentAppVersionRef = useRef(null) + const creatingRoomRef = useRef(false) + const lastSyncedRoomSignatureRef = useRef('') const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput]) const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput]) const selectedGroup = groups.find((group) => group.id === selectedGroupId) ?? null const leftTeam = activeMatchup.leftTeam const rightTeam = activeMatchup.rightTeam + const liveRoomId = liveRoomSession?.roomId ?? null useEffect(() => { window.localStorage.setItem(STORAGE_KEYS.targetDate, targetDate) @@ -242,6 +256,10 @@ function App() { }, []) const resetScoring = (nextState: ScoreState = initialScoreState) => { + if (liveRoomSession?.status === 'live') { + void releaseLiveRoom(liveRoomSession.roomId, liveRoomSession.hostToken).catch(() => {}) + } + setScoreState(nextState) setScoreHistory([]) setPointLog([]) @@ -252,6 +270,9 @@ function App() { open: false, uploading: false, }) + creatingRoomRef.current = false + setLiveRoomSession(null) + lastSyncedRoomSignatureRef.current = '' } const selectGroup = (groupId: number, nextGroups = groups) => { @@ -297,6 +318,173 @@ function App() { }) } + useEffect(() => { + if ( + !isScoreboardRoute || + !leftTeam || + !rightTeam || + liveRoomSession || + creatingRoomRef.current + ) { + return + } + + let cancelled = false + + const createRoom = async () => { + try { + creatingRoomRef.current = true + const session = await createLiveRoom( + buildLiveRoomPayload({ + groupId: selectedGroup?.id ?? null, + leftTeam, + pointLog, + rightTeam, + scoreState, + targetDate, + }), + ) + + if (!cancelled) { + setLiveRoomSession(session) + } + } catch (error) { + console.error('create live room error:', error) + } finally { + creatingRoomRef.current = false + } + } + + void createRoom() + + return () => { + cancelled = true + } + }, [ + leftTeam, + liveRoomSession, + pointLog, + rightTeam, + scoreState, + selectedGroup?.id, + targetDate, + isScoreboardRoute, + ]) + + useEffect(() => { + if (!isScoreboardRoute || !liveRoomSession || !leftTeam || !rightTeam) { + return + } + + const winnerTeamName = + scoreState.scoreLeft >= scoreState.targetScore + ? getTeamDisplayName(leftTeam) + : scoreState.scoreRight >= scoreState.targetScore + ? getTeamDisplayName(rightTeam) + : null + const nextStatus = winnerTeamName ? 'finished' : 'live' + const payload = buildLiveRoomPayload({ + groupId: selectedGroup?.id ?? null, + leftTeam, + pointLog, + rightTeam, + scoreState, + targetDate, + }) + const signature = JSON.stringify({ + payload, + roomId: liveRoomSession.roomId, + status: nextStatus, + winnerTeamName, + }) + + if (signature === lastSyncedRoomSignatureRef.current) { + return + } + + lastSyncedRoomSignatureRef.current = signature + + void updateLiveRoom(liveRoomSession.roomId, { + ...payload, + hostToken: liveRoomSession.hostToken, + status: nextStatus, + winnerTeamName, + }) + .then((room) => { + setLiveRoomSession((current) => + current + ? { + ...current, + status: room.status, + } + : current, + ) + }) + .catch((error) => { + console.error('update live room error:', error) + }) + }, [ + leftTeam, + liveRoomSession, + pointLog, + rightTeam, + scoreState, + selectedGroup?.id, + targetDate, + isScoreboardRoute, + ]) + + useEffect(() => { + if (!liveRoomSession || liveRoomSession.status !== 'live') { + return + } + + const { hostToken, roomId } = liveRoomSession + let released = false + + const release = () => { + if (released) { + return + } + + released = true + void releaseLiveRoom(roomId, hostToken).catch(() => {}) + } + + const handleBeforeUnload = () => { + if (released) { + return + } + + released = true + if (navigator.sendBeacon) { + const payload = new Blob([JSON.stringify({ hostToken })], { + type: 'application/json', + }) + navigator.sendBeacon(`/api/rooms/${roomId}/release`, payload) + return + } + + void fetch(`/api/rooms/${roomId}/release`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ hostToken }), + keepalive: true, + }).catch(() => {}) + } + + window.addEventListener('beforeunload', handleBeforeUnload) + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload) + if (!isScoreboardRoute) { + release() + } + } + }, [isScoreboardRoute, liveRoomSession]) + const loadGroupsFromDb = async () => { if (!targetDate) { setLoadStatus('error') @@ -607,6 +795,9 @@ function App() { (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/history"> 歷史戰績 + (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/rooms"> + 房間列表 + @@ -662,6 +853,7 @@ function App() { groupSource={groupSource} hasRecordedPoint={pointLog.length > 0} leftTeam={leftTeam} + liveRoomId={liveRoomId} rightTeam={rightTeam} scoreState={scoreState} selectedGroup={selectedGroup} @@ -682,6 +874,11 @@ function App() { } /> } /> + } /> + navigate('/rooms')} />} + /> {pwaUpdateReady ? ( @@ -802,4 +999,30 @@ function loadStoredHistory(storageKey: string) { } } +function buildLiveRoomPayload({ + groupId, + leftTeam, + pointLog, + rightTeam, + scoreState, + targetDate, +}: { + groupId: number | null + leftTeam: GroupTeam + pointLog: PointHistoryEntry[] + rightTeam: GroupTeam + scoreState: ScoreState + targetDate: string +}) { + return { + groupId, + leftTeamName: getTeamDisplayName(leftTeam), + matchupLabel: `${getTeamDisplayName(leftTeam)} vs ${getTeamDisplayName(rightTeam)}`, + pointLog, + rightTeamName: getTeamDisplayName(rightTeam), + scoreState, + targetDate, + } +} + export default App diff --git a/src/lib/api.ts b/src/lib/api.ts index 4873996..bac6a9b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -3,12 +3,17 @@ import type { HistoryRecord, HistoryUploadPayload, HistoryUploadResponse, + LiveRoomDetail, + LiveRoomPayload, + LiveRoomSession, + LiveRoomSummary, + LiveRoomUpdatePayload, MatchResultsRecord, } from '../types' export async function loadMatchResults(time: string) { const response = await fetch(`/api/match-results/${time}`) - const payload = (await response.json()) as { + const payload = (await readJsonSafely(response)) as { ok?: boolean message?: string data?: MatchResultsRecord @@ -34,7 +39,7 @@ export async function saveMatchHistory(payload: HistoryUploadPayload) { body: JSON.stringify(payload), }) - const result = (await response.json()) as { + const result = (await readJsonSafely(response)) as { ok?: boolean message?: string data?: HistoryUploadResponse @@ -49,7 +54,7 @@ export async function saveMatchHistory(payload: HistoryUploadPayload) { export async function loadHistoryList() { const response = await fetch('/api/history') - const payload = (await response.json()) as { + const payload = (await readJsonSafely(response)) as { ok?: boolean message?: string data?: HistoryRecord[] @@ -67,7 +72,7 @@ export async function deleteHistoryItem(id: number) { method: 'DELETE', }) - const payload = (await response.json()) as { + const payload = (await readJsonSafely(response)) as { ok?: boolean message?: string } @@ -77,6 +82,142 @@ export async function deleteHistoryItem(id: number) { } } +export async function createLiveRoom(payload: LiveRoomPayload) { + const response = await fetch('/api/rooms', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }) + + const result = (await readJsonSafely(response)) as { + ok?: boolean + message?: string + data?: LiveRoomSession + } + + if (!response.ok || !result.ok || !result.data) { + throw new Error(result.message ?? '建立觀戰房間失敗。') + } + + return result.data +} + +export async function updateLiveRoom(roomId: string, payload: LiveRoomUpdatePayload) { + const response = await fetch(`/api/rooms/${roomId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }) + + const result = (await readJsonSafely(response)) as { + ok?: boolean + message?: string + data?: LiveRoomDetail + } + + if (!response.ok || !result.ok || !result.data) { + throw new Error(result.message ?? '同步房間比分失敗。') + } + + return result.data +} + +export async function releaseLiveRoom(roomId: string, hostToken: string) { + const response = await fetch(`/api/rooms/${roomId}/release`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ hostToken }), + keepalive: true, + }) + + const result = (await readJsonSafely(response)) as { + ok?: boolean + message?: string + } + + if (!response.ok || !result.ok) { + throw new Error(result.message ?? '清除舊房間失敗。') + } +} + +export async function loadLiveRoomList() { + const response = await fetch('/api/rooms') + const result = (await readJsonSafely(response)) as { + ok?: boolean + message?: string + data?: LiveRoomSummary[] + } + + if (response.status === 404) { + throw new Error('後端還沒更新到房間功能,請重新部署最新版。') + } + + if (!response.ok || !result.ok) { + throw new Error(result.message ?? '載入房間列表失敗。') + } + + return result.data ?? [] +} + +export async function loadLiveRoom(roomId: string) { + const response = await fetch(`/api/rooms/${roomId}`) + const result = (await readJsonSafely(response)) as { + ok?: boolean + message?: string + data?: LiveRoomDetail + } + + if (response.status === 404) { + throw new Error('後端還沒更新到房間功能,或這個房間已不存在。') + } + + if (!response.ok || !result.ok || !result.data) { + throw new Error(result.message ?? '載入房間內容失敗。') + } + + return result.data +} + +export function subscribeRoomList(onMessage: (rooms: LiveRoomSummary[]) => void) { + const source = new EventSource('/api/rooms/stream') + + source.addEventListener('rooms', (event) => { + const payload = JSON.parse((event as MessageEvent).data) as LiveRoomSummary[] + onMessage(payload) + }) + + return () => { + source.close() + } +} + +export function subscribeLiveRoom( + roomId: string, + onMessage: (room: LiveRoomDetail) => void, + onError?: () => void, +) { + const source = new EventSource(`/api/rooms/${roomId}/stream`) + + source.addEventListener('room', (event) => { + const payload = JSON.parse((event as MessageEvent).data) as LiveRoomDetail + onMessage(payload) + }) + + source.onerror = () => { + onError?.() + } + + return () => { + source.close() + } +} + function normalizeHistoryRecord(record: HistoryRecord): HistoryListItem { const score = parseJson<[number, number]>(record.score, [0, 0]) const players = parseJson(record.players, []) @@ -124,3 +265,17 @@ function getDayLabel(dayOfWeek: number) { const labels = ['週日', '週一', '週二', '週三', '週四', '週五', '週六'] return labels[dayOfWeek] ?? '-' } + +async function readJsonSafely(response: Response) { + const raw = await response.text() + + if (!raw.trim()) { + return {} + } + + try { + return JSON.parse(raw) as unknown + } catch { + throw new Error('伺服器回傳內容不是有效的 JSON。') + } +} diff --git a/src/pages/RoomListPage.tsx b/src/pages/RoomListPage.tsx new file mode 100644 index 0000000..447a8a5 --- /dev/null +++ b/src/pages/RoomListPage.tsx @@ -0,0 +1,94 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { loadLiveRoomList, subscribeRoomList } from '../lib/api' +import type { LiveRoomSummary } from '../types' + +export function RoomListPage() { + const [error, setError] = useState('') + const [loading, setLoading] = useState(true) + const [rooms, setRooms] = useState([]) + + useEffect(() => { + let active = true + + const load = async () => { + try { + const nextRooms = await loadLiveRoomList() + + if (!active) { + return + } + + setRooms(nextRooms) + setError('') + } catch (loadError) { + if (!active) { + return + } + + setError(loadError instanceof Error ? loadError.message : '載入房間列表失敗。') + } finally { + if (active) { + setLoading(false) + } + } + } + + void load() + const unsubscribe = subscribeRoomList((nextRooms) => { + if (!active) { + return + } + + setRooms(nextRooms) + setError('') + setLoading(false) + }) + + return () => { + active = false + unsubscribe() + } + }, []) + + return ( +
+
+

Live Rooms

+

房間列表

+

可查看目前正在進行中的房間,點進去就能即時觀戰。

+
+ +
+ {loading ?

正在載入房間列表...

: null} + {!loading && error ?

{error}

: null} + {!loading && !error && rooms.length === 0 ? ( +

目前沒有正在進行中的房間。

+ ) : null} + + {!loading && !error && rooms.length > 0 ? ( +
+ {rooms.map((room) => ( + +
+ 房號 {room.roomId} + 目標 {room.targetScore} 分 +
+ +
+ {room.leftTeamName} + VS + {room.rightTeamName} +
+ +

+ 最後更新 {new Date(room.updatedAt).toLocaleTimeString('zh-TW', { hour12: false })} +

+ + ))} +
+ ) : null} +
+
+ ) +} diff --git a/src/pages/RoomSpectatorPage.tsx b/src/pages/RoomSpectatorPage.tsx new file mode 100644 index 0000000..09e9ca7 --- /dev/null +++ b/src/pages/RoomSpectatorPage.tsx @@ -0,0 +1,175 @@ +import { useEffect, useRef, useState } from 'react' +import { Link, useParams } from 'react-router-dom' +import { loadLiveRoom, subscribeLiveRoom } from '../lib/api' +import type { LiveRoomDetail } from '../types' + +type RoomSpectatorPageProps = { + onConfirmFinished: () => void +} + +const ROOM_POLL_MS = 1500 + +export function RoomSpectatorPage({ onConfirmFinished }: RoomSpectatorPageProps) { + const { roomId = '' } = useParams() + const [error, setError] = useState('') + const [loading, setLoading] = useState(true) + const [room, setRoom] = useState(null) + const [showFinishedDialog, setShowFinishedDialog] = useState(false) + const previousStatusRef = useRef(null) + const hasRoomRef = useRef(false) + + useEffect(() => { + if (!roomId) { + return + } + + let active = true + hasRoomRef.current = false + previousStatusRef.current = null + + const applyRoomUpdate = (nextRoom: LiveRoomDetail) => { + if (!active) { + return + } + + if (nextRoom.status === 'finished' && previousStatusRef.current !== 'finished') { + setShowFinishedDialog(true) + } + + previousStatusRef.current = nextRoom.status + hasRoomRef.current = true + setRoom(nextRoom) + setError('') + setLoading(false) + } + + const load = async (showLoadError = true) => { + try { + const nextRoom = await loadLiveRoom(roomId) + applyRoomUpdate(nextRoom) + } catch (loadError) { + if (!active) { + return + } + + if (showLoadError || !hasRoomRef.current) { + setError(loadError instanceof Error ? loadError.message : '載入觀戰房間失敗。') + setLoading(false) + } + } + } + + void load() + const unsubscribe = subscribeLiveRoom( + roomId, + (nextRoom) => { + applyRoomUpdate(nextRoom) + }, + () => { + if (!active) { + return + } + + if (!hasRoomRef.current) { + setError('觀戰連線中斷,請稍後重試。') + setLoading(false) + } + }, + ) + + const timer = window.setInterval(() => { + void load(false) + }, ROOM_POLL_MS) + + return () => { + active = false + window.clearInterval(timer) + unsubscribe() + } + }, [roomId]) + + if (loading) { + return ( +
+
+

正在載入觀戰房間...

+
+
+ ) + } + + if (!room || error) { + return ( +
+
+

Spectator

+

無法進入房間

+

{error || '這個房間不存在或已關閉。'}

+ + 返回房間列表 + +
+
+ ) + } + + return ( + <> +
+
+

Spectator

+

房號 {room.roomId}

+

這是觀戰模式,只會即時同步比分,不提供任何操作。

+
+ +
+
+
+ {room.leftTeamName} + {room.scoreState.scoreLeft} +
+
:
+
+ {room.rightTeamName} + {room.scoreState.scoreRight} +
+
+ +
+ 目標分數 {room.scoreState.targetScore} + 房間狀態 {room.status === 'finished' ? '已結束' : '進行中'} + + 最後更新 {new Date(room.updatedAt).toLocaleTimeString('zh-TW', { hour12: false })} + +
+
+
+ + {showFinishedDialog ? ( +
+
+

比賽結束

+

{room.winnerTeamName ?? '已有獲勝隊伍'}

+

+ {room.winnerTeamName + ? `${room.winnerTeamName} 已獲勝,本場觀戰會返回列表。` + : '比賽已結束,本場觀戰會返回列表。'} +

+
+ +
+
+
+ ) : null} + + ) +} diff --git a/src/pages/ScoreboardPage.tsx b/src/pages/ScoreboardPage.tsx index 8124445..ab4299a 100644 --- a/src/pages/ScoreboardPage.tsx +++ b/src/pages/ScoreboardPage.tsx @@ -41,6 +41,7 @@ type ScoreboardPageProps = { groupSource: 'idle' | 'db' | 'manual' hasRecordedPoint: boolean leftTeam: GroupTeam | null + liveRoomId: string | null rightTeam: GroupTeam | null scoreState: ScoreState selectedGroup: RoundGroup | null @@ -81,6 +82,7 @@ export function ScoreboardPage({ groupSource, hasRecordedPoint, leftTeam, + liveRoomId, rightTeam, scoreState, selectedGroup, @@ -547,6 +549,8 @@ export function ScoreboardPage({
{clock}
+ {liveRoomId ?
房號 {liveRoomId}
: null} +