Add loading match results by date

This commit is contained in:
2026-04-15 10:11:53 +08:00
parent 6c3ff0e3d1
commit 35eab44a7a
4 changed files with 165 additions and 22 deletions

View File

@@ -11,6 +11,7 @@
- 每一組都會把 A 區與 B 區名單完整分配完成 - 每一組都會把 A 區與 B 區名單完整分配完成
- 若某一區人數不足,系統會自動補入「那個」 - 若某一區人數不足,系統會自動補入「那個」
- 產生配對後可用按鈕手動上傳資料到資料庫 - 產生配對後可用按鈕手動上傳資料到資料庫
- 可讀取指定日期的資料庫內容並回填到畫面
- 支援換行、半形逗號、全形逗號與頓號輸入 - 支援換行、半形逗號、全形逗號與頓號輸入
- 會自動去除空白與重複名稱 - 會自動去除空白與重複名稱

View File

@@ -43,6 +43,55 @@ app.get('/api/health', (_request, response) => {
}) })
}) })
app.get('/api/match-results/:time', async (request, response) => {
if (!pool) {
response.status(500).json({
ok: false,
message: `資料庫環境變數缺少:${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 ensureTable(pool, tableName)
const [rows] = await pool.execute(
`SELECT time, personnel, battlecombination FROM \`${tableName}\` 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/match-results', async (request, response) => { app.post('/api/match-results', async (request, response) => {
if (!pool) { if (!pool) {
response.status(500).json({ response.status(500).json({

View File

@@ -82,6 +82,10 @@
color: #1d6c46; color: #1d6c46;
} }
.save-status-loaded {
color: #1d6c46;
}
.save-status-error { .save-status-error {
color: #8d2d22; color: #8d2d22;
} }

View File

@@ -18,7 +18,13 @@ type RoundResult = {
teams: Team[] teams: Team[]
} }
type SaveState = 'idle' | 'saving' | 'saved' | 'error' type ActionState = 'idle' | 'saving' | 'saved' | 'loading' | 'loaded' | 'error'
type LoadMatchResultsResponse = {
time: number
personnel: string
battlecombination: string | null
}
const STORAGE_KEYS = { const STORAGE_KEYS = {
areaA: 'badminton-match-hub::area-a', areaA: 'badminton-match-hub::area-a',
@@ -41,8 +47,8 @@ function App() {
const [targetDate, setTargetDate] = useState(() => formatDateInputValue()) const [targetDate, setTargetDate] = useState(() => formatDateInputValue())
const [results, setResults] = useState<RoundResult[]>([]) const [results, setResults] = useState<RoundResult[]>([])
const [error, setError] = useState('') const [error, setError] = useState('')
const [saveState, setSaveState] = useState<SaveState>('idle') const [actionState, setActionState] = useState<ActionState>('idle')
const [saveMessage, setSaveMessage] = useState('') const [actionMessage, setActionMessage] = useState('')
useEffect(() => { useEffect(() => {
window.localStorage.setItem(STORAGE_KEYS.areaA, areaAInput) window.localStorage.setItem(STORAGE_KEYS.areaA, areaAInput)
@@ -62,8 +68,8 @@ function App() {
if (parsedAreaA.length === 0 || parsedAreaB.length === 0) { if (parsedAreaA.length === 0 || parsedAreaB.length === 0) {
setResults([]) setResults([])
setError('A 區與 B 區都至少要輸入 1 位成員。') setError('A 區與 B 區都至少要輸入 1 位成員。')
setSaveState('idle') setActionState('idle')
setSaveMessage('') setActionMessage('')
return return
} }
@@ -75,45 +81,73 @@ function App() {
setResults(nextResults) setResults(nextResults)
setError('') setError('')
setSaveState('idle') setActionState('idle')
setSaveMessage('已產生配對,請按「上傳資料」。') setActionMessage('已產生配對,請按「上傳資料」。')
} }
async function uploadResults() { async function uploadResults() {
if (results.length === 0) { if (results.length === 0) {
setSaveState('error') setActionState('error')
setSaveMessage('請先產生配對結果,再上傳資料。') setActionMessage('請先產生配對結果,再上傳資料。')
return return
} }
if (!targetDate) { if (!targetDate) {
setSaveState('error') setActionState('error')
setSaveMessage('請先選擇目標日期。') setActionMessage('請先選擇目標日期。')
return return
} }
setSaveState('saving') setActionState('saving')
setSaveMessage('上傳資料到資料庫中...') setActionMessage('上傳資料到資料庫中...')
try { try {
await saveMatchResults(convertDateToKey(targetDate), parsedAreaA, parsedAreaB, results) await saveMatchResults(convertDateToKey(targetDate), parsedAreaA, parsedAreaB, results)
setSaveState('saved') setActionState('saved')
setSaveMessage(`已同步到資料庫:${convertDateToKey(targetDate)}`) setActionMessage(`已同步到資料庫:${convertDateToKey(targetDate)}`)
} catch (saveError) { } catch (saveError) {
setSaveState('error') setActionState('error')
setSaveMessage( setActionMessage(
saveError instanceof Error ? saveError.message : '資料庫儲存失敗,請稍後再試。', saveError instanceof Error ? saveError.message : '資料庫儲存失敗,請稍後再試。',
) )
} }
} }
async function loadResults() {
if (!targetDate) {
setActionState('error')
setActionMessage('請先選擇目標日期。')
return
}
setActionState('loading')
setActionMessage('讀取指定日期資料中...')
try {
const payload = await loadMatchResults(convertDateToKey(targetDate))
const loaded = convertDbRecordToAppState(payload)
setAreaAInput(loaded.areaA.join('\n'))
setAreaBInput(loaded.areaB.join('\n'))
setResults(loaded.results)
setError('')
setActionState('loaded')
setActionMessage(`已讀取資料:${convertDateToKey(targetDate)}`)
} catch (loadError) {
setResults([])
setActionState('error')
setActionMessage(
loadError instanceof Error ? loadError.message : '讀取資料失敗,請稍後再試。',
)
}
}
function resetDemo() { function resetDemo() {
setAreaAInput(defaultAreaA.join('\n')) setAreaAInput(defaultAreaA.join('\n'))
setAreaBInput(defaultAreaB.join('\n')) setAreaBInput(defaultAreaB.join('\n'))
setResults([]) setResults([])
setError('') setError('')
setSaveState('idle') setActionState('idle')
setSaveMessage('') setActionMessage('')
} }
return ( return (
@@ -135,8 +169,8 @@ function App() {
</button> </button>
</div> </div>
{saveMessage ? ( {actionMessage ? (
<p className={`save-status save-status-${saveState}`}>{saveMessage}</p> <p className={`save-status save-status-${actionState}`}>{actionMessage}</p>
) : null} ) : null}
</div> </div>
@@ -177,10 +211,18 @@ function App() {
className="primary-button upload-button" className="primary-button upload-button"
type="button" type="button"
onClick={() => void uploadResults()} onClick={() => void uploadResults()}
disabled={results.length === 0 || saveState === 'saving'} disabled={results.length === 0 || actionState === 'saving' || actionState === 'loading'}
> >
</button> </button>
<button
className="ghost-button upload-button"
type="button"
onClick={() => void loadResults()}
disabled={actionState === 'saving' || actionState === 'loading'}
>
</button>
</div> </div>
<div className="input-grid"> <div className="input-grid">
@@ -319,6 +361,53 @@ async function saveMatchResults(
} }
} }
async function loadMatchResults(time: string) {
const response = await fetch(`/api/match-results/${time}`)
const payload = (await response.json()) as {
ok?: boolean
message?: string
data?: LoadMatchResultsResponse
}
if (!response.ok || !payload.ok || !payload.data) {
throw new Error(payload.message ?? '指定日期沒有資料。')
}
return payload.data
}
function convertDbRecordToAppState(record: LoadMatchResultsResponse) {
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 results = Object.keys(battlecombination)
.sort((left, right) => Number(left) - Number(right))
.map((key, roundIndex) => ({
id: roundIndex + 1,
teams: battlecombination[key].map((team, teamIndex) => ({
id: teamIndex + 1,
playerA: createLoadedParticipant(team[0], 'A', roundIndex, teamIndex),
playerB: createLoadedParticipant(team[1], 'B', roundIndex, teamIndex),
})),
}))
return { areaA, areaB, results }
}
function createLoadedParticipant(name: string, zone: 'A' | 'B', roundIndex: number, teamIndex: number) {
return {
id: `${zone}-loaded-${roundIndex}-${teamIndex}-${name}`,
name,
isPlaceholder: name === PLACEHOLDER_NAME,
}
}
function formatDateInputValue() { function formatDateInputValue() {
const now = new Date() const now = new Date()
const year = now.getFullYear() const year = now.getFullYear()