功能:首頁改為 V2 場地排程,載入與手動產生皆為 2 場地各 8 局並依實力配對

摘要:
- 從資料庫讀取 match-hub V2 資料(battlecombination 以場地為 key)時,
  顯示「2 號場地」「3 號場地」各 8 局賽程,挑一個場地帶進記分板
- 手動分組移除 A / B 區,改為「今日出席名單」+「其他區名單」,
  演算法移植 match-hub V2:依 tg_members.skill 實力強弱互補、兩隊平衡,
  搭檔輪完一遍前不重複、每人局數落差最多 1 局;8 人以上排兩場地
- server 新增 GET /api/skills(表名可用 DB_MEMBERS_TABLE 覆寫)
- 選隊面板預設隊伍照局分組並顯示局數標題;修正帶入現有對戰時
  第 2 隊不顯示「已放入」且點擊無法取消的問題(配對比對改為不分順序)
- 舊 V1 三輪資料(personnel 含 B 區)仍照「第 N 組」顯示,自動判別

根本原因:
- 實際打球流程是 2、3 號場地各打 8 局、每局換搭檔輪休,
  舊的三輪配對制不符流程,也完全沒考慮實力;
  選隊面板的隊伍比對寫死正序,帶入現有對戰時右隊兩人反序就對不上

影響:
- 首頁載入與手動產生的結果格式一致(N 號場地 × 8 局),操作流程不變
- 舊版 A / B 區的 localStorage 內容自動合併進今日出席名單

修法:
- match.ts 移除跨區平衡三輪邏輯,移植 buildSchedule/pickBestMatching 等演算法,
  新增 buildCourtGroups 輸出場地格式;RoundGroup 增加 label
