2026-04-14 22:35:03 +08:00
|
|
|
|
import { useEffect, useState } from 'react'
|
|
|
|
|
|
import './App.css'
|
|
|
|
|
|
|
2026-04-14 23:17:45 +08:00
|
|
|
|
type Participant = {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
name: string
|
|
|
|
|
|
isPlaceholder: boolean
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type Team = {
|
2026-04-14 22:35:03 +08:00
|
|
|
|
id: number
|
2026-04-14 23:17:45 +08:00
|
|
|
|
playerA: Participant
|
|
|
|
|
|
playerB: Participant
|
2026-04-14 22:35:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 23:17:45 +08:00
|
|
|
|
type RoundResult = {
|
|
|
|
|
|
id: number
|
|
|
|
|
|
teams: Team[]
|
2026-04-14 22:35:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 23:17:45 +08:00
|
|
|
|
type SaveState = 'idle' | 'saving' | 'saved' | 'error'
|
|
|
|
|
|
|
2026-04-14 22:35:03 +08:00
|
|
|
|
const STORAGE_KEYS = {
|
|
|
|
|
|
areaA: 'badminton-match-hub::area-a',
|
|
|
|
|
|
areaB: 'badminton-match-hub::area-b',
|
|
|
|
|
|
} as const
|
|
|
|
|
|
|
2026-04-14 23:17:45 +08:00
|
|
|
|
const PLACEHOLDER_NAME = '那個'
|
|
|
|
|
|
const TOTAL_ROUNDS = 3
|
|
|
|
|
|
|
|
|
|
|
|
const defaultAreaA = ['建喵', '柏威', '景涵', 'Gary', '昱翔']
|
|
|
|
|
|
const defaultAreaB = ['小念', '玟瑄', 'RuRu', '肉肉', '蓉蓉']
|
2026-04-14 22:35:03 +08:00
|
|
|
|
|
|
|
|
|
|
function App() {
|
|
|
|
|
|
const [areaAInput, setAreaAInput] = useState(() =>
|
|
|
|
|
|
loadRoster(STORAGE_KEYS.areaA, defaultAreaA),
|
|
|
|
|
|
)
|
|
|
|
|
|
const [areaBInput, setAreaBInput] = useState(() =>
|
|
|
|
|
|
loadRoster(STORAGE_KEYS.areaB, defaultAreaB),
|
|
|
|
|
|
)
|
2026-04-14 23:17:45 +08:00
|
|
|
|
const [targetDate, setTargetDate] = useState(() => formatDateInputValue())
|
|
|
|
|
|
const [results, setResults] = useState<RoundResult[]>([])
|
2026-04-14 22:35:03 +08:00
|
|
|
|
const [error, setError] = useState('')
|
2026-04-14 23:17:45 +08:00
|
|
|
|
const [saveState, setSaveState] = useState<SaveState>('idle')
|
|
|
|
|
|
const [saveMessage, setSaveMessage] = useState('')
|
2026-04-14 22:35:03 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-04-14 23:17:45 +08:00
|
|
|
|
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 generateGroups() {
|
|
|
|
|
|
if (parsedAreaA.length === 0 || parsedAreaB.length === 0) {
|
|
|
|
|
|
setResults([])
|
|
|
|
|
|
setError('A 區與 B 區都至少要輸入 1 位成員。')
|
|
|
|
|
|
setSaveState('idle')
|
|
|
|
|
|
setSaveMessage('')
|
2026-04-14 22:35:03 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 23:17:45 +08:00
|
|
|
|
const prepared = prepareParticipants(parsedAreaA, parsedAreaB)
|
|
|
|
|
|
const nextResults = Array.from({ length: TOTAL_ROUNDS }, (_, index) => ({
|
2026-04-14 22:35:03 +08:00
|
|
|
|
id: index + 1,
|
2026-04-14 23:17:45 +08:00
|
|
|
|
teams: createRoundTeams(prepared.areaA, prepared.areaB, index),
|
2026-04-14 22:35:03 +08:00
|
|
|
|
}))
|
|
|
|
|
|
|
2026-04-14 23:17:45 +08:00
|
|
|
|
setResults(nextResults)
|
2026-04-14 22:35:03 +08:00
|
|
|
|
setError('')
|
2026-04-14 23:17:45 +08:00
|
|
|
|
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 : '資料庫儲存失敗,請稍後再試。',
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-04-14 22:35:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resetDemo() {
|
|
|
|
|
|
setAreaAInput(defaultAreaA.join('\n'))
|
|
|
|
|
|
setAreaBInput(defaultAreaB.join('\n'))
|
2026-04-14 23:17:45 +08:00
|
|
|
|
setResults([])
|
2026-04-14 22:35:03 +08:00
|
|
|
|
setError('')
|
2026-04-14 23:17:45 +08:00
|
|
|
|
setSaveState('idle')
|
|
|
|
|
|
setSaveMessage('')
|
2026-04-14 22:35:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<main className="app-shell">
|
|
|
|
|
|
<section className="hero-card">
|
|
|
|
|
|
<div className="hero-copy">
|
2026-04-14 23:17:45 +08:00
|
|
|
|
<p className="eyebrow">羽毛球隊伍配對</p>
|
|
|
|
|
|
<h1>羽毛球分組配對器</h1>
|
2026-04-14 22:35:03 +08:00
|
|
|
|
<p className="hero-text">
|
2026-04-14 23:17:45 +08:00
|
|
|
|
輸入 A 區與 B 區名單後,系統會產生 3 組完整配對。
|
|
|
|
|
|
每一隊都是 1 位 A 區成員搭配 1 位 B 區成員;
|
|
|
|
|
|
若某一區人數不足,會自動補入「那個」。
|
2026-04-14 22:35:03 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
<div className="hero-actions">
|
2026-04-14 23:17:45 +08:00
|
|
|
|
<button className="primary-button" type="button" onClick={generateGroups}>
|
2026-04-14 22:35:03 +08:00
|
|
|
|
產生三組配對
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button className="ghost-button" type="button" onClick={resetDemo}>
|
|
|
|
|
|
載入範例名單
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-04-14 23:17:45 +08:00
|
|
|
|
{saveMessage ? (
|
|
|
|
|
|
<p className={`save-status save-status-${saveState}`}>{saveMessage}</p>
|
|
|
|
|
|
) : null}
|
2026-04-14 22:35:03 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="hero-stats">
|
|
|
|
|
|
<article className="stat-card">
|
2026-04-14 23:17:45 +08:00
|
|
|
|
<span>A 區人數</span>
|
2026-04-14 22:35:03 +08:00
|
|
|
|
<strong>{parsedAreaA.length}</strong>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
<article className="stat-card">
|
2026-04-14 23:17:45 +08:00
|
|
|
|
<span>B 區人數</span>
|
2026-04-14 22:35:03 +08:00
|
|
|
|
<strong>{parsedAreaB.length}</strong>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
<article className="tip-card">
|
2026-04-14 23:17:45 +08:00
|
|
|
|
<h2>摘要</h2>
|
|
|
|
|
|
<p>
|
|
|
|
|
|
每組共 {teamCount} 隊,A 區補位 {placeholderCountA} 人,B 區補位 {placeholderCountB} 人。
|
|
|
|
|
|
</p>
|
2026-04-14 22:35:03 +08:00
|
|
|
|
</article>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
2026-04-14 23:17:45 +08:00
|
|
|
|
<section className="panel input-panel">
|
|
|
|
|
|
<div className="panel-heading">
|
|
|
|
|
|
<p className="eyebrow">名單輸入</p>
|
|
|
|
|
|
<h2>輸入 A 區與 B 區名單</h2>
|
|
|
|
|
|
</div>
|
2026-04-14 22:35:03 +08:00
|
|
|
|
|
2026-04-14 23:17:45 +08:00
|
|
|
|
<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>
|
2026-04-14 22:35:03 +08:00
|
|
|
|
|
2026-04-14 23:17:45 +08:00
|
|
|
|
<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>
|
2026-04-14 22:35:03 +08:00
|
|
|
|
|
2026-04-14 23:17:45 +08:00
|
|
|
|
<p className="helper-text">支援換行、逗號、頓號分隔,會自動去除重複名稱。</p>
|
|
|
|
|
|
{error ? <p className="error-banner">{error}</p> : null}
|
2026-04-14 22:35:03 +08:00
|
|
|
|
</section>
|
|
|
|
|
|
|
2026-04-14 23:17:45 +08:00
|
|
|
|
<section className="panel results-panel">
|
|
|
|
|
|
<div className="panel-heading">
|
|
|
|
|
|
<p className="eyebrow">配對結果</p>
|
|
|
|
|
|
<h2>三組名單</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{results.length > 0 ? (
|
|
|
|
|
|
<div className="round-list">
|
|
|
|
|
|
{results.map((round) => (
|
|
|
|
|
|
<article className="round-card" key={round.id}>
|
|
|
|
|
|
<h3 className="round-title">第 {round.id} 組</h3>
|
|
|
|
|
|
<div className="team-list">
|
|
|
|
|
|
{round.teams.map((team) => (
|
|
|
|
|
|
<article className="team-card" key={`${round.id}-${team.id}`}>
|
|
|
|
|
|
<span className="team-index">第 {team.id} 隊</span>
|
|
|
|
|
|
<div className="team-line">
|
|
|
|
|
|
<span className={team.playerA.isPlaceholder ? 'name-placeholder' : 'name-a'}>
|
|
|
|
|
|
{team.playerA.name}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="team-divider">×</span>
|
|
|
|
|
|
<span className={team.playerB.isPlaceholder ? 'name-placeholder' : 'name-b'}>
|
|
|
|
|
|
{team.playerB.name}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
))}
|
2026-04-14 22:35:03 +08:00
|
|
|
|
</div>
|
2026-04-14 23:17:45 +08:00
|
|
|
|
) : (
|
|
|
|
|
|
<div className="empty-state">
|
|
|
|
|
|
<p>按下「產生三組配對」後,這裡會直接顯示誰跟誰一隊。</p>
|
2026-04-14 22:35:03 +08:00
|
|
|
|
</div>
|
2026-04-14 23:17:45 +08:00
|
|
|
|
)}
|
2026-04-14 22:35:03 +08:00
|
|
|
|
</section>
|
|
|
|
|
|
</main>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 23:17:45 +08:00
|
|
|
|
function prepareParticipants(areaA: string[], areaB: string[]) {
|
|
|
|
|
|
const targetCount = Math.max(areaA.length, areaB.length)
|
|
|
|
|
|
const shuffledA = shuffleList(areaA)
|
|
|
|
|
|
const shuffledB = shuffleList(areaB)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
areaA: createParticipants(shuffledA, targetCount, 'A'),
|
|
|
|
|
|
areaB: createParticipants(shuffledB, targetCount, 'B'),
|
|
|
|
|
|
}
|
2026-04-14 22:35:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 23:17:45 +08:00
|
|
|
|
function createParticipants(players: string[], targetCount: number, zone: 'A' | 'B') {
|
|
|
|
|
|
const participants: Participant[] = players.map((name, index) => ({
|
|
|
|
|
|
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,
|
|
|
|
|
|
})
|
2026-04-14 22:35:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 23:17:45 +08:00
|
|
|
|
return participants
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function createRoundTeams(areaA: Participant[], areaB: Participant[], roundIndex: number) {
|
|
|
|
|
|
return areaA.map((playerA, index) => ({
|
|
|
|
|
|
id: index + 1,
|
|
|
|
|
|
playerA,
|
|
|
|
|
|
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}`
|
2026-04-14 22:35:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 23:17:45 +08:00
|
|
|
|
function shuffleList<T>(list: T[]) {
|
2026-04-14 22:35:03 +08:00
|
|
|
|
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
|