2 Commits

Author SHA1 Message Date
f3e51ea83d 調整發球鏡像規則並更新說明文件 2026-04-28 08:52:47 +08:00
edab74f125 強化房間清理與比賽中分頁限制 2026-04-19 18:05:33 +08:00
12 changed files with 484 additions and 362 deletions

122
README.md
View File

@@ -1,58 +1,56 @@
# 羽毛球記分板
這是一個使用 `Vite + React + TypeScript + Node.js` 製作的羽毛球記分板支援手機操作、PWA 安裝、即時觀戰房間、歷史戰績與 Docker / NAS 部署。
這是一個使用 `Vite + React + TypeScript + Node.js` 製作的羽毛球記分板專案提供手機優先的記分介面、歷史戰績、房間觀戰、語音播報、PWA 安裝,以及 Docker / NAS 部署方式
## 功能總覽
## 功能特色
- 選隊伍
- 選隊伍頁面
- 可依指定日期從資料庫讀取分組資料。
-指定日期沒有資料,可手動輸入 A、B 區名單建立分組
-選分組後可直接進入記分板
-當天沒有資料,可手動輸入 A、B 區名單建立配對
-進記分板時會直接帶入該組對戰
- 記分板
- 隊伍名稱只顯示在最上方與最下方
- 可在設定隊伍面板中逐一選人,也可快速選擇預設隊伍
-選到的 `1、2` 為一隊,`3、4` 為一隊
- 可設定獲勝分數,預設為 `21`
- 必須先選先攻,才能開始記分
- 點擊隊伍分數直接加分,不提供加一減一按鈕
- 第一分記下後,`設定隊伍` 會切換成 `上一步`
- 可交換上下兩隊位置,也可交換同隊左右站位。
- `比賽結算` 需要長按 `1 秒` 才會觸發
- 比分 `0:0` 時不可結算
- 全站文字預設不可選取,避免手機誤觸反白
- 兩隊隊員可自由交換上下、左右位置
- 畫面編號固定為左上 `1`、右上 `2`、右下 `3`、左下 `4`
-攻只能在開局設定一次,之後不會跟著發球權改變
- 點擊分數直接加分,沒有加一減一按鈕
- 第一分開始後,`設定隊伍` 會改成 `上一步`
- `比賽結算` 需要長按 `1` 秒才會觸發,避免誤觸
- 達標分數後有獲勝動畫與結算流程
- 羽球規則
- 預設 `21` 分制,可在設定隊伍時調整目標分數
- 支援 Deuce`20:20` 後需領先 `2` 分才獲勝
- `29:29` 時第 `30` 分直接獲勝
- 發球方依羽球規則處理,`0` 分在右發球區。
- 畫面以下方隊伍為我方、上方隊伍為對方。
- 上方隊伍採鏡像顯示,所以我方 `0:0` 在右邊發球時,對方會在左邊接發。
- 語音播報
- 可設定是否播報得分者
-設定是否播報發球者。
- 語速最高可調到 `10x`
- `RURU` 以大小寫不敏感方式播報成「嚕嚕」
- 動畫與提示
- 未選先攻時,`先攻` 文字會有提示動畫。
- 選定先攻後會顯示打勾。
- 支援連勝稱號動畫與獲勝動畫。
- 只在按下加分當下播報,不會因復原或其他操作重複報分
-選擇是否播報得分與發球者。
- 同隊連續得分才會播報 `換邊發球`
- 支援調整語速,最高可到 `10x`
- `RURU` 會做大小寫無關判斷並以指定發音播報。
- 歷史戰績
-將比賽結果上傳到資料庫 `history`
- 歷史列表直接從 DB 顯示
- 可查看逐球得分紀錄
- 每筆紀錄可刪除,刪除前會確認一次。
- 即時房間 / 觀戰
- 帶入隊伍進入記分板後會自動建立房間
- 記分板會顯示房號
- 房間列表不顯示比分,只顯示房號、隊伍、目標分數與更新時間
- 觀戰者只能看,不能操作
- 觀戰同步使用 `SSE + 輪詢備援`
- 房主重整、離開記分板或換隊伍時,未完成房間會自動清掉。
- 達標獲勝時,觀戰者會收到獲勝通知。
- 手動按 `比賽結算` 完成後,觀戰者也會收到獲勝結果,再返回房間列表。
-從資料庫讀取歷史列表
- 點開單筆可查看得分過程
- 每筆資料可刪除,刪除前會顯示確認提示
- 房間觀戰
- 記分板帶入隊伍後會自動建立房間。
- 房間列表可查看目前直播中的比賽
- 觀戰者只能看,不能操作記分。
- 分數、房間狀態、比賽結算會即時同步給觀戰者
- 房間失效、重整、重選隊伍後也會通知觀戰者。
- 房間列表有 `重新取得列表`,並帶有 `5` 秒冷卻
- PWA
-加入手機主畫面,像 App 一樣開啟
- 支援主畫面 icon 與版本更新提示
-安裝到 iPhone / iPad / Android 主畫面
- 支援 Web App 模式啟動
- 新版本部署後會提示重新整理或重新安裝。
## 開發
## 本機開發
### Port
- Client`3501`
- Server`8788`
- Client: `3501`
- Server: `8788`
### 安裝
@@ -71,7 +69,7 @@ npm run dev
- 前端:`http://localhost:3501`
- API`http://localhost:8788`
### 建置與檢查
### 檢查
```bash
npm run lint
@@ -80,7 +78,7 @@ npm run build
## 環境變數
在專案根目錄建立 `.env`
建立 `.env`
```env
DB_HOST=127.0.0.1
@@ -95,10 +93,10 @@ PORT=8788
## Docker / NAS 部署
正式部署時
對外服務配置
- App 內部服務`8788`
- 對外 HTTPS 入口`3501`
- 容器內 Node / API`8788`
- 對外 HTTPS 網址`3501`
部署指令:
@@ -106,29 +104,29 @@ PORT=8788
sudo docker compose up -d --build
```
部署完成後對外入口為
部署完成後可用
```text
https://你的網域或 NAS IP:3501
```
每次執行 `sudo docker compose up -d --build`,容器都會刷新版本號,已安裝的 PWA 會在偵測到新版本後跳出更新提示
每次執行 `sudo docker compose up -d --build` 都會重新建置前後端與 PWA 靜態資產
## SSL 憑證目錄
## SSL 憑證
Docker Compose 會直接掛載:
Docker Compose 會掛載以下目錄
```text
/volume1/homes/JianMiau/www/certificate/
```
預設使用的檔案
需包含
- `RSA-cert.pem`
- `RSA-chain.pem`
- `RSA-privkey.pem`
之後只要更新這個資料夾內的憑證檔即可,不需要重建 image
之後只要更新這個目錄內的憑證檔案,再重新部署容器即可套用新 SSL
## 資料表格式
@@ -140,17 +138,17 @@ Docker Compose 會直接掛載:
- `score`
- `winScore`
- `type`
- `0`雙打
- `1`單打
- `0`: 雙打
- `1`: 單打
- `players`
-`1 ~ 4` 編號排序的玩家陣列
- `1 ~ 4` 固定編號順序儲存玩家名單。
- `team`
- `12` 一隊
- `34` 一隊
- `1``2` 一隊
- `3``4` 一隊
- `scoreList`
- 格式:`[round, 發球者編號, 連勝數, 得分隊伍(0 或 1)]`
- 格式:`[round, 發球者編號, 連勝數, 得分隊伍(0 或 1)]`
## PWA 圖示
## PWA Icon
目前使用:
@@ -159,9 +157,9 @@ Docker Compose 會直接掛載:
- `public/pwa-192.png`
- `public/pwa-512.png`
## Git 中文設定
## Git 中文顯示
建議設定 git 使用 UTF-8
若要讓 git log / commit 顯示中文,建議設定
```bash
git config i18n.commitEncoding utf-8

