精簡選隊伍頁提示並更新 README
This commit is contained in:
248
README.md
248
README.md
@@ -1,116 +1,112 @@
|
|||||||
# badminton-scoreboard
|
# 羽毛球記分板
|
||||||
|
|
||||||
羽毛球記分板系統,使用 `Vite + React + TypeScript` 建立前端,搭配 `Express + MySQL` 提供資料讀寫 API。
|
以 `Vite + React + TypeScript + Node.js` 製作的羽毛球記分板,提供選隊伍、記分板、歷史戰績三個主要頁面,並可搭配 Docker 部署到 NAS。
|
||||||
|
|
||||||
目前專案包含三個主要頁面:
|
## 功能
|
||||||
|
|
||||||
- `選隊伍`:可依指定日期從 DB 載入隊伍資料,若沒有資料可改用手動配對
|
- 選隊伍頁
|
||||||
- `記分板`:支援先攻設定、直接點分數記分、上一步回退、上下換隊、左右換位
|
- 依指定日期從資料庫讀取分組資料
|
||||||
- `歷史戰績`:直接從 DB 的 `history` 表讀取戰績列表,點開可看得分紀錄,也能直接刪除單筆紀錄
|
- 若當天沒有資料,可手動輸入 A、B 區名單建立分組
|
||||||
|
- 成功載入後會在畫面中央顯示 0.5 秒浮動提示
|
||||||
## 目前記分板流程
|
- 記分板頁
|
||||||
|
- 從所選組別進入記分板
|
||||||
- 進入記分板前,先從選隊伍頁面選定「第幾組」
|
- 設定隊伍彈窗支援兩種方式
|
||||||
- 點 `設定隊伍` 後會打開雙欄彈窗
|
- 左側逐一選人,依順序 `1、2` 為上方隊伍,`3、4` 為下方隊伍
|
||||||
- 左邊可一個一個選球員,依照順序自動成隊:
|
- 右側快速套用預設隊伍
|
||||||
- 第 1、2 位成上方隊伍
|
- 可設定幾分獲勝,預設 `21`
|
||||||
- 第 3、4 位成下方隊伍
|
- 必須先設定先攻,之後點分數即可直接加分
|
||||||
- 右邊可直接快速選預設好的隊伍
|
- 第一分後 `設定隊伍` 會改成 `上一步`
|
||||||
- 可設定 `幾分獲勝`,預設是 `21`
|
- 支援上下交換兩隊位置、左右交換隊內站位
|
||||||
- 確認配隊後會直接帶入記分板並重設本場狀態
|
- 歷史戰績頁
|
||||||
- 開始記分後,`設定隊伍` 會改成 `上一步`
|
- 直接從資料庫 `history` 表讀取列表
|
||||||
|
- 點擊任一筆可開啟得分紀錄彈窗
|
||||||
## 手機版設計方向
|
- 每筆資料可單獨刪除
|
||||||
|
|
||||||
- 以手機完整顯示為優先
|
|
||||||
- 記分板主畫面盡量避免整頁上下滑動
|
|
||||||
- 設定隊伍彈窗會優先壓縮內容高度
|
|
||||||
- 若名單較多,改為彈窗內局部捲動,不影響整頁閱讀
|
|
||||||
|
|
||||||
## 歷史戰績功能
|
|
||||||
|
|
||||||
- 歷史列表直接從 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` 只提供容器內部反向代理使用。
|
|
||||||
|
|
||||||
## 本機開發
|
## 本機開發
|
||||||
|
|
||||||
安裝套件:
|
### Port
|
||||||
|
|
||||||
|
- Client: `3501`
|
||||||
|
- Server: `8788`
|
||||||
|
|
||||||
|
### 安裝
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
啟動開發環境:
|
### 啟動開發模式
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
會同時啟動:
|
啟動後可從以下位置開啟:
|
||||||
|
|
||||||
- Vite client on `3501`
|
- 前端:`http://localhost:3501`
|
||||||
- Node server on `8788`
|
- API:`http://localhost:8788`
|
||||||
|
|
||||||
## 建置與檢查
|
### 驗證
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run lint
|
npm run lint
|
||||||
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
## `.env` 範例
|
## 環境變數
|
||||||
|
|
||||||
請參考 [.env.example](./.env.example):
|
請在專案根目錄建立 `.env`,至少包含以下欄位:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
DB_HOST=192.168.0.15
|
DB_HOST=127.0.0.1
|
||||||
DB_PORT=3307
|
DB_PORT=3306
|
||||||
DB_USER=jianmiau
|
DB_USER=root
|
||||||
DB_PASSWORD=your-password
|
DB_PASSWORD=your_password
|
||||||
DB_DATABASE=badminton
|
DB_DATABASE=badminton
|
||||||
DB_TABLE=badminton
|
DB_TABLE=match_results
|
||||||
DB_HISTORY_TABLE=history
|
DB_HISTORY_TABLE=history
|
||||||
SERVER_PORT=8788
|
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 資料表格式
|
## Docker / NAS 部署
|
||||||
|
|
||||||
### `badminton`
|
目前部署設計如下:
|
||||||
|
|
||||||
- `time`:日期鍵值,格式 `YYYYMMDD`
|
- 對外入口:`3501`
|
||||||
- `personnel`:球員名單 JSON
|
- App 內部服務:`8788`
|
||||||
- `battlecombination`:各組配對 JSON
|
- 由 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`
|
- `id`
|
||||||
- `time`
|
- `time`
|
||||||
@@ -118,92 +114,24 @@ SSL_KEY_FILE_NAME=RSA-privkey.pem
|
|||||||
- `score`
|
- `score`
|
||||||
- `winScore`
|
- `winScore`
|
||||||
- `type`
|
- `type`
|
||||||
|
- `0`:雙打
|
||||||
|
- `1`:單打
|
||||||
- `players`
|
- `players`
|
||||||
|
- 依照 `1 ~ 4` 編號順序儲存
|
||||||
- `team`
|
- `team`
|
||||||
|
- `1、2` 為一隊
|
||||||
|
- `3、4` 為一隊
|
||||||
- `scoreList`
|
- `scoreList`
|
||||||
|
- 格式:`[round, 發球者編號, 連勝次數, 得分隊伍(0 或 1)]`
|
||||||
|
|
||||||
`scoreList` 格式:
|
## Git 中文紀錄
|
||||||
|
|
||||||
```text
|
專案已設定 git 使用 UTF-8:
|
||||||
[round, starter, winCount, winner]
|
|
||||||
```
|
|
||||||
|
|
||||||
說明:
|
|
||||||
|
|
||||||
- `round`:第幾球
|
|
||||||
- `starter`:發球者編號
|
|
||||||
- `winCount`:連勝次數
|
|
||||||
- `winner`:`0` 代表上方隊伍得分,`1` 代表下方隊伍得分
|
|
||||||
|
|
||||||
## Docker 建置
|
|
||||||
|
|
||||||
單獨建置 Node app:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t badminton-scoreboard .
|
git config i18n.commitEncoding utf-8
|
||||||
|
git config i18n.logOutputEncoding utf-8
|
||||||
|
git config core.quotepath false
|
||||||
```
|
```
|
||||||
|
|
||||||
## NAS 部署
|
之後 commit 訊息與 log 可直接使用中文。
|
||||||
|
|
||||||
專案已提供 [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` 會比較正常。
|
|
||||||
|
|||||||
31
src/App.css
31
src/App.css
@@ -178,6 +178,37 @@
|
|||||||
background: rgba(255, 214, 224, 0.94);
|
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-shell,
|
||||||
.selection-form,
|
.selection-form,
|
||||||
.group-board {
|
.group-board {
|
||||||
|
|||||||
16
src/App.tsx
16
src/App.tsx
@@ -113,6 +113,18 @@ function App() {
|
|||||||
window.localStorage.setItem(STORAGE_KEYS.history, JSON.stringify(history))
|
window.localStorage.setItem(STORAGE_KEYS.history, JSON.stringify(history))
|
||||||
}, [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) => {
|
const resetScoring = (nextState: ScoreState = initialScoreState) => {
|
||||||
setScoreState(nextState)
|
setScoreState(nextState)
|
||||||
setScoreHistory([])
|
setScoreHistory([])
|
||||||
@@ -450,8 +462,6 @@ function App() {
|
|||||||
groupSource={groupSource}
|
groupSource={groupSource}
|
||||||
loadMessage={loadMessage}
|
loadMessage={loadMessage}
|
||||||
loadStatus={loadStatus}
|
loadStatus={loadStatus}
|
||||||
parsedAreaA={parsedAreaA}
|
|
||||||
parsedAreaB={parsedAreaB}
|
|
||||||
selectedGroupId={selectedGroupId}
|
selectedGroupId={selectedGroupId}
|
||||||
targetDate={targetDate}
|
targetDate={targetDate}
|
||||||
onAreaAInputChange={setAreaAInput}
|
onAreaAInputChange={setAreaAInput}
|
||||||
@@ -474,8 +484,6 @@ function App() {
|
|||||||
groupSource={groupSource}
|
groupSource={groupSource}
|
||||||
loadMessage={loadMessage}
|
loadMessage={loadMessage}
|
||||||
loadStatus={loadStatus}
|
loadStatus={loadStatus}
|
||||||
parsedAreaA={parsedAreaA}
|
|
||||||
parsedAreaB={parsedAreaB}
|
|
||||||
selectedGroupId={selectedGroupId}
|
selectedGroupId={selectedGroupId}
|
||||||
targetDate={targetDate}
|
targetDate={targetDate}
|
||||||
onAreaAInputChange={setAreaAInput}
|
onAreaAInputChange={setAreaAInput}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { getTeamDisplayName } from '../lib/match'
|
import { getTeamDisplayName } from '../lib/match'
|
||||||
import type { LoadStatus, RoundGroup } from '../types'
|
import type { LoadStatus, RoundGroup } from '../types'
|
||||||
|
|
||||||
@@ -9,8 +9,6 @@ type TeamSelectionPageProps = {
|
|||||||
groupSource: 'idle' | 'db' | 'manual'
|
groupSource: 'idle' | 'db' | 'manual'
|
||||||
loadMessage: string
|
loadMessage: string
|
||||||
loadStatus: LoadStatus
|
loadStatus: LoadStatus
|
||||||
parsedAreaA: string[]
|
|
||||||
parsedAreaB: string[]
|
|
||||||
selectedGroupId: number | null
|
selectedGroupId: number | null
|
||||||
targetDate: string
|
targetDate: string
|
||||||
onAreaAInputChange: (value: string) => void
|
onAreaAInputChange: (value: string) => void
|
||||||
@@ -29,8 +27,6 @@ export function TeamSelectionPage({
|
|||||||
groupSource,
|
groupSource,
|
||||||
loadMessage,
|
loadMessage,
|
||||||
loadStatus,
|
loadStatus,
|
||||||
parsedAreaA,
|
|
||||||
parsedAreaB,
|
|
||||||
selectedGroupId,
|
selectedGroupId,
|
||||||
targetDate,
|
targetDate,
|
||||||
onAreaAInputChange,
|
onAreaAInputChange,
|
||||||
@@ -41,41 +37,21 @@ export function TeamSelectionPage({
|
|||||||
onTargetDateChange,
|
onTargetDateChange,
|
||||||
onUseGroup,
|
onUseGroup,
|
||||||
}: TeamSelectionPageProps) {
|
}: TeamSelectionPageProps) {
|
||||||
const navigate = useNavigate()
|
const hasGroups = groups.length > 0
|
||||||
|
const showInlineStatus = loadStatus !== 'idle' && loadStatus !== 'loaded' && Boolean(loadMessage)
|
||||||
|
const sourceLabel =
|
||||||
|
groupSource === 'db' ? '資料庫載入' : groupSource === 'manual' ? '手動產生' : '尚未建立'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="selection-shell">
|
<section className="page-grid">
|
||||||
<article className="panel panel-hero selection-hero">
|
{loadStatus === 'loaded' && loadMessage ? (
|
||||||
<div>
|
<div className="floating-status-bubble" role="status" aria-live="polite">
|
||||||
<p className="panel-kicker">步驟 1</p>
|
{loadMessage}
|
||||||
<h2>載入分組與選擇組別</h2>
|
|
||||||
<p className="panel-copy">
|
|
||||||
先用日期從 DB 讀取分組;如果指定日期沒有資料,就改用 A 區與 B 區名單手動產生配對。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="summary-grid">
|
|
||||||
<article className="mini-stat">
|
|
||||||
<span>A 區隊數</span>
|
|
||||||
<strong>{parsedAreaA.length}</strong>
|
|
||||||
</article>
|
|
||||||
<article className="mini-stat">
|
|
||||||
<span>B 區隊數</span>
|
|
||||||
<strong>{parsedAreaB.length}</strong>
|
|
||||||
</article>
|
|
||||||
<article className="mini-stat">
|
|
||||||
<span>目前組數</span>
|
|
||||||
<strong>{groups.length}</strong>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loadMessage ? (
|
|
||||||
<p className={`status-banner status-banner-${loadStatus}`}>{loadMessage}</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
</article>
|
|
||||||
|
|
||||||
<article className="panel">
|
<article className="panel">
|
||||||
<div className="selection-form">
|
<div className="selection-shell">
|
||||||
<div className="selection-toolbar">
|
<div className="selection-toolbar">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>指定日期</span>
|
<span>指定日期</span>
|
||||||
@@ -88,74 +64,64 @@ export function TeamSelectionPage({
|
|||||||
|
|
||||||
<div className="button-stack">
|
<div className="button-stack">
|
||||||
<button className="primary-button" type="button" onClick={onLoadGroupsFromDb}>
|
<button className="primary-button" type="button" onClick={onLoadGroupsFromDb}>
|
||||||
讀取指定日期
|
從資料庫讀取
|
||||||
</button>
|
</button>
|
||||||
<button className="secondary-button" type="button" onClick={onGenerateManualGroups}>
|
<button className="secondary-button" type="button" onClick={onGenerateManualGroups}>
|
||||||
手動產生配對
|
手動產生分組
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showInlineStatus ? (
|
||||||
|
<div className={`status-banner status-banner-${loadStatus}`}>{loadMessage}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="double-grid">
|
<div className="double-grid">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>A 區名單</span>
|
<span>A 區名單</span>
|
||||||
<textarea
|
<textarea
|
||||||
rows={8}
|
placeholder="每行一位球員"
|
||||||
value={areaAInput}
|
value={areaAInput}
|
||||||
onChange={(event) => onAreaAInputChange(event.target.value)}
|
onChange={(event) => onAreaAInputChange(event.target.value)}
|
||||||
placeholder={'每行一隊,例如:\n柏威'}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>B 區名單</span>
|
<span>B 區名單</span>
|
||||||
<textarea
|
<textarea
|
||||||
rows={8}
|
placeholder="每行一位球員"
|
||||||
value={areaBInput}
|
value={areaBInput}
|
||||||
onChange={(event) => onAreaBInputChange(event.target.value)}
|
onChange={(event) => onAreaBInputChange(event.target.value)}
|
||||||
placeholder={'每行一隊,例如:\nRURU'}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="selection-hint">
|
<div className="selection-hint">
|
||||||
<span>
|
<span>分組來源:{sourceLabel}</span>
|
||||||
來源:
|
|
||||||
{groupSource === 'db' ? 'DB' : groupSource === 'manual' ? '手動配對' : '尚未載入'}
|
|
||||||
</span>
|
|
||||||
<span>從下方選擇要帶進記分板的第幾組。</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="panel full-span">
|
<article className="panel">
|
||||||
<div className="panel-heading">
|
<div className="group-head">
|
||||||
<div>
|
<div>
|
||||||
<p className="panel-kicker">步驟 2</p>
|
<p className="panel-kicker">Step 2</p>
|
||||||
<h2>選擇第幾組帶進記分板</h2>
|
<h2>選擇要上場的組別</h2>
|
||||||
</div>
|
</div>
|
||||||
|
{selectedGroupId ? <span className="winner-badge">目前第 {selectedGroupId} 組</span> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{groups.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<h3>目前還沒有分組</h3>
|
|
||||||
<p>先讀取指定日期資料,或手動輸入名單後產生配對。</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="group-board">
|
<div className="group-board">
|
||||||
{groups.map((group) => (
|
{hasGroups ? (
|
||||||
|
groups.map((group) => (
|
||||||
<article
|
<article
|
||||||
className={
|
|
||||||
group.id === selectedGroupId
|
|
||||||
? 'group-card group-card-active group-card-stage'
|
|
||||||
: 'group-card group-card-stage'
|
|
||||||
}
|
|
||||||
key={group.id}
|
key={group.id}
|
||||||
|
className={`group-card ${selectedGroupId === group.id ? 'group-card-active' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="group-head">
|
<div className="group-head">
|
||||||
<div>
|
<div>
|
||||||
<p className="panel-kicker">第 {group.id} 組</p>
|
<p className="panel-kicker">第 {group.id} 組</p>
|
||||||
<h3>{group.teams.length} 隊可選</h3>
|
<h3>本組對戰名單</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="group-actions">
|
<div className="group-actions">
|
||||||
<button
|
<button
|
||||||
@@ -163,33 +129,31 @@ export function TeamSelectionPage({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelectGroup(group.id)}
|
onClick={() => onSelectGroup(group.id)}
|
||||||
>
|
>
|
||||||
先選這組
|
選擇這組
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="primary-button"
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onUseGroup(group.id)
|
|
||||||
navigate('/scoreboard')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
帶進記分板
|
|
||||||
</button>
|
</button>
|
||||||
|
<Link className="primary-button inline-link" to="/scoreboard" onClick={() => onUseGroup(group.id)}>
|
||||||
|
進入記分板
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="team-stage-grid">
|
<div className="team-stage-grid">
|
||||||
{group.teams.map((team) => (
|
{group.teams.map((team) => (
|
||||||
<article className="team-stage-card" key={`${group.id}-${team.id}`}>
|
<article key={`${group.id}-${team.id}`} className="team-stage-card">
|
||||||
<span className="team-index">第 {team.id} 隊</span>
|
<span className="team-index">隊伍 {team.id}</span>
|
||||||
<p className="team-name">{getTeamDisplayName(team)}</p>
|
<div className="team-name">{getTeamDisplayName(team)}</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<div className="empty-state">
|
||||||
|
<h3>目前還沒有分組</h3>
|
||||||
|
<p>先從資料庫讀取指定日期,或輸入 A、B 區名單後手動建立。</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user