功能:首頁新增其他區名單,可在記分板臨時選上場但不參與配對

根本原因:
現場常有臨時來打球的人,不屬於 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:
2026-08-10 15:22:17 +08:00
co-authored by Claude Opus 4.8
parent 710d04bffe
commit 37fa1446af
4 changed files with 91 additions and 3 deletions
+21
View File
@@ -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
View File
@@ -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}
+42 -3
View File
@@ -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>
+13
View File
@@ -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">