Add loading match results by date
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
- 每一組都會把 A 區與 B 區名單完整分配完成
|
- 每一組都會把 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) => {
|
app.post('/api/match-results', async (request, response) => {
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
response.status(500).json({
|
response.status(500).json({
|
||||||
|
|||||||
@@ -82,6 +82,10 @@
|
|||||||
color: #1d6c46;
|
color: #1d6c46;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.save-status-loaded {
|
||||||
|
color: #1d6c46;
|
||||||
|
}
|
||||||
|
|
||||||
.save-status-error {
|
.save-status-error {
|
||||||
color: #8d2d22;
|
color: #8d2d22;
|
||||||
}
|
}
|
||||||
|
|||||||
133
src/App.tsx
133
src/App.tsx
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user