436 lines
9.2 KiB
Vue
436 lines
9.2 KiB
Vue
<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>
|
|
</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'
|
|
|
|
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 startDrag = (e) => {
|
|
isDragging.value = true
|
|
dragStart.value = {
|
|
x: e.clientX - position.value.x,
|
|
y: position.value.y
|
|
}
|
|
}
|
|
|
|
const onDrag = (e) => {
|
|
if (isDragging.value) {
|
|
position.value = {
|
|
x: e.clientX - dragStart.value.x,
|
|
y: position.value.y
|
|
}
|
|
}
|
|
}
|
|
|
|
const stopDrag = () => {
|
|
isDragging.value = false
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
})
|
|
|
|
// 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)
|
|
}
|
|
|
|
rounds.push({ games: firstRoundGames })
|
|
|
|
// 生成后续轮次
|
|
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)
|
|
}
|
|
|
|
rounds.push({ games: currentRoundGames })
|
|
}
|
|
|
|
return rounds
|
|
}
|
|
|
|
// 初始化对阵图
|
|
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 || '未知选手',
|
|
})))
|
|
}
|
|
|
|
watch(() => props.players, () => {
|
|
initializeBracket()
|
|
}, { immediate: true })
|
|
|
|
// 轮次名称
|
|
const getRoundName = (index, totalRounds) => {
|
|
if (totalRounds === 1) {
|
|
return '决赛'
|
|
} else if (totalRounds === 2) {
|
|
return index === 0 ? '半决赛' : '决赛'
|
|
} else if (totalRounds === 3) {
|
|
return index === 0 ? '四分之一决赛' : index === 1 ? '半决赛' : '决赛'
|
|
} else {
|
|
return `第${index + 1}轮`
|
|
}
|
|
}
|
|
const getRoundBo = (index, totalRounds) => {
|
|
if (index === totalRounds - 1) {
|
|
return 'BO5'
|
|
} else {
|
|
return 'BO3'
|
|
}
|
|
}
|
|
|
|
// 格式化轮次数据为 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
|
|
|
|
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 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
|
|
}
|
|
|
|
// 处理比赛点击事件
|
|
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)
|
|
}
|
|
|
|
// 选择胜者弹窗
|
|
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
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.tournament-bracket {
|
|
position: relative;
|
|
overflow-x: auto;
|
|
overflow-y: auto;
|
|
background: #141414;
|
|
color: #fff;
|
|
height: auto;
|
|
min-height: 600px;
|
|
padding: 20px;
|
|
}
|
|
|
|
.bracket-wrapper {
|
|
transform-origin: center left;
|
|
cursor: ew-resize;
|
|
user-select: none;
|
|
padding: 20px;
|
|
min-height: calc(100% - 40px);
|
|
position: relative;
|
|
padding-top: 0;
|
|
width: max-content;
|
|
min-width: 100%;
|
|
}
|
|
|
|
.bracket-rounds {
|
|
display: flex;
|
|
gap: 40px;
|
|
}
|
|
|
|
.bracket-round {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 24px;
|
|
}
|
|
|
|
.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;
|
|
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;
|
|
font-size: 14px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
flex: 1;
|
|
}
|
|
|
|
.player-score {
|
|
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;
|
|
}
|
|
|
|
.win-score {
|
|
color: #4caf50;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.score-separator {
|
|
color: rgba(255, 255, 255, 0.5);
|
|
}
|
|
|
|
.lose-score {
|
|
color: #f44336;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.set-winner-btn {
|
|
margin-left: 12px;
|
|
padding: 2px 8px;
|
|
background: #ff9800;
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.set-winner-btn:hover {
|
|
background: #e65100;
|
|
}
|
|
|
|
.edit-dialog-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.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;
|
|
border-radius: 4px;
|
|
padding: 8px 16px;
|
|
cursor: pointer;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.confirm-btn:hover {
|
|
background: #66b1ff;
|
|
}
|
|
|
|
.cancel-btn {
|
|
background: #f5f5f5;
|
|
color: #666;
|
|
border: none;
|
|
border-radius: 4px;
|
|
padding: 8px 16px;
|
|
cursor: pointer;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.cancel-btn:hover {
|
|
background: #e8e8e8;
|
|
}
|
|
</style> |