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

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

@@ -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
12 34
</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',