新增即時觀戰房間並整理 README
This commit is contained in:
128
README.md
128
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
|
||||
|
||||
1
server/data/live-rooms.json
Normal file
1
server/data/live-rooms.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -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}\` (
|
||||
|
||||
126
src/App.css
126
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;
|
||||
}
|
||||
|
||||
227
src/App.tsx
227
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<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
|
||||
|
||||
163
src/lib/api.ts
163
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<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。')
|
||||
}
|
||||
}
|
||||
|
||||
94
src/pages/RoomListPage.tsx
Normal file
94
src/pages/RoomListPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
175
src/pages/RoomSpectatorPage.tsx
Normal file
175
src/pages/RoomSpectatorPage.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
44
src/types.ts
44
src/types.ts
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user