From b0908b4d3c816904feb23984c3cc6592d64d70de Mon Sep 17 00:00:00 2001 From: JianMiau Date: Wed, 15 Apr 2026 23:04:16 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A3=9C=E4=B8=8A=E6=AD=B7=E5=8F=B2=E6=88=B0?= =?UTF-8?q?=E7=B8=BE=E5=88=97=E8=A1=A8=E8=88=87=20NAS=20=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E8=AA=AA=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 83 ++++++++++------ docker-compose.yml | 19 ++++ server/server.mjs | 32 +++++++ src/App.css | 118 +++++++++++++++++++++++ src/App.tsx | 2 +- src/lib/api.ts | 65 +++++++++++++ src/pages/HistoryPage.tsx | 195 ++++++++++++++++++++++++++++++-------- src/types.ts | 30 ++++++ 8 files changed, 472 insertions(+), 72 deletions(-) create mode 100644 docker-compose.yml diff --git a/README.md b/README.md index 38161dd..e302b36 100644 --- a/README.md +++ b/README.md @@ -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。 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..287e65e --- /dev/null +++ b/docker-compose.yml @@ -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} diff --git a/server/server.mjs b/server/server.mjs index 9f4394b..bfdd322 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -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)) diff --git a/src/App.css b/src/App.css index 5eb100f..52a4ce5 100644 --- a/src/App.css +++ b/src/App.css @@ -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; diff --git a/src/App.tsx b/src/App.tsx index 2aa76ff..f09f09a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -511,7 +511,7 @@ function App() { /> } /> - } /> + } /> ) diff --git a/src/lib/api.ts b/src/lib/api.ts index 63ccaf6..46cd5a1 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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(record.players, []) + const team = parseJson<[string[], string[]]>(record.team, [[], []]) + const scoreList = parseJson>( + 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(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] ?? '-' +} diff --git a/src/pages/HistoryPage.tsx b/src/pages/HistoryPage.tsx index 08cf8ff..f355f3d 100644 --- a/src/pages/HistoryPage.tsx +++ b/src/pages/HistoryPage.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [selectedItem, setSelectedItem] = useState(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 ( -
-
-

History

-

歷史戰績

-

這裡會顯示本機目前這次操作中,已經成功上傳到 DB 的比賽結果。

-
+ <> +
+
+

History

+

歷史戰績

+

+ 這裡直接顯示 DB 的 `history` 列表。點任一筆戰績,可快速查看該場的得分紀錄。 +

+
-
- {history.length === 0 ? ( -
-

目前還沒有戰績

-

完成比賽結算並上傳到 DB 後,這裡就會看到紀錄。

-
- ) : ( -
- {history.map((item) => ( -
-
-
-

{item.playedAt}

-

- {item.leftTeamName} vs {item.rightTeamName} -

+
+ {loading ? ( +
+

正在讀取戰績

+

請稍候一下,正在從 DB 載入列表。

+
+ ) : error ? ( +
+

讀取失敗

+

{error}

+
+ ) : history.length === 0 ? ( +
+

目前沒有戰績

+

DB 的 `history` 資料表目前沒有可顯示的紀錄。

+
+ ) : ( +
+ {history.map((item) => ( +
-
- 比賽日期:{item.matchDate || '-'} - 資料來源:{item.source === 'db' ? 'DB' : item.source === 'manual' ? '手動' : '-'} - 第 {item.groupId} 組 - 比分:{item.scoreLeft} - {item.scoreRight} -
-
- ))} -
- )} -
-
+
+ 比分:{item.score[0]} - {item.score[1]} + 類型:{item.typeLabel} + 勝利分數:{item.winScore} +
+ + ))} + + )} + +
+ + {selectedItem ? ( + setSelectedItem(null)} /> + ) : null} + + ) +} + +type HistoryReplayModalProps = { + item: HistoryListItem + onClose: () => void +} + +function HistoryReplayModal({ item, onClose }: HistoryReplayModalProps) { + return ( +
+
+

點任意位置關閉

+

+ {item.leftTeamName} vs {item.rightTeamName} +

+ +
+
+ {item.score[0]} + {item.leftTeamName} +
+
:
+
+ {item.score[1]} + {item.rightTeamName} +
+
+ +
+ {item.playedAt} + {item.typeLabel} + 勝方:{item.winnerTeamName} +
+ +
+ {item.scoreList.length === 0 ? ( +

這筆資料沒有得分過程。

+ ) : ( + item.scoreList.map(([round, starter, winCount, winner]) => ( +
+ 第 {round + 1} 球 + 發球 #{starter + 1} + 連得 {winCount + 1} + {winner === 0 ? item.leftTeamName : item.rightTeamName} +
+ )) + )} +
+
+
) } diff --git a/src/types.ts b/src/types.ts index dad9e76..63613f0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 +}