赛事
This commit is contained in:
parent
c8fff3e5c8
commit
479b6c38a8
22
node_modules/.vite/deps/_metadata.json
generated
vendored
22
node_modules/.vite/deps/_metadata.json
generated
vendored
@ -1,32 +1,38 @@
|
||||
{
|
||||
"hash": "840712f5",
|
||||
"configHash": "a76fca65",
|
||||
"lockfileHash": "ab320c33",
|
||||
"browserHash": "dcab2661",
|
||||
"hash": "8d846fac",
|
||||
"configHash": "2595757c",
|
||||
"lockfileHash": "13f0fece",
|
||||
"browserHash": "2a4b858f",
|
||||
"optimized": {
|
||||
"axios": {
|
||||
"src": "../../axios/index.js",
|
||||
"file": "axios.js",
|
||||
"fileHash": "b46b61da",
|
||||
"fileHash": "4a809c8a",
|
||||
"needsInterop": false
|
||||
},
|
||||
"vue": {
|
||||
"src": "../../vue/dist/vue.runtime.esm-bundler.js",
|
||||
"file": "vue.js",
|
||||
"fileHash": "a29ac7ab",
|
||||
"fileHash": "9723eef7",
|
||||
"needsInterop": false
|
||||
},
|
||||
"vue-router": {
|
||||
"src": "../../vue-router/dist/vue-router.mjs",
|
||||
"file": "vue-router.js",
|
||||
"fileHash": "5cd170d9",
|
||||
"fileHash": "2ce656a9",
|
||||
"needsInterop": false
|
||||
},
|
||||
"vue-tournament-bracket": {
|
||||
"src": "../../vue-tournament-bracket/dist/vue-tournament-bracket.common.js",
|
||||
"file": "vue-tournament-bracket.js",
|
||||
"fileHash": "e7817223",
|
||||
"fileHash": "b4b6c896",
|
||||
"needsInterop": true
|
||||
},
|
||||
"xlsx": {
|
||||
"src": "../../xlsx/xlsx.mjs",
|
||||
"file": "xlsx.js",
|
||||
"fileHash": "87bb22e0",
|
||||
"needsInterop": false
|
||||
}
|
||||
},
|
||||
"chunks": {
|
||||
|
@ -1,436 +1,609 @@
|
||||
<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>
|
||||
<div class="bracket-wrapper" ref="bracketWrapper" @mousedown="startDrag" @mousemove="onDrag" @mouseup="stopDrag"
|
||||
@mouseleave="stopDrag" :style="{ transform: `scale(${scale}) translate(${position.x}px, ${position.y}px)` }">
|
||||
<div class="round-titles">
|
||||
<div v-for="(round, index) in rounds" :key="index" class="round-info">
|
||||
<div class="round-name">{{ getRoundName(index, rounds.length) }}</div>
|
||||
<div class="round-bo">{{ getRoundBo(index, rounds.length) }}</div>
|
||||
<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>
|
||||
<Bracket :rounds="formattedRounds" :options="bracketOptions" @onMatchClick="handleMatchClick" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { Bracket } from 'vue-tournament-bracket'
|
||||
import { ref, computed, onMounted, watch, nextTick, onUnmounted } from 'vue';
|
||||
import { getSignUpResultList, updateSignUpResult, getTournamentList } from '@/api/tournament';
|
||||
|
||||
const props = defineProps({
|
||||
isEditMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
tournamentId: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
tournamentName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
players: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:rounds'])
|
||||
const bracketContainer = ref(null)
|
||||
const bracketWrapper = ref(null)
|
||||
const scale = ref(1)
|
||||
const position = ref({ x: 0, y: 0 })
|
||||
const isDragging = ref(false)
|
||||
const dragStart = ref({ x: 0, y: 0 })
|
||||
const tournaments = ref([]);
|
||||
const selectedTournamentId = ref('');
|
||||
const selectedTournamentName = ref('');
|
||||
const participants = ref([]);
|
||||
const loading = ref(false);
|
||||
const finalRanking = ref(null);
|
||||
|
||||
const startDrag = (e) => {
|
||||
isDragging.value = true
|
||||
dragStart.value = {
|
||||
x: e.clientX - position.value.x,
|
||||
y: position.value.y
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
const onDrag = (e) => {
|
||||
if (isDragging.value) {
|
||||
position.value = {
|
||||
x: e.clientX - dragStart.value.x,
|
||||
y: position.value.y
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stopDrag = () => {
|
||||
isDragging.value = false
|
||||
}
|
||||
return arr;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (bracketContainer.value && bracketWrapper.value) {
|
||||
const containerRect = bracketContainer.value.getBoundingClientRect()
|
||||
const wrapperRect = bracketWrapper.value.getBoundingClientRect()
|
||||
position.value = {
|
||||
x: (containerRect.width - wrapperRect.width) / 2,
|
||||
y: 0
|
||||
}
|
||||
fetchTournamentList();
|
||||
});
|
||||
|
||||
const fetchTournamentList = async () => {
|
||||
try {
|
||||
const data = await getTournamentList();
|
||||
tournaments.value = data;
|
||||
} catch (err) {
|
||||
console.error('获取比赛列表失败:', err);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// rounds数据
|
||||
const rounds = ref([])
|
||||
|
||||
// 生成比赛轮次
|
||||
const generateRounds = (players) => {
|
||||
const totalPlayers = players.length
|
||||
if (totalPlayers < 2) return []
|
||||
|
||||
// 计算需要的轮次数量
|
||||
const roundsCount = Math.ceil(Math.log2(totalPlayers))
|
||||
|
||||
// 创建轮次数组
|
||||
const rounds = []
|
||||
|
||||
// 第一轮:随机打乱选手顺序并配对
|
||||
|
||||
|
||||
|
||||
// 处理第一轮比赛
|
||||
for (let i = 0; i < shuffledPlayers.length; i += 2) {
|
||||
const game = {
|
||||
player1: shuffledPlayers[i],
|
||||
player2: shuffledPlayers[i + 1] || null,
|
||||
winnerId: null
|
||||
}
|
||||
firstRoundGames.push(game)
|
||||
const handleTournamentChange = async () => {
|
||||
if (!selectedTournamentId.value) {
|
||||
selectedTournamentName.value = '';
|
||||
participants.value = [];
|
||||
tournament.value.id = null;
|
||||
tournament.value.name = '';
|
||||
tournament.value.participants = [];
|
||||
return;
|
||||
}
|
||||
|
||||
rounds.push({ games: firstRoundGames })
|
||||
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 firstRoundMatches = totalPlayers / 2;
|
||||
for (let i = 0; i < firstRoundMatches; i++) {
|
||||
const p1 = shuffled[i * 2];
|
||||
const p2 = shuffled[i * 2 + 1];
|
||||
let winner = null;
|
||||
let decided = false;
|
||||
if (p1 && !p2) {
|
||||
winner = p1;
|
||||
decided = true;
|
||||
} else if (!p1 && p2) {
|
||||
winner = p2;
|
||||
decided = true;
|
||||
}
|
||||
|
||||
// 获取选手的最新数据
|
||||
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 && p1 && !p2 ? 1 : (p1Latest?.win || 0),
|
||||
score2: decided && !p1 && p2 ? 1 : (p2Latest?.win || 0),
|
||||
decided
|
||||
});
|
||||
}
|
||||
|
||||
// 生成后续轮次
|
||||
for (let round = 1; round < roundsCount; round++) {
|
||||
const prevRound = rounds[round - 1]
|
||||
const currentRoundGames = []
|
||||
|
||||
// 每两个上一轮比赛产生一个当前轮次的比赛
|
||||
for (let i = 0; i < prevRound.games.length; i += 2) {
|
||||
const game = {
|
||||
player1: null,
|
||||
player2: null,
|
||||
winnerId: null
|
||||
}
|
||||
currentRoundGames.push(game)
|
||||
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,
|
||||
participant2: null,
|
||||
winner: null,
|
||||
score1: null,
|
||||
score2: null,
|
||||
decided: false
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
rounds.push({ games: currentRoundGames })
|
||||
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();
|
||||
}
|
||||
|
||||
return rounds
|
||||
// 新增:同步胜负到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 initializeBracket = () => {
|
||||
if (!props.players || props.players.length === 0) return
|
||||
// 只在初次或玩家变化时生成
|
||||
rounds.value = generateRounds(props.players.map((p, idx) => ({
|
||||
...p,
|
||||
number: idx + 1,
|
||||
win: p.win || '0',
|
||||
lose: p.lose || '0',
|
||||
status: p.status || 'tie',
|
||||
team_name: p.team_name || '个人',
|
||||
name: p.sign_name || p.name || '未知选手',
|
||||
})))
|
||||
}
|
||||
const updateNextRound = (match) => {
|
||||
if (!match.decided) return;
|
||||
|
||||
watch(() => props.players, () => {
|
||||
initializeBracket()
|
||||
}, { immediate: true })
|
||||
if (match.round === tournament.value.rounds) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 轮次名称
|
||||
const getRoundName = (index, totalRounds) => {
|
||||
if (totalRounds === 1) {
|
||||
return '决赛'
|
||||
} else if (totalRounds === 2) {
|
||||
return index === 0 ? '半决赛' : '决赛'
|
||||
} else if (totalRounds === 3) {
|
||||
return index === 0 ? '四分之一决赛' : index === 1 ? '半决赛' : '决赛'
|
||||
const nextRound = match.round + 1;
|
||||
const nextMatchIndex = Math.floor((match.matchNumber - 1) / 2);
|
||||
const nextMatch = tournament.value.matches.find(m => m.round === nextRound && m.matchNumber === nextMatchIndex + 1);
|
||||
|
||||
if (!nextMatch) return;
|
||||
|
||||
if ((match.matchNumber - 1) % 2 === 0) {
|
||||
nextMatch.participant1 = match.winner;
|
||||
} else {
|
||||
return `第${index + 1}轮`
|
||||
nextMatch.participant2 = match.winner;
|
||||
}
|
||||
}
|
||||
const getRoundBo = (index, totalRounds) => {
|
||||
if (index === totalRounds - 1) {
|
||||
return 'BO5'
|
||||
|
||||
if ((nextMatch.participant1 && !nextMatch.participant2) || (!nextMatch.participant1 && nextMatch.participant2)) {
|
||||
nextMatch.winner = nextMatch.participant1 || nextMatch.participant2;
|
||||
nextMatch.score1 = nextMatch.participant1 ? 1 : 0;
|
||||
nextMatch.score2 = nextMatch.participant2 ? 1 : 0;
|
||||
nextMatch.decided = true;
|
||||
updateNextRound(nextMatch);
|
||||
} else {
|
||||
return 'BO3'
|
||||
nextMatch.winner = null;
|
||||
nextMatch.score1 = null;
|
||||
nextMatch.score2 = null;
|
||||
nextMatch.decided = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化轮次数据为 vue-tournament-bracket 需要的格式
|
||||
const formattedRounds = computed(() => {
|
||||
return rounds.value.map((round, roundIndex) => ({
|
||||
title: getRoundName(roundIndex, rounds.value.length),
|
||||
matches: round.games.map((game, gameIndex) => {
|
||||
const nextMatchId = roundIndex < rounds.value.length - 1
|
||||
? Math.floor(gameIndex / 2)
|
||||
: null
|
||||
const calculateFinalRanking = () => {
|
||||
const finalMatch = tournament.value.matches.find(m => m.round === tournament.value.rounds);
|
||||
if (!finalMatch || !finalMatch.winner) return;
|
||||
|
||||
return {
|
||||
id: `${roundIndex}-${gameIndex}`,
|
||||
name: `比赛 ${gameIndex + 1}`,
|
||||
nextMatchId: nextMatchId !== null ? `${roundIndex + 1}-${nextMatchId}` : null,
|
||||
tournamentRoundText: getRoundBo(roundIndex, rounds.value.length),
|
||||
startTime: '',
|
||||
state: 'SCHEDULED',
|
||||
participants: [
|
||||
{
|
||||
id: game.player1?.id,
|
||||
name: game.player1?.name || '待定',
|
||||
status: game.winnerId === game.player1?.id ? 'WIN' : 'LOSE',
|
||||
resultText: `${game.player1?.win || 0}-${game.player1?.lose || 0}`
|
||||
},
|
||||
{
|
||||
id: game.player2?.id,
|
||||
name: game.player2?.name || '待定',
|
||||
status: game.winnerId === game.player2?.id ? 'WIN' : 'LOSE',
|
||||
resultText: `${game.player2?.win || 0}-${game.player2?.lose || 0}`
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
}))
|
||||
})
|
||||
const champion = finalMatch.winner;
|
||||
const runnerUp = finalMatch.participant1 && finalMatch.participant2
|
||||
? (finalMatch.winner.id === finalMatch.participant1.id ? finalMatch.participant2 : finalMatch.participant1)
|
||||
: null;
|
||||
|
||||
// 对阵图配置选项
|
||||
const bracketOptions = {
|
||||
style: {
|
||||
roundHeader: {
|
||||
backgroundColor: '#1976d2',
|
||||
color: '#fff'
|
||||
},
|
||||
connectorColor: '#1976d2',
|
||||
connectorColorHighlight: '#4caf50'
|
||||
},
|
||||
matchHeight: 100,
|
||||
roundHeaderHeight: 50,
|
||||
roundHeaderMargin: 20,
|
||||
roundHeaderFontSize: 16,
|
||||
matchWidth: 300,
|
||||
matchMargin: 20,
|
||||
participantHeight: 40,
|
||||
participantMargin: 5,
|
||||
participantFontSize: 14,
|
||||
participantPadding: 10
|
||||
}
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// 处理比赛点击事件
|
||||
const handleMatchClick = (match) => {
|
||||
if (!props.isEditMode) return
|
||||
const roundIndex = formattedRounds.value.findIndex(round =>
|
||||
round.matches.some(m => m.id === match.id)
|
||||
)
|
||||
const gameIndex = formattedRounds.value[roundIndex].matches.findIndex(m => m.id === match.id)
|
||||
const game = rounds.value[roundIndex].games[gameIndex]
|
||||
openWinnerDialog(roundIndex, gameIndex, game)
|
||||
}
|
||||
finalRanking.value = {
|
||||
champion: champion.name,
|
||||
runnerUp: runnerUp ? runnerUp.name : '未知',
|
||||
thirdPlace: semiFinalLosers.length > 0 ? semiFinalLosers[0].name : '未知'
|
||||
};
|
||||
};
|
||||
|
||||
// 选择胜者弹窗
|
||||
const showWinnerDialog = ref(false)
|
||||
const editingGame = ref(null)
|
||||
|
||||
const openWinnerDialog = (roundIndex, gameIndex, game) => {
|
||||
editingGame.value = {
|
||||
roundIndex,
|
||||
gameIndex,
|
||||
game
|
||||
}
|
||||
showWinnerDialog.value = true
|
||||
}
|
||||
|
||||
const setWinner = (playerId) => {
|
||||
if (editingGame.value) {
|
||||
const roundIndex = editingGame.value.roundIndex
|
||||
const gameIndex = editingGame.value.gameIndex
|
||||
const game = editingGame.value.game
|
||||
game.winnerId = playerId
|
||||
showWinnerDialog.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const closeWinnerDialog = () => {
|
||||
showWinnerDialog.value = false
|
||||
}
|
||||
// 监听赛程变化自动重绘连线
|
||||
watch(roundsMatches, () => {
|
||||
nextTick(() => drawConnections());
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tournament-bracket {
|
||||
<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;
|
||||
overflow-y: auto;
|
||||
background: #141414;
|
||||
color: #fff;
|
||||
height: auto;
|
||||
min-height: 600px;
|
||||
padding: 20px;
|
||||
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;
|
||||
}
|
||||
|
||||
.bracket-wrapper {
|
||||
transform-origin: center left;
|
||||
cursor: ew-resize;
|
||||
user-select: none;
|
||||
padding: 20px;
|
||||
min-height: calc(100% - 40px);
|
||||
.tournament-bracket-root .bracket {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 220px;
|
||||
gap: 40px 10px;
|
||||
position: relative;
|
||||
padding-top: 0;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
padding-bottom: 50px;
|
||||
min-width: 600px;
|
||||
min-height: 350px;
|
||||
}
|
||||
|
||||
.bracket-rounds {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
.tournament-bracket-root .round {
|
||||
display: grid;
|
||||
grid-auto-rows: 70px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.bracket-round {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
.tournament-bracket-root .round-title {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.bracket-game {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.player-info {
|
||||
background: #1976d2;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 300px;
|
||||
.tournament-bracket-root .match {
|
||||
background: #fafafa;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
margin: 4px 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.player-info.winner {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.player-info.defeated {
|
||||
background: #f44336;
|
||||
}
|
||||
|
||||
.player-number {
|
||||
color: #bbdefb;
|
||||
font-size: 12px;
|
||||
min-width: 16px;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 0.1);
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.player-score {
|
||||
.tournament-bracket-root .participant {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.win-score {
|
||||
color: #4caf50;
|
||||
font-weight: 500;
|
||||
.tournament-bracket-root .participant.winner {
|
||||
font-weight: bold;
|
||||
color: #2a7f2a;
|
||||
}
|
||||
|
||||
.score-separator {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
.tournament-bracket-root .score-input {
|
||||
width: 40px;
|
||||
font-size: 14px;
|
||||
padding: 2px 4px;
|
||||
margin-left: 6px;
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.lose-score {
|
||||
color: #f44336;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.set-winner-btn {
|
||||
margin-left: 12px;
|
||||
padding: 2px 8px;
|
||||
background: #ff9800;
|
||||
color: #fff;
|
||||
.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;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.set-winner-btn:hover {
|
||||
background: #e65100;
|
||||
.tournament-bracket-root .score-btn:disabled {
|
||||
background: #aaa;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.edit-dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
.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;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.tournament-bracket-root svg.bracket-lines path {
|
||||
stroke: #666;
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.edit-dialog {
|
||||
background: #1f1f1f;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
width: 400px;
|
||||
max-width: 90%;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dialog-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
background: #409EFF;
|
||||
color: #fff;
|
||||
border: none;
|
||||
.tournament-bracket-root .tournament-info {
|
||||
margin-bottom: 15px;
|
||||
padding: 10px;
|
||||
background: #e6f7ff;
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.confirm-btn:hover {
|
||||
background: #66b1ff;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: #f5f5f5;
|
||||
.tournament-bracket-root .loading {
|
||||
color: #666;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: #e8e8e8;
|
||||
.tournament-bracket-root select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
</style>
|
@ -75,6 +75,11 @@
|
||||
v-if="competition.status === 'finish'"
|
||||
:tournament-id="parseInt(route.query.id)"
|
||||
/>
|
||||
<tournament-bracket
|
||||
v-if="competition.status === 'starting'"
|
||||
:tournament-id="parseInt(route.query.id)"
|
||||
@refreshPlayers="fetchRegisteredPlayers"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-if="showEditForm" class="edit-dialog-overlay">
|
||||
|
Loading…
x
Reference in New Issue
Block a user