整理設定隊伍介面並更新 README

This commit is contained in:
2026-04-16 10:19:23 +08:00
parent a1e0e0f16e
commit 31168e830b
3 changed files with 404 additions and 205 deletions

170
README.md
View File

@@ -1,26 +1,40 @@
# badminton-scoreboard # badminton-scoreboard
羽毛球記分板專案,使用 `Vite + React + TypeScript` 建立前端,搭配 `Express + MySQL` 提供分組讀取、歷史戰績列表與戰績寫入 API。 羽毛球記分板系統,使用 `Vite + React + TypeScript` 建立前端,搭配 `Express + MySQL` 提供資料讀寫 API。
## 功能 目前專案包含三個主要頁面:
- 指定日期從 DB 讀取隊伍與分組資料 - `選隊伍`:可依指定日期從 DB 載入隊伍資料,若沒有資料可改用手動配對
- 若該日期沒有資料,可手動輸入名單並產生配對 - `記分板`:支援先攻設定、直接點分數記分、上一步回退、上下換隊、左右換位
- 從指定組別選 2 隊帶入記分板 - `歷史戰績`:直接從 DB 的 `history` 表讀取戰績列表,點開可看得分紀錄
- 記分板支援先攻設定、點擊分數直接加分、上一步回退
- 支援上下交換隊伍、左右交換隊員位置
- 比賽結算後可選擇是否上傳戰績到 `history` 資料表
- 歷史戰績頁直接從 DB 顯示列表
- 點擊歷史戰績可開啟彈窗,查看比分、發球人姓名、連勝次數與每球結果
## Port 說明 ## 目前記分板流程
- 進入記分板前,先從選隊伍頁面選定「第幾組」
-`設定隊伍` 後會打開雙欄彈窗
- 左邊可一個一個選球員,依照順序自動成隊:
- 第 1、2 位成上方隊伍
- 第 3、4 位成下方隊伍
- 右邊可直接快速選預設好的隊伍
- 可設定 `幾分獲勝`,預設是 `21`
- 確認配隊後會直接帶入記分板並重設本場狀態
- 開始記分後,`設定隊伍` 會改成 `上一步`
## 手機版設計方向
- 以手機完整顯示為優先
- 記分板主畫面盡量避免整頁上下滑動
- 設定隊伍彈窗會優先壓縮內容高度
- 若名單較多,改為彈窗內局部捲動,不影響整頁閱讀
## Port 設定
### 本機開發 ### 本機開發
- Client: `3501` - Client`3501`
- Server API: `8788` - Server API`8788`
開發模式入口 啟動後
- 前端:`http://localhost:3501` - 前端:`http://localhost:3501`
- API`http://localhost:8788` - API`http://localhost:8788`
@@ -28,12 +42,9 @@
### Docker / NAS 部署 ### Docker / NAS 部署
- 對外入口:`3501` - 對外入口:`3501`
- 內部 Node app`8788` - Node app 內部 port`8788`
也就是說: 正式部署時,外部會走 Nginx SSL 入口,使用 `3501``8788` 只提供容器內部反向代理使用。
- Docker 與 NAS 部署後,使用者實際連的是 `3501`
- `8788` 是容器內部 app port給 Nginx 反向代理使用
## 本機開發 ## 本機開發
@@ -43,22 +54,30 @@
npm install npm install
``` ```
啟動開發模式 啟動開發環境
```bash ```bash
npm run dev npm run dev
``` ```
會同時啟動: 會同時啟動:
- Vite client on `3501` - Vite client on `3501`
- Node server on `8788` - 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 ```env
DB_HOST=192.168.0.15 DB_HOST=192.168.0.15
@@ -75,13 +94,13 @@ SSL_CHAIN_FILE_NAME=RSA-chain.pem
SSL_KEY_FILE_NAME=RSA-privkey.pem SSL_KEY_FILE_NAME=RSA-privkey.pem
``` ```
## 資料表 ## 資料表格式
### `badminton` ### `badminton`
- `time`:日期,格式 `YYYYMMDD` - `time`:日期鍵值,格式 `YYYYMMDD`
- `personnel`人員清單,例如 `[[1,"A區成員"],[0,"B區成員"]]` - `personnel`球員名單 JSON
- `battlecombination`分組資料,例如 `{"0":[["A","B"]],"1":[...],"2":[...]}` - `battlecombination`各組配對 JSON
### `history` ### `history`
@@ -95,61 +114,40 @@ SSL_KEY_FILE_NAME=RSA-privkey.pem
- `team` - `team`
- `scoreList` - `scoreList`
其中 `scoreList` 格式 `scoreList` 格式:
```text ```text
[round, starter, winCount, winner] [round, starter, winCount, winner]
``` ```
欄位意義 說明
- `round`:第幾球 - `round`:第幾球
- `starter`:發球者編號,依記分板 `1~4` - `starter`:發球者編號
- `winCount`該隊目前連續得分次數 - `winCount`連勝次數
- `winner`哪一隊得分,`0` 代表上方隊伍,`1` 代表下方隊伍 - `winner``0` 代表上方隊伍得分`1` 代表下方隊伍得分
## 建置 ## Docker 建置
```bash 單獨建置 Node app
npm run build
```
## Docker 單次啟動
如果你只是要單獨跑 Node app可用
```bash ```bash
docker build -t badminton-scoreboard . 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 部署 ## NAS 部署
專案提供 [docker-compose.yml](./docker-compose.yml),會啟動兩個服務: 專案提供 [docker-compose.yml](./docker-compose.yml)
服務包含:
1. `badminton-scoreboard` 1. `badminton-scoreboard`
說明:Node app內部使用 `8788` Node app內部使用 `8788`
2. `badminton-scoreboard-web` 2. `badminton-scoreboard-web`
說明:Nginx SSL 入口,對外使用 `3501` Nginx SSL 入口,對外使用 `3501`
在 NAS 專案目錄中可直接執行: 在 NAS 專案目錄執行:
```bash ```bash
sudo docker compose up -d --build sudo docker compose up -d --build
@@ -157,73 +155,47 @@ sudo docker compose up -d --build
## SSL 憑證掛載 ## SSL 憑證掛載
Nginx 直接掛載這個 NAS 目錄 SSL 憑證目錄固定使用
```text ```text
/volume1/homes/JianMiau/www/certificate/ /volume1/homes/JianMiau/www/certificate/
``` ```
目前預設使用你現有的檔名: 預設檔名:
- `RSA-cert.pem` - `RSA-cert.pem`
- `RSA-chain.pem` - `RSA-chain.pem`
- `RSA-privkey.pem` - `RSA-privkey.pem`
Nginx 會在容器內自動組合: Nginx 會自動組合:
- `RSA-cert.pem` + `RSA-chain.pem` => fullchain - `RSA-cert.pem` + `RSA-chain.pem` => fullchain
- `RSA-privkey.pem` => private key - `RSA-privkey.pem` => private key
另外已加入憑證檔變更監看,所以你之後只要更新: 之後只要更新:
```text ```text
/volume1/homes/JianMiau/www/certificate/ /volume1/homes/JianMiau/www/certificate/
``` ```
容器內的 Nginx 就會自動 reload,不需要重建 image。 容器內的 Nginx 就會重新載入,不需要重建 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
```
## NAS 對外入口 ## NAS 對外入口
部署後請從這個入口使用 部署完成後可由
```text ```text
https://你的網域:3501 https://你的網域或IP:3501
``` ```
如果你的憑證是簽給特定網域,請不要用 IP 直接開,否則瀏覽器會跳憑證警告 進入系統
## 注意事項 ## Git 中文設定
- `sudo docker compose up -d --build` 現在可以直接使用 目前 repo 已設定:
- Docker / NAS 對外入口是 `3501`,不是 `8788`
- `8788` 是 Node app 內部服務埠,不是給使用者直接連的
- 若 NAS 上 `3501` 已被其他服務使用,請改 `docker-compose.yml` 左側對外埠
- 若憑證檔名之後改了,只要更新 `.env` 中對應的 `SSL_*_FILE_NAME` 即可
## Git 設定
這個 repo 已設定:
- `i18n.commitEncoding=utf-8` - `i18n.commitEncoding=utf-8`
- `i18n.logOutputEncoding=utf-8` - `i18n.logOutputEncoding=utf-8`
- `core.quotepath=false` - `core.quotepath=false`
之後可直接使用中文 commit 訊息與中文 git log。 之後使用中文 commit 訊息與 `git log` 會比較正常

