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 ? (
第 {group.id} 組 / {sourceLabel} / {targetDate || '-'}