新增即時觀戰房間並整理 README

This commit is contained in:
2026-04-19 12:46:59 +08:00
parent c097ceb9ad
commit 896c24547b
10 changed files with 1283 additions and 61 deletions

128
README.md
View File

@@ -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

View File

@@ -0,0 +1 @@
[]

View File

@@ -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}\` (

View File

@@ -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;
}

View File

@@ -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<StreakAnnouncement | null>(null)
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
const [liveRoomSession, setLiveRoomSession] = useState<LiveRoomSession | null>(null)
const currentAppVersionRef = useRef<string | null>(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() {
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/history">
</NavLink>
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/rooms">
</NavLink>
</nav>
</header>
@@ -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() {
}
/>
<Route path="/history" element={<HistoryPage />} />
<Route path="/rooms" element={<RoomListPage />} />
<Route
path="/rooms/:roomId"
element={<RoomSpectatorPage onConfirmFinished={() => navigate('/rooms')} />}
/>
</Routes>
{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

View File

@@ -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<string[]>(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。')
}
}

View File

@@ -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<LiveRoomSummary[]>([])
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 (
<section className="page-grid">
<article className="panel panel-hero full-span">
<p className="panel-kicker">Live Rooms</p>
<h2></h2>
<p className="panel-copy"></p>
</article>
<article className="panel full-span">
{loading ? <p>...</p> : null}
{!loading && error ? <p className="history-empty">{error}</p> : null}
{!loading && !error && rooms.length === 0 ? (
<p className="history-empty"></p>
) : null}
{!loading && !error && rooms.length > 0 ? (
<div className="room-list-grid">
{rooms.map((room) => (
<Link className="room-card" key={room.roomId} to={`/rooms/${room.roomId}`}>
<div className="room-card-head">
<strong> {room.roomId}</strong>
<span> {room.targetScore} </span>
</div>
<div className="room-card-matchup">
<strong>{room.leftTeamName}</strong>
<span>VS</span>
<strong>{room.rightTeamName}</strong>
</div>
<p className="room-card-updated">
{new Date(room.updatedAt).toLocaleTimeString('zh-TW', { hour12: false })}
</p>
</Link>
))}
</div>
) : null}
</article>
</section>
)
}

View File

@@ -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<LiveRoomDetail | null>(null)
const [showFinishedDialog, setShowFinishedDialog] = useState(false)
const previousStatusRef = useRef<string | null>(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 (
<section className="page-grid">
<article className="panel full-span">
<p>...</p>
</article>
</section>
)
}
if (!room || error) {
return (
<section className="page-grid">
<article className="panel panel-hero full-span">
<p className="panel-kicker">Spectator</p>
<h2></h2>
<p className="panel-copy">{error || '這個房間不存在或已關閉。'}</p>
<Link className="primary-button inline-link" to="/rooms">
</Link>
</article>
</section>
)
}
return (
<>
<section className="page-grid">
<article className="panel panel-hero full-span">
<p className="panel-kicker">Spectator</p>
<h2> {room.roomId}</h2>
<p className="panel-copy"></p>
</article>
<article className="panel full-span room-watch-panel">
<div className="room-watch-scoreboard">
<div className="room-watch-team">
<small>{room.leftTeamName}</small>
<strong>{room.scoreState.scoreLeft}</strong>
</div>
<div className="room-watch-divider">:</div>
<div className="room-watch-team">
<small>{room.rightTeamName}</small>
<strong>{room.scoreState.scoreRight}</strong>
</div>
</div>
<div className="room-watch-meta">
<span> {room.scoreState.targetScore}</span>
<span> {room.status === 'finished' ? '已結束' : '進行中'}</span>
<span>
{new Date(room.updatedAt).toLocaleTimeString('zh-TW', { hour12: false })}
</span>
</div>
</article>
</section>
{showFinishedDialog ? (
<div className="finish-dialog-overlay" role="presentation">
<div aria-modal="true" className="finish-dialog" role="dialog">
<p className="panel-kicker"></p>
<h3>{room.winnerTeamName ?? '已有獲勝隊伍'}</h3>
<p className="finish-dialog-copy">
{room.winnerTeamName
? `${room.winnerTeamName} 已獲勝,本場觀戰會返回列表。`
: '比賽已結束,本場觀戰會返回列表。'}
</p>
<div className="finish-dialog-actions">
<button
className="team-picker-confirm"
type="button"
onClick={() => {
setShowFinishedDialog(false)
onConfirmFinished()
}}
>
</button>
</div>
</div>
</div>
) : null}
</>
)
}

View File

@@ -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({
<div className="rail-clock">{clock}</div>
{liveRoomId ? <div className="rail-room-id"> {liveRoomId}</div> : null}
<div
className={
finishHoldActive ? 'rail-pill-hold-wrap rail-pill-hold-wrap-active' : 'rail-pill-hold-wrap'

View File

@@ -116,3 +116,47 @@ export type HistoryListItem = {
rightTeamName: string
winnerTeamName: string
}
export type LiveRoomStatus = 'live' | 'finished'
export type LiveRoomSession = {
hostToken: string
roomId: string
status: LiveRoomStatus
}
export type LiveRoomSummary = {
roomId: string
createdAt: string
leftTeamName: string
rightTeamName: string
scoreLeft: number
scoreRight: number
status: LiveRoomStatus
targetScore: number
updatedAt: string
}
export type LiveRoomPayload = {
groupId: number | null
leftTeamName: string
matchupLabel: string
pointLog: PointHistoryEntry[]
rightTeamName: string
scoreState: ScoreState
targetDate: string
}
export type LiveRoomDetail = LiveRoomPayload & {
createdAt: string
roomId: string
status: LiveRoomStatus
updatedAt: string
winnerTeamName: string | null
}
export type LiveRoomUpdatePayload = LiveRoomPayload & {
hostToken: string
status: LiveRoomStatus
winnerTeamName: string | null
}