From ab3647587e19d8d3aff6b07d49a34e5bd9d3a6aa Mon Sep 17 00:00:00 2001 From: JianMiau Date: Sun, 19 Apr 2026 12:54:18 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AA=BF=E6=95=B4=E7=B5=90=E7=AE=97=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E8=88=87=E5=85=A8=E7=AB=99=E9=98=B2=E9=81=B8=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 87 ++++++++--------- server/data/live-rooms.json | 185 +++++++++++++++++++++++++++++++++++- src/App.tsx | 67 +++++++++++-- src/index.css | 11 +++ 4 files changed, 294 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index d547c7d..742e0fe 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,53 @@ # 羽毛球記分板 -使用 `Vite + React + TypeScript + Node.js` 製作的羽毛球記分板,支援選隊伍、即時記分、歷史戰績、PWA 安裝,以及即時房間觀戰。 +這是一個使用 `Vite + React + TypeScript + Node.js` 製作的羽毛球記分板,支援手機操作、PWA 安裝、即時觀戰房間、歷史戰績與 Docker / NAS 部署。 -## 功能 +## 功能總覽 - 選隊伍 - - 可依指定日期從資料庫載入分組資料。 - - 若資料庫沒有當天資料,可手動輸入 A、B 區名單產生分組。 + - 可依指定日期從資料庫讀取分組資料。 + - 若指定日期沒有資料,可手動輸入 A、B 區名單建立分組。 - 點選分組後可直接進入記分板。 - 記分板 - 隊伍名稱只顯示在最上方與最下方。 - - 可在設定隊伍面板中逐一選人,也可快速套用預設隊伍。 - - 先選到的 `1、2` 為一隊,`3、4` 為另一隊。 - - 可設定本場幾分獲勝,預設為 `21` 分。 - - 必須先設定先攻,才能開始記分。 - - 點擊分數直接加分,不提供加一減一按鈕。 + - 可在設定隊伍面板中逐一選人,也可快速選擇預設隊伍。 + - 先選到的 `1、2` 為一隊,`3、4` 為一隊。 + - 可設定獲勝分數,預設為 `21` 分。 + - 必須先選先攻,才能開始記分。 + - 點擊隊伍分數直接加分,不提供加一減一按鈕。 - 第一分記下後,`設定隊伍` 會切換成 `上一步`。 - 可交換上下兩隊位置,也可交換同隊左右站位。 - - `比賽結算` 需長按 `1 秒` 才會觸發。 - - 比分 `0:0` 時不允許觸發結算。 + - `比賽結算` 需要長按 `1 秒` 才會觸發。 + - 比分 `0:0` 時不可結算。 + - 全站文字預設不可選取,避免手機誤觸反白。 - 語音播報 - 可設定是否播報得分者。 - - 可設定是否播報下一位發球者。 - - 可調整語速,最高支援到 `10x`。 - - `RURU` 會以大小寫不敏感方式播報成「嚕嚕」。 -- 動畫提示 - - 先攻未設定時,`先攻` 文字會有提示動畫。 - - 選定先攻後,會顯示打勾讓使用者更容易辨識。 - - 連勝特效: - - `3 連勝`:大殺特殺 - - `4 連勝`:暴走 - - `5 連勝`:無人能擋 - - `6 連勝`:主宰比賽 - - `7 連勝`:像神一般的 - - `8 連勝`:成為傳說 - - 達到目標分數時會顯示獲勝動畫。 + - 可設定是否播報發球者。 + - 語速最高可調到 `10x`。 + - `RURU` 以大小寫不敏感方式播報成「嚕嚕」。 +- 動畫與提示 + - 未選先攻時,`先攻` 文字會有提示動畫。 + - 選定先攻後會顯示打勾。 + - 支援連勝稱號動畫與獲勝動畫。 - 歷史戰績 - - 比賽結算後可選擇是否上傳戰績到資料庫。 - - 歷史戰績列表直接從資料庫 `history` 表讀取。 - - 可點開查看每球得分紀錄。 - - 手機上彈窗有 `X` 可快速關閉。 - - 每筆戰績可刪除,刪除前會確認一次。 + - 可將比賽結果上傳到資料庫 `history`。 + - 歷史列表直接從 DB 顯示。 + - 可查看逐球得分紀錄。 + - 每筆紀錄可刪除,刪除前會確認一次。 - 即時房間 / 觀戰 - - 只要帶入隊伍進入記分板,就會自動建立一個房間。 - - 記分板右側會顯示房號。 - - `房間列表` 只顯示房號、隊伍、目標分數與最後更新時間,不顯示比分。 - - 觀戰者進入房間後可即時看到比分,不能操作。 - - 觀戰同步使用 `SSE + 輪詢備援`,降低漏分風險。 - - 房主重整、離開記分板或換隊伍時,未結束房間會自動清掉。 - - 達到目標分數後房間會標記結束,觀戰者會看到獲勝彈窗,按確定後返回房間列表。 + - 帶入隊伍進入記分板後會自動建立房間。 + - 記分板會顯示房號。 + - 房間列表不顯示比分,只顯示房號、隊伍、目標分數與更新時間。 + - 觀戰者只能看,不能操作。 + - 觀戰同步使用 `SSE + 輪詢備援`。 + - 房主重整、離開記分板或換隊伍時,未完成房間會自動清掉。 + - 達標獲勝時,觀戰者會收到獲勝通知。 + - 手動按 `比賽結算` 完成後,觀戰者也會收到獲勝結果,再返回房間列表。 - PWA - 可加入手機主畫面,像 App 一樣開啟。 - - 支援自訂網站 icon / PWA icon。 - - 新版本部署後會顯示更新提示,可直接重新整理套用新版。 + - 支援主畫面 icon 與版本更新提示。 -## 開發環境 +## 開發 ### Port @@ -104,7 +97,7 @@ PORT=8788 正式部署時: -- App 內部服務 port:`8788` +- App 內部服務:`8788` - 對外 HTTPS 入口:`3501` 部署指令: @@ -113,23 +106,23 @@ 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 憑證目錄 -Docker Compose 會直接掛載 NAS 上的憑證目錄: +Docker Compose 會直接掛載: ```text /volume1/homes/JianMiau/www/certificate/ ``` -預設使用以下檔案: +預設使用的檔案: - `RSA-cert.pem` - `RSA-chain.pem` @@ -159,7 +152,7 @@ Docker Compose 會直接掛載 NAS 上的憑證目錄: ## PWA 圖示 -目前網站 icon 與 PWA icon 來源為: +目前使用: - `public/favicon.png` - `public/apple-touch-icon.png` @@ -168,7 +161,7 @@ Docker Compose 會直接掛載 NAS 上的憑證目錄: ## Git 中文設定 -建議設定 git 使用 UTF-8,避免中文 commit 或 log 顯示異常: +建議設定 git 使用 UTF-8: ```bash git config i18n.commitEncoding utf-8 diff --git a/server/data/live-rooms.json b/server/data/live-rooms.json index 0637a08..4655827 100644 --- a/server/data/live-rooms.json +++ b/server/data/live-rooms.json @@ -1 +1,184 @@ -[] \ No newline at end of file +[ + { + "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:52:26.119Z", + "groupId": 2, + "hostToken": "mo5ahuo76ktdmleh", + "leftTeamName": "景涵 / 小念", + "matchupLabel": "景涵 / 小念 vs 柏威 / 玟瑄", + "pointLog": [ + { + "round": 0, + "starter": 0, + "winCount": 0, + "winner": 1 + }, + { + "round": 1, + "starter": 2, + "winCount": 0, + "winner": 0 + }, + { + "round": 2, + "starter": 1, + "winCount": 1, + "winner": 0 + } + ], + "rightTeamName": "柏威 / 玟瑄", + "roomId": "208299", + "scoreState": { + "scoreLeft": 2, + "scoreRight": 1, + "gamesLeft": 0, + "gamesRight": 0, + "currentGame": 1, + "targetScore": 21, + "serving": "left", + "leftRightCourtPlayer": "playerB", + "rightRightCourtPlayer": "playerA" + }, + "status": "live", + "targetDate": "2026-04-13", + "updatedAt": "2026-04-19T04:52:32.296Z", + "winnerTeamName": null + } +] \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 58dfb80..6391b61 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -255,8 +255,15 @@ function App() { } }, []) - const resetScoring = (nextState: ScoreState = initialScoreState) => { - if (liveRoomSession?.status === 'live') { + const resetScoring = ( + nextState: ScoreState = initialScoreState, + options?: { + releaseLiveRoom?: boolean + }, + ) => { + const shouldReleaseLiveRoom = options?.releaseLiveRoom ?? true + + if (shouldReleaseLiveRoom && liveRoomSession?.status === 'live') { void releaseLiveRoom(liveRoomSession.roomId, liveRoomSession.hostToken).catch(() => {}) } @@ -275,6 +282,47 @@ function App() { lastSyncedRoomSignatureRef.current = '' } + const finalizeLiveRoom = async () => { + if (!liveRoomSession || !leftTeam || !rightTeam) { + return + } + + const winnerTeamName = getWinnerName( + getTeamDisplayName(leftTeam), + getTeamDisplayName(rightTeam), + scoreState, + ) + + const payload = buildLiveRoomPayload({ + groupId: selectedGroup?.id ?? null, + leftTeam, + pointLog, + rightTeam, + scoreState, + targetDate, + }) + + try { + await updateLiveRoom(liveRoomSession.roomId, { + ...payload, + hostToken: liveRoomSession.hostToken, + status: 'finished', + winnerTeamName, + }) + + setLiveRoomSession((current) => + current + ? { + ...current, + status: 'finished', + } + : current, + ) + } catch (error) { + console.error('finalize live room error:', error) + } + } + const selectGroup = (groupId: number, nextGroups = groups) => { const nextGroup = nextGroups.find((group) => group.id === groupId) const firstTeam = nextGroup?.teams[0] ?? null @@ -709,12 +757,14 @@ function App() { } const skipUpload = () => { - setSettlement({ - error: '', - open: false, - uploading: false, + void finalizeLiveRoom().finally(() => { + setSettlement({ + error: '', + open: false, + uploading: false, + }) + resetScoring(initialScoreState, { releaseLiveRoom: false }) }) - resetScoring() } const uploadSettledMatch = async () => { @@ -756,12 +806,13 @@ function App() { } setHistory((current) => [historyItem, ...current]) + await finalizeLiveRoom() setSettlement({ error: '', open: false, uploading: false, }) - resetScoring() + resetScoring(initialScoreState, { releaseLiveRoom: false }) } catch (error) { setSettlement({ error: error instanceof Error ? error.message : '上傳戰績失敗。', diff --git a/src/index.css b/src/index.css index 8870b48..be06b3e 100644 --- a/src/index.css +++ b/src/index.css @@ -20,6 +20,8 @@ * { box-sizing: border-box; + -webkit-user-select: none; + user-select: none; } html { @@ -51,6 +53,15 @@ body::before { min-height: 100vh; } +input, +textarea, +select, +button, +[contenteditable='true'] { + -webkit-user-select: auto; + user-select: auto; +} + h1, h2, h3,