功能:首頁新增其他區名單,可在記分板臨時選上場但不參與配對
根本原因: 現場常有臨時來打球的人,不屬於 A、B 區的正規輪替名單;原本要讓他們上場只能把名字塞進 A 或 B 區,但這樣會被排進對戰組合,打亂正規配對。 影響: 首頁 A、B 區下方新增跨整行的「其他區名單」輸入框,內容存 localStorage 且不會被資料庫載入蓋掉;分組配對完全不使用其他區。記分板選隊伍時,其他區的人排在清單最後、名字旁掛「其他區」徽章,可手動勾選上場(含與正規名單混搭),今日場次照計;「自動選擇」不會選到他們,自動輪替仍以正規名單為準。 修法: App 新增 areaOtherInput 狀態與 localStorage 持久化,parseRoster 後以 extraPlayers 傳入 ScoreboardPage;selectablePlayers 併入其他區人選並用 extraPlayerSet 標記,autoPickDraftPlayers 過濾掉其他區,TeamPickerModal 依標記顯示徽章;TeamSelectionPage 新增第三個輸入欄位與說明文字。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+21
@@ -319,6 +319,15 @@
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
/* 其他區名單跨滿整行,放在 A/B 區下方。 */
|
||||
.field-area-other {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.field-area-other textarea {
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -1501,6 +1510,18 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.team-picker-extra-badge {
|
||||
margin-left: 6px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.68rem;
|
||||
font-style: normal;
|
||||
vertical-align: 2px;
|
||||
color: #8a4d12;
|
||||
background: rgba(248, 168, 45, 0.18);
|
||||
box-shadow: inset 0 0 0 1px rgba(199, 108, 44, 0.32);
|
||||
}
|
||||
|
||||
.team-picker-option strong,
|
||||
.preset-team-card strong {
|
||||
display: block;
|
||||
|
||||
+15
@@ -47,6 +47,7 @@ import type {
|
||||
const STORAGE_KEYS = {
|
||||
areaA: 'badminton-scoreboard::area-a',
|
||||
areaB: 'badminton-scoreboard::area-b',
|
||||
areaOther: 'badminton-scoreboard::area-other',
|
||||
history: 'badminton-scoreboard::history',
|
||||
playCounts: 'badminton-scoreboard::play-counts',
|
||||
} as const
|
||||
@@ -123,6 +124,10 @@ function App() {
|
||||
const [areaBInput, setAreaBInput] = useState(() =>
|
||||
loadStoredText(STORAGE_KEYS.areaB, defaultAreaB.join('\n')),
|
||||
)
|
||||
// 其他區:不參與分組配對,但會帶進記分板讓臨時上場的人可以被選到。
|
||||
const [areaOtherInput, setAreaOtherInput] = useState(() =>
|
||||
loadStoredText(STORAGE_KEYS.areaOther, ''),
|
||||
)
|
||||
const [groups, setGroups] = useState<RoundGroup[]>([])
|
||||
const [groupSource, setGroupSource] = useState<'idle' | 'db' | 'manual'>('idle')
|
||||
const [loadStatus, setLoadStatus] = useState<LoadStatus>('idle')
|
||||
@@ -163,6 +168,7 @@ function App() {
|
||||
|
||||
const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput])
|
||||
const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput])
|
||||
const parsedAreaOther = useMemo(() => parseRoster(areaOtherInput), [areaOtherInput])
|
||||
const selectedGroup = groups.find((group) => group.id === selectedGroupId) ?? null
|
||||
const leftTeam = activeMatchup.leftTeam
|
||||
const rightTeam = activeMatchup.rightTeam
|
||||
@@ -177,6 +183,10 @@ function App() {
|
||||
window.localStorage.setItem(STORAGE_KEYS.areaB, areaBInput)
|
||||
}, [areaBInput])
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(STORAGE_KEYS.areaOther, areaOtherInput)
|
||||
}, [areaOtherInput])
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(STORAGE_KEYS.history, JSON.stringify(history))
|
||||
}, [history])
|
||||
@@ -1068,6 +1078,7 @@ function App() {
|
||||
<TeamSelectionPage
|
||||
areaAInput={areaAInput}
|
||||
areaBInput={areaBInput}
|
||||
areaOtherInput={areaOtherInput}
|
||||
groups={groups}
|
||||
groupSource={groupSource}
|
||||
loadMessage={loadMessage}
|
||||
@@ -1075,6 +1086,7 @@ function App() {
|
||||
targetDate={targetDate}
|
||||
onAreaAInputChange={setAreaAInput}
|
||||
onAreaBInputChange={setAreaBInput}
|
||||
onAreaOtherInputChange={setAreaOtherInput}
|
||||
onGenerateManualGroups={generateManualGroups}
|
||||
onLoadGroupsFromDb={() => void loadGroupsFromDb()}
|
||||
onTargetDateChange={setTargetDate}
|
||||
@@ -1088,6 +1100,7 @@ function App() {
|
||||
<TeamSelectionPage
|
||||
areaAInput={areaAInput}
|
||||
areaBInput={areaBInput}
|
||||
areaOtherInput={areaOtherInput}
|
||||
groups={groups}
|
||||
groupSource={groupSource}
|
||||
loadMessage={loadMessage}
|
||||
@@ -1095,6 +1108,7 @@ function App() {
|
||||
targetDate={targetDate}
|
||||
onAreaAInputChange={setAreaAInput}
|
||||
onAreaBInputChange={setAreaBInput}
|
||||
onAreaOtherInputChange={setAreaOtherInput}
|
||||
onGenerateManualGroups={generateManualGroups}
|
||||
onLoadGroupsFromDb={() => void loadGroupsFromDb()}
|
||||
onTargetDateChange={setTargetDate}
|
||||
@@ -1107,6 +1121,7 @@ function App() {
|
||||
element={
|
||||
<ScoreboardPage
|
||||
currentSelectionOrder={getSelectionOrder(leftTeam, rightTeam)}
|
||||
extraPlayers={parsedAreaOther}
|
||||
finishDialogError={settlement.error}
|
||||
finishDialogOpen={settlement.open}
|
||||
finishDialogUploading={settlement.uploading}
|
||||
|
||||
@@ -39,6 +39,7 @@ const SPEECH_NAME_MAP: Record<string, string> = {
|
||||
|
||||
type ScoreboardPageProps = {
|
||||
currentSelectionOrder: string[]
|
||||
extraPlayers: string[]
|
||||
finishDialogError: string
|
||||
finishDialogOpen: boolean
|
||||
finishDialogUploading: boolean
|
||||
@@ -92,6 +93,7 @@ type ScoreboardPageProps = {
|
||||
|
||||
export function ScoreboardPage({
|
||||
currentSelectionOrder,
|
||||
extraPlayers,
|
||||
finishDialogError,
|
||||
finishDialogOpen,
|
||||
finishDialogUploading,
|
||||
@@ -189,8 +191,37 @@ export function ScoreboardPage({
|
||||
}
|
||||
})
|
||||
|
||||
// 其他區的人排在正規名單後面,一樣可以被選上場。
|
||||
extraPlayers.forEach((player) => {
|
||||
if (!seen.has(player)) {
|
||||
seen.add(player)
|
||||
players.push(player)
|
||||
}
|
||||
})
|
||||
|
||||
return players
|
||||
}, [selectedGroup])
|
||||
}, [selectedGroup, extraPlayers])
|
||||
|
||||
// 標記哪些人來自其他區:不參與自動選擇,選人面板上也會標示。
|
||||
const extraPlayerSet = useMemo(() => {
|
||||
if (!selectedGroup) {
|
||||
return new Set<string>()
|
||||
}
|
||||
|
||||
const groupPlayers = new Set<string>()
|
||||
|
||||
selectedGroup.teams.forEach((team) => {
|
||||
if (!team.isPlaceholderA) {
|
||||
groupPlayers.add(team.playerA)
|
||||
}
|
||||
|
||||
if (!team.isPlaceholderB) {
|
||||
groupPlayers.add(team.playerB)
|
||||
}
|
||||
})
|
||||
|
||||
return new Set(extraPlayers.filter((player) => !groupPlayers.has(player)))
|
||||
}, [selectedGroup, extraPlayers])
|
||||
|
||||
const presetTeams = useMemo(
|
||||
() =>
|
||||
@@ -500,8 +531,9 @@ export function ScoreboardPage({
|
||||
}
|
||||
|
||||
const autoPickDraftPlayers = () => {
|
||||
// 其他區的人只能手動勾選,自動選擇留給正規輪替的名單。
|
||||
const shuffled = selectablePlayers.filter(
|
||||
(player) => !AUTO_PICK_EXCLUDED_NAMES.has(player),
|
||||
(player) => !AUTO_PICK_EXCLUDED_NAMES.has(player) && !extraPlayerSet.has(player),
|
||||
)
|
||||
|
||||
for (let index = shuffled.length - 1; index > 0; index -= 1) {
|
||||
@@ -697,6 +729,7 @@ export function ScoreboardPage({
|
||||
<TeamPickerModal
|
||||
draftPlayers={draftPlayers}
|
||||
draftTargetScore={draftTargetScore}
|
||||
extraPlayerSet={extraPlayerSet}
|
||||
group={selectedGroup}
|
||||
playCounts={playCounts}
|
||||
presetTeams={presetTeams}
|
||||
@@ -886,6 +919,7 @@ function ScoreboardTeamPanel({
|
||||
|
||||
type TeamPickerModalProps = {
|
||||
draftPlayers: string[]
|
||||
extraPlayerSet: Set<string>
|
||||
playCounts: Record<string, number>
|
||||
draftTargetScore: string
|
||||
group: RoundGroup
|
||||
@@ -905,6 +939,7 @@ type TeamPickerModalProps = {
|
||||
|
||||
function TeamPickerModal({
|
||||
draftPlayers,
|
||||
extraPlayerSet,
|
||||
playCounts,
|
||||
draftTargetScore,
|
||||
group,
|
||||
@@ -969,6 +1004,7 @@ function TeamPickerModal({
|
||||
const checked = draftPlayers.includes(playerName)
|
||||
const selectedOrder = checked ? draftPlayers.indexOf(playerName) + 1 : null
|
||||
const playedCount = playCounts[playerName] ?? 0
|
||||
const isExtra = extraPlayerSet.has(playerName)
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -985,7 +1021,10 @@ function TeamPickerModal({
|
||||
{checked ? String(selectedOrder) : ''}
|
||||
</span>
|
||||
<div className="team-picker-option-text">
|
||||
<strong>{playerName}</strong>
|
||||
<strong>
|
||||
{playerName}
|
||||
{isExtra ? <em className="team-picker-extra-badge">其他區</em> : null}
|
||||
</strong>
|
||||
<small>
|
||||
{selectedOrder ? `已選為第 ${selectedOrder} 位` : '尚未加入上場名單'}
|
||||
</small>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { LoadStatus, RoundGroup } from '../types'
|
||||
type TeamSelectionPageProps = {
|
||||
areaAInput: string
|
||||
areaBInput: string
|
||||
areaOtherInput: string
|
||||
groups: RoundGroup[]
|
||||
groupSource: 'idle' | 'db' | 'manual'
|
||||
loadMessage: string
|
||||
@@ -12,6 +13,7 @@ type TeamSelectionPageProps = {
|
||||
targetDate: string
|
||||
onAreaAInputChange: (value: string) => void
|
||||
onAreaBInputChange: (value: string) => void
|
||||
onAreaOtherInputChange: (value: string) => void
|
||||
onGenerateManualGroups: () => void
|
||||
onLoadGroupsFromDb: () => void
|
||||
onTargetDateChange: (value: string) => void
|
||||
@@ -21,6 +23,7 @@ type TeamSelectionPageProps = {
|
||||
export function TeamSelectionPage({
|
||||
areaAInput,
|
||||
areaBInput,
|
||||
areaOtherInput,
|
||||
groups,
|
||||
groupSource,
|
||||
loadMessage,
|
||||
@@ -28,6 +31,7 @@ export function TeamSelectionPage({
|
||||
targetDate,
|
||||
onAreaAInputChange,
|
||||
onAreaBInputChange,
|
||||
onAreaOtherInputChange,
|
||||
onGenerateManualGroups,
|
||||
onLoadGroupsFromDb,
|
||||
onTargetDateChange,
|
||||
@@ -90,6 +94,15 @@ export function TeamSelectionPage({
|
||||
onChange={(event) => onAreaBInputChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field field-area-other">
|
||||
<span>其他區名單(不排進對戰組合,記分板選隊伍時可臨時選上場)</span>
|
||||
<textarea
|
||||
placeholder="每行一位球員"
|
||||
value={areaOtherInput}
|
||||
onChange={(event) => onAreaOtherInputChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="selection-hint">
|
||||
|
||||
Reference in New Issue
Block a user