Add DB upload flow and Docker deployment setup

This commit is contained in:
2026-04-14 23:17:45 +08:00
parent 268e76bf0d
commit 6c3ff0e3d1
12 changed files with 1787 additions and 253 deletions

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
node_modules
dist
dist-ssr
.git
.gitignore
.env
.env.*
!.env.example
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.vscode
.idea
README.md

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
DB_HOST=192.168.0.15
DB_PORT=3307
DB_USER=jianmiau
DB_PASSWORD=your-password
DB_DATABASE=badminton
DB_TABLE=badminton
SERVER_PORT=8787

3
.gitignore vendored
View File

@@ -11,6 +11,9 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
.env
.env.*
!.env.example
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*

28
Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=8787
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/server ./server
COPY --from=builder /app/.env.example ./.env.example
EXPOSE 8787
CMD ["npm", "run", "start"]

View File

@@ -1,15 +1,18 @@
# 羽毛球隊伍配對器 # 羽毛球分組配對器
這是一個使用 `React + Vite + TypeScript` 製作的網頁應用程式,專門用來協助羽毛球活動快速配對隊伍。 這是一個使用 `React + Vite + TypeScript` 製作的網頁應用程式,用來將 A 區與 B 區成員配成羽毛球隊伍。
## 功能說明 ## 功能說明
- 分別輸入 A 區與 B 區的成員名單 - 分別輸入 A 區與 B 區名單
- 系統每次固定產生 3 組隊伍 - 可指定要寫入資料庫的目標日期
-組皆由 `A 區 1 位 + B 區 1 位` 組成 -次固定產生 3 組完整配對結果
- 同一輪內不會重複使用同一位成員 - 每一隊都由 `1 位 A 區 + 1 位 B 區` 組成
- 會顯示本輪未被選中的候補名單 - 每一組都會把 A 區與 B 區名單完整分配完成
- 會自動將輸入內容儲存在瀏覽器本機 - 若某一區人數不足,系統會自動補入「那個」
- 產生配對後可用按鈕手動上傳資料到資料庫
- 支援換行、半形逗號、全形逗號與頓號輸入
- 會自動去除空白與重複名稱
## 使用方式 ## 使用方式
@@ -18,14 +21,81 @@ npm install
npm run dev npm run dev
``` ```
## 資料庫欄位
- `time`: 目標日期,格式為 `YYYYMMDD`
- `personnel`: 人員清單,格式為 `[[1,"A區成員"],[0,"B區成員"]]`
- `battlecombination`: 三組隊伍搭配,格式為 `{"0":[["A","B"]],"1":[...],"2":[...]}`
## 正式建置 ## 正式建置
```bash ```bash
npm run build npm run build
``` ```
## 補充說明 ## Docker 部署
- 輸入格式支援換行、半形逗號、全形逗號與頓號 這個專案已提供單容器部署方式,容器啟動後會同時提供:
- 系統會自動去除空白與重複名稱
- A 區與 B 區都至少需要 3 位不重複成員才能完成配對 - 網頁前端
- `/api` 後端
- MariaDB 寫入功能
### 建立映像
```bash
docker build -t badminton-match-hub .
```
### 啟動容器
```bash
docker run -d \
--name badminton-match-hub \
-p 8787:8787 \
-e PORT=8787 \
-e DB_HOST=192.168.0.15 \
-e DB_PORT=3307 \
-e DB_USER=jianmiau \
-e DB_PASSWORD=你的密碼 \
-e DB_DATABASE=badminton \
-e DB_TABLE=badminton \
badminton-match-hub
```
### NAS 上建議設定
- 容器埠使用 `8787`
- 對外埠可自訂,例如 `8787:8787`
- 環境變數請在 NAS 的 Docker 介面中填入
- 不要把本機 `.env` 直接打包進映像
部署完成後可直接透過:
```text
http://NAS_IP:8787
```
開啟系統。
### docker-compose
專案也已提供 [docker-compose.yml](D:/Users/JianMiau/Downloads/badminton-match-hub/docker-compose.yml)。
```bash
docker compose up -d --build
```
Compose 版本預設會:
- 對外使用 `3500`
- 容器內部使用 `3500`
- 讀取 `.env` 內的資料庫設定
部署後可透過:
```text
http://NAS_IP:3500
```
開啟系統。

