Add DB upload flow and Docker deployment setup
This commit is contained in:
16
.dockerignore
Normal file
16
.dockerignore
Normal 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
7
.env.example
Normal 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
3
.gitignore
vendored
@@ -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
28
Dockerfile
Normal 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"]
|
||||||
94
README.md
94
README.md
@@ -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
18
docker-compose.yml
Normal 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
1135
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
140
server/server.mjs
Normal 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
|
||||||
|
`)
|
||||||
|
}
|
||||||
204
src/App.css
204
src/App.css
@@ -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 {
|
||||||
|
|||||||
317
src/App.tsx
317
src/App.tsx
@@ -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">使用 React、Vite 與 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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user