Add loading match results by date
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
- 每一組都會把 A 區與 B 區名單完整分配完成
|
||||
- 若某一區人數不足,系統會自動補入「那個」
|
||||
- 產生配對後可用按鈕手動上傳資料到資料庫
|
||||
- 可讀取指定日期的資料庫內容並回填到畫面
|
||||
- 支援換行、半形逗號、全形逗號與頓號輸入
|
||||
- 會自動去除空白與重複名稱
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -82,6 +82,10 @@
|
||||
color: #1d6c46;
|
||||
}
|
||||
|
||||
.save-status-loaded {
|
||||
color: #1d6c46;
|
||||
}
|
||||
|
||||
.save-status-error {
|
||||
color: #8d2d22;
|
||||
}
|
||||
|
||||
133
src/App.tsx
133
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<RoundResult[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle')
|
||||
const [saveMessage, setSaveMessage] = useState('')
|
||||
const [actionState, setActionState] = useState<ActionState>('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() {
|
||||
載入範例名單
|
||||
</button>
|
||||
</div>
|
||||
{saveMessage ? (
|
||||
<p className={`save-status save-status-${saveState}`}>{saveMessage}</p>
|
||||
{actionMessage ? (
|
||||
<p className={`save-status save-status-${actionState}`}>{actionMessage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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'}
|
||||
>
|
||||
上傳資料
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button upload-button"
|
||||
type="button"
|
||||
onClick={() => void loadResults()}
|
||||
disabled={actionState === 'saving' || actionState === 'loading'}
|
||||
>
|
||||
讀取指定日期
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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() {
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
|
||||
Reference in New Issue
Block a user