- convertDbRecordToGroups 依 personnel 是否含 B 區自動分流 V1 / V2
- ScoreboardPage 的 getPresetTeamSelectionSlot / removePresetTeamFromDraft
  改為兩人同隊即成立、不看順序

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 12:19:53 +08:00
co-authored by Claude Fable 5
parent 6ae1eda7e2
commit 6f38c155f0
9 changed files with 546 additions and 163 deletions
+58 -29
View File
@@ -5,6 +5,7 @@ import {
createLiveRoom,
loadHistoryPlayCounts,
loadMatchResults,
loadMemberSkills,
releaseLiveRoom,
rememberCertNotAfter,
saveMatchHistory,
@@ -12,7 +13,7 @@ import {
updateLiveRoom,
} from './lib/api'
import {
buildManualGroups,
buildCourtGroups,
convertDateToKey,
convertDbRecordToGroups,
formatDateInputValue,
@@ -51,10 +52,26 @@ const STORAGE_KEYS = {
areaOther: 'badminton-scoreboard::area-other',
history: 'badminton-scoreboard::history',
playCounts: 'badminton-scoreboard::play-counts',
roster: 'badminton-scoreboard::roster-today',
} as const
const defaultAreaA = ['柏威', '建喵', 'Yuki', '阿釧']
const defaultAreaB = ['RURU', '玟瑄', '培根', 'Tim']
// 首次啟用今日出席名單時,把舊版 A / B 區的儲存內容合併過來。
function loadInitialRoster() {
const stored = loadStoredText(STORAGE_KEYS.roster, '')
if (stored.trim()) {
return stored
}
const legacy = [
loadStoredText(STORAGE_KEYS.areaA, ''),
loadStoredText(STORAGE_KEYS.areaB, ''),
]
.join('\n')
.trim()
return legacy
}
const initialScoreState: ScoreState = {
scoreLeft: 0,
@@ -119,12 +136,11 @@ function App() {
const isScoreboardRoute = location.pathname === '/scoreboard'
const [targetDate, setTargetDate] = useState(() => formatDateInputValue())
const [areaAInput, setAreaAInput] = useState(() =>
loadStoredText(STORAGE_KEYS.areaA, defaultAreaA.join('\n')),
)
const [areaBInput, setAreaBInput] = useState(() =>
loadStoredText(STORAGE_KEYS.areaB, defaultAreaB.join('\n')),
)
// 今日出席名單:手動排賽程的對象,取代舊版 A / B 區。
const [rosterInput, setRosterInput] = useState(() => loadInitialRoster())
// 全體成員實力表(tg_members.skill),排賽程時做強弱互補與兩隊平衡。
const [skillMap, setSkillMap] = useState<Record<string, number>>({})
const [skillsReady, setSkillsReady] = useState(false)
// 其他區:不參與分組配對,但會帶進記分板讓臨時上場的人可以被選到。
const [areaOtherInput, setAreaOtherInput] = useState(() =>
loadStoredText(STORAGE_KEYS.areaOther, ''),
@@ -167,8 +183,7 @@ function App() {
const creatingRoomRef = useRef(false)
const lastSyncedRoomSignatureRef = useRef('')
const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput])
const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput])
const parsedRoster = useMemo(() => parseRoster(rosterInput), [rosterInput])
const parsedAreaOther = useMemo(() => parseRoster(areaOtherInput), [areaOtherInput])
const selectedGroup = groups.find((group) => group.id === selectedGroupId) ?? null
const leftTeam = activeMatchup.leftTeam
@@ -177,12 +192,21 @@ function App() {
const isNavigationLocked = Boolean(leftTeam && rightTeam && scoreState.serving !== null)
useEffect(() => {
window.localStorage.setItem(STORAGE_KEYS.areaA, areaAInput)
}, [areaAInput])
window.localStorage.setItem(STORAGE_KEYS.roster, rosterInput)
}, [rosterInput])
// 開站先載入實力表,載入完成前不能手動產生賽程(跟 match-hub 一致)。
useEffect(() => {
window.localStorage.setItem(STORAGE_KEYS.areaB, areaBInput)
}, [areaBInput])
void (async () => {
try {
const skills = await loadMemberSkills()
setSkillMap(skills)
setSkillsReady(true)
} catch (error) {
console.error('skills load error:', error)
}
})()
}, [])
useEffect(() => {
window.localStorage.setItem(STORAGE_KEYS.areaOther, areaOtherInput)
@@ -682,8 +706,7 @@ function App() {
}
const nextData = convertDbRecordToGroups(record)
setAreaAInput(nextData.areaA.join('\n'))
setAreaBInput(nextData.areaB.join('\n'))
setRosterInput([...nextData.areaA, ...nextData.areaB].join('\n'))
setGroups(nextData.groups)
setGroupSource('db')
setLoadStatus('loaded')
@@ -700,21 +723,31 @@ function App() {
}
const generateManualGroups = () => {
if (parsedAreaA.length === 0 || parsedAreaB.length === 0) {
if (parsedRoster.length < 4) {
setGroups([])
setSelectedGroupId(null)
setActiveMatchup({ leftTeam: null, rightTeam: null })
setGroupSource('idle')
setLoadStatus('error')
setLoadMessage('A 區與 B 區至少都要有 1 位成員。')
setLoadMessage('今日出席至少需要 4 位成員才能排賽程。')
return
}
const nextGroups = buildManualGroups(parsedAreaA, parsedAreaB)
if (!skillsReady) {
setLoadStatus('error')
setLoadMessage('實力資料還沒載入完成,請稍候再產生賽程(或重新整理頁面)。')
return
}
const nextGroups = buildCourtGroups(parsedRoster, skillMap)
setGroups(nextGroups)
setGroupSource('manual')
setLoadStatus('loaded')
setLoadMessage('已產生手動配對結果,請選擇要使用的組別。')
setLoadMessage(
nextGroups.length === 2
? '已依實力產生 2、3 號場地各 8 局賽程,挑一個場地進記分板。'
: '人數不足 8 人,只排 2 號場地 8 局賽程。',
)
selectGroup(nextGroups[0]?.id ?? 1, nextGroups)
}
@@ -1091,17 +1124,15 @@ function App() {
path="/"
element={
<TeamSelectionPage
areaAInput={areaAInput}
areaBInput={areaBInput}
areaOtherInput={areaOtherInput}
groups={groups}
groupSource={groupSource}
loadMessage={loadMessage}
loadStatus={loadStatus}
rosterInput={rosterInput}
targetDate={targetDate}
onAreaAInputChange={setAreaAInput}
onAreaBInputChange={setAreaBInput}
onAreaOtherInputChange={setAreaOtherInput}
onRosterInputChange={setRosterInput}
onGenerateManualGroups={generateManualGroups}
onLoadGroupsFromDb={() => void loadGroupsFromDb()}
onTargetDateChange={setTargetDate}
@@ -1113,17 +1144,15 @@ function App() {
path="/teams"
element={
<TeamSelectionPage
areaAInput={areaAInput}
areaBInput={areaBInput}
areaOtherInput={areaOtherInput}
groups={groups}
groupSource={groupSource}
loadMessage={loadMessage}
loadStatus={loadStatus}
rosterInput={rosterInput}
targetDate={targetDate}
onAreaAInputChange={setAreaAInput}
onAreaBInputChange={setAreaBInput}
onAreaOtherInputChange={setAreaOtherInput}
onRosterInputChange={setRosterInput}
onGenerateManualGroups={generateManualGroups}
onLoadGroupsFromDb={() => void loadGroupsFromDb()}
onTargetDateChange={setTargetDate}