DCFronted/src/components/TournamentBracket.vue

928 lines
28 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="tournament-info">
<p><strong>总轮数:</strong> {{ totalRounds }} (基于 {{ participantCount }} 位参赛者)</p>
</div>
<div class="container">
<div class="bracket-container" id="bracketContainer">
<div class="zoom-controls">
<button class="zoom-btn" @click="zoomIn" title="放大 (+)">+</button>
<button class="zoom-btn" @click="zoomOut" title="缩小 (-)">-</button>
<button class="zoom-btn" @click="resetZoom" title="重置视图 (0)"></button>
</div>
<div class="interaction-hint" v-if="tournament.matches.length > 0">
<span>💡 点击比赛查看详情/录入比分 | 拖拽移动 | 滚轮缩放</span>
</div>
<svg id="d3-bracket" class="d3-bracket"></svg>
</div>
</div>
<!-- 比赛详情弹窗 -->
<MatchDetailsModal
:visible="showMatchModal"
:match="selectedMatch"
:is-organizer="isOrganizer"
@close="closeMatchModal"
@submit="handleMatchSubmit"
/>
<!-- 对话框组件 -->
<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, nextTick, onUnmounted } from 'vue';
import { getSignUpResultList, updateSignUpResult, getTournamentList, addSignUpResult } from '@/api/tournament';
import { getStoredUser } from '@/utils/jwt.js';
import * as d3 from 'd3';
import SuccessDialog from '@/components/SuccessDialog.vue';
import ErrorDialog from '@/components/ErrorDialog.vue';
import MatchDetailsModal from '@/components/MatchDetailsModal.vue';
const props = defineProps({
tournamentId: {
type: Number,
required: true
}
});
const emit = defineEmits(['refreshPlayers']);
// Dialog state
const successDialog = ref({ visible: false, message: '' });
const errorDialog = ref({ visible: false, message: '' });
// Match Modal state
const showMatchModal = ref(false);
const selectedMatch = ref({});
const isOrganizer = ref(false);
// Store zoom behavior for external control
let zoomBehavior = null;
let svgSelection = null;
const tournament = ref({
id: props.tournamentId,
name: '',
participants: [],
rounds: 0,
matches: [],
apiData: [],
organizer_qq: ''
});
// 计算总轮数和参赛人数
const totalRounds = computed(() => {
return tournament.value.rounds;
});
const participantCount = computed(() => {
return tournament.value.participants.length;
});
onMounted(() => {
checkPermission();
loadTournamentData();
// Add keyboard shortcuts for zoom control
const handleKeydown = (event) => {
if (event.target.tagName === 'INPUT') return;
switch(event.key) {
case '+':
case '=':
event.preventDefault();
zoomIn();
break;
case '-':
event.preventDefault();
zoomOut();
break;
case '0':
event.preventDefault();
resetZoom();
break;
}
};
document.addEventListener('keydown', handleKeydown);
// Store cleanup function
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown);
const svg = d3.select('#d3-bracket');
if (svg.node()) svg.selectAll('*').remove();
});
});
const checkPermission = async () => {
try {
const tournaments = await getTournamentList();
const selectedTournament = tournaments.find(t => t.id === props.tournamentId);
if (selectedTournament) {
tournament.value.organizer_qq = selectedTournament.qq_code;
const user = getStoredUser();
isOrganizer.value = user && String(user.qq_code) === String(selectedTournament.qq_code);
}
} catch (error) {
console.error('权限检查失败:', error);
}
};
const loadTournamentData = async () => {
if (!props.tournamentId) return;
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;
}
// 获取参赛者数据第0轮
const data = await getSignUpResultList();
const filtered = data.filter(item =>
item.tournament_id == props.tournamentId &&
parseInt(item.round || 0) === 0
);
const participants = filtered.map(item => ({ id: item.id, name: item.sign_name }));
tournament.value.participants = participants;
// 保存API数据
tournament.value.apiData = data.filter(item => item.tournament_id == tournament.value.id);
// 计算总轮数
tournament.value.rounds = Math.ceil(Math.log2(participants.length));
// 生成单败淘汰赛树状图
if (participants.length >= 2) {
await generateSingleEliminationBracket();
nextTick(() => drawD3Bracket());
}
} catch (err) {
errorDialog.value = { visible: true, message: '获取比赛数据失败: ' + err.message };
}
};
const generateSingleEliminationBracket = async () => {
tournament.value.matches = [];
// 获取所有API数据
const allData = await getSignUpResultList();
const tournamentData = allData.filter(item => item.tournament_id == tournament.value.id);
tournament.value.apiData = tournamentData;
// 按轮数分组数据
const roundsData = {};
tournamentData.forEach(item => {
const round = parseInt(item.round || 0);
if (!roundsData[round]) {
roundsData[round] = [];
}
roundsData[round].push(item);
});
// 获取所有存在的轮次
const existingRounds = Object.keys(roundsData).map(Number).sort((a, b) => a - b);
// 生成所有轮次的比赛
for (const round of existingRounds) {
if (round === 0) {
await generateRound0Matches(roundsData[round] || []);
} else {
await generateRoundMatches(round, roundsData[round] || []);
}
}
// 更新总轮数为实际存在的最大轮次
if (existingRounds.length > 0) {
tournament.value.rounds = Math.max(...existingRounds);
}
};
const generateRound0Matches = async (round0Data) => {
const matches = [];
const usedPlayers = new Set();
// 处理正常配对
for (const player of round0Data) {
if (usedPlayers.has(player.id)) continue;
const rivalName = player.rival_name;
const isBye = rivalName === '轮空' || rivalName === 'bye' || rivalName === 'BYE' || rivalName === 'Bye';
const isPending = rivalName === '待定' || rivalName === 'pending' || rivalName === 'PENDING' || rivalName === 'Pending';
if (isBye || isPending) continue;
// 寻找对手
const rival = round0Data.find(p =>
p.sign_name === rivalName && p.id !== player.id && !usedPlayers.has(p.id)
);
if (rival) {
const matchNumber = matches.length + 1;
matches.push({
id: `0-${matchNumber}`,
round: 0,
matchNumber: matchNumber,
participant1: { id: player.id, name: player.sign_name },
participant2: { id: rival.id, name: rival.sign_name },
winner: null,
score1: parseInt(player.win || 0),
score2: parseInt(rival.win || 0),
decided: false
});
usedPlayers.add(player.id);
usedPlayers.add(rival.id);
}
}
// 处理轮空
for (const player of round0Data) {
if (usedPlayers.has(player.id)) continue;
const rivalName = player.rival_name;
const isBye = rivalName === '轮空' || rivalName === 'bye' || rivalName === 'BYE' || rivalName === 'Bye';
if (isBye) {
const matchNumber = matches.length + 1;
matches.push({
id: `0-${matchNumber}`,
round: 0,
matchNumber: matchNumber,
participant1: { id: player.id, name: player.sign_name },
participant2: null,
winner: { id: player.id, name: player.sign_name },
score1: 1,
score2: 0,
decided: true
});
usedPlayers.add(player.id);
}
}
tournament.value.matches.push(...matches);
};
const generateRoundMatches = async (round, roundData) => {
const matches = [];
const usedPlayers = new Set();
// 处理已有数据的比赛
for (const player of roundData) {
if (usedPlayers.has(player.id)) continue;
const rivalName = player.rival_name;
const isBye = rivalName === '轮空' || rivalName === 'bye' || rivalName === 'BYE' || rivalName === 'Bye';
const isPending = rivalName === '待定' || rivalName === 'pending' || rivalName === 'PENDING' || rivalName === 'Pending';
if (isBye || isPending) continue;
// 寻找对手
const rival = roundData.find(p =>
p.sign_name === rivalName && p.id !== player.id && !usedPlayers.has(p.id)
);
if (rival) {
const matchNumber = matches.length + 1;
// 判断比赛是否已完成
const playerStatus = player.status;
const rivalStatus = rival.status;
const playerWin = parseInt(player.win || 0);
const rivalWin = parseInt(rival.win || 0);
// 如果任一玩家有胜场,说明比赛已完成
const isDecided = playerWin > 0 || rivalWin > 0 ||
playerStatus === 'win' || playerStatus === 'lose' ||
rivalStatus === 'win' || rivalStatus === 'lose';
// 确定获胜者
let winner = null;
if (playerStatus === 'win' || playerWin > rivalWin) {
winner = { id: player.id, name: player.sign_name };
} else if (rivalStatus === 'win' || rivalWin > playerWin) {
winner = { id: rival.id, name: rival.sign_name };
}
matches.push({
id: `${round}-${matchNumber}`,
round: round,
matchNumber: matchNumber,
participant1: { id: player.id, name: player.sign_name },
participant2: { id: rival.id, name: rival.sign_name },
winner: winner,
score1: parseInt(player.win || 0),
score2: parseInt(rival.win || 0),
decided: isDecided
});
usedPlayers.add(player.id);
usedPlayers.add(rival.id);
}
}
// 处理轮空
for (const player of roundData) {
if (usedPlayers.has(player.id)) continue;
const rivalName = player.rival_name;
const isBye = rivalName === '轮空' || rivalName === 'bye' || rivalName === 'BYE' || rivalName === 'Bye';
if (isBye) {
const matchNumber = matches.length + 1;
matches.push({
id: `${round}-${matchNumber}`,
round: round,
matchNumber: matchNumber,
participant1: { id: player.id, name: player.sign_name },
participant2: null,
winner: { id: player.id, name: player.sign_name },
score1: 1,
score2: 0,
decided: true
});
usedPlayers.add(player.id);
}
}
// 处理待定的玩家(单独显示)
for (const player of roundData) {
if (usedPlayers.has(player.id)) continue;
const rivalName = player.rival_name;
const isPending = rivalName === '待定' || rivalName === 'pending' || rivalName === 'PENDING' || rivalName === 'Pending';
if (isPending) {
const matchNumber = matches.length + 1;
// 判断比赛是否已完成如果玩家状态是win或lose或者有胜场说明比赛已完成
const playerWin = parseInt(player.win || 0);
const isDecided = player.status === 'win' || player.status === 'lose' || playerWin > 0;
let winner = null;
if (player.status === 'win' || playerWin > 0) {
winner = { id: player.id, name: player.sign_name };
}
matches.push({
id: `${round}-${matchNumber}`,
round: round,
matchNumber: matchNumber,
participant1: { id: player.id, name: player.sign_name },
participant2: null,
winner: winner,
score1: parseInt(player.win || 0),
score2: 0,
decided: isDecided
});
usedPlayers.add(player.id);
}
}
tournament.value.matches.push(...matches);
};
const openMatchModal = (match) => {
selectedMatch.value = { ...match };
showMatchModal.value = true;
};
const closeMatchModal = () => {
showMatchModal.value = false;
selectedMatch.value = {};
};
const handleMatchSubmit = async (updatedMatch) => {
await confirmScore(updatedMatch);
};
const confirmScore = async (match) => {
console.log('确认比分:', match);
// 处理轮空比赛
if (!match.participant2) {
if (!match.decided) {
match.winner = match.participant1;
match.decided = true;
match.score1 = 1;
match.score2 = 0;
}
// 更新轮空玩家的状态并创建新记录
try {
const p1Data = tournament.value.apiData.find(item => item.id === match.participant1.id);
if (p1Data) {
// 更新轮空玩家的状态
const winnerUpdate = {
...p1Data,
win: '1',
lose: '0',
status: 'win'
};
await updateSignUpResult(p1Data.id, winnerUpdate);
// 为轮空玩家创建新的API记录轮数+1
const nextRound = parseInt(p1Data.round || 0) + 1;
const newRecord = {
tournament_id: parseInt(p1Data.tournament_id),
tournament_name: p1Data.tournament_name,
team_name: p1Data.team_name || null,
sign_name: p1Data.sign_name,
win: '0',
lose: '0',
status: 'tie',
round: String(nextRound),
rival_name: '待定'
};
// 检查是否已存在该玩家的晋级记录
const existingRecord = tournament.value.apiData.find(item =>
item.sign_name === p1Data.sign_name &&
parseInt(item.round) === nextRound
);
if (!existingRecord) {
await addSignUpResult(newRecord);
// 尝试匹配对手
await matchOpponentsInRound(nextRound);
}
}
} catch (error) {
console.error('处理轮空晋级失败:', error);
}
nextTick(() => drawD3Bracket());
await loadTournamentData();
emit('refreshPlayers');
return;
}
// 验证比分
const s1 = Number(match.score1);
const s2 = Number(match.score2);
// 设置比赛结果
match.score1 = s1;
match.score2 = s2;
match.winner = s1 > s2 ? match.participant1 : match.participant2;
match.decided = true;
// 更新API数据
try {
const p1Data = tournament.value.apiData.find(item => item.id === match.participant1.id);
const p2Data = tournament.value.apiData.find(item => item.id === match.participant2.id);
if (p1Data && p2Data) {
// 更新玩家1
const update1 = {
...p1Data,
win: String(s1),
lose: String(s2),
status: s1 > s2 ? 'win' : 'lose'
};
await updateSignUpResult(p1Data.id, update1);
// 更新玩家2
const update2 = {
...p2Data,
win: String(s2),
lose: String(s1),
status: s2 > s1 ? 'win' : 'lose'
};
await updateSignUpResult(p2Data.id, update2);
// 处理晋级
await handlePlayerAdvancement(match);
successDialog.value = { visible: true, message: '比分已确认' };
await loadTournamentData();
emit('refreshPlayers');
}
} catch (error) {
console.error('更新比分失败:', error);
errorDialog.value = { visible: true, message: '更新比分失败' };
}
};
const handlePlayerAdvancement = async (match) => {
if (!match.winner) return;
const nextRound = match.round + 1;
// 检查是否到达最后一轮
if (nextRound > tournament.value.rounds) return;
// 获胜者晋级
try {
const winnerData = tournament.value.apiData.find(item => item.id === match.winner.id);
if (winnerData) {
// 检查是否已经存在该玩家在下一轮的记录
const existingNextRoundRecord = tournament.value.apiData.find(item =>
item.tournament_id == tournament.value.id &&
item.sign_name === winnerData.sign_name &&
parseInt(item.round || 0) === nextRound
);
if (existingNextRoundRecord) {
// 如果已存在下一轮记录,更新状态
const updateData = {
...existingNextRoundRecord,
win: '0',
lose: '0',
status: 'tie',
rival_name: '待定'
};
await updateSignUpResult(existingNextRoundRecord.id, updateData);
} else {
// 如果不存在下一轮记录,创建新记录
const advancementData = {
tournament_id: parseInt(winnerData.tournament_id),
tournament_name: winnerData.tournament_name,
team_name: winnerData.team_name || null,
sign_name: winnerData.sign_name,
win: '0',
lose: '0',
status: 'tie',
round: String(nextRound),
rival_name: '待定'
};
await addSignUpResult(advancementData);
}
// 尝试匹配对手
await matchOpponentsInRound(nextRound);
}
} catch (error) {
console.error('处理晋级记录失败:', error);
}
};
// 匹配指定轮次的对手
const matchOpponentsInRound = async (round) => {
try {
// 重新获取最新数据
const allData = await getSignUpResultList();
const roundData = allData.filter(item =>
item.tournament_id == tournament.value.id &&
parseInt(item.round) === round &&
item.rival_name === '待定'
);
// 如果待定玩家数量为偶数,可以两两匹配
if (roundData.length >= 2 && roundData.length % 2 === 0) {
// 按ID排序确保从上到下的顺序
const sortedPlayers = roundData.sort((a, b) => a.id - b.id);
// 按照从上到下的顺序两两匹配
for (let i = 0; i < sortedPlayers.length; i += 2) {
const player1 = sortedPlayers[i];
const player2 = sortedPlayers[i + 1];
if (player1 && player2) {
// 更新player1的对手
await updateSignUpResult(player1.id, {
...player1,
rival_name: player2.sign_name
});
// 更新player2的对手
await updateSignUpResult(player2.id, {
...player2,
rival_name: player1.sign_name
});
}
}
}
} catch (error) {
console.error(`匹配第${round}轮对手失败:`, error);
}
};
// D3.js drawing function
const drawD3Bracket = () => {
const margin = { top: 40, right: 40, bottom: 40, left: 40 };
const matchWidth = 200;
const matchHeight = 100;
const roundGap = 80;
const matchGap = 40;
const rounds = tournament.value.rounds + 1;
// 计算每一轮的比赛数量
const matchesPerRound = [];
for (let i = 0; i < rounds; i++) {
const matchesInRound = tournament.value.matches.filter(m => m.round === i);
matchesPerRound.push(matchesInRound.length || Math.pow(2, tournament.value.rounds - i - 1));
}
const maxMatchesInRound = Math.max(...matchesPerRound);
const totalWidth = rounds * (matchWidth + roundGap) + margin.left + margin.right;
const totalHeight = maxMatchesInRound * (matchHeight + matchGap) + margin.top + margin.bottom;
const container = document.getElementById('bracketContainer');
const containerWidth = container.clientWidth - 30;
const containerHeight = container.clientHeight - 30;
const svg = d3.select('#d3-bracket')
.attr('width', containerWidth)
.attr('height', containerHeight)
.attr('viewBox', `0 0 ${totalWidth} ${totalHeight}`)
.attr('preserveAspectRatio', 'xMidYMid meet');
// Clear previous content
svg.selectAll('*').remove();
// Create zoom behavior
const zoom = d3.zoom()
.scaleExtent([0.1, 3])
.on('zoom', (event) => {
g.attr('transform', `translate(${margin.left},${margin.top}) ${event.transform}`);
});
zoomBehavior = zoom;
svgSelection = 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
for (let roundIndex = 0; roundIndex < rounds; roundIndex++) {
const roundMatches = tournament.value.matches.filter(m => m.round === roundIndex)
.sort((a, b) => a.matchNumber - b.matchNumber);
if (roundMatches.length === 0) continue;
const roundX = roundIndex * (matchWidth + roundGap);
// Calculate Y positions to center matches relative to previous round
const roundStartY = (totalHeight - margin.top - margin.bottom - (roundMatches.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', '16px')
.style('fill', '#606266')
.text(`${roundIndex + 1}`);
roundMatches.forEach((match, index) => {
// Calculate Y position based on bracket logic (centering parents)
let matchY;
if (roundIndex === 0) {
matchY = roundStartY + index * (matchHeight + matchGap);
} else {
// Find child matches in previous round
const prevRoundMatches = tournament.value.matches.filter(m => m.round === roundIndex - 1)
.sort((a, b) => a.matchNumber - b.matchNumber);
const childMatch1 = prevRoundMatches[index * 2];
const childMatch2 = prevRoundMatches[index * 2 + 1];
if (childMatch1 && childMatch2 && childMatch1._y !== undefined && childMatch2._y !== undefined) {
matchY = (childMatch1._y + childMatch2._y) / 2 - matchHeight / 2;
} else if (childMatch1 && childMatch1._y !== undefined) {
matchY = childMatch1._y; // Should not happen in standard bracket but handle safely
} else {
matchY = roundStartY + index * (matchHeight + matchGap);
}
}
// Match container group
const matchGroup = g.append('g')
.attr('class', 'match-group')
.attr('transform', `translate(${roundX}, ${matchY})`)
.style('cursor', 'pointer')
.on('click', () => openMatchModal(match));
// Match background
matchGroup.append('rect')
.attr('width', matchWidth)
.attr('height', matchHeight)
.attr('rx', 8)
.style('fill', 'white')
.style('stroke', match.decided ? '#67c23a' : '#dcdfe6')
.style('stroke-width', match.decided ? 2 : 1)
.style('filter', 'drop-shadow(0 2px 4px rgba(0,0,0,0.05))');
// Match ID
matchGroup.append('text')
.attr('x', 10)
.attr('y', 20)
.style('font-size', '12px')
.style('fill', '#909399')
.text(`#${match.matchNumber}`);
// Participant 1
const p1Group = matchGroup.append('g').attr('transform', `translate(10, 35)`);
p1Group.append('rect')
.attr('width', matchWidth - 20)
.attr('height', 24)
.attr('rx', 4)
.style('fill', match.winner && match.participant1 && match.winner.id === match.participant1.id ? '#f0f9eb' : '#f5f7fa');
p1Group.append('text')
.attr('x', 8)
.attr('y', 17)
.style('font-size', '13px')
.style('font-weight', match.winner && match.participant1 && match.winner.id === match.participant1.id ? 'bold' : 'normal')
.style('fill', '#303133')
.text(match.participant1 ? match.participant1.name : '待定');
if (match.decided) {
p1Group.append('text')
.attr('x', matchWidth - 30)
.attr('y', 17)
.style('font-weight', 'bold')
.style('text-anchor', 'end')
.text(match.score1);
}
// Participant 2
const p2Group = matchGroup.append('g').attr('transform', `translate(10, 65)`);
p2Group.append('rect')
.attr('width', matchWidth - 20)
.attr('height', 24)
.attr('rx', 4)
.style('fill', match.winner && match.participant2 && match.winner.id === match.participant2.id ? '#f0f9eb' : '#f5f7fa');
p2Group.append('text')
.attr('x', 8)
.attr('y', 17)
.style('font-size', '13px')
.style('font-weight', match.winner && match.participant2 && match.winner.id === match.participant2.id ? 'bold' : 'normal')
.style('fill', '#303133')
.text(match.participant2 ? match.participant2.name : '待定');
if (match.decided) {
p2Group.append('text')
.attr('x', matchWidth - 30)
.attr('y', 17)
.style('font-weight', 'bold')
.style('text-anchor', 'end')
.text(match.score2);
}
// Store coordinates for connections
match._x = roundX + matchWidth;
match._y = matchY + matchHeight / 2;
match._leftX = roundX;
});
}
// Draw connections
tournament.value.matches.forEach(match => {
if (match.round > 0) {
// Find parents
const prevRoundMatches = tournament.value.matches.filter(m => m.round === match.round - 1)
.sort((a, b) => a.matchNumber - b.matchNumber);
// The match index in current round determines which parents it has
const currentRoundMatches = tournament.value.matches.filter(m => m.round === match.round)
.sort((a, b) => a.matchNumber - b.matchNumber);
const matchIndex = currentRoundMatches.findIndex(m => m.id === match.id);
const parent1 = prevRoundMatches[matchIndex * 2];
const parent2 = prevRoundMatches[matchIndex * 2 + 1];
if (parent1 && parent1._x !== undefined) {
drawConnection(g, parent1._x, parent1._y, match._leftX, match._y);
}
if (parent2 && parent2._x !== undefined) {
drawConnection(g, parent2._x, parent2._y, match._leftX, match._y);
}
}
});
};
const drawConnection = (g, x1, y1, x2, y2) => {
const midX = (x1 + x2) / 2;
g.append('path')
.attr('d', `M${x1},${y1} L${midX},${y1} L${midX},${y2} L${x2},${y2}`)
.style('stroke', '#c0c4cc')
.style('stroke-width', 2)
.style('fill', 'none');
};
// Zoom controls
const zoomIn = () => {
if (zoomBehavior && svgSelection) {
zoomBehavior.scaleBy(svgSelection.transition().duration(300), 1.2);
}
};
const zoomOut = () => {
if (zoomBehavior && svgSelection) {
zoomBehavior.scaleBy(svgSelection.transition().duration(300), 0.8);
}
};
const resetZoom = () => {
if (zoomBehavior && svgSelection) {
svgSelection.transition().duration(300).call(zoomBehavior.transform, d3.zoomIdentity);
}
};
</script>
<style scoped>
.tournament-bracket-root {
display: flex;
flex-direction: column;
height: 100%;
background: #f5f7fa;
}
.tournament-info {
padding: 10px 20px;
background: white;
border-bottom: 1px solid #ebeef5;
}
.container {
flex: 1;
position: relative;
overflow: hidden;
padding: 20px;
}
.bracket-container {
width: 100%;
height: 600px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0,0,0,0.05);
position: relative;
overflow: hidden;
}
.zoom-controls {
position: absolute;
bottom: 20px;
right: 20px;
display: flex;
gap: 8px;
z-index: 10;
}
.zoom-btn {
width: 32px;
height: 32px;
border: 1px solid #dcdfe6;
background: white;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: #606266;
transition: all 0.3s;
}
.zoom-btn:hover {
background: #ecf5ff;
color: #409EFF;
border-color: #c6e2ff;
}
.interaction-hint {
position: absolute;
top: 20px;
left: 20px;
background: rgba(255, 255, 255, 0.9);
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
color: #909399;
pointer-events: none;
z-index: 10;
border: 1px solid #ebeef5;
}
.d3-bracket {
width: 100%;
height: 100%;
}
</style>