DCFronted/src/components/DoubleEliminationBracket.vue
2025-08-03 13:53:54 +08:00

1263 lines
39 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="tournament-bracket-root">
<h1>双败淘汰赛赛程树状图</h1>
<div class="container">
<!-- 比赛对战表 -->
<div class="bracket-main">
<!-- 胜者组 -->
<div class="bracket-section">
<h2>胜者组</h2>
<div class="bracket-container" id="winnersBracketContainer">
<div class="zoom-controls">
<button class="zoom-btn" @click="zoomInWinners" title="放大 (+)">+</button>
<button class="zoom-btn" @click="zoomOutWinners" title="缩小 (-)">-</button>
<button class="zoom-btn" @click="resetZoomWinners" title="重置视图 (0)"></button>
</div>
<div class="interaction-hint" v-if="winnersBracket.length > 0">
<span>💡 拖拽移动 | 滚轮缩放 | 快捷键: +放大 -缩小 0重置</span>
</div>
<svg id="d3-winners-bracket" class="d3-bracket"></svg>
</div>
</div>
<!-- 败者组 -->
<div class="bracket-section">
<h2>败者组</h2>
<div class="bracket-container" id="losersBracketContainer">
<div class="zoom-controls">
<button class="zoom-btn" @click="zoomInLosers" title="放大 (+)">+</button>
<button class="zoom-btn" @click="zoomOutLosers" title="缩小 (-)">-</button>
<button class="zoom-btn" @click="resetZoomLosers" title="重置视图 (0)"></button>
</div>
<div class="interaction-hint" v-if="losersBracket.length > 0">
<span>💡 拖拽移动 | 滚轮缩放 | 快捷键: +放大 -缩小 0重置</span>
</div>
<svg id="d3-losers-bracket" class="d3-bracket"></svg>
</div>
</div>
<!-- 决赛 -->
<div class="bracket-section" v-if="finalMatch">
<h2>决赛 (BO5)</h2>
<div class="final-match-container">
<div class="final-match">
<div class="participant" :class="{ winner: finalMatch.decided && finalMatch.score1 > finalMatch.score2 }">
<span>{{ finalMatch.participant1 ? finalMatch.participant1.name : '待定' }}</span>
<input type="number" v-model.number="finalMatch.score1" :disabled="finalMatch.decided" placeholder="0">
</div>
<div class="participant" :class="{ winner: finalMatch.decided && finalMatch.score2 > finalMatch.score1 }">
<span>{{ finalMatch.participant2 ? finalMatch.participant2.name : '待定' }}</span>
<input type="number" v-model.number="finalMatch.score2" :disabled="finalMatch.decided" placeholder="0">
</div>
<button @click="confirmFinal" :disabled="finalMatch.decided">确认决赛比分</button>
</div>
</div>
</div>
</div>
</div>
<!-- 对话框组件 -->
<SuccessDialog
:visible="successDialog.visible"
:message="successDialog.message"
@close="successDialog.visible = false"
/>
<ErrorDialog
:visible="errorDialog.visible"
:message="errorDialog.message"
@close="errorDialog.visible = false"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick, onUnmounted } from 'vue';
import { getSignUpResultList, updateSignUpResult, getTournamentList } from '@/api/tournament';
import * as d3 from 'd3';
import SuccessDialog from '@/components/SuccessDialog.vue';
import ErrorDialog from '@/components/ErrorDialog.vue';
const props = defineProps({
tournamentId: {
type: Number,
required: true
}
});
const tournament = ref({
id: props.tournamentId,
name: ''
});
const participants = ref([]);
const winnersBracket = ref([]);
const losersBracket = ref([]);
const finalMatch = ref(null);
const champion = ref(null);
const runnerUp = ref(null);
const thirdPlace = ref(null);
const loading = ref(false);
// Dialog state
const successDialog = ref({ visible: false, message: '' });
const errorDialog = ref({ visible: false, message: '' });
// Store zoom behaviors for both brackets
let winnersZoomBehavior = null;
let winnerssvgSelection = null;
let losersZoomBehavior = null;
let loserssvgSelection = null;
onMounted(() => {
loadTournamentData();
// Add keyboard shortcuts for zoom control
const handleKeydown = (event) => {
if (event.target.tagName === 'INPUT') return;
switch(event.key) {
case '+':
case '=':
event.preventDefault();
if (event.ctrlKey) {
zoomInLosers();
} else {
zoomInWinners();
}
break;
case '-':
event.preventDefault();
if (event.ctrlKey) {
zoomOutLosers();
} else {
zoomOutWinners();
}
break;
case '0':
event.preventDefault();
if (event.ctrlKey) {
resetZoomLosers();
} else {
resetZoomWinners();
}
break;
}
};
document.addEventListener('keydown', handleKeydown);
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown);
const winnersSvg = d3.select('#d3-winners-bracket');
const losersSvg = d3.select('#d3-losers-bracket');
if (winnersSvg.node()) winnersSvg.selectAll('*').remove();
if (losersSvg.node()) losersSvg.selectAll('*').remove();
});
});
const loadTournamentData = async () => {
if (!props.tournamentId) return;
loading.value = true;
tournament.value.id = props.tournamentId;
try {
const tournaments = await getTournamentList();
const selectedTournament = tournaments.find(t => t.id === props.tournamentId);
if (selectedTournament) {
tournament.value.name = selectedTournament.name;
}
const data = await getSignUpResultList();
const filtered = data.filter(item => item.tournament_id == props.tournamentId);
participants.value = filtered.map(item => ({
id: item.id,
name: item.sign_name,
team_name: item.team_name || '',
win: item.win || '0',
lose: item.lose || '0',
qq_code: item.qq_code || '',
status: item.status || ''
}));
// 数据加载完成后自动生成赛程
if (participants.value.length >= 4) {
generateDoubleEliminationBracket();
}
} catch (err) {
console.error('获取比赛数据失败:', err);
} finally {
loading.value = false;
}
};
const generateDoubleEliminationBracket = () => {
// 重置所有状态
winnersBracket.value = [];
losersBracket.value = [];
finalMatch.value = null;
champion.value = runnerUp.value = thirdPlace.value = null;
const totalPlayers = participants.value.length;
if (totalPlayers < 4) {
errorDialog.value = { visible: true, message: '双败淘汰赛至少需要4名参赛者' };
return;
}
// 计算需要的总槽位数最近的2的幂
const totalSlots = Math.pow(2, Math.ceil(Math.log2(totalPlayers)));
const shuffledParticipants = [...participants.value].sort(() => Math.random() - 0.5);
// 填充到总槽位数剩余位置为null轮空
while (shuffledParticipants.length < totalSlots) {
shuffledParticipants.push(null);
}
console.log(`总参赛者: ${totalPlayers}, 总槽位: ${totalSlots}`);
// 生成胜者组第一轮
const winnersRound1 = [];
for (let i = 0; i < totalSlots; i += 2) {
const p1 = shuffledParticipants[i];
const p2 = shuffledParticipants[i + 1];
// 处理轮空情况
let winner = null;
let decided = false;
let score1 = 0;
let score2 = 0;
if (p1 && !p2) {
// p1轮空直接晋级
winner = p1;
decided = true;
score1 = 1;
score2 = 0;
console.log(`${p1.name} 轮空直接晋级`);
} else if (!p1 && p2) {
// p2轮空直接晋级
winner = p2;
decided = true;
score1 = 0;
score2 = 1;
console.log(`${p2.name} 轮空直接晋级`);
} else if (!p1 && !p2) {
// 双轮空,无人晋级
winner = null;
decided = true;
score1 = 0;
score2 = 0;
console.log('双轮空,无人晋级');
}
winnersRound1.push({
participant1: p1,
participant2: p2,
score1: score1,
score2: score2,
decided: decided,
winner: winner,
loser: null // 轮空情况下没有败者
});
}
winnersBracket.value = [winnersRound1];
// 预生成胜者组的所有轮次结构
let currentRoundSize = Math.ceil(totalSlots / 2);
while (currentRoundSize > 1) {
currentRoundSize = Math.ceil(currentRoundSize / 2);
const nextRound = [];
for (let i = 0; i < currentRoundSize; i++) {
nextRound.push({
participant1: null,
participant2: null,
score1: 0,
score2: 0,
decided: false,
winner: null,
loser: null
});
}
if (currentRoundSize > 0) {
winnersBracket.value.push(nextRound);
}
}
console.log(`胜者组轮次数: ${winnersBracket.value.length}`);
// 预生成败者组结构
generateLosersBracketStructure(totalPlayers);
// 处理第一轮的轮空晋级
winnersRound1.forEach((match, index) => {
if (match.decided && match.winner) {
// 轮空获胜者直接晋级到胜者组下一轮
handleWinnersBracketAdvancement(match, 0, index);
}
});
nextTick(() => {
drawD3WinnersBracket();
drawD3LosersBracket();
});
};
// 生成败者组结构 - 根据标准双败逻辑
const generateLosersBracketStructure = (totalPlayers) => {
const winnersRounds = winnersBracket.value.length;
losersBracket.value = [];
// 标准双败淘汰赛败者组结构:
// 计算实际的参赛者数量和轮空数量
const totalSlots = Math.pow(2, Math.ceil(Math.log2(totalPlayers)));
const byeCount = totalSlots - totalPlayers;
console.log(`总人数: ${totalPlayers}, 总槽位: ${totalSlots}, 轮空数: ${byeCount}`);
// 败者组轮次数计算:考虑轮空影响
let losersRounds;
if (winnersRounds === 1) {
losersRounds = 0; // 2人比赛无败者组
} else if (totalPlayers <= 4) {
losersRounds = 2; // 4人或5人比赛败者组2轮
} else {
losersRounds = (winnersRounds - 1) * 2;
}
console.log(`胜者组轮次: ${winnersRounds}, 败者组轮次: ${losersRounds}`);
if (losersRounds === 0) {
console.log('只有2人比赛不需要败者组');
return;
}
for (let round = 0; round < losersRounds; round++) {
const roundMatches = [];
let matchCount = 1;
// 败者组比赛数量规律:
// LR1: 1场 (胜者组第1轮败者们对战但要考虑轮空)
// LR2: 1场 (LR1胜者 vs 胜者组第2轮败者)
if (round === 0) {
// 第一轮败者组:计算实际的败者数量
const firstRoundLosers = Math.floor(totalPlayers / 2); // 第一轮实际对战产生的败者数
if (firstRoundLosers >= 2) {
matchCount = 1; // 至少需要1场比赛
} else if (firstRoundLosers === 1) {
// 如果只有1个败者这个败者直接晋级到下一轮
matchCount = 0; // 这轮不需要比赛
}
} else {
matchCount = 1; // 其他轮次通常是1场
}
// 确保至少有0场比赛可能某轮不需要比赛
matchCount = Math.max(0, matchCount);
if (matchCount > 0) {
for (let i = 0; i < matchCount; i++) {
roundMatches.push({
participant1: null,
participant2: null,
score1: 0,
score2: 0,
decided: false,
winner: null,
loser: null
});
}
losersBracket.value.push(roundMatches);
console.log(`败者组第${round + 1}轮: ${matchCount}场比赛`);
} else {
console.log(`败者组第${round + 1}轮: 跳过(无需比赛)`);
}
}
};
// D3.js drawing functions for winners bracket
const drawD3WinnersBracket = () => {
const svg = d3.select('#d3-winners-bracket');
svg.selectAll('*').remove();
if (!winnersBracket.value.length) return;
drawD3Bracket(svg, winnersBracket.value, 'winnersBracketContainer', 'winners');
};
// D3.js drawing functions for losers bracket
const drawD3LosersBracket = () => {
const svg = d3.select('#d3-losers-bracket');
svg.selectAll('*').remove();
if (!losersBracket.value.length) return;
drawD3Bracket(svg, losersBracket.value, 'losersBracketContainer', 'losers');
};
// Generic D3.js bracket drawing function
const drawD3Bracket = (svg, bracket, containerId, bracketType) => {
const margin = { top: 40, right: 40, bottom: 40, left: 40 };
const matchWidth = 180;
const matchHeight = 120;
const roundGap = 50;
const matchGap = 20;
const rounds = bracket.length;
const maxMatchesInRound = Math.max(...bracket.map(round => round.length));
const totalWidth = rounds * (matchWidth + roundGap) + margin.left + margin.right;
const totalHeight = maxMatchesInRound * (matchHeight + matchGap) + margin.top + margin.bottom;
const container = document.getElementById(containerId);
const containerWidth = container.clientWidth - 30;
const containerHeight = container.clientHeight - 30;
svg
.attr('width', containerWidth)
.attr('height', containerHeight)
.attr('viewBox', `0 0 ${totalWidth} ${totalHeight}`)
.attr('preserveAspectRatio', 'xMidYMid meet');
// Create zoom behavior
const zoom = d3.zoom()
.scaleExtent([0.1, 3])
.on('zoom', (event) => {
g.attr('transform', `translate(${margin.left},${margin.top}) ${event.transform}`);
});
// Store references for external control
if (bracketType === 'winners') {
winnersZoomBehavior = zoom;
winnerssvgSelection = svg;
} else {
losersZoomBehavior = zoom;
loserssvgSelection = svg;
}
svg.call(zoom);
svg.style('cursor', 'grab')
.on('mousedown', () => svg.style('cursor', 'grabbing'))
.on('mouseup', () => svg.style('cursor', 'grab'));
const g = svg.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
// Draw matches for each round
bracket.forEach((round, roundIndex) => {
const roundX = roundIndex * (matchWidth + roundGap);
const roundStartY = (maxMatchesInRound * (matchHeight + matchGap) - round.length * (matchHeight + matchGap)) / 2;
// Round title
g.append('text')
.attr('x', roundX + matchWidth / 2)
.attr('y', -10)
.attr('text-anchor', 'middle')
.attr('class', 'round-title')
.style('font-weight', 'bold')
.style('font-size', '14px')
.text(`${roundIndex + 1}`);
round.forEach((match, matchIndex) => {
const matchY = roundStartY + matchIndex * (matchHeight + matchGap);
// Match container
const matchGroup = g.append('g')
.attr('class', 'match-group')
.attr('data-match-id', `${bracketType}-${roundIndex}-${matchIndex}`);
// Match background
matchGroup.append('rect')
.attr('x', roundX)
.attr('y', matchY)
.attr('width', matchWidth)
.attr('height', matchHeight)
.attr('class', 'match-bg')
.style('fill', '#fafafa')
.style('stroke', '#ddd')
.style('stroke-width', 1)
.style('rx', 6);
// Store match position for connection drawing
match._x = roundX + matchWidth;
match._y = matchY + matchHeight / 2;
match._roundIndex = roundIndex;
match._matchIndex = matchIndex;
// Participant 1
const p1Group = matchGroup.append('g')
.attr('class', 'participant-group')
.attr('transform', `translate(${roundX + 10}, ${matchY + 20})`);
p1Group.append('rect')
.attr('width', matchWidth - 20)
.attr('height', 30)
.style('fill', match.winner && match.participant1 && match.winner.id === match.participant1.id ? '#e8f5e8' : '#fff')
.style('stroke', '#ccc')
.style('rx', 3);
p1Group.append('text')
.attr('x', 8)
.attr('y', 20)
.style('font-size', '12px')
.style('font-weight', match.winner && match.participant1 && match.winner.id === match.participant1.id ? 'bold' : 'normal')
.style('fill', match.winner && match.participant1 && match.winner.id === match.participant1.id ? '#2a7f2a' : '#333')
.text(match.participant1 ? match.participant1.name : '轮空');
// Score input for participant 1
if (match.participant1 && match.participant2 && !match.decided) {
const scoreInput1 = p1Group.append('foreignObject')
.attr('x', matchWidth - 60)
.attr('y', 5)
.attr('width', 40)
.attr('height', 20);
scoreInput1.append('xhtml:input')
.attr('type', 'number')
.attr('min', '0')
.style('width', '35px')
.style('height', '18px')
.style('font-size', '11px')
.style('text-align', 'center')
.style('border', '1px solid #aaa')
.style('border-radius', '3px')
.property('value', match.score1 || '')
.on('input', function() {
match.score1 = +this.value;
});
} else if (match.decided) {
p1Group.append('text')
.attr('x', matchWidth - 35)
.attr('y', 20)
.style('font-size', '12px')
.style('font-weight', 'bold')
.style('text-anchor', 'middle')
.text(match.score1 || 0);
}
// Participant 2
const p2Group = matchGroup.append('g')
.attr('class', 'participant-group')
.attr('transform', `translate(${roundX + 10}, ${matchY + 55})`);
p2Group.append('rect')
.attr('width', matchWidth - 20)
.attr('height', 30)
.style('fill', match.winner && match.participant2 && match.winner.id === match.participant2.id ? '#e8f5e8' : '#fff')
.style('stroke', '#ccc')
.style('rx', 3);
p2Group.append('text')
.attr('x', 8)
.attr('y', 20)
.style('font-size', '12px')
.style('font-weight', match.winner && match.participant2 && match.winner.id === match.participant2.id ? 'bold' : 'normal')
.style('fill', match.winner && match.participant2 && match.winner.id === match.participant2.id ? '#2a7f2a' : '#333')
.text(match.participant2 ? match.participant2.name : '轮空');
// Score input for participant 2
if (match.participant1 && match.participant2 && !match.decided) {
const scoreInput2 = p2Group.append('foreignObject')
.attr('x', matchWidth - 60)
.attr('y', 5)
.attr('width', 40)
.attr('height', 20);
scoreInput2.append('xhtml:input')
.attr('type', 'number')
.attr('min', '0')
.style('width', '35px')
.style('height', '18px')
.style('font-size', '11px')
.style('text-align', 'center')
.style('border', '1px solid #aaa')
.style('border-radius', '3px')
.property('value', match.score2 || '')
.on('input', function() {
match.score2 = +this.value;
});
} else if (match.decided) {
p2Group.append('text')
.attr('x', matchWidth - 35)
.attr('y', 20)
.style('font-size', '12px')
.style('font-weight', 'bold')
.style('text-anchor', 'middle')
.text(match.score2 || 0);
}
// Confirm score button
if (match.participant1 && match.participant2 && !match.decided) {
const buttonGroup = matchGroup.append('g')
.attr('transform', `translate(${roundX + 10}, ${matchY + 90})`);
const button = buttonGroup.append('rect')
.attr('width', matchWidth - 20)
.attr('height', 25)
.style('fill', '#007acc')
.style('stroke', 'none')
.style('rx', 4)
.style('cursor', 'pointer')
.on('click', () => confirmScore(match, bracketType, roundIndex, matchIndex));
buttonGroup.append('text')
.attr('x', (matchWidth - 20) / 2)
.attr('y', 17)
.style('fill', 'white')
.style('font-size', '12px')
.style('font-weight', 'bold')
.style('text-anchor', 'middle')
.style('pointer-events', 'none')
.text('确认比分');
}
});
});
// Draw connections between matches
drawD3Connections(g, bracket, bracketType, matchWidth, matchHeight, roundGap, matchGap);
};
// Draw connections between matches in brackets
const drawD3Connections = (g, bracket, bracketType, matchWidth, matchHeight, roundGap, matchGap) => {
// Draw connections for adjacent rounds
for (let roundIndex = 0; roundIndex < bracket.length - 1; roundIndex++) {
const currentRound = bracket[roundIndex];
const nextRound = bracket[roundIndex + 1];
if (!nextRound) continue;
// Connect matches from current round to next round
for (let matchIndex = 0; matchIndex < currentRound.length; matchIndex += 2) {
const match1 = currentRound[matchIndex];
const match2 = currentRound[matchIndex + 1];
const nextMatchIndex = Math.floor(matchIndex / 2);
const nextMatch = nextRound[nextMatchIndex];
if (!nextMatch || nextMatch._x === undefined) continue;
// Draw connection from match1 to next match
if (match1 && match1.decided && match1._x !== undefined) {
const startX = match1._x;
const startY = match1._y;
const endX = nextMatch._x - matchWidth;
const endY = nextMatch._y;
const midX = startX + roundGap / 2;
g.append('path')
.attr('d', `M${startX},${startY} L${midX},${startY} L${midX},${endY} L${endX},${endY}`)
.style('stroke', '#4CAF50')
.style('stroke-width', 2)
.style('fill', 'none')
.style('opacity', 0.8);
}
// Draw connection from match2 to next match (if match2 exists)
if (match2 && match2.decided && match2._x !== undefined) {
const startX = match2._x;
const startY = match2._y;
const endX = nextMatch._x - matchWidth;
const endY = nextMatch._y;
const midX = startX + roundGap / 2;
g.append('path')
.attr('d', `M${startX},${startY} L${midX},${startY} L${midX},${endY} L${endX},${endY}`)
.style('stroke', '#4CAF50')
.style('stroke-width', 2)
.style('fill', 'none')
.style('opacity', 0.8);
}
}
}
};
const confirmScore = async (match, bracketType, rIndex, mIndex) => {
if (match.score1 === match.score2) {
errorDialog.value = { visible: true, message: '不能平局' };
return;
}
match.decided = true;
match.winner = match.score1 > match.score2 ? match.participant1 : match.participant2;
match.loser = match.score1 > match.score2 ? match.participant2 : match.participant1;
// 更新选手胜负场数据
await updatePlayerStats(match.participant1, match.participant2, match.score1, match.score2, bracketType);
if (bracketType === 'winners') {
// 胜者组逻辑
handleWinnersBracketAdvancement(match, rIndex, mIndex);
} else {
// 败者组逻辑
handleLosersBracketAdvancement(match, rIndex, mIndex);
}
// 检查是否需要生成决赛
checkForFinalMatch();
// Redraw brackets
nextTick(() => {
drawD3WinnersBracket();
drawD3LosersBracket();
});
};
// 处理胜者组晋级逻辑
const handleWinnersBracketAdvancement = (match, rIndex, mIndex) => {
// 胜者晋级到胜者组下一轮
if (rIndex + 1 < winnersBracket.value.length) {
const nextRound = winnersBracket.value[rIndex + 1];
const nextMatchIndex = Math.floor(mIndex / 2);
const isUpperSlot = mIndex % 2 === 0;
if (nextRound[nextMatchIndex]) {
if (isUpperSlot) {
nextRound[nextMatchIndex].participant1 = match.winner;
} else {
nextRound[nextMatchIndex].participant2 = match.winner;
}
console.log(`胜者组第${rIndex + 1}轮胜者${match.winner ? match.winner.name : 'null'}晋级到第${rIndex + 2}`);
}
}
// 败者进入败者组(只有在有败者的情况下)
if (match.loser) {
sendLoserToLosersBracket(match.loser, rIndex);
} else if (match.participant1 && match.participant2) {
// 只有在两个参赛者都存在的情况下才有败者
const loser = match.winner === match.participant1 ? match.participant2 : match.participant1;
sendLoserToLosersBracket(loser, rIndex);
}
// 轮空情况下不产生败者,不需要送入败者组
};
// 处理败者组晋级逻辑
const handleLosersBracketAdvancement = (match, rIndex, mIndex) => {
// 败者组胜者晋级到败者组下一轮
if (rIndex + 1 < losersBracket.value.length) {
const nextRound = losersBracket.value[rIndex + 1];
// 败者组的晋级逻辑:
// LR1 胜者 → LR2 的 participant1
// LR2 需要等待胜者组第2轮败者作为 participant2
if (nextRound.length > 0) {
const nextMatch = nextRound[0]; // 败者组通常每轮只有1场比赛
if (rIndex % 2 === 0) {
// 偶数轮 (LR1, LR3...): 胜者进入下一轮作为participant1
if (!nextMatch.participant1) {
nextMatch.participant1 = match.winner;
console.log(`败者组第${rIndex + 1}轮胜者${match.winner.name}晋级到第${rIndex + 2}轮作为participant1`);
}
} else {
// 奇数轮 (LR2, LR4...): 胜者进入下一轮作为participant2
if (!nextMatch.participant2) {
nextMatch.participant2 = match.winner;
console.log(`败者组第${rIndex + 1}轮胜者${match.winner.name}晋级到第${rIndex + 2}轮作为participant2`);
}
}
}
}
// 败者组的败者直接淘汰,不做处理
};
// 将败者送入败者组的正确位置
const sendLoserToLosersBracket = (loser, winnersRoundIndex) => {
if (!loser || losersBracket.value.length === 0) return;
console.log(`处理败者: ${loser.name}, 来自胜者组第${winnersRoundIndex + 1}`);
// 标准双败淘汰赛逻辑:
// 胜者组第1轮败者 → 败者组第1轮 (与其他第1轮败者对战)
// 胜者组第2轮败者 → 败者组第2轮 (与败者组第1轮胜者对战)
let targetRound;
if (winnersRoundIndex === 0) {
// 胜者组第1轮败者进入败者组第1轮
targetRound = 0;
// 特殊情况如果败者组第1轮没有比赛只有1个败者直接晋级到第2轮
if (losersBracket.value.length > 0 && losersBracket.value[0].length === 0) {
targetRound = 1;
console.log(`败者组第1轮无比赛${loser.name}直接进入第2轮`);
}
} else {
// 胜者组第N轮败者进入败者组第(2N-1)轮
targetRound = winnersRoundIndex * 2 - 1;
// 确保不超过败者组轮次数
if (targetRound >= losersBracket.value.length) {
targetRound = losersBracket.value.length - 1;
}
}
console.log(`${loser.name}放入败者组第${targetRound + 1}`);
if (targetRound < losersBracket.value.length && targetRound >= 0 && losersBracket.value[targetRound]) {
const losersRound = losersBracket.value[targetRound];
if (losersRound.length === 0) {
// 如果这一轮没有比赛,说明败者直接晋级到下一轮
console.log(`败者组第${targetRound + 1}轮无比赛,${loser.name}继续晋级`);
if (targetRound + 1 < losersBracket.value.length) {
sendLoserToLosersBracket(loser, winnersRoundIndex + 1);
}
return;
}
// 找到第一个空位置放入败者
for (let i = 0; i < losersRound.length; i++) {
const match = losersRound[i];
if (!match.participant1) {
match.participant1 = loser;
console.log(`${loser.name}被放入败者组第${targetRound + 1}轮第${i + 1}场的participant1位置`);
break;
} else if (!match.participant2) {
match.participant2 = loser;
console.log(`${loser.name}被放入败者组第${targetRound + 1}轮第${i + 1}场的participant2位置`);
break;
}
}
}
};
// 检查是否需要生成决赛
const checkForFinalMatch = () => {
const lastWinnersRound = winnersBracket.value[winnersBracket.value.length - 1];
const lastLosersRound = losersBracket.value[losersBracket.value.length - 1];
if (lastWinnersRound && lastWinnersRound.length === 1 && lastWinnersRound[0].decided &&
lastLosersRound && lastLosersRound.length === 1 && lastLosersRound[0].decided) {
const winnersChampion = lastWinnersRound[0].winner;
const losersChampion = lastLosersRound[0].winner;
if (winnersChampion && losersChampion && !finalMatch.value) {
finalMatch.value = {
participant1: winnersChampion,
participant2: losersChampion,
score1: 0,
score2: 0,
decided: false,
winner: null,
loser: null
};
}
}
};
// Zoom control functions for winners bracket
const zoomInWinners = () => {
if (winnerssvgSelection && winnersZoomBehavior) {
winnerssvgSelection.transition().duration(300).call(
winnersZoomBehavior.scaleBy, 1.5
);
}
};
const zoomOutWinners = () => {
if (winnerssvgSelection && winnersZoomBehavior) {
winnerssvgSelection.transition().duration(300).call(
winnersZoomBehavior.scaleBy, 1 / 1.5
);
}
};
const resetZoomWinners = () => {
if (winnerssvgSelection && winnersZoomBehavior) {
winnerssvgSelection.transition().duration(500).call(
winnersZoomBehavior.transform,
d3.zoomIdentity
);
}
};
// Zoom control functions for losers bracket
const zoomInLosers = () => {
if (loserssvgSelection && losersZoomBehavior) {
loserssvgSelection.transition().duration(300).call(
losersZoomBehavior.scaleBy, 1.5
);
}
};
const zoomOutLosers = () => {
if (loserssvgSelection && losersZoomBehavior) {
loserssvgSelection.transition().duration(300).call(
losersZoomBehavior.scaleBy, 1 / 1.5
);
}
};
const resetZoomLosers = () => {
if (loserssvgSelection && losersZoomBehavior) {
loserssvgSelection.transition().duration(500).call(
losersZoomBehavior.transform,
d3.zoomIdentity
);
}
};
const confirmFinal = async () => {
const match = finalMatch.value;
if (!match || match.score1 === match.score2) {
errorDialog.value = { visible: true, message: '决赛不能平局' };
return;
}
match.decided = true;
match.winner = match.score1 > match.score2 ? match.participant1 : match.participant2;
match.loser = match.score1 > match.score2 ? match.participant2 : match.participant1;
// 更新决赛选手胜负场数据
await updatePlayerStats(match.participant1, match.participant2, match.score1, match.score2, 'final');
// 双败淘汰赛的特殊规则bracket reset
// 如果败者组冠军participant2在决赛中获胜则需要重置比赛
if (match.winner === match.participant2) {
// Bracket Reset: 败者组冠军获胜,需要再打一场
successDialog.value = { visible: true, message: '败者组冠军获胜!比赛重置,将进行最终决赛!' };
finalMatch.value = {
participant1: match.participant2, // 原败者组冠军
participant2: match.participant1, // 原胜者组冠军
score1: 0,
score2: 0,
decided: false,
winner: null,
loser: null,
isResetMatch: true // 标记这是重置后的比赛
};
return;
}
// 如果胜者组冠军获胜,或者这是重置后的比赛,则确定最终名次
champion.value = match.winner.name || match.winner;
runnerUp.value = match.loser.name || match.loser;
// 在双败赛制中,季军是败者组决赛的败者
if (losersBracket.value.length > 0) {
const lastLoserRound = losersBracket.value[losersBracket.value.length - 1];
if (lastLoserRound && lastLoserRound.length > 0) {
const lastLoserMatch = lastLoserRound[lastLoserRound.length - 1];
if (lastLoserMatch && lastLoserMatch.loser) {
// 如果是重置比赛,季军应该是原来的胜者组冠军(如果败者组冠军最终获胜)
if (match.isResetMatch && match.winner === match.participant1) {
thirdPlace.value = match.participant2.name || match.participant2;
} else {
thirdPlace.value = lastLoserMatch.loser.name || lastLoserMatch.loser;
}
}
}
}
// 备选季军逻辑
if (!thirdPlace.value && losersBracket.value.length > 1) {
const semiLoserRound = losersBracket.value[losersBracket.value.length - 2];
if (semiLoserRound && semiLoserRound.length > 0) {
const semiLoserMatch = semiLoserRound[semiLoserRound.length - 1];
if (semiLoserMatch && semiLoserMatch.loser) {
thirdPlace.value = semiLoserMatch.loser.name || semiLoserMatch.loser;
}
}
}
// 更新最终排名状态
try {
const latestData = await getSignUpResultList();
// 更新冠军状态
const championData = latestData.find(item => item.sign_name === champion.value);
if (championData) {
await updateSignUpResult(championData.id, {
...championData,
status: 'win',
rank: '1'
});
}
// 更新亚军状态
const runnerUpData = latestData.find(item => item.sign_name === runnerUp.value);
if (runnerUpData) {
await updateSignUpResult(runnerUpData.id, {
...runnerUpData,
status: 'lose',
rank: '2'
});
}
// 更新季军状态
if (thirdPlace.value && thirdPlace.value !== '待定') {
const thirdPlaceData = latestData.find(item => item.sign_name === thirdPlace.value);
if (thirdPlaceData) {
await updateSignUpResult(thirdPlaceData.id, {
...thirdPlaceData,
status: 'lose',
rank: '3'
});
}
}
} catch (err) {
console.error('更新最终排名失败:', err);
}
};
// 更新选手胜负场数据到API
const updatePlayerStats = async (p1, p2, s1, s2, bracketType = 'winners') => {
if (!p1 || !p2) return;
try {
// 先获取最新的报名数据
const latestData = await getSignUpResultList();
const p1Latest = latestData.find(item => item.id === p1.id);
const p2Latest = latestData.find(item => item.id === p2.id);
if (!p1Latest || !p2Latest) return;
// 计算积分 - 胜者组获得全额积分,败者组获得一半积分
const pointMultiplier = bracketType === 'winners' ? 1 : 0.5;
const winPoints = s1 > s2 ? 1 * pointMultiplier : 0;
const losePoints = s2 > s1 ? 1 * pointMultiplier : 0;
// 更新选手1的数据
const p1Data = {
tournament_id: props.tournamentId,
tournament_name: tournament.value.name,
team_name: p1.team_name || '',
sign_name: p1.name,
win: String(Number(p1Latest.win || 0) + s1),
lose: String(Number(p1Latest.lose || 0) + s2),
status: s1 > s2 ? 'win' : 'lose',
qq_code: p1.qq_code || '',
points: String(Number(p1Latest.points || 0) + (s1 > s2 ? winPoints : 0)),
bracket_type: bracketType
};
// 更新选手2的数据
const p2Data = {
tournament_id: props.tournamentId,
tournament_name: tournament.value.name,
team_name: p2.team_name || '',
sign_name: p2.name,
win: String(Number(p2Latest.win || 0) + s2),
lose: String(Number(p2Latest.lose || 0) + s1),
status: s2 > s1 ? 'win' : 'lose',
qq_code: p2.qq_code || '',
points: String(Number(p2Latest.points || 0) + (s2 > s1 ? winPoints : 0)),
bracket_type: bracketType
};
// 调用API更新数据
await updateSignUpResult(p1.id, p1Data);
await updateSignUpResult(p2.id, p2Data);
// 刷新参赛者列表
await loadTournamentData();
} catch (err) {
console.error('更新选手数据失败:', err);
errorDialog.value = { visible: true, message: '更新选手数据失败' };
}
};
// Watch for prop changes
watch(() => props.tournamentId, (newId) => {
if (newId) {
loadTournamentData();
}
}, { immediate: false });
// Watch for bracket changes to redraw
watch([winnersBracket, losersBracket], () => {
nextTick(() => {
drawD3WinnersBracket();
drawD3LosersBracket();
});
}, { deep: true });
</script>
<style scoped>
h1 {
text-align: center;
color: #1a237e;
margin-bottom: 20px;
font-size: 1.8rem;
font-weight: normal;
}
.tournament-bracket-root {
font-family: Arial, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.container {
display: flex;
gap: 20px;
align-items: flex-start;
min-height: 500px;
}
.bracket-main {
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
}
.bracket-section {
background: #fff;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.bracket-section h2 {
color: #1a237e;
font-size: 1.2rem;
font-weight: 600;
margin: 0 0 15px 0;
}
.bracket-container {
position: relative;
overflow: auto;
background: #fff;
padding: 15px;
border-radius: 6px;
border: 1px solid #ccc;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
min-height: 400px;
display: flex;
align-items: flex-start;
}
.zoom-controls {
position: absolute;
top: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 5px;
z-index: 10;
background: rgba(255, 255, 255, 0.9);
border-radius: 6px;
padding: 5px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.zoom-btn {
width: 32px;
height: 32px;
background: #007acc;
border: none;
border-radius: 4px;
color: white;
font-size: 16px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.zoom-btn:hover {
background: #005a9e;
transform: scale(1.05);
}
.zoom-btn:active {
transform: scale(0.95);
}
.interaction-hint {
position: absolute;
bottom: 15px;
left: 15px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
z-index: 5;
user-select: none;
opacity: 0.8;
transition: opacity 0.3s ease;
}
.interaction-hint:hover {
opacity: 1;
}
.d3-bracket {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
.d3-bracket:active {
cursor: grabbing;
}
.final-match-container {
display: flex;
justify-content: center;
margin: 20px 0;
}
.final-match {
background: #f9f9f9;
border: 2px solid #007acc;
border-radius: 8px;
padding: 20px;
min-width: 300px;
}
.participant {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #eee;
}
.participant:last-of-type {
border-bottom: none;
}
.participant.winner {
font-weight: bold;
color: #2a7f2a;
background-color: #e8f5e8;
border-radius: 4px;
padding: 10px;
}
.participant span {
flex: 1;
margin-right: 10px;
}
input[type="number"] {
width: 60px;
text-align: center;
padding: 5px;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
background: #409EFF;
color: white;
border: none;
padding: 10px 15px;
border-radius: 4px;
cursor: pointer;
margin-top: 10px;
width: 100%;
font-size: 14px;
font-weight: bold;
transition: all 0.3s;
}
button:disabled {
background: #cccccc;
cursor: not-allowed;
}
button:hover:not(:disabled) {
background: #66b1ff;
}
</style>