Initial React badminton team matching app
This commit is contained in:
260
src/App.tsx
Normal file
260
src/App.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import './App.css'
|
||||
|
||||
type Pair = {
|
||||
id: number
|
||||
playerA: string
|
||||
playerB: string
|
||||
}
|
||||
|
||||
type DrawResult = {
|
||||
pairs: Pair[]
|
||||
benchA: string[]
|
||||
benchB: string[]
|
||||
}
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
areaA: 'badminton-match-hub::area-a',
|
||||
areaB: 'badminton-match-hub::area-b',
|
||||
} as const
|
||||
|
||||
const defaultAreaA = ['小杰', '阿誠', '建宏', '柏宇', 'Eason']
|
||||
const defaultAreaB = ['小安', '佩珊', '阿廷', 'Luna', 'Mina']
|
||||
|
||||
function App() {
|
||||
const [areaAInput, setAreaAInput] = useState(() =>
|
||||
loadRoster(STORAGE_KEYS.areaA, defaultAreaA),
|
||||
)
|
||||
const [areaBInput, setAreaBInput] = useState(() =>
|
||||
loadRoster(STORAGE_KEYS.areaB, defaultAreaB),
|
||||
)
|
||||
const [result, setResult] = useState<DrawResult | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(STORAGE_KEYS.areaA, areaAInput)
|
||||
}, [areaAInput])
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(STORAGE_KEYS.areaB, areaBInput)
|
||||
}, [areaBInput])
|
||||
|
||||
const parsedAreaA = parseRoster(areaAInput)
|
||||
const parsedAreaB = parseRoster(areaBInput)
|
||||
|
||||
function generateMatches() {
|
||||
if (parsedAreaA.length < 3 || parsedAreaB.length < 3) {
|
||||
setResult(null)
|
||||
setError('A 區與 B 區都至少要有 3 位不重複成員,才能產生三組配對。')
|
||||
return
|
||||
}
|
||||
|
||||
const shuffledA = shuffleList(parsedAreaA)
|
||||
const shuffledB = shuffleList(parsedAreaB)
|
||||
const selectedA = shuffledA.slice(0, 3)
|
||||
const selectedB = shuffledB.slice(0, 3)
|
||||
|
||||
const pairs = selectedA.map((playerA, index) => ({
|
||||
id: index + 1,
|
||||
playerA,
|
||||
playerB: selectedB[index],
|
||||
}))
|
||||
|
||||
setResult({
|
||||
pairs,
|
||||
benchA: shuffledA.slice(3),
|
||||
benchB: shuffledB.slice(3),
|
||||
})
|
||||
setError('')
|
||||
}
|
||||
|
||||
function resetDemo() {
|
||||
setAreaAInput(defaultAreaA.join('\n'))
|
||||
setAreaBInput(defaultAreaB.join('\n'))
|
||||
setResult(null)
|
||||
setError('')
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<section className="hero-card">
|
||||
<div className="hero-copy">
|
||||
<p className="eyebrow">使用 React、Vite 與 TypeScript 建置</p>
|
||||
<h1>羽毛球隊伍配對器</h1>
|
||||
<p className="hero-text">
|
||||
分別輸入 A 區與 B 區名單後,系統會從兩區各抽出 3 位成員,
|
||||
自動組成 3 組隊伍。每一組都是 A 區 1 位搭配 B 區 1 位,
|
||||
同一輪內三組成員都不會重複。
|
||||
</p>
|
||||
<div className="hero-actions">
|
||||
<button className="primary-button" type="button" onClick={generateMatches}>
|
||||
產生三組配對
|
||||
</button>
|
||||
<button className="ghost-button" type="button" onClick={resetDemo}>
|
||||
載入範例名單
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hero-stats">
|
||||
<article className="stat-card">
|
||||
<span>A 區可用人數</span>
|
||||
<strong>{parsedAreaA.length}</strong>
|
||||
</article>
|
||||
<article className="stat-card">
|
||||
<span>B 區可用人數</span>
|
||||
<strong>{parsedAreaB.length}</strong>
|
||||
</article>
|
||||
<article className="tip-card">
|
||||
<h2>配對規則</h2>
|
||||
<p>固定產生 3 組,且每組皆為 A 區 1 位加上 B 區 1 位,當輪不重複。</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="content-grid">
|
||||
<article className="panel">
|
||||
<div className="panel-heading">
|
||||
<p className="eyebrow">名單輸入</p>
|
||||
<h2>輸入 A 區與 B 區成員</h2>
|
||||
</div>
|
||||
|
||||
<div className="input-grid">
|
||||
<label className="roster-field">
|
||||
<span>A 區成員</span>
|
||||
<textarea
|
||||
value={areaAInput}
|
||||
onChange={(event) => setAreaAInput(event.target.value)}
|
||||
placeholder={'每行一位\n例如:小杰'}
|
||||
rows={10}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="roster-field">
|
||||
<span>B 區成員</span>
|
||||
<textarea
|
||||
value={areaBInput}
|
||||
onChange={(event) => setAreaBInput(event.target.value)}
|
||||
placeholder={'每行一位\n例如:小安'}
|
||||
rows={10}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p className="helper-text">
|
||||
支援換行、半形逗號、全形逗號與頓號分隔,系統會自動去除空白與重複名稱。
|
||||
</p>
|
||||
|
||||
{error ? <p className="error-banner">{error}</p> : null}
|
||||
</article>
|
||||
|
||||
<article className="panel">
|
||||
<div className="panel-heading">
|
||||
<p className="eyebrow">配對結果</p>
|
||||
<h2>本輪三組隊伍</h2>
|
||||
</div>
|
||||
|
||||
{result ? (
|
||||
<div className="pair-list">
|
||||
{result.pairs.map((pair) => (
|
||||
<article className="pair-card" key={pair.id}>
|
||||
<div className="pair-index">第 {pair.id} 組</div>
|
||||
<div className="pair-names">
|
||||
<div className="player-chip player-chip-a">
|
||||
<span className="chip-label">A 區</span>
|
||||
<strong>{pair.playerA}</strong>
|
||||
</div>
|
||||
<div className="pair-link">+</div>
|
||||
<div className="player-chip player-chip-b">
|
||||
<span className="chip-label">B 區</span>
|
||||
<strong>{pair.playerB}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<p>輸入名單後按下「產生三組配對」,這裡會顯示本輪的抽籤結果。</p>
|
||||
</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>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
type RosterListProps = {
|
||||
names: string[]
|
||||
emptyText: string
|
||||
}
|
||||
|
||||
function RosterList({ names, emptyText }: RosterListProps) {
|
||||
if (names.length === 0) {
|
||||
return <p className="empty-inline">{emptyText}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="name-list">
|
||||
{names.map((name) => (
|
||||
<span className="name-pill" key={name}>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function loadRoster(storageKey: string, fallbackList: string[]) {
|
||||
const storedValue = window.localStorage.getItem(storageKey)
|
||||
return storedValue && storedValue.trim() ? storedValue : fallbackList.join('\n')
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function shuffleList(list: string[]) {
|
||||
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
|
||||
}
|
||||
|
||||
export default App
|
||||
Reference in New Issue
Block a user