調整設定隊伍流程並支援勝利分數設定
This commit is contained in:
226
src/App.css
226
src/App.css
@@ -347,8 +347,10 @@
|
||||
gap: 14px;
|
||||
padding: 12px;
|
||||
border-radius: 22px;
|
||||
background: #060606;
|
||||
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.26);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(255, 205, 96, 0.22), transparent 26%),
|
||||
linear-gradient(145deg, rgba(8, 47, 73, 0.97), rgba(10, 96, 84, 0.93));
|
||||
box-shadow: 0 30px 60px rgba(8, 47, 73, 0.24);
|
||||
}
|
||||
|
||||
.scoreboard-team-section {
|
||||
@@ -370,7 +372,8 @@
|
||||
min-height: 64px;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
background: #d8cfd2;
|
||||
background: rgba(255, 248, 232, 0.92);
|
||||
box-shadow: inset 0 0 0 1px rgba(195, 154, 88, 0.2);
|
||||
}
|
||||
|
||||
.scoreboard-name-chip {
|
||||
@@ -380,8 +383,8 @@
|
||||
min-height: 46px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
color: #111;
|
||||
background: rgba(255, 255, 255, 0.42);
|
||||
color: #16342f;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.scoreboard-name-chip strong {
|
||||
@@ -391,8 +394,8 @@
|
||||
}
|
||||
|
||||
.scoreboard-name-chip-serving {
|
||||
background: #c8f400;
|
||||
box-shadow: inset 0 0 0 2px rgba(34, 48, 0, 0.18);
|
||||
background: linear-gradient(180deg, #ebf8a4, #d6e164);
|
||||
box-shadow: inset 0 0 0 2px rgba(111, 128, 27, 0.24);
|
||||
}
|
||||
|
||||
.team-number {
|
||||
@@ -405,7 +408,7 @@
|
||||
font-family: var(--mono);
|
||||
font-size: 1.15rem;
|
||||
color: #fff;
|
||||
background: #06253e;
|
||||
background: linear-gradient(180deg, rgba(8, 47, 73, 0.96), rgba(10, 96, 84, 0.92));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -421,8 +424,27 @@
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 1.2rem;
|
||||
color: #111;
|
||||
background: #d0d0d0;
|
||||
color: #5b2f13;
|
||||
background: linear-gradient(180deg, #fff8e8, #f2d9a3);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(199, 155, 83, 0.35),
|
||||
0 6px 14px rgba(8, 47, 73, 0.14);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
box-shadow 0.16s ease,
|
||||
filter 0.16s ease;
|
||||
}
|
||||
|
||||
.team-icon-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(199, 155, 83, 0.45),
|
||||
0 10px 18px rgba(8, 47, 73, 0.18);
|
||||
}
|
||||
|
||||
.team-icon-button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
filter: brightness(0.98);
|
||||
}
|
||||
|
||||
.team-icon-button:disabled {
|
||||
@@ -442,28 +464,41 @@
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: #fff;
|
||||
background: #2f2f2f;
|
||||
color: #f8f4ea;
|
||||
background: rgba(255, 248, 232, 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 236, 202, 0.12);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
box-shadow 0.16s ease,
|
||||
background 0.16s ease;
|
||||
}
|
||||
|
||||
.serve-lane:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.serve-lane:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
background: rgba(255, 248, 232, 0.18);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 236, 202, 0.22),
|
||||
0 10px 18px rgba(8, 47, 73, 0.12);
|
||||
}
|
||||
|
||||
.serve-lane small {
|
||||
justify-self: end;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
color: rgba(248, 244, 234, 0.72);
|
||||
}
|
||||
|
||||
.serve-lane-locked {
|
||||
box-shadow: inset 0 0 0 1px rgba(200, 244, 0, 0.28);
|
||||
box-shadow: inset 0 0 0 1px rgba(214, 225, 100, 0.42);
|
||||
}
|
||||
|
||||
.serve-lane-box {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
background: #f7f7f7;
|
||||
background: linear-gradient(180deg, #fff8e8, #f0dfbd);
|
||||
}
|
||||
|
||||
.score-panel-surface {
|
||||
@@ -479,9 +514,10 @@
|
||||
transform 0.16s ease,
|
||||
box-shadow 0.16s ease;
|
||||
background:
|
||||
linear-gradient(transparent 0 40%, rgba(0, 0, 0, 0.24) 40% 60%, transparent 60% 100%),
|
||||
linear-gradient(90deg, transparent 0 40%, rgba(0, 0, 0, 0.24) 40% 60%, transparent 60% 100%),
|
||||
#2f2f2f;
|
||||
linear-gradient(transparent 0 40%, rgba(11, 39, 34, 0.18) 40% 60%, transparent 60% 100%),
|
||||
linear-gradient(90deg, transparent 0 40%, rgba(11, 39, 34, 0.18) 40% 60%, transparent 60% 100%),
|
||||
linear-gradient(180deg, rgba(255, 248, 232, 0.2), rgba(255, 248, 232, 0.08)),
|
||||
rgba(245, 237, 221, 0.96);
|
||||
}
|
||||
|
||||
.score-panel-surface-live {
|
||||
@@ -490,14 +526,17 @@
|
||||
|
||||
.score-panel-surface-live:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: inset 0 0 0 2px rgba(213, 234, 2, 0.2);
|
||||
box-shadow:
|
||||
inset 0 0 0 2px rgba(236, 193, 112, 0.44),
|
||||
0 10px 20px rgba(8, 47, 73, 0.12);
|
||||
}
|
||||
|
||||
.score-panel-value {
|
||||
font-family: var(--mono);
|
||||
font-size: clamp(4.4rem, 11vw, 7.2rem);
|
||||
line-height: 1;
|
||||
color: #d5ea02;
|
||||
color: #0d544a;
|
||||
text-shadow: 0 2px 0 rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
|
||||
.scoreboard-center-banner {
|
||||
@@ -509,13 +548,13 @@
|
||||
|
||||
.scoreboard-center-banner p {
|
||||
font-size: clamp(1.5rem, 3vw, 2.3rem);
|
||||
color: #fff;
|
||||
color: #fff7e9;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.scoreboard-center-banner small {
|
||||
font-size: 0.92rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
color: rgba(255, 247, 233, 0.76);
|
||||
}
|
||||
|
||||
.scoreboard-rail {
|
||||
@@ -536,8 +575,27 @@
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: #111;
|
||||
background: #d0d0d0;
|
||||
color: #5b2f13;
|
||||
background: linear-gradient(180deg, #fff8e8, #f2d9a3);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(199, 155, 83, 0.35),
|
||||
0 8px 18px rgba(8, 47, 73, 0.14);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
box-shadow 0.16s ease,
|
||||
filter 0.16s ease;
|
||||
}
|
||||
|
||||
.rail-square-button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(199, 155, 83, 0.45),
|
||||
0 12px 20px rgba(8, 47, 73, 0.18);
|
||||
}
|
||||
|
||||
.rail-square-button:active {
|
||||
transform: translateY(0);
|
||||
filter: brightness(0.98);
|
||||
}
|
||||
|
||||
.rail-clock {
|
||||
@@ -547,8 +605,10 @@
|
||||
border-radius: 12px;
|
||||
font-family: var(--mono);
|
||||
font-size: 1.7rem;
|
||||
color: #fff;
|
||||
background: #060606;
|
||||
color: #fff7e9;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(255, 205, 96, 0.16), transparent 28%),
|
||||
linear-gradient(145deg, rgba(8, 47, 73, 0.97), rgba(10, 96, 84, 0.93));
|
||||
}
|
||||
|
||||
.rail-pill {
|
||||
@@ -560,11 +620,30 @@
|
||||
font-size: 1rem;
|
||||
color: #4a2e1d;
|
||||
background: linear-gradient(180deg, #fff0c7, #f8c870);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(199, 155, 83, 0.28),
|
||||
0 10px 18px rgba(8, 47, 73, 0.14);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
box-shadow 0.16s ease,
|
||||
filter 0.16s ease;
|
||||
}
|
||||
|
||||
.rail-pill:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(199, 155, 83, 0.34),
|
||||
0 14px 22px rgba(8, 47, 73, 0.18);
|
||||
}
|
||||
|
||||
.rail-pill:active {
|
||||
transform: translateY(0);
|
||||
filter: brightness(0.98);
|
||||
}
|
||||
|
||||
.rail-pill-danger {
|
||||
color: #fff;
|
||||
background: linear-gradient(180deg, #d41d1d, #a91212);
|
||||
background: linear-gradient(180deg, #d95a44, #b53a28);
|
||||
}
|
||||
|
||||
.rail-pill-muted {
|
||||
@@ -602,6 +681,25 @@
|
||||
font-size: 2.4rem;
|
||||
color: #b34e3a;
|
||||
background: linear-gradient(180deg, #ffe5bf, #f0bd7c);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(199, 125, 63, 0.34),
|
||||
0 14px 26px rgba(8, 47, 73, 0.2);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
box-shadow 0.16s ease,
|
||||
filter 0.16s ease;
|
||||
}
|
||||
|
||||
.team-picker-close:hover {
|
||||
transform: translateY(-1px) scale(1.01);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(199, 125, 63, 0.42),
|
||||
0 18px 30px rgba(8, 47, 73, 0.24);
|
||||
}
|
||||
|
||||
.team-picker-close:active {
|
||||
transform: translateY(0);
|
||||
filter: brightness(0.98);
|
||||
}
|
||||
|
||||
.team-picker-ribbon {
|
||||
@@ -658,6 +756,27 @@
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.team-picker-config {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: #4a2e1d;
|
||||
}
|
||||
|
||||
.team-picker-config span {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.team-picker-score-input {
|
||||
width: 100%;
|
||||
max-width: 140px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(124, 98, 61, 0.22);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #2e231b;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.team-picker-list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
@@ -678,6 +797,17 @@
|
||||
text-align: left;
|
||||
color: #2e231b;
|
||||
background: rgba(255, 249, 238, 0.92);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
box-shadow 0.16s ease,
|
||||
border-color 0.16s ease,
|
||||
background 0.16s ease;
|
||||
}
|
||||
|
||||
.team-picker-option:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(199, 155, 83, 0.34);
|
||||
box-shadow: 0 12px 22px rgba(8, 47, 73, 0.08);
|
||||
}
|
||||
|
||||
.team-picker-option-active {
|
||||
@@ -730,6 +860,29 @@
|
||||
padding: 16px 18px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(199, 155, 83, 0.24),
|
||||
0 10px 18px rgba(8, 47, 73, 0.12);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
box-shadow 0.16s ease,
|
||||
filter 0.16s ease;
|
||||
}
|
||||
|
||||
.team-picker-ghost:hover,
|
||||
.team-picker-confirm:hover:not(:disabled),
|
||||
.team-picker-clear:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(199, 155, 83, 0.34),
|
||||
0 14px 22px rgba(8, 47, 73, 0.16);
|
||||
}
|
||||
|
||||
.team-picker-ghost:active,
|
||||
.team-picker-confirm:active:not(:disabled),
|
||||
.team-picker-clear:active {
|
||||
transform: translateY(0);
|
||||
filter: brightness(0.98);
|
||||
}
|
||||
|
||||
.team-picker-ghost {
|
||||
@@ -829,6 +982,25 @@
|
||||
font-size: 1.8rem;
|
||||
color: #b34e3a;
|
||||
background: linear-gradient(180deg, #ffe5bf, #f0bd7c);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(199, 125, 63, 0.34),
|
||||
0 10px 18px rgba(8, 47, 73, 0.16);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
box-shadow 0.16s ease,
|
||||
filter 0.16s ease;
|
||||
}
|
||||
|
||||
.finish-dialog-close:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(199, 125, 63, 0.42),
|
||||
0 14px 22px rgba(8, 47, 73, 0.2);
|
||||
}
|
||||
|
||||
.finish-dialog-close:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
filter: brightness(0.98);
|
||||
}
|
||||
|
||||
.finish-score {
|
||||
|
||||
64
src/App.tsx
64
src/App.tsx
@@ -17,11 +17,11 @@ import { HistoryPage } from './pages/HistoryPage'
|
||||
import { ScoreboardPage } from './pages/ScoreboardPage'
|
||||
import { TeamSelectionPage } from './pages/TeamSelectionPage'
|
||||
import type {
|
||||
ActiveMatchup,
|
||||
GroupTeam,
|
||||
HistoryUploadPayload,
|
||||
LoadStatus,
|
||||
MatchHistoryItem,
|
||||
Matchup,
|
||||
PointHistoryEntry,
|
||||
RoundGroup,
|
||||
ScoreSide,
|
||||
@@ -75,9 +75,9 @@ function App() {
|
||||
const [loadStatus, setLoadStatus] = useState<LoadStatus>('idle')
|
||||
const [loadMessage, setLoadMessage] = useState('')
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<number | null>(null)
|
||||
const [matchup, setMatchup] = useState<Matchup>({
|
||||
leftTeamId: null,
|
||||
rightTeamId: null,
|
||||
const [activeMatchup, setActiveMatchup] = useState<ActiveMatchup>({
|
||||
leftTeam: null,
|
||||
rightTeam: null,
|
||||
})
|
||||
const [scoreState, setScoreState] = useState<ScoreState>(initialScoreState)
|
||||
const [scoreHistory, setScoreHistory] = useState<ScoreSnapshot[]>([])
|
||||
@@ -94,10 +94,8 @@ function App() {
|
||||
const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput])
|
||||
const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput])
|
||||
const selectedGroup = groups.find((group) => group.id === selectedGroupId) ?? null
|
||||
const leftTeam =
|
||||
selectedGroup?.teams.find((team) => team.id === matchup.leftTeamId) ?? null
|
||||
const rightTeam =
|
||||
selectedGroup?.teams.find((team) => team.id === matchup.rightTeamId) ?? null
|
||||
const leftTeam = activeMatchup.leftTeam
|
||||
const rightTeam = activeMatchup.rightTeam
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(STORAGE_KEYS.targetDate, targetDate)
|
||||
@@ -132,19 +130,26 @@ function App() {
|
||||
const secondTeam = nextGroup?.teams[1] ?? null
|
||||
|
||||
setSelectedGroupId(nextGroup?.id ?? null)
|
||||
setMatchup({
|
||||
leftTeamId: firstTeam?.id ?? null,
|
||||
rightTeamId: secondTeam?.id ?? null,
|
||||
setActiveMatchup({
|
||||
leftTeam: firstTeam,
|
||||
rightTeam: secondTeam,
|
||||
})
|
||||
resetScoring()
|
||||
}
|
||||
|
||||
const applyMatchup = (leftTeamId: number, rightTeamId: number) => {
|
||||
setMatchup({
|
||||
leftTeamId,
|
||||
rightTeamId,
|
||||
const applyMatchup = (
|
||||
leftTeam: GroupTeam,
|
||||
rightTeam: GroupTeam,
|
||||
targetScore: number,
|
||||
) => {
|
||||
setActiveMatchup({
|
||||
leftTeam,
|
||||
rightTeam,
|
||||
})
|
||||
resetScoring({
|
||||
...initialScoreState,
|
||||
targetScore,
|
||||
})
|
||||
resetScoring()
|
||||
}
|
||||
|
||||
const loadGroupsFromDb = async () => {
|
||||
@@ -163,7 +168,7 @@ function App() {
|
||||
if (!record) {
|
||||
setGroups([])
|
||||
setSelectedGroupId(null)
|
||||
setMatchup({ leftTeamId: null, rightTeamId: null })
|
||||
setActiveMatchup({ leftTeam: null, rightTeam: null })
|
||||
setGroupSource('idle')
|
||||
setLoadStatus('empty')
|
||||
setLoadMessage('指定日期沒有資料,請改用手動配對。')
|
||||
@@ -181,7 +186,7 @@ function App() {
|
||||
} catch (error) {
|
||||
setGroups([])
|
||||
setSelectedGroupId(null)
|
||||
setMatchup({ leftTeamId: null, rightTeamId: null })
|
||||
setActiveMatchup({ leftTeam: null, rightTeam: null })
|
||||
setGroupSource('idle')
|
||||
setLoadStatus('error')
|
||||
setLoadMessage(error instanceof Error ? error.message : '讀取資料失敗。')
|
||||
@@ -192,7 +197,7 @@ function App() {
|
||||
if (parsedAreaA.length === 0 || parsedAreaB.length === 0) {
|
||||
setGroups([])
|
||||
setSelectedGroupId(null)
|
||||
setMatchup({ leftTeamId: null, rightTeamId: null })
|
||||
setActiveMatchup({ leftTeam: null, rightTeam: null })
|
||||
setGroupSource('idle')
|
||||
setLoadStatus('error')
|
||||
setLoadMessage('A 區與 B 區至少都要有 1 位成員。')
|
||||
@@ -212,9 +217,9 @@ function App() {
|
||||
return
|
||||
}
|
||||
|
||||
setMatchup((current) => ({
|
||||
leftTeamId: current.rightTeamId,
|
||||
rightTeamId: current.leftTeamId,
|
||||
setActiveMatchup((current) => ({
|
||||
leftTeam: current.rightTeam,
|
||||
rightTeam: current.leftTeam,
|
||||
}))
|
||||
|
||||
setScoreState((current) => ({
|
||||
@@ -487,13 +492,13 @@ function App() {
|
||||
path="/scoreboard"
|
||||
element={
|
||||
<ScoreboardPage
|
||||
currentSelectionOrder={getSelectionOrder(leftTeam, rightTeam)}
|
||||
finishDialogError={settlement.error}
|
||||
finishDialogOpen={settlement.open}
|
||||
finishDialogUploading={settlement.uploading}
|
||||
groupSource={groupSource}
|
||||
hasRecordedPoint={pointLog.length > 0}
|
||||
leftTeam={leftTeam}
|
||||
matchup={matchup}
|
||||
rightTeam={rightTeam}
|
||||
scoreState={scoreState}
|
||||
selectedGroup={selectedGroup}
|
||||
@@ -587,6 +592,19 @@ function formatPlayedAt(timestamp: number) {
|
||||
return new Date(timestamp * 1000).toLocaleString('zh-TW', { hour12: false })
|
||||
}
|
||||
|
||||
function getSelectionOrder(leftTeam: GroupTeam | null, rightTeam: GroupTeam | null) {
|
||||
if (!leftTeam || !rightTeam) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
leftTeam.playerA,
|
||||
leftTeam.playerB,
|
||||
rightTeam.playerB,
|
||||
rightTeam.playerA,
|
||||
].filter((name) => name.trim().length > 0)
|
||||
}
|
||||
|
||||
function loadStoredText(storageKey: string, fallback: string) {
|
||||
const value = window.localStorage.getItem(storageKey)
|
||||
return value && value.trim() ? value : fallback
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import type {
|
||||
CourtSide,
|
||||
GroupTeam,
|
||||
Matchup,
|
||||
PlayerSlot,
|
||||
RoundGroup,
|
||||
ScoreSide,
|
||||
@@ -18,18 +17,22 @@ import type {
|
||||
} from '../types'
|
||||
|
||||
type ScoreboardPageProps = {
|
||||
currentSelectionOrder: string[]
|
||||
finishDialogError: string
|
||||
finishDialogOpen: boolean
|
||||
finishDialogUploading: boolean
|
||||
groupSource: 'idle' | 'db' | 'manual'
|
||||
hasRecordedPoint: boolean
|
||||
leftTeam: GroupTeam | null
|
||||
matchup: Matchup
|
||||
rightTeam: GroupTeam | null
|
||||
scoreState: ScoreState
|
||||
selectedGroup: RoundGroup | null
|
||||
targetDate: string
|
||||
onApplyMatchup: (leftTeamId: number, rightTeamId: number) => void
|
||||
onApplyMatchup: (
|
||||
leftTeam: GroupTeam,
|
||||
rightTeam: GroupTeam,
|
||||
targetScore: number,
|
||||
) => void
|
||||
onCloseFinishDialog: () => void
|
||||
onConfirmUpload: () => void
|
||||
onOpenFinishDialog: () => void
|
||||
@@ -42,13 +45,13 @@ type ScoreboardPageProps = {
|
||||
}
|
||||
|
||||
export function ScoreboardPage({
|
||||
currentSelectionOrder,
|
||||
finishDialogError,
|
||||
finishDialogOpen,
|
||||
finishDialogUploading,
|
||||
groupSource,
|
||||
hasRecordedPoint,
|
||||
leftTeam,
|
||||
matchup,
|
||||
rightTeam,
|
||||
scoreState,
|
||||
selectedGroup,
|
||||
@@ -65,7 +68,8 @@ export function ScoreboardPage({
|
||||
onUndoLastPoint,
|
||||
}: ScoreboardPageProps) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [draftTeamIds, setDraftTeamIds] = useState<number[]>([])
|
||||
const [draftPlayers, setDraftPlayers] = useState<string[]>([])
|
||||
const [draftTargetScore, setDraftTargetScore] = useState(() => String(scoreState.targetScore))
|
||||
const [clock, setClock] = useState(() => formatClock())
|
||||
|
||||
useEffect(() => {
|
||||
@@ -76,6 +80,29 @@ export function ScoreboardPage({
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
const selectablePlayers = useMemo(() => {
|
||||
if (!selectedGroup) {
|
||||
return []
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const players: string[] = []
|
||||
|
||||
selectedGroup.teams.forEach((team) => {
|
||||
if (!team.isPlaceholderA && !seen.has(team.playerA)) {
|
||||
seen.add(team.playerA)
|
||||
players.push(team.playerA)
|
||||
}
|
||||
|
||||
if (!team.isPlaceholderB && !seen.has(team.playerB)) {
|
||||
seen.add(team.playerB)
|
||||
players.push(team.playerB)
|
||||
}
|
||||
})
|
||||
|
||||
return players
|
||||
}, [selectedGroup])
|
||||
|
||||
const canArrangeMatch = !hasRecordedPoint
|
||||
const canScore = scoreState.serving !== null
|
||||
|
||||
@@ -134,9 +161,9 @@ export function ScoreboardPage({
|
||||
<section className="page-grid">
|
||||
<article className="panel panel-hero">
|
||||
<p className="panel-kicker">Step 3</p>
|
||||
<h2>先到選隊伍頁面建立對戰組合</h2>
|
||||
<h2>先從選隊伍頁面帶入一組名單</h2>
|
||||
<p className="panel-copy">
|
||||
目前還沒有可用的組別。先載入指定日期資料,或手動建立分組後,再回來開始記分。
|
||||
記分板會依照你挑選的組別顯示球員,再從設定隊伍裡決定這一場實際對打的兩隊。
|
||||
</p>
|
||||
<Link className="primary-button inline-link" to="/teams">
|
||||
前往選隊伍
|
||||
@@ -152,46 +179,59 @@ export function ScoreboardPage({
|
||||
: '尚未設定對戰隊伍'
|
||||
|
||||
const openPicker = () => {
|
||||
const next = [matchup.leftTeamId, matchup.rightTeamId].filter(
|
||||
(value): value is number => value !== null,
|
||||
)
|
||||
|
||||
setDraftTeamIds(next)
|
||||
setDraftPlayers(currentSelectionOrder.filter((player) => selectablePlayers.includes(player)))
|
||||
setDraftTargetScore(String(scoreState.targetScore))
|
||||
setPickerOpen(true)
|
||||
}
|
||||
|
||||
const toggleDraftTeam = (teamId: number) => {
|
||||
setDraftTeamIds((current) => {
|
||||
if (current.includes(teamId)) {
|
||||
return current.filter((value) => value !== teamId)
|
||||
const toggleDraftPlayer = (playerName: string) => {
|
||||
setDraftPlayers((current) => {
|
||||
if (current.includes(playerName)) {
|
||||
return current.filter((value) => value !== playerName)
|
||||
}
|
||||
|
||||
if (current.length >= 2) {
|
||||
return [current[1], teamId]
|
||||
if (current.length >= 4) {
|
||||
return current
|
||||
}
|
||||
|
||||
return [...current, teamId]
|
||||
return [...current, playerName]
|
||||
})
|
||||
}
|
||||
|
||||
const confirmDraftTeams = () => {
|
||||
if (draftTeamIds.length !== 2) {
|
||||
if (draftPlayers.length !== 4) {
|
||||
return
|
||||
}
|
||||
|
||||
onApplyMatchup(draftTeamIds[0], draftTeamIds[1])
|
||||
onApplyMatchup(
|
||||
{
|
||||
id: -1,
|
||||
playerA: draftPlayers[0],
|
||||
playerB: draftPlayers[1],
|
||||
isPlaceholderA: false,
|
||||
isPlaceholderB: false,
|
||||
},
|
||||
{
|
||||
id: -2,
|
||||
playerA: draftPlayers[3],
|
||||
playerB: draftPlayers[2],
|
||||
isPlaceholderA: false,
|
||||
isPlaceholderB: false,
|
||||
},
|
||||
sanitizeTargetScore(draftTargetScore),
|
||||
)
|
||||
setPickerOpen(false)
|
||||
}
|
||||
|
||||
const autoPickDraftTeams = () => {
|
||||
const shuffled = [...selectedGroup.teams]
|
||||
const autoPickDraftPlayers = () => {
|
||||
const shuffled = [...selectablePlayers]
|
||||
|
||||
for (let index = shuffled.length - 1; index > 0; index -= 1) {
|
||||
const swapIndex = Math.floor(Math.random() * (index + 1))
|
||||
;[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]]
|
||||
}
|
||||
|
||||
setDraftTeamIds(shuffled.slice(0, 2).map((team) => team.id))
|
||||
setDraftPlayers(shuffled.slice(0, 4))
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -215,13 +255,13 @@ export function ScoreboardPage({
|
||||
/>
|
||||
|
||||
<div className="scoreboard-center-banner">
|
||||
<p>{scoreState.serving === null ? '請設定發球方' : '點擊分數開始計分'}</p>
|
||||
<p>{scoreState.serving === null ? '請先設定發球方' : '點擊分數即可記分'}</p>
|
||||
<small>
|
||||
{scoreState.serving === null
|
||||
? '先在上方或下方按下先攻'
|
||||
: `目前發球:${currentServer?.name ?? '-'}${
|
||||
? `本場 ${scoreState.targetScore} 分獲勝`
|
||||
: `發球:${currentServer?.name ?? '-'}${
|
||||
currentReceiver ? ` / 接發:${currentReceiver.name}` : ''
|
||||
}`}
|
||||
} / 目標 ${scoreState.targetScore} 分`}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
@@ -265,18 +305,20 @@ export function ScoreboardPage({
|
||||
|
||||
{pickerOpen ? (
|
||||
<TeamPickerModal
|
||||
currentLeftTeamId={matchup.leftTeamId}
|
||||
currentRightTeamId={matchup.rightTeamId}
|
||||
draftTeamIds={draftTeamIds}
|
||||
currentSelectionOrder={currentSelectionOrder}
|
||||
draftPlayers={draftPlayers}
|
||||
draftTargetScore={draftTargetScore}
|
||||
group={selectedGroup}
|
||||
selectionCount={draftTeamIds.length}
|
||||
selectablePlayers={selectablePlayers}
|
||||
selectionCount={draftPlayers.length}
|
||||
sourceLabel={groupSource === 'db' ? 'DB' : groupSource === 'manual' ? '手動' : '-'}
|
||||
targetDate={targetDate}
|
||||
onAutoPick={autoPickDraftTeams}
|
||||
onClear={() => setDraftTeamIds([])}
|
||||
onAutoPick={autoPickDraftPlayers}
|
||||
onClear={() => setDraftPlayers([])}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onConfirm={confirmDraftTeams}
|
||||
onToggleTeam={toggleDraftTeam}
|
||||
onDraftTargetScoreChange={setDraftTargetScore}
|
||||
onTogglePlayer={toggleDraftPlayer}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -284,10 +326,10 @@ export function ScoreboardPage({
|
||||
<FinishDialog
|
||||
error={finishDialogError}
|
||||
leftScore={scoreState.scoreLeft}
|
||||
leftTeamName={leftTeam ? getTeamDisplayName(leftTeam) : '未設定'}
|
||||
leftTeamName={leftTeam ? getTeamDisplayName(leftTeam) : '尚未設定'}
|
||||
matchupLabel={matchupLabel}
|
||||
rightScore={scoreState.scoreRight}
|
||||
rightTeamName={rightTeam ? getTeamDisplayName(rightTeam) : '未設定'}
|
||||
rightTeamName={rightTeam ? getTeamDisplayName(rightTeam) : '尚未設定'}
|
||||
uploading={finishDialogUploading}
|
||||
onClose={onCloseFinishDialog}
|
||||
onConfirm={onConfirmUpload}
|
||||
@@ -366,7 +408,7 @@ function ScoreboardTeamPanel({
|
||||
↕
|
||||
</button>
|
||||
<button
|
||||
aria-label="左右交換隊員"
|
||||
aria-label="左右交換球員"
|
||||
className="team-icon-button"
|
||||
disabled={!canArrangeMatch}
|
||||
type="button"
|
||||
@@ -391,11 +433,11 @@ function ScoreboardTeamPanel({
|
||||
<span>先攻</span>
|
||||
{currentServer ? (
|
||||
<small>
|
||||
發球位 {serviceCourt === 'left' ? '左' : serviceCourt === 'right' ? '右' : '-'}
|
||||
{currentReceiver ? ` / 接發 ${currentReceiver}` : ''}
|
||||
發球區:{serviceCourt === 'left' ? '左' : serviceCourt === 'right' ? '右' : '-'}
|
||||
{currentReceiver ? ` / 接發:${currentReceiver}` : ''}
|
||||
</small>
|
||||
) : (
|
||||
<small>選擇這一隊先攻</small>
|
||||
<small>點擊設定這一隊先攻</small>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
@@ -431,10 +473,11 @@ function ScoreboardTeamPanel({
|
||||
}
|
||||
|
||||
type TeamPickerModalProps = {
|
||||
currentLeftTeamId: number | null
|
||||
currentRightTeamId: number | null
|
||||
draftTeamIds: number[]
|
||||
currentSelectionOrder: string[]
|
||||
draftPlayers: string[]
|
||||
draftTargetScore: string
|
||||
group: RoundGroup
|
||||
selectablePlayers: string[]
|
||||
selectionCount: number
|
||||
sourceLabel: string
|
||||
targetDate: string
|
||||
@@ -442,14 +485,16 @@ type TeamPickerModalProps = {
|
||||
onClear: () => void
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
onToggleTeam: (teamId: number) => void
|
||||
onDraftTargetScoreChange: (value: string) => void
|
||||
onTogglePlayer: (playerName: string) => void
|
||||
}
|
||||
|
||||
function TeamPickerModal({
|
||||
currentLeftTeamId,
|
||||
currentRightTeamId,
|
||||
draftTeamIds,
|
||||
currentSelectionOrder,
|
||||
draftPlayers,
|
||||
draftTargetScore,
|
||||
group,
|
||||
selectablePlayers,
|
||||
selectionCount,
|
||||
sourceLabel,
|
||||
targetDate,
|
||||
@@ -457,41 +502,67 @@ function TeamPickerModal({
|
||||
onClear,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onToggleTeam,
|
||||
onDraftTargetScoreChange,
|
||||
onTogglePlayer,
|
||||
}: 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 (
|
||||
<div className="team-picker-overlay" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
aria-label="設定對戰隊伍"
|
||||
aria-label="設定隊伍"
|
||||
aria-modal="true"
|
||||
className="team-picker-shell"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
role="dialog"
|
||||
>
|
||||
<button aria-label="關閉選隊視窗" className="team-picker-close" type="button" onClick={onClose}>
|
||||
<button aria-label="關閉設定隊伍" className="team-picker-close" type="button" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div className="team-picker-ribbon">
|
||||
<span>{selectionCount >= 2 ? '已完成選擇' : '請選擇 2 隊'}</span>
|
||||
<span>{selectionCount >= 4 ? '已完成選擇' : '請依序選 4 位球員'}</span>
|
||||
</div>
|
||||
|
||||
<div className="team-picker-layout">
|
||||
<section className="team-picker-panel team-picker-list-panel">
|
||||
<div className="team-picker-title">
|
||||
<span className="team-picker-count">{selectionCount}/2</span>
|
||||
<span className="team-picker-count">{selectionCount}/4</span>
|
||||
<div>
|
||||
<strong>從這一組挑選要對打的隊伍</strong>
|
||||
<strong>依序選人後自動配隊</strong>
|
||||
<p>
|
||||
第 {group.id} 組 / {sourceLabel} / {targetDate || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="team-picker-config">
|
||||
<span>幾分獲勝</span>
|
||||
<input
|
||||
className="team-picker-score-input"
|
||||
inputMode="numeric"
|
||||
maxLength={2}
|
||||
type="text"
|
||||
value={draftTargetScore}
|
||||
onChange={(event) => onDraftTargetScoreChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="team-picker-list">
|
||||
{group.teams.map((team) => {
|
||||
const checked = draftTeamIds.includes(team.id)
|
||||
const selectedOrder = checked ? draftTeamIds.indexOf(team.id) + 1 : null
|
||||
{selectablePlayers.map((playerName) => {
|
||||
const checked = draftPlayers.includes(playerName)
|
||||
const selectedOrder = checked ? draftPlayers.indexOf(playerName) + 1 : null
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -500,16 +571,18 @@ function TeamPickerModal({
|
||||
? 'team-picker-option team-picker-option-active'
|
||||
: 'team-picker-option'
|
||||
}
|
||||
key={`team-option-${team.id}`}
|
||||
key={`player-option-${playerName}`}
|
||||
type="button"
|
||||
onClick={() => onToggleTeam(team.id)}
|
||||
onClick={() => onTogglePlayer(playerName)}
|
||||
>
|
||||
<span className="team-picker-checkbox">
|
||||
{checked ? String(selectedOrder) : ''}
|
||||
</span>
|
||||
<div className="team-picker-option-text">
|
||||
<strong>{getTeamDisplayName(team)}</strong>
|
||||
<small>隊伍編號 {team.id}</small>
|
||||
<strong>{playerName}</strong>
|
||||
<small>
|
||||
{selectedOrder ? `已選第 ${selectedOrder} 位` : '點擊加入目前配對'}
|
||||
</small>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
@@ -522,7 +595,7 @@ function TeamPickerModal({
|
||||
</button>
|
||||
<button
|
||||
className="team-picker-confirm"
|
||||
disabled={draftTeamIds.length !== 2}
|
||||
disabled={draftPlayers.length !== 4}
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
@@ -534,20 +607,17 @@ function TeamPickerModal({
|
||||
<aside className="team-picker-panel team-picker-side-panel">
|
||||
<div className="picked-team-list">
|
||||
{[0, 1].map((slotIndex) => {
|
||||
const teamId = draftTeamIds[slotIndex] ?? null
|
||||
const team = group.teams.find((item) => item.id === teamId) ?? null
|
||||
const isCurrent =
|
||||
teamId !== null &&
|
||||
teamId === (slotIndex === 0 ? currentLeftTeamId : currentRightTeamId)
|
||||
const teamLabel = draftTeams[slotIndex]
|
||||
const isCurrent = currentTeams[slotIndex] === teamLabel
|
||||
|
||||
return (
|
||||
<div className="picked-team-card" key={`picked-${slotIndex}`}>
|
||||
<span className="picked-team-index">{slotIndex + 1}</span>
|
||||
<div>
|
||||
<strong>{team ? getTeamDisplayName(team) : '尚未選擇'}</strong>
|
||||
<strong>{teamLabel}</strong>
|
||||
<small>
|
||||
{slotIndex === 0 ? '上方隊伍' : '下方隊伍'}
|
||||
{isCurrent ? ' / 目前使用中' : ''}
|
||||
{slotIndex === 0 ? '第 1、2 位自動成隊' : '第 3、4 位自動成隊'}
|
||||
{isCurrent ? ' / 目前場上配置' : ''}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -555,17 +625,12 @@ function TeamPickerModal({
|
||||
})}
|
||||
</div>
|
||||
|
||||
<label className="picker-mode-toggle">
|
||||
<input disabled type="checkbox" />
|
||||
<span>單打模式</span>
|
||||
</label>
|
||||
|
||||
<button className="team-picker-clear" type="button" onClick={onClear}>
|
||||
清空
|
||||
</button>
|
||||
|
||||
<p className="picker-side-hint">
|
||||
選好 2 隊後按確認,會直接帶入記分板的上方與下方位置。
|
||||
先選到的第 1、2 位會帶到上方隊伍,第 3、4 位會帶到下方隊伍。
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -603,7 +668,7 @@ function FinishDialog({
|
||||
<div className="finish-dialog-overlay" role="presentation">
|
||||
<div aria-modal="true" className="finish-dialog" role="dialog">
|
||||
<button
|
||||
aria-label="關閉結算視窗"
|
||||
aria-label="關閉比賽結算"
|
||||
className="finish-dialog-close"
|
||||
disabled={uploading}
|
||||
type="button"
|
||||
@@ -627,7 +692,7 @@ function FinishDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="finish-dialog-copy">要把這場戰績上傳到 DB 嗎?</p>
|
||||
<p className="finish-dialog-copy">要把這場戰績上傳到資料庫嗎?</p>
|
||||
|
||||
{error ? <p className="finish-dialog-error">{error}</p> : null}
|
||||
|
||||
@@ -646,7 +711,7 @@ function FinishDialog({
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{uploading ? '上傳中...' : '上傳戰績'}
|
||||
{uploading ? '上傳中...' : '確認上傳'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -662,6 +727,16 @@ function getPlayerNumber(teamSlot: 'top' | 'bottom', slot: PlayerSlot) {
|
||||
return slot === 'playerA' ? 4 : 3
|
||||
}
|
||||
|
||||
function sanitizeTargetScore(value: string) {
|
||||
const numeric = Number.parseInt(value.replace(/[^\d]/g, ''), 10)
|
||||
|
||||
if (Number.isNaN(numeric)) {
|
||||
return 21
|
||||
}
|
||||
|
||||
return Math.min(99, Math.max(1, numeric))
|
||||
}
|
||||
|
||||
function formatClock() {
|
||||
return new Date().toLocaleTimeString('zh-TW', {
|
||||
hour: '2-digit',
|
||||
|
||||
@@ -30,6 +30,11 @@ export type Matchup = {
|
||||
rightTeamId: number | null
|
||||
}
|
||||
|
||||
export type ActiveMatchup = {
|
||||
leftTeam: GroupTeam | null
|
||||
rightTeam: GroupTeam | null
|
||||
}
|
||||
|
||||
export type ScoreState = {
|
||||
scoreLeft: number
|
||||
scoreRight: number
|
||||
|
||||
Reference in New Issue
Block a user