新增歷史戰績刪除功能並更新 README

This commit is contained in:
2026-04-16 10:26:58 +08:00
parent 31168e830b
commit bbedb70e7e
5 changed files with 200 additions and 33 deletions

View File

@@ -6,7 +6,7 @@
- `選隊伍`:可依指定日期從 DB 載入隊伍資料,若沒有資料可改用手動配對 - `選隊伍`:可依指定日期從 DB 載入隊伍資料,若沒有資料可改用手動配對
- `記分板`:支援先攻設定、直接點分數記分、上一步回退、上下換隊、左右換位 - `記分板`:支援先攻設定、直接點分數記分、上一步回退、上下換隊、左右換位
- `歷史戰績`:直接從 DB 的 `history` 表讀取戰績列表,點開可看得分紀錄 - `歷史戰績`:直接從 DB 的 `history` 表讀取戰績列表,點開可看得分紀錄,也能直接刪除單筆紀錄
## 目前記分板流程 ## 目前記分板流程
@@ -27,6 +27,14 @@
- 設定隊伍彈窗會優先壓縮內容高度 - 設定隊伍彈窗會優先壓縮內容高度
- 若名單較多,改為彈窗內局部捲動,不影響整頁閱讀 - 若名單較多,改為彈窗內局部捲動,不影響整頁閱讀
## 歷史戰績功能
- 歷史列表直接從 DB 的 `history` 表讀取
- 點列表卡片可查看得分過程
- 每筆列表右側都有 `刪除此筆` 按鈕
- 刪除前會跳出確認視窗
- 刪除成功後列表會即時更新
## Port 設定 ## Port 設定
### 本機開發 ### 本機開發

View File

