補上歷史戰績列表與 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

View File

@@ -1,36 +1,30 @@
# badminton-scoreboard
羽毛球記分板專案,使用 `Vite + React + TypeScript` 建立前端,搭配 `Express + MySQL` 提供分組讀取與戰績寫入 API。
羽毛球記分板專案,使用 `Vite + React + TypeScript` 建立前端,搭配 `Express + MySQL` 提供分組讀取、歷史戰績列表與戰績寫入 API。
## 目前功能
## 功能
- 選擇日期後從 DB 讀取隊伍與分組資料
-指定日期沒有資料,可手動輸入名單並產生配對
- 指定日期後從 DB 讀取隊伍與分組資料
-日期沒有資料,可手動輸入名單並產生配對
- 從指定組別選 2 隊帶入記分板
- 記分板支援先攻設定、點擊分數直接加分、上一步回退
- 支援上下換隊、左右交換隊員位置
- 支援上下換隊、左右交換隊員位置
- 比賽結算後可選擇是否上傳戰績到 `history` 資料表
- 歷史戰績頁直接從 DB 顯示列表,點擊可查看得分過程
## 開發環境 Port
## 開發 Port
- Client: `3501`
- Server API: `8788`
Vite 前端會開在
本機開發模式
```text
http://localhost:3501
```
- 前端:`http://localhost:3501`
- API`http://localhost:8788`
API 會開在:
## 本機開發
```text
http://localhost:8788
```
## 啟動方式
先安裝套件:
安裝套件:
```bash
npm install
@@ -42,7 +36,7 @@ npm install
npm run dev
```
個指令會同時啟動:
這會同時啟動:
- Vite client on `3501`
- Node server on `8788`
@@ -64,13 +58,13 @@ DB_HISTORY_TABLE=history
SERVER_PORT=8788
```
## 資料表說明
## 資料表
### `badminton`
- `time`: 日期,格式 `YYYYMMDD`
- `personnel`: 人員清單,格式例如 `[[1,"A區成員"],[0,"B區成員"]]`
- `battlecombination`: 分組資料,格式例如 `{"0":[["A","B"]],"1":[...],"2":[...]}`
- `personnel`: 人員清單,例如 `[[1,"A區成員"],[0,"B區成員"]]`
- `battlecombination`: 分組資料,例如 `{"0":[["A","B"]],"1":[...],"2":[...]}`
### `history`
@@ -90,12 +84,12 @@ SERVER_PORT=8788
[round, starter, winCount, winner]
```
對應意義:
欄位意義:
- `round`: 第幾球
- `starter`: 發球者編號,依記分板 `1~4`
- `winCount`: 連續得分次數
- `winner`: 該球由哪一隊得分,`0` 代表上方隊伍,`1` 代表下方隊伍
- `winCount`: 該隊目前連續得分次數
- `winner`: 哪一隊得分,`0` 代表上方隊伍,`1` 代表下方隊伍
## 建置
@@ -103,7 +97,7 @@ SERVER_PORT=8788
npm run build
```
## Docker
## Docker 單次啟動
建置映像:
@@ -128,10 +122,39 @@ docker run -d \
badminton-scoreboard
```
容器啟動後可透過:
## NAS 部署
```text
http://localhost:8788
這個專案現在已經補上 [docker-compose.yml](./docker-compose.yml),所以在 NAS 上可以直接使用:
```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
View 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}

View File

@@ -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) {
app.use(express.static(distDir))

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;

View File

@@ -511,7 +511,7 @@ function App() {
/>
}
/>
<Route path="/history" element={<HistoryPage history={history} />} />
<Route path="/history" element={<HistoryPage />} />
</Routes>
</div>
)

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] ?? '-'
}

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>
)
}

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
}