新增即時觀戰房間並整理 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 區名單產生分組。
|
- 若資料庫沒有當天資料,可手動輸入 A、B 區名單產生分組。
|
||||||
- 每組可直接進入記分板,不需額外再點選這組。
|
- 點選分組後可直接進入記分板。
|
||||||
- 計分板
|
- 記分板
|
||||||
- 設定隊伍彈窗支援逐一選人。
|
- 隊伍名稱只顯示在最上方與最下方。
|
||||||
- 依選取順序自動成隊:`1、2` 一隊,`3、4` 一隊。
|
- 可在設定隊伍面板中逐一選人,也可快速套用預設隊伍。
|
||||||
- 右側可快速選擇預設隊伍。
|
- 先選到的 `1、2` 為一隊,`3、4` 為另一隊。
|
||||||
- 可設定本場幾分獲勝,預設 `21` 分。
|
- 可設定本場幾分獲勝,預設為 `21` 分。
|
||||||
- 需先指定先攻,之後點擊分數即可直接加分。
|
- 必須先設定先攻,才能開始記分。
|
||||||
- 第一分記錄後,右側 `設定隊伍` 會切成 `上一步`。
|
- 點擊分數直接加分,不提供加一減一按鈕。
|
||||||
- 可交換上下隊伍位置,也可交換同隊左右球員位置。
|
- 第一分記下後,`設定隊伍` 會切換成 `上一步`。
|
||||||
- `比賽結算` 為防誤觸設計,需長按 `1 秒` 才會觸發。
|
- 可交換上下兩隊位置,也可交換同隊左右站位。
|
||||||
- 比分仍是 `0:0` 時,不會啟動比賽結算長按。
|
- `比賽結算` 需長按 `1 秒` 才會觸發。
|
||||||
- 長按 `比賽結算` 時,按鈕下方會顯示進度條,按鈕本身也會有亮起與下壓的視覺回饋。
|
- 比分 `0:0` 時不允許觸發結算。
|
||||||
- 手機長按 `比賽結算` 不會再觸發文字選取。
|
- 語音播報
|
||||||
- 連勝會出現特效提示:
|
- 可設定是否播報得分者。
|
||||||
- `3 連勝`:`大殺特殺`
|
- 可設定是否播報下一位發球者。
|
||||||
- `4 連勝`:`暴走`
|
- 可調整語速,最高支援到 `10x`。
|
||||||
- `5 連勝`:`無人能擋`
|
- `RURU` 會以大小寫不敏感方式播報成「嚕嚕」。
|
||||||
- `6 連勝`:`主宰比賽`
|
- 動畫提示
|
||||||
- `7 連勝`:`像神一般的`
|
- 先攻未設定時,`先攻` 文字會有提示動畫。
|
||||||
- `8 連勝`:`成為傳說`
|
- 選定先攻後,會顯示打勾讓使用者更容易辨識。
|
||||||
|
- 連勝特效:
|
||||||
|
- `3 連勝`:大殺特殺
|
||||||
|
- `4 連勝`:暴走
|
||||||
|
- `5 連勝`:無人能擋
|
||||||
|
- `6 連勝`:主宰比賽
|
||||||
|
- `7 連勝`:像神一般的
|
||||||
|
- `8 連勝`:成為傳說
|
||||||
- 達到目標分數時會顯示獲勝動畫。
|
- 達到目標分數時會顯示獲勝動畫。
|
||||||
- 內建免費瀏覽器 TTS。
|
|
||||||
- 可設定是否播報得分者、是否播報發球者、以及語速。
|
|
||||||
- `RURU` 已支援不分大小寫的發音別名,會念成 `嚕嚕`。
|
|
||||||
- 歷史戰績
|
- 歷史戰績
|
||||||
- 直接從資料庫 `history` 表讀取列表。
|
- 比賽結算後可選擇是否上傳戰績到資料庫。
|
||||||
- 點擊單筆戰績可開啟得分紀錄彈窗。
|
- 歷史戰績列表直接從資料庫 `history` 表讀取。
|
||||||
- 彈窗支援右上角 `X` 關閉按鈕。
|
- 可點開查看每球得分紀錄。
|
||||||
- 每筆資料可直接刪除,刪除前會跳一次確認提示。
|
- 手機上彈窗有 `X` 可快速關閉。
|
||||||
|
- 每筆戰績可刪除,刪除前會確認一次。
|
||||||
|
- 即時房間 / 觀戰
|
||||||
|
- 只要帶入隊伍進入記分板,就會自動建立一個房間。
|
||||||
|
- 記分板右側會顯示房號。
|
||||||
|
- `房間列表` 只顯示房號、隊伍、目標分數與最後更新時間,不顯示比分。
|
||||||
|
- 觀戰者進入房間後可即時看到比分,不能操作。
|
||||||
|
- 觀戰同步使用 `SSE + 輪詢備援`,降低漏分風險。
|
||||||
|
- 房主重整、離開記分板或換隊伍時,未結束房間會自動清掉。
|
||||||
|
- 達到目標分數後房間會標記結束,觀戰者會看到獲勝彈窗,按確定後返回房間列表。
|
||||||
- PWA
|
- PWA
|
||||||
- 可加入手機主畫面,像 App 一樣開啟。
|
- 可加入手機主畫面,像 App 一樣開啟。
|
||||||
- 支援 `manifest`、`service worker`、主畫面 icon。
|
- 支援自訂網站 icon / PWA icon。
|
||||||
- 網頁 favicon 與 PWA icon 已改用 `ICON.png` 產生的 PNG 圖示。
|
- 新版本部署後會顯示更新提示,可直接重新整理套用新版。
|
||||||
- 偵測到新版本時,畫面底部會顯示更新提示,可一鍵重新整理套用新版。
|
|
||||||
- 前端會定期輪詢 `/api/version`,只要重新部署並重建 app container,就能偵測到新版本。
|
|
||||||
|
|
||||||
## 執行環境
|
## 開發環境
|
||||||
|
|
||||||
### Port
|
### Port
|
||||||
|
|
||||||
@@ -56,18 +67,18 @@
|
|||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### 開發模式
|
### 啟動開發模式
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
啟動後會同時開兩個服務:
|
啟動後:
|
||||||
|
|
||||||
- 前端:`http://localhost:3501`
|
- 前端:`http://localhost:3501`
|
||||||
- API:`http://localhost:8788`
|
- API:`http://localhost:8788`
|
||||||
|
|
||||||
### 檢查
|
### 建置與檢查
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run lint
|
npm run lint
|
||||||
@@ -91,24 +102,24 @@ PORT=8788
|
|||||||
|
|
||||||
## Docker / NAS 部署
|
## Docker / NAS 部署
|
||||||
|
|
||||||
正式部署時目前是雙容器架構:
|
正式部署時:
|
||||||
|
|
||||||
- App 內部服務:`8788`
|
- App 內部服務 port:`8788`
|
||||||
- Nginx SSL 對外入口:`3501`
|
- 對外 HTTPS 入口:`3501`
|
||||||
|
|
||||||
啟動指令:
|
部署指令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo docker compose up -d --build
|
sudo docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
部署完成後,對外入口:
|
部署完成後,對外入口為:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://你的網域或 NAS IP:3501
|
https://你的網域或 NAS IP:3501
|
||||||
```
|
```
|
||||||
|
|
||||||
每次執行 `sudo docker compose up -d --build` 重建 app container 時,後端都會帶新的容器啟動版號;前端輪詢發現版號不同後,就會跳出更新提示。
|
每次執行 `sudo docker compose up -d --build`,容器都會更新啟動版號,已安裝 PWA 的裝置會在偵測到新版本後顯示更新提示。
|
||||||
|
|
||||||
## SSL 憑證目錄
|
## SSL 憑證目錄
|
||||||
|
|
||||||
@@ -118,17 +129,17 @@ Docker Compose 會直接掛載 NAS 上的憑證目錄:
|
|||||||
/volume1/homes/JianMiau/www/certificate/
|
/volume1/homes/JianMiau/www/certificate/
|
||||||
```
|
```
|
||||||
|
|
||||||
目前預設使用這三個檔名:
|
預設使用以下檔案:
|
||||||
|
|
||||||
- `RSA-cert.pem`
|
- `RSA-cert.pem`
|
||||||
- `RSA-chain.pem`
|
- `RSA-chain.pem`
|
||||||
- `RSA-privkey.pem`
|
- `RSA-privkey.pem`
|
||||||
|
|
||||||
更新憑證時,只要更新上述目錄內的檔案,再重新啟動容器即可。
|
之後只要更新這個資料夾內的憑證檔即可,不需要重建 image。
|
||||||
|
|
||||||
## history 資料表格式
|
## 資料表格式
|
||||||
|
|
||||||
`history` 表目前使用以下欄位:
|
### `history`
|
||||||
|
|
||||||
- `id`
|
- `id`
|
||||||
- `time`
|
- `time`
|
||||||
@@ -139,16 +150,25 @@ Docker Compose 會直接掛載 NAS 上的憑證目錄:
|
|||||||
- `0`:雙打
|
- `0`:雙打
|
||||||
- `1`:單打
|
- `1`:單打
|
||||||
- `players`
|
- `players`
|
||||||
- 依 `1 ~ 4` 順序排序的玩家名稱
|
- 依 `1 ~ 4` 編號排序的玩家陣列
|
||||||
- `team`
|
- `team`
|
||||||
- `1、2` 一隊
|
- `1、2` 為一隊
|
||||||
- `3、4` 一隊
|
- `3、4` 為一隊
|
||||||
- `scoreList`
|
- `scoreList`
|
||||||
- 格式:`[round, 發球者編號, 連勝次數, 得分隊伍(0 或 1)]`
|
- 格式:`[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
|
```bash
|
||||||
git config i18n.commitEncoding utf-8
|
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 express from 'express'
|
||||||
import mysql from 'mysql2/promise'
|
import mysql from 'mysql2/promise'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { existsSync } from 'node:fs'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
@@ -16,6 +16,8 @@ const currentFilePath = fileURLToPath(import.meta.url)
|
|||||||
const currentDir = path.dirname(currentFilePath)
|
const currentDir = path.dirname(currentFilePath)
|
||||||
const projectRoot = path.resolve(currentDir, '..')
|
const projectRoot = path.resolve(currentDir, '..')
|
||||||
const distDir = path.join(projectRoot, 'dist')
|
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 distReady = existsSync(path.join(distDir, 'index.html'))
|
||||||
|
|
||||||
const requiredEnv = ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_DATABASE']
|
const requiredEnv = ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_DATABASE']
|
||||||
@@ -35,6 +37,9 @@ const pool =
|
|||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
const rooms = loadPersistedRooms()
|
||||||
|
const roomListClients = new Set()
|
||||||
|
|
||||||
app.use(express.json())
|
app.use(express.json())
|
||||||
|
|
||||||
app.get('/api/health', (_request, response) => {
|
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) => {
|
app.get('/api/match-results/:time', async (request, response) => {
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
response.status(500).json({
|
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) {
|
async function ensureMatchTable(poolInstance, currentTableName) {
|
||||||
await poolInstance.execute(`
|
await poolInstance.execute(`
|
||||||
CREATE TABLE IF NOT EXISTS \`${currentTableName}\` (
|
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));
|
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 {
|
.rail-pill {
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -1964,6 +1973,11 @@
|
|||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rail-room-id {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
.rail-pill {
|
.rail-pill {
|
||||||
padding: 10px 8px;
|
padding: 10px 8px;
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
@@ -2030,6 +2044,16 @@
|
|||||||
width: 100%;
|
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 {
|
.team-picker-ribbon {
|
||||||
left: 18px;
|
left: 18px;
|
||||||
right: 90px;
|
right: 90px;
|
||||||
@@ -2146,3 +2170,105 @@
|
|||||||
font-size: 0.92rem;
|
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 { 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 './App.css'
|
||||||
import { loadMatchResults, saveMatchHistory } from './lib/api'
|
import {
|
||||||
|
createLiveRoom,
|
||||||
|
loadMatchResults,
|
||||||
|
releaseLiveRoom,
|
||||||
|
saveMatchHistory,
|
||||||
|
updateLiveRoom,
|
||||||
|
} from './lib/api'
|
||||||
import {
|
import {
|
||||||
buildManualGroups,
|
buildManualGroups,
|
||||||
convertDateToKey,
|
convertDateToKey,
|
||||||
@@ -14,12 +20,15 @@ import {
|
|||||||
swapCourtPositions,
|
swapCourtPositions,
|
||||||
} from './lib/match'
|
} from './lib/match'
|
||||||
import { HistoryPage } from './pages/HistoryPage'
|
import { HistoryPage } from './pages/HistoryPage'
|
||||||
|
import { RoomListPage } from './pages/RoomListPage'
|
||||||
|
import { RoomSpectatorPage } from './pages/RoomSpectatorPage'
|
||||||
import { ScoreboardPage } from './pages/ScoreboardPage'
|
import { ScoreboardPage } from './pages/ScoreboardPage'
|
||||||
import { TeamSelectionPage } from './pages/TeamSelectionPage'
|
import { TeamSelectionPage } from './pages/TeamSelectionPage'
|
||||||
import type {
|
import type {
|
||||||
ActiveMatchup,
|
ActiveMatchup,
|
||||||
GroupTeam,
|
GroupTeam,
|
||||||
HistoryUploadPayload,
|
HistoryUploadPayload,
|
||||||
|
LiveRoomSession,
|
||||||
LoadStatus,
|
LoadStatus,
|
||||||
MatchHistoryItem,
|
MatchHistoryItem,
|
||||||
PointHistoryEntry,
|
PointHistoryEntry,
|
||||||
@@ -84,6 +93,7 @@ const APP_VERSION_POLL_MS = 30000
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
const isScoreboardRoute = location.pathname === '/scoreboard'
|
const isScoreboardRoute = location.pathname === '/scoreboard'
|
||||||
|
|
||||||
const [targetDate, setTargetDate] = useState(() =>
|
const [targetDate, setTargetDate] = useState(() =>
|
||||||
@@ -118,13 +128,17 @@ function App() {
|
|||||||
const [streakAnnouncement, setStreakAnnouncement] = useState<StreakAnnouncement | null>(null)
|
const [streakAnnouncement, setStreakAnnouncement] = useState<StreakAnnouncement | null>(null)
|
||||||
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
|
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
|
||||||
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
|
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
|
||||||
|
const [liveRoomSession, setLiveRoomSession] = useState<LiveRoomSession | null>(null)
|
||||||
const currentAppVersionRef = useRef<string | null>(null)
|
const currentAppVersionRef = useRef<string | null>(null)
|
||||||
|
const creatingRoomRef = useRef(false)
|
||||||
|
const lastSyncedRoomSignatureRef = useRef('')
|
||||||
|
|
||||||
const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput])
|
const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput])
|
||||||
const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput])
|
const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput])
|
||||||
const selectedGroup = groups.find((group) => group.id === selectedGroupId) ?? null
|
const selectedGroup = groups.find((group) => group.id === selectedGroupId) ?? null
|
||||||
const leftTeam = activeMatchup.leftTeam
|
const leftTeam = activeMatchup.leftTeam
|
||||||
const rightTeam = activeMatchup.rightTeam
|
const rightTeam = activeMatchup.rightTeam
|
||||||
|
const liveRoomId = liveRoomSession?.roomId ?? null
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.localStorage.setItem(STORAGE_KEYS.targetDate, targetDate)
|
window.localStorage.setItem(STORAGE_KEYS.targetDate, targetDate)
|
||||||
@@ -242,6 +256,10 @@ function App() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const resetScoring = (nextState: ScoreState = initialScoreState) => {
|
const resetScoring = (nextState: ScoreState = initialScoreState) => {
|
||||||
|
if (liveRoomSession?.status === 'live') {
|
||||||
|
void releaseLiveRoom(liveRoomSession.roomId, liveRoomSession.hostToken).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
setScoreState(nextState)
|
setScoreState(nextState)
|
||||||
setScoreHistory([])
|
setScoreHistory([])
|
||||||
setPointLog([])
|
setPointLog([])
|
||||||
@@ -252,6 +270,9 @@ function App() {
|
|||||||
open: false,
|
open: false,
|
||||||
uploading: false,
|
uploading: false,
|
||||||
})
|
})
|
||||||
|
creatingRoomRef.current = false
|
||||||
|
setLiveRoomSession(null)
|
||||||
|
lastSyncedRoomSignatureRef.current = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectGroup = (groupId: number, nextGroups = groups) => {
|
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 () => {
|
const loadGroupsFromDb = async () => {
|
||||||
if (!targetDate) {
|
if (!targetDate) {
|
||||||
setLoadStatus('error')
|
setLoadStatus('error')
|
||||||
@@ -607,6 +795,9 @@ function App() {
|
|||||||
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/history">
|
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/history">
|
||||||
歷史戰績
|
歷史戰績
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/rooms">
|
||||||
|
房間列表
|
||||||
|
</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -662,6 +853,7 @@ function App() {
|
|||||||
groupSource={groupSource}
|
groupSource={groupSource}
|
||||||
hasRecordedPoint={pointLog.length > 0}
|
hasRecordedPoint={pointLog.length > 0}
|
||||||
leftTeam={leftTeam}
|
leftTeam={leftTeam}
|
||||||
|
liveRoomId={liveRoomId}
|
||||||
rightTeam={rightTeam}
|
rightTeam={rightTeam}
|
||||||
scoreState={scoreState}
|
scoreState={scoreState}
|
||||||
selectedGroup={selectedGroup}
|
selectedGroup={selectedGroup}
|
||||||
@@ -682,6 +874,11 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/history" element={<HistoryPage />} />
|
<Route path="/history" element={<HistoryPage />} />
|
||||||
|
<Route path="/rooms" element={<RoomListPage />} />
|
||||||
|
<Route
|
||||||
|
path="/rooms/:roomId"
|
||||||
|
element={<RoomSpectatorPage onConfirmFinished={() => navigate('/rooms')} />}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
||||||
{pwaUpdateReady ? (
|
{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
|
export default App
|
||||||
|
|||||||
163
src/lib/api.ts
163
src/lib/api.ts
@@ -3,12 +3,17 @@ import type {
|
|||||||
HistoryRecord,
|
HistoryRecord,
|
||||||
HistoryUploadPayload,
|
HistoryUploadPayload,
|
||||||
HistoryUploadResponse,
|
HistoryUploadResponse,
|
||||||
|
LiveRoomDetail,
|
||||||
|
LiveRoomPayload,
|
||||||
|
LiveRoomSession,
|
||||||
|
LiveRoomSummary,
|
||||||
|
LiveRoomUpdatePayload,
|
||||||
MatchResultsRecord,
|
MatchResultsRecord,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
|
||||||
export async function loadMatchResults(time: string) {
|
export async function loadMatchResults(time: string) {
|
||||||
const response = await fetch(`/api/match-results/${time}`)
|
const response = await fetch(`/api/match-results/${time}`)
|
||||||
const payload = (await response.json()) as {
|
const payload = (await readJsonSafely(response)) as {
|
||||||
ok?: boolean
|
ok?: boolean
|
||||||
message?: string
|
message?: string
|
||||||
data?: MatchResultsRecord
|
data?: MatchResultsRecord
|
||||||
@@ -34,7 +39,7 @@ export async function saveMatchHistory(payload: HistoryUploadPayload) {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = (await response.json()) as {
|
const result = (await readJsonSafely(response)) as {
|
||||||
ok?: boolean
|
ok?: boolean
|
||||||
message?: string
|
message?: string
|
||||||
data?: HistoryUploadResponse
|
data?: HistoryUploadResponse
|
||||||
@@ -49,7 +54,7 @@ export async function saveMatchHistory(payload: HistoryUploadPayload) {
|
|||||||
|
|
||||||
export async function loadHistoryList() {
|
export async function loadHistoryList() {
|
||||||
const response = await fetch('/api/history')
|
const response = await fetch('/api/history')
|
||||||
const payload = (await response.json()) as {
|
const payload = (await readJsonSafely(response)) as {
|
||||||
ok?: boolean
|
ok?: boolean
|
||||||
message?: string
|
message?: string
|
||||||
data?: HistoryRecord[]
|
data?: HistoryRecord[]
|
||||||
@@ -67,7 +72,7 @@ export async function deleteHistoryItem(id: number) {
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
|
|
||||||
const payload = (await response.json()) as {
|
const payload = (await readJsonSafely(response)) as {
|
||||||
ok?: boolean
|
ok?: boolean
|
||||||
message?: string
|
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 {
|
function normalizeHistoryRecord(record: HistoryRecord): HistoryListItem {
|
||||||
const score = parseJson<[number, number]>(record.score, [0, 0])
|
const score = parseJson<[number, number]>(record.score, [0, 0])
|
||||||
const players = parseJson<string[]>(record.players, [])
|
const players = parseJson<string[]>(record.players, [])
|
||||||
@@ -124,3 +265,17 @@ function getDayLabel(dayOfWeek: number) {
|
|||||||
const labels = ['週日', '週一', '週二', '週三', '週四', '週五', '週六']
|
const labels = ['週日', '週一', '週二', '週三', '週四', '週五', '週六']
|
||||||
return labels[dayOfWeek] ?? '-'
|
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'
|
groupSource: 'idle' | 'db' | 'manual'
|
||||||
hasRecordedPoint: boolean
|
hasRecordedPoint: boolean
|
||||||
leftTeam: GroupTeam | null
|
leftTeam: GroupTeam | null
|
||||||
|
liveRoomId: string | null
|
||||||
rightTeam: GroupTeam | null
|
rightTeam: GroupTeam | null
|
||||||
scoreState: ScoreState
|
scoreState: ScoreState
|
||||||
selectedGroup: RoundGroup | null
|
selectedGroup: RoundGroup | null
|
||||||
@@ -81,6 +82,7 @@ export function ScoreboardPage({
|
|||||||
groupSource,
|
groupSource,
|
||||||
hasRecordedPoint,
|
hasRecordedPoint,
|
||||||
leftTeam,
|
leftTeam,
|
||||||
|
liveRoomId,
|
||||||
rightTeam,
|
rightTeam,
|
||||||
scoreState,
|
scoreState,
|
||||||
selectedGroup,
|
selectedGroup,
|
||||||
@@ -547,6 +549,8 @@ export function ScoreboardPage({
|
|||||||
|
|
||||||
<div className="rail-clock">{clock}</div>
|
<div className="rail-clock">{clock}</div>
|
||||||
|
|
||||||
|
{liveRoomId ? <div className="rail-room-id">房號 {liveRoomId}</div> : null}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
finishHoldActive ? 'rail-pill-hold-wrap rail-pill-hold-wrap-active' : 'rail-pill-hold-wrap'
|
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
|
rightTeamName: string
|
||||||
winnerTeamName: 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