新增歷史戰績刪除功能並更新 README
This commit is contained in:
10
README.md
10
README.md
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
- `選隊伍`:可依指定日期從 DB 載入隊伍資料,若沒有資料可改用手動配對
|
- `選隊伍`:可依指定日期從 DB 載入隊伍資料,若沒有資料可改用手動配對
|
||||||
- `記分板`:支援先攻設定、直接點分數記分、上一步回退、上下換隊、左右換位
|
- `記分板`:支援先攻設定、直接點分數記分、上一步回退、上下換隊、左右換位
|
||||||
- `歷史戰績`:直接從 DB 的 `history` 表讀取戰績列表,點開可看得分紀錄
|
- `歷史戰績`:直接從 DB 的 `history` 表讀取戰績列表,點開可看得分紀錄,也能直接刪除單筆紀錄
|
||||||
|
|
||||||
## 目前記分板流程
|
## 目前記分板流程
|
||||||
|
|
||||||
@@ -27,6 +27,14 @@
|
|||||||
- 設定隊伍彈窗會優先壓縮內容高度
|
- 設定隊伍彈窗會優先壓縮內容高度
|
||||||
- 若名單較多,改為彈窗內局部捲動,不影響整頁閱讀
|
- 若名單較多,改為彈窗內局部捲動,不影響整頁閱讀
|
||||||
|
|
||||||
|
## 歷史戰績功能
|
||||||
|
|
||||||
|
- 歷史列表直接從 DB 的 `history` 表讀取
|
||||||
|
- 點列表卡片可查看得分過程
|
||||||
|
- 每筆列表右側都有 `刪除此筆` 按鈕
|
||||||
|
- 刪除前會跳出確認視窗
|
||||||
|
- 刪除成功後列表會即時更新
|
||||||
|
|
||||||
## Port 設定
|
## Port 設定
|
||||||
|
|
||||||
### 本機開發
|
### 本機開發
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
56
src/App.css
56
src/App.css
@@ -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;
|
||||||
|
|||||||
@@ -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, [])
|
||||||
|
|||||||
@@ -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,35 +92,47 @@ 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) => (
|
||||||
<button
|
<article className="history-card history-card-shell" key={item.id}>
|
||||||
className="history-card history-card-button"
|
<button
|
||||||
key={item.id}
|
className="history-card-button history-card-content"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedItem(item)}
|
onClick={() => setSelectedItem(item)}
|
||||||
>
|
>
|
||||||
<div className="history-head">
|
<div className="history-head">
|
||||||
<div>
|
<div>
|
||||||
<p className="panel-kicker">
|
<p className="panel-kicker">
|
||||||
{item.playedAt} / {item.dayLabel}
|
{item.playedAt} / {item.dayLabel}
|
||||||
</p>
|
</p>
|
||||||
<h3>
|
<h3>
|
||||||
{item.leftTeamName} vs {item.rightTeamName}
|
{item.leftTeamName} vs {item.rightTeamName}
|
||||||
</h3>
|
</h3>
|
||||||
|
</div>
|
||||||
|
<span className="winner-badge">勝隊:{item.winnerTeamName}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="winner-badge">勝方:{item.winnerTeamName}</span>
|
|
||||||
</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>
|
||||||
</div>
|
<span>模式:{item.typeLabel}</span>
|
||||||
</button>
|
<span>目標分:{item.winScore}</span>
|
||||||
|
</div>
|
||||||
|
</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] ?? '未知球員'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user