功能:首頁新增其他區名單,可在記分板臨時選上場但不參與配對
根本原因: 現場常有臨時來打球的人,不屬於 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));
|
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 {
|
.field {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -1501,6 +1510,18 @@
|
|||||||
white-space: nowrap;
|
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,
|
.team-picker-option strong,
|
||||||
.preset-team-card strong {
|
.preset-team-card strong {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
+15
@@ -47,6 +47,7 @@ import type {
|
|||||||
const STORAGE_KEYS = {
|
const STORAGE_KEYS = {
|
||||||
areaA: 'badminton-scoreboard::area-a',
|
areaA: 'badminton-scoreboard::area-a',
|
||||||
areaB: 'badminton-scoreboard::area-b',
|
areaB: 'badminton-scoreboard::area-b',
|
||||||
|
areaOther: 'badminton-scoreboard::area-other',
|
||||||
history: 'badminton-scoreboard::history',
|
history: 'badminton-scoreboard::history',
|
||||||
playCounts: 'badminton-scoreboard::play-counts',
|
playCounts: 'badminton-scoreboard::play-counts',
|
||||||
} as const
|
} as const
|
||||||
@@ -123,6 +124,10 @@ function App() {
|
|||||||
const [areaBInput, setAreaBInput] = useState(() =>
|
const [areaBInput, setAreaBInput] = useState(() =>
|
||||||
loadStoredText(STORAGE_KEYS.areaB, defaultAreaB.join('\n')),
|
loadStoredText(STORAGE_KEYS.areaB, defaultAreaB.join('\n')),
|
||||||
)
|
)
|
||||||
|
// 其他區:不參與分組配對,但會帶進記分板讓臨時上場的人可以被選到。
|
||||||
|
const [areaOtherInput, setAreaOtherInput] = useState(() =>
|
||||||
|
loadStoredText(STORAGE_KEYS.areaOther, ''),
|
||||||
|
)
|
||||||
const [groups, setGroups] = useState<RoundGroup[]>([])
|
const [groups, setGroups] = useState<RoundGroup[]>([])
|
||||||
const [groupSource, setGroupSource] = useState<'idle' | 'db' | 'manual'>('idle')
|
const [groupSource, setGroupSource] = useState<'idle' | 'db' | 'manual'>('idle')
|
||||||
const [loadStatus, setLoadStatus] = useState<LoadStatus>('idle')
|
const [loadStatus, setLoadStatus] = useState<LoadStatus>('idle')
|
||||||
@@ -163,6 +168,7 @@ 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 parsedAreaOther = useMemo(() => parseRoster(areaOtherInput), [areaOtherInput])
|
||||||
const selectedGroup = groups.find((group) => group.id === selectedGroupId) ?? null
|
const selectedGroup = groups.find((group) => group.id === selectedGroupId) ?? null
|
||||||
const leftTeam = activeMatchup.leftTeam
|
const leftTeam = activeMatchup.leftTeam
|
||||||
const rightTeam = activeMatchup.rightTeam
|
const rightTeam = activeMatchup.rightTeam
|
||||||
@@ -177,6 +183,10 @@ function App() {
|
|||||||
window.localStorage.setItem(STORAGE_KEYS.areaB, areaBInput)
|
window.localStorage.setItem(STORAGE_KEYS.areaB, areaBInput)
|
||||||
}, [areaBInput])
|
}, [areaBInput])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.localStorage.setItem(STORAGE_KEYS.areaOther, areaOtherInput)
|
||||||
|
}, [areaOtherInput])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.localStorage.setItem(STORAGE_KEYS.history, JSON.stringify(history))
|
window.localStorage.setItem(STORAGE_KEYS.history, JSON.stringify(history))
|
||||||
}, [history])
|
}, [history])
|
||||||
@@ -1068,6 +1078,7 @@ function App() {
|
|||||||
<TeamSelectionPage
|
<TeamSelectionPage
|
||||||
areaAInput={areaAInput}
|
areaAInput={areaAInput}
|
||||||
areaBInput={areaBInput}
|
areaBInput={areaBInput}
|
||||||
|
areaOtherInput={areaOtherInput}
|
||||||
groups={groups}
|
groups={groups}
|
||||||
groupSource={groupSource}
|
groupSource={groupSource}
|
||||||
loadMessage={loadMessage}
|
loadMessage={loadMessage}
|
||||||
@@ -1075,6 +1086,7 @@ function App() {
|
|||||||
targetDate={targetDate}
|
targetDate={targetDate}
|
||||||
onAreaAInputChange={setAreaAInput}
|
onAreaAInputChange={setAreaAInput}
|
||||||
onAreaBInputChange={setAreaBInput}
|
onAreaBInputChange={setAreaBInput}
|
||||||
|
onAreaOtherInputChange={setAreaOtherInput}
|
||||||
onGenerateManualGroups={generateManualGroups}
|
onGenerateManualGroups={generateManualGroups}
|
||||||
onLoadGroupsFromDb={() => void loadGroupsFromDb()}
|
onLoadGroupsFromDb={() => void loadGroupsFromDb()}
|
||||||
onTargetDateChange={setTargetDate}
|
onTargetDateChange={setTargetDate}
|
||||||
@@ -1088,6 +1100,7 @@ function App() {
|
|||||||
<TeamSelectionPage
|
<TeamSelectionPage
|
||||||
areaAInput={areaAInput}
|
areaAInput={areaAInput}
|
||||||
areaBInput={areaBInput}
|
areaBInput={areaBInput}
|
||||||
|
areaOtherInput={areaOtherInput}
|
||||||
groups={groups}
|
groups={groups}
|
||||||
groupSource={groupSource}
|
groupSource={groupSource}
|
||||||
loadMessage={loadMessage}
|
loadMessage={loadMessage}
|
||||||
@@ -1095,6 +1108,7 @@ function App() {
|
|||||||
targetDate={targetDate}
|
targetDate={targetDate}
|
||||||
onAreaAInputChange={setAreaAInput}
|
onAreaAInputChange={setAreaAInput}
|
||||||
onAreaBInputChange={setAreaBInput}
|
onAreaBInputChange={setAreaBInput}
|
||||||
|
onAreaOtherInputChange={setAreaOtherInput}
|
||||||
onGenerateManualGroups={generateManualGroups}
|
onGenerateManualGroups={generateManualGroups}
|
||||||
onLoadGroupsFromDb={() => void loadGroupsFromDb()}
|
onLoadGroupsFromDb={() => void loadGroupsFromDb()}
|
||||||
onTargetDateChange={setTargetDate}
|
onTargetDateChange={setTargetDate}
|
||||||
@@ -1107,6 +1121,7 @@ function App() {
|
|||||||
element={
|
element={
|
||||||
<ScoreboardPage
|
<ScoreboardPage
|
||||||
currentSelectionOrder={getSelectionOrder(leftTeam, rightTeam)}
|
currentSelectionOrder={getSelectionOrder(leftTeam, rightTeam)}
|
||||||
|
extraPlayers={parsedAreaOther}
|
||||||
finishDialogError={settlement.error}
|
finishDialogError={settlement.error}
|
||||||
finishDialogOpen={settlement.open}
|
finishDialogOpen={settlement.open}
|
||||||
finishDialogUploading={settlement.uploading}
|
finishDialogUploading={settlement.uploading}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const SPEECH_NAME_MAP: Record<string, string> = {
|
|||||||
|
|
||||||
type ScoreboardPageProps = {
|
type ScoreboardPageProps = {
|
||||||
currentSelectionOrder: string[]
|
currentSelectionOrder: string[]
|
||||||
|
extraPlayers: string[]
|
||||||
finishDialogError: string
|
finishDialogError: string
|
||||||
finishDialogOpen: boolean
|
finishDialogOpen: boolean
|
||||||
finishDialogUploading: boolean
|
finishDialogUploading: boolean
|
||||||
@@ -92,6 +93,7 @@ type ScoreboardPageProps = {
|
|||||||
|
|
||||||
export function ScoreboardPage({
|
export function ScoreboardPage({
|
||||||
currentSelectionOrder,
|
currentSelectionOrder,
|
||||||
|
extraPlayers,
|
||||||
finishDialogError,
|
finishDialogError,
|
||||||
finishDialogOpen,
|
finishDialogOpen,
|
||||||
finishDialogUploading,
|
finishDialogUploading,
|
||||||
@@ -189,8 +191,37 @@ export function ScoreboardPage({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 其他區的人排在正規名單後面,一樣可以被選上場。
|
||||||
|
extraPlayers.forEach((player) => {
|
||||||
|
if (!seen.has(player)) {
|
||||||
|
seen.add(player)
|
||||||
|
players.push(player)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return players
|
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(
|
const presetTeams = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -500,8 +531,9 @@ export function ScoreboardPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const autoPickDraftPlayers = () => {
|
const autoPickDraftPlayers = () => {
|
||||||
|
// 其他區的人只能手動勾選,自動選擇留給正規輪替的名單。
|
||||||
const shuffled = selectablePlayers.filter(
|
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) {
|
for (let index = shuffled.length - 1; index > 0; index -= 1) {
|
||||||
@@ -697,6 +729,7 @@ export function ScoreboardPage({
|
|||||||
<TeamPickerModal
|
<TeamPickerModal
|
||||||
draftPlayers={draftPlayers}
|
draftPlayers={draftPlayers}
|
||||||
draftTargetScore={draftTargetScore}
|
draftTargetScore={draftTargetScore}
|
||||||
|
extraPlayerSet={extraPlayerSet}
|
||||||
group={selectedGroup}
|
group={selectedGroup}
|
||||||
playCounts={playCounts}
|
playCounts={playCounts}
|
||||||
presetTeams={presetTeams}
|
presetTeams={presetTeams}
|
||||||
@@ -886,6 +919,7 @@ function ScoreboardTeamPanel({
|
|||||||
|
|
||||||
type TeamPickerModalProps = {
|
type TeamPickerModalProps = {
|
||||||
draftPlayers: string[]
|
draftPlayers: string[]
|
||||||
|
extraPlayerSet: Set<string>
|
||||||
playCounts: Record<string, number>
|
playCounts: Record<string, number>
|
||||||
draftTargetScore: string
|
draftTargetScore: string
|
||||||
group: RoundGroup
|
group: RoundGroup
|
||||||
@@ -905,6 +939,7 @@ type TeamPickerModalProps = {
|
|||||||
|
|
||||||
function TeamPickerModal({
|
function TeamPickerModal({
|
||||||
draftPlayers,
|
draftPlayers,
|
||||||
|
extraPlayerSet,
|
||||||
playCounts,
|
playCounts,
|
||||||
draftTargetScore,
|
draftTargetScore,
|
||||||
group,
|
group,
|
||||||
@@ -969,6 +1004,7 @@ function TeamPickerModal({
|
|||||||
const checked = draftPlayers.includes(playerName)
|
const checked = draftPlayers.includes(playerName)
|
||||||
const selectedOrder = checked ? draftPlayers.indexOf(playerName) + 1 : null
|
const selectedOrder = checked ? draftPlayers.indexOf(playerName) + 1 : null
|
||||||
const playedCount = playCounts[playerName] ?? 0
|
const playedCount = playCounts[playerName] ?? 0
|
||||||
|
const isExtra = extraPlayerSet.has(playerName)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -985,7 +1021,10 @@ function TeamPickerModal({
|
|||||||
{checked ? String(selectedOrder) : ''}
|
{checked ? String(selectedOrder) : ''}
|
||||||
</span>
|
</span>
|
||||||
<div className="team-picker-option-text">
|
<div className="team-picker-option-text">
|
||||||
<strong>{playerName}</strong>
|
<strong>
|
||||||
|
{playerName}
|
||||||
|
{isExtra ? <em className="team-picker-extra-badge">其他區</em> : null}
|
||||||
|
</strong>
|
||||||
<small>
|
<small>
|
||||||
{selectedOrder ? `已選為第 ${selectedOrder} 位` : '尚未加入上場名單'}
|
{selectedOrder ? `已選為第 ${selectedOrder} 位` : '尚未加入上場名單'}
|
||||||
</small>
|
</small>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { LoadStatus, RoundGroup } from '../types'
|
|||||||
type TeamSelectionPageProps = {
|
type TeamSelectionPageProps = {
|
||||||
areaAInput: string
|
areaAInput: string
|
||||||
areaBInput: string
|
areaBInput: string
|
||||||
|
areaOtherInput: string
|
||||||
groups: RoundGroup[]
|
groups: RoundGroup[]
|
||||||
groupSource: 'idle' | 'db' | 'manual'
|
groupSource: 'idle' | 'db' | 'manual'
|
||||||
loadMessage: string
|
loadMessage: string
|
||||||
@@ -12,6 +13,7 @@ type TeamSelectionPageProps = {
|
|||||||
targetDate: string
|
targetDate: string
|
||||||
onAreaAInputChange: (value: string) => void
|
onAreaAInputChange: (value: string) => void
|
||||||
onAreaBInputChange: (value: string) => void
|
onAreaBInputChange: (value: string) => void
|
||||||
|
onAreaOtherInputChange: (value: string) => void
|
||||||
onGenerateManualGroups: () => void
|
onGenerateManualGroups: () => void
|
||||||
onLoadGroupsFromDb: () => void
|
onLoadGroupsFromDb: () => void
|
||||||
onTargetDateChange: (value: string) => void
|
onTargetDateChange: (value: string) => void
|
||||||
@@ -21,6 +23,7 @@ type TeamSelectionPageProps = {
|
|||||||
export function TeamSelectionPage({
|
export function TeamSelectionPage({
|
||||||
areaAInput,
|
areaAInput,
|
||||||
areaBInput,
|
areaBInput,
|
||||||
|
areaOtherInput,
|
||||||
groups,
|
groups,
|
||||||
groupSource,
|
groupSource,
|
||||||
loadMessage,
|
loadMessage,
|
||||||
@@ -28,6 +31,7 @@ export function TeamSelectionPage({
|
|||||||
targetDate,
|
targetDate,
|
||||||
onAreaAInputChange,
|
onAreaAInputChange,
|
||||||
onAreaBInputChange,
|
onAreaBInputChange,
|
||||||
|
onAreaOtherInputChange,
|
||||||
onGenerateManualGroups,
|
onGenerateManualGroups,
|
||||||
onLoadGroupsFromDb,
|
onLoadGroupsFromDb,
|
||||||
onTargetDateChange,
|
onTargetDateChange,
|
||||||
@@ -90,6 +94,15 @@ export function TeamSelectionPage({
|
|||||||
onChange={(event) => onAreaBInputChange(event.target.value)}
|
onChange={(event) => onAreaBInputChange(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="field field-area-other">
|
||||||
|
<span>其他區名單(不排進對戰組合,記分板選隊伍時可臨時選上場)</span>
|
||||||
|
<textarea
|
||||||
|
placeholder="每行一位球員"
|
||||||
|
value={areaOtherInput}
|
||||||
|
onChange={(event) => onAreaOtherInputChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="selection-hint">
|
<div className="selection-hint">
|
||||||
|
|||||||
Reference in New Issue
Block a user