From 35eab44a7a53107c06d2d287fd27a8725a3b3c8e Mon Sep 17 00:00:00 2001 From: JianMiau Date: Wed, 15 Apr 2026 10:11:53 +0800 Subject: [PATCH] Add loading match results by date --- README.md | 1 + server/server.mjs | 49 +++++++++++++++++ src/App.css | 4 ++ src/App.tsx | 133 ++++++++++++++++++++++++++++++++++++++-------- 4 files changed, 165 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 7a3c8b5..f6348ea 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - 每一組都會把 A 區與 B 區名單完整分配完成 - 若某一區人數不足,系統會自動補入「那個」 - 產生配對後可用按鈕手動上傳資料到資料庫 +- 可讀取指定日期的資料庫內容並回填到畫面 - 支援換行、半形逗號、全形逗號與頓號輸入 - 會自動去除空白與重複名稱 diff --git a/server/server.mjs b/server/server.mjs index 4e63c09..619ab68 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -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) => { if (!pool) { response.status(500).json({ diff --git a/src/App.css b/src/App.css index d89ea56..8f18496 100644 --- a/src/App.css +++ b/src/App.css @@ -82,6 +82,10 @@ color: #1d6c46; } +.save-status-loaded { + color: #1d6c46; +} + .save-status-error { color: #8d2d22; } diff --git a/src/App.tsx b/src/App.tsx index efc3885..91879a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,7 +18,13 @@ type RoundResult = { 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 = { areaA: 'badminton-match-hub::area-a', @@ -41,8 +47,8 @@ function App() { const [targetDate, setTargetDate] = useState(() => formatDateInputValue()) const [results, setResults] = useState([]) const [error, setError] = useState('') - const [saveState, setSaveState] = useState('idle') - const [saveMessage, setSaveMessage] = useState('') + const [actionState, setActionState] = useState('idle') + const [actionMessage, setActionMessage] = useState('') useEffect(() => { window.localStorage.setItem(STORAGE_KEYS.areaA, areaAInput) @@ -62,8 +68,8 @@ function App() { if (parsedAreaA.length === 0 || parsedAreaB.length === 0) { setResults([]) setError('A 區與 B 區都至少要輸入 1 位成員。') - setSaveState('idle') - setSaveMessage('') + setActionState('idle') + setActionMessage('') return } @@ -75,45 +81,73 @@ function App() { setResults(nextResults) setError('') - setSaveState('idle') - setSaveMessage('已產生配對,請按「上傳資料」。') + setActionState('idle') + setActionMessage('已產生配對,請按「上傳資料」。') } async function uploadResults() { if (results.length === 0) { - setSaveState('error') - setSaveMessage('請先產生配對結果,再上傳資料。') + setActionState('error') + setActionMessage('請先產生配對結果,再上傳資料。') return } if (!targetDate) { - setSaveState('error') - setSaveMessage('請先選擇目標日期。') + setActionState('error') + setActionMessage('請先選擇目標日期。') return } - setSaveState('saving') - setSaveMessage('上傳資料到資料庫中...') + setActionState('saving') + setActionMessage('上傳資料到資料庫中...') try { await saveMatchResults(convertDateToKey(targetDate), parsedAreaA, parsedAreaB, results) - setSaveState('saved') - setSaveMessage(`已同步到資料庫:${convertDateToKey(targetDate)}`) + setActionState('saved') + setActionMessage(`已同步到資料庫:${convertDateToKey(targetDate)}`) } catch (saveError) { - setSaveState('error') - setSaveMessage( + setActionState('error') + setActionMessage( 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() { setAreaAInput(defaultAreaA.join('\n')) setAreaBInput(defaultAreaB.join('\n')) setResults([]) setError('') - setSaveState('idle') - setSaveMessage('') + setActionState('idle') + setActionMessage('') } return ( @@ -135,8 +169,8 @@ function App() { 載入範例名單 - {saveMessage ? ( -

{saveMessage}

+ {actionMessage ? ( +

{actionMessage}

) : null} @@ -177,10 +211,18 @@ function App() { className="primary-button upload-button" type="button" onClick={() => void uploadResults()} - disabled={results.length === 0 || saveState === 'saving'} + disabled={results.length === 0 || actionState === 'saving' || actionState === 'loading'} > 上傳資料 +
@@ -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() { const now = new Date() const year = now.getFullYear()