赛事
This commit is contained in:
parent
b706598b9f
commit
93bcb51190
286
src/api/tournament.js
Normal file
286
src/api/tournament.js
Normal file
@ -0,0 +1,286 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE_URL = 'http://zybdatasupport.online:8000'
|
||||
|
||||
// 创建 axios 实例
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
// 设置请求拦截器,自动添加 token
|
||||
axiosInstance.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 添加响应拦截器
|
||||
axiosInstance.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response) {
|
||||
console.error('请求错误:', {
|
||||
status: error.response.status,
|
||||
data: error.response.data,
|
||||
config: error.config
|
||||
})
|
||||
} else if (error.request) {
|
||||
console.error('网络错误:', error.request)
|
||||
} else {
|
||||
console.error('请求配置错误:', error.message)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 添加赛事
|
||||
* @param {Object} tournamentData - 赛事数据
|
||||
* @param {string} tournamentData.name - 赛事名称
|
||||
* @param {string} tournamentData.format - 赛事类型(single, double, count)
|
||||
* @param {string} tournamentData.organizer - 组织者
|
||||
* @param {string} tournamentData.qq_code - QQ号
|
||||
* @param {string} tournamentData.status - 状态(prepare, finish, starting)
|
||||
* @param {string} tournamentData.start_time - 开始时间(格式年/月/日,例2025/05/24)
|
||||
* @param {string} tournamentData.end_time - 结束时间(格式年/月/日,例2025/05/24)
|
||||
* @returns {Promise} 返回添加赛事的响应数据
|
||||
*/
|
||||
export const addTournament = async (tournamentData) => {
|
||||
try {
|
||||
const response = await axiosInstance.post('/tournament/add', tournamentData)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('添加赛事失败:', {
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
message: error.message
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取赛事列表
|
||||
* @returns {Promise} 返回赛事列表数据
|
||||
*/
|
||||
export const getTournamentList = async () => {
|
||||
try {
|
||||
const response = await axiosInstance.get('/tournament/getlist')
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('获取赛事列表失败:', {
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
message: error.message
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 更新赛事
|
||||
export const updateTournament = async (id, data) => {
|
||||
try {
|
||||
console.log('更新赛事,发送数据:', data)
|
||||
const response = await axiosInstance.put(`/tournament/update/${id}`, {
|
||||
name: data.name,
|
||||
format: data.format,
|
||||
organizer: data.organizer,
|
||||
qq_code: data.qq_code,
|
||||
start_time: data.start_time,
|
||||
end_time: data.end_time,
|
||||
status: data.status
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('更新赛事失败:', error)
|
||||
if (error.response) {
|
||||
console.error('错误详情:', {
|
||||
status: error.response.status,
|
||||
data: error.response.data,
|
||||
headers: error.response.headers,
|
||||
config: error.config
|
||||
})
|
||||
// 如果有详细的错误信息,抛出它
|
||||
if (error.response.data?.detail) {
|
||||
throw new Error(error.response.data.detail)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 删除赛事
|
||||
export const deleteTournament = async (id) => {
|
||||
try {
|
||||
const response = await axiosInstance.delete(`/tournament/delete/${id}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('删除赛事失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 添加报名结果
|
||||
export const addSignUpResult = async (data) => {
|
||||
try {
|
||||
const response = await axiosInstance.post('/tournament/signup_result/add', {
|
||||
tournament_id: parseInt(data.tournament_id),
|
||||
tournament_name: data.tournament_name,
|
||||
team_name: data.team_name,
|
||||
sign_name: data.sign_name.trim(),
|
||||
win: '0',
|
||||
lose: '0',
|
||||
status: 'tie'
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('请求错误:', error)
|
||||
if (error.response?.data?.detail) {
|
||||
throw new Error(error.response.data.detail)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 获取参赛结果列表
|
||||
export const getSignUpResultList = async () => {
|
||||
try {
|
||||
const response = await axiosInstance.get('/tournament/signup_result/getlist')
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('获取参赛结果列表失败:', {
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
message: error.message
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 更新参赛结果
|
||||
export const updateSignUpResult = async (id, data) => {
|
||||
try {
|
||||
// 更新报名信息
|
||||
console.log('更新报名信息...')
|
||||
await axiosInstance.put(`/tournament/signup/update/${id}`, {
|
||||
tournament_id: parseInt(data.tournament_id),
|
||||
type: data.team_name ? 'teamname' : 'individual',
|
||||
teamname: data.team_name || '',
|
||||
faction: data.faction || 'random',
|
||||
username: data.sign_name,
|
||||
qq: data.qq || ''
|
||||
})
|
||||
console.log('报名信息更新成功')
|
||||
// 更新报名结果
|
||||
console.log('更新报名结果...')
|
||||
await axiosInstance.put(`/tournament/signup_result/update/${id}`, {
|
||||
tournament_id: parseInt(data.tournament_id),
|
||||
tournament_name: data.tournament_name,
|
||||
team_name: data.team_name || null,
|
||||
sign_name: data.sign_name,
|
||||
win: data.win || '0',
|
||||
lose: data.lose || '0',
|
||||
status: data.status || 'tie'
|
||||
})
|
||||
console.log('报名结果更新成功')
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('更新参赛结果失败:', {
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
message: error.message
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 删除参赛选手
|
||||
export const deleteSignUpResult = async (id) => {
|
||||
try {
|
||||
// 删除报名结果
|
||||
console.log('删除报名结果...')
|
||||
await axiosInstance.delete(`/tournament/signup_result/delete/${id}`)
|
||||
console.log('报名结果删除成功')
|
||||
|
||||
// 删除报名信息
|
||||
console.log('删除报名信息...')
|
||||
await axiosInstance.delete(`/tournament/signup/delete/${id}`)
|
||||
console.log('报名信息删除成功')
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('删除参赛选手失败:', {
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
message: error.message
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 添加报名
|
||||
export const addSignUp = async (data) => {
|
||||
try {
|
||||
console.log('开始报名流程,数据:', data)
|
||||
|
||||
// 调用报名 API
|
||||
console.log('调用报名 API...')
|
||||
await axiosInstance.post('/tournament/signup/add', {
|
||||
tournament_id: data.id,
|
||||
type: data.type,
|
||||
teamname: data.team_name || '',
|
||||
faction: data.faction || 'random',
|
||||
username: data.sign_name,
|
||||
qq: data.qq || ''
|
||||
})
|
||||
console.log('报名 API 调用成功')
|
||||
|
||||
// 调用报名结果 API
|
||||
console.log('调用报名结果 API...')
|
||||
await axiosInstance.post('/tournament/signup_result/add', {
|
||||
tournament_id: data.id,
|
||||
tournament_name: data.tournament_name,
|
||||
team_name: data.team_name || null,
|
||||
sign_name: data.sign_name,
|
||||
win: '0',
|
||||
lose: '0',
|
||||
status: 'tie'
|
||||
})
|
||||
console.log('报名结果 API 调用成功')
|
||||
|
||||
return {
|
||||
signup: { success: true },
|
||||
result: { success: true }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('报名请求错误:', {
|
||||
message: error.message,
|
||||
response: error.response?.data,
|
||||
status: error.response?.status,
|
||||
config: error.config
|
||||
})
|
||||
|
||||
// 如果是服务器返回的错误信息,直接使用
|
||||
if (error.response?.data?.detail) {
|
||||
throw new Error(error.response.data.detail)
|
||||
}
|
||||
// 其他错误,包装成更友好的错误信息
|
||||
throw new Error('报名失败,请检查网络连接后重试')
|
||||
}
|
||||
}
|
BIN
src/assets/备案图标.png
Normal file
BIN
src/assets/备案图标.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.4 KiB |
221
src/components/RankContestant.vue
Normal file
221
src/components/RankContestant.vue
Normal 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>
|
@ -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>
|
||||
<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>
|
||||
</template>
|
||||
</bracket>
|
||||
<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 }
|
||||
const props = defineProps({
|
||||
isEditMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
{
|
||||
player1: { id: "3", name: "战队 C", score: 0, winner: false },
|
||||
player2: { id: "4", name: "战队 D", score: 2, winner: true }
|
||||
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}`
|
||||
},
|
||||
// 决赛
|
||||
{
|
||||
games: [
|
||||
{
|
||||
player1: { id: "1", name: "战队 A", score: 3, winner: true },
|
||||
player2: { id: "4", name: "战队 D", score: 1, winner: false }
|
||||
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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.team-name {
|
||||
font-size: 14px;
|
||||
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;
|
||||
padding: 2px 8px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.vtb-player.winner) .team-score,
|
||||
.custom-node.winner .team-score {
|
||||
background: rgba(129, 199, 132, 0.1);
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:deep(.vtb-wrapper) {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
:deep(.vtb-player),
|
||||
.node-content {
|
||||
gap: 8px;
|
||||
width: 300px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
border-radius: 4px;
|
||||
margin: 4px 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.team-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
.player-info.winner {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.team-score {
|
||||
min-width: 20px;
|
||||
.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: 1px 6px;
|
||||
}
|
||||
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>
|
@ -191,7 +191,7 @@ const handleLogin = async () => {
|
||||
|
||||
// 登录成功,跳转到首页
|
||||
if (response.access_token) {
|
||||
router.push('/dashboard')
|
||||
router.push('/')
|
||||
} else {
|
||||
throw new Error('登录失败:未获取到访问令牌')
|
||||
}
|
||||
|
@ -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('')
|
||||
@ -189,7 +174,6 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
const handleRegister = async () => {
|
||||
try {
|
||||
// 验证表单
|
||||
if (!validateUsername() || !validatePassword() || !validateConfirmPassword()) {
|
||||
return
|
||||
@ -201,7 +185,6 @@ const handleRegister = async () => {
|
||||
}
|
||||
|
||||
// 获取验证token
|
||||
try {
|
||||
const serverToken = await VAPTCHAObj.value.getServerToken()
|
||||
console.log('获取到的 serverToken:', serverToken)
|
||||
|
||||
@ -229,14 +212,6 @@ const handleRegister = async () => {
|
||||
alert('注册成功')
|
||||
// 切换到登录模块
|
||||
emit('login')
|
||||
} catch (tokenError) {
|
||||
console.error('获取验证token失败:', tokenError)
|
||||
alert('获取验证token失败,请重新验证')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('注册失败:', error)
|
||||
alert(error.message || '注册失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -16,7 +16,8 @@ const routes = [
|
||||
{
|
||||
path: 'demands',
|
||||
name: 'DemandList',
|
||||
component: () => import('@/views/index/DemandList.vue')
|
||||
component: () => import('@/views/index/DemandList.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'maps',
|
||||
@ -36,17 +37,32 @@ const routes = [
|
||||
{
|
||||
path: 'weapon-match',
|
||||
name: 'WeaponMatch',
|
||||
component: () => import('@/views/index/WeaponMatch.vue')
|
||||
component: () => import('@/views/index/WeaponMatch.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'competition',
|
||||
name: 'Competition',
|
||||
component: () => import('@/views/index/Competition.vue')
|
||||
component: () => import('@/views/index/Competition.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'competition/:id',
|
||||
path: 'competition/add',
|
||||
name: 'AddCompetition',
|
||||
component: () => import('@/views/index/AddContestant.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'competition/detail',
|
||||
name: 'CompetitionDetail',
|
||||
component: () => import('@/views/index/CompetitionDetail.vue')
|
||||
component: () => import('@/views/index/CompetitionDetail.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'competition/signup',
|
||||
name: 'CompetitionSignUp',
|
||||
component: () => import('@/views/index/CompetitionSignUp.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -1,5 +1,4 @@
|
||||
<template>
|
||||
|
||||
<button @click="handleLogout">
|
||||
退出登录
|
||||
</button>
|
||||
@ -26,7 +25,7 @@ const handleLogout = () => {
|
||||
// 清除本地存储的 token
|
||||
localStorage.removeItem('access_token')
|
||||
// 跳转到登录页面
|
||||
router.push('/backend/login')
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -1,5 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const isLoggedIn = computed(() => {
|
||||
return !!localStorage.getItem('access_token')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -10,14 +14,14 @@
|
||||
<div class="nav-brand">红色警戒3数据分析中心</div>
|
||||
<router-link to="/maps" class="nav-link">最近上传地图</router-link>
|
||||
<router-link to="/weekly" class="nav-link">热门下载地图</router-link>
|
||||
<router-link to="/weapon-match" class="nav-link">Weapon 匹配</router-link>
|
||||
<router-link to="/competition" class="nav-link">赛程信息</router-link>
|
||||
<router-link to="/demands" class="nav-link">办事大厅</router-link>
|
||||
<router-link v-if="isLoggedIn" to="/weapon-match" class="nav-link">Weapon 匹配</router-link>
|
||||
<router-link v-if="isLoggedIn" to="/competition" class="nav-link">赛程信息</router-link>
|
||||
<router-link v-if="isLoggedIn" to="/demands" class="nav-link">办事大厅</router-link>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<router-link to="/backend/login" class="nav-link login-btn">
|
||||
<router-link :to="isLoggedIn ? '/backend/dashboard' : '/backend/login'" class="nav-link login-btn">
|
||||
<i class="fas fa-user"></i>
|
||||
管理登录
|
||||
{{ isLoggedIn ? '管理后台' : '登录' }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@ -28,6 +32,10 @@
|
||||
<footer class="footer">
|
||||
<div class="footer-bottom">
|
||||
<p>Byz解忧杂货铺</p>
|
||||
<p class="beian">
|
||||
<img src="../assets/备案图标.png" alt="公安备案图标" class="police-icon"/>
|
||||
<a href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802045768" rel="noreferrer" target="_blank">京公网安备11010802045768号</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
@ -240,6 +248,33 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.beian {
|
||||
margin-top: 12px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.police-icon {
|
||||
height: 20px;
|
||||
width: 18px;
|
||||
object-fit: contain;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.beian a {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.beian a:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.footer {
|
||||
padding: 1.5rem 1rem;
|
||||
@ -258,5 +293,19 @@
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.beian {
|
||||
margin-top: 8px !important;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.police-icon {
|
||||
height: 18px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.beian a {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
678
src/views/index/AddContestant.vue
Normal file
678
src/views/index/AddContestant.vue
Normal file
@ -0,0 +1,678 @@
|
||||
<template>
|
||||
<div class="add-contestant">
|
||||
<div class="page-header">
|
||||
<h1>添加新赛事</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn-excel" @click="handleExcelImport">
|
||||
<i class="fas fa-file-excel"></i>
|
||||
通过表格添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Excel导入弹窗 -->
|
||||
<div v-if="showExcelDialog" class="excel-dialog-overlay">
|
||||
<div class="excel-dialog">
|
||||
<h3>通过Excel导入赛事信息</h3>
|
||||
<div class="excel-upload-area" @click="triggerFileInput" @dragover.prevent @drop.prevent="handleFileDrop"
|
||||
:class="{ 'is-dragover': isDragover }">
|
||||
<input type="file" ref="fileInput" accept=".xlsx" @change="handleFileSelect" style="display: none">
|
||||
<div class="upload-content">
|
||||
<i class="fas fa-file-excel"></i>
|
||||
<p>点击或拖拽Excel文件到此处</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="eventPreviewData.length > 0" class="preview-table">
|
||||
<h4>赛事信息表预览</h4>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="h in eventPreviewHeaders" :key="h">{{ h }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in eventPreviewData" :key="index">
|
||||
<td v-for="h in eventPreviewHeaders" :key="h">{{ item[h] }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="playerPreviewData.length > 0" class="preview-table">
|
||||
<h4>选手报名表预览</h4>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="h in playerPreviewHeaders" :key="h">{{ h }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in playerPreviewData" :key="index">
|
||||
<td v-for="h in playerPreviewHeaders" :key="h">{{ item[h] }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button class="confirm-btn" @click="confirmImport" :disabled="eventPreviewData.length === 0">确认导入</button>
|
||||
<button class="cancel-btn" @click="closeExcelDialog">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="contest-form">
|
||||
<div class="form-group">
|
||||
<label for="name">赛事名称</label>
|
||||
<input type="text" id="name" v-model="formData.name" required placeholder="请输入赛事名称">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="format">赛制类型</label>
|
||||
<select id="format" v-model="formData.format" required>
|
||||
<option value="">请选择赛制类型</option>
|
||||
<option value="single">单败淘汰</option>
|
||||
<option value="double">双败淘汰</option>
|
||||
<option value="count">积分赛</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="organizer">组织者</label>
|
||||
<input type="text" id="organizer" v-model="formData.organizer" required placeholder="请输入组织者姓名">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="qq_code">QQ号</label>
|
||||
<input type="text" id="qq_code" v-model="formData.qq_code" required placeholder="请输入QQ号">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="start_time">开始时间</label>
|
||||
<input type="date" id="start_time" v-model="formData.start_time" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="end_time">结束时间</label>
|
||||
<input type="date" id="end_time" v-model="formData.end_time" required>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-btn">提交</button>
|
||||
<button type="button" class="cancel-btn" @click="handleCancel">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { addTournament, addSignUp, getTournamentList } from '@/api/tournament'
|
||||
import * as XLSX from 'xlsx'
|
||||
|
||||
const router = useRouter()
|
||||
const showExcelDialog = ref(false)
|
||||
const isDragover = ref(false)
|
||||
const fileInput = ref(null)
|
||||
const eventPreviewData = ref([])
|
||||
const eventPreviewHeaders = ref([])
|
||||
const playerPreviewData = ref([])
|
||||
const playerPreviewHeaders = ref([])
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
format: '',
|
||||
organizer: '',
|
||||
qq_code: '',
|
||||
status: 'prepare',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
// Excel赛事信息字段与表单字段的映射
|
||||
const excelFieldMap = {
|
||||
'赛事名称': 'name',
|
||||
'开始时间': 'start_time',
|
||||
'结束时间': 'end_time',
|
||||
'组织者': 'organizer',
|
||||
'联系方式': 'qq_code',
|
||||
'赛制类型': 'format',
|
||||
}
|
||||
|
||||
// 赛制类型中英文映射
|
||||
const formatMap = {
|
||||
'单败淘汰': 'single',
|
||||
'双败淘汰': 'double',
|
||||
'积分赛': 'count'
|
||||
}
|
||||
|
||||
// 阵营中英文映射
|
||||
const factionMap = {
|
||||
'盟军': 'allied',
|
||||
'苏联': 'soviet',
|
||||
'帝国': 'empire',
|
||||
'OB': 'ob',
|
||||
'解说': 'voice',
|
||||
'随机': 'random'
|
||||
}
|
||||
const parseDate = (val) => {
|
||||
if (!val) return ''
|
||||
if (!isNaN(val) && Number(val) > 30000 && Number(val) < 90000) {
|
||||
const date = new Date(Math.round((Number(val) - 25569) * 86400 * 1000))
|
||||
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`
|
||||
}
|
||||
const match = String(val).match(/(\d{4})[年/-](\d{1,2})[月/-](\d{1,2})日?/)
|
||||
if (match) {
|
||||
return `${match[1]}/${parseInt(match[2], 10)}/${parseInt(match[3], 10)}`
|
||||
}
|
||||
return String(val).replace(/-/g, '/').slice(0, 10)
|
||||
}
|
||||
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const formatDate = (dateStr) => {
|
||||
const [year, month, day] = dateStr.split('-')
|
||||
return `${year}/${month}/${day}`
|
||||
}
|
||||
|
||||
// 构建API请求数据
|
||||
const tournamentData = {
|
||||
name: formData.value.name,
|
||||
format: formData.value.format,
|
||||
organizer: formData.value.organizer,
|
||||
qq_code: String(formData.value.qq_code), // 确保 qq_code 是字符串类型
|
||||
status: 'prepare',
|
||||
start_time: formatDate(formData.value.start_time),
|
||||
end_time: formatDate(formData.value.end_time)
|
||||
}
|
||||
|
||||
// 调用API添加赛事
|
||||
await addTournament(tournamentData)
|
||||
|
||||
alert('添加赛事成功!')
|
||||
router.push('/competition')
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
alert(error.response?.data?.message || '添加赛事失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const handleExcelImport = () => {
|
||||
showExcelDialog.value = true
|
||||
}
|
||||
|
||||
const handleFileDrop = (e) => {
|
||||
isDragover.value = false
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) {
|
||||
processExcelFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (file) {
|
||||
processExcelFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
const processExcelFile = (file) => {
|
||||
eventPreviewData.value = []
|
||||
eventPreviewHeaders.value = []
|
||||
playerPreviewData.value = []
|
||||
playerPreviewHeaders.value = []
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target.result)
|
||||
const workbook = XLSX.read(data, { type: 'array' })
|
||||
const playerSheetName = workbook.SheetNames.find(name => name === '选手报名表')
|
||||
if (playerSheetName) {
|
||||
const sheet = workbook.Sheets[playerSheetName]
|
||||
const jsonData = XLSX.utils.sheet_to_json(sheet, { header: 1 })
|
||||
if (jsonData.length >= 2) {
|
||||
let headerRowIdx = -1
|
||||
for (let i = 0; i < jsonData.length; i++) {
|
||||
if (!jsonData[i]) continue
|
||||
const normRow = jsonData[i].map(cell => String(cell || '').replace(/\s+/g, '').replace(/ /g, '').trim())
|
||||
if (normRow.includes('序号')) {
|
||||
headerRowIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (headerRowIdx !== -1) {
|
||||
const headers = jsonData[headerRowIdx].map(cell => String(cell || '').replace(/\s+/g, '').replace(/ /g, '').trim())
|
||||
const players = []
|
||||
for (let i = headerRowIdx + 1; i < jsonData.length; i++) {
|
||||
const row = jsonData[i]
|
||||
if (!row || row.length === 0 || row.every(cell => cell === undefined || cell === '')) continue
|
||||
const player = {}
|
||||
headers.forEach((h, idx) => {
|
||||
player[h] = row[idx]
|
||||
})
|
||||
players.push(player)
|
||||
}
|
||||
playerPreviewData.value = players
|
||||
playerPreviewHeaders.value = headers
|
||||
}
|
||||
}
|
||||
}
|
||||
const eventSheetName = workbook.SheetNames.find(name => name === '赛事表')
|
||||
if (eventSheetName) {
|
||||
const firstSheet = workbook.Sheets[eventSheetName]
|
||||
const jsonData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 })
|
||||
if (jsonData.length >= 2) {
|
||||
let headerRowIdx = -1
|
||||
let headerColIdx = -1
|
||||
for (let i = 0; i < jsonData.length; i++) {
|
||||
if (!jsonData[i]) continue
|
||||
for (let j = 0; j < jsonData[i].length; j++) {
|
||||
const cell = typeof jsonData[i][j] === 'string' ? jsonData[i][j].trim() : jsonData[i][j]
|
||||
if (Object.keys(excelFieldMap).includes(cell)) {
|
||||
headerRowIdx = i
|
||||
headerColIdx = j
|
||||
break
|
||||
}
|
||||
}
|
||||
if (headerRowIdx !== -1) break
|
||||
}
|
||||
if (headerRowIdx !== -1 && headerColIdx !== -1) {
|
||||
let dataRow = null
|
||||
for (let i = headerRowIdx + 1; i < jsonData.length; i++) {
|
||||
if (jsonData[i] && jsonData[i].slice(headerColIdx).some(cell => cell !== undefined && cell !== '')) {
|
||||
dataRow = jsonData[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if (dataRow) {
|
||||
const headers = jsonData[headerRowIdx].slice(headerColIdx).map(h => typeof h === 'string' ? h.trim() : h)
|
||||
const dataCells = dataRow.slice(headerColIdx)
|
||||
const previewObj = {}
|
||||
Object.keys(excelFieldMap).forEach((h) => {
|
||||
const idx = headers.indexOf(h)
|
||||
let val = idx !== -1 ? dataCells[idx] : ''
|
||||
if (["开始时间", "结束时间"].includes(h)) {
|
||||
val = parseDate(val)
|
||||
} else if (h === "联系方式") { // 处理 qq_code
|
||||
val = String(val || '') // 确保 qq_code 是字符串类型
|
||||
}
|
||||
previewObj[h] = val
|
||||
})
|
||||
eventPreviewData.value = [previewObj]
|
||||
eventPreviewHeaders.value = Object.keys(excelFieldMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Excel文件格式错误,请检查文件内容')
|
||||
}
|
||||
}
|
||||
reader.readAsArrayBuffer(file)
|
||||
}
|
||||
|
||||
const confirmImport = async () => {
|
||||
if (eventPreviewData.value.length > 0) {
|
||||
const excelRow = eventPreviewData.value[0]
|
||||
const tournamentData = {}
|
||||
Object.keys(excelFieldMap).forEach((excelKey) => {
|
||||
const apiKey = excelFieldMap[excelKey]
|
||||
if (apiKey === 'qq_code') {
|
||||
tournamentData[apiKey] = String(excelRow[excelKey])
|
||||
} else if (apiKey === 'format') {
|
||||
const formatValue = formatMap[excelRow[excelKey]]
|
||||
if (formatValue) {
|
||||
tournamentData[apiKey] = formatValue
|
||||
} else {
|
||||
throw new Error('赛制类型必须是:单败淘汰、双败淘汰或积分赛')
|
||||
}
|
||||
} else {
|
||||
tournamentData[apiKey] = excelRow[excelKey]
|
||||
}
|
||||
})
|
||||
tournamentData.status = 'prepare'
|
||||
let tournamentName = tournamentData.name
|
||||
|
||||
try {
|
||||
await addTournament(tournamentData)
|
||||
alert('赛事导入成功!')
|
||||
|
||||
const tournamentList = await getTournamentList()
|
||||
console.log('获取到的赛事列表:', tournamentList)
|
||||
console.log('要匹配的赛事名称:', tournamentName)
|
||||
|
||||
const matchedTournament = tournamentList.find(t => t.name === tournamentName)
|
||||
console.log('匹配到的赛事:', matchedTournament)
|
||||
|
||||
if (!matchedTournament || !matchedTournament.id) {
|
||||
throw new Error(`未找到对应的赛事信息: ${tournamentName}`)
|
||||
}
|
||||
|
||||
if (playerPreviewData.value.length > 0) {
|
||||
for (const row of playerPreviewData.value) {
|
||||
console.log('处理选手数据:', row)
|
||||
const isTeam = !!row['团队参赛选手ID']
|
||||
const signUpData = {
|
||||
id: matchedTournament.id,
|
||||
tournament_name: tournamentName,
|
||||
type: isTeam ? 'teamname' : 'individual',
|
||||
team_name: row['队伍名称或者个人参赛名称'] || '',
|
||||
sign_name: row['队伍名称或者个人参赛名称'],
|
||||
faction: factionMap[row['阵营']] || 'random', // 使用阵营映射
|
||||
qq_code: String(row['QQ'] || '')
|
||||
}
|
||||
console.log('提交的报名数据:', signUpData)
|
||||
|
||||
if (!signUpData.id || !signUpData.sign_name) {
|
||||
console.error('报名数据缺少必需字段:', signUpData)
|
||||
throw new Error(`报名数据不完整,请检查数据: ${JSON.stringify(row)}`)
|
||||
}
|
||||
|
||||
try {
|
||||
await addSignUp(signUpData)
|
||||
} catch (error) {
|
||||
console.error('报名失败:', error)
|
||||
alert(`报名失败: ${row['队伍名称或者个人参赛名称']},${error.message}`)
|
||||
}
|
||||
}
|
||||
alert('选手报名表导入完成!')
|
||||
}
|
||||
|
||||
router.push('/competition')
|
||||
} catch (error) {
|
||||
console.error('导入失败:', error)
|
||||
alert(error.response?.data?.message || '赛事导入失败,请重试')
|
||||
}
|
||||
closeExcelDialog()
|
||||
}
|
||||
}
|
||||
|
||||
const closeExcelDialog = () => {
|
||||
showExcelDialog.value = false
|
||||
eventPreviewData.value = []
|
||||
eventPreviewHeaders.value = []
|
||||
playerPreviewData.value = []
|
||||
playerPreviewHeaders.value = []
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const triggerFileInput = () => {
|
||||
if (fileInput.value) {
|
||||
fileInput.value.click()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.add-contestant {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 30px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 24px;
|
||||
color: #1a237e;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-back,
|
||||
.btn-excel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
background: white;
|
||||
color: #666;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background: #f5f5f5;
|
||||
border-color: #ccc;
|
||||
}
|
||||
|
||||
.btn-excel {
|
||||
background: linear-gradient(135deg, #71eaeb 0%, #416bdf 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-excel:hover {
|
||||
background: linear-gradient(135deg, #416bdf 0%, #71eaeb 100%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.contest-form {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
border-color: #1a237e;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(26, 35, 126, 0.1);
|
||||
}
|
||||
|
||||
.form-group input::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.submit-btn,
|
||||
.cancel-btn {
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: linear-gradient(135deg, #71eaeb 0%, #416bdf 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
background: linear-gradient(135deg, #416bdf 0%, #71eaeb 100%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: white;
|
||||
color: #666;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: #f5f5f5;
|
||||
border-color: #ccc;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.add-contestant {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.contest-form {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.submit-btn,
|
||||
.cancel-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.excel-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;
|
||||
}
|
||||
|
||||
.excel-dialog {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 600px;
|
||||
max-width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.excel-upload-area {
|
||||
border: 2px dashed #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.excel-upload-area.is-dragover {
|
||||
border-color: #416bdf;
|
||||
background: rgba(65, 107, 223, 0.1);
|
||||
}
|
||||
|
||||
.upload-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.upload-content i {
|
||||
font-size: 48px;
|
||||
color: #416bdf;
|
||||
}
|
||||
|
||||
.upload-tip {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview-table {
|
||||
margin: 20px 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.preview-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.preview-table th,
|
||||
.preview-table td {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
text-align: left;
|
||||
max-width: 200px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.preview-table th {
|
||||
background: #f5f5f5;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
background: linear-gradient(135deg, #71eaeb 0%, #416bdf 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 20px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, box-shadow 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(65, 107, 223, 0.08);
|
||||
}
|
||||
|
||||
.confirm-btn:hover {
|
||||
background: linear-gradient(135deg, #416bdf 0%, #71eaeb 100%);
|
||||
box-shadow: 0 4px 16px rgba(65, 107, 223, 0.15);
|
||||
}
|
||||
|
||||
.confirm-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
@ -1,193 +1,523 @@
|
||||
<template>
|
||||
<div class="demand-hall">
|
||||
<div class="competition-page">
|
||||
<div class="page-header">
|
||||
<h1>赛程信息</h1>
|
||||
<div class="header-subtitle">
|
||||
<span class="date-range">点击即可查看和报名</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button class="btn-common btn-gradient btn-margin-right" @click="addNewCompetition">添加赛程</button>
|
||||
<button class="btn-common btn-light" @click="refreshCompetitions">刷新赛程</button>
|
||||
|
||||
<div class="action-bar">
|
||||
<div class="left-actions">
|
||||
<button class="btn-common btn-gradient" @click="addNewCompetition">
|
||||
<i class="fas fa-plus"></i>
|
||||
添加赛程
|
||||
</button>
|
||||
<button
|
||||
class="btn-common btn-light"
|
||||
@click="refreshCompetitions"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<i class="fas" :class="isLoading ? 'fa-spinner fa-spin' : 'fa-sync-alt'"></i>
|
||||
{{ isLoading ? '刷新中...' : '刷新赛程' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="maps-table">
|
||||
<div class="right-actions">
|
||||
<div class="search-box">
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索赛程..."
|
||||
@input="handleSearch"
|
||||
>
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
</div>
|
||||
<select v-model="filterStatus" @change="handleFilter" class="filter-select">
|
||||
<option value="all">全部状态</option>
|
||||
<option value="ongoing">进行中</option>
|
||||
<option value="finished">已结束</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="errorMessage" class="error-message">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
{{ errorMessage }}
|
||||
<button class="retry-btn" @click="refreshCompetitions">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-container" :class="{ 'loading': isLoading }">
|
||||
<table class="competition-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>序号</th>
|
||||
<th>赛程名称</th>
|
||||
<th>比赛时间</th>
|
||||
<th>是否结赛</th>
|
||||
<th>主办人</th>
|
||||
<th>联系方式</th>
|
||||
<th>比赛方式</th>
|
||||
<th>比赛类型</th>
|
||||
<th>开始时间</th>
|
||||
<th>结束时间</th>
|
||||
<th>状态</th>
|
||||
<th>组织者</th>
|
||||
<th>QQ号</th>
|
||||
<th>赛制类型</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(competition, index) in competitions"
|
||||
:key="competition.id"
|
||||
@click="handleCompetitionClick(competition)"
|
||||
class="competition-row">
|
||||
<tr v-for="(competition, index) in filteredCompetitions"
|
||||
:key="index"
|
||||
class="competition-row"
|
||||
@click="handleView(competition)">
|
||||
<td>{{ index + 1 }}</td>
|
||||
<td>{{ competition.name }}</td>
|
||||
<td>{{ competition.time }}</td>
|
||||
<td class="competition-name">{{ competition.name }}</td>
|
||||
<td>{{ formatDate(competition.start_time) }}</td>
|
||||
<td>{{ formatDate(competition.end_time) }}</td>
|
||||
<td>
|
||||
<span :class="['status-tag', competition.isFinished ? 'finished' : 'ongoing']">
|
||||
{{ competition.isFinished ? '已结束' : '进行中' }}
|
||||
<span :class="['status-tag', competition.status]">
|
||||
{{ competition.status === 'prepare' ? '筹备中' :
|
||||
competition.status === 'starting' ? '进行中' : '已结束' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ competition.organizer }}</td>
|
||||
<td>{{ competition.contact }}</td>
|
||||
<td>{{ competition.mode }}</td>
|
||||
<td>{{ competition.type }}</td>
|
||||
<td>{{ competition.qq_code }}</td>
|
||||
<td>{{ competition.format === 'single' ? '单败淘汰' :
|
||||
competition.format === 'double' ? '双败淘汰' : '积分赛' }}</td>
|
||||
<td class="action-cell">
|
||||
<button class="action-btn view" @click.stop="handleSignUp(competition)">
|
||||
报名
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="isLoading" class="loading-overlay">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 空状态显示 -->
|
||||
<div v-else-if="filteredCompetitions.length === 0" class="empty-state">
|
||||
<i class="fas fa-calendar-times"></i>
|
||||
<p>暂无赛程信息</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getTournamentList } from '@/api/tournament'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 模拟赛程数据
|
||||
const competitions = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: '单淘汰赛',
|
||||
time: '2024-04-01 14:00',
|
||||
isFinished: false,
|
||||
organizer: '赵六',
|
||||
contact: '13600136000',
|
||||
mode: '单阶段',
|
||||
type: '单淘汰'
|
||||
}
|
||||
])
|
||||
// 状态管理
|
||||
const competitions = ref([])
|
||||
const searchQuery = ref('')
|
||||
const filterStatus = ref('all')
|
||||
const isLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
// 处理赛程点击事件
|
||||
const handleCompetitionClick = (competition) => {
|
||||
// 计算属性
|
||||
const filteredCompetitions = computed(() => {
|
||||
let result = competitions.value
|
||||
// 搜索过滤
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter(comp =>
|
||||
comp.name.toLowerCase().includes(query) ||
|
||||
comp.organizer.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
// 状态过滤
|
||||
if (filterStatus.value !== 'all') {
|
||||
result = result.filter(comp =>
|
||||
filterStatus.value === 'ongoing' ? comp.status === 'starting' : comp.status === 'finish'
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 方法
|
||||
const formatDate = (date) => {
|
||||
return date.replace(/\//g, '-')
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
// 搜索时重置过滤
|
||||
}
|
||||
|
||||
const handleFilter = () => {
|
||||
// 过滤时重置搜索
|
||||
}
|
||||
|
||||
const handleView = (competition) => {
|
||||
router.push({
|
||||
path: `/competition/${competition.id}`,
|
||||
path: '/competition/detail',
|
||||
query: {
|
||||
id: competition.id,
|
||||
name: competition.name,
|
||||
time: competition.time,
|
||||
start_time: competition.start_time,
|
||||
end_time: competition.end_time,
|
||||
organizer: competition.organizer,
|
||||
contact: competition.contact,
|
||||
mode: competition.mode,
|
||||
type: competition.type
|
||||
qq_code: competition.qq_code,
|
||||
format: competition.format,
|
||||
status: competition.status
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 刷新赛程数据
|
||||
const refreshCompetitions = () => {
|
||||
// 这里可以添加实际的API调用
|
||||
console.log('刷新赛程数据')
|
||||
const handleSignUp = (competition) => {
|
||||
router.push({
|
||||
name: 'CompetitionSignUp',
|
||||
query: {
|
||||
id: competition.id,
|
||||
name: competition.name,
|
||||
start_time: competition.start_time,
|
||||
end_time: competition.end_time,
|
||||
organizer: competition.organizer,
|
||||
qq_code: competition.qq_code,
|
||||
format: competition.format,
|
||||
status: competition.status
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 添加新赛程
|
||||
const addNewCompetition = () => {
|
||||
// 这里可以添加跳转到添加赛程页面的逻辑
|
||||
console.log('添加新赛程')
|
||||
router.push('/competition/add')
|
||||
}
|
||||
|
||||
const refreshCompetitions = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
errorMessage.value = ''
|
||||
const data = await getTournamentList()
|
||||
competitions.value = data
|
||||
console.log('刷新赛程数据成功')
|
||||
} catch (error) {
|
||||
console.error('获取赛程数据失败:', error)
|
||||
errorMessage.value = error.response?.data?.message || '获取赛程数据失败,请重试'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
refreshCompetitions()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.competition-page {
|
||||
padding: 20px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 24px;
|
||||
color: #1a237e;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.left-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.right-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
padding: 8px 12px 8px 32px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
width: 200px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
border-color: #1a237e;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(26, 35, 126, 0.1);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
min-width: 120px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-common {
|
||||
display: inline-block;
|
||||
padding: 8px 22px;
|
||||
font-size: 15px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #b6d2ff;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s, border 0.2s;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
margin-bottom: 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-gradient {
|
||||
background: linear-gradient(90deg, #71eaeb 0%, #416bdf 100%);
|
||||
color: #fff;
|
||||
border: 1px solid #71eaeb;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-gradient:hover {
|
||||
background: linear-gradient(90deg, #416bdf 0%, #71eaeb 100%);
|
||||
color: #fff;
|
||||
border: 1.5px solid #416bdf;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-light {
|
||||
background: linear-gradient(90deg, #e3f0ff 0%, #f7fbff 100%);
|
||||
background: white;
|
||||
color: #2563eb;
|
||||
border: 1px solid #b6d2ff;
|
||||
}
|
||||
|
||||
.btn-light:hover {
|
||||
background: linear-gradient(90deg, #d0e7ff 0%, #eaf4ff 100%);
|
||||
color: #174ea6;
|
||||
border: 1.5px solid #2563eb;
|
||||
background: #f5f7fa;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
/* 按钮间距 */
|
||||
.btn-margin-right {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
.table-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.competition-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 1000px;
|
||||
}
|
||||
|
||||
.competition-table th,
|
||||
.competition-table td {
|
||||
padding: 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.competition-table th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #1a237e;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.competition-row {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.competition-row:hover {
|
||||
background-color: #f5f7fa;
|
||||
background-color: #f0f7ff;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.competition-name {
|
||||
font-weight: 500;
|
||||
color: #1a237e;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-tag.ongoing {
|
||||
background-color: #e1f3d8;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.status-tag.finished {
|
||||
background-color: #f4f4f5;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.maps-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.maps-table th,
|
||||
.maps-table td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.maps-table th {
|
||||
background-color: #f5f7fa;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.maps-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
.status-tag.prepare {
|
||||
background-color: #e6a23c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.status-tag.starting {
|
||||
background-color: #67c23a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.status-tag.finish {
|
||||
background-color: #909399;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background: linear-gradient(90deg, #71eaeb 0%, #416bdf 100%);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(65, 107, 223, 0.2);
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #fef0f0;
|
||||
color: #f56c6c;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.error-message i {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
margin-left: auto;
|
||||
padding: 4px 12px;
|
||||
background: #f56c6c;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background: #f78989;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.loading-overlay i {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.table-container.loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn-common:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-common:disabled:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.competition-page {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.right-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
margin: 0 -15px;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
File diff suppressed because it is too large
Load Diff
518
src/views/index/CompetitionSignUp.vue
Normal file
518
src/views/index/CompetitionSignUp.vue
Normal file
@ -0,0 +1,518 @@
|
||||
<template>
|
||||
<div class="signup-page">
|
||||
<div class="page-header">
|
||||
<h1>比赛报名</h1>
|
||||
</div>
|
||||
|
||||
<div class="signup-form">
|
||||
<div class="form-group">
|
||||
<label>比赛信息</label>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="label">比赛名称:</span>
|
||||
<span class="value">{{ competitionInfo.name }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">开始时间:</span>
|
||||
<span class="value">{{ formatDate(competitionInfo.start_time) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">结束时间:</span>
|
||||
<span class="value">{{ formatDate(competitionInfo.end_time) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">主办方:</span>
|
||||
<span class="value">{{ competitionInfo.organizer }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">QQ号:</span>
|
||||
<span class="value">{{ competitionInfo.qq_code }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">赛制类型:</span>
|
||||
<span class="value">{{ formatType(competitionInfo.format) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">比赛状态:</span>
|
||||
<span class="value status-tag" :class="competitionInfo.status">
|
||||
{{ formatStatus(competitionInfo.status) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>报名信息</label>
|
||||
<div class="input-group">
|
||||
<select v-model="signupForm.type" class="form-select">
|
||||
<option value="teamname">队伍报名</option>
|
||||
<option value="individual">个人报名</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 队伍报名表单 -->
|
||||
<template v-if="signupForm.type === 'teamname'">
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text"
|
||||
v-model="signupForm.teamName"
|
||||
placeholder="请输入队伍名称"
|
||||
class="form-input"
|
||||
>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text"
|
||||
v-model="signupForm.username"
|
||||
placeholder="请输入参赛人员名称"
|
||||
class="form-input"
|
||||
>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<select v-model="signupForm.faction" class="form-select">
|
||||
<option value="allied">盟军</option>
|
||||
<option value="soviet">苏联</option>
|
||||
<option value="empire">帝国</option>
|
||||
<option value="ob">OB</option>
|
||||
<option value="voice">解说</option>
|
||||
<option value="random">随机</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text"
|
||||
v-model="signupForm.qq"
|
||||
placeholder="请输入QQ号"
|
||||
class="form-input"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 个人报名表单 -->
|
||||
<template v-if="signupForm.type === 'individual'">
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text"
|
||||
v-model="signupForm.username"
|
||||
placeholder="请输入参赛人员名称"
|
||||
class="form-input"
|
||||
>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<select v-model="signupForm.faction" class="form-select">
|
||||
<option value="allied">盟军</option>
|
||||
<option value="soviet">苏联</option>
|
||||
<option value="empire">帝国</option>
|
||||
<option value="ob">OB</option>
|
||||
<option value="voice">解说</option>
|
||||
<option value="random">随机</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text"
|
||||
v-model="signupForm.qq"
|
||||
placeholder="请输入QQ号"
|
||||
class="form-input"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn-cancel" @click="handleCancel">取消</button>
|
||||
<button class="btn-submit" @click="handleSubmit">提交报名</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { addSignUp } from '@/api/tournament'
|
||||
|
||||
defineOptions({
|
||||
name: 'CompetitionSignUp'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const competitionInfo = ref({
|
||||
id: route.query.id || '',
|
||||
name: route.query.name || '',
|
||||
start_time: route.query.start_time || '',
|
||||
end_time: route.query.end_time || '',
|
||||
organizer: route.query.organizer || '',
|
||||
qq_code: route.query.qq_code || '',
|
||||
format: route.query.format || '',
|
||||
status: route.query.status || ''
|
||||
})
|
||||
|
||||
const signupForm = ref({
|
||||
type: 'individual',
|
||||
teamName: '',
|
||||
username: '',
|
||||
faction: 'random',
|
||||
qq: ''
|
||||
})
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return ''
|
||||
// 将年/月/日格式转换为年-月-日格式显示
|
||||
return date.replace(/\//g, '-')
|
||||
}
|
||||
|
||||
const formatType = (type) => {
|
||||
const typeMap = {
|
||||
'single': '单败淘汰',
|
||||
'double': '双败淘汰',
|
||||
'count': '积分赛'
|
||||
}
|
||||
return typeMap[type] || type
|
||||
}
|
||||
|
||||
const formatStatus = (status) => {
|
||||
const statusMap = {
|
||||
'prepare': '筹备中',
|
||||
'starting': '进行中',
|
||||
'finish': '已结束'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// 根据报名类型验证必填字段
|
||||
if (signupForm.value.type === 'teamname') {
|
||||
if (!signupForm.value.teamName || !signupForm.value.username) {
|
||||
alert('请填写完整的队伍信息')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (!signupForm.value.username) {
|
||||
alert('请填写完整的个人信息')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 sign_name
|
||||
const signName = signupForm.value.username.trim()
|
||||
if (!signName) {
|
||||
alert('参赛人员名称不能为空')
|
||||
return
|
||||
}
|
||||
if (signName.length < 2) {
|
||||
alert('参赛人员名称至少需要2个字符')
|
||||
return
|
||||
}
|
||||
if (signName.length > 20) {
|
||||
alert('参赛人员名称不能超过20个字符')
|
||||
return
|
||||
}
|
||||
// 只允许中文、英文、数字和下划线
|
||||
if (!/^[\u4e00-\u9fa5a-zA-Z0-9_]+$/.test(signName)) {
|
||||
alert('参赛人员名称只能包含中文、英文、数字和下划线')
|
||||
return
|
||||
}
|
||||
// 不允许纯数字
|
||||
if (/^\d+$/.test(signName)) {
|
||||
alert('参赛人员名称不能为纯数字')
|
||||
return
|
||||
}
|
||||
// 不允许纯下划线
|
||||
if (/^_+$/.test(signName)) {
|
||||
alert('参赛人员名称不能为纯下划线')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 确保所有必需字段都存在
|
||||
if (!competitionInfo.value.id || !competitionInfo.value.name) {
|
||||
alert('比赛信息不完整,请返回重试')
|
||||
return
|
||||
}
|
||||
|
||||
const submitData = {
|
||||
id: parseInt(competitionInfo.value.id),
|
||||
tournament_name: competitionInfo.value.name,
|
||||
type: signupForm.value.type,
|
||||
team_name: signupForm.value.type === 'teamname' ? signupForm.value.teamName : '',
|
||||
sign_name: signName,
|
||||
faction: signupForm.value.faction,
|
||||
qq_code: String(competitionInfo.value.qq_code) // 确保 qq_code 是字符串类型
|
||||
}
|
||||
|
||||
console.log('提交的报名数据:', submitData)
|
||||
const result = await addSignUp(submitData)
|
||||
console.log('报名结果:', result)
|
||||
|
||||
if (result.signup && result.result) {
|
||||
alert('报名成功!')
|
||||
router.push('/competition')
|
||||
} else {
|
||||
console.error('报名结果不完整:', result)
|
||||
throw new Error('报名数据不完整,请重试')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('报名失败:', error)
|
||||
console.error('错误详情:', {
|
||||
message: error.message,
|
||||
response: error.response?.data,
|
||||
status: error.response?.status
|
||||
})
|
||||
|
||||
// 显示具体的错误信息
|
||||
if (error.message.includes('返回数据为空')) {
|
||||
alert('服务器返回数据为空,请稍后重试')
|
||||
} else if (error.message.includes('数据不完整')) {
|
||||
alert('报名数据不完整,请重试')
|
||||
} else if (error.message.includes('网络连接')) {
|
||||
alert('网络连接失败,请检查网络后重试')
|
||||
} else {
|
||||
alert(error.message || '报名失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!competitionInfo.value.name) {
|
||||
router.push('/competition')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.signup-page {
|
||||
padding: 20px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 24px;
|
||||
color: #1a237e;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.competition-name {
|
||||
font-weight: 500;
|
||||
color: #1a237e;
|
||||
}
|
||||
|
||||
.signup-form {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #1a237e;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-item .label {
|
||||
color: #666;
|
||||
min-width: 80px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-item .value {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: #1a237e;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(26, 35, 126, 0.1);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.form-textarea:focus {
|
||||
border-color: #1a237e;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(26, 35, 126, 0.1);
|
||||
}
|
||||
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background-color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.form-select:focus {
|
||||
border-color: #1a237e;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(26, 35, 126, 0.1);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 10px 24px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: #f5f5f5;
|
||||
border-color: #ccc;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
padding: 10px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, #71eaeb 0%, #416bdf 100%);
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(65, 107, 223, 0.2);
|
||||
}
|
||||
|
||||
.btn-submit:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.signup-page {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-cancel,
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-tag.prepare {
|
||||
background-color: #e6a23c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.status-tag.starting {
|
||||
background-color: #67c23a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.status-tag.finish {
|
||||
background-color: #909399;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.status-select {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.status-select .form-select {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.status-select .form-select option[value="tie"] {
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.status-select .form-select option[value="win"] {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.status-select .form-select option[value="lose"] {
|
||||
color: #f44336;
|
||||
}
|
||||
</style>
|
@ -6,6 +6,7 @@
|
||||
<ul>
|
||||
<li :class="{active: activeSection === 'match'}" @click="activeSection = 'match'">模板匹配</li>
|
||||
<li :class="{active: activeSection === 'compare'}" @click="activeSection = 'compare'">武器对比</li>
|
||||
<li :class="{active: activeSection === 'GOBedit'}" @click="activeSection = 'GOBedit'">物体编辑器</li><!--GOBedit:gameobject--->
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@ -235,7 +236,6 @@
|
||||
<p>{{ !selectedWeapons.length ? '请先选择要对比的武器' : '请选择要对比的属性' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="additional-info">
|
||||
<div class="info-card">
|
||||
<h3><i class="icon icon-inheritance"></i> 继承关系</h3>
|
||||
@ -276,14 +276,433 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<!-- 物体编辑器部分 -->
|
||||
<section v-show="activeSection === 'GOBedit'" class="GOBeditor-section">
|
||||
<div class="file-input-container">
|
||||
<input type="file" @change="handleUnitFile" accept=".xml" class="file-input" title="加载单位.xml">
|
||||
<input type="file" @change="handleWeaponFile" accept=".xml" class="file-input" title="加载Weapon.xml">
|
||||
<button v-if="unitDoc" @click="exportUnitXml" class="export-btn">导出XML</button>
|
||||
</div>
|
||||
|
||||
<div v-if="unitDoc" class="unit-editor">
|
||||
<div class="unit-list">
|
||||
<div v-for="gameObject in unitDoc.querySelectorAll('GameObject')" :key="gameObject.getAttribute('id')"
|
||||
class="unit-item" @click="selectGameObject(gameObject)"
|
||||
:class="{ 'selected': selectedGameObject === gameObject }">
|
||||
{{ gameObject.getAttribute('id') || '未命名单位' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedGameObject" class="unit-details">
|
||||
<h3>单位详细信息</h3>
|
||||
<!--ID面板-->
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">ID:</span>
|
||||
<input class="detail-value edit-input" v-model="editableFields.id" @change="updateGameObject">
|
||||
</div>
|
||||
|
||||
<!--CSF面板-->
|
||||
<div class="detail-group">
|
||||
<div class="group-header" @click="toggleGroup('csf')">
|
||||
<span>CSF相关描述</span>
|
||||
<span class="toggle-icon">{{ isGroupOpen('csf') ? '−' : '+' }}</span>
|
||||
</div>
|
||||
<div v-if="isGroupOpen('csf')" class="group-content">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">DisplayName:</span>
|
||||
<input class="detail-value edit-input" v-model="editableFields.displayName" @change="updateDisplayName">
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">TypeDescription:</span>
|
||||
<input class="detail-value edit-input" v-model="editableFields.typeDescription" @change="updateGameObject">
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Description:</span>
|
||||
<input class="detail-value edit-input" v-model="editableFields.description" @change="updateGameObject">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--建造时间面板-->
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">建造时间:</span>
|
||||
<input class="detail-value edit-input" v-model="editableFields.buildTime" @change="updateGameObject" type="number">
|
||||
<span class="unit-suffix">秒</span>
|
||||
</div>
|
||||
|
||||
<!--科技需求面板-->
|
||||
<div class="detail-group">
|
||||
<div class="group-header" @click="toggleGroup('tech')">
|
||||
<span>科技需求</span>
|
||||
<span class="toggle-icon">{{ isGroupOpen('tech') ? '−' : '+' }}</span>
|
||||
</div>
|
||||
<div v-if="isGroupOpen('tech')" class="group-content">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">NeededUpgrade:</span>
|
||||
<input class="detail-value edit-input" v-model="editableFields.neededUpgrade" @change="updateNeededUpgrade">
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">建造数量上限:</span>
|
||||
<input class="detail-value edit-input" v-model="editableFields.maxSimultaneous" @change="updateGameObject" type="number">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--造价和生命值面板-->
|
||||
<div class="detail-group">
|
||||
<div class="group-header" @click="toggleGroup('costHealth')">
|
||||
<span>造价与生命值</span>
|
||||
<span class="toggle-icon">{{ isGroupOpen('costHealth') ? '−' : '+' }}</span>
|
||||
</div>
|
||||
<div v-if="isGroupOpen('costHealth')" class="group-content">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">造价:</span>
|
||||
<input class="detail-value edit-input" v-model="editableFields.buildCost" @change="updateBuildCost" type="number">
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">生命值:</span>
|
||||
<input class="detail-value edit-input" v-model="editableFields.maxHealth" @change="updateMaxHealth" type="number">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!--DPS面板-->
|
||||
|
||||
<!--射程面板-->
|
||||
<div class="detail-group">
|
||||
<div class="group-header" @click="toggleGroup('range')">
|
||||
<span>武器射程</span>
|
||||
<span class="toggle-icon">{{ isGroupOpen('range') ? '−' : '+' }}</span>
|
||||
</div>
|
||||
<div v-if="isGroupOpen('range')" class="group-content">
|
||||
<div v-if="weaponData.length === 0" class="detail-row">
|
||||
<span class="detail-label">无武器数据</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="detail-row" v-for="(weapon, index) in weaponData" :key="index">
|
||||
<span class="detail-label">武器 {{ index + 1 }} ({{ weapon.id }}):</span>
|
||||
<div class="weapon-stats">
|
||||
<!-- 清雾范围未添加 -->
|
||||
<div>攻击范围: {{ weapon.attackRange || 0 }}</div>
|
||||
<div>最小射程: {{ weapon.minAttackRange || 0 }}</div>
|
||||
<div>伤害值: {{ weapon.Damage || 0 }}</div>
|
||||
<div>伤害范围: {{ weapon.Radius || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--移速面板-->
|
||||
<!--技能面板-->
|
||||
<!--碾压等级面板-->
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
//编辑器
|
||||
const weaponXmlData = ref(null)
|
||||
const weaponData = ref([])
|
||||
const selectedGameObject = ref(null)
|
||||
const openGroups = ref(['csf', 'tech', 'costHealth'])
|
||||
const editableFields = ref({
|
||||
id: '',
|
||||
displayName: '',
|
||||
typeDescription: '',
|
||||
description: '',
|
||||
buildTime: '',
|
||||
neededUpgrade: '',
|
||||
maxSimultaneous: '',
|
||||
buildCost: '',
|
||||
maxHealth: ''
|
||||
})
|
||||
|
||||
function selectGameObject(gameObject) {
|
||||
selectedGameObject.value = gameObject
|
||||
updateEditableFields()
|
||||
updateWeaponData()
|
||||
}
|
||||
function updateEditableFields() {
|
||||
if (!selectedGameObject.value) return
|
||||
|
||||
editableFields.value = {
|
||||
id: selectedGameObject.value.getAttribute('id') || '',
|
||||
displayName: extractDisplayName(selectedGameObject.value) || '',
|
||||
typeDescription: selectedGameObject.value.getAttribute('TypeDescription') || '',
|
||||
description: selectedGameObject.value.getAttribute('Description') || '',
|
||||
buildTime: selectedGameObject.value.getAttribute('BuildTime') || '',
|
||||
neededUpgrade: getNeededUpgrade(selectedGameObject.value) || '',
|
||||
maxSimultaneous: selectedGameObject.value.getAttribute('MaxSimultaneousOfType') || '',
|
||||
buildCost: getBuildCost(selectedGameObject.value) || '',
|
||||
maxHealth: getMaxHealth(selectedGameObject.value) || ''
|
||||
}
|
||||
}
|
||||
|
||||
function updateGameObject() {
|
||||
if (!selectedGameObject.value) return
|
||||
|
||||
// 更新基本属性
|
||||
selectedGameObject.value.setAttribute('id', editableFields.value.id)
|
||||
selectedGameObject.value.setAttribute('TypeDescription', editableFields.value.typeDescription)
|
||||
selectedGameObject.value.setAttribute('Description', editableFields.value.description)
|
||||
selectedGameObject.value.setAttribute('BuildTime', editableFields.value.buildTime)
|
||||
selectedGameObject.value.setAttribute('MaxSimultaneousOfType', editableFields.value.maxSimultaneous)
|
||||
}
|
||||
|
||||
function updateDisplayName() {
|
||||
if (!selectedGameObject.value) return
|
||||
|
||||
let displayNameNode = selectedGameObject.value.querySelector('DisplayName')
|
||||
if (!displayNameNode) {
|
||||
displayNameNode = unitDoc.value.createElement('DisplayName')
|
||||
selectedGameObject.value.appendChild(displayNameNode)
|
||||
}
|
||||
displayNameNode.textContent = editableFields.value.displayName
|
||||
}
|
||||
|
||||
function updateNeededUpgrade() {
|
||||
if (!selectedGameObject.value) return
|
||||
|
||||
let gameDependency = selectedGameObject.value.querySelector('GameDependency')
|
||||
if (!gameDependency) {
|
||||
gameDependency = unitDoc.value.createElement('GameDependency')
|
||||
selectedGameObject.value.appendChild(gameDependency)
|
||||
}
|
||||
gameDependency.setAttribute('NeededUpgrade', editableFields.value.neededUpgrade)
|
||||
}
|
||||
|
||||
function updateBuildCost() {
|
||||
if (!selectedGameObject.value) return
|
||||
|
||||
let resourceInfo = selectedGameObject.value.querySelector('ObjectResourceInfo')
|
||||
if (!resourceInfo) {
|
||||
resourceInfo = unitDoc.value.createElement('ObjectResourceInfo')
|
||||
selectedGameObject.value.appendChild(resourceInfo)
|
||||
}
|
||||
|
||||
let buildCost = resourceInfo.querySelector('BuildCost')
|
||||
if (!buildCost) {
|
||||
buildCost = unitDoc.value.createElement('BuildCost')
|
||||
resourceInfo.appendChild(buildCost)
|
||||
}
|
||||
buildCost.setAttribute('Amount', editableFields.value.buildCost)
|
||||
}
|
||||
|
||||
function updateMaxHealth() {
|
||||
if (!selectedGameObject.value) return
|
||||
|
||||
let body = selectedGameObject.value.querySelector('Body')
|
||||
if (!body) {
|
||||
body = unitDoc.value.createElement('Body')
|
||||
selectedGameObject.value.appendChild(body)
|
||||
}
|
||||
|
||||
let activeBody = body.querySelector('ActiveBody')
|
||||
if (!activeBody) {
|
||||
activeBody = unitDoc.value.createElement('ActiveBody')
|
||||
body.appendChild(activeBody)
|
||||
}
|
||||
activeBody.setAttribute('MaxHealth', editableFields.value.maxHealth)
|
||||
}
|
||||
//导出
|
||||
function exportUnitXml() {
|
||||
if (!unitDoc.value || !selectedGameObject.value) return;
|
||||
|
||||
const serializer = new XMLSerializer();
|
||||
const unitId = selectedGameObject.value.getAttribute('id') || 'unit';
|
||||
|
||||
// 1. 导出主单位文件
|
||||
const unitXmlString = serializer.serializeToString(unitDoc.value);
|
||||
downloadFile(`${unitId}.xml`, unitXmlString);
|
||||
|
||||
// 2. 创建符合规范的config.xml
|
||||
const configXml = document.implementation.createDocument(
|
||||
"uri:ea.com:eala:asset",
|
||||
"AssetDeclaration"
|
||||
);
|
||||
|
||||
// 添加XML声明
|
||||
const xmlDeclaration = '<?xml version="1.0" encoding="utf-8" ?>';
|
||||
|
||||
// 添加Tags和Includes
|
||||
const tags = configXml.createElement("Tags");
|
||||
configXml.documentElement.appendChild(tags);
|
||||
|
||||
const includes = configXml.createElement("Includes");
|
||||
const include = configXml.createElement("Include");
|
||||
include.setAttribute("type", "all");
|
||||
include.setAttribute("source", "DATA:GlobalData/GlobalDefines.xml");
|
||||
includes.appendChild(include);
|
||||
configXml.documentElement.appendChild(includes);
|
||||
|
||||
// 添加Defines
|
||||
const defines = configXml.createElement("Defines");
|
||||
const define1 = configXml.createElement("Define");
|
||||
define1.setAttribute("name", "FACTION_WEAPON_SECONDARY_DAMAGE_AMOUNT");
|
||||
define1.setAttribute("value", "-500.0");
|
||||
defines.appendChild(define1);
|
||||
|
||||
const define2 = configXml.createElement("Define");
|
||||
define2.setAttribute("name", "EMPERORS_RESOLVE_AFFECTS");
|
||||
define2.setAttribute("value", "ALLIES NEUTRALS ENEMIES");
|
||||
defines.appendChild(define2);
|
||||
configXml.documentElement.appendChild(defines);
|
||||
|
||||
// 添加匹配的WeaponTemplate
|
||||
if (weaponData.value.length > 0 && weaponXmlData.value) {
|
||||
weaponData.value.forEach(weapon => {
|
||||
const weaponTemplate = weaponXmlData.value.querySelector(`WeaponTemplate[id="${weapon.id}"]`);
|
||||
if (weaponTemplate) {
|
||||
// 创建新的WeaponTemplate元素
|
||||
const newWeaponTemplate = configXml.createElement("WeaponTemplate");
|
||||
|
||||
// 复制所有属性
|
||||
Array.from(weaponTemplate.attributes).forEach(attr => {
|
||||
newWeaponTemplate.setAttribute(attr.name, attr.value);
|
||||
});
|
||||
|
||||
// 复制所有子节点
|
||||
Array.from(weaponTemplate.children).forEach(child => {
|
||||
const newChild = configXml.createElement(child.tagName);
|
||||
|
||||
// 复制子节点属性
|
||||
Array.from(child.attributes).forEach(attr => {
|
||||
newChild.setAttribute(attr.name, attr.value);
|
||||
});
|
||||
|
||||
// 如果有文本内容也复制
|
||||
if (child.textContent.trim()) {
|
||||
newChild.textContent = child.textContent;
|
||||
}
|
||||
|
||||
// 处理Nuggets的子节点
|
||||
if (child.tagName === 'Nuggets') {
|
||||
Array.from(child.children).forEach(nugget => {
|
||||
const newNugget = configXml.createElement(nugget.tagName);
|
||||
|
||||
Array.from(nugget.attributes).forEach(attr => {
|
||||
newNugget.setAttribute(attr.name, attr.value);
|
||||
});
|
||||
|
||||
newChild.appendChild(newNugget);
|
||||
});
|
||||
}
|
||||
|
||||
newWeaponTemplate.appendChild(newChild);
|
||||
});
|
||||
|
||||
configXml.documentElement.appendChild(newWeaponTemplate);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 序列化config.xml
|
||||
let configXmlString = serializer.serializeToString(configXml);
|
||||
// 添加XML声明和格式化
|
||||
configXmlString = xmlDeclaration + '\n' + formatXml(configXmlString);
|
||||
|
||||
// 下载config.xml
|
||||
setTimeout(() => {
|
||||
downloadFile('config.xml', configXmlString);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// XML格式化函数
|
||||
function formatXml(xml) {
|
||||
const PADDING = ' ';
|
||||
const reg = /(>)(<)(\/*)/g;
|
||||
let formatted = '';
|
||||
let pad = 0;
|
||||
|
||||
xml = xml.replace(reg, '$1\r\n$2$3');
|
||||
|
||||
xml.split('\r\n').forEach(node => {
|
||||
let indent = 0;
|
||||
if (node.match(/.+<\/\w[^>]*>$/)) {
|
||||
indent = 0;
|
||||
} else if (node.match(/^<\/\w/)) {
|
||||
if (pad !== 0) pad -= 1;
|
||||
} else if (node.match(/^<\w[^>]*[^\/]>.*$/)) {
|
||||
indent = 1;
|
||||
} else {
|
||||
indent = 0;
|
||||
}
|
||||
|
||||
formatted += PADDING.repeat(pad) + node + '\r\n';
|
||||
pad += indent;
|
||||
});
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
// 文件下载函数
|
||||
function downloadFile(filename, content) {
|
||||
const blob = new Blob([content], { type: 'text/xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
//导出结束
|
||||
|
||||
function toggleGroup(groupName) {
|
||||
if (openGroups.value.includes(groupName)) {
|
||||
openGroups.value = openGroups.value.filter(g => g !== groupName)
|
||||
} else {
|
||||
openGroups.value.push(groupName)
|
||||
}
|
||||
}
|
||||
|
||||
function isGroupOpen(groupName) {
|
||||
return openGroups.value.includes(groupName)
|
||||
}
|
||||
|
||||
function extractDisplayName(gameObject) {
|
||||
const displayNameNode = gameObject.querySelector('DisplayName');
|
||||
return displayNameNode ? displayNameNode.textContent.trim() : null;
|
||||
}
|
||||
|
||||
function getNeededUpgrade(gameObject) {
|
||||
const gameDependency = gameObject.querySelector('GameDependency')
|
||||
return gameDependency ? gameDependency.getAttribute('NeededUpgrade') : null
|
||||
}
|
||||
|
||||
function getBuildCost(gameObject) {
|
||||
const resourceInfo = gameObject.querySelector('ObjectResourceInfo');
|
||||
if (!resourceInfo) return null;
|
||||
|
||||
const buildCost = resourceInfo.querySelector('BuildCost');
|
||||
if (!buildCost) return null;
|
||||
|
||||
return buildCost.getAttribute('Amount');
|
||||
}
|
||||
|
||||
function getMaxHealth(gameObject) {
|
||||
const body = gameObject.querySelector('Body');
|
||||
if (!body) return null;
|
||||
|
||||
const activeBody = body.querySelector('ActiveBody');
|
||||
if (!activeBody) return null;
|
||||
|
||||
return activeBody.getAttribute('MaxHealth');
|
||||
}
|
||||
|
||||
//武器比对
|
||||
// 数据状态
|
||||
const activeSection = ref('match')
|
||||
const weaponXml = ref('')
|
||||
@ -301,14 +720,68 @@
|
||||
const selectAll = ref(false)
|
||||
const filteredAttributes = ref([])
|
||||
|
||||
// 计算属性
|
||||
const allAttributes = computed(() => {
|
||||
const attrs = new Set()
|
||||
// 计算属性
|
||||
const allAttributes = computed(() => {
|
||||
const attrs = new Set();
|
||||
|
||||
// 属性名翻译映射表
|
||||
const attributeTranslations = {
|
||||
'id': '单位ID',
|
||||
'weapon': '武器',
|
||||
'name': '名称',
|
||||
'type': '类型',
|
||||
'damage': '伤害值',
|
||||
'range': '射程',
|
||||
'speed': '速度',
|
||||
'accuracy': '精准度',
|
||||
'ammo': '弹药量',
|
||||
'reload': '装弹时间',
|
||||
'level': '等级',
|
||||
'cost': '消耗',
|
||||
'cooldown': '冷却时间',
|
||||
'effect': '效果',
|
||||
'target': '目标类型',
|
||||
'radius': '作用半径',
|
||||
'duration': '持续时间'
|
||||
// 可以根据需要添加更多翻译
|
||||
};
|
||||
|
||||
// 递归提取节点及其子节点的所有属性
|
||||
const extractAttributes = (node, parentName = '') => {
|
||||
// 添加当前节点的属性
|
||||
Array.from(node.attributes || []).forEach(attr => {
|
||||
const translation = attributeTranslations[attr.name];
|
||||
// 格式化为"原属性名(翻译)"的形式,如果没有翻译则只显示原属性名
|
||||
const displayName = translation ? `${attr.name}(${translation})` : attr.name;
|
||||
|
||||
// 如果有父节点名称,则添加前缀
|
||||
const fullName = parentName ? `${parentName}下的${displayName}` : displayName;
|
||||
|
||||
if (!parentName) {
|
||||
// 顶层属性加粗
|
||||
attrs.add(`**${fullName}**`);
|
||||
} else {
|
||||
attrs.add(fullName);
|
||||
}
|
||||
});
|
||||
|
||||
// 递归处理子节点
|
||||
Array.from(node.children || []).forEach(child => {
|
||||
// 使用当前节点的名称作为子节点的父节点名称
|
||||
const nodeName = node.attributes?.name?.value || node.nodeName;
|
||||
extractAttributes(child, nodeName);
|
||||
});
|
||||
};
|
||||
|
||||
// 遍历所有武器,提取属性和子节点属性
|
||||
allWeapons.value.forEach(w => {
|
||||
Array.from(w.node.attributes).forEach(attr => attrs.add(attr.name))
|
||||
})
|
||||
return Array.from(attrs)
|
||||
})
|
||||
extractAttributes(w.node);
|
||||
});
|
||||
|
||||
return Array.from(attrs);
|
||||
});
|
||||
|
||||
|
||||
|
||||
const pagedWeapons = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
@ -342,12 +815,47 @@
|
||||
if (isTransposed.value) return 'transposed'
|
||||
return 'normal'
|
||||
})
|
||||
// 方法
|
||||
function getWeaponAttribute(weapon, attr, searchChildren = true) {
|
||||
// 处理所有可能的属性显示格式:
|
||||
// 1. 加粗格式:**WeaponSpeed**
|
||||
// 2. 带父节点前缀:Weapon下的Speed
|
||||
// 3. 带翻译格式:Speed(速度)
|
||||
// 4. 组合格式:Weapon下的Speed(速度)
|
||||
|
||||
// 方法
|
||||
function getWeaponAttribute(weapon, attr) {
|
||||
return weapon.node.getAttribute(attr)
|
||||
let rawAttr = attr;
|
||||
|
||||
// 1. 去除Markdown加粗标记
|
||||
rawAttr = rawAttr.replace(/\*\*/g, '');
|
||||
|
||||
// 2. 去除"下的"前缀部分
|
||||
if (rawAttr.includes('下的')) {
|
||||
rawAttr = rawAttr.split('下的')[1];
|
||||
}
|
||||
|
||||
// 3. 去除括号翻译部分
|
||||
if (rawAttr.includes('(')) {
|
||||
rawAttr = rawAttr.split('(')[0];
|
||||
}
|
||||
|
||||
// 4. 去除可能存在的首尾空格
|
||||
rawAttr = rawAttr.trim();
|
||||
|
||||
// 5. 优先检查当前节点
|
||||
const value = weapon.node.getAttribute(rawAttr);
|
||||
if (value !== null) return value;
|
||||
|
||||
// 6. 若未找到且允许查找子节点,则递归搜索
|
||||
if (searchChildren && weapon.node.children) {
|
||||
for (const child of weapon.node.children) {
|
||||
const childValue = getWeaponAttribute({ node: child }, rawAttr, true);
|
||||
if (childValue !== null) return childValue;
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 最终未找到返回 null
|
||||
return null;
|
||||
}
|
||||
function filterAttributes() {
|
||||
filteredAttributes.value = allAttributes.value.filter(attr =>
|
||||
attr.toLowerCase().includes(attributeSearchText.value.toLowerCase())
|
||||
@ -413,14 +921,59 @@
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const parser = new DOMParser()
|
||||
const newContent = reader.result
|
||||
weaponXmlData.value = parser.parseFromString(reader.result, 'text/xml')
|
||||
weaponXml.value = newContent
|
||||
localStorage.setItem('weaponXml', newContent)
|
||||
parseWeaponFile(newContent)
|
||||
updateWeaponData()
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
///武器数据更新xml
|
||||
function updateWeaponData() {
|
||||
if (!selectedGameObject.value || !weaponXmlData.value) {
|
||||
weaponData.value = []
|
||||
return
|
||||
}
|
||||
const weapons = []
|
||||
// 查找单位的所有武器模板
|
||||
for (let slotName of ['WeaponSlotHardpoint', 'WeaponSlotTurret', 'WeaponSlotHierarchicalTurret', 'WeaponSlotContained']) {
|
||||
const weaponNodes = selectedGameObject.value.querySelectorAll(`Behaviors > WeaponSetUpdate > ${slotName} > Weapon`)
|
||||
weaponNodes.forEach(weaponNode => {
|
||||
const templateId = weaponNode.getAttribute('Template')
|
||||
if (templateId) {
|
||||
const weaponTemplate = weaponXmlData.value.querySelector(`WeaponTemplate[id="${templateId}"]`)
|
||||
if (weaponTemplate) {
|
||||
const attackRange = weaponTemplate.getAttribute('AttackRange')
|
||||
const minAttackRange = weaponTemplate.getAttribute('MinimumAttackRange')
|
||||
|
||||
// 查找DamageNugget
|
||||
let Radius = 0
|
||||
let Damage = 0
|
||||
const damageNugget = weaponTemplate.querySelector('Nuggets > DamageNugget')
|
||||
if (damageNugget) {
|
||||
Radius = damageNugget.getAttribute('Radius') || 0
|
||||
Damage = damageNugget.getAttribute('Damage') || 0
|
||||
}
|
||||
|
||||
weapons.push({
|
||||
id: templateId,
|
||||
attackRange,
|
||||
minAttackRange,
|
||||
Damage,
|
||||
Radius
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
weaponData.value = weapons
|
||||
}
|
||||
|
||||
////////
|
||||
function handleCompareWeaponFile(e) {
|
||||
handleWeaponFile(e)
|
||||
}
|
||||
@ -1167,4 +1720,118 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
.GOBeditor-section {
|
||||
padding: 20px;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.file-input-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.unit-editor {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.unit-list {
|
||||
width: 250px;
|
||||
border-right: 1px solid #ddd;
|
||||
padding-right: 10px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.unit-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 5px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.unit-item:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.unit-details {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: bold;
|
||||
min-width: 150px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.detail-group {
|
||||
margin-bottom: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
padding: 8px 12px;
|
||||
background-color: #e9e9e9;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.group-header:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.group-content {
|
||||
padding: 10px 15px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
font-weight: bold;
|
||||
}
|
||||
/* 新增样式 */
|
||||
.weapon-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
margin-left: 10px;
|
||||
padding: 8px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.weapon-stats div {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 调整文件输入容器样式 */
|
||||
.file-input-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
}
|
||||
</style>
|
Loading…
x
Reference in New Issue
Block a user