diff --git a/README.md b/README.md index d4a1e0f..3237547 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,40 @@ # badminton-scoreboard -羽毛球記分板專案,使用 `Vite + React + TypeScript` 建立前端,搭配 `Express + MySQL` 提供分組讀取、歷史戰績列表與戰績寫入 API。 +羽毛球記分板系統,使用 `Vite + React + TypeScript` 建立前端,搭配 `Express + MySQL` 提供資料讀寫 API。 -## 功能 +目前專案包含三個主要頁面: -- 指定日期後從 DB 讀取隊伍與分組資料 -- 若該日期沒有資料,可手動輸入名單並產生配對 -- 從指定組別選 2 隊帶入記分板 -- 記分板支援先攻設定、點擊分數直接加分、上一步回退 -- 支援上下交換隊伍、左右交換隊員位置 -- 比賽結算後可選擇是否上傳戰績到 `history` 資料表 -- 歷史戰績頁直接從 DB 顯示列表 -- 點擊歷史戰績可開啟彈窗,查看比分、發球人姓名、連勝次數與每球結果 +- `選隊伍`:可依指定日期從 DB 載入隊伍資料,若沒有資料可改用手動配對 +- `記分板`:支援先攻設定、直接點分數記分、上一步回退、上下換隊、左右換位 +- `歷史戰績`:直接從 DB 的 `history` 表讀取戰績列表,點開可看得分紀錄 -## Port 說明 +## 目前記分板流程 + +- 進入記分板前,先從選隊伍頁面選定「第幾組」 +- 點 `設定隊伍` 後會打開雙欄彈窗 +- 左邊可一個一個選球員,依照順序自動成隊: + - 第 1、2 位成上方隊伍 + - 第 3、4 位成下方隊伍 +- 右邊可直接快速選預設好的隊伍 +- 可設定 `幾分獲勝`,預設是 `21` +- 確認配隊後會直接帶入記分板並重設本場狀態 +- 開始記分後,`設定隊伍` 會改成 `上一步` + +## 手機版設計方向 + +- 以手機完整顯示為優先 +- 記分板主畫面盡量避免整頁上下滑動 +- 設定隊伍彈窗會優先壓縮內容高度 +- 若名單較多,改為彈窗內局部捲動,不影響整頁閱讀 + +## Port 設定 ### 本機開發 -- Client: `3501` -- Server API: `8788` +- Client:`3501` +- Server API:`8788` -開發模式入口: +啟動後: - 前端:`http://localhost:3501` - API:`http://localhost:8788` @@ -28,12 +42,9 @@ ### Docker / NAS 部署 - 對外入口:`3501` -- 內部 Node app:`8788` +- Node app 內部 port:`8788` -也就是說: - -- Docker 與 NAS 部署後,使用者實際連的是 `3501` -- `8788` 是容器內部 app port,給 Nginx 反向代理使用 +正式部署時,外部會走 Nginx SSL 入口,使用 `3501`;`8788` 只提供容器內部反向代理使用。 ## 本機開發 @@ -43,22 +54,30 @@ npm install ``` -啟動開發模式: +啟動開發環境: ```bash npm run dev ``` -這會同時啟動: +會同時啟動: - Vite client on `3501` - Node server on `8788` -其中後端已使用 `node --watch`,修改 `server/server.mjs` 會自動重啟。 +## 建置與檢查 -## 環境變數 +```bash +npm run build +``` -可參考 [.env.example](./.env.example): +```bash +npm run lint +``` + +## `.env` 範例 + +請參考 [.env.example](./.env.example): ```env DB_HOST=192.168.0.15 @@ -75,13 +94,13 @@ SSL_CHAIN_FILE_NAME=RSA-chain.pem SSL_KEY_FILE_NAME=RSA-privkey.pem ``` -## 資料表 +## 資料表格式 ### `badminton` -- `time`:日期,格式 `YYYYMMDD` -- `personnel`:人員清單,例如 `[[1,"A區成員"],[0,"B區成員"]]` -- `battlecombination`:分組資料,例如 `{"0":[["A","B"]],"1":[...],"2":[...]}` +- `time`:日期鍵值,格式 `YYYYMMDD` +- `personnel`:球員名單 JSON +- `battlecombination`:各組配對 JSON ### `history` @@ -95,61 +114,40 @@ SSL_KEY_FILE_NAME=RSA-privkey.pem - `team` - `scoreList` -其中 `scoreList` 格式為: +`scoreList` 格式: ```text [round, starter, winCount, winner] ``` -欄位意義: +說明: - `round`:第幾球 -- `starter`:發球者編號,依記分板 `1~4` -- `winCount`:該隊目前連續得分次數 -- `winner`:哪一隊得分,`0` 代表上方隊伍,`1` 代表下方隊伍 +- `starter`:發球者編號 +- `winCount`:連勝次數 +- `winner`:`0` 代表上方隊伍得分,`1` 代表下方隊伍得分 -## 建置 +## Docker 建置 -```bash -npm run build -``` - -## Docker 單次啟動 - -如果你只是要單獨跑 Node app,可用: +單獨建置 Node app: ```bash docker build -t badminton-scoreboard . ``` -```bash -docker run -d \ - --name badminton-scoreboard \ - -p 8788:8788 \ - -e PORT=8788 \ - -e DB_HOST=192.168.0.15 \ - -e DB_PORT=3307 \ - -e DB_USER=jianmiau \ - -e DB_PASSWORD=your-password \ - -e DB_DATABASE=badminton \ - -e DB_TABLE=badminton \ - -e DB_HISTORY_TABLE=history \ - badminton-scoreboard -``` - -這種方式只會直接提供 Node app 本身,不含 Nginx SSL 入口。 - ## NAS 部署 -專案提供 [docker-compose.yml](./docker-compose.yml),會啟動兩個服務: +專案已提供 [docker-compose.yml](./docker-compose.yml)。 + +服務包含: 1. `badminton-scoreboard` -說明:Node app,內部使用 `8788` +Node app,內部使用 `8788` 2. `badminton-scoreboard-web` -說明:Nginx SSL 入口,對外使用 `3501` +Nginx SSL 入口,對外使用 `3501` -在 NAS 專案目錄中可直接執行: +在 NAS 專案目錄執行: ```bash sudo docker compose up -d --build @@ -157,73 +155,47 @@ sudo docker compose up -d --build ## SSL 憑證掛載 -Nginx 直接掛載這個 NAS 目錄: +SSL 憑證目錄固定使用: ```text /volume1/homes/JianMiau/www/certificate/ ``` -目前預設使用你現有的檔名: +預設檔名: - `RSA-cert.pem` - `RSA-chain.pem` - `RSA-privkey.pem` -Nginx 會在容器內自動組合: +Nginx 會自動組合: - `RSA-cert.pem` + `RSA-chain.pem` => fullchain - `RSA-privkey.pem` => private key -另外已加入憑證檔變更監看,所以你之後只要更新: +之後只要更新: ```text /volume1/homes/JianMiau/www/certificate/ ``` -容器內的 Nginx 就會自動 reload,不需要重建 image。 - -## NAS `.env` 範例 - -部署前請先在 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 -NGINX_SERVER_NAME=你的網域 -SSL_CERT_FILE_NAME=RSA-cert.pem -SSL_CHAIN_FILE_NAME=RSA-chain.pem -SSL_KEY_FILE_NAME=RSA-privkey.pem -``` +容器內的 Nginx 就會重新載入,不需要重建 image。 ## NAS 對外入口 -部署後請從這個入口使用: +部署完成後可由: ```text -https://你的網域:3501 +https://你的網域或IP:3501 ``` -如果你的憑證是簽給特定網域,請不要用 IP 直接開,否則瀏覽器會跳憑證警告。 +進入系統。 -## 注意事項 +## Git 中文設定 -- `sudo docker compose up -d --build` 現在可以直接使用 -- Docker / NAS 對外入口是 `3501`,不是 `8788` -- `8788` 是 Node app 內部服務埠,不是給使用者直接連的 -- 若 NAS 上 `3501` 已被其他服務使用,請改 `docker-compose.yml` 左側對外埠 -- 若憑證檔名之後改了,只要更新 `.env` 中對應的 `SSL_*_FILE_NAME` 即可 - -## Git 設定 - -這個 repo 已設定: +目前 repo 已設定: - `i18n.commitEncoding=utf-8` - `i18n.logOutputEncoding=utf-8` - `core.quotepath=false` -之後可直接使用中文 commit 訊息與中文 git log。 +之後使用中文 commit 訊息與 `git log` 會比較正常。 diff --git a/src/App.css b/src/App.css index 6450b71..47ce24c 100644 --- a/src/App.css +++ b/src/App.css @@ -657,14 +657,15 @@ z-index: 60; display: grid; place-items: center; - padding: 20px; + padding: 12px; background: rgba(0, 0, 0, 0.52); backdrop-filter: blur(8px); } .team-picker-shell { position: relative; - width: min(1080px, 100%); + width: min(980px, 100%); + max-height: calc(100dvh - 24px); } .team-picker-close { @@ -715,50 +716,58 @@ .team-picker-layout { display: grid; - grid-template-columns: minmax(0, 1fr) 300px; - gap: 22px; - padding: 34px 22px 22px; - border-radius: 34px; + grid-template-columns: minmax(0, 1fr) 260px; + gap: 16px; + padding: 22px 16px 16px; + border-radius: 28px; background: rgba(20, 10, 6, 0.18); + max-height: calc(100dvh - 52px); } .team-picker-panel { display: grid; - gap: 18px; - padding: 18px; - border-radius: 26px; + gap: 12px; + padding: 14px; + border-radius: 22px; 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); + min-height: 0; } .team-picker-title { display: flex; align-items: center; - gap: 14px; + gap: 10px; } .team-picker-count { display: inline-flex; align-items: center; justify-content: center; - min-width: 86px; - min-height: 54px; - padding: 0 18px; - border-radius: 18px; - font-size: 1.5rem; + min-width: 72px; + min-height: 46px; + padding: 0 14px; + border-radius: 16px; + font-size: 1.25rem; color: #4a2e1d; background: linear-gradient(180deg, #f5d89f, #ecc170); } .team-picker-title p { - margin-top: 4px; + margin-top: 2px; + font-size: 0.84rem; +} + +.team-picker-config-row { + display: flex; + justify-content: flex-start; } .team-picker-config { display: grid; - gap: 8px; + gap: 6px; color: #4a2e1d; } @@ -766,6 +775,16 @@ font-weight: 700; } +.team-picker-config-compact { + grid-template-columns: auto auto; + align-items: center; + gap: 8px; +} + +.team-picker-config-compact span { + font-size: 0.92rem; +} + .team-picker-score-input { width: 100%; max-width: 140px; @@ -777,22 +796,29 @@ font: inherit; } +.team-picker-score-input-compact { + width: 72px; + max-width: 72px; + padding: 8px 10px; + text-align: center; +} + .team-picker-list { display: grid; - gap: 14px; - max-height: min(62vh, 760px); + gap: 10px; + max-height: min(48dvh, 430px); overflow: auto; - padding-right: 8px; + padding-right: 4px; } .team-picker-option { display: grid; grid-template-columns: 34px minmax(0, 1fr); - gap: 14px; + gap: 10px; align-items: center; - padding: 18px 16px; + padding: 12px 12px; border: 1px solid rgba(124, 98, 61, 0.18); - border-radius: 18px; + border-radius: 14px; cursor: pointer; text-align: left; color: #2e231b; @@ -832,24 +858,25 @@ } .team-picker-option strong, -.picked-team-card strong { +.preset-team-card strong { display: block; - font-size: 1.35rem; + font-size: 1.05rem; line-height: 1.2; } .team-picker-option small, -.picked-team-card small, +.preset-team-card small, .picker-side-hint { display: block; - margin-top: 6px; + margin-top: 4px; color: #7b6148; + font-size: 0.82rem; } .team-picker-actions { display: grid; grid-template-columns: 1fr 1fr; - gap: 14px; + gap: 10px; } .team-picker-ghost, @@ -857,7 +884,7 @@ .team-picker-clear { border: 0; border-radius: 999px; - padding: 16px 18px; + padding: 12px 14px; cursor: pointer; font: inherit; box-shadow: @@ -900,44 +927,68 @@ opacity: 0.55; } -.picked-team-list { +.preset-team-block { display: grid; - gap: 16px; + gap: 10px; + min-height: 0; } -.picked-team-card { +.preset-team-head { display: grid; - grid-template-columns: 72px minmax(0, 1fr); - gap: 14px; - align-items: center; - padding: 18px 16px; - border-radius: 22px; - background: rgba(255, 249, 238, 0.92); - border: 1px solid rgba(124, 98, 61, 0.16); -} - -.picked-team-index { - display: inline-flex; - align-items: center; - justify-content: center; - width: 72px; - height: 72px; - border-radius: 999px; - font-size: 2rem; - color: #5b2f13; - background: linear-gradient(180deg, #ffc84d, #f2a316); -} - -.picker-mode-toggle { - display: flex; - align-items: center; - gap: 12px; + gap: 2px; color: #4a2e1d; } -.picker-mode-toggle input { - width: 24px; - height: 24px; +.preset-team-head small { + color: #7b6148; + font-size: 0.8rem; +} + +.preset-team-list { + display: grid; + gap: 8px; + max-height: min(44dvh, 360px); + overflow: auto; +} + +.preset-team-card { + display: grid; + grid-template-columns: 44px minmax(0, 1fr); + gap: 10px; + align-items: center; + padding: 10px; + border: 1px solid rgba(124, 98, 61, 0.16); + border-radius: 14px; + cursor: pointer; + text-align: left; + background: rgba(255, 249, 238, 0.92); + transition: + transform 0.16s ease, + box-shadow 0.16s ease, + border-color 0.16s ease; +} + +.preset-team-card:hover { + transform: translateY(-1px); + border-color: rgba(199, 155, 83, 0.34); + box-shadow: 0 12px 22px rgba(8, 47, 73, 0.08); +} + +.preset-team-card-active { + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 10px 20px rgba(147, 104, 35, 0.12); +} + +.preset-team-index { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 999px; + font-size: 1.1rem; + color: #5b2f13; + background: linear-gradient(180deg, #ffc84d, #f2a316); } .team-picker-clear { @@ -1155,7 +1206,6 @@ .summary-grid, .double-grid, .history-meta, - .team-picker-layout, .selection-toolbar { grid-template-columns: 1fr; } @@ -1187,6 +1237,11 @@ .team-head-main { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .team-picker-layout { + grid-template-columns: minmax(0, 1fr) 220px; + gap: 12px; + } } @media (max-width: 720px) { @@ -1361,5 +1416,116 @@ .team-picker-ribbon { left: 18px; right: 90px; + top: -18px; + padding: 10px 16px; + border-radius: 18px; + font-size: 0.88rem; + } + + .team-picker-shell { + max-height: calc(100dvh - 12px); + } + + .team-picker-layout { + grid-template-columns: minmax(0, 1fr) 156px; + gap: 8px; + padding: 18px 10px 10px; + border-radius: 20px; + max-height: calc(100dvh - 24px); + } + + .team-picker-panel { + gap: 8px; + padding: 10px; + border-radius: 16px; + } + + .team-picker-title { + gap: 8px; + } + + .team-picker-count { + min-width: 56px; + min-height: 38px; + padding: 0 10px; + font-size: 1rem; + } + + .team-picker-title strong { + font-size: 0.96rem; + } + + .team-picker-title p { + font-size: 0.72rem; + } + + .team-picker-config-compact span { + font-size: 0.8rem; + } + + .team-picker-score-input-compact { + width: 58px; + max-width: 58px; + padding: 6px 8px; + font-size: 0.88rem; + } + + .team-picker-list { + gap: 6px; + max-height: min(46dvh, 330px); + } + + .team-picker-option { + grid-template-columns: 24px minmax(0, 1fr); + gap: 8px; + padding: 9px 8px; + border-radius: 12px; + } + + .team-picker-checkbox { + width: 24px; + height: 24px; + font-size: 0.8rem; + } + + .team-picker-option strong, + .preset-team-card strong { + font-size: 0.9rem; + } + + .team-picker-option small, + .preset-team-card small, + .picker-side-hint, + .preset-team-head small { + font-size: 0.72rem; + } + + .team-picker-actions { + gap: 8px; + } + + .team-picker-ghost, + .team-picker-confirm, + .team-picker-clear { + padding: 10px 10px; + font-size: 0.86rem; + } + + .preset-team-list { + gap: 6px; + max-height: min(42dvh, 300px); + } + + .preset-team-card { + grid-template-columns: 34px minmax(0, 1fr); + gap: 8px; + padding: 8px; + border-radius: 12px; + } + + .preset-team-index { + width: 34px; + height: 34px; + font-size: 0.92rem; } } diff --git a/src/pages/ScoreboardPage.tsx b/src/pages/ScoreboardPage.tsx index f23d67a..4de119c 100644 --- a/src/pages/ScoreboardPage.tsx +++ b/src/pages/ScoreboardPage.tsx @@ -103,6 +103,12 @@ export function ScoreboardPage({ return players }, [selectedGroup]) + const presetTeams = useMemo( + () => + selectedGroup?.teams.filter((team) => !team.isPlaceholderA && !team.isPlaceholderB) ?? [], + [selectedGroup], + ) + const canArrangeMatch = !hasRecordedPoint const canScore = scoreState.serving !== null @@ -198,6 +204,26 @@ export function ScoreboardPage({ }) } + const togglePresetTeam = (team: GroupTeam) => { + setDraftPlayers((current) => { + const removed = removePresetTeamFromDraft(current, team) + + if (removed.length !== current.length) { + return removed + } + + if (current.length >= 4 || current.length % 2 !== 0) { + return current + } + + if (current.includes(team.playerA) || current.includes(team.playerB)) { + return current + } + + return [...current, team.playerA, team.playerB] + }) + } + const confirmDraftTeams = () => { if (draftPlayers.length !== 4) { return @@ -305,10 +331,10 @@ export function ScoreboardPage({ {pickerOpen ? ( ) : null} @@ -473,10 +500,10 @@ function ScoreboardTeamPanel({ } type TeamPickerModalProps = { - currentSelectionOrder: string[] draftPlayers: string[] draftTargetScore: string group: RoundGroup + presetTeams: GroupTeam[] selectablePlayers: string[] selectionCount: number sourceLabel: string @@ -487,13 +514,14 @@ type TeamPickerModalProps = { onConfirm: () => void onDraftTargetScoreChange: (value: string) => void onTogglePlayer: (playerName: string) => void + onTogglePresetTeam: (team: GroupTeam) => void } function TeamPickerModal({ - currentSelectionOrder, draftPlayers, draftTargetScore, group, + presetTeams, selectablePlayers, selectionCount, sourceLabel, @@ -504,20 +532,8 @@ function TeamPickerModal({ onConfirm, onDraftTargetScoreChange, onTogglePlayer, + onTogglePresetTeam, }: TeamPickerModalProps) { - const draftTeams = [ - draftPlayers.length >= 2 ? `${draftPlayers[0]} / ${draftPlayers[1]}` : '尚未選滿 2 位', - draftPlayers.length >= 4 ? `${draftPlayers[2]} / ${draftPlayers[3]}` : '尚未選滿 2 位', - ] - const currentTeams = [ - currentSelectionOrder.length >= 2 - ? `${currentSelectionOrder[0]} / ${currentSelectionOrder[1]}` - : null, - currentSelectionOrder.length >= 4 - ? `${currentSelectionOrder[2]} / ${currentSelectionOrder[3]}` - : null, - ] - return (
{selectionCount}/4
- 依序選人後自動配隊 + 左邊逐一選人

第 {group.id} 組 / {sourceLabel} / {targetDate || '-'}

- +
+ +
{selectablePlayers.map((playerName) => { @@ -605,24 +623,40 @@ function TeamPickerModal({