4 Commits

17 changed files with 5080 additions and 4863 deletions

1
.gitignore vendored
View File

@@ -25,3 +25,4 @@ dist-ssr
*.njsproj
*.sln
*.sw?
/*.stackdump

130
README.md
View File

@@ -1,58 +1,57 @@
# 羽毛球記分板
這是一個使用 `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,16 +70,23 @@ npm run dev
- 前端:`http://localhost:3501`
- API`http://localhost:8788`
### 建置與檢查
### 檢查
```bash
npm run lint
npm run build
```
## 記分板滿版模式
- 記分板頁面會套用 `100dvh` 高度。
- 手機進入記分板時會關閉頁面捲動與 overscroll。
- `viewport` 已加上 `viewport-fit=cover`,較能貼合 iPhone / iPad 安全區。
- 若手機高度較矮,會再縮小字級、按鈕與分數區,盡量維持整頁顯示。
## 環境變數
在專案根目錄建立 `.env`
建立 `.env`
```env
DB_HOST=127.0.0.1
@@ -95,10 +101,10 @@ PORT=8788
## Docker / NAS 部署
正式部署時
對外服務配置
- App 內部服務`8788`
- 對外 HTTPS 入口`3501`
- 容器內 Node / API`8788`
- 對外 HTTPS 網址`3501`
部署指令:
@@ -106,29 +112,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 +146,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 +165,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

@@ -5,7 +5,7 @@
<link rel="icon" type="image/png" sizes="64x64" href="/favicon.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#143f49" />
<meta
name="description"

View File

@@ -6,7 +6,7 @@
"scripts": {
"dev": "concurrently \"npm:dev:server\" \"npm:dev:client\"",
"dev:client": "vite",
"dev:server": "node --watch server/server.mjs",
"dev:server": "node server/server.mjs",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",

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')

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,97 +1,107 @@
:root {
--page-bg: #eff5df;
--page-bg-2: #dbe8c6;
--panel-strong: #0a332d;
--panel-soft: #587169;
--border: rgba(7, 51, 44, 0.12);
--shadow:
0 24px 60px rgba(19, 43, 34, 0.12),
0 8px 20px rgba(19, 43, 34, 0.08);
--sans: 'Bahnschrift', 'Trebuchet MS', sans-serif;
--heading: 'Arial Black', 'Bahnschrift', sans-serif;
--mono: 'Consolas', 'Courier New', monospace;
font: 18px/1.5 var(--sans);
color: var(--panel-strong);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
-webkit-user-select: none;
user-select: none;
}
:root {
--page-bg: #eff5df;
--page-bg-2: #dbe8c6;
--panel-strong: #0a332d;
--panel-soft: #587169;
--border: rgba(7, 51, 44, 0.12);
--shadow:
0 24px 60px rgba(19, 43, 34, 0.12),
0 8px 20px rgba(19, 43, 34, 0.08);
--sans: 'Bahnschrift', 'Trebuchet MS', sans-serif;
--heading: 'Arial Black', 'Bahnschrift', sans-serif;
--mono: 'Consolas', 'Courier New', monospace;
font: 18px/1.5 var(--sans);
color: var(--panel-strong);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
-webkit-user-select: none;
user-select: none;
}
html {
min-height: 100%;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(255, 214, 10, 0.35), transparent 28%),
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(255, 214, 10, 0.35), transparent 28%),
radial-gradient(circle at bottom right, rgba(11, 88, 73, 0.2), transparent 32%),
linear-gradient(180deg, var(--page-bg), var(--page-bg-2));
}
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(255, 255, 255, 0.14) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.14) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.45), transparent 78%);
body.body-scoreboard {
overflow: hidden;
overscroll-behavior: none;
}
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(255, 255, 255, 0.14) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.14) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.45), transparent 78%);
}
#root {
min-height: 100vh;
}
input,
textarea,
select,
button,
[contenteditable='true'] {
-webkit-user-select: auto;
user-select: auto;
}
h1,
h2,
h3,
p {
margin: 0;
}
h1,
h2,
h3 {
font-family: var(--heading);
letter-spacing: 0.02em;
}
h1 {
margin: 18px 0 14px;
font-size: clamp(2.8rem, 8vw, 5rem);
line-height: 0.94;
}
h2 {
font-size: clamp(1.6rem, 3vw, 2.2rem);
}
p {
color: var(--panel-soft);
}
@media (max-width: 720px) {
:root {
font-size: 16px;
}
body.body-scoreboard #root {
min-height: 100dvh;
overflow: hidden;
}
input,
textarea,
select,
button,
[contenteditable='true'] {
-webkit-user-select: auto;
user-select: auto;
}
h1,
h2,
h3,
p {
margin: 0;
}
h1,
h2,
h3 {
font-family: var(--heading);
letter-spacing: 0.02em;
}
h1 {
margin: 18px 0 14px;
font-size: clamp(2.8rem, 8vw, 5rem);
line-height: 0.94;
}
h2 {
font-size: clamp(1.6rem, 3vw, 2.2rem);
}
p {
color: var(--panel-soft);
}
@media (max-width: 720px) {
:root {
font-size: 16px;
}
}

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)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +1,40 @@
export type LoadStatus = 'idle' | 'loading' | 'loaded' | 'empty' | 'error'
export type ScoreSide = 'left' | 'right'
export type PlayerSlot = 'playerA' | 'playerB'
export type CourtSide = 'left' | 'right'
export type GroupTeam = {
id: number
playerA: string
playerB: string
isPlaceholderA: boolean
isPlaceholderB: boolean
}
export type RoundGroup = {
id: number
teams: GroupTeam[]
}
export type MatchResultsRecord = {
time: number
personnel: string
battlecombination: string | null
}
export type Matchup = {
leftTeamId: number | null
rightTeamId: number | null
}
export type ActiveMatchup = {
leftTeam: GroupTeam | null
rightTeam: GroupTeam | null
}
export type LoadStatus = 'idle' | 'loading' | 'loaded' | 'empty' | 'error'
export type ScoreSide = 'left' | 'right'
export type PlayerSlot = 'playerA' | 'playerB'
export type CourtSide = 'left' | 'right'
export type GroupTeam = {
id: number
playerA: string
playerB: string
isPlaceholderA: boolean
isPlaceholderB: boolean
}
export type RoundGroup = {
id: number
teams: GroupTeam[]
}
export type MatchResultsRecord = {
time: number
personnel: string
battlecombination: string | null
}
export type Matchup = {
leftTeamId: number | null
rightTeamId: number | null
}
export type ActiveMatchup = {
leftTeam: GroupTeam | null
rightTeam: GroupTeam | null
}
export type ScoreState = {
scoreLeft: number
scoreRight: number
@@ -42,121 +42,122 @@ export type ScoreState = {
gamesRight: number
currentGame: number
targetScore: number
initialServing: ScoreSide | null
serving: ScoreSide | null
leftRightCourtPlayer: PlayerSlot
rightRightCourtPlayer: PlayerSlot
}
export type PointHistoryEntry = {
round: number
starter: number
winCount: number
winner: 0 | 1
}
export type ScoreSnapshot = {
pointLog: PointHistoryEntry[]
scoreState: ScoreState
}
export type MatchHistoryItem = {
id: string
playedAt: string
matchDate: string
source: 'db' | 'manual' | 'idle'
groupId: number
leftTeamName: string
rightTeamName: string
scoreLeft: number
scoreRight: number
winner: string
}
export type HistoryUploadPayload = {
dayOfWeek: number
players: string[]
score: [number, number]
scoreList: Array<[number, number, number, 0 | 1]>
team: [string[], string[]]
time: number
type: 0 | 1
winScore: number
}
export type HistoryUploadResponse = {
id: number
}
export type HistoryRecord = {
id: number
time: number
dayOfWeek: number
score: string
winScore: number
type: 0 | 1
players: string
team: string
scoreList: string | null
}
export type HistoryListItem = {
id: number
time: number
playedAt: string
dayOfWeek: number
dayLabel: string
score: [number, number]
winScore: number
type: 0 | 1
typeLabel: string
players: string[]
team: [string[], string[]]
scoreList: Array<[number, number, number, 0 | 1]>
leftTeamName: string
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
}
export type PointHistoryEntry = {
round: number
starter: number
winCount: number
winner: 0 | 1
}
export type ScoreSnapshot = {
pointLog: PointHistoryEntry[]
scoreState: ScoreState
}
export type MatchHistoryItem = {
id: string
playedAt: string
matchDate: string
source: 'db' | 'manual' | 'idle'
groupId: number
leftTeamName: string
rightTeamName: string
scoreLeft: number
scoreRight: number
winner: string
}
export type HistoryUploadPayload = {
dayOfWeek: number
players: string[]
score: [number, number]
scoreList: Array<[number, number, number, 0 | 1]>
team: [string[], string[]]
time: number
type: 0 | 1
winScore: number
}
export type HistoryUploadResponse = {
id: number
}
export type HistoryRecord = {
id: number
time: number
dayOfWeek: number
score: string
winScore: number
type: 0 | 1
players: string
team: string
scoreList: string | null
}
export type HistoryListItem = {
id: number
time: number
playedAt: string
dayOfWeek: number
dayLabel: string
score: [number, number]
winScore: number
type: 0 | 1
typeLabel: string
players: string[]
team: [string[], string[]]
scoreList: Array<[number, number, number, 0 | 1]>
leftTeamName: string
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
}