@@ -201,6 +201,53 @@ app.get('/api/history', async (_request, response) => {
} }
}) })
app.delete('/api/history/:id', async (request, response) => {
if (!pool) {
response.status(500).json({
ok: false,
message: `DB 設定不完整,缺少:${missingEnv.join(', ')}`,
})
return
}
const id = Number(request.params.id)
if (!Number.isInteger(id) || id <= 0) {
response.status(400).json({
ok: false,
message: '戰績編號格式不正確。',
})
return
}
try {
await ensureHistoryTable(pool, historyTableName)
const [result] = await pool.execute(
`DELETE FROM \`${historyTableName}\` WHERE id = ? LIMIT 1`,
[id],
)
if (result.affectedRows === 0) {
response.status(404).json({
ok: false,
message: '找不到要刪除的戰績。',
})
return
}
response.json({
ok: true,
message: '戰績已刪除。',
})
} catch (error) {
console.error('history delete error:', error)
response.status(500).json({
ok: false,
message: error instanceof Error ? error.message : '刪除戰績失敗。',
})
}
})
if (distReady) { if (distReady) {
app.use(express.static(distDir)) app.use(express.static(distDir))

View File

@@ -335,6 +335,53 @@
box-shadow: 0 12px 28px rgba(8, 47, 73, 0.08); box-shadow: 0 12px 28px rgba(8, 47, 73, 0.08);
} }
.history-card-shell {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 14px;
align-items: center;
}
.history-card-content {
padding: 0;
background: transparent;
}
.history-delete-button {
border: 0;
border-radius: 999px;
padding: 12px 16px;
cursor: pointer;
font: inherit;
color: #fff;
background: linear-gradient(180deg, #e57a63, #c44c3d);
box-shadow:
inset 0 0 0 1px rgba(161, 54, 37, 0.22),
0 10px 18px rgba(8, 47, 73, 0.12);
transition:
transform 0.16s ease,
box-shadow 0.16s ease,
filter 0.16s ease,
opacity 0.16s ease;
}
.history-delete-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow:
inset 0 0 0 1px rgba(161, 54, 37, 0.28),
0 14px 22px rgba(8, 47, 73, 0.16);
}
.history-delete-button:active:not(:disabled) {
transform: translateY(0);
filter: brightness(0.98);
}
.history-delete-button:disabled {
cursor: default;
opacity: 0.62;
}
.scoreboard-screen { .scoreboard-screen {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 160px; grid-template-columns: minmax(0, 1fr) 160px;
@@ -1413,6 +1460,15 @@
gap: 4px; gap: 4px;
} }
.history-card-shell {
grid-template-columns: 1fr;
gap: 10px;
}
.history-delete-button {
width: 100%;
}
.team-picker-ribbon { .team-picker-ribbon {
left: 18px; left: 18px;
right: 90px; right: 90px;

View File

@@ -62,6 +62,21 @@ export async function loadHistoryList() {
return (payload.data ?? []).map(normalizeHistoryRecord) return (payload.data ?? []).map(normalizeHistoryRecord)
} }
export async function deleteHistoryItem(id: number) {
const response = await fetch(`/api/history/${id}`, {
method: 'DELETE',
})
const payload = (await response.json()) as {
ok?: boolean
message?: string
}
if (!response.ok || !payload.ok) {
throw new Error(payload.message ?? '刪除歷史戰績失敗。')
}
}
function normalizeHistoryRecord(record: HistoryRecord): HistoryListItem { function normalizeHistoryRecord(record: HistoryRecord): HistoryListItem {
const score = parseJson<[number, number]>(record.score, [0, 0]) const score = parseJson<[number, number]>(record.score, [0, 0])
const players = parseJson<string[]>(record.players, []) const players = parseJson<string[]>(record.players, [])

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { loadHistoryList } from '../lib/api' import { deleteHistoryItem, loadHistoryList } from '../lib/api'
import type { HistoryListItem } from '../types' import type { HistoryListItem } from '../types'
export function HistoryPage() { export function HistoryPage() {
@@ -7,6 +7,7 @@ export function HistoryPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [selectedItem, setSelectedItem] = useState<HistoryListItem | null>(null) const [selectedItem, setSelectedItem] = useState<HistoryListItem | null>(null)
const [deletingId, setDeletingId] = useState<number | null>(null)
useEffect(() => { useEffect(() => {
let active = true let active = true
@@ -28,7 +29,7 @@ export function HistoryPage() {
return return
} }
setError(fetchError instanceof Error ? fetchError.message : '無法讀取歷史戰績。') setError(fetchError instanceof Error ? fetchError.message : '讀取歷史戰績失敗。')
} finally { } finally {
if (active) { if (active) {
setLoading(false) setLoading(false)
@@ -43,6 +44,29 @@ export function HistoryPage() {
} }
}, []) }, [])
const handleDelete = async (item: HistoryListItem) => {
const confirmed = window.confirm(
`確定要刪除這筆戰績嗎?\n${item.leftTeamName} vs ${item.rightTeamName}`,
)
if (!confirmed) {
return
}
setDeletingId(item.id)
setError('')
try {
await deleteHistoryItem(item.id)
setHistory((current) => current.filter((entry) => entry.id !== item.id))
setSelectedItem((current) => (current?.id === item.id ? null : current))
} catch (deleteError) {
setError(deleteError instanceof Error ? deleteError.message : '刪除戰績失敗。')
} finally {
setDeletingId(null)
}
}
return ( return (
<> <>
<section className="page-grid"> <section className="page-grid">
@@ -50,7 +74,7 @@ export function HistoryPage() {
<p className="panel-kicker">History</p> <p className="panel-kicker">History</p>
<h2></h2> <h2></h2>
<p className="panel-copy"> <p className="panel-copy">
DB `history` `history`
</p> </p>
</article> </article>
@@ -58,7 +82,7 @@ export function HistoryPage() {
{loading ? ( {loading ? (
<div className="empty-state"> <div className="empty-state">
<h3></h3> <h3></h3>
<p> DB </p> <p></p>
</div> </div>
) : error ? ( ) : error ? (
<div className="empty-state"> <div className="empty-state">
@@ -68,14 +92,14 @@ export function HistoryPage() {
) : history.length === 0 ? ( ) : history.length === 0 ? (
<div className="empty-state"> <div className="empty-state">
<h3></h3> <h3></h3>
<p>DB `history` </p> <p> `history` </p>
</div> </div>
) : ( ) : (
<div className="history-list"> <div className="history-list">
{history.map((item) => ( {history.map((item) => (
<article className="history-card history-card-shell" key={item.id}>
<button <button
className="history-card history-card-button" className="history-card-button history-card-content"
key={item.id}
type="button" type="button"
onClick={() => setSelectedItem(item)} onClick={() => setSelectedItem(item)}
> >
@@ -88,15 +112,27 @@ export function HistoryPage() {
{item.leftTeamName} vs {item.rightTeamName} {item.leftTeamName} vs {item.rightTeamName}
</h3> </h3>
</div> </div>
<span className="winner-badge">{item.winnerTeamName}</span> <span className="winner-badge">{item.winnerTeamName}</span>
</div> </div>
<div className="history-meta"> <div className="history-meta">
<span>{item.score[0]} - {item.score[1]}</span> <span>
<span>{item.typeLabel}</span> {item.score[0]} - {item.score[1]}
<span>{item.winScore}</span> </span>
<span>{item.typeLabel}</span>
<span>{item.winScore}</span>
</div> </div>
</button> </button>
<button
className="history-delete-button"
disabled={deletingId === item.id}
type="button"
onClick={() => void handleDelete(item)}
>
{deletingId === item.id ? '刪除中...' : '刪除此筆'}
</button>
</article>
))} ))}
</div> </div>
)} )}
@@ -118,8 +154,13 @@ type HistoryReplayModalProps = {
function HistoryReplayModal({ item, onClose }: HistoryReplayModalProps) { function HistoryReplayModal({ item, onClose }: HistoryReplayModalProps) {
return ( return (
<div className="history-modal-overlay" role="presentation" onClick={onClose}> <div className="history-modal-overlay" role="presentation" onClick={onClose}>
<div aria-modal="true" className="history-modal" role="dialog"> <div
<p className="panel-kicker"></p> aria-modal="true"
className="history-modal"
role="dialog"
onClick={(event) => event.stopPropagation()}
>
<p className="panel-kicker"></p>
<h3> <h3>
{item.leftTeamName} vs {item.rightTeamName} {item.leftTeamName} vs {item.rightTeamName}
</h3> </h3>
@@ -139,12 +180,12 @@ function HistoryReplayModal({ item, onClose }: HistoryReplayModalProps) {
<div className="history-modal-summary"> <div className="history-modal-summary">
<span>{item.playedAt}</span> <span>{item.playedAt}</span>
<span>{item.typeLabel}</span> <span>{item.typeLabel}</span>
<span>{item.winnerTeamName}</span> <span>{item.winnerTeamName}</span>
</div> </div>
<div className="history-replay-list"> <div className="history-replay-list">
{item.scoreList.length === 0 ? ( {item.scoreList.length === 0 ? (
<p className="history-replay-empty"></p> <p className="history-replay-empty"></p>
) : ( ) : (
item.scoreList.map(([round, starter, winCount, winner]) => ( item.scoreList.map(([round, starter, winCount, winner]) => (
<div className="history-replay-row" key={`${item.id}-${round}`}> <div className="history-replay-row" key={`${item.id}-${round}`}>
@@ -162,5 +203,5 @@ function HistoryReplayModal({ item, onClose }: HistoryReplayModalProps) {
} }
function getStarterName(item: HistoryListItem, starter: number) { function getStarterName(item: HistoryListItem, starter: number) {
return item.players[starter] ?? '未知玩家' return item.players[starter] ?? '未知球員'
} }