View File

@@ -657,14 +657,15 @@
z-index: 60; z-index: 60;
display: grid; display: grid;
place-items: center; place-items: center;
padding: 20px; padding: 12px;
background: rgba(0, 0, 0, 0.52); background: rgba(0, 0, 0, 0.52);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
} }
.team-picker-shell { .team-picker-shell {
position: relative; position: relative;
width: min(1080px, 100%); width: min(980px, 100%);
max-height: calc(100dvh - 24px);
} }
.team-picker-close { .team-picker-close {
@@ -715,50 +716,58 @@
.team-picker-layout { .team-picker-layout {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 300px; grid-template-columns: minmax(0, 1fr) 260px;
gap: 22px; gap: 16px;
padding: 34px 22px 22px; padding: 22px 16px 16px;
border-radius: 34px; border-radius: 28px;
background: rgba(20, 10, 6, 0.18); background: rgba(20, 10, 6, 0.18);
max-height: calc(100dvh - 52px);
} }
.team-picker-panel { .team-picker-panel {
display: grid; display: grid;
gap: 18px; gap: 12px;
padding: 18px; padding: 14px;
border-radius: 26px; border-radius: 22px;
background: linear-gradient(180deg, #fff8e8, #ffe5ad); background: linear-gradient(180deg, #fff8e8, #ffe5ad);
box-shadow: box-shadow:
0 0 0 4px rgba(255, 255, 255, 0.18), 0 0 0 4px rgba(255, 255, 255, 0.18),
inset 0 0 0 2px rgba(200, 140, 46, 0.45); inset 0 0 0 2px rgba(200, 140, 46, 0.45);
min-height: 0;
} }
.team-picker-title { .team-picker-title {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: 10px;
} }
.team-picker-count { .team-picker-count {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 86px; min-width: 72px;
min-height: 54px; min-height: 46px;
padding: 0 18px; padding: 0 14px;
border-radius: 18px; border-radius: 16px;
font-size: 1.5rem; font-size: 1.25rem;
color: #4a2e1d; color: #4a2e1d;
background: linear-gradient(180deg, #f5d89f, #ecc170); background: linear-gradient(180deg, #f5d89f, #ecc170);
} }
.team-picker-title p { .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 { .team-picker-config {
display: grid; display: grid;
gap: 8px; gap: 6px;
color: #4a2e1d; color: #4a2e1d;
} }
@@ -766,6 +775,16 @@
font-weight: 700; 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 { .team-picker-score-input {
width: 100%; width: 100%;
max-width: 140px; max-width: 140px;
@@ -777,22 +796,29 @@
font: inherit; font: inherit;
} }
.team-picker-score-input-compact {
width: 72px;
max-width: 72px;
padding: 8px 10px;
text-align: center;
}
.team-picker-list { .team-picker-list {
display: grid; display: grid;
gap: 14px; gap: 10px;
max-height: min(62vh, 760px); max-height: min(48dvh, 430px);
overflow: auto; overflow: auto;
padding-right: 8px; padding-right: 4px;
} }
.team-picker-option { .team-picker-option {
display: grid; display: grid;
grid-template-columns: 34px minmax(0, 1fr); grid-template-columns: 34px minmax(0, 1fr);
gap: 14px; gap: 10px;
align-items: center; align-items: center;
padding: 18px 16px; padding: 12px 12px;
border: 1px solid rgba(124, 98, 61, 0.18); border: 1px solid rgba(124, 98, 61, 0.18);
border-radius: 18px; border-radius: 14px;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
color: #2e231b; color: #2e231b;
@@ -832,24 +858,25 @@
} }
.team-picker-option strong, .team-picker-option strong,
.picked-team-card strong { .preset-team-card strong {
display: block; display: block;
font-size: 1.35rem; font-size: 1.05rem;
line-height: 1.2; line-height: 1.2;
} }
.team-picker-option small, .team-picker-option small,
.picked-team-card small, .preset-team-card small,
.picker-side-hint { .picker-side-hint {
display: block; display: block;
margin-top: 6px; margin-top: 4px;
color: #7b6148; color: #7b6148;
font-size: 0.82rem;
} }
.team-picker-actions { .team-picker-actions {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 14px; gap: 10px;
} }
.team-picker-ghost, .team-picker-ghost,
@@ -857,7 +884,7 @@
.team-picker-clear { .team-picker-clear {
border: 0; border: 0;
border-radius: 999px; border-radius: 999px;
padding: 16px 18px; padding: 12px 14px;
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
box-shadow: box-shadow:
@@ -900,44 +927,68 @@
opacity: 0.55; opacity: 0.55;
} }
.picked-team-list { .preset-team-block {
display: grid; display: grid;
gap: 16px; gap: 10px;
min-height: 0;
} }
.picked-team-card { .preset-team-head {
display: grid; display: grid;
grid-template-columns: 72px minmax(0, 1fr); gap: 2px;
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;
color: #4a2e1d; color: #4a2e1d;
} }
.picker-mode-toggle input { .preset-team-head small {
width: 24px; color: #7b6148;
height: 24px; 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 { .team-picker-clear {
@@ -1155,7 +1206,6 @@
.summary-grid, .summary-grid,
.double-grid, .double-grid,
.history-meta, .history-meta,
.team-picker-layout,
.selection-toolbar { .selection-toolbar {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1187,6 +1237,11 @@
.team-head-main { .team-head-main {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.team-picker-layout {
grid-template-columns: minmax(0, 1fr) 220px;
gap: 12px;
}
} }
@media (max-width: 720px) { @media (max-width: 720px) {
@@ -1361,5 +1416,116 @@
.team-picker-ribbon { .team-picker-ribbon {
left: 18px; left: 18px;
right: 90px; 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;
} }
} }

View File

@@ -103,6 +103,12 @@ export function ScoreboardPage({
return players return players
}, [selectedGroup]) }, [selectedGroup])
const presetTeams = useMemo(
() =>
selectedGroup?.teams.filter((team) => !team.isPlaceholderA && !team.isPlaceholderB) ?? [],
[selectedGroup],
)
const canArrangeMatch = !hasRecordedPoint const canArrangeMatch = !hasRecordedPoint
const canScore = scoreState.serving !== null 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 = () => { const confirmDraftTeams = () => {
if (draftPlayers.length !== 4) { if (draftPlayers.length !== 4) {
return return
@@ -305,10 +331,10 @@ export function ScoreboardPage({
{pickerOpen ? ( {pickerOpen ? (
<TeamPickerModal <TeamPickerModal
currentSelectionOrder={currentSelectionOrder}
draftPlayers={draftPlayers} draftPlayers={draftPlayers}
draftTargetScore={draftTargetScore} draftTargetScore={draftTargetScore}
group={selectedGroup} group={selectedGroup}
presetTeams={presetTeams}
selectablePlayers={selectablePlayers} selectablePlayers={selectablePlayers}
selectionCount={draftPlayers.length} selectionCount={draftPlayers.length}
sourceLabel={groupSource === 'db' ? 'DB' : groupSource === 'manual' ? '手動' : '-'} sourceLabel={groupSource === 'db' ? 'DB' : groupSource === 'manual' ? '手動' : '-'}
@@ -319,6 +345,7 @@ export function ScoreboardPage({
onConfirm={confirmDraftTeams} onConfirm={confirmDraftTeams}
onDraftTargetScoreChange={setDraftTargetScore} onDraftTargetScoreChange={setDraftTargetScore}
onTogglePlayer={toggleDraftPlayer} onTogglePlayer={toggleDraftPlayer}
onTogglePresetTeam={togglePresetTeam}
/> />
) : null} ) : null}
@@ -473,10 +500,10 @@ function ScoreboardTeamPanel({
} }
type TeamPickerModalProps = { type TeamPickerModalProps = {
currentSelectionOrder: string[]
draftPlayers: string[] draftPlayers: string[]
draftTargetScore: string draftTargetScore: string
group: RoundGroup group: RoundGroup
presetTeams: GroupTeam[]
selectablePlayers: string[] selectablePlayers: string[]
selectionCount: number selectionCount: number
sourceLabel: string sourceLabel: string
@@ -487,13 +514,14 @@ type TeamPickerModalProps = {
onConfirm: () => void onConfirm: () => void
onDraftTargetScoreChange: (value: string) => void onDraftTargetScoreChange: (value: string) => void
onTogglePlayer: (playerName: string) => void onTogglePlayer: (playerName: string) => void
onTogglePresetTeam: (team: GroupTeam) => void
} }
function TeamPickerModal({ function TeamPickerModal({
currentSelectionOrder,
draftPlayers, draftPlayers,
draftTargetScore, draftTargetScore,
group, group,
presetTeams,
selectablePlayers, selectablePlayers,
selectionCount, selectionCount,
sourceLabel, sourceLabel,
@@ -504,20 +532,8 @@ function TeamPickerModal({
onConfirm, onConfirm,
onDraftTargetScoreChange, onDraftTargetScoreChange,
onTogglePlayer, onTogglePlayer,
onTogglePresetTeam,
}: TeamPickerModalProps) { }: 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 ( return (
<div className="team-picker-overlay" role="presentation" onClick={onClose}> <div className="team-picker-overlay" role="presentation" onClick={onClose}>
<div <div
@@ -540,24 +556,26 @@ function TeamPickerModal({
<div className="team-picker-title"> <div className="team-picker-title">
<span className="team-picker-count">{selectionCount}/4</span> <span className="team-picker-count">{selectionCount}/4</span>
<div> <div>
<strong></strong> <strong></strong>
<p> <p>
{group.id} / {sourceLabel} / {targetDate || '-'} {group.id} / {sourceLabel} / {targetDate || '-'}
</p> </p>
</div> </div>
</div> </div>
<label className="team-picker-config"> <div className="team-picker-config-row">
<span></span> <label className="team-picker-config team-picker-config-compact">
<input <span></span>
className="team-picker-score-input" <input
inputMode="numeric" className="team-picker-score-input team-picker-score-input-compact"
maxLength={2} inputMode="numeric"
type="text" maxLength={2}
value={draftTargetScore} type="text"
onChange={(event) => onDraftTargetScoreChange(event.target.value)} value={draftTargetScore}
/> onChange={(event) => onDraftTargetScoreChange(event.target.value)}
</label> />
</label>
</div>
<div className="team-picker-list"> <div className="team-picker-list">
{selectablePlayers.map((playerName) => { {selectablePlayers.map((playerName) => {
@@ -605,24 +623,40 @@ function TeamPickerModal({
</section> </section>
<aside className="team-picker-panel team-picker-side-panel"> <aside className="team-picker-panel team-picker-side-panel">
<div className="picked-team-list"> <div className="preset-team-block">
{[0, 1].map((slotIndex) => { <div className="preset-team-head">
const teamLabel = draftTeams[slotIndex] <strong></strong>
const isCurrent = currentTeams[slotIndex] === teamLabel <small></small>
</div>
return ( <div className="preset-team-list">
<div className="picked-team-card" key={`picked-${slotIndex}`}> {presetTeams.map((team) => {
<span className="picked-team-index">{slotIndex + 1}</span> const selectedSlot = getPresetTeamSelectionSlot(draftPlayers, team)
<div>
<strong>{teamLabel}</strong> return (
<small> <button
{slotIndex === 0 ? '第 1、2 位自動成隊' : '第 3、4 位自動成隊'} className={
{isCurrent ? ' / 目前場上配置' : ''} selectedSlot === null
</small> ? 'preset-team-card'
</div> : 'preset-team-card preset-team-card-active'
</div> }
) key={`preset-team-${team.id}`}
})} type="button"
onClick={() => onTogglePresetTeam(team)}
>
<span className="preset-team-index">{team.id}</span>
<div>
<strong>{getTeamDisplayName(team)}</strong>
<small>
{selectedSlot === null
? '點擊帶入這組預設隊伍'
: `已帶入第 ${selectedSlot === 0 ? '1' : '2'}`}
</small>
</div>
</button>
)
})}
</div>
</div> </div>
<button className="team-picker-clear" type="button" onClick={onClear}> <button className="team-picker-clear" type="button" onClick={onClear}>
@@ -737,6 +771,33 @@ function sanitizeTargetScore(value: string) {
return Math.min(99, Math.max(1, numeric)) return Math.min(99, Math.max(1, numeric))
} }
function removePresetTeamFromDraft(players: string[], team: GroupTeam) {
const firstPairSelected = players[0] === team.playerA && players[1] === team.playerB
const secondPairSelected = players[2] === team.playerA && players[3] === team.playerB
if (firstPairSelected) {
return players.slice(2)
}
if (secondPairSelected) {
return players.slice(0, 2)
}
return players
}
function getPresetTeamSelectionSlot(players: string[], team: GroupTeam) {
if (players[0] === team.playerA && players[1] === team.playerB) {
return 0
}
if (players[2] === team.playerA && players[3] === team.playerB) {
return 1
}
return null
}
function formatClock() { function formatClock() {
return new Date().toLocaleTimeString('zh-TW', { return new Date().toLocaleTimeString('zh-TW', {
hour: '2-digit', hour: '2-digit',