Refine scoreboard flow and update ports

This commit is contained in:
2026-04-15 22:56:50 +08:00
parent 8f4411d97e
commit 7fc8e2698b
17 changed files with 4368 additions and 294 deletions

8
.env.example Normal file
View 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

View File

@@ -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
View File

@@ -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 與建置後的前端頁面。
- 單打 / 雙打模式
- 發球權切換
- 局數統計
- 比賽計時
- 賽程與場地管理

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
`)
}

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
View 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
}

View File

@@ -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
View 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>
)
}

View 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,
})
}

View 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
View 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
}

View File

@@ -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',
},
},
}) })