補上歷史戰績列表與 NAS 部署說明

This commit is contained in:
2026-04-15 23:04:16 +08:00
parent 7fc8e2698b
commit b0908b4d3c
8 changed files with 472 additions and 72 deletions
+118
View File
@@ -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
View File
@@ -511,7 +511,7 @@ function App() {
/>
}
/>
<Route path="/history" element={<HistoryPage history={history} />} />
<Route path="/history" element={<HistoryPage />} />
</Routes>
</div>
)
+65
View File
@@ -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
View File
@@ -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>
)
}
+30
View File
@@ -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
}