DCFronted/src/components/TournamentBracket.vue
2025-05-28 17:45:10 +08:00

658 lines
20 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="control-panel">
<h2>选择比赛</h2>
<div class="tournament-info">
<select v-model="selectedTournamentId" @change="handleTournamentChange">
<option value="">-- 请选择比赛 --</option>
<option v-for="tournament in tournaments" :key="tournament.id" :value="tournament.id">
{{ tournament.name }}
</option>
</select>
<div v-if="selectedTournamentId">
<strong>已选择:</strong> {{ selectedTournamentName }}
</div>
</div>
<h2>参赛者列表</h2>
<div id="player-list">
<div v-if="!selectedTournamentId" class="loading">请先选择比赛</div>
<div v-else-if="loading" class="loading">加载中...</div>
<div v-else>
<p v-if="participants.length === 0">暂无参赛者</p>
<template v-else>
<p>共 {{ participants.length }} 位参赛者</p>
<ul>
<li v-for="participant in participants" :key="participant.id">
{{ participant.name }}
</li>
</ul>
</template>
</div>
</div>
<h2>生成赛程</h2>
<button @click="generateBracket" :disabled="!canGenerateBracket">生成单败淘汰赛程</button>
<div v-if="finalRanking" id="finalRanking">
<h3>最终排名</h3>
<p>冠军: {{ finalRanking.champion }}</p>
<p>亚军: {{ finalRanking.runnerUp }}</p>
<p>季军: {{ finalRanking.thirdPlace }}</p>
</div>
</div>
<div class="bracket-container" id="bracketContainer">
<div class="bracket" id="bracket">
<template v-for="(roundMatches, rIdx) in roundsMatches" :key="rIdx">
<div class="round">
<div class="round-title"> {{ rIdx + 1 }} </div>
<template v-for="match in roundMatches" :key="match.id">
<div class="match" :id="'match-' + match.id">
<!-- 参赛者1 -->
<div class="participant" :class="{ winner: match.winner && match.participant1 && match.winner.id === match.participant1.id }">
{{ match.participant1 ? match.participant1.name : '轮空' }}
<input type="number" min="0" class="score-input"
v-model.number="match.score1"
:disabled="match.decided || !match.participant1 || !match.participant2"
:placeholder="match.participant1 ? (match.participant1.win || '0') : '0'"
>
</div>
<!-- 参赛者2 -->
<div class="participant" :class="{ winner: match.winner && match.participant2 && match.winner.id === match.participant2.id }">
{{ match.participant2 ? match.participant2.name : '轮空' }}
<input type="number" min="0" class="score-input"
v-model.number="match.score2"
:disabled="match.decided || !match.participant1 || !match.participant2"
:placeholder="match.participant2 ? (match.participant2.win || '0') : '0'"
>
</div>
<!-- 确认比分按钮 -->
<button class="score-btn"
:disabled="match.decided || !match.participant1 || !match.participant2"
@click="confirmScore(match)">
确认比分
</button>
</div>
</template>
</div>
</template>
</div>
<svg class="bracket-lines" id="bracketLines"></svg>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick, onUnmounted } from 'vue';
import { getSignUpResultList, updateSignUpResult, getTournamentList } from '@/api/tournament';
const props = defineProps({
tournamentId: {
type: Number,
required: true
}
});
const tournaments = ref([]);
const selectedTournamentId = ref('');
const selectedTournamentName = ref('');
const participants = ref([]);
const loading = ref(false);
const finalRanking = ref(null);
const tournament = ref({
id: props.tournamentId,
name: '',
participants: [],
rounds: 0,
matches: []
});
const canGenerateBracket = computed(() => {
return selectedTournamentId.value && participants.value.length >= 2;
});
const roundsMatches = computed(() => {
const arr = [];
for (let r = 1; r <= tournament.value.rounds; r++) {
arr.push(tournament.value.matches.filter(m => m.round === r));
}
return arr;
});
onMounted(() => {
fetchTournamentList();
});
const fetchTournamentList = async () => {
try {
const data = await getTournamentList();
tournaments.value = data;
} catch (err) {
console.error('获取比赛列表失败:', err);
}
};
const handleTournamentChange = async () => {
if (!selectedTournamentId.value) {
selectedTournamentName.value = '';
participants.value = [];
tournament.value.id = null;
tournament.value.name = '';
tournament.value.participants = [];
return;
}
const selectedTournament = tournaments.value.find(t => t.id === selectedTournamentId.value);
if (selectedTournament) {
selectedTournamentName.value = selectedTournament.name;
tournament.value.id = selectedTournamentId.value;
tournament.value.name = selectedTournament.name;
loading.value = true;
try {
const data = await getSignUpResultList();
const filtered = data.filter(item => item.tournament_id == selectedTournamentId.value);
participants.value = filtered.map(item => ({ id: item.id, name: item.sign_name }));
tournament.value.participants = participants.value;
} catch (err) {
console.error('获取参赛者失败:', err);
} finally {
loading.value = false;
}
}
};
const generateBracket = () => {
if (!canGenerateBracket.value) return;
finalRanking.value = null;
generateSingleEliminationBracket();
nextTick(() => drawConnections());
};
const generateSingleEliminationBracket = async () => {
tournament.value.matches = [];
tournament.value.rounds = Math.ceil(Math.log2(tournament.value.participants.length));
const totalPlayers = Math.pow(2, tournament.value.rounds);
const shuffled = [...tournament.value.participants].sort(() => Math.random() - 0.5);
const latestData = await getSignUpResultList();
const filteredData = latestData.filter(item => item.tournament_id == tournament.value.id);
while (shuffled.length < totalPlayers) {
shuffled.push(null);
}
// 生成第一轮比赛
const firstRoundMatchesCount = totalPlayers / 2;
for (let i = 0; i < firstRoundMatchesCount; i++) {
const p1 = shuffled[i * 2];
const p2 = shuffled[i * 2 + 1];
let winner = null;
let decided = false;
if (p1 && !p2) { // p1 gets a bye
winner = p1;
decided = true;
} else if (!p1 && p2) { // p2 gets a bye
winner = p2;
decided = true;
} else if (!p1 && !p2) { // double bye
winner = null; // Winner is null for a double bye
decided = true;
}
// If p1 && p2, winner is null, decided is false (normal match)
const p1Latest = p1 ? filteredData.find(item => item.id === p1.id) : null;
const p2Latest = p2 ? filteredData.find(item => item.id === p2.id) : null;
tournament.value.matches.push({
id: `${1}-${i + 1}`,
round: 1,
matchNumber: i + 1,
participant1: p1,
participant2: p2,
winner,
score1: decided && winner === p1 ? 1 : (p1Latest?.win || 0), // Score 1 for bye winner, else existing or 0
score2: decided && winner === p2 ? 1 : (p2Latest?.win || 0), // Score 1 for bye winner, else existing or 0
decided,
// participantXResolved flags are not needed for round 1 matches as they are resolved by definition or play
});
}
// 生成后续轮次
for (let r = 2; r <= tournament.value.rounds; r++) {
const matchCount = totalPlayers / Math.pow(2, r);
for (let i = 0; i < matchCount; i++) {
tournament.value.matches.push({
id: `${r}-${i + 1}`,
round: r,
matchNumber: i + 1,
participant1: null, // Placeholder, will be filled by previous round's winner
participant2: null, // Placeholder
participant1Resolved: false, // New flag
participant2Resolved: false, // New flag
winner: null,
score1: null,
score2: null,
decided: false
});
}
}
// 处理第一轮中的轮空晋级
const firstRoundActualMatches = tournament.value.matches.filter(m => m.round === 1);
for (const match of firstRoundActualMatches) {
if (match.decided) { // If the match was decided (bye, double bye)
updateNextRound(match); // Propagate its result (winner could be player or null)
}
}
};
const drawConnections = () => {
try {
const bracketLinesSVG = document.getElementById('bracketLines');
const bracketDiv = document.getElementById('bracket');
if (!bracketDiv || !bracketLinesSVG) return;
bracketLinesSVG.innerHTML = '';
const bracketRect = bracketDiv.getBoundingClientRect();
const matchElements = {};
document.querySelectorAll('.match').forEach(el => {
const id = el.id.replace('match-', '');
matchElements[id] = el;
});
tournament.value.matches.forEach(match => {
if (match.round === tournament.value.rounds) return;
if (!match.decided) return;
const nextRound = match.round + 1;
const nextMatchNumber = Math.floor((match.matchNumber - 1) / 2) + 1;
const nextMatchId = `${nextRound}-${nextMatchNumber}`;
const nextMatchEl = matchElements[nextMatchId];
const currentMatchEl = matchElements[match.id];
if (!nextMatchEl || !currentMatchEl) return;
const winnerIndex = (match.winner.id === (match.participant1 && match.participant1.id)) ? 1 : 2;
const curRect = currentMatchEl.getBoundingClientRect();
const nextRect = nextMatchEl.getBoundingClientRect();
const startX = curRect.right - bracketRect.left;
const startY = curRect.top - bracketRect.top + (winnerIndex === 1 ? 20 : 50);
const endX = nextRect.left - bracketRect.left;
const endY = nextRect.top - bracketRect.top + (match.matchNumber % 2 === 1 ? 20 : 50);
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute('stroke', '#666');
path.setAttribute('fill', 'none');
path.setAttribute('stroke-width', '2');
const midX = (startX + endX) / 2;
const d = `M${startX},${startY} L${midX},${startY} L${midX},${endY} L${endX},${endY}`;
path.setAttribute('d', d);
bracketLinesSVG.appendChild(path);
});
} catch (e) {
console.warn('drawConnections error:', e);
}
};
onUnmounted(() => {
const bracketLinesSVG = document.getElementById('bracketLines');
if (bracketLinesSVG) bracketLinesSVG.innerHTML = '';
});
const emit = defineEmits(['refreshPlayers']);
async function confirmScore(match) {
const s1 = Number(match.score1);
const s2 = Number(match.score2);
if (isNaN(s1) || isNaN(s2)) {
alert('请输入有效的比分');
return;
}
if (s1 === s2) {
alert('比分不能平局');
return;
}
match.score1 = s1;
match.score2 = s2;
match.winner = s1 > s2 ? match.participant1 : match.participant2;
match.decided = true;
updateNextRound(match);
// 重新绘制连线
nextTick(() => drawConnections());
if (match.round === tournament.value.rounds) {
calculateFinalRanking();
}
// 新增同步胜负到API
const p1 = match.participant1;
const p2 = match.participant2;
try {
if (p1 && p2) {
// 先获取最新的报名数据
const latestData = await getSignUpResultList();
const p1Latest = latestData.find(item => item.id === p1.id);
const p2Latest = latestData.find(item => item.id === p2.id);
// 补全字段使用最新的API数据
const p1Data = {
tournament_id: tournament.value.id,
tournament_name: tournament.value.name,
team_name: p1.team_name || '',
sign_name: p1.name || p1.sign_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 || p1.qq || ''
};
const p2Data = {
tournament_id: tournament.value.id,
tournament_name: tournament.value.name,
team_name: p2.team_name || '',
sign_name: p2.name || p2.sign_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 || p2.qq || ''
};
await updateSignUpResult(p1.id, p1Data);
await updateSignUpResult(p2.id, p2Data);
// 刷新报名数据和赛程
await handleTournamentChange();
// 强制同步 participants 和 tournament.value.participants
participants.value = [...participants.value];
tournament.value.participants = [...participants.value];
}
} catch (err) {
alert('更新数据失败');
console.error(err);
}
emit('refreshPlayers');
}
const updateNextRound = (currentDecidedMatch) => {
if (!currentDecidedMatch.decided) { // Winner can be null (e.g. double bye)
console.warn("updateNextRound called on non-decided match", currentDecidedMatch);
return;
}
if (currentDecidedMatch.round === tournament.value.rounds) {
return; // This was the final match or a match in the final round
}
const nextRound = currentDecidedMatch.round + 1;
const nextMatchIndexInRound = Math.floor((currentDecidedMatch.matchNumber - 1) / 2);
const nextMatch = tournament.value.matches.find(m => m.round === nextRound && m.matchNumber === nextMatchIndexInRound + 1);
if (!nextMatch) {
console.error("Could not find next match for", currentDecidedMatch);
return;
}
const isFeederForParticipant1 = (currentDecidedMatch.matchNumber - 1) % 2 === 0;
if (isFeederForParticipant1) {
nextMatch.participant1 = currentDecidedMatch.winner; // Winner might be null
nextMatch.participant1Resolved = true;
} else {
nextMatch.participant2 = currentDecidedMatch.winner; // Winner might be null
nextMatch.participant2Resolved = true;
}
// Only proceed if both participant slots for nextMatch are resolved
if (nextMatch.participant1Resolved && nextMatch.participant2Resolved) {
const p1 = nextMatch.participant1;
const p2 = nextMatch.participant2;
if (p1 && !p2) { // p1 advances due to p2 being a bye (null)
nextMatch.winner = p1;
nextMatch.score1 = 1; // Convention for bye win
nextMatch.score2 = 0;
nextMatch.decided = true;
} else if (!p1 && p2) { // p2 advances due to p1 being a bye (null)
nextMatch.winner = p2;
nextMatch.score1 = 0;
nextMatch.score2 = 1; // Convention for bye win
nextMatch.decided = true;
} else if (!p1 && !p2) { // Double bye in this nextMatch
nextMatch.winner = null; // No winner advances
nextMatch.score1 = 0;
nextMatch.score2 = 0;
nextMatch.decided = true; // This "match" is decided as a double bye
} else if (p1 && p2) { // Both participants are actual players, match is ready to be played
nextMatch.winner = null;
nextMatch.score1 = null; // Or 0, depending on display preference for undecided matches
nextMatch.score2 = null; // Or 0
nextMatch.decided = false;
} else {
// This case should ideally not be reached if logic is correct
console.warn("Unexpected state in updateNextRound for nextMatch:", nextMatch);
nextMatch.decided = false; // Default to not decided to prevent erroneous propagation
}
// If nextMatch itself was decided (due to byes), propagate its result further
if (nextMatch.decided) {
updateNextRound(nextMatch);
}
}
// If only one participant is resolved, wait for the other feeder match to complete.
};
const calculateFinalRanking = () => {
const finalMatch = tournament.value.matches.find(m => m.round === tournament.value.rounds);
if (!finalMatch || !finalMatch.winner) return;
const champion = finalMatch.winner;
const runnerUp = finalMatch.participant1 && finalMatch.participant2
? (finalMatch.winner.id === finalMatch.participant1.id ? finalMatch.participant2 : finalMatch.participant1)
: null;
let semiFinalMatches = tournament.value.matches.filter(m => m.round === tournament.value.rounds - 1);
let semiFinalLosers = [];
semiFinalMatches.forEach(m => {
if (m.decided && m.winner) {
const loser = (m.participant1 && m.participant1.id !== m.winner.id) ? m.participant1 : m.participant2;
if (loser) semiFinalLosers.push(loser);
}
});
finalRanking.value = {
champion: champion.name,
runnerUp: runnerUp ? runnerUp.name : '未知',
thirdPlace: semiFinalLosers.length > 0 ? semiFinalLosers[0].name : '未知'
};
};
// 监听赛程变化自动重绘连线
watch(roundsMatches, () => {
nextTick(() => drawConnections());
});
</script>
<style>
.tournament-bracket-root {
font-family: Arial, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.tournament-bracket-root * {
box-sizing: border-box;
}
.tournament-bracket-root ul {
padding-left: 20px;
margin: 0 0 10px 0;
}
.tournament-bracket-root li {
margin-bottom: 4px;
list-style: disc;
}
.tournament-bracket-root button {
outline: none;
}
.tournament-bracket-root input[type=number]::-webkit-inner-spin-button,
.tournament-bracket-root input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.tournament-bracket-root input[type=number] {
-moz-appearance: textfield;
}
.tournament-bracket-root h1 {
text-align: center;
}
.tournament-bracket-root .container {
display: flex;
gap: 20px;
align-items: flex-start;
min-height: 500px;
}
.tournament-bracket-root .control-panel {
flex: 1;
min-width: 280px;
background: #f0f0f0;
padding: 15px;
border-radius: 6px;
height: 100%;
box-sizing: border-box;
}
.tournament-bracket-root .bracket-container {
flex: 3;
position: relative;
overflow-x: 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;
}
.tournament-bracket-root .bracket {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 220px;
gap: 40px 10px;
position: relative;
padding-bottom: 50px;
min-width: 600px;
min-height: 350px;
}
.tournament-bracket-root .round {
display: grid;
grid-auto-rows: 70px;
gap: 20px;
}
.tournament-bracket-root .round-title {
text-align: center;
font-weight: bold;
margin-bottom: 15px;
}
.tournament-bracket-root .match {
background: #fafafa;
border: 1px solid #ddd;
border-radius: 6px;
padding: 8px 12px;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.1);
position: relative;
font-size: 14px;
}
.tournament-bracket-root .participant {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
cursor: default;
user-select: none;
}
.tournament-bracket-root .participant.winner {
font-weight: bold;
color: #2a7f2a;
}
.tournament-bracket-root .score-input {
width: 40px;
font-size: 14px;
padding: 2px 4px;
margin-left: 6px;
border: 1px solid #aaa;
border-radius: 3px;
}
.tournament-bracket-root .score-btn {
margin-top: 4px;
width: 100%;
background: #007acc;
border: none;
color: white;
font-weight: bold;
padding: 6px 0;
border-radius: 4px;
cursor: pointer;
}
.tournament-bracket-root .score-btn:disabled {
background: #aaa;
cursor: not-allowed;
}
.tournament-bracket-root #finalRanking {
margin-top: 15px;
background: #fffbdb;
border: 1px solid #f0e68c;
padding: 12px;
border-radius: 6px;
}
.tournament-bracket-root svg.bracket-lines {
position: absolute;
top: 40px;
left: 0;
pointer-events: none;
overflow: visible;
height: 100%;
width: 100%;
}
.tournament-bracket-root svg.bracket-lines path {
stroke: #666;
fill: none;
stroke-width: 2;
}
.tournament-bracket-root .tournament-info {
margin-bottom: 15px;
padding: 10px;
background: #e6f7ff;
border-radius: 4px;
}
.tournament-bracket-root .loading {
color: #666;
font-style: italic;
}
.tournament-bracket-root select {
width: 100%;
padding: 8px;
margin-bottom: 10px;
border-radius: 4px;
border: 1px solid #ccc;
}
</style>