This commit is contained in:
2025-05-26 20:35:34 +08:00
parent b706598b9f
commit 93bcb51190
15 changed files with 4519 additions and 520 deletions

View File

@@ -0,0 +1,221 @@
<template>
<div class="rank-contestant">
<div class="rank-contestant-header">
<h2>选手排名</h2>
</div>
<div class="rank-content">
<div class="top-three">
<div v-for="(item, idx) in rankData.slice(0, 3)" :key="idx" class="rank-card">
<div class="rank-number">
{{ item.rank }}
</div>
<div class="player-info">
<div class="player-name">{{ item.username }}</div>
<div class="player-faction">{{ item.faction }}</div>
<div class="player-score">{{ item.score }}</div>
</div>
</div>
</div>
<div class="rank-list">
<div v-for="(item, idx) in rankData.slice(3)" :key="idx + 3" class="rank-item">
<div class="rank">{{ item.rank }}</div>
<div class="player-info">
<div class="player-name">{{ item.username }}</div>
<div class="player-faction">{{ item.faction }}</div>
<div class="player-score">{{ item.score }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getSignUpResultList } from '@/api/tournament'
const props = defineProps({
tournamentId: {
type: Number,
required: true
}
})
const rankData = ref([])
const fetchRankData = async () => {
try {
const response = await getSignUpResultList()
// 筛选当前赛事的玩家并按胜场数排序
const results = response
.filter(player => player.tournament_id === props.tournamentId)
.map((player, index) => ({
rank: index + 1,
username: player.sign_name,
faction: player.faction,
win: parseInt(player.win) || 0,
lose: parseInt(player.lose) || 0,
score: `${player.win}${player.lose}`
}))
.sort((a, b) => {
// 首先按胜场数排序
if (b.win !== a.win) {
return b.win - a.win
}
// 如果胜场数相同,则按负场数排序(负场数少的排前面)
return a.lose - b.lose
})
.map((player, index) => ({
...player,
rank: index + 1
}))
rankData.value = results
} catch (error) {
console.error('获取排名数据失败:', error)
}
}
onMounted(() => {
fetchRankData()
})
</script>
<style scoped>
.rank-contestant {
background: white;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.rank-contestant-header {
margin-bottom: 24px;
}
.rank-contestant-header h2 {
font-size: 20px;
color: #303133;
margin: 0;
}
.rank-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.top-three {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.rank-card {
background: white;
border-radius: 8px;
padding: 20px;
display: flex;
align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border: 1px solid #EBEEF5;
}
.rank-card:nth-child(1) {
background: #FFF9EB;
border: 1px solid #FFE4B5;
}
.rank-card:nth-child(2) {
background: #F8F9FA;
border: 1px solid #E4E7ED;
}
.rank-card:nth-child(3) {
background: #FDF6EC;
border: 1px solid #F3D19E;
}
.rank-number {
font-size: 24px;
font-weight: bold;
margin-right: 16px;
min-width: 50px;
text-align: center;
}
.rank-card:nth-child(1) .rank-number {
color: #E6A23C;
}
.rank-card:nth-child(2) .rank-number {
color: #909399;
}
.rank-card:nth-child(3) .rank-number {
color: #F56C6C;
}
.player-info {
flex: 1;
}
.player-name {
font-size: 16px;
font-weight: 500;
color: #303133;
margin-bottom: 4px;
}
.player-qq {
font-size: 14px;
color: #909399;
margin-bottom: 4px;
}
.player-faction {
font-size: 14px;
color: #409EFF;
margin-bottom: 4px;
}
.player-score {
font-size: 14px;
color: #67C23A;
font-weight: 500;
}
.rank-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.rank-item {
display: flex;
align-items: center;
padding: 16px;
background: white;
border-radius: 4px;
border: 1px solid #EBEEF5;
}
.rank {
width: 40px;
font-size: 16px;
font-weight: 600;
color: #909399;
text-align: center;
margin-right: 16px;
}
@media (max-width: 768px) {
.top-three {
grid-template-columns: 1fr;
}
.rank-contestant {
padding: 16px;
}
}
</style>

View File

@@ -1,204 +1,436 @@
<template>
<div class="tournament-container">
<bracket :rounds="rounds">
<template #player="{ player }">
<div class="custom-node" :class="{ 'winner': player.winner }">
<div class="node-content">
<span class="team-name">{{ player.name }}</span>
<span class="team-score">{{ player.score || 0 }}</span>
</div>
</div>
</template>
</bracket>
<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 } from 'vue'
import Bracket from 'vue-tournament-bracket'
import { ref, onMounted, computed, watch } from 'vue'
import { Bracket } from 'vue-tournament-bracket'
// 示例数据
const rounds = ref([
// 半决赛
{
games: [
{
player1: { id: "1", name: "战队 A", score: 2, winner: true },
player2: { id: "2", name: "战队 B", score: 1, winner: false }
},
{
player1: { id: "3", name: "战队 C", score: 0, winner: false },
player2: { id: "4", name: "战队 D", score: 2, winner: true }
}
]
const props = defineProps({
isEditMode: {
type: Boolean,
default: false
},
// 决赛
{
games: [
{
player1: { id: "1", name: "战队 A", score: 3, winner: true },
player2: { id: "4", name: "战队 D", score: 1, winner: 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-container {
min-height: 400px;
.tournament-bracket {
position: relative;
overflow-x: auto;
background: transparent;
overflow-y: auto;
background: #141414;
color: #fff;
height: auto;
min-height: 600px;
padding: 20px;
}
:deep(.vtb-wrapper),
:deep(.vtb-item),
:deep(.vtb-item-parent),
:deep(.vtb-item-players),
:deep(.vtb-item-children),
:deep(.vtb-item-child),
:deep(.custom-node),
:deep(.custom-node.winner) {
background: transparent !important;
}
:deep(.vtb-wrapper) {
display: flex;
padding: 20px 40px;
}
:deep(.vtb-item) {
display: flex;
flex-direction: row-reverse;
}
:deep(.vtb-item-parent) {
.bracket-wrapper {
transform-origin: center left;
cursor: ew-resize;
user-select: none;
padding: 20px;
min-height: calc(100% - 40px);
position: relative;
margin-left: 50px;
padding-top: 0;
width: max-content;
min-width: 100%;
}
.bracket-rounds {
display: flex;
align-items: center;
gap: 40px;
}
:deep(.vtb-item-players) {
flex-direction: column;
margin: 0;
}
:deep(.vtb-player) {
padding: 10px 16px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(143, 143, 143, 0.3);
color: #333;
border-radius: 24px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(8px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
}
:deep(.vtb-player:hover) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-color: rgba(143, 143, 143, 0.5);
}
:deep(.vtb-player.winner) {
border: 1px solid rgba(129, 199, 132, 0.5);
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 2px 8px rgba(129, 199, 132, 0.15);
}
:deep(.vtb-player.winner:hover) {
border-color: rgba(129, 199, 132, 0.8);
box-shadow: 0 4px 12px rgba(129, 199, 132, 0.2);
}
:deep(.vtb-item-children) {
.bracket-round {
display: flex;
flex-direction: column;
justify-content: center;
gap: 24px;
}
:deep(.vtb-item-child) {
.bracket-game {
display: flex;
align-items: flex-start;
justify-content: flex-end;
margin: 12px 0;
position: relative;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.node-content {
.player-info {
background: #1976d2;
color: #fff;
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(143, 143, 143, 0.3);
color: #333;
padding: 10px 16px;
border-radius: 24px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(8px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
gap: 8px;
width: 300px;
padding: 8px 12px;
border-radius: 4px;
margin: 4px 0;
transition: all 0.3s ease;
}
.node-content:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-color: rgba(143, 143, 143, 0.5);
.player-info.winner {
background: #4caf50;
}
.custom-node.winner .node-content {
border: 1px solid rgba(129, 199, 132, 0.5);
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 2px 8px rgba(129, 199, 132, 0.15);
.player-info.defeated {
background: #f44336;
}
.custom-node.winner .node-content:hover {
border-color: rgba(129, 199, 132, 0.8);
box-shadow: 0 4px 12px rgba(129, 199, 132, 0.2);
.player-number {
color: #bbdefb;
font-size: 12px;
min-width: 16px;
}
.team-name {
.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;
color: #333;
letter-spacing: 0.3px;
}
.team-score {
font-weight: 600;
min-width: 24px;
text-align: center;
margin-left: 16px;
color: #333;
.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: rgba(0, 0, 0, 0.04);
border-radius: 12px;
background: #ff9800;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
:deep(.vtb-player.winner) .team-score,
.custom-node.winner .team-score {
background: rgba(129, 199, 132, 0.1);
color: #2e7d32;
.set-winner-btn:hover {
background: #e65100;
}
@media (max-width: 768px) {
:deep(.vtb-wrapper) {
padding: 10px;
}
:deep(.vtb-player),
.node-content {
padding: 8px 12px;
}
.team-name {
font-size: 13px;
}
.team-score {
min-width: 20px;
margin-left: 12px;
padding: 1px 6px;
}
.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;
}
</style>
.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>

View File

@@ -191,7 +191,7 @@ const handleLogin = async () => {
// 登录成功,跳转到首页
if (response.access_token) {
router.push('/dashboard')
router.push('/')
} else {
throw new Error('登录失败:未获取到访问令牌')
}

View File

@@ -4,38 +4,20 @@
<form class="login-form-container" @submit.prevent="handleRegister">
<div class="input-container">
<label for="username">QQ号</label>
<input
type="text"
id="username"
v-model="username"
placeholder="请输入QQ号"
@blur="validateUsername"
:class="{ 'error': usernameError }"
/>
<input type="text" id="username" v-model="username" placeholder="请输入QQ号" @blur="validateUsername"
:class="{ 'error': usernameError }" />
<span class="error-message" v-if="usernameError">{{ usernameError }}</span>
</div>
<div class="input-container">
<label for="password">密码</label>
<input
type="password"
id="password"
v-model="password"
placeholder="请输入密码"
@blur="validatePassword"
:class="{ 'error': passwordError }"
/>
<input type="password" id="password" v-model="password" placeholder="请输入密码" @blur="validatePassword"
:class="{ 'error': passwordError }" />
<span class="error-message" v-if="passwordError">{{ passwordError }}</span>
</div>
<div class="input-container">
<label for="confirmPassword">再次输入密码</label>
<input
type="password"
id="confirmPassword"
v-model="confirmPassword"
placeholder="请输入密码"
@blur="validateConfirmPassword"
:class="{ 'error': confirmPasswordError }"
/>
<input type="password" id="confirmPassword" v-model="confirmPassword" placeholder="请输入密码"
@blur="validateConfirmPassword" :class="{ 'error': confirmPasswordError }" />
<span class="error-message" v-if="confirmPasswordError">{{ confirmPasswordError }}</span>
</div>
<div class="input-container">
@@ -94,6 +76,9 @@ const username = ref('')
const password = ref('')
const confirmPassword = ref('')
// 定义 emit
const emit = defineEmits(['login'])
// 错误信息
const usernameError = ref('')
const passwordError = ref('')
@@ -144,14 +129,14 @@ const validateConfirmPassword = () => {
const isVaptchaVerified = ref(false)
// 计算表单是否有效
const isFormValid = computed(() => {
return !usernameError.value &&
!passwordError.value &&
!confirmPasswordError.value &&
username.value &&
password.value &&
confirmPassword.value &&
VAPTCHAObj.value && // 确保人机验证已初始化
isVaptchaVerified.value // 确保人机验证已通过
return !usernameError.value &&
!passwordError.value &&
!confirmPasswordError.value &&
username.value &&
password.value &&
confirmPassword.value &&
VAPTCHAObj.value && // 确保人机验证已初始化
isVaptchaVerified.value // 确保人机验证已通过
})
@@ -189,54 +174,44 @@ onMounted(() => {
})
const handleRegister = async () => {
try {
// 验证表单
if (!validateUsername() || !validatePassword() || !validateConfirmPassword()) {
return
}
// 验证人机验证
if (!isVaptchaVerified.value || !VAPTCHAObj.value) {
alert('请完成人机验证')
return
}
// 获取验证token
try {
const serverToken = await VAPTCHAObj.value.getServerToken()
console.log('获取到的 serverToken:', serverToken)
if (!serverToken || !serverToken.token || !serverToken.server) {
alert('获取验证token失败请重新验证')
return
}
// 构造注册请求参数
const registerData = {
qq_code: username.value,
password: password.value,
server: serverToken.server,
token: serverToken.token
}
console.log('注册请求参数:', registerData)
// 发送注册请求
await userRegister(
registerData.qq_code,
registerData.password,
registerData.server,
registerData.token
)
alert('注册成功')
// 切换到登录模块
emit('login')
} catch (tokenError) {
console.error('获取验证token失败:', tokenError)
alert('获取验证token失败请重新验证')
}
} catch (error) {
console.error('注册失败:', error)
alert(error.message || '注册失败')
// 验证表单
if (!validateUsername() || !validatePassword() || !validateConfirmPassword()) {
return
}
// 验证人机验证
if (!isVaptchaVerified.value || !VAPTCHAObj.value) {
alert('请完成人机验证')
return
}
// 获取验证token
const serverToken = await VAPTCHAObj.value.getServerToken()
console.log('获取到的 serverToken:', serverToken)
if (!serverToken || !serverToken.token || !serverToken.server) {
alert('获取验证token失败请重新验证')
return
}
// 构造注册请求参数
const registerData = {
qq_code: username.value,
password: password.value,
server: serverToken.server,
token: serverToken.token
}
console.log('注册请求参数:', registerData)
// 发送注册请求
await userRegister(
registerData.qq_code,
registerData.password,
registerData.server,
registerData.token
)
alert('注册成功')
// 切换到登录模块
emit('login')
}
</script>