928 lines
28 KiB
Vue
928 lines
28 KiB
Vue
<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> |