補上歷史戰績列表與 NAS 部署說明
This commit is contained in:
+118
@@ -323,6 +323,18 @@
|
||||
color: var(--panel-soft);
|
||||
}
|
||||
|
||||
.history-card-button {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.history-card-button:hover {
|
||||
box-shadow: 0 12px 28px rgba(8, 47, 73, 0.08);
|
||||
}
|
||||
|
||||
.scoreboard-screen {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 160px;
|
||||
@@ -871,6 +883,94 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.history-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 70;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
background: rgba(0, 0, 0, 0.56);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.history-modal {
|
||||
width: min(680px, 100%);
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 22px 20px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, #fff8e8, #ffe5ad);
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(255, 255, 255, 0.18),
|
||||
inset 0 0 0 2px rgba(200, 140, 46, 0.45);
|
||||
}
|
||||
|
||||
.history-modal-score {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 249, 238, 0.94);
|
||||
}
|
||||
|
||||
.history-modal-score div {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.history-modal-score strong {
|
||||
font-family: var(--mono);
|
||||
font-size: 2.5rem;
|
||||
line-height: 1;
|
||||
color: #16342f;
|
||||
}
|
||||
|
||||
.history-modal-score span {
|
||||
color: #5f4a35;
|
||||
}
|
||||
|
||||
.history-modal-score-divider {
|
||||
font-family: var(--mono);
|
||||
font-size: 1.8rem;
|
||||
color: #70543c;
|
||||
}
|
||||
|
||||
.history-modal-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
color: #5f4a35;
|
||||
}
|
||||
|
||||
.history-replay-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-height: min(50vh, 480px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.history-replay-row {
|
||||
display: grid;
|
||||
grid-template-columns: 108px 92px 92px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 249, 238, 0.92);
|
||||
}
|
||||
|
||||
.history-replay-empty {
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
color: #5f4a35;
|
||||
background: rgba(255, 249, 238, 0.92);
|
||||
}
|
||||
|
||||
.inline-link {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
@@ -1050,6 +1150,11 @@
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.history-modal {
|
||||
padding: 18px 14px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.finish-dialog-close {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@@ -1068,6 +1173,19 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.history-modal-score {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.history-modal-score-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.history-replay-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.team-picker-ribbon {
|
||||
left: 18px;
|
||||
right: 90px;
|
||||
|
||||
+1
-1
@@ -511,7 +511,7 @@ function App() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/history" element={<HistoryPage history={history} />} />
|
||||
<Route path="/history" element={<HistoryPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type {
|
||||
HistoryListItem,
|
||||
HistoryRecord,
|
||||
HistoryUploadPayload,
|
||||
HistoryUploadResponse,
|
||||
MatchResultsRecord,
|
||||
@@ -44,3 +46,66 @@ export async function saveMatchHistory(payload: HistoryUploadPayload) {
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
export async function loadHistoryList() {
|
||||
const response = await fetch('/api/history')
|
||||
const payload = (await response.json()) as {
|
||||
ok?: boolean
|
||||
message?: string
|
||||
data?: HistoryRecord[]
|
||||
}
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
throw new Error(payload.message ?? '無法讀取歷史戰績。')
|
||||
}
|
||||
|
||||
return (payload.data ?? []).map(normalizeHistoryRecord)
|
||||
}
|
||||
|
||||
function normalizeHistoryRecord(record: HistoryRecord): HistoryListItem {
|
||||
const score = parseJson<[number, number]>(record.score, [0, 0])
|
||||
const players = parseJson<string[]>(record.players, [])
|
||||
const team = parseJson<[string[], string[]]>(record.team, [[], []])
|
||||
const scoreList = parseJson<Array<[number, number, number, 0 | 1]>>(
|
||||
record.scoreList,
|
||||
[],
|
||||
)
|
||||
const leftTeamName = team[0]?.join(' / ') || players.slice(0, 2).join(' / ') || '-'
|
||||
const rightTeamName = team[1]?.join(' / ') || players.slice(2, 4).join(' / ') || '-'
|
||||
const winnerTeamName = score[0] >= score[1] ? leftTeamName : rightTeamName
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
time: record.time,
|
||||
playedAt: new Date(record.time * 1000).toLocaleString('zh-TW', { hour12: false }),
|
||||
dayOfWeek: record.dayOfWeek,
|
||||
dayLabel: getDayLabel(record.dayOfWeek),
|
||||
score,
|
||||
winScore: record.winScore,
|
||||
type: record.type,
|
||||
typeLabel: record.type === 1 ? '單打' : '雙打',
|
||||
players,
|
||||
team,
|
||||
scoreList,
|
||||
leftTeamName,
|
||||
rightTeamName,
|
||||
winnerTeamName,
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson<T>(value: string | null, fallback: T): T {
|
||||
if (!value) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as T
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function getDayLabel(dayOfWeek: number) {
|
||||
const labels = ['週日', '週一', '週二', '週三', '週四', '週五', '週六']
|
||||
return labels[dayOfWeek] ?? '-'
|
||||
}
|
||||
|
||||
+154
-41
@@ -1,49 +1,162 @@
|
||||
import type { MatchHistoryItem } from '../types'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { loadHistoryList } from '../lib/api'
|
||||
import type { HistoryListItem } from '../types'
|
||||
|
||||
type HistoryPageProps = {
|
||||
history: MatchHistoryItem[]
|
||||
}
|
||||
export function HistoryPage() {
|
||||
const [history, setHistory] = useState<HistoryListItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [selectedItem, setSelectedItem] = useState<HistoryListItem | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
const run = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const nextHistory = await loadHistoryList()
|
||||
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
setHistory(nextHistory)
|
||||
} catch (fetchError) {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
setError(fetchError instanceof Error ? fetchError.message : '無法讀取歷史戰績。')
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void run()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
export function HistoryPage({ history }: HistoryPageProps) {
|
||||
return (
|
||||
<section className="page-grid">
|
||||
<article className="panel panel-hero">
|
||||
<p className="panel-kicker">History</p>
|
||||
<h2>歷史戰績</h2>
|
||||
<p className="panel-copy">這裡會顯示本機目前這次操作中,已經成功上傳到 DB 的比賽結果。</p>
|
||||
</article>
|
||||
<>
|
||||
<section className="page-grid">
|
||||
<article className="panel panel-hero">
|
||||
<p className="panel-kicker">History</p>
|
||||
<h2>歷史戰績</h2>
|
||||
<p className="panel-copy">
|
||||
這裡直接顯示 DB 的 `history` 列表。點任一筆戰績,可快速查看該場的得分紀錄。
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article className="panel full-span">
|
||||
{history.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>目前還沒有戰績</h3>
|
||||
<p>完成比賽結算並上傳到 DB 後,這裡就會看到紀錄。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="history-list">
|
||||
{history.map((item) => (
|
||||
<article className="history-card" key={item.id}>
|
||||
<div className="history-head">
|
||||
<div>
|
||||
<p className="panel-kicker">{item.playedAt}</p>
|
||||
<h3>
|
||||
{item.leftTeamName} vs {item.rightTeamName}
|
||||
</h3>
|
||||
<article className="panel full-span">
|
||||
{loading ? (
|
||||
<div className="empty-state">
|
||||
<h3>正在讀取戰績</h3>
|
||||
<p>請稍候一下,正在從 DB 載入列表。</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="empty-state">
|
||||
<h3>讀取失敗</h3>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
) : history.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>目前沒有戰績</h3>
|
||||
<p>DB 的 `history` 資料表目前沒有可顯示的紀錄。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="history-list">
|
||||
{history.map((item) => (
|
||||
<button
|
||||
className="history-card history-card-button"
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedItem(item)}
|
||||
>
|
||||
<div className="history-head">
|
||||
<div>
|
||||
<p className="panel-kicker">
|
||||
{item.playedAt} / {item.dayLabel}
|
||||
</p>
|
||||
<h3>
|
||||
{item.leftTeamName} vs {item.rightTeamName}
|
||||
</h3>
|
||||
</div>
|
||||
<span className="winner-badge">勝方:{item.winnerTeamName}</span>
|
||||
</div>
|
||||
<span className="winner-badge">勝方:{item.winner}</span>
|
||||
</div>
|
||||
|
||||
<div className="history-meta">
|
||||
<span>比賽日期:{item.matchDate || '-'}</span>
|
||||
<span>資料來源:{item.source === 'db' ? 'DB' : item.source === 'manual' ? '手動' : '-'}</span>
|
||||
<span>第 {item.groupId} 組</span>
|
||||
<span>比分:{item.scoreLeft} - {item.scoreRight}</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
<div className="history-meta">
|
||||
<span>比分:{item.score[0]} - {item.score[1]}</span>
|
||||
<span>類型:{item.typeLabel}</span>
|
||||
<span>勝利分數:{item.winScore}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{selectedItem ? (
|
||||
<HistoryReplayModal item={selectedItem} onClose={() => setSelectedItem(null)} />
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type HistoryReplayModalProps = {
|
||||
item: HistoryListItem
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function HistoryReplayModal({ item, onClose }: HistoryReplayModalProps) {
|
||||
return (
|
||||
<div className="history-modal-overlay" role="presentation" onClick={onClose}>
|
||||
<div aria-modal="true" className="history-modal" role="dialog">
|
||||
<p className="panel-kicker">點任意位置關閉</p>
|
||||
<h3>
|
||||
{item.leftTeamName} vs {item.rightTeamName}
|
||||
</h3>
|
||||
|
||||
<div className="history-modal-score">
|
||||
<div>
|
||||
<strong>{item.score[0]}</strong>
|
||||
<span>{item.leftTeamName}</span>
|
||||
</div>
|
||||
<div className="history-modal-score-divider">:</div>
|
||||
<div>
|
||||
<strong>{item.score[1]}</strong>
|
||||
<span>{item.rightTeamName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="history-modal-summary">
|
||||
<span>{item.playedAt}</span>
|
||||
<span>{item.typeLabel}</span>
|
||||
<span>勝方:{item.winnerTeamName}</span>
|
||||
</div>
|
||||
|
||||
<div className="history-replay-list">
|
||||
{item.scoreList.length === 0 ? (
|
||||
<p className="history-replay-empty">這筆資料沒有得分過程。</p>
|
||||
) : (
|
||||
item.scoreList.map(([round, starter, winCount, winner]) => (
|
||||
<div className="history-replay-row" key={`${item.id}-${round}`}>
|
||||
<span>第 {round + 1} 球</span>
|
||||
<span>發球 #{starter + 1}</span>
|
||||
<span>連得 {winCount + 1}</span>
|
||||
<strong>{winner === 0 ? item.leftTeamName : item.rightTeamName}</strong>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,3 +81,33 @@ export type HistoryUploadPayload = {
|
||||
export type HistoryUploadResponse = {
|
||||
id: number
|
||||
}
|
||||
|
||||
export type HistoryRecord = {
|
||||
id: number
|
||||
time: number
|
||||
dayOfWeek: number
|
||||
score: string
|
||||
winScore: number
|
||||
type: 0 | 1
|
||||
players: string
|
||||
team: string
|
||||
scoreList: string | null
|
||||
}
|
||||
|
||||
export type HistoryListItem = {
|
||||
id: number
|
||||
time: number
|
||||
playedAt: string
|
||||
dayOfWeek: number
|
||||
dayLabel: string
|
||||
score: [number, number]
|
||||
winScore: number
|
||||
type: 0 | 1
|
||||
typeLabel: string
|
||||
players: string[]
|
||||
team: [string[], string[]]
|
||||
scoreList: Array<[number, number, number, 0 | 1]>
|
||||
leftTeamName: string
|
||||
rightTeamName: string
|
||||
winnerTeamName: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user