18
docker-compose.yml Normal file
View File

@@ -0,0 +1,18 @@
services:
badminton-match-hub:
container_name: badminton-match-hub
build:
context: .
dockerfile: Dockerfile
image: badminton-match-hub:latest
restart: unless-stopped
ports:
- "3500:3500"
environment:
PORT: 3500
DB_HOST: ${DB_HOST:-192.168.0.15}
DB_PORT: ${DB_PORT:-3307}
DB_USER: ${DB_USER:-jianmiau}
DB_PASSWORD: ${DB_PASSWORD}
DB_DATABASE: ${DB_DATABASE:-badminton}
DB_TABLE: ${DB_TABLE:-badminton}

1135
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,17 +4,24 @@
"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 server/server.mjs",
"start": "node server/server.mjs",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"dotenv": "^17.2.3",
"express": "^5.1.0",
"mysql2": "^3.15.3",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"concurrently": "^9.2.1",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",

140
server/server.mjs Normal file
View File

@@ -0,0 +1,140 @@
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 ?? 8787)
const tableName = process.env.DB_TABLE ?? 'badminton'
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,
missingEnv,
})
})
app.post('/api/match-results', async (request, response) => {
if (!pool) {
response.status(500).json({
ok: false,
message: `資料庫環境變數缺少:${missingEnv.join(', ')}`,
})
return
}
const { time, areaA, areaB, teams } = request.body ?? {}
if (
typeof time !== 'string' ||
!Array.isArray(areaA) ||
!Array.isArray(areaB) ||
!Array.isArray(teams)
) {
response.status(400).json({
ok: false,
message: '送出的資料格式不正確。',
})
return
}
try {
await ensureTable(pool, tableName)
const personnel = JSON.stringify([
...areaA.map((name) => [1, name]),
...areaB.map((name) => [0, name]),
])
const battlecombination = JSON.stringify(
Object.fromEntries(
teams.map((round, index) => [
String(index),
round.teams.map((team) => [team.a, team.b]),
]),
),
)
await pool.execute(
`
INSERT INTO \`${tableName}\` (time, personnel, battlecombination)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE
personnel = VALUES(personnel),
battlecombination = VALUES(battlecombination)
`,
[Number(time), personnel, battlecombination],
)
response.json({
ok: true,
message: '已寫入資料庫。',
})
} catch (error) {
console.error('match-results 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'))
})
} else {
app.get('/', (_request, response) => {
response
.status(503)
.send('前端尚未建置,請先執行 npm run build 或使用 Docker 映像部署。')
})
}
app.listen(port, () => {
console.log(`Server ready on http://localhost:${port}`)
console.log(`Static files: ${distReady ? 'loaded' : 'missing'}`)
if (missingEnv.length > 0) {
console.log(`Missing env: ${missingEnv.join(', ')}`)
}
})
async function ensureTable(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
`)
}

View File

@@ -8,7 +8,8 @@
.panel, .panel,
.stat-card, .stat-card,
.tip-card, .tip-card,
.pair-card { .round-card,
.team-card {
border: 1px solid rgba(24, 57, 38, 0.12); border: 1px solid rgba(24, 57, 38, 0.12);
box-shadow: 0 20px 45px rgba(26, 47, 35, 0.08); box-shadow: 0 20px 45px rgba(26, 47, 35, 0.08);
} }
@@ -35,15 +36,15 @@
.eyebrow { .eyebrow {
margin: 0 0 12px; margin: 0 0 12px;
color: #1d6c46; color: #1d6c46;
text-transform: uppercase; letter-spacing: 0.12em;
letter-spacing: 0.16em; font-size: 0.82rem;
font-size: 0.78rem;
font-weight: 700; font-weight: 700;
} }
.hero-copy h1, .hero-copy h1,
.panel-heading h2, .panel-heading h2,
.tip-card h2 { .tip-card h2,
.round-title {
margin: 0; margin: 0;
color: #183926; color: #183926;
} }
@@ -68,6 +69,23 @@
margin-top: 28px; margin-top: 28px;
} }
.save-status {
margin: 14px 0 0;
font-size: 0.95rem;
}
.save-status-saving {
color: #5c6f61;
}
.save-status-saved {
color: #1d6c46;
}
.save-status-error {
color: #8d2d22;
}
.primary-button, .primary-button,
.ghost-button { .ghost-button {
min-height: 50px; min-height: 50px;
@@ -141,30 +159,47 @@
line-height: 1.65; line-height: 1.65;
} }
.content-grid,
.bench-grid {
display: grid;
gap: 24px;
}
.content-grid {
grid-template-columns: 1.02fr 0.98fr;
}
.bench-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 24px;
}
.panel { .panel {
padding: 28px; padding: 28px;
background: rgba(255, 253, 249, 0.78); background: rgba(255, 253, 249, 0.78);
margin-bottom: 24px;
} }
.panel-heading { .panel-heading {
margin-bottom: 20px; margin-bottom: 20px;
} }
.upload-row {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: end;
margin-bottom: 18px;
}
.date-field {
display: grid;
gap: 10px;
min-width: 220px;
color: #4b5b50;
font-size: 0.96rem;
}
.date-field span {
font-weight: 600;
color: #183926;
}
.date-field input {
min-height: 50px;
border: 1px solid rgba(24, 57, 38, 0.15);
border-radius: 18px;
padding: 0 14px;
background: rgba(255, 255, 255, 0.86);
color: #183926;
font: inherit;
}
.input-grid { .input-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -197,14 +232,21 @@
} }
.roster-field textarea:focus, .roster-field textarea:focus,
.date-field input:focus,
.primary-button:focus-visible, .primary-button:focus-visible,
.ghost-button:focus-visible { .ghost-button:focus-visible {
outline: 2px solid rgba(29, 123, 77, 0.26); outline: 2px solid rgba(29, 123, 77, 0.26);
outline-offset: 2px; outline-offset: 2px;
} }
.upload-button:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.helper-text, .helper-text,
.empty-inline,
.empty-state p { .empty-state p {
margin: 14px 0 0; margin: 14px 0 0;
color: #627265; color: #627265;
@@ -220,72 +262,65 @@
color: #8d2d22; color: #8d2d22;
} }
.pair-list { .round-list,
.team-list {
display: grid; display: grid;
gap: 14px; gap: 14px;
} }
.pair-card { .round-card,
.team-card {
border-radius: 22px; border-radius: 22px;
padding: 18px;
background: linear-gradient(145deg, rgba(255, 247, 236, 0.95), rgba(244, 251, 246, 0.92)); background: linear-gradient(145deg, rgba(255, 247, 236, 0.95), rgba(244, 251, 246, 0.92));
} }
.pair-index { .round-card {
padding: 20px;
}
.round-title {
margin-bottom: 14px; margin-bottom: 14px;
font-size: 0.9rem; font-size: 1.25rem;
}
.team-card {
padding: 16px;
}
.team-index {
display: block;
margin-bottom: 10px;
font-size: 0.85rem;
color: #617265; color: #617265;
font-weight: 700; font-weight: 700;
} }
.pair-names { .team-line {
display: grid; display: flex;
grid-template-columns: 1fr auto 1fr;
gap: 12px;
align-items: center; align-items: center;
} gap: 12px;
font-size: 1.1rem;
.player-chip {
display: grid;
gap: 8px;
padding: 16px;
border-radius: 20px;
min-height: 98px;
}
.player-chip strong {
font-size: clamp(1.15rem, 2vw, 1.45rem);
color: #183926;
}
.player-chip-a {
background: rgba(244, 157, 55, 0.16);
}
.player-chip-b {
background: rgba(29, 123, 77, 0.12);
}
.chip-label {
font-size: 0.82rem;
color: #5f6f63;
font-weight: 700; font-weight: 700;
letter-spacing: 0.08em;
} }
.pair-link { .team-divider {
width: 42px; color: #627265;
height: 42px; }
border-radius: 999px;
display: grid; .name-a {
place-items: center; color: #8b5a0d;
background: #183926; }
color: #fff6ea;
font-weight: 700; .name-b {
color: #1d6c46;
}
.name-placeholder {
color: #4f5662;
} }
.empty-state { .empty-state {
min-height: 240px; min-height: 220px;
display: grid; display: grid;
place-items: center; place-items: center;
border-radius: 22px; border-radius: 22px;
@@ -294,27 +329,8 @@
padding: 20px; padding: 20px;
} }
.name-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.name-pill {
display: inline-flex;
align-items: center;
min-height: 36px;
padding: 0 14px;
border-radius: 999px;
background: rgba(29, 123, 77, 0.08);
color: #1d6c46;
font-weight: 600;
}
@media (max-width: 980px) { @media (max-width: 980px) {
.hero-card, .hero-card,
.content-grid,
.bench-grid,
.input-grid { .input-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -333,18 +349,14 @@
.hero-copy, .hero-copy,
.panel, .panel,
.stat-card, .stat-card,
.tip-card { .tip-card,
padding: 22px; .round-card,
.team-card {
padding: 18px;
} }
.pair-names { .team-line {
grid-template-columns: 1fr; flex-wrap: wrap;
}
.pair-link {
width: 100%;
border-radius: 16px;
height: 36px;
} }
.roster-field textarea { .roster-field textarea {

View File

@@ -1,25 +1,35 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import './App.css' import './App.css'
type Pair = { type Participant = {
id: number id: string
playerA: string name: string
playerB: string isPlaceholder: boolean
} }
type DrawResult = { type Team = {
pairs: Pair[] id: number
benchA: string[] playerA: Participant
benchB: string[] playerB: Participant
} }
type RoundResult = {
id: number
teams: Team[]
}
type SaveState = 'idle' | 'saving' | 'saved' | 'error'
const STORAGE_KEYS = { const STORAGE_KEYS = {
areaA: 'badminton-match-hub::area-a', areaA: 'badminton-match-hub::area-a',
areaB: 'badminton-match-hub::area-b', areaB: 'badminton-match-hub::area-b',
} as const } as const
const defaultAreaA = ['小杰', '阿誠', '建宏', '柏宇', 'Eason'] const PLACEHOLDER_NAME = '那個'
const defaultAreaB = ['小安', '佩珊', '阿廷', 'Luna', 'Mina'] const TOTAL_ROUNDS = 3
const defaultAreaA = ['建喵', '柏威', '景涵', 'Gary', '昱翔']
const defaultAreaB = ['小念', '玟瑄', 'RuRu', '肉肉', '蓉蓉']
function App() { function App() {
const [areaAInput, setAreaAInput] = useState(() => const [areaAInput, setAreaAInput] = useState(() =>
@@ -28,8 +38,11 @@ function App() {
const [areaBInput, setAreaBInput] = useState(() => const [areaBInput, setAreaBInput] = useState(() =>
loadRoster(STORAGE_KEYS.areaB, defaultAreaB), loadRoster(STORAGE_KEYS.areaB, defaultAreaB),
) )
const [result, setResult] = useState<DrawResult | null>(null) const [targetDate, setTargetDate] = useState(() => formatDateInputValue())
const [results, setResults] = useState<RoundResult[]>([])
const [error, setError] = useState('') const [error, setError] = useState('')
const [saveState, setSaveState] = useState<SaveState>('idle')
const [saveMessage, setSaveMessage] = useState('')
useEffect(() => { useEffect(() => {
window.localStorage.setItem(STORAGE_KEYS.areaA, areaAInput) window.localStorage.setItem(STORAGE_KEYS.areaA, areaAInput)
@@ -41,82 +54,133 @@ function App() {
const parsedAreaA = parseRoster(areaAInput) const parsedAreaA = parseRoster(areaAInput)
const parsedAreaB = parseRoster(areaBInput) const parsedAreaB = parseRoster(areaBInput)
const teamCount = Math.max(parsedAreaA.length, parsedAreaB.length)
const placeholderCountA = Math.max(0, teamCount - parsedAreaA.length)
const placeholderCountB = Math.max(0, teamCount - parsedAreaB.length)
function generateMatches() { function generateGroups() {
if (parsedAreaA.length < 3 || parsedAreaB.length < 3) { if (parsedAreaA.length === 0 || parsedAreaB.length === 0) {
setResult(null) setResults([])
setError('A 區與 B 區都至少要有 3 位不重複成員,才能產生三組配對。') setError('A 區與 B 區都至少要輸入 1 位成員。')
setSaveState('idle')
setSaveMessage('')
return return
} }
const shuffledA = shuffleList(parsedAreaA) const prepared = prepareParticipants(parsedAreaA, parsedAreaB)
const shuffledB = shuffleList(parsedAreaB) const nextResults = Array.from({ length: TOTAL_ROUNDS }, (_, index) => ({
const selectedA = shuffledA.slice(0, 3)
const selectedB = shuffledB.slice(0, 3)
const pairs = selectedA.map((playerA, index) => ({
id: index + 1, id: index + 1,
playerA, teams: createRoundTeams(prepared.areaA, prepared.areaB, index),
playerB: selectedB[index],
})) }))
setResult({ setResults(nextResults)
pairs,
benchA: shuffledA.slice(3),
benchB: shuffledB.slice(3),
})
setError('') setError('')
setSaveState('idle')
setSaveMessage('已產生配對,請按「上傳資料」。')
}
async function uploadResults() {
if (results.length === 0) {
setSaveState('error')
setSaveMessage('請先產生配對結果,再上傳資料。')
return
}
if (!targetDate) {
setSaveState('error')
setSaveMessage('請先選擇目標日期。')
return
}
setSaveState('saving')
setSaveMessage('上傳資料到資料庫中...')
try {
await saveMatchResults(convertDateToKey(targetDate), parsedAreaA, parsedAreaB, results)
setSaveState('saved')
setSaveMessage(`已同步到資料庫:${convertDateToKey(targetDate)}`)
} catch (saveError) {
setSaveState('error')
setSaveMessage(
saveError instanceof Error ? saveError.message : '資料庫儲存失敗,請稍後再試。',
)
}
} }
function resetDemo() { function resetDemo() {
setAreaAInput(defaultAreaA.join('\n')) setAreaAInput(defaultAreaA.join('\n'))
setAreaBInput(defaultAreaB.join('\n')) setAreaBInput(defaultAreaB.join('\n'))
setResult(null) setResults([])
setError('') setError('')
setSaveState('idle')
setSaveMessage('')
} }
return ( return (
<main className="app-shell"> <main className="app-shell">
<section className="hero-card"> <section className="hero-card">
<div className="hero-copy"> <div className="hero-copy">
<p className="eyebrow">使 ReactVite TypeScript </p> <p className="eyebrow"></p>
<h1></h1> <h1></h1>
<p className="hero-text"> <p className="hero-text">
A B 3 A B 3
3 A 1 B 1 1 A 1 B
</p> </p>
<div className="hero-actions"> <div className="hero-actions">
<button className="primary-button" type="button" onClick={generateMatches}> <button className="primary-button" type="button" onClick={generateGroups}>
</button> </button>
<button className="ghost-button" type="button" onClick={resetDemo}> <button className="ghost-button" type="button" onClick={resetDemo}>
</button> </button>
</div> </div>
{saveMessage ? (
<p className={`save-status save-status-${saveState}`}>{saveMessage}</p>
) : null}
</div> </div>
<div className="hero-stats"> <div className="hero-stats">
<article className="stat-card"> <article className="stat-card">
<span>A </span> <span>A </span>
<strong>{parsedAreaA.length}</strong> <strong>{parsedAreaA.length}</strong>
</article> </article>
<article className="stat-card"> <article className="stat-card">
<span>B </span> <span>B </span>
<strong>{parsedAreaB.length}</strong> <strong>{parsedAreaB.length}</strong>
</article> </article>
<article className="tip-card"> <article className="tip-card">
<h2></h2> <h2></h2>
<p> 3 A 1 B 1 </p> <p>
{teamCount} A {placeholderCountA} B {placeholderCountB}
</p>
</article> </article>
</div> </div>
</section> </section>
<section className="content-grid"> <section className="panel input-panel">
<article className="panel">
<div className="panel-heading"> <div className="panel-heading">
<p className="eyebrow"></p> <p className="eyebrow"></p>
<h2> A B </h2> <h2> A B </h2>
</div>
<div className="upload-row">
<label className="date-field">
<span></span>
<input
type="date"
value={targetDate}
onChange={(event) => setTargetDate(event.target.value)}
/>
</label>
<button
className="primary-button upload-button"
type="button"
onClick={() => void uploadResults()}
disabled={results.length === 0 || saveState === 'saving'}
>
</button>
</div> </div>
<div className="input-grid"> <div className="input-grid">
@@ -125,7 +189,7 @@ function App() {
<textarea <textarea
value={areaAInput} value={areaAInput}
onChange={(event) => setAreaAInput(event.target.value)} onChange={(event) => setAreaAInput(event.target.value)}
placeholder={'每行一位\n例如小杰'} placeholder={'每行一位\n例如建喵'}
rows={10} rows={10}
/> />
</label> </label>
@@ -135,98 +199,137 @@ function App() {
<textarea <textarea
value={areaBInput} value={areaBInput}
onChange={(event) => setAreaBInput(event.target.value)} onChange={(event) => setAreaBInput(event.target.value)}
placeholder={'每行一位\n例如'} placeholder={'每行一位\n例如'}
rows={10} rows={10}
/> />
</label> </label>
</div> </div>
<p className="helper-text"> <p className="helper-text"></p>
</p>
{error ? <p className="error-banner">{error}</p> : null} {error ? <p className="error-banner">{error}</p> : null}
</article> </section>
<article className="panel"> <section className="panel results-panel">
<div className="panel-heading"> <div className="panel-heading">
<p className="eyebrow"></p> <p className="eyebrow"></p>
<h2></h2> <h2></h2>
</div> </div>
{result ? ( {results.length > 0 ? (
<div className="pair-list"> <div className="round-list">
{result.pairs.map((pair) => ( {results.map((round) => (
<article className="pair-card" key={pair.id}> <article className="round-card" key={round.id}>
<div className="pair-index"> {pair.id} </div> <h3 className="round-title"> {round.id} </h3>
<div className="pair-names"> <div className="team-list">
<div className="player-chip player-chip-a"> {round.teams.map((team) => (
<span className="chip-label">A </span> <article className="team-card" key={`${round.id}-${team.id}`}>
<strong>{pair.playerA}</strong> <span className="team-index"> {team.id} </span>
</div> <div className="team-line">
<div className="pair-link">+</div> <span className={team.playerA.isPlaceholder ? 'name-placeholder' : 'name-a'}>
<div className="player-chip player-chip-b"> {team.playerA.name}
<span className="chip-label">B </span> </span>
<strong>{pair.playerB}</strong> <span className="team-divider">×</span>
<span className={team.playerB.isPlaceholder ? 'name-placeholder' : 'name-b'}>
{team.playerB.name}
</span>
</div> </div>
</article>
))}
</div> </div>
</article> </article>
))} ))}
</div> </div>
) : ( ) : (
<div className="empty-state"> <div className="empty-state">
<p></p> <p></p>
</div> </div>
)} )}
</article>
</section>
<section className="bench-grid">
<article className="panel">
<div className="panel-heading">
<p className="eyebrow"></p>
<h2>A </h2>
</div>
<RosterList
names={result?.benchA ?? parsedAreaA.slice(3)}
emptyText="目前沒有 A 區候補成員"
/>
</article>
<article className="panel">
<div className="panel-heading">
<p className="eyebrow"></p>
<h2>B </h2>
</div>
<RosterList
names={result?.benchB ?? parsedAreaB.slice(3)}
emptyText="目前沒有 B 區候補成員"
/>
</article>
</section> </section>
</main> </main>
) )
} }
type RosterListProps = { function prepareParticipants(areaA: string[], areaB: string[]) {
names: string[] const targetCount = Math.max(areaA.length, areaB.length)
emptyText: string const shuffledA = shuffleList(areaA)
const shuffledB = shuffleList(areaB)
return {
areaA: createParticipants(shuffledA, targetCount, 'A'),
areaB: createParticipants(shuffledB, targetCount, 'B'),
}
} }
function RosterList({ names, emptyText }: RosterListProps) { function createParticipants(players: string[], targetCount: number, zone: 'A' | 'B') {
if (names.length === 0) { const participants: Participant[] = players.map((name, index) => ({
return <p className="empty-inline">{emptyText}</p> id: `${zone}-${index}-${name}`,
name,
isPlaceholder: false,
}))
for (let index = players.length; index < targetCount; index += 1) {
participants.push({
id: `${zone}-placeholder-${index}`,
name: PLACEHOLDER_NAME,
isPlaceholder: true,
})
} }
return ( return participants
<div className="name-list"> }
{names.map((name) => (
<span className="name-pill" key={name}> function createRoundTeams(areaA: Participant[], areaB: Participant[], roundIndex: number) {
{name} return areaA.map((playerA, index) => ({
</span> id: index + 1,
))} playerA,
</div> playerB: areaB[(index + roundIndex) % areaB.length],
) }))
}
async function saveMatchResults(
time: string,
areaA: string[],
areaB: string[],
rounds: RoundResult[],
) {
const response = await fetch('/api/match-results', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
time,
areaA,
areaB,
teams: rounds.map((round) => ({
round: round.id,
teams: round.teams.map((team) => ({
team: team.id,
a: team.playerA.name,
b: team.playerB.name,
})),
})),
}),
})
const payload = (await response.json()) as { ok?: boolean; message?: string }
if (!response.ok || !payload.ok) {
throw new Error(payload.message ?? '資料庫儲存失敗。')
}
}
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}`
}
function convertDateToKey(dateValue: string) {
const [year = '', month = '', day = ''] = dateValue.split('-')
return `${year}${month}${day}`
} }
function loadRoster(storageKey: string, fallbackList: string[]) { function loadRoster(storageKey: string, fallbackList: string[]) {
@@ -246,7 +349,7 @@ function parseRoster(input: string) {
return Array.from(uniqueNames) return Array.from(uniqueNames)
} }
function shuffleList(list: string[]) { function shuffleList<T>(list: T[]) {
const next = [...list] const next = [...list]
for (let index = next.length - 1; index > 0; index -= 1) { for (let index = next.length - 1; index > 0; index -= 1) {

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: 3500,
strictPort: true,
proxy: {
'/api': 'http://127.0.0.1:8787',
},
},
}) })