Compare commits
4 Commits
feature/li
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3677162747 | |||
| 30a8e1a44c | |||
| f3e51ea83d | |||
| edab74f125 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -25,3 +25,4 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
/*.stackdump
|
||||
|
||||
130
README.md
130
README.md
@@ -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`
|
||||
- `1、2` 為一隊
|
||||
- `3、4` 為一隊
|
||||
- `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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
49
public/sw.js
49
public/sw.js
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
[]
|
||||
|
||||
@@ -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')
|
||||
|
||||
4638
src/App.css
4638
src/App.css
File diff suppressed because it is too large
Load Diff
2008
src/App.tsx
2008
src/App.tsx
File diff suppressed because it is too large
Load Diff
172
src/index.css
172
src/index.css
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] ?? '-'
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
303
src/types.ts
303
src/types.ts
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user