1263 lines
39 KiB
Vue
1263 lines
39 KiB
Vue
<template>
|
||
<div class="tournament-bracket-root">
|
||
<h1>双败淘汰赛赛程树状图</h1>
|
||
<div class="container">
|
||
<!-- 比赛对战表 -->
|
||
<div class="bracket-main">
|
||
<!-- 胜者组 -->
|
||
<div class="bracket-section">
|
||
<h2>胜者组</h2>
|
||
<div class="bracket-container" id="winnersBracketContainer">
|
||
<div class="zoom-controls">
|
||
<button class="zoom-btn" @click="zoomInWinners" title="放大 (+)">+</button>
|
||
<button class="zoom-btn" @click="zoomOutWinners" title="缩小 (-)">-</button>
|
||
<button class="zoom-btn" @click="resetZoomWinners" title="重置视图 (0)">⌂</button>
|
||
</div>
|
||
<div class="interaction-hint" v-if="winnersBracket.length > 0">
|
||
<span>💡 拖拽移动 | 滚轮缩放 | 快捷键: +放大 -缩小 0重置</span>
|
||
</div>
|
||
<svg id="d3-winners-bracket" class="d3-bracket"></svg>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 败者组 -->
|
||
<div class="bracket-section">
|
||
<h2>败者组</h2>
|
||
<div class="bracket-container" id="losersBracketContainer">
|
||
<div class="zoom-controls">
|
||
<button class="zoom-btn" @click="zoomInLosers" title="放大 (+)">+</button>
|
||
<button class="zoom-btn" @click="zoomOutLosers" title="缩小 (-)">-</button>
|
||
<button class="zoom-btn" @click="resetZoomLosers" title="重置视图 (0)">⌂</button>
|
||
</div>
|
||
<div class="interaction-hint" v-if="losersBracket.length > 0">
|
||
<span>💡 拖拽移动 | 滚轮缩放 | 快捷键: +放大 -缩小 0重置</span>
|
||
</div>
|
||
<svg id="d3-losers-bracket" class="d3-bracket"></svg>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 决赛 -->
|
||
<div class="bracket-section" v-if="finalMatch">
|
||
<h2>决赛 (BO5)</h2>
|
||
<div class="final-match-container">
|
||
<div class="final-match">
|
||
<div class="participant" :class="{ winner: finalMatch.decided && finalMatch.score1 > finalMatch.score2 }">
|
||
<span>{{ finalMatch.participant1 ? finalMatch.participant1.name : '待定' }}</span>
|
||
<input type="number" v-model.number="finalMatch.score1" :disabled="finalMatch.decided" placeholder="0">
|
||
</div>
|
||
<div class="participant" :class="{ winner: finalMatch.decided && finalMatch.score2 > finalMatch.score1 }">
|
||
<span>{{ finalMatch.participant2 ? finalMatch.participant2.name : '待定' }}</span>
|
||
<input type="number" v-model.number="finalMatch.score2" :disabled="finalMatch.decided" placeholder="0">
|
||
</div>
|
||
<button @click="confirmFinal" :disabled="finalMatch.decided">确认决赛比分</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 对话框组件 -->
|
||
<SuccessDialog
|
||
:visible="successDialog.visible"
|
||
:message="successDialog.message"
|
||
@close="successDialog.visible = false"
|
||
/>
|
||
<ErrorDialog
|
||
:visible="errorDialog.visible"
|
||
:message="errorDialog.message"
|
||
@close="errorDialog.visible = false"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, watch, nextTick, onUnmounted } from 'vue';
|
||
import { getSignUpResultList, updateSignUpResult, getTournamentList } from '@/api/tournament';
|
||
import * as d3 from 'd3';
|
||
import SuccessDialog from '@/components/SuccessDialog.vue';
|
||
import ErrorDialog from '@/components/ErrorDialog.vue';
|
||
|
||
const props = defineProps({
|
||
tournamentId: {
|
||
type: Number,
|
||
required: true
|
||
}
|
||
});
|
||
|
||
const tournament = ref({
|
||
id: props.tournamentId,
|
||
name: ''
|
||
});
|
||
|
||
const participants = ref([]);
|
||
const winnersBracket = ref([]);
|
||
const losersBracket = ref([]);
|
||
const finalMatch = ref(null);
|
||
const champion = ref(null);
|
||
const runnerUp = ref(null);
|
||
const thirdPlace = ref(null);
|
||
const loading = ref(false);
|
||
|
||
// Dialog state
|
||
const successDialog = ref({ visible: false, message: '' });
|
||
const errorDialog = ref({ visible: false, message: '' });
|
||
|
||
// Store zoom behaviors for both brackets
|
||
let winnersZoomBehavior = null;
|
||
let winnerssvgSelection = null;
|
||
let losersZoomBehavior = null;
|
||
let loserssvgSelection = null;
|
||
|
||
|
||
|
||
onMounted(() => {
|
||
loadTournamentData();
|
||
|
||
// Add keyboard shortcuts for zoom control
|
||
const handleKeydown = (event) => {
|
||
if (event.target.tagName === 'INPUT') return;
|
||
switch(event.key) {
|
||
case '+':
|
||
case '=':
|
||
event.preventDefault();
|
||
if (event.ctrlKey) {
|
||
zoomInLosers();
|
||
} else {
|
||
zoomInWinners();
|
||
}
|
||
break;
|
||
case '-':
|
||
event.preventDefault();
|
||
if (event.ctrlKey) {
|
||
zoomOutLosers();
|
||
} else {
|
||
zoomOutWinners();
|
||
}
|
||
break;
|
||
case '0':
|
||
event.preventDefault();
|
||
if (event.ctrlKey) {
|
||
resetZoomLosers();
|
||
} else {
|
||
resetZoomWinners();
|
||
}
|
||
break;
|
||
}
|
||
};
|
||
|
||
document.addEventListener('keydown', handleKeydown);
|
||
|
||
onUnmounted(() => {
|
||
document.removeEventListener('keydown', handleKeydown);
|
||
const winnersSvg = d3.select('#d3-winners-bracket');
|
||
const losersSvg = d3.select('#d3-losers-bracket');
|
||
if (winnersSvg.node()) winnersSvg.selectAll('*').remove();
|
||
if (losersSvg.node()) losersSvg.selectAll('*').remove();
|
||
});
|
||
});
|
||
|
||
const loadTournamentData = async () => {
|
||
if (!props.tournamentId) return;
|
||
|
||
loading.value = true;
|
||
tournament.value.id = props.tournamentId;
|
||
|
||
try {
|
||
const tournaments = await getTournamentList();
|
||
const selectedTournament = tournaments.find(t => t.id === props.tournamentId);
|
||
if (selectedTournament) {
|
||
tournament.value.name = selectedTournament.name;
|
||
}
|
||
|
||
const data = await getSignUpResultList();
|
||
const filtered = data.filter(item => item.tournament_id == props.tournamentId);
|
||
participants.value = filtered.map(item => ({
|
||
id: item.id,
|
||
name: item.sign_name,
|
||
team_name: item.team_name || '',
|
||
win: item.win || '0',
|
||
lose: item.lose || '0',
|
||
qq_code: item.qq_code || '',
|
||
status: item.status || ''
|
||
}));
|
||
|
||
// 数据加载完成后自动生成赛程
|
||
if (participants.value.length >= 4) {
|
||
generateDoubleEliminationBracket();
|
||
}
|
||
} catch (err) {
|
||
console.error('获取比赛数据失败:', err);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
const generateDoubleEliminationBracket = () => {
|
||
// 重置所有状态
|
||
winnersBracket.value = [];
|
||
losersBracket.value = [];
|
||
finalMatch.value = null;
|
||
champion.value = runnerUp.value = thirdPlace.value = null;
|
||
|
||
const totalPlayers = participants.value.length;
|
||
if (totalPlayers < 4) {
|
||
errorDialog.value = { visible: true, message: '双败淘汰赛至少需要4名参赛者' };
|
||
return;
|
||
}
|
||
|
||
// 计算需要的总槽位数(最近的2的幂)
|
||
const totalSlots = Math.pow(2, Math.ceil(Math.log2(totalPlayers)));
|
||
const shuffledParticipants = [...participants.value].sort(() => Math.random() - 0.5);
|
||
|
||
// 填充到总槽位数,剩余位置为null(轮空)
|
||
while (shuffledParticipants.length < totalSlots) {
|
||
shuffledParticipants.push(null);
|
||
}
|
||
console.log(`总参赛者: ${totalPlayers}, 总槽位: ${totalSlots}`);
|
||
// 生成胜者组第一轮
|
||
const winnersRound1 = [];
|
||
for (let i = 0; i < totalSlots; i += 2) {
|
||
const p1 = shuffledParticipants[i];
|
||
const p2 = shuffledParticipants[i + 1];
|
||
// 处理轮空情况
|
||
let winner = null;
|
||
let decided = false;
|
||
let score1 = 0;
|
||
let score2 = 0;
|
||
if (p1 && !p2) {
|
||
// p1轮空直接晋级
|
||
winner = p1;
|
||
decided = true;
|
||
score1 = 1;
|
||
score2 = 0;
|
||
console.log(`${p1.name} 轮空直接晋级`);
|
||
} else if (!p1 && p2) {
|
||
// p2轮空直接晋级
|
||
winner = p2;
|
||
decided = true;
|
||
score1 = 0;
|
||
score2 = 1;
|
||
console.log(`${p2.name} 轮空直接晋级`);
|
||
} else if (!p1 && !p2) {
|
||
// 双轮空,无人晋级
|
||
winner = null;
|
||
decided = true;
|
||
score1 = 0;
|
||
score2 = 0;
|
||
console.log('双轮空,无人晋级');
|
||
}
|
||
|
||
winnersRound1.push({
|
||
participant1: p1,
|
||
participant2: p2,
|
||
score1: score1,
|
||
score2: score2,
|
||
decided: decided,
|
||
winner: winner,
|
||
loser: null // 轮空情况下没有败者
|
||
});
|
||
}
|
||
winnersBracket.value = [winnersRound1];
|
||
// 预生成胜者组的所有轮次结构
|
||
let currentRoundSize = Math.ceil(totalSlots / 2);
|
||
while (currentRoundSize > 1) {
|
||
currentRoundSize = Math.ceil(currentRoundSize / 2);
|
||
const nextRound = [];
|
||
for (let i = 0; i < currentRoundSize; i++) {
|
||
nextRound.push({
|
||
participant1: null,
|
||
participant2: null,
|
||
score1: 0,
|
||
score2: 0,
|
||
decided: false,
|
||
winner: null,
|
||
loser: null
|
||
});
|
||
}
|
||
if (currentRoundSize > 0) {
|
||
winnersBracket.value.push(nextRound);
|
||
}
|
||
}
|
||
console.log(`胜者组轮次数: ${winnersBracket.value.length}`);
|
||
// 预生成败者组结构
|
||
generateLosersBracketStructure(totalPlayers);
|
||
// 处理第一轮的轮空晋级
|
||
winnersRound1.forEach((match, index) => {
|
||
if (match.decided && match.winner) {
|
||
// 轮空获胜者直接晋级到胜者组下一轮
|
||
handleWinnersBracketAdvancement(match, 0, index);
|
||
}
|
||
});
|
||
|
||
nextTick(() => {
|
||
drawD3WinnersBracket();
|
||
drawD3LosersBracket();
|
||
});
|
||
};
|
||
|
||
// 生成败者组结构 - 根据标准双败逻辑
|
||
const generateLosersBracketStructure = (totalPlayers) => {
|
||
const winnersRounds = winnersBracket.value.length;
|
||
losersBracket.value = [];
|
||
|
||
// 标准双败淘汰赛败者组结构:
|
||
// 计算实际的参赛者数量和轮空数量
|
||
const totalSlots = Math.pow(2, Math.ceil(Math.log2(totalPlayers)));
|
||
const byeCount = totalSlots - totalPlayers;
|
||
|
||
console.log(`总人数: ${totalPlayers}, 总槽位: ${totalSlots}, 轮空数: ${byeCount}`);
|
||
|
||
// 败者组轮次数计算:考虑轮空影响
|
||
let losersRounds;
|
||
if (winnersRounds === 1) {
|
||
losersRounds = 0; // 2人比赛无败者组
|
||
} else if (totalPlayers <= 4) {
|
||
losersRounds = 2; // 4人或5人比赛,败者组2轮
|
||
} else {
|
||
losersRounds = (winnersRounds - 1) * 2;
|
||
}
|
||
|
||
console.log(`胜者组轮次: ${winnersRounds}, 败者组轮次: ${losersRounds}`);
|
||
|
||
if (losersRounds === 0) {
|
||
console.log('只有2人比赛,不需要败者组');
|
||
return;
|
||
}
|
||
|
||
for (let round = 0; round < losersRounds; round++) {
|
||
const roundMatches = [];
|
||
let matchCount = 1;
|
||
// 败者组比赛数量规律:
|
||
// LR1: 1场 (胜者组第1轮败者们对战,但要考虑轮空)
|
||
// LR2: 1场 (LR1胜者 vs 胜者组第2轮败者)
|
||
if (round === 0) {
|
||
// 第一轮败者组:计算实际的败者数量
|
||
const firstRoundLosers = Math.floor(totalPlayers / 2); // 第一轮实际对战产生的败者数
|
||
if (firstRoundLosers >= 2) {
|
||
matchCount = 1; // 至少需要1场比赛
|
||
} else if (firstRoundLosers === 1) {
|
||
// 如果只有1个败者,这个败者直接晋级到下一轮
|
||
matchCount = 0; // 这轮不需要比赛
|
||
}
|
||
} else {
|
||
matchCount = 1; // 其他轮次通常是1场
|
||
}
|
||
// 确保至少有0场比赛(可能某轮不需要比赛)
|
||
matchCount = Math.max(0, matchCount);
|
||
if (matchCount > 0) {
|
||
for (let i = 0; i < matchCount; i++) {
|
||
roundMatches.push({
|
||
participant1: null,
|
||
participant2: null,
|
||
score1: 0,
|
||
score2: 0,
|
||
decided: false,
|
||
winner: null,
|
||
loser: null
|
||
});
|
||
}
|
||
losersBracket.value.push(roundMatches);
|
||
console.log(`败者组第${round + 1}轮: ${matchCount}场比赛`);
|
||
} else {
|
||
console.log(`败者组第${round + 1}轮: 跳过(无需比赛)`);
|
||
}
|
||
}
|
||
};
|
||
|
||
// D3.js drawing functions for winners bracket
|
||
const drawD3WinnersBracket = () => {
|
||
const svg = d3.select('#d3-winners-bracket');
|
||
svg.selectAll('*').remove();
|
||
|
||
if (!winnersBracket.value.length) return;
|
||
|
||
drawD3Bracket(svg, winnersBracket.value, 'winnersBracketContainer', 'winners');
|
||
};
|
||
|
||
// D3.js drawing functions for losers bracket
|
||
const drawD3LosersBracket = () => {
|
||
const svg = d3.select('#d3-losers-bracket');
|
||
svg.selectAll('*').remove();
|
||
|
||
if (!losersBracket.value.length) return;
|
||
|
||
drawD3Bracket(svg, losersBracket.value, 'losersBracketContainer', 'losers');
|
||
};
|
||
// Generic D3.js bracket drawing function
|
||
const drawD3Bracket = (svg, bracket, containerId, bracketType) => {
|
||
const margin = { top: 40, right: 40, bottom: 40, left: 40 };
|
||
const matchWidth = 180;
|
||
const matchHeight = 120;
|
||
const roundGap = 50;
|
||
const matchGap = 20;
|
||
|
||
const rounds = bracket.length;
|
||
const maxMatchesInRound = Math.max(...bracket.map(round => round.length));
|
||
|
||
const totalWidth = rounds * (matchWidth + roundGap) + margin.left + margin.right;
|
||
const totalHeight = maxMatchesInRound * (matchHeight + matchGap) + margin.top + margin.bottom;
|
||
|
||
const container = document.getElementById(containerId);
|
||
const containerWidth = container.clientWidth - 30;
|
||
const containerHeight = container.clientHeight - 30;
|
||
|
||
svg
|
||
.attr('width', containerWidth)
|
||
.attr('height', containerHeight)
|
||
.attr('viewBox', `0 0 ${totalWidth} ${totalHeight}`)
|
||
.attr('preserveAspectRatio', 'xMidYMid meet');
|
||
|
||
// Create zoom behavior
|
||
const zoom = d3.zoom()
|
||
.scaleExtent([0.1, 3])
|
||
.on('zoom', (event) => {
|
||
g.attr('transform', `translate(${margin.left},${margin.top}) ${event.transform}`);
|
||
});
|
||
|
||
// Store references for external control
|
||
if (bracketType === 'winners') {
|
||
winnersZoomBehavior = zoom;
|
||
winnerssvgSelection = svg;
|
||
} else {
|
||
losersZoomBehavior = zoom;
|
||
loserssvgSelection = svg;
|
||
}
|
||
|
||
svg.call(zoom);
|
||
|
||
svg.style('cursor', 'grab')
|
||
.on('mousedown', () => svg.style('cursor', 'grabbing'))
|
||
.on('mouseup', () => svg.style('cursor', 'grab'));
|
||
|
||
const g = svg.append('g')
|
||
.attr('transform', `translate(${margin.left},${margin.top})`);
|
||
|
||
// Draw matches for each round
|
||
bracket.forEach((round, roundIndex) => {
|
||
const roundX = roundIndex * (matchWidth + roundGap);
|
||
const roundStartY = (maxMatchesInRound * (matchHeight + matchGap) - round.length * (matchHeight + matchGap)) / 2;
|
||
|
||
// Round title
|
||
g.append('text')
|
||
.attr('x', roundX + matchWidth / 2)
|
||
.attr('y', -10)
|
||
.attr('text-anchor', 'middle')
|
||
.attr('class', 'round-title')
|
||
.style('font-weight', 'bold')
|
||
.style('font-size', '14px')
|
||
.text(`第 ${roundIndex + 1} 轮`);
|
||
|
||
round.forEach((match, matchIndex) => {
|
||
const matchY = roundStartY + matchIndex * (matchHeight + matchGap);
|
||
|
||
// Match container
|
||
const matchGroup = g.append('g')
|
||
.attr('class', 'match-group')
|
||
.attr('data-match-id', `${bracketType}-${roundIndex}-${matchIndex}`);
|
||
|
||
// Match background
|
||
matchGroup.append('rect')
|
||
.attr('x', roundX)
|
||
.attr('y', matchY)
|
||
.attr('width', matchWidth)
|
||
.attr('height', matchHeight)
|
||
.attr('class', 'match-bg')
|
||
.style('fill', '#fafafa')
|
||
.style('stroke', '#ddd')
|
||
.style('stroke-width', 1)
|
||
.style('rx', 6);
|
||
|
||
// Store match position for connection drawing
|
||
match._x = roundX + matchWidth;
|
||
match._y = matchY + matchHeight / 2;
|
||
match._roundIndex = roundIndex;
|
||
match._matchIndex = matchIndex;
|
||
|
||
// Participant 1
|
||
const p1Group = matchGroup.append('g')
|
||
.attr('class', 'participant-group')
|
||
.attr('transform', `translate(${roundX + 10}, ${matchY + 20})`);
|
||
|
||
p1Group.append('rect')
|
||
.attr('width', matchWidth - 20)
|
||
.attr('height', 30)
|
||
.style('fill', match.winner && match.participant1 && match.winner.id === match.participant1.id ? '#e8f5e8' : '#fff')
|
||
.style('stroke', '#ccc')
|
||
.style('rx', 3);
|
||
|
||
p1Group.append('text')
|
||
.attr('x', 8)
|
||
.attr('y', 20)
|
||
.style('font-size', '12px')
|
||
.style('font-weight', match.winner && match.participant1 && match.winner.id === match.participant1.id ? 'bold' : 'normal')
|
||
.style('fill', match.winner && match.participant1 && match.winner.id === match.participant1.id ? '#2a7f2a' : '#333')
|
||
.text(match.participant1 ? match.participant1.name : '轮空');
|
||
|
||
// Score input for participant 1
|
||
if (match.participant1 && match.participant2 && !match.decided) {
|
||
const scoreInput1 = p1Group.append('foreignObject')
|
||
.attr('x', matchWidth - 60)
|
||
.attr('y', 5)
|
||
.attr('width', 40)
|
||
.attr('height', 20);
|
||
|
||
scoreInput1.append('xhtml:input')
|
||
.attr('type', 'number')
|
||
.attr('min', '0')
|
||
.style('width', '35px')
|
||
.style('height', '18px')
|
||
.style('font-size', '11px')
|
||
.style('text-align', 'center')
|
||
.style('border', '1px solid #aaa')
|
||
.style('border-radius', '3px')
|
||
.property('value', match.score1 || '')
|
||
.on('input', function() {
|
||
match.score1 = +this.value;
|
||
});
|
||
} else if (match.decided) {
|
||
p1Group.append('text')
|
||
.attr('x', matchWidth - 35)
|
||
.attr('y', 20)
|
||
.style('font-size', '12px')
|
||
.style('font-weight', 'bold')
|
||
.style('text-anchor', 'middle')
|
||
.text(match.score1 || 0);
|
||
}
|
||
|
||
// Participant 2
|
||
const p2Group = matchGroup.append('g')
|
||
.attr('class', 'participant-group')
|
||
.attr('transform', `translate(${roundX + 10}, ${matchY + 55})`);
|
||
|
||
p2Group.append('rect')
|
||
.attr('width', matchWidth - 20)
|
||
.attr('height', 30)
|
||
.style('fill', match.winner && match.participant2 && match.winner.id === match.participant2.id ? '#e8f5e8' : '#fff')
|
||
.style('stroke', '#ccc')
|
||
.style('rx', 3);
|
||
|
||
p2Group.append('text')
|
||
.attr('x', 8)
|
||
.attr('y', 20)
|
||
.style('font-size', '12px')
|
||
.style('font-weight', match.winner && match.participant2 && match.winner.id === match.participant2.id ? 'bold' : 'normal')
|
||
.style('fill', match.winner && match.participant2 && match.winner.id === match.participant2.id ? '#2a7f2a' : '#333')
|
||
.text(match.participant2 ? match.participant2.name : '轮空');
|
||
|
||
// Score input for participant 2
|
||
if (match.participant1 && match.participant2 && !match.decided) {
|
||
const scoreInput2 = p2Group.append('foreignObject')
|
||
.attr('x', matchWidth - 60)
|
||
.attr('y', 5)
|
||
.attr('width', 40)
|
||
.attr('height', 20);
|
||
|
||
scoreInput2.append('xhtml:input')
|
||
.attr('type', 'number')
|
||
.attr('min', '0')
|
||
.style('width', '35px')
|
||
.style('height', '18px')
|
||
.style('font-size', '11px')
|
||
.style('text-align', 'center')
|
||
.style('border', '1px solid #aaa')
|
||
.style('border-radius', '3px')
|
||
.property('value', match.score2 || '')
|
||
.on('input', function() {
|
||
match.score2 = +this.value;
|
||
});
|
||
} else if (match.decided) {
|
||
p2Group.append('text')
|
||
.attr('x', matchWidth - 35)
|
||
.attr('y', 20)
|
||
.style('font-size', '12px')
|
||
.style('font-weight', 'bold')
|
||
.style('text-anchor', 'middle')
|
||
.text(match.score2 || 0);
|
||
}
|
||
|
||
// Confirm score button
|
||
if (match.participant1 && match.participant2 && !match.decided) {
|
||
const buttonGroup = matchGroup.append('g')
|
||
.attr('transform', `translate(${roundX + 10}, ${matchY + 90})`);
|
||
|
||
const button = buttonGroup.append('rect')
|
||
.attr('width', matchWidth - 20)
|
||
.attr('height', 25)
|
||
.style('fill', '#007acc')
|
||
.style('stroke', 'none')
|
||
.style('rx', 4)
|
||
.style('cursor', 'pointer')
|
||
.on('click', () => confirmScore(match, bracketType, roundIndex, matchIndex));
|
||
|
||
buttonGroup.append('text')
|
||
.attr('x', (matchWidth - 20) / 2)
|
||
.attr('y', 17)
|
||
.style('fill', 'white')
|
||
.style('font-size', '12px')
|
||
.style('font-weight', 'bold')
|
||
.style('text-anchor', 'middle')
|
||
.style('pointer-events', 'none')
|
||
.text('确认比分');
|
||
}
|
||
});
|
||
});
|
||
|
||
// Draw connections between matches
|
||
drawD3Connections(g, bracket, bracketType, matchWidth, matchHeight, roundGap, matchGap);
|
||
};
|
||
|
||
// Draw connections between matches in brackets
|
||
const drawD3Connections = (g, bracket, bracketType, matchWidth, matchHeight, roundGap, matchGap) => {
|
||
// Draw connections for adjacent rounds
|
||
for (let roundIndex = 0; roundIndex < bracket.length - 1; roundIndex++) {
|
||
const currentRound = bracket[roundIndex];
|
||
const nextRound = bracket[roundIndex + 1];
|
||
|
||
if (!nextRound) continue;
|
||
|
||
// Connect matches from current round to next round
|
||
for (let matchIndex = 0; matchIndex < currentRound.length; matchIndex += 2) {
|
||
const match1 = currentRound[matchIndex];
|
||
const match2 = currentRound[matchIndex + 1];
|
||
const nextMatchIndex = Math.floor(matchIndex / 2);
|
||
const nextMatch = nextRound[nextMatchIndex];
|
||
|
||
if (!nextMatch || nextMatch._x === undefined) continue;
|
||
|
||
// Draw connection from match1 to next match
|
||
if (match1 && match1.decided && match1._x !== undefined) {
|
||
const startX = match1._x;
|
||
const startY = match1._y;
|
||
const endX = nextMatch._x - matchWidth;
|
||
const endY = nextMatch._y;
|
||
const midX = startX + roundGap / 2;
|
||
|
||
g.append('path')
|
||
.attr('d', `M${startX},${startY} L${midX},${startY} L${midX},${endY} L${endX},${endY}`)
|
||
.style('stroke', '#4CAF50')
|
||
.style('stroke-width', 2)
|
||
.style('fill', 'none')
|
||
.style('opacity', 0.8);
|
||
}
|
||
|
||
// Draw connection from match2 to next match (if match2 exists)
|
||
if (match2 && match2.decided && match2._x !== undefined) {
|
||
const startX = match2._x;
|
||
const startY = match2._y;
|
||
const endX = nextMatch._x - matchWidth;
|
||
const endY = nextMatch._y;
|
||
const midX = startX + roundGap / 2;
|
||
|
||
g.append('path')
|
||
.attr('d', `M${startX},${startY} L${midX},${startY} L${midX},${endY} L${endX},${endY}`)
|
||
.style('stroke', '#4CAF50')
|
||
.style('stroke-width', 2)
|
||
.style('fill', 'none')
|
||
.style('opacity', 0.8);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
const confirmScore = async (match, bracketType, rIndex, mIndex) => {
|
||
if (match.score1 === match.score2) {
|
||
errorDialog.value = { visible: true, message: '不能平局' };
|
||
return;
|
||
}
|
||
match.decided = true;
|
||
match.winner = match.score1 > match.score2 ? match.participant1 : match.participant2;
|
||
match.loser = match.score1 > match.score2 ? match.participant2 : match.participant1;
|
||
|
||
// 更新选手胜负场数据
|
||
await updatePlayerStats(match.participant1, match.participant2, match.score1, match.score2, bracketType);
|
||
|
||
if (bracketType === 'winners') {
|
||
// 胜者组逻辑
|
||
handleWinnersBracketAdvancement(match, rIndex, mIndex);
|
||
} else {
|
||
// 败者组逻辑
|
||
handleLosersBracketAdvancement(match, rIndex, mIndex);
|
||
}
|
||
|
||
// 检查是否需要生成决赛
|
||
checkForFinalMatch();
|
||
|
||
// Redraw brackets
|
||
nextTick(() => {
|
||
drawD3WinnersBracket();
|
||
drawD3LosersBracket();
|
||
});
|
||
};
|
||
|
||
// 处理胜者组晋级逻辑
|
||
const handleWinnersBracketAdvancement = (match, rIndex, mIndex) => {
|
||
// 胜者晋级到胜者组下一轮
|
||
if (rIndex + 1 < winnersBracket.value.length) {
|
||
const nextRound = winnersBracket.value[rIndex + 1];
|
||
const nextMatchIndex = Math.floor(mIndex / 2);
|
||
const isUpperSlot = mIndex % 2 === 0;
|
||
|
||
if (nextRound[nextMatchIndex]) {
|
||
if (isUpperSlot) {
|
||
nextRound[nextMatchIndex].participant1 = match.winner;
|
||
} else {
|
||
nextRound[nextMatchIndex].participant2 = match.winner;
|
||
}
|
||
|
||
console.log(`胜者组第${rIndex + 1}轮胜者${match.winner ? match.winner.name : 'null'}晋级到第${rIndex + 2}轮`);
|
||
}
|
||
}
|
||
|
||
// 败者进入败者组(只有在有败者的情况下)
|
||
if (match.loser) {
|
||
sendLoserToLosersBracket(match.loser, rIndex);
|
||
} else if (match.participant1 && match.participant2) {
|
||
// 只有在两个参赛者都存在的情况下才有败者
|
||
const loser = match.winner === match.participant1 ? match.participant2 : match.participant1;
|
||
sendLoserToLosersBracket(loser, rIndex);
|
||
}
|
||
// 轮空情况下不产生败者,不需要送入败者组
|
||
};
|
||
|
||
// 处理败者组晋级逻辑
|
||
const handleLosersBracketAdvancement = (match, rIndex, mIndex) => {
|
||
// 败者组胜者晋级到败者组下一轮
|
||
if (rIndex + 1 < losersBracket.value.length) {
|
||
const nextRound = losersBracket.value[rIndex + 1];
|
||
|
||
// 败者组的晋级逻辑:
|
||
// LR1 胜者 → LR2 的 participant1
|
||
// LR2 需要等待胜者组第2轮败者作为 participant2
|
||
|
||
if (nextRound.length > 0) {
|
||
const nextMatch = nextRound[0]; // 败者组通常每轮只有1场比赛
|
||
|
||
if (rIndex % 2 === 0) {
|
||
// 偶数轮 (LR1, LR3...): 胜者进入下一轮作为participant1
|
||
if (!nextMatch.participant1) {
|
||
nextMatch.participant1 = match.winner;
|
||
console.log(`败者组第${rIndex + 1}轮胜者${match.winner.name}晋级到第${rIndex + 2}轮作为participant1`);
|
||
}
|
||
} else {
|
||
// 奇数轮 (LR2, LR4...): 胜者进入下一轮作为participant2
|
||
if (!nextMatch.participant2) {
|
||
nextMatch.participant2 = match.winner;
|
||
console.log(`败者组第${rIndex + 1}轮胜者${match.winner.name}晋级到第${rIndex + 2}轮作为participant2`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 败者组的败者直接淘汰,不做处理
|
||
};
|
||
|
||
// 将败者送入败者组的正确位置
|
||
const sendLoserToLosersBracket = (loser, winnersRoundIndex) => {
|
||
if (!loser || losersBracket.value.length === 0) return;
|
||
|
||
console.log(`处理败者: ${loser.name}, 来自胜者组第${winnersRoundIndex + 1}轮`);
|
||
|
||
// 标准双败淘汰赛逻辑:
|
||
// 胜者组第1轮败者 → 败者组第1轮 (与其他第1轮败者对战)
|
||
// 胜者组第2轮败者 → 败者组第2轮 (与败者组第1轮胜者对战)
|
||
|
||
let targetRound;
|
||
if (winnersRoundIndex === 0) {
|
||
// 胜者组第1轮败者进入败者组第1轮
|
||
targetRound = 0;
|
||
|
||
// 特殊情况:如果败者组第1轮没有比赛(只有1个败者),直接晋级到第2轮
|
||
if (losersBracket.value.length > 0 && losersBracket.value[0].length === 0) {
|
||
targetRound = 1;
|
||
console.log(`败者组第1轮无比赛,${loser.name}直接进入第2轮`);
|
||
}
|
||
} else {
|
||
// 胜者组第N轮败者进入败者组第(2N-1)轮
|
||
targetRound = winnersRoundIndex * 2 - 1;
|
||
|
||
// 确保不超过败者组轮次数
|
||
if (targetRound >= losersBracket.value.length) {
|
||
targetRound = losersBracket.value.length - 1;
|
||
}
|
||
}
|
||
|
||
console.log(`将${loser.name}放入败者组第${targetRound + 1}轮`);
|
||
|
||
if (targetRound < losersBracket.value.length && targetRound >= 0 && losersBracket.value[targetRound]) {
|
||
const losersRound = losersBracket.value[targetRound];
|
||
|
||
if (losersRound.length === 0) {
|
||
// 如果这一轮没有比赛,说明败者直接晋级到下一轮
|
||
console.log(`败者组第${targetRound + 1}轮无比赛,${loser.name}继续晋级`);
|
||
if (targetRound + 1 < losersBracket.value.length) {
|
||
sendLoserToLosersBracket(loser, winnersRoundIndex + 1);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 找到第一个空位置放入败者
|
||
for (let i = 0; i < losersRound.length; i++) {
|
||
const match = losersRound[i];
|
||
if (!match.participant1) {
|
||
match.participant1 = loser;
|
||
console.log(`${loser.name}被放入败者组第${targetRound + 1}轮第${i + 1}场的participant1位置`);
|
||
break;
|
||
} else if (!match.participant2) {
|
||
match.participant2 = loser;
|
||
console.log(`${loser.name}被放入败者组第${targetRound + 1}轮第${i + 1}场的participant2位置`);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// 检查是否需要生成决赛
|
||
const checkForFinalMatch = () => {
|
||
const lastWinnersRound = winnersBracket.value[winnersBracket.value.length - 1];
|
||
const lastLosersRound = losersBracket.value[losersBracket.value.length - 1];
|
||
|
||
if (lastWinnersRound && lastWinnersRound.length === 1 && lastWinnersRound[0].decided &&
|
||
lastLosersRound && lastLosersRound.length === 1 && lastLosersRound[0].decided) {
|
||
|
||
const winnersChampion = lastWinnersRound[0].winner;
|
||
const losersChampion = lastLosersRound[0].winner;
|
||
|
||
if (winnersChampion && losersChampion && !finalMatch.value) {
|
||
finalMatch.value = {
|
||
participant1: winnersChampion,
|
||
participant2: losersChampion,
|
||
score1: 0,
|
||
score2: 0,
|
||
decided: false,
|
||
winner: null,
|
||
loser: null
|
||
};
|
||
}
|
||
}
|
||
};
|
||
|
||
// Zoom control functions for winners bracket
|
||
const zoomInWinners = () => {
|
||
if (winnerssvgSelection && winnersZoomBehavior) {
|
||
winnerssvgSelection.transition().duration(300).call(
|
||
winnersZoomBehavior.scaleBy, 1.5
|
||
);
|
||
}
|
||
};
|
||
|
||
const zoomOutWinners = () => {
|
||
if (winnerssvgSelection && winnersZoomBehavior) {
|
||
winnerssvgSelection.transition().duration(300).call(
|
||
winnersZoomBehavior.scaleBy, 1 / 1.5
|
||
);
|
||
}
|
||
};
|
||
|
||
const resetZoomWinners = () => {
|
||
if (winnerssvgSelection && winnersZoomBehavior) {
|
||
winnerssvgSelection.transition().duration(500).call(
|
||
winnersZoomBehavior.transform,
|
||
d3.zoomIdentity
|
||
);
|
||
}
|
||
};
|
||
|
||
// Zoom control functions for losers bracket
|
||
const zoomInLosers = () => {
|
||
if (loserssvgSelection && losersZoomBehavior) {
|
||
loserssvgSelection.transition().duration(300).call(
|
||
losersZoomBehavior.scaleBy, 1.5
|
||
);
|
||
}
|
||
};
|
||
|
||
const zoomOutLosers = () => {
|
||
if (loserssvgSelection && losersZoomBehavior) {
|
||
loserssvgSelection.transition().duration(300).call(
|
||
losersZoomBehavior.scaleBy, 1 / 1.5
|
||
);
|
||
}
|
||
};
|
||
|
||
const resetZoomLosers = () => {
|
||
if (loserssvgSelection && losersZoomBehavior) {
|
||
loserssvgSelection.transition().duration(500).call(
|
||
losersZoomBehavior.transform,
|
||
d3.zoomIdentity
|
||
);
|
||
}
|
||
};
|
||
|
||
const confirmFinal = async () => {
|
||
const match = finalMatch.value;
|
||
if (!match || match.score1 === match.score2) {
|
||
errorDialog.value = { visible: true, message: '决赛不能平局' };
|
||
return;
|
||
}
|
||
|
||
match.decided = true;
|
||
match.winner = match.score1 > match.score2 ? match.participant1 : match.participant2;
|
||
match.loser = match.score1 > match.score2 ? match.participant2 : match.participant1;
|
||
|
||
// 更新决赛选手胜负场数据
|
||
await updatePlayerStats(match.participant1, match.participant2, match.score1, match.score2, 'final');
|
||
|
||
// 双败淘汰赛的特殊规则:bracket reset
|
||
// 如果败者组冠军(participant2)在决赛中获胜,则需要重置比赛
|
||
if (match.winner === match.participant2) {
|
||
// Bracket Reset: 败者组冠军获胜,需要再打一场
|
||
successDialog.value = { visible: true, message: '败者组冠军获胜!比赛重置,将进行最终决赛!' };
|
||
finalMatch.value = {
|
||
participant1: match.participant2, // 原败者组冠军
|
||
participant2: match.participant1, // 原胜者组冠军
|
||
score1: 0,
|
||
score2: 0,
|
||
decided: false,
|
||
winner: null,
|
||
loser: null,
|
||
isResetMatch: true // 标记这是重置后的比赛
|
||
};
|
||
return;
|
||
}
|
||
|
||
// 如果胜者组冠军获胜,或者这是重置后的比赛,则确定最终名次
|
||
champion.value = match.winner.name || match.winner;
|
||
runnerUp.value = match.loser.name || match.loser;
|
||
|
||
// 在双败赛制中,季军是败者组决赛的败者
|
||
if (losersBracket.value.length > 0) {
|
||
const lastLoserRound = losersBracket.value[losersBracket.value.length - 1];
|
||
if (lastLoserRound && lastLoserRound.length > 0) {
|
||
const lastLoserMatch = lastLoserRound[lastLoserRound.length - 1];
|
||
if (lastLoserMatch && lastLoserMatch.loser) {
|
||
// 如果是重置比赛,季军应该是原来的胜者组冠军(如果败者组冠军最终获胜)
|
||
if (match.isResetMatch && match.winner === match.participant1) {
|
||
thirdPlace.value = match.participant2.name || match.participant2;
|
||
} else {
|
||
thirdPlace.value = lastLoserMatch.loser.name || lastLoserMatch.loser;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 备选季军逻辑
|
||
if (!thirdPlace.value && losersBracket.value.length > 1) {
|
||
const semiLoserRound = losersBracket.value[losersBracket.value.length - 2];
|
||
if (semiLoserRound && semiLoserRound.length > 0) {
|
||
const semiLoserMatch = semiLoserRound[semiLoserRound.length - 1];
|
||
if (semiLoserMatch && semiLoserMatch.loser) {
|
||
thirdPlace.value = semiLoserMatch.loser.name || semiLoserMatch.loser;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 更新最终排名状态
|
||
try {
|
||
const latestData = await getSignUpResultList();
|
||
|
||
// 更新冠军状态
|
||
const championData = latestData.find(item => item.sign_name === champion.value);
|
||
if (championData) {
|
||
await updateSignUpResult(championData.id, {
|
||
...championData,
|
||
status: 'win',
|
||
rank: '1'
|
||
});
|
||
}
|
||
|
||
// 更新亚军状态
|
||
const runnerUpData = latestData.find(item => item.sign_name === runnerUp.value);
|
||
if (runnerUpData) {
|
||
await updateSignUpResult(runnerUpData.id, {
|
||
...runnerUpData,
|
||
status: 'lose',
|
||
rank: '2'
|
||
});
|
||
}
|
||
|
||
// 更新季军状态
|
||
if (thirdPlace.value && thirdPlace.value !== '待定') {
|
||
const thirdPlaceData = latestData.find(item => item.sign_name === thirdPlace.value);
|
||
if (thirdPlaceData) {
|
||
await updateSignUpResult(thirdPlaceData.id, {
|
||
...thirdPlaceData,
|
||
status: 'lose',
|
||
rank: '3'
|
||
});
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('更新最终排名失败:', err);
|
||
}
|
||
};
|
||
|
||
// 更新选手胜负场数据到API
|
||
const updatePlayerStats = async (p1, p2, s1, s2, bracketType = 'winners') => {
|
||
if (!p1 || !p2) return;
|
||
|
||
try {
|
||
// 先获取最新的报名数据
|
||
const latestData = await getSignUpResultList();
|
||
const p1Latest = latestData.find(item => item.id === p1.id);
|
||
const p2Latest = latestData.find(item => item.id === p2.id);
|
||
|
||
if (!p1Latest || !p2Latest) return;
|
||
|
||
// 计算积分 - 胜者组获得全额积分,败者组获得一半积分
|
||
const pointMultiplier = bracketType === 'winners' ? 1 : 0.5;
|
||
const winPoints = s1 > s2 ? 1 * pointMultiplier : 0;
|
||
const losePoints = s2 > s1 ? 1 * pointMultiplier : 0;
|
||
|
||
// 更新选手1的数据
|
||
const p1Data = {
|
||
tournament_id: props.tournamentId,
|
||
tournament_name: tournament.value.name,
|
||
team_name: p1.team_name || '',
|
||
sign_name: p1.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 || '',
|
||
points: String(Number(p1Latest.points || 0) + (s1 > s2 ? winPoints : 0)),
|
||
bracket_type: bracketType
|
||
};
|
||
|
||
// 更新选手2的数据
|
||
const p2Data = {
|
||
tournament_id: props.tournamentId,
|
||
tournament_name: tournament.value.name,
|
||
team_name: p2.team_name || '',
|
||
sign_name: p2.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 || '',
|
||
points: String(Number(p2Latest.points || 0) + (s2 > s1 ? winPoints : 0)),
|
||
bracket_type: bracketType
|
||
};
|
||
|
||
// 调用API更新数据
|
||
await updateSignUpResult(p1.id, p1Data);
|
||
await updateSignUpResult(p2.id, p2Data);
|
||
|
||
// 刷新参赛者列表
|
||
await loadTournamentData();
|
||
} catch (err) {
|
||
console.error('更新选手数据失败:', err);
|
||
errorDialog.value = { visible: true, message: '更新选手数据失败' };
|
||
}
|
||
};
|
||
|
||
// Watch for prop changes
|
||
watch(() => props.tournamentId, (newId) => {
|
||
if (newId) {
|
||
loadTournamentData();
|
||
}
|
||
}, { immediate: false });
|
||
|
||
// Watch for bracket changes to redraw
|
||
watch([winnersBracket, losersBracket], () => {
|
||
nextTick(() => {
|
||
drawD3WinnersBracket();
|
||
drawD3LosersBracket();
|
||
});
|
||
}, { deep: true });
|
||
</script>
|
||
|
||
<style scoped>
|
||
h1 {
|
||
text-align: center;
|
||
color: #1a237e;
|
||
margin-bottom: 20px;
|
||
font-size: 1.8rem;
|
||
font-weight: normal;
|
||
}
|
||
|
||
.tournament-bracket-root {
|
||
font-family: Arial, sans-serif;
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
padding: 20px;
|
||
}
|
||
|
||
.container {
|
||
display: flex;
|
||
gap: 20px;
|
||
align-items: flex-start;
|
||
min-height: 500px;
|
||
}
|
||
|
||
.bracket-main {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
}
|
||
|
||
.bracket-section {
|
||
background: #fff;
|
||
padding: 15px;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.bracket-section h2 {
|
||
color: #1a237e;
|
||
font-size: 1.2rem;
|
||
font-weight: 600;
|
||
margin: 0 0 15px 0;
|
||
}
|
||
|
||
.bracket-container {
|
||
position: relative;
|
||
overflow: 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;
|
||
}
|
||
|
||
.zoom-controls {
|
||
position: absolute;
|
||
top: 20px;
|
||
right: 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 5px;
|
||
z-index: 10;
|
||
background: rgba(255, 255, 255, 0.9);
|
||
border-radius: 6px;
|
||
padding: 5px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||
}
|
||
|
||
.zoom-btn {
|
||
width: 32px;
|
||
height: 32px;
|
||
background: #007acc;
|
||
border: none;
|
||
border-radius: 4px;
|
||
color: white;
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.zoom-btn:hover {
|
||
background: #005a9e;
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
.zoom-btn:active {
|
||
transform: scale(0.95);
|
||
}
|
||
|
||
.interaction-hint {
|
||
position: absolute;
|
||
bottom: 15px;
|
||
left: 15px;
|
||
background: rgba(0, 0, 0, 0.7);
|
||
color: white;
|
||
padding: 8px 12px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
z-index: 5;
|
||
user-select: none;
|
||
opacity: 0.8;
|
||
transition: opacity 0.3s ease;
|
||
}
|
||
|
||
.interaction-hint:hover {
|
||
opacity: 1;
|
||
}
|
||
|
||
.d3-bracket {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
cursor: grab;
|
||
}
|
||
|
||
.d3-bracket:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.final-match-container {
|
||
display: flex;
|
||
justify-content: center;
|
||
margin: 20px 0;
|
||
}
|
||
|
||
.final-match {
|
||
background: #f9f9f9;
|
||
border: 2px solid #007acc;
|
||
border-radius: 8px;
|
||
padding: 20px;
|
||
min-width: 300px;
|
||
}
|
||
|
||
.participant {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 10px 0;
|
||
border-bottom: 1px solid #eee;
|
||
}
|
||
|
||
.participant:last-of-type {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.participant.winner {
|
||
font-weight: bold;
|
||
color: #2a7f2a;
|
||
background-color: #e8f5e8;
|
||
border-radius: 4px;
|
||
padding: 10px;
|
||
}
|
||
|
||
.participant span {
|
||
flex: 1;
|
||
margin-right: 10px;
|
||
}
|
||
|
||
input[type="number"] {
|
||
width: 60px;
|
||
text-align: center;
|
||
padding: 5px;
|
||
border: 1px solid #ccc;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
button {
|
||
background: #409EFF;
|
||
color: white;
|
||
border: none;
|
||
padding: 10px 15px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
margin-top: 10px;
|
||
width: 100%;
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
button:disabled {
|
||
background: #cccccc;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
button:hover:not(:disabled) {
|
||
background: #66b1ff;
|
||
}
|
||
|
||
|
||
</style> |