調整結算通知與全站防選字
This commit is contained in:
87
README.md
87
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` 為另一隊。
|
- 先選到的 `1、2` 為一隊,`3、4` 為一隊。
|
||||||
- 可設定本場幾分獲勝,預設為 `21` 分。
|
- 可設定獲勝分數,預設為 `21` 分。
|
||||||
- 必須先設定先攻,才能開始記分。
|
- 必須先選先攻,才能開始記分。
|
||||||
- 點擊分數直接加分,不提供加一減一按鈕。
|
- 點擊隊伍分數直接加分,不提供加一減一按鈕。
|
||||||
- 第一分記下後,`設定隊伍` 會切換成 `上一步`。
|
- 第一分記下後,`設定隊伍` 會切換成 `上一步`。
|
||||||
- 可交換上下兩隊位置,也可交換同隊左右站位。
|
- 可交換上下兩隊位置,也可交換同隊左右站位。
|
||||||
- `比賽結算` 需長按 `1 秒` 才會觸發。
|
- `比賽結算` 需要長按 `1 秒` 才會觸發。
|
||||||
- 比分 `0:0` 時不允許觸發結算。
|
- 比分 `0:0` 時不可結算。
|
||||||
|
- 全站文字預設不可選取,避免手機誤觸反白。
|
||||||
- 語音播報
|
- 語音播報
|
||||||
- 可設定是否播報得分者。
|
- 可設定是否播報得分者。
|
||||||
- 可設定是否播報下一位發球者。
|
- 可設定是否播報發球者。
|
||||||
- 可調整語速,最高支援到 `10x`。
|
- 語速最高可調到 `10x`。
|
||||||
- `RURU` 會以大小寫不敏感方式播報成「嚕嚕」。
|
- `RURU` 以大小寫不敏感方式播報成「嚕嚕」。
|
||||||
- 動畫提示
|
- 動畫與提示
|
||||||
- 先攻未設定時,`先攻` 文字會有提示動畫。
|
- 未選先攻時,`先攻` 文字會有提示動畫。
|
||||||
- 選定先攻後,會顯示打勾讓使用者更容易辨識。
|
- 選定先攻後會顯示打勾。
|
||||||
- 連勝特效:
|
- 支援連勝稱號動畫與獲勝動畫。
|
||||||
- `3 連勝`:大殺特殺
|
|
||||||
- `4 連勝`:暴走
|
|
||||||
- `5 連勝`:無人能擋
|
|
||||||
- `6 連勝`:主宰比賽
|
|
||||||
- `7 連勝`:像神一般的
|
|
||||||
- `8 連勝`:成為傳說
|
|
||||||
- 達到目標分數時會顯示獲勝動畫。
|
|
||||||
- 歷史戰績
|
- 歷史戰績
|
||||||
- 比賽結算後可選擇是否上傳戰績到資料庫。
|
- 可將比賽結果上傳到資料庫 `history`。
|
||||||
- 歷史戰績列表直接從資料庫 `history` 表讀取。
|
- 歷史列表直接從 DB 顯示。
|
||||||
- 可點開查看每球得分紀錄。
|
- 可查看逐球得分紀錄。
|
||||||
- 手機上彈窗有 `X` 可快速關閉。
|
- 每筆紀錄可刪除,刪除前會確認一次。
|
||||||
- 每筆戰績可刪除,刪除前會確認一次。
|
|
||||||
- 即時房間 / 觀戰
|
- 即時房間 / 觀戰
|
||||||
- 只要帶入隊伍進入記分板,就會自動建立一個房間。
|
- 帶入隊伍進入記分板後會自動建立房間。
|
||||||
- 記分板右側會顯示房號。
|
- 記分板會顯示房號。
|
||||||
- `房間列表` 只顯示房號、隊伍、目標分數與最後更新時間,不顯示比分。
|
- 房間列表不顯示比分,只顯示房號、隊伍、目標分數與更新時間。
|
||||||
- 觀戰者進入房間後可即時看到比分,不能操作。
|
- 觀戰者只能看,不能操作。
|
||||||
- 觀戰同步使用 `SSE + 輪詢備援`,降低漏分風險。
|
- 觀戰同步使用 `SSE + 輪詢備援`。
|
||||||
- 房主重整、離開記分板或換隊伍時,未結束房間會自動清掉。
|
- 房主重整、離開記分板或換隊伍時,未完成房間會自動清掉。
|
||||||
- 達到目標分數後房間會標記結束,觀戰者會看到獲勝彈窗,按確定後返回房間列表。
|
- 達標獲勝時,觀戰者會收到獲勝通知。
|
||||||
|
- 手動按 `比賽結算` 完成後,觀戰者也會收到獲勝結果,再返回房間列表。
|
||||||
- PWA
|
- PWA
|
||||||
- 可加入手機主畫面,像 App 一樣開啟。
|
- 可加入手機主畫面,像 App 一樣開啟。
|
||||||
- 支援自訂網站 icon / PWA icon。
|
- 支援主畫面 icon 與版本更新提示。
|
||||||
- 新版本部署後會顯示更新提示,可直接重新整理套用新版。
|
|
||||||
|
|
||||||
## 開發環境
|
## 開發
|
||||||
|
|
||||||
### Port
|
### Port
|
||||||
|
|
||||||
@@ -104,7 +97,7 @@ PORT=8788
|
|||||||
|
|
||||||
正式部署時:
|
正式部署時:
|
||||||
|
|
||||||
- App 內部服務 port:`8788`
|
- App 內部服務:`8788`
|
||||||
- 對外 HTTPS 入口:`3501`
|
- 對外 HTTPS 入口:`3501`
|
||||||
|
|
||||||
部署指令:
|
部署指令:
|
||||||
@@ -113,23 +106,23 @@ PORT=8788
|
|||||||
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`,容器都會更新啟動版號,已安裝 PWA 的裝置會在偵測到新版本後顯示更新提示。
|
每次執行 `sudo docker compose up -d --build`,容器都會刷新版本號,已安裝的 PWA 會在偵測到新版本後跳出更新提示。
|
||||||
|
|
||||||
## SSL 憑證目錄
|
## SSL 憑證目錄
|
||||||
|
|
||||||
Docker Compose 會直接掛載 NAS 上的憑證目錄:
|
Docker Compose 會直接掛載:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/volume1/homes/JianMiau/www/certificate/
|
/volume1/homes/JianMiau/www/certificate/
|
||||||
```
|
```
|
||||||
|
|
||||||
預設使用以下檔案:
|
預設使用的檔案:
|
||||||
|
|
||||||
- `RSA-cert.pem`
|
- `RSA-cert.pem`
|
||||||
- `RSA-chain.pem`
|
- `RSA-chain.pem`
|
||||||
@@ -159,7 +152,7 @@ Docker Compose 會直接掛載 NAS 上的憑證目錄:
|
|||||||
|
|
||||||
## PWA 圖示
|
## PWA 圖示
|
||||||
|
|
||||||
目前網站 icon 與 PWA icon 來源為:
|
目前使用:
|
||||||
|
|
||||||
- `public/favicon.png`
|
- `public/favicon.png`
|
||||||
- `public/apple-touch-icon.png`
|
- `public/apple-touch-icon.png`
|
||||||
@@ -168,7 +161,7 @@ Docker Compose 會直接掛載 NAS 上的憑證目錄:
|
|||||||
|
|
||||||
## Git 中文設定
|
## Git 中文設定
|
||||||
|
|
||||||
建議設定 git 使用 UTF-8,避免中文 commit 或 log 顯示異常:
|
建議設定 git 使用 UTF-8:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git config i18n.commitEncoding utf-8
|
git config i18n.commitEncoding utf-8
|
||||||
|
|||||||
@@ -1 +1,184 @@
|
|||||||
[]
|
[
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
59
src/App.tsx
59
src/App.tsx
@@ -255,8 +255,15 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const resetScoring = (nextState: ScoreState = initialScoreState) => {
|
const resetScoring = (
|
||||||
if (liveRoomSession?.status === 'live') {
|
nextState: ScoreState = initialScoreState,
|
||||||
|
options?: {
|
||||||
|
releaseLiveRoom?: boolean
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const shouldReleaseLiveRoom = options?.releaseLiveRoom ?? true
|
||||||
|
|
||||||
|
if (shouldReleaseLiveRoom && liveRoomSession?.status === 'live') {
|
||||||
void releaseLiveRoom(liveRoomSession.roomId, liveRoomSession.hostToken).catch(() => {})
|
void releaseLiveRoom(liveRoomSession.roomId, liveRoomSession.hostToken).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,6 +282,47 @@ function App() {
|
|||||||
lastSyncedRoomSignatureRef.current = ''
|
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 selectGroup = (groupId: number, nextGroups = groups) => {
|
||||||
const nextGroup = nextGroups.find((group) => group.id === groupId)
|
const nextGroup = nextGroups.find((group) => group.id === groupId)
|
||||||
const firstTeam = nextGroup?.teams[0] ?? null
|
const firstTeam = nextGroup?.teams[0] ?? null
|
||||||
@@ -709,12 +757,14 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const skipUpload = () => {
|
const skipUpload = () => {
|
||||||
|
void finalizeLiveRoom().finally(() => {
|
||||||
setSettlement({
|
setSettlement({
|
||||||
error: '',
|
error: '',
|
||||||
open: false,
|
open: false,
|
||||||
uploading: false,
|
uploading: false,
|
||||||
})
|
})
|
||||||
resetScoring()
|
resetScoring(initialScoreState, { releaseLiveRoom: false })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadSettledMatch = async () => {
|
const uploadSettledMatch = async () => {
|
||||||
@@ -756,12 +806,13 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setHistory((current) => [historyItem, ...current])
|
setHistory((current) => [historyItem, ...current])
|
||||||
|
await finalizeLiveRoom()
|
||||||
setSettlement({
|
setSettlement({
|
||||||
error: '',
|
error: '',
|
||||||
open: false,
|
open: false,
|
||||||
uploading: false,
|
uploading: false,
|
||||||
})
|
})
|
||||||
resetScoring()
|
resetScoring(initialScoreState, { releaseLiveRoom: false })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSettlement({
|
setSettlement({
|
||||||
error: error instanceof Error ? error.message : '上傳戰績失敗。',
|
error: error instanceof Error ? error.message : '上傳戰績失敗。',
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
@@ -51,6 +53,15 @@ body::before {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select,
|
||||||
|
button,
|
||||||
|
[contenteditable='true'] {
|
||||||
|
-webkit-user-select: auto;
|
||||||
|
user-select: auto;
|
||||||
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
h3,
|
h3,
|
||||||
|
|||||||
Reference in New Issue
Block a user