diff --git a/README.md b/README.md index 9628b91..38fd529 100644 --- a/README.md +++ b/README.md @@ -1,116 +1,112 @@ -# badminton-scoreboard +# 羽毛球記分板 -羽毛球記分板系統,使用 `Vite + React + TypeScript` 建立前端,搭配 `Express + MySQL` 提供資料讀寫 API。 +以 `Vite + React + TypeScript + Node.js` 製作的羽毛球記分板,提供選隊伍、記分板、歷史戰績三個主要頁面,並可搭配 Docker 部署到 NAS。 -目前專案包含三個主要頁面: +## 功能 -- `選隊伍`:可依指定日期從 DB 載入隊伍資料,若沒有資料可改用手動配對 -- `記分板`:支援先攻設定、直接點分數記分、上一步回退、上下換隊、左右換位 -- `歷史戰績`:直接從 DB 的 `history` 表讀取戰績列表,點開可看得分紀錄,也能直接刪除單筆紀錄 - -## 目前記分板流程 - -- 進入記分板前,先從選隊伍頁面選定「第幾組」 -- 點 `設定隊伍` 後會打開雙欄彈窗 -- 左邊可一個一個選球員,依照順序自動成隊: - - 第 1、2 位成上方隊伍 - - 第 3、4 位成下方隊伍 -- 右邊可直接快速選預設好的隊伍 -- 可設定 `幾分獲勝`,預設是 `21` -- 確認配隊後會直接帶入記分板並重設本場狀態 -- 開始記分後,`設定隊伍` 會改成 `上一步` - -## 手機版設計方向 - -- 以手機完整顯示為優先 -- 記分板主畫面盡量避免整頁上下滑動 -- 設定隊伍彈窗會優先壓縮內容高度 -- 若名單較多,改為彈窗內局部捲動,不影響整頁閱讀 - -## 歷史戰績功能 - -- 歷史列表直接從 DB 的 `history` 表讀取 -- 點列表卡片可查看得分過程 -- 每筆列表右側都有 `刪除此筆` 按鈕 -- 刪除前會跳出確認視窗 -- 刪除成功後列表會即時更新 - -## Port 設定 - -### 本機開發 - -- Client:`3501` -- Server API:`8788` - -啟動後: - -- 前端:`http://localhost:3501` -- API:`http://localhost:8788` - -### Docker / NAS 部署 - -- 對外入口:`3501` -- Node app 內部 port:`8788` - -正式部署時,外部會走 Nginx SSL 入口,使用 `3501`;`8788` 只提供容器內部反向代理使用。 +- 選隊伍頁 + - 依指定日期從資料庫讀取分組資料 + - 若當天沒有資料,可手動輸入 A、B 區名單建立分組 + - 成功載入後會在畫面中央顯示 0.5 秒浮動提示 +- 記分板頁 + - 從所選組別進入記分板 + - 設定隊伍彈窗支援兩種方式 + - 左側逐一選人,依順序 `1、2` 為上方隊伍,`3、4` 為下方隊伍 + - 右側快速套用預設隊伍 + - 可設定幾分獲勝,預設 `21` + - 必須先設定先攻,之後點分數即可直接加分 + - 第一分後 `設定隊伍` 會改成 `上一步` + - 支援上下交換兩隊位置、左右交換隊內站位 +- 歷史戰績頁 + - 直接從資料庫 `history` 表讀取列表 + - 點擊任一筆可開啟得分紀錄彈窗 + - 每筆資料可單獨刪除 ## 本機開發 -安裝套件: +### Port + +- Client: `3501` +- Server: `8788` + +### 安裝 ```bash npm install ``` -啟動開發環境: +### 啟動開發模式 ```bash npm run dev ``` -會同時啟動: +啟動後可從以下位置開啟: -- Vite client on `3501` -- Node server on `8788` +- 前端:`http://localhost:3501` +- API:`http://localhost:8788` -## 建置與檢查 - -```bash -npm run build -``` +### 驗證 ```bash npm run lint +npm run build ``` -## `.env` 範例 +## 環境變數 -請參考 [.env.example](./.env.example): +請在專案根目錄建立 `.env`,至少包含以下欄位: ```env -DB_HOST=192.168.0.15 -DB_PORT=3307 -DB_USER=jianmiau -DB_PASSWORD=your-password +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_USER=root +DB_PASSWORD=your_password DB_DATABASE=badminton -DB_TABLE=badminton +DB_TABLE=match_results DB_HISTORY_TABLE=history -SERVER_PORT=8788 -NGINX_SERVER_NAME=_ -SSL_CERT_FILE_NAME=RSA-cert.pem -SSL_CHAIN_FILE_NAME=RSA-chain.pem -SSL_KEY_FILE_NAME=RSA-privkey.pem +PORT=8788 ``` -## 資料表格式 +## Docker / NAS 部署 -### `badminton` +目前部署設計如下: -- `time`:日期鍵值,格式 `YYYYMMDD` -- `personnel`:球員名單 JSON -- `battlecombination`:各組配對 JSON +- 對外入口:`3501` +- App 內部服務:`8788` +- 由 Nginx 負責 SSL 與反向代理 -### `history` +### 啟動 + +```bash +sudo docker compose up -d --build +``` + +正式部署後入口會是: + +```text +https://你的網域或 NAS IP:3501 +``` + +## SSL 憑證目錄 + +Docker Compose 會掛載 NAS 的憑證目錄: + +```text +/volume1/homes/JianMiau/www/certificate/ +``` + +目前 Nginx 會使用這個目錄下的檔案產生 `fullchain.pem`,因此之後若你更新 SSL,只要更新這個資料夾內的憑證即可,再重新啟動容器讓設定重載。 + +預設使用的檔名為: + +- `RSA-cert.pem` +- `RSA-chain.pem` +- `RSA-privkey.pem` + +## 歷史戰績寫入格式 + +`history` 表使用的資料欄位如下: - `id` - `time` @@ -118,92 +114,24 @@ SSL_KEY_FILE_NAME=RSA-privkey.pem - `score` - `winScore` - `type` + - `0`:雙打 + - `1`:單打 - `players` + - 依照 `1 ~ 4` 編號順序儲存 - `team` + - `1、2` 為一隊 + - `3、4` 為一隊 - `scoreList` + - 格式:`[round, 發球者編號, 連勝次數, 得分隊伍(0 或 1)]` -`scoreList` 格式: +## Git 中文紀錄 -```text -[round, starter, winCount, winner] -``` - -說明: - -- `round`:第幾球 -- `starter`:發球者編號 -- `winCount`:連勝次數 -- `winner`:`0` 代表上方隊伍得分,`1` 代表下方隊伍得分 - -## Docker 建置 - -單獨建置 Node app: +專案已設定 git 使用 UTF-8: ```bash -docker build -t badminton-scoreboard . +git config i18n.commitEncoding utf-8 +git config i18n.logOutputEncoding utf-8 +git config core.quotepath false ``` -## NAS 部署 - -專案已提供 [docker-compose.yml](./docker-compose.yml)。 - -服務包含: - -1. `badminton-scoreboard` -Node app,內部使用 `8788` - -2. `badminton-scoreboard-web` -Nginx SSL 入口,對外使用 `3501` - -在 NAS 專案目錄執行: - -```bash -sudo docker compose up -d --build -``` - -## SSL 憑證掛載 - -SSL 憑證目錄固定使用: - -```text -/volume1/homes/JianMiau/www/certificate/ -``` - -預設檔名: - -- `RSA-cert.pem` -- `RSA-chain.pem` -- `RSA-privkey.pem` - -Nginx 會自動組合: - -- `RSA-cert.pem` + `RSA-chain.pem` => fullchain -- `RSA-privkey.pem` => private key - -之後只要更新: - -```text -/volume1/homes/JianMiau/www/certificate/ -``` - -容器內的 Nginx 就會重新載入,不需要重建 image。 - -## NAS 對外入口 - -部署完成後可由: - -```text -https://你的網域或IP:3501 -``` - -進入系統。 - -## Git 中文設定 - -目前 repo 已設定: - -- `i18n.commitEncoding=utf-8` -- `i18n.logOutputEncoding=utf-8` -- `core.quotepath=false` - -之後使用中文 commit 訊息與 `git log` 會比較正常。 +之後 commit 訊息與 log 可直接使用中文。 diff --git a/src/App.css b/src/App.css index e40eb7d..bb61cbb 100644 --- a/src/App.css +++ b/src/App.css @@ -178,6 +178,37 @@ background: rgba(255, 214, 224, 0.94); } +.floating-status-bubble { + position: fixed; + left: 50%; + top: 50%; + z-index: 60; + transform: translate(-50%, -50%); + min-width: min(78vw, 320px); + max-width: min(84vw, 420px); + padding: 14px 20px; + border-radius: 999px; + text-align: center; + font-weight: 700; + color: #f8fff9; + background: linear-gradient(135deg, rgba(8, 47, 73, 0.96), rgba(10, 96, 84, 0.92)); + box-shadow: 0 18px 40px rgba(8, 47, 73, 0.28); + pointer-events: none; + animation: status-bubble-in 0.18s ease-out; +} + +@keyframes status-bubble-in { + from { + opacity: 0; + transform: translate(-50%, calc(-50% + 10px)) scale(0.96); + } + + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} + .selection-shell, .selection-form, .group-board { diff --git a/src/App.tsx b/src/App.tsx index f95fdff..9119818 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -113,6 +113,18 @@ function App() { window.localStorage.setItem(STORAGE_KEYS.history, JSON.stringify(history)) }, [history]) + useEffect(() => { + if (loadStatus !== 'loaded' || !loadMessage) { + return + } + + const timer = window.setTimeout(() => { + setLoadMessage('') + }, 500) + + return () => window.clearTimeout(timer) + }, [loadMessage, loadStatus]) + const resetScoring = (nextState: ScoreState = initialScoreState) => { setScoreState(nextState) setScoreHistory([]) @@ -450,8 +462,6 @@ function App() { groupSource={groupSource} loadMessage={loadMessage} loadStatus={loadStatus} - parsedAreaA={parsedAreaA} - parsedAreaB={parsedAreaB} selectedGroupId={selectedGroupId} targetDate={targetDate} onAreaAInputChange={setAreaAInput} @@ -474,8 +484,6 @@ function App() { groupSource={groupSource} loadMessage={loadMessage} loadStatus={loadStatus} - parsedAreaA={parsedAreaA} - parsedAreaB={parsedAreaB} selectedGroupId={selectedGroupId} targetDate={targetDate} onAreaAInputChange={setAreaAInput} diff --git a/src/pages/TeamSelectionPage.tsx b/src/pages/TeamSelectionPage.tsx index 66ac02f..21dfb31 100644 --- a/src/pages/TeamSelectionPage.tsx +++ b/src/pages/TeamSelectionPage.tsx @@ -1,4 +1,4 @@ -import { useNavigate } from 'react-router-dom' +import { Link } from 'react-router-dom' import { getTeamDisplayName } from '../lib/match' import type { LoadStatus, RoundGroup } from '../types' @@ -9,8 +9,6 @@ type TeamSelectionPageProps = { groupSource: 'idle' | 'db' | 'manual' loadMessage: string loadStatus: LoadStatus - parsedAreaA: string[] - parsedAreaB: string[] selectedGroupId: number | null targetDate: string onAreaAInputChange: (value: string) => void @@ -29,8 +27,6 @@ export function TeamSelectionPage({ groupSource, loadMessage, loadStatus, - parsedAreaA, - parsedAreaB, selectedGroupId, targetDate, onAreaAInputChange, @@ -41,41 +37,21 @@ export function TeamSelectionPage({ onTargetDateChange, onUseGroup, }: TeamSelectionPageProps) { - const navigate = useNavigate() + const hasGroups = groups.length > 0 + const showInlineStatus = loadStatus !== 'idle' && loadStatus !== 'loaded' && Boolean(loadMessage) + const sourceLabel = + groupSource === 'db' ? '資料庫載入' : groupSource === 'manual' ? '手動產生' : '尚未建立' return ( -
-
-
-

步驟 1

-

載入分組與選擇組別

-

- 先用日期從 DB 讀取分組;如果指定日期沒有資料,就改用 A 區與 B 區名單手動產生配對。 -

+
+ {loadStatus === 'loaded' && loadMessage ? ( +
+ {loadMessage}
- -
-
- A 區隊數 - {parsedAreaA.length} -
-
- B 區隊數 - {parsedAreaB.length} -
-
- 目前組數 - {groups.length} -
-
- - {loadMessage ? ( -

{loadMessage}

- ) : null} -
+ ) : null}
-
+
+ {showInlineStatus ? ( +
{loadMessage}
+ ) : null} +