整理設定隊伍介面並更新 README
This commit is contained in:
170
README.md
170
README.md
@@ -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` 會比較正常。
|
||||||
|
|||||||
288
src/App.css
288
src/App.css
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user