調整設定隊伍流程並支援勝利分數設定

This commit is contained in:
2026-04-16 08:53:05 +08:00
parent e903d3ae52
commit a1e0e0f16e
4 changed files with 399 additions and 129 deletions

View File

@@ -347,8 +347,10 @@
gap: 14px; gap: 14px;
padding: 12px; padding: 12px;
border-radius: 22px; border-radius: 22px;
background: #060606; background:
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.26); 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 { .scoreboard-team-section {
@@ -370,7 +372,8 @@
min-height: 64px; min-height: 64px;
padding: 8px; padding: 8px;
border-radius: 4px; 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 { .scoreboard-name-chip {
@@ -380,8 +383,8 @@
min-height: 46px; min-height: 46px;
padding: 4px 8px; padding: 4px 8px;
border-radius: 4px; border-radius: 4px;
color: #111; color: #16342f;
background: rgba(255, 255, 255, 0.42); background: rgba(255, 255, 255, 0.7);
} }
.scoreboard-name-chip strong { .scoreboard-name-chip strong {
@@ -391,8 +394,8 @@
} }
.scoreboard-name-chip-serving { .scoreboard-name-chip-serving {
background: #c8f400; background: linear-gradient(180deg, #ebf8a4, #d6e164);
box-shadow: inset 0 0 0 2px rgba(34, 48, 0, 0.18); box-shadow: inset 0 0 0 2px rgba(111, 128, 27, 0.24);
} }
.team-number { .team-number {
@@ -405,7 +408,7 @@
font-family: var(--mono); font-family: var(--mono);
font-size: 1.15rem; font-size: 1.15rem;
color: #fff; color: #fff;
background: #06253e; background: linear-gradient(180deg, rgba(8, 47, 73, 0.96), rgba(10, 96, 84, 0.92));
border-radius: 4px; border-radius: 4px;
} }
@@ -421,8 +424,27 @@
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
font-size: 1.2rem; font-size: 1.2rem;
color: #111; color: #5b2f13;
background: #d0d0d0; 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 { .team-icon-button:disabled {
@@ -442,28 +464,41 @@
padding: 8px 10px; padding: 8px 10px;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
color: #fff; color: #f8f4ea;
background: #2f2f2f; 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 { .serve-lane:disabled {
cursor: default; 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 { .serve-lane small {
justify-self: end; justify-self: end;
color: rgba(255, 255, 255, 0.72); color: rgba(248, 244, 234, 0.72);
} }
.serve-lane-locked { .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 { .serve-lane-box {
width: 24px; width: 24px;
height: 24px; height: 24px;
border-radius: 4px; border-radius: 4px;
background: #f7f7f7; background: linear-gradient(180deg, #fff8e8, #f0dfbd);
} }
.score-panel-surface { .score-panel-surface {
@@ -479,9 +514,10 @@
transform 0.16s ease, transform 0.16s ease,
box-shadow 0.16s ease; box-shadow 0.16s ease;
background: background:
linear-gradient(transparent 0 40%, rgba(0, 0, 0, 0.24) 40% 60%, transparent 60% 100%), linear-gradient(transparent 0 40%, rgba(11, 39, 34, 0.18) 40% 60%, transparent 60% 100%),
linear-gradient(90deg, transparent 0 40%, rgba(0, 0, 0, 0.24) 40% 60%, transparent 60% 100%), linear-gradient(90deg, transparent 0 40%, rgba(11, 39, 34, 0.18) 40% 60%, transparent 60% 100%),
#2f2f2f; 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 { .score-panel-surface-live {
@@ -490,14 +526,17 @@
.score-panel-surface-live:hover { .score-panel-surface-live:hover {
transform: translateY(-1px); 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 { .score-panel-value {
font-family: var(--mono); font-family: var(--mono);
font-size: clamp(4.4rem, 11vw, 7.2rem); font-size: clamp(4.4rem, 11vw, 7.2rem);
line-height: 1; line-height: 1;
color: #d5ea02; color: #0d544a;
text-shadow: 0 2px 0 rgba(255, 255, 255, 0.42);
} }
.scoreboard-center-banner { .scoreboard-center-banner {
@@ -509,13 +548,13 @@
.scoreboard-center-banner p { .scoreboard-center-banner p {
font-size: clamp(1.5rem, 3vw, 2.3rem); font-size: clamp(1.5rem, 3vw, 2.3rem);
color: #fff; color: #fff7e9;
text-align: center; text-align: center;
} }
.scoreboard-center-banner small { .scoreboard-center-banner small {
font-size: 0.92rem; font-size: 0.92rem;
color: rgba(255, 255, 255, 0.7); color: rgba(255, 247, 233, 0.76);
} }
.scoreboard-rail { .scoreboard-rail {
@@ -536,8 +575,27 @@
border-radius: 10px; border-radius: 10px;
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
color: #111; color: #5b2f13;
background: #d0d0d0; 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 { .rail-clock {
@@ -547,8 +605,10 @@
border-radius: 12px; border-radius: 12px;
font-family: var(--mono); font-family: var(--mono);
font-size: 1.7rem; font-size: 1.7rem;
color: #fff; color: #fff7e9;
background: #060606; 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 { .rail-pill {
@@ -560,11 +620,30 @@
font-size: 1rem; font-size: 1rem;
color: #4a2e1d; color: #4a2e1d;
background: linear-gradient(180deg, #fff0c7, #f8c870); 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 { .rail-pill-danger {
color: #fff; color: #fff;
background: linear-gradient(180deg, #d41d1d, #a91212); background: linear-gradient(180deg, #d95a44, #b53a28);
} }
.rail-pill-muted { .rail-pill-muted {
@@ -602,6 +681,25 @@
font-size: 2.4rem; font-size: 2.4rem;
color: #b34e3a; color: #b34e3a;
background: linear-gradient(180deg, #ffe5bf, #f0bd7c); 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 { .team-picker-ribbon {
@@ -658,6 +756,27 @@
margin-top: 4px; 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 { .team-picker-list {
display: grid; display: grid;
gap: 14px; gap: 14px;
@@ -678,6 +797,17 @@
text-align: left; text-align: left;
color: #2e231b; color: #2e231b;
background: rgba(255, 249, 238, 0.92); 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 { .team-picker-option-active {
@@ -730,6 +860,29 @@
padding: 16px 18px; padding: 16px 18px;
cursor: pointer; cursor: pointer;
font: inherit; 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 { .team-picker-ghost {
@@ -829,6 +982,25 @@
font-size: 1.8rem; font-size: 1.8rem;
color: #b34e3a; color: #b34e3a;
background: linear-gradient(180deg, #ffe5bf, #f0bd7c); 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 { .finish-score {

View File

@@ -17,11 +17,11 @@ import { HistoryPage } from './pages/HistoryPage'
import { ScoreboardPage } from './pages/ScoreboardPage' import { ScoreboardPage } from './pages/ScoreboardPage'
import { TeamSelectionPage } from './pages/TeamSelectionPage' import { TeamSelectionPage } from './pages/TeamSelectionPage'
import type { import type {
ActiveMatchup,
GroupTeam, GroupTeam,
HistoryUploadPayload, HistoryUploadPayload,
LoadStatus, LoadStatus,
MatchHistoryItem, MatchHistoryItem,
Matchup,
PointHistoryEntry, PointHistoryEntry,
RoundGroup, RoundGroup,
ScoreSide, ScoreSide,
@@ -75,9 +75,9 @@ function App() {
const [loadStatus, setLoadStatus] = useState<LoadStatus>('idle') const [loadStatus, setLoadStatus] = useState<LoadStatus>('idle')
const [loadMessage, setLoadMessage] = useState('') const [loadMessage, setLoadMessage] = useState('')
const [selectedGroupId, setSelectedGroupId] = useState<number | null>(null) const [selectedGroupId, setSelectedGroupId] = useState<number | null>(null)
const [matchup, setMatchup] = useState<Matchup>({ const [activeMatchup, setActiveMatchup] = useState<ActiveMatchup>({
leftTeamId: null, leftTeam: null,
rightTeamId: null, rightTeam: null,
}) })
const [scoreState, setScoreState] = useState<ScoreState>(initialScoreState) const [scoreState, setScoreState] = useState<ScoreState>(initialScoreState)
const [scoreHistory, setScoreHistory] = useState<ScoreSnapshot[]>([]) const [scoreHistory, setScoreHistory] = useState<ScoreSnapshot[]>([])
@@ -94,10 +94,8 @@ function App() {
const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput]) const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput])
const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput]) const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput])
const selectedGroup = groups.find((group) => group.id === selectedGroupId) ?? null const selectedGroup = groups.find((group) => group.id === selectedGroupId) ?? null
const leftTeam = const leftTeam = activeMatchup.leftTeam
selectedGroup?.teams.find((team) => team.id === matchup.leftTeamId) ?? null const rightTeam = activeMatchup.rightTeam
const rightTeam =
selectedGroup?.teams.find((team) => team.id === matchup.rightTeamId) ?? null
useEffect(() => { useEffect(() => {
window.localStorage.setItem(STORAGE_KEYS.targetDate, targetDate) window.localStorage.setItem(STORAGE_KEYS.targetDate, targetDate)
@@ -132,19 +130,26 @@ function App() {
const secondTeam = nextGroup?.teams[1] ?? null const secondTeam = nextGroup?.teams[1] ?? null
setSelectedGroupId(nextGroup?.id ?? null) setSelectedGroupId(nextGroup?.id ?? null)
setMatchup({ setActiveMatchup({
leftTeamId: firstTeam?.id ?? null, leftTeam: firstTeam,
rightTeamId: secondTeam?.id ?? null, rightTeam: secondTeam,
}) })
resetScoring() resetScoring()
} }
const applyMatchup = (leftTeamId: number, rightTeamId: number) => { const applyMatchup = (
setMatchup({ leftTeam: GroupTeam,
leftTeamId, rightTeam: GroupTeam,
rightTeamId, targetScore: number,
) => {
setActiveMatchup({
leftTeam,
rightTeam,
})
resetScoring({
...initialScoreState,
targetScore,
}) })
resetScoring()
} }
const loadGroupsFromDb = async () => { const loadGroupsFromDb = async () => {
@@ -163,7 +168,7 @@ function App() {
if (!record) { if (!record) {
setGroups([]) setGroups([])
setSelectedGroupId(null) setSelectedGroupId(null)
setMatchup({ leftTeamId: null, rightTeamId: null }) setActiveMatchup({ leftTeam: null, rightTeam: null })
setGroupSource('idle') setGroupSource('idle')
setLoadStatus('empty') setLoadStatus('empty')
setLoadMessage('指定日期沒有資料,請改用手動配對。') setLoadMessage('指定日期沒有資料,請改用手動配對。')
@@ -181,7 +186,7 @@ function App() {
} catch (error) { } catch (error) {
setGroups([]) setGroups([])
setSelectedGroupId(null) setSelectedGroupId(null)
setMatchup({ leftTeamId: null, rightTeamId: null }) setActiveMatchup({ leftTeam: null, rightTeam: null })
setGroupSource('idle') setGroupSource('idle')
setLoadStatus('error') setLoadStatus('error')
setLoadMessage(error instanceof Error ? error.message : '讀取資料失敗。') setLoadMessage(error instanceof Error ? error.message : '讀取資料失敗。')
@@ -192,7 +197,7 @@ function App() {
if (parsedAreaA.length === 0 || parsedAreaB.length === 0) { if (parsedAreaA.length === 0 || parsedAreaB.length === 0) {
setGroups([]) setGroups([])
setSelectedGroupId(null) setSelectedGroupId(null)
setMatchup({ leftTeamId: null, rightTeamId: null }) setActiveMatchup({ leftTeam: null, rightTeam: null })
setGroupSource('idle') setGroupSource('idle')
setLoadStatus('error') setLoadStatus('error')
setLoadMessage('A 區與 B 區至少都要有 1 位成員。') setLoadMessage('A 區與 B 區至少都要有 1 位成員。')
@@ -212,9 +217,9 @@ function App() {
return return
} }
setMatchup((current) => ({ setActiveMatchup((current) => ({
leftTeamId: current.rightTeamId, leftTeam: current.rightTeam,
rightTeamId: current.leftTeamId, rightTeam: current.leftTeam,
})) }))
setScoreState((current) => ({ setScoreState((current) => ({
@@ -487,13 +492,13 @@ function App() {
path="/scoreboard" path="/scoreboard"
element={ element={
<ScoreboardPage <ScoreboardPage
currentSelectionOrder={getSelectionOrder(leftTeam, rightTeam)}
finishDialogError={settlement.error} finishDialogError={settlement.error}
finishDialogOpen={settlement.open} finishDialogOpen={settlement.open}
finishDialogUploading={settlement.uploading} finishDialogUploading={settlement.uploading}
groupSource={groupSource} groupSource={groupSource}
hasRecordedPoint={pointLog.length > 0} hasRecordedPoint={pointLog.length > 0}
leftTeam={leftTeam} leftTeam={leftTeam}
matchup={matchup}
rightTeam={rightTeam} rightTeam={rightTeam}
scoreState={scoreState} scoreState={scoreState}
selectedGroup={selectedGroup} selectedGroup={selectedGroup}
@@ -587,6 +592,19 @@ function formatPlayedAt(timestamp: number) {
return new Date(timestamp * 1000).toLocaleString('zh-TW', { hour12: false }) 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) { function loadStoredText(storageKey: string, fallback: string) {
const value = window.localStorage.getItem(storageKey) const value = window.localStorage.getItem(storageKey)
return value && value.trim() ? value : fallback return value && value.trim() ? value : fallback

View File

@@ -10,7 +10,6 @@ import {
import type { import type {
CourtSide, CourtSide,
GroupTeam, GroupTeam,
Matchup,
PlayerSlot, PlayerSlot,
RoundGroup, RoundGroup,
ScoreSide, ScoreSide,
@@ -18,18 +17,22 @@ import type {
} from '../types' } from '../types'
type ScoreboardPageProps = { type ScoreboardPageProps = {
currentSelectionOrder: string[]
finishDialogError: string finishDialogError: string
finishDialogOpen: boolean finishDialogOpen: boolean
finishDialogUploading: boolean finishDialogUploading: boolean
groupSource: 'idle' | 'db' | 'manual' groupSource: 'idle' | 'db' | 'manual'
hasRecordedPoint: boolean hasRecordedPoint: boolean
leftTeam: GroupTeam | null leftTeam: GroupTeam | null
matchup: Matchup
rightTeam: GroupTeam | null rightTeam: GroupTeam | null
scoreState: ScoreState scoreState: ScoreState
selectedGroup: RoundGroup | null selectedGroup: RoundGroup | null
targetDate: string targetDate: string
onApplyMatchup: (leftTeamId: number, rightTeamId: number) => void onApplyMatchup: (
leftTeam: GroupTeam,
rightTeam: GroupTeam,
targetScore: number,
) => void
onCloseFinishDialog: () => void onCloseFinishDialog: () => void
onConfirmUpload: () => void onConfirmUpload: () => void
onOpenFinishDialog: () => void onOpenFinishDialog: () => void
@@ -42,13 +45,13 @@ type ScoreboardPageProps = {
} }
export function ScoreboardPage({ export function ScoreboardPage({
currentSelectionOrder,
finishDialogError, finishDialogError,
finishDialogOpen, finishDialogOpen,
finishDialogUploading, finishDialogUploading,
groupSource, groupSource,
hasRecordedPoint, hasRecordedPoint,
leftTeam, leftTeam,
matchup,
rightTeam, rightTeam,
scoreState, scoreState,
selectedGroup, selectedGroup,
@@ -65,7 +68,8 @@ export function ScoreboardPage({
onUndoLastPoint, onUndoLastPoint,
}: ScoreboardPageProps) { }: ScoreboardPageProps) {
const [pickerOpen, setPickerOpen] = useState(false) 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()) const [clock, setClock] = useState(() => formatClock())
useEffect(() => { useEffect(() => {
@@ -76,6 +80,29 @@ export function ScoreboardPage({
return () => window.clearInterval(timer) 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 canArrangeMatch = !hasRecordedPoint
const canScore = scoreState.serving !== null const canScore = scoreState.serving !== null
@@ -134,9 +161,9 @@ export function ScoreboardPage({
<section className="page-grid"> <section className="page-grid">
<article className="panel panel-hero"> <article className="panel panel-hero">
<p className="panel-kicker">Step 3</p> <p className="panel-kicker">Step 3</p>
<h2></h2> <h2></h2>
<p className="panel-copy"> <p className="panel-copy">
</p> </p>
<Link className="primary-button inline-link" to="/teams"> <Link className="primary-button inline-link" to="/teams">
@@ -152,46 +179,59 @@ export function ScoreboardPage({
: '尚未設定對戰隊伍' : '尚未設定對戰隊伍'
const openPicker = () => { const openPicker = () => {
const next = [matchup.leftTeamId, matchup.rightTeamId].filter( setDraftPlayers(currentSelectionOrder.filter((player) => selectablePlayers.includes(player)))
(value): value is number => value !== null, setDraftTargetScore(String(scoreState.targetScore))
)
setDraftTeamIds(next)
setPickerOpen(true) setPickerOpen(true)
} }
const toggleDraftTeam = (teamId: number) => { const toggleDraftPlayer = (playerName: string) => {
setDraftTeamIds((current) => { setDraftPlayers((current) => {
if (current.includes(teamId)) { if (current.includes(playerName)) {
return current.filter((value) => value !== teamId) return current.filter((value) => value !== playerName)
} }
if (current.length >= 2) { if (current.length >= 4) {
return [current[1], teamId] return current
} }
return [...current, teamId] return [...current, playerName]
}) })
} }
const confirmDraftTeams = () => { const confirmDraftTeams = () => {
if (draftTeamIds.length !== 2) { if (draftPlayers.length !== 4) {
return 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) setPickerOpen(false)
} }
const autoPickDraftTeams = () => { const autoPickDraftPlayers = () => {
const shuffled = [...selectedGroup.teams] const shuffled = [...selectablePlayers]
for (let index = shuffled.length - 1; index > 0; index -= 1) { for (let index = shuffled.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(Math.random() * (index + 1)) const swapIndex = Math.floor(Math.random() * (index + 1))
;[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]] ;[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]]
} }
setDraftTeamIds(shuffled.slice(0, 2).map((team) => team.id)) setDraftPlayers(shuffled.slice(0, 4))
} }
return ( return (
@@ -215,13 +255,13 @@ export function ScoreboardPage({
/> />
<div className="scoreboard-center-banner"> <div className="scoreboard-center-banner">
<p>{scoreState.serving === null ? '請設定發球方' : '點擊分數開始計分'}</p> <p>{scoreState.serving === null ? '請設定發球方' : '點擊分數即可記分'}</p>
<small> <small>
{scoreState.serving === null {scoreState.serving === null
? '先在上方或下方按下先攻' ? `本場 ${scoreState.targetScore} 分獲勝`
: `目前發球:${currentServer?.name ?? '-'}${ : `發球:${currentServer?.name ?? '-'}${
currentReceiver ? ` / 接發:${currentReceiver.name}` : '' currentReceiver ? ` / 接發:${currentReceiver.name}` : ''
}`} } / 目標 ${scoreState.targetScore}`}
</small> </small>
</div> </div>
@@ -265,18 +305,20 @@ export function ScoreboardPage({
{pickerOpen ? ( {pickerOpen ? (
<TeamPickerModal <TeamPickerModal
currentLeftTeamId={matchup.leftTeamId} currentSelectionOrder={currentSelectionOrder}
currentRightTeamId={matchup.rightTeamId} draftPlayers={draftPlayers}
draftTeamIds={draftTeamIds} draftTargetScore={draftTargetScore}
group={selectedGroup} group={selectedGroup}
selectionCount={draftTeamIds.length} selectablePlayers={selectablePlayers}
selectionCount={draftPlayers.length}
sourceLabel={groupSource === 'db' ? 'DB' : groupSource === 'manual' ? '手動' : '-'} sourceLabel={groupSource === 'db' ? 'DB' : groupSource === 'manual' ? '手動' : '-'}
targetDate={targetDate} targetDate={targetDate}
onAutoPick={autoPickDraftTeams} onAutoPick={autoPickDraftPlayers}
onClear={() => setDraftTeamIds([])} onClear={() => setDraftPlayers([])}
onClose={() => setPickerOpen(false)} onClose={() => setPickerOpen(false)}
onConfirm={confirmDraftTeams} onConfirm={confirmDraftTeams}
onToggleTeam={toggleDraftTeam} onDraftTargetScoreChange={setDraftTargetScore}
onTogglePlayer={toggleDraftPlayer}
/> />
) : null} ) : null}
@@ -284,10 +326,10 @@ export function ScoreboardPage({
<FinishDialog <FinishDialog
error={finishDialogError} error={finishDialogError}
leftScore={scoreState.scoreLeft} leftScore={scoreState.scoreLeft}
leftTeamName={leftTeam ? getTeamDisplayName(leftTeam) : '未設定'} leftTeamName={leftTeam ? getTeamDisplayName(leftTeam) : '未設定'}
matchupLabel={matchupLabel} matchupLabel={matchupLabel}
rightScore={scoreState.scoreRight} rightScore={scoreState.scoreRight}
rightTeamName={rightTeam ? getTeamDisplayName(rightTeam) : '未設定'} rightTeamName={rightTeam ? getTeamDisplayName(rightTeam) : '未設定'}
uploading={finishDialogUploading} uploading={finishDialogUploading}
onClose={onCloseFinishDialog} onClose={onCloseFinishDialog}
onConfirm={onConfirmUpload} onConfirm={onConfirmUpload}
@@ -366,7 +408,7 @@ function ScoreboardTeamPanel({
</button> </button>
<button <button
aria-label="左右交換員" aria-label="左右交換員"
className="team-icon-button" className="team-icon-button"
disabled={!canArrangeMatch} disabled={!canArrangeMatch}
type="button" type="button"
@@ -391,11 +433,11 @@ function ScoreboardTeamPanel({
<span></span> <span></span>
{currentServer ? ( {currentServer ? (
<small> <small>
{serviceCourt === 'left' ? '左' : serviceCourt === 'right' ? '右' : '-'} {serviceCourt === 'left' ? '左' : serviceCourt === 'right' ? '右' : '-'}
{currentReceiver ? ` / 接發 ${currentReceiver}` : ''} {currentReceiver ? ` / 接發${currentReceiver}` : ''}
</small> </small>
) : ( ) : (
<small></small> <small></small>
)} )}
</button> </button>
) )
@@ -431,10 +473,11 @@ function ScoreboardTeamPanel({
} }
type TeamPickerModalProps = { type TeamPickerModalProps = {
currentLeftTeamId: number | null currentSelectionOrder: string[]
currentRightTeamId: number | null draftPlayers: string[]
draftTeamIds: number[] draftTargetScore: string
group: RoundGroup group: RoundGroup
selectablePlayers: string[]
selectionCount: number selectionCount: number
sourceLabel: string sourceLabel: string
targetDate: string targetDate: string
@@ -442,14 +485,16 @@ type TeamPickerModalProps = {
onClear: () => void onClear: () => void
onClose: () => void onClose: () => void
onConfirm: () => void onConfirm: () => void
onToggleTeam: (teamId: number) => void onDraftTargetScoreChange: (value: string) => void
onTogglePlayer: (playerName: string) => void
} }
function TeamPickerModal({ function TeamPickerModal({
currentLeftTeamId, currentSelectionOrder,
currentRightTeamId, draftPlayers,
draftTeamIds, draftTargetScore,
group, group,
selectablePlayers,
selectionCount, selectionCount,
sourceLabel, sourceLabel,
targetDate, targetDate,
@@ -457,41 +502,67 @@ function TeamPickerModal({
onClear, onClear,
onClose, onClose,
onConfirm, onConfirm,
onToggleTeam, onDraftTargetScoreChange,
onTogglePlayer,
}: 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
aria-label="設定對戰隊伍" aria-label="設定隊伍"
aria-modal="true" aria-modal="true"
className="team-picker-shell" className="team-picker-shell"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
role="dialog" 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> </button>
<div className="team-picker-ribbon"> <div className="team-picker-ribbon">
<span>{selectionCount >= 2 ? '已完成選擇' : '請選擇 2 隊'}</span> <span>{selectionCount >= 4 ? '已完成選擇' : '請依序選 4 位球員'}</span>
</div> </div>
<div className="team-picker-layout"> <div className="team-picker-layout">
<section className="team-picker-panel team-picker-list-panel"> <section className="team-picker-panel team-picker-list-panel">
<div className="team-picker-title"> <div className="team-picker-title">
<span className="team-picker-count">{selectionCount}/2</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">
<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"> <div className="team-picker-list">
{group.teams.map((team) => { {selectablePlayers.map((playerName) => {
const checked = draftTeamIds.includes(team.id) const checked = draftPlayers.includes(playerName)
const selectedOrder = checked ? draftTeamIds.indexOf(team.id) + 1 : null const selectedOrder = checked ? draftPlayers.indexOf(playerName) + 1 : null
return ( return (
<button <button
@@ -500,16 +571,18 @@ function TeamPickerModal({
? 'team-picker-option team-picker-option-active' ? 'team-picker-option team-picker-option-active'
: 'team-picker-option' : 'team-picker-option'
} }
key={`team-option-${team.id}`} key={`player-option-${playerName}`}
type="button" type="button"
onClick={() => onToggleTeam(team.id)} onClick={() => onTogglePlayer(playerName)}
> >
<span className="team-picker-checkbox"> <span className="team-picker-checkbox">
{checked ? String(selectedOrder) : ''} {checked ? String(selectedOrder) : ''}
</span> </span>
<div className="team-picker-option-text"> <div className="team-picker-option-text">
<strong>{getTeamDisplayName(team)}</strong> <strong>{playerName}</strong>
<small> {team.id}</small> <small>
{selectedOrder ? `已選第 ${selectedOrder}` : '點擊加入目前配對'}
</small>
</div> </div>
</button> </button>
) )
@@ -522,7 +595,7 @@ function TeamPickerModal({
</button> </button>
<button <button
className="team-picker-confirm" className="team-picker-confirm"
disabled={draftTeamIds.length !== 2} disabled={draftPlayers.length !== 4}
type="button" type="button"
onClick={onConfirm} onClick={onConfirm}
> >
@@ -534,20 +607,17 @@ function TeamPickerModal({
<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="picked-team-list">
{[0, 1].map((slotIndex) => { {[0, 1].map((slotIndex) => {
const teamId = draftTeamIds[slotIndex] ?? null const teamLabel = draftTeams[slotIndex]
const team = group.teams.find((item) => item.id === teamId) ?? null const isCurrent = currentTeams[slotIndex] === teamLabel
const isCurrent =
teamId !== null &&
teamId === (slotIndex === 0 ? currentLeftTeamId : currentRightTeamId)
return ( return (
<div className="picked-team-card" key={`picked-${slotIndex}`}> <div className="picked-team-card" key={`picked-${slotIndex}`}>
<span className="picked-team-index">{slotIndex + 1}</span> <span className="picked-team-index">{slotIndex + 1}</span>
<div> <div>
<strong>{team ? getTeamDisplayName(team) : '尚未選擇'}</strong> <strong>{teamLabel}</strong>
<small> <small>
{slotIndex === 0 ? '上方隊伍' : '下方隊伍'} {slotIndex === 0 ? '第 1、2 位自動成隊' : '第 3、4 位自動成隊'}
{isCurrent ? ' / 目前使用中' : ''} {isCurrent ? ' / 目前場上配置' : ''}
</small> </small>
</div> </div>
</div> </div>
@@ -555,17 +625,12 @@ function TeamPickerModal({
})} })}
</div> </div>
<label className="picker-mode-toggle">
<input disabled type="checkbox" />
<span></span>
</label>
<button className="team-picker-clear" type="button" onClick={onClear}> <button className="team-picker-clear" type="button" onClick={onClear}>
</button> </button>
<p className="picker-side-hint"> <p className="picker-side-hint">
2 12 34
</p> </p>
</aside> </aside>
</div> </div>
@@ -603,7 +668,7 @@ function FinishDialog({
<div className="finish-dialog-overlay" role="presentation"> <div className="finish-dialog-overlay" role="presentation">
<div aria-modal="true" className="finish-dialog" role="dialog"> <div aria-modal="true" className="finish-dialog" role="dialog">
<button <button
aria-label="關閉結算視窗" aria-label="關閉比賽結算"
className="finish-dialog-close" className="finish-dialog-close"
disabled={uploading} disabled={uploading}
type="button" type="button"
@@ -627,7 +692,7 @@ function FinishDialog({
</div> </div>
</div> </div>
<p className="finish-dialog-copy"> DB </p> <p className="finish-dialog-copy"></p>
{error ? <p className="finish-dialog-error">{error}</p> : null} {error ? <p className="finish-dialog-error">{error}</p> : null}
@@ -646,7 +711,7 @@ function FinishDialog({
type="button" type="button"
onClick={onConfirm} onClick={onConfirm}
> >
{uploading ? '上傳中...' : '上傳戰績'} {uploading ? '上傳中...' : '確認上傳'}
</button> </button>
</div> </div>
</div> </div>
@@ -662,6 +727,16 @@ function getPlayerNumber(teamSlot: 'top' | 'bottom', slot: PlayerSlot) {
return slot === 'playerA' ? 4 : 3 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() { function formatClock() {
return new Date().toLocaleTimeString('zh-TW', { return new Date().toLocaleTimeString('zh-TW', {
hour: '2-digit', hour: '2-digit',

View File

@@ -30,6 +30,11 @@ export type Matchup = {
rightTeamId: number | null rightTeamId: number | null
} }
export type ActiveMatchup = {
leftTeam: GroupTeam | null
rightTeam: GroupTeam | null
}
export type ScoreState = { export type ScoreState = {
scoreLeft: number scoreLeft: number
scoreRight: number scoreRight: number