Refine scoreboard flow and update ports
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
DB_HOST=192.168.0.15
|
||||||
|
DB_PORT=3307
|
||||||
|
DB_USER=jianmiau
|
||||||
|
DB_PASSWORD=your-password
|
||||||
|
DB_DATABASE=badminton
|
||||||
|
DB_TABLE=badminton
|
||||||
|
DB_HISTORY_TABLE=history
|
||||||
|
SERVER_PORT=8788
|
||||||
17
Dockerfile
17
Dockerfile
@@ -8,11 +8,18 @@ RUN npm ci
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:1.29-alpine
|
FROM node:22-alpine AS runner
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
WORKDIR /app
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=8788
|
||||||
|
|
||||||
EXPOSE 80
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
COPY server ./server
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
EXPOSE 8788
|
||||||
|
|
||||||
|
CMD ["node", "server/server.mjs"]
|
||||||
|
|||||||
127
README.md
127
README.md
@@ -1,39 +1,111 @@
|
|||||||
# 羽毛球記分板
|
# badminton-scoreboard
|
||||||
|
|
||||||
使用 `Vite + React + TypeScript` 初始化的前端專案,作為羽毛球記分板與賽事畫面的開發基底。
|
羽毛球記分板專案,使用 `Vite + React + TypeScript` 建立前端,搭配 `Express + MySQL` 提供分組讀取與戰績寫入 API。
|
||||||
|
|
||||||
## 技術堆疊
|
## 目前功能
|
||||||
|
|
||||||
- Vite
|
- 選擇日期後從 DB 讀取隊伍與分組資料
|
||||||
- React
|
- 若指定日期沒有資料,可手動輸入名單並產生配對
|
||||||
- TypeScript
|
- 從指定組別選 2 隊帶入記分板
|
||||||
- ESLint
|
- 記分板支援先攻設定、點擊分數直接加分、上一步回退
|
||||||
- Docker / Nginx 靜態部署
|
- 支援上下換隊、左右交換隊員位置
|
||||||
|
- 比賽結算後可選擇是否上傳戰績到 `history` 資料表
|
||||||
|
|
||||||
## 開發指令
|
## 開發環境 Port
|
||||||
|
|
||||||
|
- Client: `3501`
|
||||||
|
- Server API: `8788`
|
||||||
|
|
||||||
|
Vite 前端會開在:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:3501
|
||||||
|
```
|
||||||
|
|
||||||
|
API 會開在:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:8788
|
||||||
|
```
|
||||||
|
|
||||||
|
## 啟動方式
|
||||||
|
|
||||||
|
先安裝套件:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
啟動開發模式:
|
||||||
|
|
||||||
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
本機開發預設網址:
|
這個指令會同時啟動:
|
||||||
|
|
||||||
|
- Vite client on `3501`
|
||||||
|
- Node server on `8788`
|
||||||
|
|
||||||
|
其中後端已使用 `node --watch`,修改 `server/server.mjs` 會自動重啟。
|
||||||
|
|
||||||
|
## 環境變數
|
||||||
|
|
||||||
|
可參考 [.env.example](./.env.example):
|
||||||
|
|
||||||
|
```env
|
||||||
|
DB_HOST=192.168.0.15
|
||||||
|
DB_PORT=3307
|
||||||
|
DB_USER=jianmiau
|
||||||
|
DB_PASSWORD=your-password
|
||||||
|
DB_DATABASE=badminton
|
||||||
|
DB_TABLE=badminton
|
||||||
|
DB_HISTORY_TABLE=history
|
||||||
|
SERVER_PORT=8788
|
||||||
|
```
|
||||||
|
|
||||||
|
## 資料表說明
|
||||||
|
|
||||||
|
### `badminton`
|
||||||
|
|
||||||
|
- `time`: 日期,格式 `YYYYMMDD`
|
||||||
|
- `personnel`: 人員清單,格式例如 `[[1,"A區成員"],[0,"B區成員"]]`
|
||||||
|
- `battlecombination`: 分組資料,格式例如 `{"0":[["A","B"]],"1":[...],"2":[...]}`
|
||||||
|
|
||||||
|
### `history`
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `time`
|
||||||
|
- `dayOfWeek`
|
||||||
|
- `score`
|
||||||
|
- `winScore`
|
||||||
|
- `type`
|
||||||
|
- `players`
|
||||||
|
- `team`
|
||||||
|
- `scoreList`
|
||||||
|
|
||||||
|
其中 `scoreList` 格式為:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://localhost:5173
|
[round, starter, winCount, winner]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
對應意義:
|
||||||
|
|
||||||
|
- `round`: 第幾球
|
||||||
|
- `starter`: 發球者編號,依記分板 `1~4`
|
||||||
|
- `winCount`: 連續得分次數
|
||||||
|
- `winner`: 該球由哪一隊得分,`0` 代表上方隊伍,`1` 代表下方隊伍
|
||||||
|
|
||||||
## 建置
|
## 建置
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
建置完成後,輸出會在 `dist/`。
|
## Docker
|
||||||
|
|
||||||
## Docker 打包
|
建置映像:
|
||||||
|
|
||||||
建立映像:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t badminton-scoreboard .
|
docker build -t badminton-scoreboard .
|
||||||
@@ -42,19 +114,24 @@ docker build -t badminton-scoreboard .
|
|||||||
啟動容器:
|
啟動容器:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d -p 8080:80 --name badminton-scoreboard badminton-scoreboard
|
docker run -d \
|
||||||
|
--name badminton-scoreboard \
|
||||||
|
-p 8788:8788 \
|
||||||
|
-e PORT=8788 \
|
||||||
|
-e DB_HOST=192.168.0.15 \
|
||||||
|
-e DB_PORT=3307 \
|
||||||
|
-e DB_USER=jianmiau \
|
||||||
|
-e DB_PASSWORD=your-password \
|
||||||
|
-e DB_DATABASE=badminton \
|
||||||
|
-e DB_TABLE=badminton \
|
||||||
|
-e DB_HISTORY_TABLE=history \
|
||||||
|
badminton-scoreboard
|
||||||
```
|
```
|
||||||
|
|
||||||
啟動後可由以下網址檢視:
|
容器啟動後可透過:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://localhost:8080
|
http://localhost:8788
|
||||||
```
|
```
|
||||||
|
|
||||||
## 後續可擴充功能
|
提供 API 與建置後的前端頁面。
|
||||||
|
|
||||||
- 單打 / 雙打模式
|
|
||||||
- 發球權切換
|
|
||||||
- 局數統計
|
|
||||||
- 比賽計時
|
|
||||||
- 賽程與場地管理
|
|
||||||
|
|||||||
11
nginx.conf
11
nginx.conf
@@ -1,11 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1186
package-lock.json
generated
1186
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -4,14 +4,22 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "concurrently \"npm:dev:server\" \"npm:dev:client\"",
|
||||||
|
"dev:client": "vite",
|
||||||
|
"dev:server": "node --watch server/server.mjs",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"start": "node server/server.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"mysql2": "^3.22.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.14.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
|||||||
213
server/server.mjs
Normal file
213
server/server.mjs
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import 'dotenv/config'
|
||||||
|
import express from 'express'
|
||||||
|
import mysql from 'mysql2/promise'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
const port = Number(process.env.PORT ?? process.env.SERVER_PORT ?? 8788)
|
||||||
|
const matchTableName = process.env.DB_TABLE ?? 'badminton'
|
||||||
|
const historyTableName = process.env.DB_HISTORY_TABLE ?? 'history'
|
||||||
|
|
||||||
|
const currentFilePath = fileURLToPath(import.meta.url)
|
||||||
|
const currentDir = path.dirname(currentFilePath)
|
||||||
|
const projectRoot = path.resolve(currentDir, '..')
|
||||||
|
const distDir = path.join(projectRoot, 'dist')
|
||||||
|
const distReady = existsSync(path.join(distDir, 'index.html'))
|
||||||
|
|
||||||
|
const requiredEnv = ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_DATABASE']
|
||||||
|
const missingEnv = requiredEnv.filter((key) => !process.env[key])
|
||||||
|
|
||||||
|
const pool =
|
||||||
|
missingEnv.length === 0
|
||||||
|
? mysql.createPool({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: Number(process.env.DB_PORT),
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_DATABASE,
|
||||||
|
charset: 'utf8mb4',
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
|
app.use(express.json())
|
||||||
|
|
||||||
|
app.get('/api/health', (_request, response) => {
|
||||||
|
response.json({
|
||||||
|
ok: true,
|
||||||
|
dbReady: Boolean(pool),
|
||||||
|
distReady,
|
||||||
|
historyTableName,
|
||||||
|
matchTableName,
|
||||||
|
missingEnv,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/match-results/:time', async (request, response) => {
|
||||||
|
if (!pool) {
|
||||||
|
response.status(500).json({
|
||||||
|
ok: false,
|
||||||
|
message: `DB 尚未設定完成,缺少 ${missingEnv.join(', ')}`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = String(request.params.time ?? '')
|
||||||
|
|
||||||
|
if (!/^\d{8}$/.test(time)) {
|
||||||
|
response.status(400).json({
|
||||||
|
ok: false,
|
||||||
|
message: '日期格式必須是 YYYYMMDD。',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureMatchTable(pool, matchTableName)
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
`SELECT time, personnel, battlecombination FROM \`${matchTableName}\` WHERE time = ? LIMIT 1`,
|
||||||
|
[Number(time)],
|
||||||
|
)
|
||||||
|
|
||||||
|
const record = rows[0]
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
response.status(404).json({
|
||||||
|
ok: false,
|
||||||
|
message: '指定日期沒有資料。',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.json({
|
||||||
|
ok: true,
|
||||||
|
data: record,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('match-results load error:', error)
|
||||||
|
response.status(500).json({
|
||||||
|
ok: false,
|
||||||
|
message: error instanceof Error ? error.message : '讀取對戰資料失敗。',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/history', async (request, response) => {
|
||||||
|
if (!pool) {
|
||||||
|
response.status(500).json({
|
||||||
|
ok: false,
|
||||||
|
message: `DB 尚未設定完成,缺少 ${missingEnv.join(', ')}`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
dayOfWeek,
|
||||||
|
players,
|
||||||
|
score,
|
||||||
|
scoreList,
|
||||||
|
team,
|
||||||
|
time,
|
||||||
|
type,
|
||||||
|
winScore,
|
||||||
|
} = request.body ?? {}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof time !== 'number' ||
|
||||||
|
typeof dayOfWeek !== 'number' ||
|
||||||
|
typeof winScore !== 'number' ||
|
||||||
|
typeof type !== 'number' ||
|
||||||
|
!Array.isArray(score) ||
|
||||||
|
score.length !== 2 ||
|
||||||
|
!Array.isArray(players) ||
|
||||||
|
!Array.isArray(team) ||
|
||||||
|
!Array.isArray(scoreList)
|
||||||
|
) {
|
||||||
|
response.status(400).json({
|
||||||
|
ok: false,
|
||||||
|
message: '戰績資料格式不正確。',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureHistoryTable(pool, historyTableName)
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`
|
||||||
|
INSERT INTO \`${historyTableName}\`
|
||||||
|
(time, dayOfWeek, score, winScore, type, players, team, scoreList)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
time,
|
||||||
|
dayOfWeek,
|
||||||
|
JSON.stringify(score),
|
||||||
|
winScore,
|
||||||
|
type,
|
||||||
|
JSON.stringify(players),
|
||||||
|
JSON.stringify(team),
|
||||||
|
JSON.stringify(scoreList),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
response.json({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
id: result.insertId,
|
||||||
|
},
|
||||||
|
message: '戰績已寫入 DB。',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('history save error:', error)
|
||||||
|
response.status(500).json({
|
||||||
|
ok: false,
|
||||||
|
message: error instanceof Error ? error.message : '寫入戰績失敗。',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (distReady) {
|
||||||
|
app.use(express.static(distDir))
|
||||||
|
|
||||||
|
app.get(/^(?!\/api).*/, (_request, response) => {
|
||||||
|
response.sendFile(path.join(distDir, 'index.html'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Server ready on http://localhost:${port}`)
|
||||||
|
if (missingEnv.length > 0) {
|
||||||
|
console.log(`Missing env: ${missingEnv.join(', ')}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function ensureMatchTable(poolInstance, currentTableName) {
|
||||||
|
await poolInstance.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS \`${currentTableName}\` (
|
||||||
|
time INT(11) NOT NULL,
|
||||||
|
personnel TEXT NOT NULL,
|
||||||
|
battlecombination TEXT DEFAULT NULL,
|
||||||
|
PRIMARY KEY (time)
|
||||||
|
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureHistoryTable(poolInstance, currentTableName) {
|
||||||
|
await poolInstance.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS \`${currentTableName}\` (
|
||||||
|
id INT(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
time INT(11) NOT NULL COMMENT '記錄時間',
|
||||||
|
dayOfWeek INT(1) NOT NULL COMMENT '星期',
|
||||||
|
score VARCHAR(255) NOT NULL COMMENT '隊伍分數 [ [隊伍1分數], [隊伍2分數] ]',
|
||||||
|
winScore INT(2) NOT NULL COMMENT '幾分獲勝',
|
||||||
|
type INT(1) NOT NULL COMMENT '遊戲類型(0:雙打,1:單打)',
|
||||||
|
players VARCHAR(255) NOT NULL COMMENT '玩家',
|
||||||
|
team VARCHAR(255) NOT NULL COMMENT '玩家隊伍 [ [隊伍1成員], [隊伍2成員] ]',
|
||||||
|
scoreList TEXT DEFAULT NULL COMMENT '得分過程[round, starter, winCount, winner]',
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||||
|
`)
|
||||||
|
}
|
||||||
1209
src/App.css
1209
src/App.css
File diff suppressed because it is too large
Load Diff
653
src/App.tsx
653
src/App.tsx
@@ -1,65 +1,610 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { NavLink, Route, Routes, useLocation } from 'react-router-dom'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
import { loadMatchResults, saveMatchHistory } from './lib/api'
|
||||||
|
import {
|
||||||
|
buildManualGroups,
|
||||||
|
convertDateToKey,
|
||||||
|
convertDbRecordToGroups,
|
||||||
|
formatDateInputValue,
|
||||||
|
getServingPlayer,
|
||||||
|
getTeamDisplayName,
|
||||||
|
getWinnerName,
|
||||||
|
parseRoster,
|
||||||
|
swapCourtPositions,
|
||||||
|
} from './lib/match'
|
||||||
|
import { HistoryPage } from './pages/HistoryPage'
|
||||||
|
import { ScoreboardPage } from './pages/ScoreboardPage'
|
||||||
|
import { TeamSelectionPage } from './pages/TeamSelectionPage'
|
||||||
|
import type {
|
||||||
|
GroupTeam,
|
||||||
|
HistoryUploadPayload,
|
||||||
|
LoadStatus,
|
||||||
|
MatchHistoryItem,
|
||||||
|
Matchup,
|
||||||
|
PointHistoryEntry,
|
||||||
|
RoundGroup,
|
||||||
|
ScoreSide,
|
||||||
|
ScoreSnapshot,
|
||||||
|
ScoreState,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
areaA: 'badminton-scoreboard::area-a',
|
||||||
|
areaB: 'badminton-scoreboard::area-b',
|
||||||
|
history: 'badminton-scoreboard::history',
|
||||||
|
targetDate: 'badminton-scoreboard::target-date',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const defaultAreaA = ['柏威', '建喵', 'Yuki', '阿釧']
|
||||||
|
const defaultAreaB = ['RURU', '玟瑄', '培根', 'Tim']
|
||||||
|
|
||||||
|
const initialScoreState: ScoreState = {
|
||||||
|
scoreLeft: 0,
|
||||||
|
scoreRight: 0,
|
||||||
|
gamesLeft: 0,
|
||||||
|
gamesRight: 0,
|
||||||
|
currentGame: 1,
|
||||||
|
targetScore: 21,
|
||||||
|
serving: null,
|
||||||
|
leftRightCourtPlayer: 'playerA',
|
||||||
|
rightRightCourtPlayer: 'playerA',
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettlementState = {
|
||||||
|
error: string
|
||||||
|
open: boolean
|
||||||
|
uploading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const location = useLocation()
|
||||||
|
const isScoreboardRoute = location.pathname === '/scoreboard'
|
||||||
|
|
||||||
|
const [targetDate, setTargetDate] = useState(() =>
|
||||||
|
loadStoredText(STORAGE_KEYS.targetDate, formatDateInputValue()),
|
||||||
|
)
|
||||||
|
const [areaAInput, setAreaAInput] = useState(() =>
|
||||||
|
loadStoredText(STORAGE_KEYS.areaA, defaultAreaA.join('\n')),
|
||||||
|
)
|
||||||
|
const [areaBInput, setAreaBInput] = useState(() =>
|
||||||
|
loadStoredText(STORAGE_KEYS.areaB, defaultAreaB.join('\n')),
|
||||||
|
)
|
||||||
|
const [groups, setGroups] = useState<RoundGroup[]>([])
|
||||||
|
const [groupSource, setGroupSource] = useState<'idle' | 'db' | 'manual'>('idle')
|
||||||
|
const [loadStatus, setLoadStatus] = useState<LoadStatus>('idle')
|
||||||
|
const [loadMessage, setLoadMessage] = useState('')
|
||||||
|
const [selectedGroupId, setSelectedGroupId] = useState<number | null>(null)
|
||||||
|
const [matchup, setMatchup] = useState<Matchup>({
|
||||||
|
leftTeamId: null,
|
||||||
|
rightTeamId: null,
|
||||||
|
})
|
||||||
|
const [scoreState, setScoreState] = useState<ScoreState>(initialScoreState)
|
||||||
|
const [scoreHistory, setScoreHistory] = useState<ScoreSnapshot[]>([])
|
||||||
|
const [pointLog, setPointLog] = useState<PointHistoryEntry[]>([])
|
||||||
|
const [history, setHistory] = useState<MatchHistoryItem[]>(() =>
|
||||||
|
loadStoredHistory(STORAGE_KEYS.history),
|
||||||
|
)
|
||||||
|
const [settlement, setSettlement] = useState<SettlementState>({
|
||||||
|
error: '',
|
||||||
|
open: false,
|
||||||
|
uploading: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput])
|
||||||
|
const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput])
|
||||||
|
const selectedGroup = groups.find((group) => group.id === selectedGroupId) ?? null
|
||||||
|
const leftTeam =
|
||||||
|
selectedGroup?.teams.find((team) => team.id === matchup.leftTeamId) ?? null
|
||||||
|
const rightTeam =
|
||||||
|
selectedGroup?.teams.find((team) => team.id === matchup.rightTeamId) ?? null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.localStorage.setItem(STORAGE_KEYS.targetDate, targetDate)
|
||||||
|
}, [targetDate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.localStorage.setItem(STORAGE_KEYS.areaA, areaAInput)
|
||||||
|
}, [areaAInput])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.localStorage.setItem(STORAGE_KEYS.areaB, areaBInput)
|
||||||
|
}, [areaBInput])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.localStorage.setItem(STORAGE_KEYS.history, JSON.stringify(history))
|
||||||
|
}, [history])
|
||||||
|
|
||||||
|
const resetScoring = (nextState: ScoreState = initialScoreState) => {
|
||||||
|
setScoreState(nextState)
|
||||||
|
setScoreHistory([])
|
||||||
|
setPointLog([])
|
||||||
|
setSettlement({
|
||||||
|
error: '',
|
||||||
|
open: false,
|
||||||
|
uploading: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectGroup = (groupId: number, nextGroups = groups) => {
|
||||||
|
const nextGroup = nextGroups.find((group) => group.id === groupId)
|
||||||
|
const firstTeam = nextGroup?.teams[0] ?? null
|
||||||
|
const secondTeam = nextGroup?.teams[1] ?? null
|
||||||
|
|
||||||
|
setSelectedGroupId(nextGroup?.id ?? null)
|
||||||
|
setMatchup({
|
||||||
|
leftTeamId: firstTeam?.id ?? null,
|
||||||
|
rightTeamId: secondTeam?.id ?? null,
|
||||||
|
})
|
||||||
|
resetScoring()
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyMatchup = (leftTeamId: number, rightTeamId: number) => {
|
||||||
|
setMatchup({
|
||||||
|
leftTeamId,
|
||||||
|
rightTeamId,
|
||||||
|
})
|
||||||
|
resetScoring()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadGroupsFromDb = async () => {
|
||||||
|
if (!targetDate) {
|
||||||
|
setLoadStatus('error')
|
||||||
|
setLoadMessage('請先選擇日期。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadStatus('loading')
|
||||||
|
setLoadMessage('正在讀取指定日期的分組資料...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const record = await loadMatchResults(convertDateToKey(targetDate))
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
setGroups([])
|
||||||
|
setSelectedGroupId(null)
|
||||||
|
setMatchup({ leftTeamId: null, rightTeamId: null })
|
||||||
|
setGroupSource('idle')
|
||||||
|
setLoadStatus('empty')
|
||||||
|
setLoadMessage('指定日期沒有資料,請改用手動配對。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextData = convertDbRecordToGroups(record)
|
||||||
|
setAreaAInput(nextData.areaA.join('\n'))
|
||||||
|
setAreaBInput(nextData.areaB.join('\n'))
|
||||||
|
setGroups(nextData.groups)
|
||||||
|
setGroupSource('db')
|
||||||
|
setLoadStatus('loaded')
|
||||||
|
setLoadMessage(`已載入 ${convertDateToKey(targetDate)} 的分組資料。`)
|
||||||
|
selectGroup(nextData.groups[0]?.id ?? 1, nextData.groups)
|
||||||
|
} catch (error) {
|
||||||
|
setGroups([])
|
||||||
|
setSelectedGroupId(null)
|
||||||
|
setMatchup({ leftTeamId: null, rightTeamId: null })
|
||||||
|
setGroupSource('idle')
|
||||||
|
setLoadStatus('error')
|
||||||
|
setLoadMessage(error instanceof Error ? error.message : '讀取資料失敗。')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateManualGroups = () => {
|
||||||
|
if (parsedAreaA.length === 0 || parsedAreaB.length === 0) {
|
||||||
|
setGroups([])
|
||||||
|
setSelectedGroupId(null)
|
||||||
|
setMatchup({ leftTeamId: null, rightTeamId: null })
|
||||||
|
setGroupSource('idle')
|
||||||
|
setLoadStatus('error')
|
||||||
|
setLoadMessage('A 區與 B 區至少都要有 1 位成員。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextGroups = buildManualGroups(parsedAreaA, parsedAreaB)
|
||||||
|
setGroups(nextGroups)
|
||||||
|
setGroupSource('manual')
|
||||||
|
setLoadStatus('loaded')
|
||||||
|
setLoadMessage('已產生手動配對結果,請選擇要使用的組別。')
|
||||||
|
selectGroup(nextGroups[0]?.id ?? 1, nextGroups)
|
||||||
|
}
|
||||||
|
|
||||||
|
const swapMatchupSides = () => {
|
||||||
|
if (scoreHistory.length > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setMatchup((current) => ({
|
||||||
|
leftTeamId: current.rightTeamId,
|
||||||
|
rightTeamId: current.leftTeamId,
|
||||||
|
}))
|
||||||
|
|
||||||
|
setScoreState((current) => ({
|
||||||
|
...current,
|
||||||
|
scoreLeft: current.scoreRight,
|
||||||
|
scoreRight: current.scoreLeft,
|
||||||
|
gamesLeft: current.gamesRight,
|
||||||
|
gamesRight: current.gamesLeft,
|
||||||
|
serving:
|
||||||
|
current.serving === 'left'
|
||||||
|
? 'right'
|
||||||
|
: current.serving === 'right'
|
||||||
|
? 'left'
|
||||||
|
: null,
|
||||||
|
leftRightCourtPlayer: current.rightRightCourtPlayer,
|
||||||
|
rightRightCourtPlayer: current.leftRightCourtPlayer,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const swapTeamPlayers = (side: ScoreSide) => {
|
||||||
|
if (scoreHistory.length > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setScoreState((current) => ({
|
||||||
|
...current,
|
||||||
|
leftRightCourtPlayer:
|
||||||
|
side === 'left'
|
||||||
|
? swapCourtPositions(current.leftRightCourtPlayer)
|
||||||
|
: current.leftRightCourtPlayer,
|
||||||
|
rightRightCourtPlayer:
|
||||||
|
side === 'right'
|
||||||
|
? swapCourtPositions(current.rightRightCourtPlayer)
|
||||||
|
: current.rightRightCourtPlayer,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const setServing = (side: ScoreSide) => {
|
||||||
|
if (scoreHistory.length > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setScoreState((current) => ({
|
||||||
|
...current,
|
||||||
|
serving: side,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordPoint = (side: ScoreSide) => {
|
||||||
|
if (!leftTeam || !rightTeam || scoreState.serving === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const starter = getServerHistoryIndex(scoreState, leftTeam, rightTeam)
|
||||||
|
|
||||||
|
if (starter === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const winner: 0 | 1 = side === 'left' ? 0 : 1
|
||||||
|
const previousPoint = pointLog.at(-1)
|
||||||
|
const winCount = previousPoint?.winner === winner ? previousPoint.winCount + 1 : 0
|
||||||
|
|
||||||
|
const nextPointLog = [
|
||||||
|
...pointLog,
|
||||||
|
{
|
||||||
|
round: pointLog.length,
|
||||||
|
starter,
|
||||||
|
winCount,
|
||||||
|
winner,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const nextScoreState: ScoreState = {
|
||||||
|
...scoreState,
|
||||||
|
scoreLeft: side === 'left' ? scoreState.scoreLeft + 1 : scoreState.scoreLeft,
|
||||||
|
scoreRight: side === 'right' ? scoreState.scoreRight + 1 : scoreState.scoreRight,
|
||||||
|
serving: side,
|
||||||
|
leftRightCourtPlayer:
|
||||||
|
side === 'left' && side === scoreState.serving
|
||||||
|
? swapCourtPositions(scoreState.leftRightCourtPlayer)
|
||||||
|
: scoreState.leftRightCourtPlayer,
|
||||||
|
rightRightCourtPlayer:
|
||||||
|
side === 'right' && side === scoreState.serving
|
||||||
|
? swapCourtPositions(scoreState.rightRightCourtPlayer)
|
||||||
|
: scoreState.rightRightCourtPlayer,
|
||||||
|
}
|
||||||
|
|
||||||
|
setScoreHistory((current) => [...current, { pointLog, scoreState }])
|
||||||
|
setPointLog(nextPointLog)
|
||||||
|
setScoreState(nextScoreState)
|
||||||
|
}
|
||||||
|
|
||||||
|
const undoLastPoint = () => {
|
||||||
|
const previous = scoreHistory.at(-1)
|
||||||
|
|
||||||
|
if (!previous) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setScoreHistory((current) => current.slice(0, -1))
|
||||||
|
setPointLog(previous.pointLog)
|
||||||
|
setScoreState(previous.scoreState)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSettlementDialog = () => {
|
||||||
|
if (!leftTeam || !rightTeam || pointLog.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSettlement({
|
||||||
|
error: '',
|
||||||
|
open: true,
|
||||||
|
uploading: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeSettlementDialog = () => {
|
||||||
|
if (settlement.uploading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSettlement((current) => ({
|
||||||
|
...current,
|
||||||
|
error: '',
|
||||||
|
open: false,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipUpload = () => {
|
||||||
|
setSettlement({
|
||||||
|
error: '',
|
||||||
|
open: false,
|
||||||
|
uploading: false,
|
||||||
|
})
|
||||||
|
resetScoring()
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadSettledMatch = async () => {
|
||||||
|
if (!leftTeam || !rightTeam || !selectedGroup || pointLog.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSettlement((current) => ({
|
||||||
|
...current,
|
||||||
|
error: '',
|
||||||
|
uploading: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = buildHistoryPayload({
|
||||||
|
leftTeam,
|
||||||
|
pointLog,
|
||||||
|
rightTeam,
|
||||||
|
scoreState,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await saveMatchHistory(payload)
|
||||||
|
|
||||||
|
const historyItem: MatchHistoryItem = {
|
||||||
|
id: String(result.id),
|
||||||
|
playedAt: formatPlayedAt(payload.time),
|
||||||
|
matchDate: targetDate,
|
||||||
|
source: groupSource,
|
||||||
|
groupId: selectedGroup.id,
|
||||||
|
leftTeamName: getTeamDisplayName(leftTeam),
|
||||||
|
rightTeamName: getTeamDisplayName(rightTeam),
|
||||||
|
scoreLeft: scoreState.scoreLeft,
|
||||||
|
scoreRight: scoreState.scoreRight,
|
||||||
|
winner: getWinnerName(
|
||||||
|
getTeamDisplayName(leftTeam),
|
||||||
|
getTeamDisplayName(rightTeam),
|
||||||
|
scoreState,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
setHistory((current) => [historyItem, ...current])
|
||||||
|
setSettlement({
|
||||||
|
error: '',
|
||||||
|
open: false,
|
||||||
|
uploading: false,
|
||||||
|
})
|
||||||
|
resetScoring()
|
||||||
|
} catch (error) {
|
||||||
|
setSettlement({
|
||||||
|
error: error instanceof Error ? error.message : '上傳戰績失敗。',
|
||||||
|
open: true,
|
||||||
|
uploading: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app-shell">
|
<div className={isScoreboardRoute ? 'app-shell app-shell-scoreboard' : 'app-shell'}>
|
||||||
<section className="hero-panel">
|
<header className={isScoreboardRoute ? 'topbar topbar-compact' : 'topbar'}>
|
||||||
<div className="hero-copy">
|
<div className="branding">
|
||||||
<span className="eyebrow">Vite + React + TypeScript</span>
|
<p className="eyebrow">Badminton Scoreboard</p>
|
||||||
<h1>羽毛球記分板</h1>
|
<h1>{isScoreboardRoute ? '羽毛球記分板' : '羽毛球分組與記分板'}</h1>
|
||||||
<p className="hero-text">
|
{!isScoreboardRoute ? (
|
||||||
專案已完成初始化,接下來可以往單打、雙打、發球權切換、局數統計與賽事模式繼續擴充。
|
<p className="intro-copy">
|
||||||
</p>
|
先選日期或手動建立組別,再從該組挑選要對打的兩隊進入記分板。比賽結束後可直接上傳戰績到
|
||||||
|
DB。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="status-strip" aria-label="專案狀態">
|
<nav className="topnav" aria-label="主要導覽">
|
||||||
<span>即時比分</span>
|
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/teams">
|
||||||
<span>局數追蹤</span>
|
選隊伍
|
||||||
<span>Docker Ready</span>
|
</NavLink>
|
||||||
</div>
|
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/scoreboard">
|
||||||
</section>
|
記分板
|
||||||
|
</NavLink>
|
||||||
|
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/history">
|
||||||
|
歷史戰績
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
<section className="scoreboard-card" aria-label="比賽記分板預覽">
|
<Routes>
|
||||||
<div className="board-header">
|
<Route
|
||||||
<div>
|
path="/"
|
||||||
<p className="label">友誼賽</p>
|
element={
|
||||||
<h2>中央球場</h2>
|
<TeamSelectionPage
|
||||||
</div>
|
areaAInput={areaAInput}
|
||||||
<div className="match-meta">
|
areaBInput={areaBInput}
|
||||||
<span>第 2 局</span>
|
groups={groups}
|
||||||
<span>21 分制</span>
|
groupSource={groupSource}
|
||||||
</div>
|
loadMessage={loadMessage}
|
||||||
</div>
|
loadStatus={loadStatus}
|
||||||
|
parsedAreaA={parsedAreaA}
|
||||||
<div className="score-grid">
|
parsedAreaB={parsedAreaB}
|
||||||
<article className="team-card">
|
selectedGroupId={selectedGroupId}
|
||||||
<p className="team-tag">A 隊</p>
|
targetDate={targetDate}
|
||||||
<h3>林 / 陳</h3>
|
onAreaAInputChange={setAreaAInput}
|
||||||
<strong>18</strong>
|
onAreaBInputChange={setAreaBInput}
|
||||||
<p>上一局 21 : 16</p>
|
onGenerateManualGroups={generateManualGroups}
|
||||||
</article>
|
onLoadGroupsFromDb={() => void loadGroupsFromDb()}
|
||||||
|
onSelectGroup={selectGroup}
|
||||||
<article className="team-card team-card-active">
|
onTargetDateChange={setTargetDate}
|
||||||
<p className="team-tag">B 隊</p>
|
onUseGroup={selectGroup}
|
||||||
<h3>王 / 黃</h3>
|
/>
|
||||||
<strong>21</strong>
|
}
|
||||||
<p>目前發球權</p>
|
/>
|
||||||
</article>
|
<Route
|
||||||
</div>
|
path="/teams"
|
||||||
|
element={
|
||||||
<div className="detail-grid">
|
<TeamSelectionPage
|
||||||
<div>
|
areaAInput={areaAInput}
|
||||||
<span className="label">本局節奏</span>
|
areaBInput={areaBInput}
|
||||||
<p>多拍相持偏多,比分進入收尾階段。</p>
|
groups={groups}
|
||||||
</div>
|
groupSource={groupSource}
|
||||||
<div>
|
loadMessage={loadMessage}
|
||||||
<span className="label">下一步</span>
|
loadStatus={loadStatus}
|
||||||
<p>把計分控制、局數規則與賽事資料模型串起來。</p>
|
parsedAreaA={parsedAreaA}
|
||||||
</div>
|
parsedAreaB={parsedAreaB}
|
||||||
</div>
|
selectedGroupId={selectedGroupId}
|
||||||
</section>
|
targetDate={targetDate}
|
||||||
</main>
|
onAreaAInputChange={setAreaAInput}
|
||||||
|
onAreaBInputChange={setAreaBInput}
|
||||||
|
onGenerateManualGroups={generateManualGroups}
|
||||||
|
onLoadGroupsFromDb={() => void loadGroupsFromDb()}
|
||||||
|
onSelectGroup={selectGroup}
|
||||||
|
onTargetDateChange={setTargetDate}
|
||||||
|
onUseGroup={selectGroup}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/scoreboard"
|
||||||
|
element={
|
||||||
|
<ScoreboardPage
|
||||||
|
finishDialogError={settlement.error}
|
||||||
|
finishDialogOpen={settlement.open}
|
||||||
|
finishDialogUploading={settlement.uploading}
|
||||||
|
groupSource={groupSource}
|
||||||
|
hasRecordedPoint={pointLog.length > 0}
|
||||||
|
leftTeam={leftTeam}
|
||||||
|
matchup={matchup}
|
||||||
|
rightTeam={rightTeam}
|
||||||
|
scoreState={scoreState}
|
||||||
|
selectedGroup={selectedGroup}
|
||||||
|
targetDate={targetDate}
|
||||||
|
onApplyMatchup={applyMatchup}
|
||||||
|
onCloseFinishDialog={closeSettlementDialog}
|
||||||
|
onConfirmUpload={uploadSettledMatch}
|
||||||
|
onOpenFinishDialog={openSettlementDialog}
|
||||||
|
onRecordPoint={recordPoint}
|
||||||
|
onSetServing={setServing}
|
||||||
|
onSkipUpload={skipUpload}
|
||||||
|
onSwapMatchup={swapMatchupSides}
|
||||||
|
onSwapTeamPlayers={swapTeamPlayers}
|
||||||
|
onUndoLastPoint={undoLastPoint}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/history" element={<HistoryPage history={history} />} />
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildHistoryPayload({
|
||||||
|
leftTeam,
|
||||||
|
pointLog,
|
||||||
|
rightTeam,
|
||||||
|
scoreState,
|
||||||
|
}: {
|
||||||
|
leftTeam: GroupTeam
|
||||||
|
pointLog: PointHistoryEntry[]
|
||||||
|
rightTeam: GroupTeam
|
||||||
|
scoreState: ScoreState
|
||||||
|
}): HistoryUploadPayload {
|
||||||
|
const players = [
|
||||||
|
leftTeam.playerA,
|
||||||
|
leftTeam.playerB,
|
||||||
|
rightTeam.playerB,
|
||||||
|
rightTeam.playerA,
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
dayOfWeek: new Date().getDay(),
|
||||||
|
players,
|
||||||
|
score: [scoreState.scoreLeft, scoreState.scoreRight],
|
||||||
|
scoreList: pointLog.map((point) => [
|
||||||
|
point.round,
|
||||||
|
point.starter,
|
||||||
|
point.winCount,
|
||||||
|
point.winner,
|
||||||
|
]),
|
||||||
|
team: [
|
||||||
|
[leftTeam.playerA, leftTeam.playerB],
|
||||||
|
[rightTeam.playerB, rightTeam.playerA],
|
||||||
|
],
|
||||||
|
time: Math.floor(Date.now() / 1000),
|
||||||
|
type: 0,
|
||||||
|
winScore: scoreState.targetScore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerHistoryIndex(
|
||||||
|
state: ScoreState,
|
||||||
|
leftTeam: GroupTeam,
|
||||||
|
rightTeam: GroupTeam,
|
||||||
|
) {
|
||||||
|
if (state.serving === 'left') {
|
||||||
|
const server = getServingPlayer(leftTeam, state.leftRightCourtPlayer, state.scoreLeft)
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.slot === 'playerA' ? 0 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.serving === 'right') {
|
||||||
|
const server = getServingPlayer(rightTeam, state.rightRightCourtPlayer, state.scoreRight)
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.slot === 'playerB' ? 2 : 3
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPlayedAt(timestamp: number) {
|
||||||
|
return new Date(timestamp * 1000).toLocaleString('zh-TW', { hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStoredText(storageKey: string, fallback: string) {
|
||||||
|
const value = window.localStorage.getItem(storageKey)
|
||||||
|
return value && value.trim() ? value : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStoredHistory(storageKey: string) {
|
||||||
|
const value = window.localStorage.getItem(storageKey)
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as MatchHistoryItem[]
|
||||||
|
return Array.isArray(parsed) ? parsed : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
46
src/lib/api.ts
Normal file
46
src/lib/api.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type {
|
||||||
|
HistoryUploadPayload,
|
||||||
|
HistoryUploadResponse,
|
||||||
|
MatchResultsRecord,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
export async function loadMatchResults(time: string) {
|
||||||
|
const response = await fetch(`/api/match-results/${time}`)
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
ok?: boolean
|
||||||
|
message?: string
|
||||||
|
data?: MatchResultsRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !payload.ok) {
|
||||||
|
throw new Error(payload.message ?? '無法讀取對戰資料。')
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.data ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveMatchHistory(payload: HistoryUploadPayload) {
|
||||||
|
const response = await fetch('/api/history', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = (await response.json()) as {
|
||||||
|
ok?: boolean
|
||||||
|
message?: string
|
||||||
|
data?: HistoryUploadResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !result.ok || !result.data) {
|
||||||
|
throw new Error(result.message ?? '無法上傳戰績。')
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
167
src/lib/match.ts
Normal file
167
src/lib/match.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import type {
|
||||||
|
CourtSide,
|
||||||
|
GroupTeam,
|
||||||
|
MatchResultsRecord,
|
||||||
|
PlayerSlot,
|
||||||
|
ScoreState,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
const PLACEHOLDER_NAME = '輪空'
|
||||||
|
const TOTAL_GROUPS = 3
|
||||||
|
|
||||||
|
export function parseRoster(input: string) {
|
||||||
|
const uniqueNames = new Set<string>()
|
||||||
|
|
||||||
|
input
|
||||||
|
.split(/[\n,,、]+/g)
|
||||||
|
.map((name) => name.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.forEach((name) => uniqueNames.add(name))
|
||||||
|
|
||||||
|
return Array.from(uniqueNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildManualGroups(areaA: string[], areaB: string[]) {
|
||||||
|
const targetCount = Math.max(areaA.length, areaB.length)
|
||||||
|
const shuffledA = shuffleList(areaA)
|
||||||
|
const shuffledB = shuffleList(areaB)
|
||||||
|
const paddedA = padTeams(shuffledA, targetCount)
|
||||||
|
const paddedB = padTeams(shuffledB, targetCount)
|
||||||
|
|
||||||
|
return Array.from({ length: TOTAL_GROUPS }, (_, roundIndex) => ({
|
||||||
|
id: roundIndex + 1,
|
||||||
|
teams: paddedA.map((playerA, index) =>
|
||||||
|
createTeam(
|
||||||
|
index + 1,
|
||||||
|
playerA,
|
||||||
|
paddedB[(index + roundIndex) % paddedB.length],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertDbRecordToGroups(record: MatchResultsRecord) {
|
||||||
|
const personnel = JSON.parse(record.personnel) as [number, string][]
|
||||||
|
const battlecombination = JSON.parse(record.battlecombination ?? '{}') as Record<
|
||||||
|
string,
|
||||||
|
[string, string][]
|
||||||
|
>
|
||||||
|
|
||||||
|
const areaA = personnel.filter(([group]) => group === 1).map(([, name]) => name)
|
||||||
|
const areaB = personnel.filter(([group]) => group === 0).map(([, name]) => name)
|
||||||
|
|
||||||
|
const groups = Object.keys(battlecombination)
|
||||||
|
.sort((left, right) => Number(left) - Number(right))
|
||||||
|
.map((key, roundIndex) => ({
|
||||||
|
id: roundIndex + 1,
|
||||||
|
teams: battlecombination[key].map(([playerA, playerB], teamIndex) =>
|
||||||
|
createTeam(teamIndex + 1, playerA, playerB),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { areaA, areaB, groups }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTeamDisplayName(team: GroupTeam) {
|
||||||
|
return `${team.playerA} / ${team.playerB}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWinnerName(
|
||||||
|
leftTeamName: string,
|
||||||
|
rightTeamName: string,
|
||||||
|
scoreState: ScoreState,
|
||||||
|
) {
|
||||||
|
return scoreState.scoreLeft >= scoreState.scoreRight ? leftTeamName : rightTeamName
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateInputValue() {
|
||||||
|
const now = new Date()
|
||||||
|
const year = now.getFullYear()
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(now.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertDateToKey(dateValue: string) {
|
||||||
|
const [year = '', month = '', day = ''] = dateValue.split('-')
|
||||||
|
return `${year}${month}${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServiceCourt(score: number): CourtSide {
|
||||||
|
return score % 2 === 0 ? 'right' : 'left'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCourtAssignments(team: GroupTeam, rightCourtPlayer: PlayerSlot) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
slot: 'playerA' as const,
|
||||||
|
name: team.playerA,
|
||||||
|
court: (rightCourtPlayer === 'playerA' ? 'right' : 'left') as CourtSide,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slot: 'playerB' as const,
|
||||||
|
name: team.playerB,
|
||||||
|
court: (rightCourtPlayer === 'playerB' ? 'right' : 'left') as CourtSide,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlayerOnCourt(
|
||||||
|
team: GroupTeam,
|
||||||
|
rightCourtPlayer: PlayerSlot,
|
||||||
|
targetCourt: CourtSide,
|
||||||
|
) {
|
||||||
|
const assignments = getCourtAssignments(team, rightCourtPlayer)
|
||||||
|
return assignments.find((assignment) => assignment.court === targetCourt) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServingPlayer(
|
||||||
|
team: GroupTeam,
|
||||||
|
rightCourtPlayer: PlayerSlot,
|
||||||
|
score: number,
|
||||||
|
) {
|
||||||
|
return getPlayerOnCourt(team, rightCourtPlayer, getServiceCourt(score))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReceivingPlayer(
|
||||||
|
team: GroupTeam,
|
||||||
|
rightCourtPlayer: PlayerSlot,
|
||||||
|
servingScore: number,
|
||||||
|
) {
|
||||||
|
return getPlayerOnCourt(team, rightCourtPlayer, getServiceCourt(servingScore))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function swapCourtPositions(rightCourtPlayer: PlayerSlot): PlayerSlot {
|
||||||
|
return rightCourtPlayer === 'playerA' ? 'playerB' : 'playerA'
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTeam(id: number, playerA: string, playerB: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
playerA,
|
||||||
|
playerB,
|
||||||
|
isPlaceholderA: playerA === PLACEHOLDER_NAME,
|
||||||
|
isPlaceholderB: playerB === PLACEHOLDER_NAME,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function padTeams(list: string[], targetCount: number) {
|
||||||
|
const next = [...list]
|
||||||
|
|
||||||
|
while (next.length < targetCount) {
|
||||||
|
next.push(PLACEHOLDER_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffleList<T>(list: T[]) {
|
||||||
|
const next = [...list]
|
||||||
|
|
||||||
|
for (let index = next.length - 1; index > 0; index -= 1) {
|
||||||
|
const swapIndex = Math.floor(Math.random() * (index + 1))
|
||||||
|
;[next[index], next[swapIndex]] = [next[swapIndex], next[index]]
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
49
src/pages/HistoryPage.tsx
Normal file
49
src/pages/HistoryPage.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { MatchHistoryItem } from '../types'
|
||||||
|
|
||||||
|
type HistoryPageProps = {
|
||||||
|
history: MatchHistoryItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryPage({ history }: HistoryPageProps) {
|
||||||
|
return (
|
||||||
|
<section className="page-grid">
|
||||||
|
<article className="panel panel-hero">
|
||||||
|
<p className="panel-kicker">History</p>
|
||||||
|
<h2>歷史戰績</h2>
|
||||||
|
<p className="panel-copy">這裡會顯示本機目前這次操作中,已經成功上傳到 DB 的比賽結果。</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel full-span">
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<h3>目前還沒有戰績</h3>
|
||||||
|
<p>完成比賽結算並上傳到 DB 後,這裡就會看到紀錄。</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="history-list">
|
||||||
|
{history.map((item) => (
|
||||||
|
<article className="history-card" key={item.id}>
|
||||||
|
<div className="history-head">
|
||||||
|
<div>
|
||||||
|
<p className="panel-kicker">{item.playedAt}</p>
|
||||||
|
<h3>
|
||||||
|
{item.leftTeamName} vs {item.rightTeamName}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<span className="winner-badge">勝方:{item.winner}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="history-meta">
|
||||||
|
<span>比賽日期:{item.matchDate || '-'}</span>
|
||||||
|
<span>資料來源:{item.source === 'db' ? 'DB' : item.source === 'manual' ? '手動' : '-'}</span>
|
||||||
|
<span>第 {item.groupId} 組</span>
|
||||||
|
<span>比分:{item.scoreLeft} - {item.scoreRight}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
671
src/pages/ScoreboardPage.tsx
Normal file
671
src/pages/ScoreboardPage.tsx
Normal file
@@ -0,0 +1,671 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
getCourtAssignments,
|
||||||
|
getReceivingPlayer,
|
||||||
|
getServiceCourt,
|
||||||
|
getServingPlayer,
|
||||||
|
getTeamDisplayName,
|
||||||
|
} from '../lib/match'
|
||||||
|
import type {
|
||||||
|
CourtSide,
|
||||||
|
GroupTeam,
|
||||||
|
Matchup,
|
||||||
|
PlayerSlot,
|
||||||
|
RoundGroup,
|
||||||
|
ScoreSide,
|
||||||
|
ScoreState,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
type ScoreboardPageProps = {
|
||||||
|
finishDialogError: string
|
||||||
|
finishDialogOpen: boolean
|
||||||
|
finishDialogUploading: boolean
|
||||||
|
groupSource: 'idle' | 'db' | 'manual'
|
||||||
|
hasRecordedPoint: boolean
|
||||||
|
leftTeam: GroupTeam | null
|
||||||
|
matchup: Matchup
|
||||||
|
rightTeam: GroupTeam | null
|
||||||
|
scoreState: ScoreState
|
||||||
|
selectedGroup: RoundGroup | null
|
||||||
|
targetDate: string
|
||||||
|
onApplyMatchup: (leftTeamId: number, rightTeamId: number) => void
|
||||||
|
onCloseFinishDialog: () => void
|
||||||
|
onConfirmUpload: () => void
|
||||||
|
onOpenFinishDialog: () => void
|
||||||
|
onRecordPoint: (side: ScoreSide) => void
|
||||||
|
onSetServing: (side: ScoreSide) => void
|
||||||
|
onSkipUpload: () => void
|
||||||
|
onSwapMatchup: () => void
|
||||||
|
onSwapTeamPlayers: (side: ScoreSide) => void
|
||||||
|
onUndoLastPoint: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScoreboardPage({
|
||||||
|
finishDialogError,
|
||||||
|
finishDialogOpen,
|
||||||
|
finishDialogUploading,
|
||||||
|
groupSource,
|
||||||
|
hasRecordedPoint,
|
||||||
|
leftTeam,
|
||||||
|
matchup,
|
||||||
|
rightTeam,
|
||||||
|
scoreState,
|
||||||
|
selectedGroup,
|
||||||
|
targetDate,
|
||||||
|
onApplyMatchup,
|
||||||
|
onCloseFinishDialog,
|
||||||
|
onConfirmUpload,
|
||||||
|
onOpenFinishDialog,
|
||||||
|
onRecordPoint,
|
||||||
|
onSetServing,
|
||||||
|
onSkipUpload,
|
||||||
|
onSwapMatchup,
|
||||||
|
onSwapTeamPlayers,
|
||||||
|
onUndoLastPoint,
|
||||||
|
}: ScoreboardPageProps) {
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
|
const [draftTeamIds, setDraftTeamIds] = useState<number[]>([])
|
||||||
|
const [clock, setClock] = useState(() => formatClock())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
setClock(formatClock())
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const canArrangeMatch = !hasRecordedPoint
|
||||||
|
const canScore = scoreState.serving !== null
|
||||||
|
|
||||||
|
const servingScore =
|
||||||
|
scoreState.serving === 'left' ? scoreState.scoreLeft : scoreState.scoreRight
|
||||||
|
const servingCourt =
|
||||||
|
scoreState.serving === null ? null : getServiceCourt(servingScore)
|
||||||
|
|
||||||
|
const leftAssignments = useMemo(
|
||||||
|
() =>
|
||||||
|
leftTeam ? getCourtAssignments(leftTeam, scoreState.leftRightCourtPlayer) : [],
|
||||||
|
[leftTeam, scoreState.leftRightCourtPlayer],
|
||||||
|
)
|
||||||
|
const rightAssignments = useMemo(
|
||||||
|
() =>
|
||||||
|
rightTeam ? getCourtAssignments(rightTeam, scoreState.rightRightCourtPlayer) : [],
|
||||||
|
[rightTeam, scoreState.rightRightCourtPlayer],
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentServer =
|
||||||
|
scoreState.serving === 'left'
|
||||||
|
? leftTeam
|
||||||
|
? getServingPlayer(leftTeam, scoreState.leftRightCourtPlayer, scoreState.scoreLeft)
|
||||||
|
: null
|
||||||
|
: scoreState.serving === 'right'
|
||||||
|
? rightTeam
|
||||||
|
? getServingPlayer(
|
||||||
|
rightTeam,
|
||||||
|
scoreState.rightRightCourtPlayer,
|
||||||
|
scoreState.scoreRight,
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
: null
|
||||||
|
|
||||||
|
const currentReceiver =
|
||||||
|
scoreState.serving === 'left'
|
||||||
|
? rightTeam
|
||||||
|
? getReceivingPlayer(
|
||||||
|
rightTeam,
|
||||||
|
scoreState.rightRightCourtPlayer,
|
||||||
|
scoreState.scoreLeft,
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
: scoreState.serving === 'right'
|
||||||
|
? leftTeam
|
||||||
|
? getReceivingPlayer(
|
||||||
|
leftTeam,
|
||||||
|
scoreState.leftRightCourtPlayer,
|
||||||
|
scoreState.scoreRight,
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!selectedGroup) {
|
||||||
|
return (
|
||||||
|
<section className="page-grid">
|
||||||
|
<article className="panel panel-hero">
|
||||||
|
<p className="panel-kicker">Step 3</p>
|
||||||
|
<h2>先到選隊伍頁面建立對戰組合</h2>
|
||||||
|
<p className="panel-copy">
|
||||||
|
目前還沒有可用的組別。先載入指定日期資料,或手動建立分組後,再回來開始記分。
|
||||||
|
</p>
|
||||||
|
<Link className="primary-button inline-link" to="/teams">
|
||||||
|
前往選隊伍
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchupLabel =
|
||||||
|
leftTeam && rightTeam
|
||||||
|
? `${getTeamDisplayName(leftTeam)} vs ${getTeamDisplayName(rightTeam)}`
|
||||||
|
: '尚未設定對戰隊伍'
|
||||||
|
|
||||||
|
const openPicker = () => {
|
||||||
|
const next = [matchup.leftTeamId, matchup.rightTeamId].filter(
|
||||||
|
(value): value is number => value !== null,
|
||||||
|
)
|
||||||
|
|
||||||
|
setDraftTeamIds(next)
|
||||||
|
setPickerOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleDraftTeam = (teamId: number) => {
|
||||||
|
setDraftTeamIds((current) => {
|
||||||
|
if (current.includes(teamId)) {
|
||||||
|
return current.filter((value) => value !== teamId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.length >= 2) {
|
||||||
|
return [current[1], teamId]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...current, teamId]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDraftTeams = () => {
|
||||||
|
if (draftTeamIds.length !== 2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onApplyMatchup(draftTeamIds[0], draftTeamIds[1])
|
||||||
|
setPickerOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoPickDraftTeams = () => {
|
||||||
|
const shuffled = [...selectedGroup.teams]
|
||||||
|
|
||||||
|
for (let index = shuffled.length - 1; index > 0; index -= 1) {
|
||||||
|
const swapIndex = Math.floor(Math.random() * (index + 1))
|
||||||
|
;[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]]
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraftTeamIds(shuffled.slice(0, 2).map((team) => team.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="scoreboard-screen">
|
||||||
|
<div className="scoreboard-court">
|
||||||
|
<ScoreboardTeamPanel
|
||||||
|
assignments={leftAssignments}
|
||||||
|
canArrangeMatch={canArrangeMatch}
|
||||||
|
canScore={canScore}
|
||||||
|
currentReceiver={scoreState.serving === 'right' ? currentReceiver?.name ?? null : null}
|
||||||
|
currentServer={scoreState.serving === 'left' ? currentServer?.name ?? null : null}
|
||||||
|
onRecordPoint={() => onRecordPoint('left')}
|
||||||
|
onSetServing={() => onSetServing('left')}
|
||||||
|
onSwapPlayers={() => onSwapTeamPlayers('left')}
|
||||||
|
onSwapTeams={onSwapMatchup}
|
||||||
|
score={scoreState.scoreLeft}
|
||||||
|
serviceCourt={scoreState.serving === 'left' ? servingCourt : null}
|
||||||
|
team={leftTeam}
|
||||||
|
teamSlot="top"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="scoreboard-center-banner">
|
||||||
|
<p>{scoreState.serving === null ? '請設定發球方' : '點擊分數開始計分'}</p>
|
||||||
|
<small>
|
||||||
|
{scoreState.serving === null
|
||||||
|
? '先在上方或下方按下先攻'
|
||||||
|
: `目前發球:${currentServer?.name ?? '-'}${
|
||||||
|
currentReceiver ? ` / 接發:${currentReceiver.name}` : ''
|
||||||
|
}`}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScoreboardTeamPanel
|
||||||
|
assignments={rightAssignments}
|
||||||
|
canArrangeMatch={canArrangeMatch}
|
||||||
|
canScore={canScore}
|
||||||
|
currentReceiver={scoreState.serving === 'left' ? currentReceiver?.name ?? null : null}
|
||||||
|
currentServer={scoreState.serving === 'right' ? currentServer?.name ?? null : null}
|
||||||
|
onRecordPoint={() => onRecordPoint('right')}
|
||||||
|
onSetServing={() => onSetServing('right')}
|
||||||
|
onSwapPlayers={() => onSwapTeamPlayers('right')}
|
||||||
|
onSwapTeams={onSwapMatchup}
|
||||||
|
score={scoreState.scoreRight}
|
||||||
|
serviceCourt={scoreState.serving === 'right' ? servingCourt : null}
|
||||||
|
team={rightTeam}
|
||||||
|
teamSlot="bottom"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="scoreboard-rail">
|
||||||
|
<div className="rail-icon-grid">
|
||||||
|
{hasRecordedPoint ? (
|
||||||
|
<button className="rail-square-button" type="button" onClick={onUndoLastPoint}>
|
||||||
|
上一步
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="rail-square-button" type="button" onClick={openPicker}>
|
||||||
|
設定隊伍
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rail-clock">{clock}</div>
|
||||||
|
|
||||||
|
<button className="rail-pill rail-pill-danger" type="button" onClick={onOpenFinishDialog}>
|
||||||
|
比賽結算
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{pickerOpen ? (
|
||||||
|
<TeamPickerModal
|
||||||
|
currentLeftTeamId={matchup.leftTeamId}
|
||||||
|
currentRightTeamId={matchup.rightTeamId}
|
||||||
|
draftTeamIds={draftTeamIds}
|
||||||
|
group={selectedGroup}
|
||||||
|
selectionCount={draftTeamIds.length}
|
||||||
|
sourceLabel={groupSource === 'db' ? 'DB' : groupSource === 'manual' ? '手動' : '-'}
|
||||||
|
targetDate={targetDate}
|
||||||
|
onAutoPick={autoPickDraftTeams}
|
||||||
|
onClear={() => setDraftTeamIds([])}
|
||||||
|
onClose={() => setPickerOpen(false)}
|
||||||
|
onConfirm={confirmDraftTeams}
|
||||||
|
onToggleTeam={toggleDraftTeam}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{finishDialogOpen ? (
|
||||||
|
<FinishDialog
|
||||||
|
error={finishDialogError}
|
||||||
|
leftScore={scoreState.scoreLeft}
|
||||||
|
leftTeamName={leftTeam ? getTeamDisplayName(leftTeam) : '未設定'}
|
||||||
|
matchupLabel={matchupLabel}
|
||||||
|
rightScore={scoreState.scoreRight}
|
||||||
|
rightTeamName={rightTeam ? getTeamDisplayName(rightTeam) : '未設定'}
|
||||||
|
uploading={finishDialogUploading}
|
||||||
|
onClose={onCloseFinishDialog}
|
||||||
|
onConfirm={onConfirmUpload}
|
||||||
|
onSkip={onSkipUpload}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScoreboardTeamPanelProps = {
|
||||||
|
assignments: Array<{ slot: PlayerSlot; name: string; court: CourtSide }>
|
||||||
|
canArrangeMatch: boolean
|
||||||
|
canScore: boolean
|
||||||
|
currentReceiver: string | null
|
||||||
|
currentServer: string | null
|
||||||
|
onRecordPoint: () => void
|
||||||
|
onSetServing: () => void
|
||||||
|
onSwapPlayers: () => void
|
||||||
|
onSwapTeams: () => void
|
||||||
|
score: number
|
||||||
|
serviceCourt: CourtSide | null
|
||||||
|
team: GroupTeam | null
|
||||||
|
teamSlot: 'top' | 'bottom'
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScoreboardTeamPanel({
|
||||||
|
assignments,
|
||||||
|
canArrangeMatch,
|
||||||
|
canScore,
|
||||||
|
currentReceiver,
|
||||||
|
currentServer,
|
||||||
|
onRecordPoint,
|
||||||
|
onSetServing,
|
||||||
|
onSwapPlayers,
|
||||||
|
onSwapTeams,
|
||||||
|
score,
|
||||||
|
serviceCourt,
|
||||||
|
team,
|
||||||
|
teamSlot,
|
||||||
|
}: ScoreboardTeamPanelProps) {
|
||||||
|
const orderedAssignments = [...assignments].sort((left, right) => {
|
||||||
|
if (left.court === right.court) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.court === 'left' ? -1 : 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<div className="scoreboard-team-head">
|
||||||
|
<div className="team-head-main">
|
||||||
|
{orderedAssignments.map((assignment) => (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
assignment.name === currentServer
|
||||||
|
? 'scoreboard-name-chip scoreboard-name-chip-serving'
|
||||||
|
: 'scoreboard-name-chip'
|
||||||
|
}
|
||||||
|
key={assignment.slot}
|
||||||
|
>
|
||||||
|
<span className="team-number">{getPlayerNumber(teamSlot, assignment.slot)}</span>
|
||||||
|
<strong>{assignment.name}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="team-head-buttons">
|
||||||
|
<button
|
||||||
|
aria-label="上下交換隊伍"
|
||||||
|
className="team-icon-button"
|
||||||
|
disabled={!canArrangeMatch}
|
||||||
|
type="button"
|
||||||
|
onClick={onSwapTeams}
|
||||||
|
>
|
||||||
|
↕
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="左右交換隊員"
|
||||||
|
className="team-icon-button"
|
||||||
|
disabled={!canArrangeMatch}
|
||||||
|
type="button"
|
||||||
|
onClick={onSwapPlayers}
|
||||||
|
>
|
||||||
|
↔
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const serveBar = (
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
currentServer && !canArrangeMatch ? 'serve-lane serve-lane-locked' : 'serve-lane'
|
||||||
|
}
|
||||||
|
disabled={!canArrangeMatch || !team}
|
||||||
|
type="button"
|
||||||
|
onClick={onSetServing}
|
||||||
|
>
|
||||||
|
<span className="serve-lane-box" />
|
||||||
|
<span>先攻</span>
|
||||||
|
{currentServer ? (
|
||||||
|
<small>
|
||||||
|
發球位 {serviceCourt === 'left' ? '左' : serviceCourt === 'right' ? '右' : '-'}
|
||||||
|
{currentReceiver ? ` / 接發 ${currentReceiver}` : ''}
|
||||||
|
</small>
|
||||||
|
) : (
|
||||||
|
<small>選擇這一隊先攻</small>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
const scoreBoard = (
|
||||||
|
<button
|
||||||
|
className={canScore ? 'score-panel-surface score-panel-surface-live' : 'score-panel-surface'}
|
||||||
|
disabled={!canScore || !team}
|
||||||
|
type="button"
|
||||||
|
onClick={onRecordPoint}
|
||||||
|
>
|
||||||
|
<span className="score-panel-value">{score}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="scoreboard-team-section">
|
||||||
|
{teamSlot === 'top' ? (
|
||||||
|
<>
|
||||||
|
{header}
|
||||||
|
{serveBar}
|
||||||
|
{scoreBoard}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{scoreBoard}
|
||||||
|
{serveBar}
|
||||||
|
{header}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeamPickerModalProps = {
|
||||||
|
currentLeftTeamId: number | null
|
||||||
|
currentRightTeamId: number | null
|
||||||
|
draftTeamIds: number[]
|
||||||
|
group: RoundGroup
|
||||||
|
selectionCount: number
|
||||||
|
sourceLabel: string
|
||||||
|
targetDate: string
|
||||||
|
onAutoPick: () => void
|
||||||
|
onClear: () => void
|
||||||
|
onClose: () => void
|
||||||
|
onConfirm: () => void
|
||||||
|
onToggleTeam: (teamId: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function TeamPickerModal({
|
||||||
|
currentLeftTeamId,
|
||||||
|
currentRightTeamId,
|
||||||
|
draftTeamIds,
|
||||||
|
group,
|
||||||
|
selectionCount,
|
||||||
|
sourceLabel,
|
||||||
|
targetDate,
|
||||||
|
onAutoPick,
|
||||||
|
onClear,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
onToggleTeam,
|
||||||
|
}: TeamPickerModalProps) {
|
||||||
|
return (
|
||||||
|
<div className="team-picker-overlay" role="presentation" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
aria-label="設定對戰隊伍"
|
||||||
|
aria-modal="true"
|
||||||
|
className="team-picker-shell"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
<button aria-label="關閉選隊視窗" className="team-picker-close" type="button" onClick={onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="team-picker-ribbon">
|
||||||
|
<span>{selectionCount >= 2 ? '已完成選擇' : '請選擇 2 隊'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="team-picker-layout">
|
||||||
|
<section className="team-picker-panel team-picker-list-panel">
|
||||||
|
<div className="team-picker-title">
|
||||||
|
<span className="team-picker-count">{selectionCount}/2</span>
|
||||||
|
<div>
|
||||||
|
<strong>從這一組挑選要對打的隊伍</strong>
|
||||||
|
<p>
|
||||||
|
第 {group.id} 組 / {sourceLabel} / {targetDate || '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="team-picker-list">
|
||||||
|
{group.teams.map((team) => {
|
||||||
|
const checked = draftTeamIds.includes(team.id)
|
||||||
|
const selectedOrder = checked ? draftTeamIds.indexOf(team.id) + 1 : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
checked
|
||||||
|
? 'team-picker-option team-picker-option-active'
|
||||||
|
: 'team-picker-option'
|
||||||
|
}
|
||||||
|
key={`team-option-${team.id}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleTeam(team.id)}
|
||||||
|
>
|
||||||
|
<span className="team-picker-checkbox">
|
||||||
|
{checked ? String(selectedOrder) : ''}
|
||||||
|
</span>
|
||||||
|
<div className="team-picker-option-text">
|
||||||
|
<strong>{getTeamDisplayName(team)}</strong>
|
||||||
|
<small>隊伍編號 {team.id}</small>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="team-picker-actions">
|
||||||
|
<button className="team-picker-ghost" type="button" onClick={onAutoPick}>
|
||||||
|
自動選擇
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="team-picker-confirm"
|
||||||
|
disabled={draftTeamIds.length !== 2}
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
確認
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="team-picker-panel team-picker-side-panel">
|
||||||
|
<div className="picked-team-list">
|
||||||
|
{[0, 1].map((slotIndex) => {
|
||||||
|
const teamId = draftTeamIds[slotIndex] ?? null
|
||||||
|
const team = group.teams.find((item) => item.id === teamId) ?? null
|
||||||
|
const isCurrent =
|
||||||
|
teamId !== null &&
|
||||||
|
teamId === (slotIndex === 0 ? currentLeftTeamId : currentRightTeamId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="picked-team-card" key={`picked-${slotIndex}`}>
|
||||||
|
<span className="picked-team-index">{slotIndex + 1}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{team ? getTeamDisplayName(team) : '尚未選擇'}</strong>
|
||||||
|
<small>
|
||||||
|
{slotIndex === 0 ? '上方隊伍' : '下方隊伍'}
|
||||||
|
{isCurrent ? ' / 目前使用中' : ''}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="picker-mode-toggle">
|
||||||
|
<input disabled type="checkbox" />
|
||||||
|
<span>單打模式</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button className="team-picker-clear" type="button" onClick={onClear}>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="picker-side-hint">
|
||||||
|
選好 2 隊後按確認,會直接帶入記分板的上方與下方位置。
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FinishDialogProps = {
|
||||||
|
error: string
|
||||||
|
leftScore: number
|
||||||
|
leftTeamName: string
|
||||||
|
matchupLabel: string
|
||||||
|
rightScore: number
|
||||||
|
rightTeamName: string
|
||||||
|
uploading: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onConfirm: () => void
|
||||||
|
onSkip: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function FinishDialog({
|
||||||
|
error,
|
||||||
|
leftScore,
|
||||||
|
leftTeamName,
|
||||||
|
matchupLabel,
|
||||||
|
rightScore,
|
||||||
|
rightTeamName,
|
||||||
|
uploading,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
onSkip,
|
||||||
|
}: FinishDialogProps) {
|
||||||
|
return (
|
||||||
|
<div className="finish-dialog-overlay" role="presentation">
|
||||||
|
<div aria-modal="true" className="finish-dialog" role="dialog">
|
||||||
|
<button
|
||||||
|
aria-label="關閉結算視窗"
|
||||||
|
className="finish-dialog-close"
|
||||||
|
disabled={uploading}
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="panel-kicker">比賽結算</p>
|
||||||
|
<h3>{matchupLabel}</h3>
|
||||||
|
|
||||||
|
<div className="finish-score">
|
||||||
|
<div>
|
||||||
|
<strong>{leftScore}</strong>
|
||||||
|
<span>{leftTeamName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="finish-score-divider">:</div>
|
||||||
|
<div>
|
||||||
|
<strong>{rightScore}</strong>
|
||||||
|
<span>{rightTeamName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="finish-dialog-copy">要把這場戰績上傳到 DB 嗎?</p>
|
||||||
|
|
||||||
|
{error ? <p className="finish-dialog-error">{error}</p> : null}
|
||||||
|
|
||||||
|
<div className="finish-dialog-actions">
|
||||||
|
<button
|
||||||
|
className="team-picker-ghost"
|
||||||
|
disabled={uploading}
|
||||||
|
type="button"
|
||||||
|
onClick={onSkip}
|
||||||
|
>
|
||||||
|
不上傳
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="team-picker-confirm"
|
||||||
|
disabled={uploading}
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{uploading ? '上傳中...' : '上傳戰績'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlayerNumber(teamSlot: 'top' | 'bottom', slot: PlayerSlot) {
|
||||||
|
if (teamSlot === 'top') {
|
||||||
|
return slot === 'playerA' ? 1 : 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return slot === 'playerA' ? 4 : 3
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatClock() {
|
||||||
|
return new Date().toLocaleTimeString('zh-TW', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
196
src/pages/TeamSelectionPage.tsx
Normal file
196
src/pages/TeamSelectionPage.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { getTeamDisplayName } from '../lib/match'
|
||||||
|
import type { LoadStatus, RoundGroup } from '../types'
|
||||||
|
|
||||||
|
type TeamSelectionPageProps = {
|
||||||
|
areaAInput: string
|
||||||
|
areaBInput: string
|
||||||
|
groups: RoundGroup[]
|
||||||
|
groupSource: 'idle' | 'db' | 'manual'
|
||||||
|
loadMessage: string
|
||||||
|
loadStatus: LoadStatus
|
||||||
|
parsedAreaA: string[]
|
||||||
|
parsedAreaB: string[]
|
||||||
|
selectedGroupId: number | null
|
||||||
|
targetDate: string
|
||||||
|
onAreaAInputChange: (value: string) => void
|
||||||
|
onAreaBInputChange: (value: string) => void
|
||||||
|
onGenerateManualGroups: () => void
|
||||||
|
onLoadGroupsFromDb: () => void
|
||||||
|
onSelectGroup: (groupId: number) => void
|
||||||
|
onTargetDateChange: (value: string) => void
|
||||||
|
onUseGroup: (groupId: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamSelectionPage({
|
||||||
|
areaAInput,
|
||||||
|
areaBInput,
|
||||||
|
groups,
|
||||||
|
groupSource,
|
||||||
|
loadMessage,
|
||||||
|
loadStatus,
|
||||||
|
parsedAreaA,
|
||||||
|
parsedAreaB,
|
||||||
|
selectedGroupId,
|
||||||
|
targetDate,
|
||||||
|
onAreaAInputChange,
|
||||||
|
onAreaBInputChange,
|
||||||
|
onGenerateManualGroups,
|
||||||
|
onLoadGroupsFromDb,
|
||||||
|
onSelectGroup,
|
||||||
|
onTargetDateChange,
|
||||||
|
onUseGroup,
|
||||||
|
}: TeamSelectionPageProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="selection-shell">
|
||||||
|
<article className="panel panel-hero selection-hero">
|
||||||
|
<div>
|
||||||
|
<p className="panel-kicker">步驟 1</p>
|
||||||
|
<h2>載入分組與選擇組別</h2>
|
||||||
|
<p className="panel-copy">
|
||||||
|
先用日期從 DB 讀取分組;如果指定日期沒有資料,就改用 A 區與 B 區名單手動產生配對。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="summary-grid">
|
||||||
|
<article className="mini-stat">
|
||||||
|
<span>A 區隊數</span>
|
||||||
|
<strong>{parsedAreaA.length}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="mini-stat">
|
||||||
|
<span>B 區隊數</span>
|
||||||
|
<strong>{parsedAreaB.length}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="mini-stat">
|
||||||
|
<span>目前組數</span>
|
||||||
|
<strong>{groups.length}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadMessage ? (
|
||||||
|
<p className={`status-banner status-banner-${loadStatus}`}>{loadMessage}</p>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel">
|
||||||
|
<div className="selection-form">
|
||||||
|
<div className="selection-toolbar">
|
||||||
|
<label className="field">
|
||||||
|
<span>指定日期</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={targetDate}
|
||||||
|
onChange={(event) => onTargetDateChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="button-stack">
|
||||||
|
<button className="primary-button" type="button" onClick={onLoadGroupsFromDb}>
|
||||||
|
讀取指定日期
|
||||||
|
</button>
|
||||||
|
<button className="secondary-button" type="button" onClick={onGenerateManualGroups}>
|
||||||
|
手動產生配對
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="double-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span>A 區名單</span>
|
||||||
|
<textarea
|
||||||
|
rows={8}
|
||||||
|
value={areaAInput}
|
||||||
|
onChange={(event) => onAreaAInputChange(event.target.value)}
|
||||||
|
placeholder={'每行一隊,例如:\n柏威'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>B 區名單</span>
|
||||||
|
<textarea
|
||||||
|
rows={8}
|
||||||
|
value={areaBInput}
|
||||||
|
onChange={(event) => onAreaBInputChange(event.target.value)}
|
||||||
|
placeholder={'每行一隊,例如:\nRURU'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="selection-hint">
|
||||||
|
<span>
|
||||||
|
來源:
|
||||||
|
{groupSource === 'db' ? 'DB' : groupSource === 'manual' ? '手動配對' : '尚未載入'}
|
||||||
|
</span>
|
||||||
|
<span>從下方選擇要帶進記分板的第幾組。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel full-span">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<p className="panel-kicker">步驟 2</p>
|
||||||
|
<h2>選擇第幾組帶進記分板</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<h3>目前還沒有分組</h3>
|
||||||
|
<p>先讀取指定日期資料,或手動輸入名單後產生配對。</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="group-board">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<article
|
||||||
|
className={
|
||||||
|
group.id === selectedGroupId
|
||||||
|
? 'group-card group-card-active group-card-stage'
|
||||||
|
: 'group-card group-card-stage'
|
||||||
|
}
|
||||||
|
key={group.id}
|
||||||
|
>
|
||||||
|
<div className="group-head">
|
||||||
|
<div>
|
||||||
|
<p className="panel-kicker">第 {group.id} 組</p>
|
||||||
|
<h3>{group.teams.length} 隊可選</h3>
|
||||||
|
</div>
|
||||||
|
<div className="group-actions">
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectGroup(group.id)}
|
||||||
|
>
|
||||||
|
先選這組
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onUseGroup(group.id)
|
||||||
|
navigate('/scoreboard')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
帶進記分板
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="team-stage-grid">
|
||||||
|
{group.teams.map((team) => (
|
||||||
|
<article className="team-stage-card" key={`${group.id}-${team.id}`}>
|
||||||
|
<span className="team-index">第 {team.id} 隊</span>
|
||||||
|
<p className="team-name">{getTeamDisplayName(team)}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
src/types.ts
Normal file
83
src/types.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
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 ScoreState = {
|
||||||
|
scoreLeft: number
|
||||||
|
scoreRight: number
|
||||||
|
gamesLeft: number
|
||||||
|
gamesRight: number
|
||||||
|
currentGame: number
|
||||||
|
targetScore: number
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -4,4 +4,11 @@ import react from '@vitejs/plugin-react'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3501,
|
||||||
|
strictPort: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://127.0.0.1:8788',
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user