功能:首頁改為 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:
+58
-29
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user