View File

@@ -1,7 +1,5 @@
const CACHE_NAME = 'badminton-scoreboard-v1'
const CACHE_NAME = 'badminton-scoreboard-v2'
const APP_SHELL = [
'/',
'/index.html',
'/manifest.webmanifest',
'/favicon.png',
'/icon.png',
@@ -51,19 +49,45 @@ self.addEventListener('fetch', (event) => {
return
}
if (requestUrl.pathname.startsWith('/api/')) {
event.respondWith(fetch(event.request))
return
}
const isNavigationRequest =
event.request.mode === 'navigate' || event.request.destination === 'document'
if (isNavigationRequest) {
event.respondWith(
fetch(event.request)
.then(async (networkResponse) => {
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME)
cache.put('/index.html', networkResponse.clone())
}
return networkResponse
})
.catch(async () => {
const fallback = await caches.match('/index.html')
if (fallback) {
return fallback
}
throw new Error('Navigation request failed')
}),
)
return
}
event.respondWith(
caches.match(event.request).then(async (cachedResponse) => {
if (cachedResponse) {
return cachedResponse
}
try {
const networkResponse = await fetch(event.request)
if (
networkResponse.ok &&
(event.request.destination === 'document' ||
event.request.destination === 'script' ||
(event.request.destination === 'script' ||
event.request.destination === 'style' ||
event.request.destination === 'image' ||
requestUrl.pathname.startsWith('/assets/'))
@@ -74,11 +98,8 @@ self.addEventListener('fetch', (event) => {
return networkResponse
} catch (error) {
if (event.request.mode === 'navigate') {
const fallback = await caches.match('/index.html')
if (fallback) {
return fallback
}
if (cachedResponse) {
return cachedResponse
}
throw error

View File

@@ -1,197 +1 @@
[
{
"createdAt": "2026-04-19T04:50:38.216Z",
"groupId": 1,
"hostToken": "mo5afjewicxz50b9",
"leftTeamName": "柏威 / 玟瑄",
"matchupLabel": "柏威 / 玟瑄 vs 小念 / 建喵",
"pointLog": [
{
"round": 0,
"starter": 3,
"winCount": 0,
"winner": 1
},
{
"round": 1,
"starter": 3,
"winCount": 0,
"winner": 0
},
{
"round": 2,
"starter": 1,
"winCount": 1,
"winner": 0
},
{
"round": 3,
"starter": 1,
"winCount": 0,
"winner": 1
},
{
"round": 4,
"starter": 2,
"winCount": 1,
"winner": 1
}
],
"rightTeamName": "小念 / 建喵",
"roomId": "341793",
"scoreState": {
"scoreLeft": 2,
"scoreRight": 3,
"gamesLeft": 0,
"gamesRight": 0,
"currentGame": 1,
"targetScore": 21,
"serving": "right",
"leftRightCourtPlayer": "playerB",
"rightRightCourtPlayer": "playerA"
},
"status": "finished",
"targetDate": "2026-04-13",
"updatedAt": "2026-04-19T04:50:56.351Z",
"winnerTeamName": "小念 / 建喵"
},
{
"createdAt": "2026-04-19T04:51:17.794Z",
"groupId": 2,
"hostToken": "mo5agdyag7fyqyxv",
"leftTeamName": "景涵 / 小念",
"matchupLabel": "景涵 / 小念 vs 柏威 / 玟瑄",
"pointLog": [
{
"round": 0,
"starter": 0,
"winCount": 0,
"winner": 0
},
{
"round": 1,
"starter": 0,
"winCount": 0,
"winner": 1
}
],
"rightTeamName": "柏威 / 玟瑄",
"roomId": "174740",
"scoreState": {
"scoreLeft": 1,
"scoreRight": 1,
"gamesLeft": 0,
"gamesRight": 0,
"currentGame": 1,
"targetScore": 21,
"serving": "right",
"leftRightCourtPlayer": "playerB",
"rightRightCourtPlayer": "playerA"
},
"status": "finished",
"targetDate": "2026-04-13",
"updatedAt": "2026-04-19T04:51:25.160Z",
"winnerTeamName": "景涵 / 小念"
},
{
"createdAt": "2026-04-19T04:51:25.190Z",
"groupId": 2,
"hostToken": "mo5agjnqeabkfpr2",
"leftTeamName": "景涵 / 小念",
"matchupLabel": "景涵 / 小念 vs 柏威 / 玟瑄",
"pointLog": [
{
"round": 0,
"starter": 0,
"winCount": 0,
"winner": 0
},
{
"round": 1,
"starter": 0,
"winCount": 0,
"winner": 1
},
{
"round": 2,
"starter": 2,
"winCount": 1,
"winner": 1
}
],
"rightTeamName": "柏威 / 玟瑄",
"roomId": "239300",
"scoreState": {
"scoreLeft": 1,
"scoreRight": 2,
"gamesLeft": 0,
"gamesRight": 0,
"currentGame": 1,
"targetScore": 21,
"serving": "right",
"leftRightCourtPlayer": "playerB",
"rightRightCourtPlayer": "playerB"
},
"status": "finished",
"targetDate": "2026-04-13",
"updatedAt": "2026-04-19T04:52:26.087Z",
"winnerTeamName": "柏威 / 玟瑄"
},
{
"createdAt": "2026-04-19T04:58:15.291Z",
"groupId": 1,
"hostToken": "mo5apc3foksw0enn",
"leftTeamName": "景涵 / RuRu",
"matchupLabel": "景涵 / RuRu vs 小念 / 柏威",
"pointLog": [
{
"round": 0,
"starter": 0,
"winCount": 0,
"winner": 0
}
],
"rightTeamName": "小念 / 柏威",
"roomId": "432277",
"scoreState": {
"scoreLeft": 1,
"scoreRight": 0,
"gamesLeft": 0,
"gamesRight": 0,
"currentGame": 1,
"targetScore": 21,
"serving": "left",
"leftRightCourtPlayer": "playerB",
"rightRightCourtPlayer": "playerA"
},
"status": "finished",
"targetDate": "2026-04-13",
"updatedAt": "2026-04-19T04:58:25.870Z",
"winnerTeamName": "景涵 / RuRu"
},
{
"createdAt": "2026-04-19T05:01:10.705Z",
"groupId": 1,
"hostToken": "mo5at3g18sybc1fw",
"leftTeamName": "柏威 / RuRu",
"matchupLabel": "柏威 / RuRu vs 建喵 / 小念",
"pointLog": [],
"rightTeamName": "建喵 / 小念",
"roomId": "498013",
"scoreState": {
"scoreLeft": 0,
"scoreRight": 0,
"gamesLeft": 0,
"gamesRight": 0,
"currentGame": 1,
"targetScore": 21,
"serving": null,
"leftRightCourtPlayer": "playerA",
"rightRightCourtPlayer": "playerA"
},
"status": "live",
"targetDate": "2026-04-13",
"updatedAt": "2026-04-19T05:01:10.731Z",
"winnerTeamName": null
}
]
[]

View File

@@ -11,6 +11,7 @@ const matchTableName = process.env.DB_TABLE ?? 'badminton'
const historyTableName = process.env.DB_HISTORY_TABLE ?? 'history'
const appVersion = process.env.APP_VERSION ?? `${Date.now()}`
const appStartedAt = new Date().toISOString()
const LIVE_ROOM_STALE_MS = 30_000
const currentFilePath = fileURLToPath(import.meta.url)
const currentDir = path.dirname(currentFilePath)
@@ -77,6 +78,17 @@ app.get('/api/rooms', (_request, response) => {
})
})
app.post('/api/rooms/reconcile', (_request, response) => {
const removedRoomIds = pruneStaleRooms()
response.json({
ok: true,
data: {
removedRoomIds,
},
})
})
app.get('/api/rooms/stream', (request, response) => {
setupSse(response)
roomListClients.add(response)
@@ -106,6 +118,7 @@ app.post('/api/rooms', (request, response) => {
clients: new Set(),
createdAt: now,
hostToken,
hostSeenAt: now,
roomId,
status: 'live',
updatedAt: now,
@@ -171,6 +184,35 @@ app.post('/api/rooms/:roomId/release', (request, response) => {
})
})
app.post('/api/rooms/:roomId/heartbeat', (request, response) => {
const room = rooms.get(request.params.roomId)
if (!room) {
response.status(404).json({
ok: false,
message: '找不到這個房間。',
})
return
}
const { hostToken } = request.body ?? {}
if (typeof hostToken !== 'string' || hostToken !== room.hostToken) {
response.status(403).json({
ok: false,
message: '沒有權限更新房間心跳。',
})
return
}
room.hostSeenAt = new Date().toISOString()
persistRooms()
response.json({
ok: true,
})
})
app.get('/api/rooms/:roomId', (request, response) => {
const room = rooms.get(request.params.roomId)
@@ -520,6 +562,8 @@ function loadPersistedRooms() {
nextRooms.set(savedRoom.roomId, {
...savedRoom,
hostSeenAt:
typeof savedRoom.hostSeenAt === 'string' ? savedRoom.hostSeenAt : savedRoom.updatedAt,
clients: new Set(),
})
})
@@ -540,6 +584,7 @@ function persistRooms() {
createdAt: room.createdAt,
groupId: room.groupId,
hostToken: room.hostToken,
hostSeenAt: room.hostSeenAt,
leftTeamName: room.leftTeamName,
matchupLabel: room.matchupLabel,
pointLog: room.pointLog,
@@ -618,6 +663,39 @@ function serializeRoom(room) {
}
}
function pruneStaleRooms() {
const now = Date.now()
const removedRoomIds = []
rooms.forEach((room, roomId) => {
if (room.status !== 'live') {
return
}
const hostSeenAtTime = Date.parse(room.hostSeenAt ?? '')
if (!Number.isFinite(hostSeenAtTime) || now - hostSeenAtTime > LIVE_ROOM_STALE_MS) {
room.clients.forEach((client) => {
sendSse(client, 'room-closed', {
roomId: room.roomId,
status: 'stale',
})
client.end()
})
rooms.delete(roomId)
removedRoomIds.push(roomId)
}
})
if (removedRoomIds.length > 0) {
persistRooms()
broadcastRoomList()
}
return removedRoomIds
}
function getLiveRoomSummaries() {
return Array.from(rooms.values())
.filter((room) => room.status === 'live')

View File

@@ -6,6 +6,7 @@ import {
loadMatchResults,
releaseLiveRoom,
saveMatchHistory,
sendLiveRoomHeartbeat,
updateLiveRoom,
} from './lib/api'
import {
@@ -13,6 +14,8 @@ import {
convertDateToKey,
convertDbRecordToGroups,
formatDateInputValue,
getMirroredCourt,
getServiceCourt,
getServingPlayer,
getTeamDisplayName,
getWinnerName,
@@ -55,6 +58,7 @@ const initialScoreState: ScoreState = {
gamesRight: 0,
currentGame: 1,
targetScore: 21,
initialServing: null,
serving: null,
leftRightCourtPlayer: 'playerA',
rightRightCourtPlayer: 'playerA',
@@ -80,6 +84,13 @@ type VictoryAnnouncement = {
title: string
}
type VoiceAnnouncement = {
key: number
scorerName: string
serverChanged: boolean
serverName: string
}
const STREAK_TITLES: Record<number, string> = {
3: '大殺特殺',
4: '暴走',
@@ -90,6 +101,7 @@ const STREAK_TITLES: Record<number, string> = {
}
const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready'
const APP_VERSION_POLL_MS = 30000
const LIVE_ROOM_HEARTBEAT_MS = 10_000
function App() {
const location = useLocation()
@@ -127,8 +139,10 @@ function App() {
})
const [streakAnnouncement, setStreakAnnouncement] = useState<StreakAnnouncement | null>(null)
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
const [voiceAnnouncement, setVoiceAnnouncement] = useState<VoiceAnnouncement | null>(null)
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
const [liveRoomSession, setLiveRoomSession] = useState<LiveRoomSession | null>(null)
const [navigationLockMessage, setNavigationLockMessage] = useState('')
const currentAppVersionRef = useRef<string | null>(null)
const creatingRoomRef = useRef(false)
const lastSyncedRoomSignatureRef = useRef('')
@@ -139,6 +153,7 @@ function App() {
const leftTeam = activeMatchup.leftTeam
const rightTeam = activeMatchup.rightTeam
const liveRoomId = liveRoomSession?.roomId ?? null
const isNavigationLocked = Boolean(leftTeam && rightTeam && scoreState.serving !== null)
useEffect(() => {
window.localStorage.setItem(STORAGE_KEYS.targetDate, targetDate)
@@ -192,6 +207,18 @@ function App() {
return () => window.clearTimeout(timer)
}, [victoryAnnouncement])
useEffect(() => {
if (!navigationLockMessage) {
return
}
const timer = window.setTimeout(() => {
setNavigationLockMessage('')
}, 1400)
return () => window.clearTimeout(timer)
}, [navigationLockMessage])
useEffect(() => {
const handlePwaUpdateReady = () => {
setPwaUpdateReady(true)
@@ -272,6 +299,7 @@ function App() {
setPointLog([])
setStreakAnnouncement(null)
setVictoryAnnouncement(null)
setVoiceAnnouncement(null)
setSettlement({
error: '',
open: false,
@@ -425,9 +453,9 @@ function App() {
}
const winnerTeamName =
scoreState.scoreLeft >= scoreState.targetScore
hasWonGame(scoreState) && scoreState.scoreLeft > scoreState.scoreRight
? getTeamDisplayName(leftTeam)
: scoreState.scoreRight >= scoreState.targetScore
: hasWonGame(scoreState) && scoreState.scoreRight > scoreState.scoreLeft
? getTeamDisplayName(rightTeam)
: null
const nextStatus = winnerTeamName ? 'finished' : 'live'
@@ -482,6 +510,43 @@ function App() {
isScoreboardRoute,
])
useEffect(() => {
if (!isNavigationLocked || isScoreboardRoute) {
return
}
navigate('/scoreboard', { replace: true })
setNavigationLockMessage('比賽進行中,請先完成結算。')
}, [isNavigationLocked, isScoreboardRoute, navigate])
useEffect(() => {
if (!isScoreboardRoute || !liveRoomSession || liveRoomSession.status !== 'live') {
return
}
let active = true
const syncHeartbeat = async () => {
try {
await sendLiveRoomHeartbeat(liveRoomSession.roomId, liveRoomSession.hostToken)
} catch (error) {
if (active) {
console.error('live room heartbeat error:', error)
}
}
}
void syncHeartbeat()
const timer = window.setInterval(() => {
void syncHeartbeat()
}, LIVE_ROOM_HEARTBEAT_MS)
return () => {
active = false
window.clearInterval(timer)
}
}, [isScoreboardRoute, liveRoomSession])
useEffect(() => {
if (!liveRoomSession || liveRoomSession.status !== 'live') {
return
@@ -615,6 +680,12 @@ function App() {
: current.serving === 'right'
? 'left'
: null,
initialServing:
current.initialServing === 'left'
? 'right'
: current.initialServing === 'right'
? 'left'
: null,
leftRightCourtPlayer: current.rightRightCourtPlayer,
rightRightCourtPlayer: current.leftRightCourtPlayer,
}))
@@ -639,12 +710,13 @@ function App() {
}
const setServing = (side: ScoreSide) => {
if (scoreHistory.length > 0) {
if (scoreHistory.length > 0 || scoreState.initialServing !== null) {
return
}
setScoreState((current) => ({
...current,
initialServing: side,
serving: side,
}))
}
@@ -694,6 +766,12 @@ function App() {
setScoreHistory((current) => [...current, { pointLog, scoreState }])
setPointLog(nextPointLog)
setScoreState(nextScoreState)
setVoiceAnnouncement({
key: Date.now(),
scorerName: side === 'left' ? leftTeam.playerA : rightTeam.playerA,
serverChanged: side === scoreState.serving,
serverName: getNextServerName(nextScoreState, leftTeam, rightTeam, side),
})
if (streakTitle) {
setStreakAnnouncement({
@@ -704,9 +782,7 @@ function App() {
})
}
const reachedTarget =
nextScoreState.scoreLeft >= nextScoreState.targetScore ||
nextScoreState.scoreRight >= nextScoreState.targetScore
const reachedTarget = hasWonGame(nextScoreState)
if (reachedTarget) {
setVictoryAnnouncement({
@@ -822,6 +898,15 @@ function App() {
}
}
const handleNavAttempt = (targetPath: string) => (event: React.MouseEvent<HTMLAnchorElement>) => {
if (!isNavigationLocked || targetPath === '/scoreboard') {
return
}
event.preventDefault()
setNavigationLockMessage('比賽進行中,請先完成結算。')
}
return (
<div className={isScoreboardRoute ? 'app-shell app-shell-scoreboard' : 'app-shell'}>
<header className={isScoreboardRoute ? 'topbar topbar-compact' : 'topbar'}>
@@ -837,16 +922,32 @@ function App() {
</div>
<nav className="topnav" aria-label="主要導覽">
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/teams">
<NavLink
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
onClick={handleNavAttempt('/teams')}
to="/teams"
>
</NavLink>
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/scoreboard">
<NavLink
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
onClick={handleNavAttempt('/scoreboard')}
to="/scoreboard"
>
</NavLink>
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/history">
<NavLink
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
onClick={handleNavAttempt('/history')}
to="/history"
>
</NavLink>
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/rooms">
<NavLink
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
onClick={handleNavAttempt('/rooms')}
to="/rooms"
>
</NavLink>
</nav>
@@ -910,6 +1011,7 @@ function App() {
selectedGroup={selectedGroup}
streakAnnouncement={streakAnnouncement}
victoryAnnouncement={victoryAnnouncement}
voiceAnnouncement={voiceAnnouncement}
targetDate={targetDate}
onApplyMatchup={applyMatchup}
onCloseFinishDialog={closeSettlementDialog}
@@ -943,6 +1045,12 @@ function App() {
</button>
</div>
) : null}
{navigationLockMessage ? (
<div className="floating-status-bubble" role="status" aria-live="polite">
{navigationLockMessage}
</div>
) : null}
</div>
)
}
@@ -997,7 +1105,7 @@ function getServerHistoryIndex(
return null
}
return server.slot === 'playerA' ? 0 : 1
return getMirroredCourt(getServiceCourt(state.scoreLeft)) === 'left' ? 0 : 1
}
if (state.serving === 'right') {
@@ -1007,12 +1115,44 @@ function getServerHistoryIndex(
return null
}
return server.slot === 'playerB' ? 2 : 3
return getServiceCourt(state.scoreRight) === 'right' ? 2 : 3
}
return null
}
function getNextServerName(
state: ScoreState,
leftTeam: GroupTeam,
rightTeam: GroupTeam,
side: ScoreSide,
) {
if (side === 'left') {
return getServingPlayer(leftTeam, state.leftRightCourtPlayer, state.scoreLeft)?.name ?? ''
}
return getServingPlayer(rightTeam, state.rightRightCourtPlayer, state.scoreRight)?.name ?? ''
}
function hasWonGame(state: ScoreState) {
const leadingScore = Math.max(state.scoreLeft, state.scoreRight)
const trailingScore = Math.min(state.scoreLeft, state.scoreRight)
if (leadingScore < state.targetScore) {
return false
}
if (leadingScore >= 30) {
return true
}
if (trailingScore >= state.targetScore - 1) {
return leadingScore - trailingScore >= 2
}
return true
}
function formatPlayedAt(timestamp: number) {
return new Date(timestamp * 1000).toLocaleString('zh-TW', { hour12: false })
}

View File

@@ -24,7 +24,7 @@ export async function loadMatchResults(time: string) {
}
if (!response.ok || !payload.ok) {
throw new Error(payload.message ?? '無法讀取對戰資料。')
throw new Error(payload.message ?? '讀取指定日期分組失敗。')
}
return payload.data ?? null
@@ -46,7 +46,7 @@ export async function saveMatchHistory(payload: HistoryUploadPayload) {
}
if (!response.ok || !result.ok || !result.data) {
throw new Error(result.message ?? '無法上傳戰績。')
throw new Error(result.message ?? '上傳戰績失敗。')
}
return result.data
@@ -61,7 +61,7 @@ export async function loadHistoryList() {
}
if (!response.ok || !payload.ok) {
throw new Error(payload.message ?? '無法讀取歷史戰績。')
throw new Error(payload.message ?? '讀取歷史戰績失敗。')
}
return (payload.data ?? []).map(normalizeHistoryRecord)
@@ -98,7 +98,7 @@ export async function createLiveRoom(payload: LiveRoomPayload) {
}
if (!response.ok || !result.ok || !result.data) {
throw new Error(result.message ?? '建立觀戰房間失敗。')
throw new Error(result.message ?? '建立房間失敗。')
}
return result.data
@@ -146,6 +146,46 @@ export async function releaseLiveRoom(roomId: string, hostToken: string) {
}
}
export async function sendLiveRoomHeartbeat(roomId: string, hostToken: string) {
const response = await fetch(`/api/rooms/${roomId}/heartbeat`, {
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 reconcileLiveRooms() {
const response = await fetch('/api/rooms/reconcile', {
method: 'POST',
})
const result = (await readJsonSafely(response)) as {
ok?: boolean
message?: string
data?: {
removedRoomIds?: string[]
}
}
if (!response.ok || !result.ok) {
throw new Error(result.message ?? '清理無主房間失敗。')
}
return result.data?.removedRoomIds ?? []
}
export async function loadLiveRoomList() {
const response = await fetch('/api/rooms')
const result = (await readJsonSafely(response)) as {
@@ -178,7 +218,7 @@ export async function loadLiveRoom(roomId: string) {
}
if (!response.ok || !result.ok || !result.data) {
throw new Error(result.message ?? '載入房間內容失敗。')
throw new Error(result.message ?? '載入觀戰房間失敗。')
}
return result.data
@@ -271,7 +311,7 @@ function parseJson<T>(value: string | null, fallback: T): T {
}
function getDayLabel(dayOfWeek: number) {
const labels = ['日', '一', '二', '三', '四', '五', '六']
const labels = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
return labels[dayOfWeek] ?? '-'
}

View File

@@ -91,17 +91,28 @@ export function getServiceCourt(score: number): CourtSide {
return score % 2 === 0 ? 'right' : 'left'
}
export function getCourtAssignments(team: GroupTeam, rightCourtPlayer: PlayerSlot) {
export function getMirroredCourt(court: CourtSide): CourtSide {
return court === 'right' ? 'left' : 'right'
}
export function getCourtAssignments(
team: GroupTeam,
rightCourtPlayer: PlayerSlot,
mirrored = false,
) {
const rightScreenCourt = mirrored ? getMirroredCourt('right') : 'right'
const leftScreenCourt = mirrored ? getMirroredCourt('left') : 'left'
return [
{
slot: 'playerA' as const,
name: team.playerA,
court: (rightCourtPlayer === 'playerA' ? 'right' : 'left') as CourtSide,
court: (rightCourtPlayer === 'playerA' ? rightScreenCourt : leftScreenCourt) as CourtSide,
},
{
slot: 'playerB' as const,
name: team.playerB,
court: (rightCourtPlayer === 'playerB' ? 'right' : 'left') as CourtSide,
court: (rightCourtPlayer === 'playerB' ? rightScreenCourt : leftScreenCourt) as CourtSide,
},
]
}
@@ -120,7 +131,17 @@ export function getServingPlayer(
rightCourtPlayer: PlayerSlot,
score: number,
) {
return getPlayerOnCourt(team, rightCourtPlayer, getServiceCourt(score))
const serverSlot = getServiceCourt(score) === 'right'
? rightCourtPlayer
: rightCourtPlayer === 'playerA'
? 'playerB'
: 'playerA'
return {
slot: serverSlot,
name: team[serverSlot],
court: getServiceCourt(score),
}
}
export function getReceivingPlayer(
@@ -128,7 +149,17 @@ export function getReceivingPlayer(
rightCourtPlayer: PlayerSlot,
servingScore: number,
) {
return getPlayerOnCourt(team, rightCourtPlayer, getServiceCourt(servingScore))
const receiverSlot = getServiceCourt(servingScore) === 'right'
? rightCourtPlayer
: rightCourtPlayer === 'playerA'
? 'playerB'
: 'playerA'
return {
slot: receiverSlot,
name: team[receiverSlot],
court: getServiceCourt(servingScore),
}
}
export function swapCourtPositions(rightCourtPlayer: PlayerSlot): PlayerSlot {

View File

@@ -43,7 +43,11 @@ if ('serviceWorker' in navigator) {
window.location.reload()
})
void navigator.serviceWorker.register('/sw.js').then((registration) => {
void navigator.serviceWorker
.register('/sw.js', {
updateViaCache: 'none',
})
.then((registration) => {
if (registration.waiting) {
notifyUpdateReady()
}
@@ -52,6 +56,7 @@ if ('serviceWorker' in navigator) {
registration.addEventListener('updatefound', () => {
trackWorker(registration.installing)
})
void registration.update()
})
})
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { loadLiveRoomList, subscribeRoomList } from '../lib/api'
import { loadLiveRoomList, reconcileLiveRooms, subscribeRoomList } from '../lib/api'
import type { LiveRoomSummary } from '../types'
const REFRESH_COOLDOWN_SECONDS = 5
@@ -10,23 +10,20 @@ export function RoomListPage() {
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [refreshCooldown, setRefreshCooldown] = useState(0)
const [refreshMessage, setRefreshMessage] = useState('')
const [rooms, setRooms] = useState<LiveRoomSummary[]>([])
const loadingRef = useRef(false)
useEffect(() => {
let active = true
const loadRooms = async (options?: { manual?: boolean }) => {
const loadRooms = async () => {
if (loadingRef.current) {
return
}
loadingRef.current = true
if (options?.manual) {
setRefreshing(true)
}
try {
const nextRooms = await loadLiveRoomList()
@@ -47,9 +44,6 @@ export function RoomListPage() {
if (active) {
setLoading(false)
if (options?.manual) {
setRefreshing(false)
}
}
}
}
@@ -83,6 +77,18 @@ export function RoomListPage() {
return () => window.clearTimeout(timer)
}, [refreshCooldown])
useEffect(() => {
if (!refreshMessage) {
return
}
const timer = window.setTimeout(() => {
setRefreshMessage('')
}, 2000)
return () => window.clearTimeout(timer)
}, [refreshMessage])
const refreshRoomList = async () => {
if (refreshCooldown > 0 || loadingRef.current) {
return
@@ -93,9 +99,15 @@ export function RoomListPage() {
setRefreshCooldown(REFRESH_COOLDOWN_SECONDS)
try {
const removedRoomIds = await reconcileLiveRooms()
const nextRooms = await loadLiveRoomList()
setRooms(nextRooms)
setError('')
setRefreshMessage(
removedRoomIds.length > 0
? `已清掉 ${removedRoomIds.length} 個無主房間。`
: '已檢查房間列表,沒有需要清理的房間。',
)
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : '載入房間列表失敗。')
} finally {
@@ -129,6 +141,7 @@ export function RoomListPage() {
</button>
</div>
{refreshMessage ? <p className="selection-hint">{refreshMessage}</p> : null}
{loading ? <p>...</p> : null}
{!loading && error ? <p className="history-empty">{error}</p> : null}
{!loading && !error && rooms.length === 0 ? (

View File

@@ -86,10 +86,13 @@ export function RoomSpectatorPage({ onConfirmFinished }: RoomSpectatorPageProps)
return
}
if (payload.status === 'released') {
if (payload.status === 'released' || payload.status === 'stale') {
setRoomClosedDialog({
title: '房間已關閉',
message: '房主已重整頁面、離開記分板或重新選隊伍,本房間已結束觀戰。',
message:
payload.status === 'stale'
? '這個房間已經沒有主控在線上,系統已自動清理並結束觀戰。'
: '房主已重整頁面、離開記分板或重新選隊伍,本房間已結束觀戰。',
})
setLoading(false)
}

View File

@@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import {
getCourtAssignments,
getMirroredCourt,
getReceivingPlayer,
getServiceCourt,
getServingPlayer,
@@ -57,6 +58,12 @@ type ScoreboardPageProps = {
teamName: string
title: string
} | null
voiceAnnouncement: {
key: number
scorerName: string
serverChanged: boolean
serverName: string
} | null
targetDate: string
onApplyMatchup: (
leftTeam: GroupTeam,
@@ -88,6 +95,7 @@ export function ScoreboardPage({
selectedGroup,
streakAnnouncement,
victoryAnnouncement,
voiceAnnouncement,
targetDate,
onApplyMatchup,
onCloseFinishDialog,
@@ -117,8 +125,6 @@ export function ScoreboardPage({
const finishHoldTimerRef = useRef<number | null>(null)
const finishHoldStartRef = useRef(0)
const finishTriggeredRef = useRef(false)
const lastAnnouncedPointRef = useRef(0)
const previousScoresRef = useRef({ left: 0, right: 0 })
useEffect(() => {
const timer = window.setInterval(() => {
@@ -191,12 +197,12 @@ export function ScoreboardPage({
const leftAssignments = useMemo(
() =>
leftTeam ? getCourtAssignments(leftTeam, scoreState.leftRightCourtPlayer) : [],
leftTeam ? getCourtAssignments(leftTeam, scoreState.leftRightCourtPlayer, true) : [],
[leftTeam, scoreState.leftRightCourtPlayer],
)
const rightAssignments = useMemo(
() =>
rightTeam ? getCourtAssignments(rightTeam, scoreState.rightRightCourtPlayer) : [],
rightTeam ? getCourtAssignments(rightTeam, scoreState.rightRightCourtPlayer, false) : [],
[rightTeam, scoreState.rightRightCourtPlayer],
)
@@ -235,57 +241,29 @@ export function ScoreboardPage({
: null
useEffect(() => {
const totalPoints = scoreState.scoreLeft + scoreState.scoreRight
if (scoreState.serving === null || !currentServer?.name || totalPoints === 0) {
lastAnnouncedPointRef.current = totalPoints
previousScoresRef.current = {
left: scoreState.scoreLeft,
right: scoreState.scoreRight,
}
if (!voiceAnnouncement) {
return
}
if (lastAnnouncedPointRef.current === totalPoints) {
return
}
lastAnnouncedPointRef.current = totalPoints
const scorerSide =
scoreState.scoreLeft > previousScoresRef.current.left
? 'left'
: scoreState.scoreRight > previousScoresRef.current.right
? 'right'
: null
previousScoresRef.current = {
left: scoreState.scoreLeft,
right: scoreState.scoreRight,
}
const parts: string[] = []
if (voiceSettings.announceScore && scorerSide) {
parts.push(
`${getAnnouncementName(scorerSide === 'left' ? leftTeam : rightTeam)}得分`,
)
if (voiceSettings.announceScore) {
parts.push(`${getSpeechName(voiceAnnouncement.scorerName)}得分`)
}
if (voiceSettings.announceServer) {
parts.push(`${getSpeechName(currentServer.name)}發球`)
if (voiceSettings.announceServer && voiceAnnouncement.serverName) {
parts.push(
`${getSpeechName(voiceAnnouncement.serverName)}${
voiceAnnouncement.serverChanged ? '換邊發球' : '發球'
}`,
)
}
if (parts.length > 0) {
speakAnnouncement(parts.join(''), voiceSettings.rate)
}
}, [
currentServer?.name,
leftTeam,
rightTeam,
scoreState.scoreLeft,
scoreState.scoreRight,
scoreState.serving,
voiceAnnouncement,
voiceSettings.announceScore,
voiceSettings.announceServer,
voiceSettings.rate,
@@ -485,14 +463,20 @@ export function ScoreboardPage({
assignments={leftAssignments}
canArrangeMatch={canArrangeMatch}
canScore={canScore}
canSetServing={canArrangeMatch && scoreState.initialServing === null}
currentReceiver={scoreState.serving === 'right' ? currentReceiver?.name ?? null : null}
currentServer={scoreState.serving === 'left' ? currentServer?.name ?? null : null}
hasInitialServing={scoreState.initialServing === 'left'}
onRecordPoint={() => onRecordPoint('left')}
onSetServing={() => onSetServing('left')}
onSwapPlayers={() => onSwapTeamPlayers('left')}
onSwapTeams={onSwapMatchup}
score={scoreState.scoreLeft}
serviceCourt={scoreState.serving === 'left' ? servingCourt : null}
serviceCourt={
scoreState.serving === 'left' && servingCourt
? getMirroredCourt(servingCourt)
: null
}
showServingPrompt={scoreState.serving === null}
team={leftTeam}
teamSlot="top"
@@ -512,8 +496,10 @@ export function ScoreboardPage({
assignments={rightAssignments}
canArrangeMatch={canArrangeMatch}
canScore={canScore}
canSetServing={canArrangeMatch && scoreState.initialServing === null}
currentReceiver={scoreState.serving === 'left' ? currentReceiver?.name ?? null : null}
currentServer={scoreState.serving === 'right' ? currentServer?.name ?? null : null}
hasInitialServing={scoreState.initialServing === 'right'}
onRecordPoint={() => onRecordPoint('right')}
onSetServing={() => onSetServing('right')}
onSwapPlayers={() => onSwapTeamPlayers('right')}
@@ -650,8 +636,10 @@ type ScoreboardTeamPanelProps = {
assignments: Array<{ slot: PlayerSlot; name: string; court: CourtSide }>
canArrangeMatch: boolean
canScore: boolean
canSetServing: boolean
currentReceiver: string | null
currentServer: string | null
hasInitialServing: boolean
onRecordPoint: () => void
onSetServing: () => void
onSwapPlayers: () => void
@@ -667,8 +655,10 @@ function ScoreboardTeamPanel({
assignments,
canArrangeMatch,
canScore,
canSetServing,
currentReceiver,
currentServer,
hasInitialServing,
onRecordPoint,
onSetServing,
onSwapPlayers,
@@ -699,7 +689,7 @@ function ScoreboardTeamPanel({
}
key={assignment.slot}
>
<span className="team-number">{getPlayerNumber(teamSlot, assignment.slot)}</span>
<span className="team-number">{getPlayerNumber(teamSlot, assignment.court)}</span>
<strong>{assignment.name}</strong>
</div>
))}
@@ -731,23 +721,25 @@ function ScoreboardTeamPanel({
const serveBar = (
<button
className={
currentServer && !canArrangeMatch
hasInitialServing && !canSetServing
? 'serve-lane serve-lane-locked'
: showServingPrompt
? 'serve-lane serve-lane-prompt'
: 'serve-lane'
}
disabled={!canArrangeMatch || !team}
disabled={!canSetServing || !team}
type="button"
onClick={onSetServing}
>
<span className={currentServer ? 'serve-lane-box serve-lane-box-checked' : 'serve-lane-box'} />
<span className={hasInitialServing ? 'serve-lane-box serve-lane-box-checked' : 'serve-lane-box'} />
<span></span>
{currentServer ? (
<small>
{serviceCourt === 'left' ? '左' : serviceCourt === 'right' ? '右' : '-'}
{currentReceiver ? ` / 接發:${currentReceiver}` : ''}
</small>
) : hasInitialServing ? (
<small></small>
) : (
<small></small>
)}
@@ -1120,12 +1112,12 @@ function FinishDialog({
)
}
function getPlayerNumber(teamSlot: 'top' | 'bottom', slot: PlayerSlot) {
function getPlayerNumber(teamSlot: 'top' | 'bottom', court: CourtSide) {
if (teamSlot === 'top') {
return slot === 'playerA' ? 1 : 2
return court === 'left' ? 1 : 2
}
return slot === 'playerA' ? 4 : 3
return court === 'right' ? 3 : 4
}
function sanitizeTargetScore(value: string) {
@@ -1196,10 +1188,6 @@ function loadVoiceSettings(): VoiceSettings {
}
}
function getAnnouncementName(team: GroupTeam | null) {
return getSpeechName(team?.playerA ?? '本隊')
}
function getSpeechName(name: string) {
return SPEECH_NAME_MAP[name.trim().toLowerCase()] ?? name
}

View File

@@ -42,6 +42,7 @@ export type ScoreState = {
gamesRight: number
currentGame: number
targetScore: number
initialServing: ScoreSide | null
serving: ScoreSide | null
leftRightCourtPlayer: PlayerSlot
rightRightCourtPlayer: PlayerSlot