兩區人數落差時改為跨區平衡,並調整結果版面
根本原因: 原本配對邏輯只以人數多的一區為基準,缺額全部補「那個」。例如 A 區 7 人、B 區 5 人時會補 2 個「那個」,但總人數 12 是偶數,其實可以讓 A 區 1 人跨區支援就湊滿 6 隊真人對打。 影響: - 兩區差距 2 以上時,每輪自動抽「差距÷2」人跨區支援,且每輪輪替不同人,跨區的人平均分攤 - 只有總人數為奇數時才會補 1 個「那個」 - 三輪之間維持無重複搭檔、同輪無人重複上場 - 「推送到 LINE」按鈕移到上方操作列,三組結果改為三欄並排顯示 - 摘要卡改顯示跨區支援與補位人數 修法: 新增 balanceZones / pickCrossMembers 依輪次輪替抽出跨區成員,createRoundTeams 改在每輪平衡後再建立隊伍;移除不再使用的 prepareParticipants 與 .share-row/.share-button 樣式,並同步更新 README 規則說明。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,8 @@
|
|||||||
- 每次固定產生 3 組完整配對結果
|
- 每次固定產生 3 組完整配對結果
|
||||||
- 每一隊都由 `1 位 A 區 + 1 位 B 區` 組成
|
- 每一隊都由 `1 位 A 區 + 1 位 B 區` 組成
|
||||||
- 每一組都會把 A 區與 B 區名單完整分配完成
|
- 每一組都會把 A 區與 B 區名單完整分配完成
|
||||||
- 若某一區人數不足,系統會自動補入「那個」
|
- 兩區人數差 2 以上時,每輪會輪流讓人多的一區成員跨區支援
|
||||||
|
- 跨區平衡後仍有缺口(總人數為奇數)時,才補入「那個」
|
||||||
- 產生配對後可用按鈕手動上傳資料到資料庫
|
- 產生配對後可用按鈕手動上傳資料到資料庫
|
||||||
- 若指定日期已經有資料,上傳前會先詢問是否覆蓋
|
- 若指定日期已經有資料,上傳前會先詢問是否覆蓋
|
||||||
- 可讀取指定日期的資料庫內容並回填到畫面
|
- 可讀取指定日期的資料庫內容並回填到畫面
|
||||||
|
|||||||
+9
-7
@@ -287,18 +287,16 @@
|
|||||||
color: #8d2d22;
|
color: #8d2d22;
|
||||||
}
|
}
|
||||||
|
|
||||||
.round-list,
|
|
||||||
.team-list {
|
.team-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-row {
|
.round-list {
|
||||||
margin-bottom: 18px;
|
display: grid;
|
||||||
}
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 18px;
|
||||||
.share-button {
|
align-items: start;
|
||||||
min-width: 160px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.round-card,
|
.round-card,
|
||||||
@@ -368,6 +366,10 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.round-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-copy h1 {
|
.hero-copy h1 {
|
||||||
max-width: 10ch;
|
max-width: 10ch;
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-30
@@ -72,9 +72,10 @@ function App() {
|
|||||||
|
|
||||||
const parsedAreaA = parseRoster(areaAInput)
|
const parsedAreaA = parseRoster(areaAInput)
|
||||||
const parsedAreaB = parseRoster(areaBInput)
|
const parsedAreaB = parseRoster(areaBInput)
|
||||||
const teamCount = Math.max(parsedAreaA.length, parsedAreaB.length)
|
const zoneGap = Math.abs(parsedAreaA.length - parsedAreaB.length)
|
||||||
const placeholderCountA = Math.max(0, teamCount - parsedAreaA.length)
|
const crossCount = Math.floor(zoneGap / 2)
|
||||||
const placeholderCountB = Math.max(0, teamCount - parsedAreaB.length)
|
const placeholderCount = zoneGap % 2
|
||||||
|
const teamCount = Math.ceil((parsedAreaA.length + parsedAreaB.length) / 2)
|
||||||
|
|
||||||
function generateGroups() {
|
function generateGroups() {
|
||||||
if (parsedAreaA.length === 0 || parsedAreaB.length === 0) {
|
if (parsedAreaA.length === 0 || parsedAreaB.length === 0) {
|
||||||
@@ -85,10 +86,11 @@ function App() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const prepared = prepareParticipants(parsedAreaA, parsedAreaB)
|
const shuffledA = shuffleList(parsedAreaA)
|
||||||
|
const shuffledB = shuffleList(parsedAreaB)
|
||||||
const nextResults = Array.from({ length: TOTAL_ROUNDS }, (_, index) => ({
|
const nextResults = Array.from({ length: TOTAL_ROUNDS }, (_, index) => ({
|
||||||
id: index + 1,
|
id: index + 1,
|
||||||
teams: createRoundTeams(prepared.areaA, prepared.areaB, index),
|
teams: createRoundTeams(shuffledA, shuffledB, index),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
setResults(nextResults)
|
setResults(nextResults)
|
||||||
@@ -213,8 +215,8 @@ function App() {
|
|||||||
<h1>羽毛球分組配對器</h1>
|
<h1>羽毛球分組配對器</h1>
|
||||||
<p className="hero-text">
|
<p className="hero-text">
|
||||||
輸入 A 區與 B 區名單後,系統會產生 3 組完整配對。
|
輸入 A 區與 B 區名單後,系統會產生 3 組完整配對。
|
||||||
每一隊都是 1 位 A 區成員搭配 1 位 B 區成員;
|
兩區人數有落差時,每輪會輪流讓人多的一區成員跨區支援;
|
||||||
若某一區人數不足,會自動補入「那個」。
|
補完後仍有缺口才會補入「那個」。
|
||||||
</p>
|
</p>
|
||||||
<div className="hero-actions">
|
<div className="hero-actions">
|
||||||
<button className="primary-button" type="button" onClick={generateGroups}>
|
<button className="primary-button" type="button" onClick={generateGroups}>
|
||||||
@@ -241,7 +243,7 @@ function App() {
|
|||||||
<article className="tip-card">
|
<article className="tip-card">
|
||||||
<h2>摘要</h2>
|
<h2>摘要</h2>
|
||||||
<p>
|
<p>
|
||||||
每組共 {teamCount} 隊,A 區補位 {placeholderCountA} 人,B 區補位 {placeholderCountB} 人。
|
每組共 {teamCount} 隊,跨區支援 {crossCount} 人,補位「那個」{placeholderCount} 人。
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
@@ -278,6 +280,16 @@ function App() {
|
|||||||
>
|
>
|
||||||
讀取指定日期
|
讀取指定日期
|
||||||
</button>
|
</button>
|
||||||
|
{results.length > 0 ? (
|
||||||
|
<button
|
||||||
|
className="primary-button upload-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void pushToLine()}
|
||||||
|
disabled={actionState === 'saving' || actionState === 'loading'}
|
||||||
|
>
|
||||||
|
推送到 LINE
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="input-grid">
|
<div className="input-grid">
|
||||||
@@ -312,19 +324,6 @@ function App() {
|
|||||||
<h2>三組名單</h2>
|
<h2>三組名單</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{results.length > 0 ? (
|
|
||||||
<div className="share-row">
|
|
||||||
<button
|
|
||||||
className="primary-button share-button"
|
|
||||||
type="button"
|
|
||||||
onClick={() => void pushToLine()}
|
|
||||||
disabled={actionState === 'saving' || actionState === 'loading'}
|
|
||||||
>
|
|
||||||
推送到 LINE
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{results.length > 0 ? (
|
{results.length > 0 ? (
|
||||||
<div className="round-list">
|
<div className="round-list">
|
||||||
{results.map((round) => (
|
{results.map((round) => (
|
||||||
@@ -359,14 +358,30 @@ function App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareParticipants(areaA: string[], areaB: string[]) {
|
function balanceZones(areaA: string[], areaB: string[], roundIndex: number) {
|
||||||
const targetCount = Math.max(areaA.length, areaB.length)
|
const moveCount = Math.floor(Math.abs(areaA.length - areaB.length) / 2)
|
||||||
const shuffledA = shuffleList(areaA)
|
|
||||||
const shuffledB = shuffleList(areaB)
|
if (moveCount === 0) {
|
||||||
|
return { areaA, areaB }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (areaA.length > areaB.length) {
|
||||||
|
const { moved, remaining } = pickCrossMembers(areaA, moveCount, roundIndex)
|
||||||
|
return { areaA: remaining, areaB: [...areaB, ...moved] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { moved, remaining } = pickCrossMembers(areaB, moveCount, roundIndex)
|
||||||
|
return { areaA: [...areaA, ...moved], areaB: remaining }
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickCrossMembers(list: string[], moveCount: number, roundIndex: number) {
|
||||||
|
const movedIndexes = new Set(
|
||||||
|
Array.from({ length: moveCount }, (_, offset) => (roundIndex * moveCount + offset) % list.length),
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
areaA: createParticipants(shuffledA, targetCount, 'A'),
|
moved: list.filter((_, index) => movedIndexes.has(index)),
|
||||||
areaB: createParticipants(shuffledB, targetCount, 'B'),
|
remaining: list.filter((_, index) => !movedIndexes.has(index)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,11 +403,16 @@ function createParticipants(players: string[], targetCount: number, zone: 'A' |
|
|||||||
return participants
|
return participants
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRoundTeams(areaA: Participant[], areaB: Participant[], roundIndex: number) {
|
function createRoundTeams(areaA: string[], areaB: string[], roundIndex: number) {
|
||||||
return areaA.map((playerA, index) => ({
|
const balanced = balanceZones(areaA, areaB, roundIndex)
|
||||||
|
const targetCount = Math.max(balanced.areaA.length, balanced.areaB.length)
|
||||||
|
const playersA = createParticipants(balanced.areaA, targetCount, 'A')
|
||||||
|
const playersB = createParticipants(balanced.areaB, targetCount, 'B')
|
||||||
|
|
||||||
|
return playersA.map((playerA, index) => ({
|
||||||
id: index + 1,
|
id: index + 1,
|
||||||
playerA,
|
playerA,
|
||||||
playerB: areaB[(index + roundIndex) % areaB.length],
|
playerB: playersB[(index + roundIndex) % playersB.length],
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user