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 . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.29-alpine
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
WORKDIR /app
|
||||
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
|
||||
- React
|
||||
- TypeScript
|
||||
- ESLint
|
||||
- Docker / Nginx 靜態部署
|
||||
- 選擇日期後從 DB 讀取隊伍與分組資料
|
||||
- 若指定日期沒有資料,可手動輸入名單並產生配對
|
||||
- 從指定組別選 2 隊帶入記分板
|
||||
- 記分板支援先攻設定、點擊分數直接加分、上一步回退
|
||||
- 支援上下換隊、左右交換隊員位置
|
||||
- 比賽結算後可選擇是否上傳戰績到 `history` 資料表
|
||||
|
||||
## 開發指令
|
||||
## 開發環境 Port
|
||||
|
||||
- Client: `3501`
|
||||
- Server API: `8788`
|
||||
|
||||
Vite 前端會開在:
|
||||
|
||||
```text
|
||||
http://localhost:3501
|
||||
```
|
||||
|
||||
API 會開在:
|
||||
|
||||
```text
|
||||
http://localhost:8788
|
||||
```
|
||||
|
||||
## 啟動方式
|
||||
|
||||
先安裝套件:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
啟動開發模式:
|
||||
|
||||
```bash
|
||||
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
|
||||
http://localhost:5173
|
||||
[round, starter, winCount, winner]
|
||||
```
|
||||
|
||||
對應意義:
|
||||
|
||||
- `round`: 第幾球
|
||||
- `starter`: 發球者編號,依記分板 `1~4`
|
||||
- `winCount`: 連續得分次數
|
||||
- `winner`: 該球由哪一隊得分,`0` 代表上方隊伍,`1` 代表下方隊伍
|
||||
|
||||
## 建置
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
建置完成後,輸出會在 `dist/`。
|
||||
## Docker
|
||||
|
||||
## Docker 打包
|
||||
|
||||
建立映像:
|
||||
建置映像:
|
||||
|
||||
```bash
|
||||
docker build -t badminton-scoreboard .
|
||||
@@ -42,19 +114,24 @@ docker build -t badminton-scoreboard .
|
||||
啟動容器:
|
||||
|
||||
```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
|
||||
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",
|
||||
"type": "module",
|
||||
"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",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"start": "node server/server.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"concurrently": "^9.2.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"mysql2": "^3.22.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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 { 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() {
|
||||
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 (
|
||||
<main className="app-shell">
|
||||
<section className="hero-panel">
|
||||
<div className="hero-copy">
|
||||
<span className="eyebrow">Vite + React + TypeScript</span>
|
||||
<h1>羽毛球記分板</h1>
|
||||
<p className="hero-text">
|
||||
專案已完成初始化,接下來可以往單打、雙打、發球權切換、局數統計與賽事模式繼續擴充。
|
||||
</p>
|
||||
<div className={isScoreboardRoute ? 'app-shell app-shell-scoreboard' : 'app-shell'}>
|
||||
<header className={isScoreboardRoute ? 'topbar topbar-compact' : 'topbar'}>
|
||||
<div className="branding">
|
||||
<p className="eyebrow">Badminton Scoreboard</p>
|
||||
<h1>{isScoreboardRoute ? '羽毛球記分板' : '羽毛球分組與記分板'}</h1>
|
||||
{!isScoreboardRoute ? (
|
||||
<p className="intro-copy">
|
||||
先選日期或手動建立組別,再從該組挑選要對打的兩隊進入記分板。比賽結束後可直接上傳戰績到
|
||||
DB。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="status-strip" aria-label="專案狀態">
|
||||
<span>即時比分</span>
|
||||
<span>局數追蹤</span>
|
||||
<span>Docker Ready</span>
|
||||
</div>
|
||||
</section>
|
||||
<nav className="topnav" aria-label="主要導覽">
|
||||
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/teams">
|
||||
選隊伍
|
||||
</NavLink>
|
||||
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/scoreboard">
|
||||
記分板
|
||||
</NavLink>
|
||||
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/history">
|
||||
歷史戰績
|
||||
</NavLink>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section className="scoreboard-card" aria-label="比賽記分板預覽">
|
||||
<div className="board-header">
|
||||
<div>
|
||||
<p className="label">友誼賽</p>
|
||||
<h2>中央球場</h2>
|
||||
</div>
|
||||
<div className="match-meta">
|
||||
<span>第 2 局</span>
|
||||
<span>21 分制</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="score-grid">
|
||||
<article className="team-card">
|
||||
<p className="team-tag">A 隊</p>
|
||||
<h3>林 / 陳</h3>
|
||||
<strong>18</strong>
|
||||
<p>上一局 21 : 16</p>
|
||||
</article>
|
||||
|
||||
<article className="team-card team-card-active">
|
||||
<p className="team-tag">B 隊</p>
|
||||
<h3>王 / 黃</h3>
|
||||
<strong>21</strong>
|
||||
<p>目前發球權</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="detail-grid">
|
||||
<div>
|
||||
<span className="label">本局節奏</span>
|
||||
<p>多拍相持偏多,比分進入收尾階段。</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="label">下一步</span>
|
||||
<p>把計分控制、局數規則與賽事資料模型串起來。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<TeamSelectionPage
|
||||
areaAInput={areaAInput}
|
||||
areaBInput={areaBInput}
|
||||
groups={groups}
|
||||
groupSource={groupSource}
|
||||
loadMessage={loadMessage}
|
||||
loadStatus={loadStatus}
|
||||
parsedAreaA={parsedAreaA}
|
||||
parsedAreaB={parsedAreaB}
|
||||
selectedGroupId={selectedGroupId}
|
||||
targetDate={targetDate}
|
||||
onAreaAInputChange={setAreaAInput}
|
||||
onAreaBInputChange={setAreaBInput}
|
||||
onGenerateManualGroups={generateManualGroups}
|
||||
onLoadGroupsFromDb={() => void loadGroupsFromDb()}
|
||||
onSelectGroup={selectGroup}
|
||||
onTargetDateChange={setTargetDate}
|
||||
onUseGroup={selectGroup}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/teams"
|
||||
element={
|
||||
<TeamSelectionPage
|
||||
areaAInput={areaAInput}
|
||||
areaBInput={areaBInput}
|
||||
groups={groups}
|
||||
groupSource={groupSource}
|
||||
loadMessage={loadMessage}
|
||||
loadStatus={loadStatus}
|
||||
parsedAreaA={parsedAreaA}
|
||||
parsedAreaB={parsedAreaB}
|
||||
selectedGroupId={selectedGroupId}
|
||||
targetDate={targetDate}
|
||||
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
|
||||
|
||||
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 { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</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/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3501,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8788',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user