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