補上歷史戰績列表與 NAS 部署說明
This commit is contained in:
83
README.md
83
README.md
@@ -1,36 +1,30 @@
|
|||||||
# badminton-scoreboard
|
# badminton-scoreboard
|
||||||
|
|
||||||
羽毛球記分板專案,使用 `Vite + React + TypeScript` 建立前端,搭配 `Express + MySQL` 提供分組讀取與戰績寫入 API。
|
羽毛球記分板專案,使用 `Vite + React + TypeScript` 建立前端,搭配 `Express + MySQL` 提供分組讀取、歷史戰績列表與戰績寫入 API。
|
||||||
|
|
||||||
## 目前功能
|
## 功能
|
||||||
|
|
||||||
- 選擇日期後從 DB 讀取隊伍與分組資料
|
- 指定日期後從 DB 讀取隊伍與分組資料
|
||||||
- 若指定日期沒有資料,可手動輸入名單並產生配對
|
- 若該日期沒有資料,可手動輸入名單並產生配對
|
||||||
- 從指定組別選 2 隊帶入記分板
|
- 從指定組別選 2 隊帶入記分板
|
||||||
- 記分板支援先攻設定、點擊分數直接加分、上一步回退
|
- 記分板支援先攻設定、點擊分數直接加分、上一步回退
|
||||||
- 支援上下換隊、左右交換隊員位置
|
- 支援上下交換隊伍、左右交換隊員位置
|
||||||
- 比賽結算後可選擇是否上傳戰績到 `history` 資料表
|
- 比賽結算後可選擇是否上傳戰績到 `history` 資料表
|
||||||
|
- 歷史戰績頁直接從 DB 顯示列表,點擊可查看得分過程
|
||||||
|
|
||||||
## 開發環境 Port
|
## 開發 Port
|
||||||
|
|
||||||
- Client: `3501`
|
- Client: `3501`
|
||||||
- Server API: `8788`
|
- Server API: `8788`
|
||||||
|
|
||||||
Vite 前端會開在:
|
本機開發模式:
|
||||||
|
|
||||||
```text
|
- 前端:`http://localhost:3501`
|
||||||
http://localhost:3501
|
- API:`http://localhost:8788`
|
||||||
```
|
|
||||||
|
|
||||||
API 會開在:
|
## 本機開發
|
||||||
|
|
||||||
```text
|
安裝套件:
|
||||||
http://localhost:8788
|
|
||||||
```
|
|
||||||
|
|
||||||
## 啟動方式
|
|
||||||
|
|
||||||
先安裝套件:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
@@ -42,7 +36,7 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
這個指令會同時啟動:
|
這會同時啟動:
|
||||||
|
|
||||||
- Vite client on `3501`
|
- Vite client on `3501`
|
||||||
- Node server on `8788`
|
- Node server on `8788`
|
||||||
@@ -64,13 +58,13 @@ DB_HISTORY_TABLE=history
|
|||||||
SERVER_PORT=8788
|
SERVER_PORT=8788
|
||||||
```
|
```
|
||||||
|
|
||||||
## 資料表說明
|
## 資料表
|
||||||
|
|
||||||
### `badminton`
|
### `badminton`
|
||||||
|
|
||||||
- `time`: 日期,格式 `YYYYMMDD`
|
- `time`: 日期,格式 `YYYYMMDD`
|
||||||
- `personnel`: 人員清單,格式例如 `[[1,"A區成員"],[0,"B區成員"]]`
|
- `personnel`: 人員清單,例如 `[[1,"A區成員"],[0,"B區成員"]]`
|
||||||
- `battlecombination`: 分組資料,格式例如 `{"0":[["A","B"]],"1":[...],"2":[...]}`
|
- `battlecombination`: 分組資料,例如 `{"0":[["A","B"]],"1":[...],"2":[...]}`
|
||||||
|
|
||||||
### `history`
|
### `history`
|
||||||
|
|
||||||
@@ -90,12 +84,12 @@ SERVER_PORT=8788
|
|||||||
[round, starter, winCount, winner]
|
[round, starter, winCount, winner]
|
||||||
```
|
```
|
||||||
|
|
||||||
對應意義:
|
欄位意義:
|
||||||
|
|
||||||
- `round`: 第幾球
|
- `round`: 第幾球
|
||||||
- `starter`: 發球者編號,依記分板 `1~4`
|
- `starter`: 發球者編號,依記分板 `1~4`
|
||||||
- `winCount`: 連續得分次數
|
- `winCount`: 該隊目前連續得分次數
|
||||||
- `winner`: 該球由哪一隊得分,`0` 代表上方隊伍,`1` 代表下方隊伍
|
- `winner`: 哪一隊得分,`0` 代表上方隊伍,`1` 代表下方隊伍
|
||||||
|
|
||||||
## 建置
|
## 建置
|
||||||
|
|
||||||
@@ -103,7 +97,7 @@ SERVER_PORT=8788
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker
|
## Docker 單次啟動
|
||||||
|
|
||||||
建置映像:
|
建置映像:
|
||||||
|
|
||||||
@@ -128,10 +122,39 @@ docker run -d \
|
|||||||
badminton-scoreboard
|
badminton-scoreboard
|
||||||
```
|
```
|
||||||
|
|
||||||
容器啟動後可透過:
|
## NAS 部署
|
||||||
|
|
||||||
```text
|
這個專案現在已經補上 [docker-compose.yml](./docker-compose.yml),所以在 NAS 上可以直接使用:
|
||||||
http://localhost:8788
|
|
||||||
|
```bash
|
||||||
|
sudo docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
提供 API 與建置後的前端頁面。
|
但前提是你要先在 NAS 的專案目錄準備好 `.env`,至少要有:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DB_HOST=192.168.0.15
|
||||||
|
DB_PORT=3307
|
||||||
|
DB_USER=jianmiau
|
||||||
|
DB_PASSWORD=你的密碼
|
||||||
|
DB_DATABASE=badminton
|
||||||
|
DB_TABLE=badminton
|
||||||
|
DB_HISTORY_TABLE=history
|
||||||
|
```
|
||||||
|
|
||||||
|
部署後會對外提供:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://NAS_IP:8788
|
||||||
|
```
|
||||||
|
|
||||||
|
## NAS 部署注意事項
|
||||||
|
|
||||||
|
- 這個專案在正式部署時沒有獨立的 `3501` 前端埠,前端建置後由 Node server 一起從 `8788` 提供。
|
||||||
|
- 如果 NAS 上已經有其他服務佔用 `8788`,要先改 `docker-compose.yml` 的左側對外埠。
|
||||||
|
- 指令要完整寫成 `sudo docker compose up -d --build`,不是 `--buil`。
|
||||||
|
- 第一次部署前,建議先確認 NAS 已安裝 Docker / Container Manager,且帳號可執行 `sudo docker compose`。
|
||||||
|
|
||||||
|
## Git 記錄
|
||||||
|
|
||||||
|
這個專案後續提交我會使用中文 commit 訊息,並已將本地 repo 的 git 中文編碼輸出設定好,方便直接看中文 log。
|
||||||
|
|||||||
19
docker-compose.yml
Normal file
19
docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
services:
|
||||||
|
badminton-scoreboard:
|
||||||
|
container_name: badminton-scoreboard
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: badminton-scoreboard:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8788:8788"
|
||||||
|
environment:
|
||||||
|
PORT: 8788
|
||||||
|
DB_HOST: ${DB_HOST:-192.168.0.15}
|
||||||
|
DB_PORT: ${DB_PORT:-3307}
|
||||||
|
DB_USER: ${DB_USER:-jianmiau}
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
|
DB_DATABASE: ${DB_DATABASE:-badminton}
|
||||||
|
DB_TABLE: ${DB_TABLE:-badminton}
|
||||||
|
DB_HISTORY_TABLE: ${DB_HISTORY_TABLE:-history}
|
||||||
@@ -169,6 +169,38 @@ app.post('/api/history', async (request, response) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get('/api/history', async (_request, response) => {
|
||||||
|
if (!pool) {
|
||||||
|
response.status(500).json({
|
||||||
|
ok: false,
|
||||||
|
message: `DB 尚未設定完成,缺少 ${missingEnv.join(', ')}`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureHistoryTable(pool, historyTableName)
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
`
|
||||||
|
SELECT id, time, dayOfWeek, score, winScore, type, players, team, scoreList
|
||||||
|
FROM \`${historyTableName}\`
|
||||||
|
ORDER BY id DESC
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
response.json({
|
||||||
|
ok: true,
|
||||||
|
data: rows,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('history load 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))
|
||||||
|
|
||||||
|
|||||||
118
src/App.css
118
src/App.css
@@ -323,6 +323,18 @@
|
|||||||
color: var(--panel-soft);
|
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 {
|
.scoreboard-screen {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 160px;
|
grid-template-columns: minmax(0, 1fr) 160px;
|
||||||
@@ -871,6 +883,94 @@
|
|||||||
gap: 12px;
|
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 {
|
.inline-link {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
@@ -1050,6 +1150,11 @@
|
|||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-modal {
|
||||||
|
padding: 18px 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.finish-dialog-close {
|
.finish-dialog-close {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@@ -1068,6 +1173,19 @@
|
|||||||
grid-template-columns: 1fr;
|
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 {
|
.team-picker-ribbon {
|
||||||
left: 18px;
|
left: 18px;
|
||||||
right: 90px;
|
right: 90px;
|
||||||
|
|||||||
@@ -511,7 +511,7 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/history" element={<HistoryPage history={history} />} />
|
<Route path="/history" element={<HistoryPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
|
HistoryListItem,
|
||||||
|
HistoryRecord,
|
||||||
HistoryUploadPayload,
|
HistoryUploadPayload,
|
||||||
HistoryUploadResponse,
|
HistoryUploadResponse,
|
||||||
MatchResultsRecord,
|
MatchResultsRecord,
|
||||||
@@ -44,3 +46,66 @@ export async function saveMatchHistory(payload: HistoryUploadPayload) {
|
|||||||
|
|
||||||
return result.data
|
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] ?? '-'
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = {
|
export function HistoryPage() {
|
||||||
history: MatchHistoryItem[]
|
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 (
|
return (
|
||||||
<section className="page-grid">
|
<>
|
||||||
<article className="panel panel-hero">
|
<section className="page-grid">
|
||||||
<p className="panel-kicker">History</p>
|
<article className="panel panel-hero">
|
||||||
<h2>歷史戰績</h2>
|
<p className="panel-kicker">History</p>
|
||||||
<p className="panel-copy">這裡會顯示本機目前這次操作中,已經成功上傳到 DB 的比賽結果。</p>
|
<h2>歷史戰績</h2>
|
||||||
</article>
|
<p className="panel-copy">
|
||||||
|
這裡直接顯示 DB 的 `history` 列表。點任一筆戰績,可快速查看該場的得分紀錄。
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
<article className="panel full-span">
|
<article className="panel full-span">
|
||||||
{history.length === 0 ? (
|
{loading ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<h3>目前還沒有戰績</h3>
|
<h3>正在讀取戰績</h3>
|
||||||
<p>完成比賽結算並上傳到 DB 後,這裡就會看到紀錄。</p>
|
<p>請稍候一下,正在從 DB 載入列表。</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : error ? (
|
||||||
<div className="history-list">
|
<div className="empty-state">
|
||||||
{history.map((item) => (
|
<h3>讀取失敗</h3>
|
||||||
<article className="history-card" key={item.id}>
|
<p>{error}</p>
|
||||||
<div className="history-head">
|
</div>
|
||||||
<div>
|
) : history.length === 0 ? (
|
||||||
<p className="panel-kicker">{item.playedAt}</p>
|
<div className="empty-state">
|
||||||
<h3>
|
<h3>目前沒有戰績</h3>
|
||||||
{item.leftTeamName} vs {item.rightTeamName}
|
<p>DB 的 `history` 資料表目前沒有可顯示的紀錄。</p>
|
||||||
</h3>
|
</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>
|
</div>
|
||||||
<span className="winner-badge">勝方:{item.winner}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="history-meta">
|
<div className="history-meta">
|
||||||
<span>比賽日期:{item.matchDate || '-'}</span>
|
<span>比分:{item.score[0]} - {item.score[1]}</span>
|
||||||
<span>資料來源:{item.source === 'db' ? 'DB' : item.source === 'manual' ? '手動' : '-'}</span>
|
<span>類型:{item.typeLabel}</span>
|
||||||
<span>第 {item.groupId} 組</span>
|
<span>勝利分數:{item.winScore}</span>
|
||||||
<span>比分:{item.scoreLeft} - {item.scoreRight}</span>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</article>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</article>
|
||||||
</article>
|
</section>
|
||||||
</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
src/types.ts
30
src/types.ts
@@ -81,3 +81,33 @@ export type HistoryUploadPayload = {
|
|||||||
export type HistoryUploadResponse = {
|
export type HistoryUploadResponse = {
|
||||||
id: number
|
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