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

286
src/api/tournament.js Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

View File

@ -1,204 +1,436 @@
<template>
<div class="tournament-container">
<bracket :rounds="rounds">
<template #player="{ player }">
<div class="custom-node" :class="{ 'winner': player.winner }">
<div class="node-content">
<span class="team-name">{{ player.name }}</span>
<span class="team-score">{{ player.score || 0 }}</span>
</div>
</div>
</template>
</bracket>
<template v-else>
<div class="bracket-wrapper" ref="bracketWrapper" @mousedown="startDrag" @mousemove="onDrag" @mouseup="stopDrag"
@mouseleave="stopDrag" :style="{ transform: `scale(${scale}) translate(${position.x}px, ${position.y}px)` }">
<div class="round-titles">
<div v-for="(round, index) in rounds" :key="index" class="round-info">
<div class="round-name">{{ getRoundName(index, rounds.length) }}</div>
<div class="round-bo">{{ getRoundBo(index, rounds.length) }}</div>
</div>
</div>
<Bracket :rounds="formattedRounds" :options="bracketOptions" @onMatchClick="handleMatchClick" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import Bracket from 'vue-tournament-bracket'
import { ref, onMounted, computed, watch } from 'vue'
import { Bracket } from 'vue-tournament-bracket'
//
const rounds = ref([
//
{
games: [
{
player1: { id: "1", name: "战队 A", score: 2, winner: true },
player2: { id: "2", name: "战队 B", score: 1, winner: false }
},
{
player1: { id: "3", name: "战队 C", score: 0, winner: false },
player2: { id: "4", name: "战队 D", score: 2, winner: true }
}
]
const props = defineProps({
isEditMode: {
type: Boolean,
default: false
},
//
{
games: [
{
player1: { id: "1", name: "战队 A", score: 3, winner: true },
player2: { id: "4", name: "战队 D", score: 1, winner: false }
}
]
tournamentId: {
type: Number,
required: true
},
tournamentName: {
type: String,
required: true
},
players: {
type: Array,
default: () => []
}
])
})
const emit = defineEmits(['update:rounds'])
const bracketContainer = ref(null)
const bracketWrapper = ref(null)
const scale = ref(1)
const position = ref({ x: 0, y: 0 })
const isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 })
const startDrag = (e) => {
isDragging.value = true
dragStart.value = {
x: e.clientX - position.value.x,
y: position.value.y
}
}
const onDrag = (e) => {
if (isDragging.value) {
position.value = {
x: e.clientX - dragStart.value.x,
y: position.value.y
}
}
}
const stopDrag = () => {
isDragging.value = false
}
onMounted(() => {
if (bracketContainer.value && bracketWrapper.value) {
const containerRect = bracketContainer.value.getBoundingClientRect()
const wrapperRect = bracketWrapper.value.getBoundingClientRect()
position.value = {
x: (containerRect.width - wrapperRect.width) / 2,
y: 0
}
}
})
// rounds
const rounds = ref([])
//
const generateRounds = (players) => {
const totalPlayers = players.length
if (totalPlayers < 2) return []
//
const roundsCount = Math.ceil(Math.log2(totalPlayers))
//
const rounds = []
//
//
for (let i = 0; i < shuffledPlayers.length; i += 2) {
const game = {
player1: shuffledPlayers[i],
player2: shuffledPlayers[i + 1] || null,
winnerId: null
}
firstRoundGames.push(game)
}
rounds.push({ games: firstRoundGames })
//
for (let round = 1; round < roundsCount; round++) {
const prevRound = rounds[round - 1]
const currentRoundGames = []
//
for (let i = 0; i < prevRound.games.length; i += 2) {
const game = {
player1: null,
player2: null,
winnerId: null
}
currentRoundGames.push(game)
}
rounds.push({ games: currentRoundGames })
}
return rounds
}
//
const initializeBracket = () => {
if (!props.players || props.players.length === 0) return
//
rounds.value = generateRounds(props.players.map((p, idx) => ({
...p,
number: idx + 1,
win: p.win || '0',
lose: p.lose || '0',
status: p.status || 'tie',
team_name: p.team_name || '个人',
name: p.sign_name || p.name || '未知选手',
})))
}
watch(() => props.players, () => {
initializeBracket()
}, { immediate: true })
//
const getRoundName = (index, totalRounds) => {
if (totalRounds === 1) {
return '决赛'
} else if (totalRounds === 2) {
return index === 0 ? '半决赛' : '决赛'
} else if (totalRounds === 3) {
return index === 0 ? '四分之一决赛' : index === 1 ? '半决赛' : '决赛'
} else {
return `${index + 1}`
}
}
const getRoundBo = (index, totalRounds) => {
if (index === totalRounds - 1) {
return 'BO5'
} else {
return 'BO3'
}
}
// vue-tournament-bracket
const formattedRounds = computed(() => {
return rounds.value.map((round, roundIndex) => ({
title: getRoundName(roundIndex, rounds.value.length),
matches: round.games.map((game, gameIndex) => {
const nextMatchId = roundIndex < rounds.value.length - 1
? Math.floor(gameIndex / 2)
: null
return {
id: `${roundIndex}-${gameIndex}`,
name: `比赛 ${gameIndex + 1}`,
nextMatchId: nextMatchId !== null ? `${roundIndex + 1}-${nextMatchId}` : null,
tournamentRoundText: getRoundBo(roundIndex, rounds.value.length),
startTime: '',
state: 'SCHEDULED',
participants: [
{
id: game.player1?.id,
name: game.player1?.name || '待定',
status: game.winnerId === game.player1?.id ? 'WIN' : 'LOSE',
resultText: `${game.player1?.win || 0}-${game.player1?.lose || 0}`
},
{
id: game.player2?.id,
name: game.player2?.name || '待定',
status: game.winnerId === game.player2?.id ? 'WIN' : 'LOSE',
resultText: `${game.player2?.win || 0}-${game.player2?.lose || 0}`
}
]
}
})
}))
})
//
const bracketOptions = {
style: {
roundHeader: {
backgroundColor: '#1976d2',
color: '#fff'
},
connectorColor: '#1976d2',
connectorColorHighlight: '#4caf50'
},
matchHeight: 100,
roundHeaderHeight: 50,
roundHeaderMargin: 20,
roundHeaderFontSize: 16,
matchWidth: 300,
matchMargin: 20,
participantHeight: 40,
participantMargin: 5,
participantFontSize: 14,
participantPadding: 10
}
//
const handleMatchClick = (match) => {
if (!props.isEditMode) return
const roundIndex = formattedRounds.value.findIndex(round =>
round.matches.some(m => m.id === match.id)
)
const gameIndex = formattedRounds.value[roundIndex].matches.findIndex(m => m.id === match.id)
const game = rounds.value[roundIndex].games[gameIndex]
openWinnerDialog(roundIndex, gameIndex, game)
}
//
const showWinnerDialog = ref(false)
const editingGame = ref(null)
const openWinnerDialog = (roundIndex, gameIndex, game) => {
editingGame.value = {
roundIndex,
gameIndex,
game
}
showWinnerDialog.value = true
}
const setWinner = (playerId) => {
if (editingGame.value) {
const roundIndex = editingGame.value.roundIndex
const gameIndex = editingGame.value.gameIndex
const game = editingGame.value.game
game.winnerId = playerId
showWinnerDialog.value = false
}
}
const closeWinnerDialog = () => {
showWinnerDialog.value = false
}
</script>
<style scoped>
.tournament-container {
min-height: 400px;
.tournament-bracket {
position: relative;
overflow-x: auto;
background: transparent;
overflow-y: auto;
background: #141414;
color: #fff;
height: auto;
min-height: 600px;
padding: 20px;
}
:deep(.vtb-wrapper),
:deep(.vtb-item),
:deep(.vtb-item-parent),
:deep(.vtb-item-players),
:deep(.vtb-item-children),
:deep(.vtb-item-child),
:deep(.custom-node),
:deep(.custom-node.winner) {
background: transparent !important;
}
:deep(.vtb-wrapper) {
display: flex;
padding: 20px 40px;
}
:deep(.vtb-item) {
display: flex;
flex-direction: row-reverse;
}
:deep(.vtb-item-parent) {
.bracket-wrapper {
transform-origin: center left;
cursor: ew-resize;
user-select: none;
padding: 20px;
min-height: calc(100% - 40px);
position: relative;
margin-left: 50px;
padding-top: 0;
width: max-content;
min-width: 100%;
}
.bracket-rounds {
display: flex;
align-items: center;
gap: 40px;
}
:deep(.vtb-item-players) {
flex-direction: column;
margin: 0;
}
:deep(.vtb-player) {
padding: 10px 16px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(143, 143, 143, 0.3);
color: #333;
border-radius: 24px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(8px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
}
:deep(.vtb-player:hover) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-color: rgba(143, 143, 143, 0.5);
}
:deep(.vtb-player.winner) {
border: 1px solid rgba(129, 199, 132, 0.5);
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 2px 8px rgba(129, 199, 132, 0.15);
}
:deep(.vtb-player.winner:hover) {
border-color: rgba(129, 199, 132, 0.8);
box-shadow: 0 4px 12px rgba(129, 199, 132, 0.2);
}
:deep(.vtb-item-children) {
.bracket-round {
display: flex;
flex-direction: column;
justify-content: center;
gap: 24px;
}
:deep(.vtb-item-child) {
.bracket-game {
display: flex;
align-items: flex-start;
justify-content: flex-end;
margin: 12px 0;
position: relative;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.node-content {
.player-info {
background: #1976d2;
color: #fff;
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(143, 143, 143, 0.3);
color: #333;
padding: 10px 16px;
border-radius: 24px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(8px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
gap: 8px;
width: 300px;
padding: 8px 12px;
border-radius: 4px;
margin: 4px 0;
transition: all 0.3s ease;
}
.node-content:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-color: rgba(143, 143, 143, 0.5);
.player-info.winner {
background: #4caf50;
}
.custom-node.winner .node-content {
border: 1px solid rgba(129, 199, 132, 0.5);
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 2px 8px rgba(129, 199, 132, 0.15);
.player-info.defeated {
background: #f44336;
}
.custom-node.winner .node-content:hover {
border-color: rgba(129, 199, 132, 0.8);
box-shadow: 0 4px 12px rgba(129, 199, 132, 0.2);
.player-number {
color: #bbdefb;
font-size: 12px;
min-width: 16px;
}
.team-name {
.player-name {
color: #fff;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.player-score {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #fff;
background: rgba(255, 255, 255, 0.2);
padding: 2px 6px;
border-radius: 2px;
}
.win-score {
color: #4caf50;
font-weight: 500;
color: #333;
letter-spacing: 0.3px;
}
.team-score {
font-weight: 600;
min-width: 24px;
text-align: center;
margin-left: 16px;
color: #333;
.score-separator {
color: rgba(255, 255, 255, 0.5);
}
.lose-score {
color: #f44336;
font-weight: 500;
}
.set-winner-btn {
margin-left: 12px;
padding: 2px 8px;
background: rgba(0, 0, 0, 0.04);
border-radius: 12px;
background: #ff9800;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
:deep(.vtb-player.winner) .team-score,
.custom-node.winner .team-score {
background: rgba(129, 199, 132, 0.1);
color: #2e7d32;
.set-winner-btn:hover {
background: #e65100;
}
@media (max-width: 768px) {
:deep(.vtb-wrapper) {
padding: 10px;
}
:deep(.vtb-player),
.node-content {
padding: 8px 12px;
}
.team-name {
font-size: 13px;
}
.team-score {
min-width: 20px;
margin-left: 12px;
padding: 1px 6px;
}
.edit-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
</style>
.edit-dialog {
background: #1f1f1f;
border-radius: 8px;
padding: 24px;
width: 400px;
max-width: 90%;
color: #fff;
}
.dialog-buttons {
display: flex;
gap: 12px;
margin-top: 24px;
}
.confirm-btn {
background: #409EFF;
color: #fff;
border: none;
border-radius: 4px;
padding: 8px 16px;
cursor: pointer;
font-weight: 500;
}
.confirm-btn:hover {
background: #66b1ff;
}
.cancel-btn {
background: #f5f5f5;
color: #666;
border: none;
border-radius: 4px;
padding: 8px 16px;
cursor: pointer;
font-weight: 500;
}
.cancel-btn:hover {
background: #e8e8e8;
}
</style>

View File

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

View File

@ -4,38 +4,20 @@
<form class="login-form-container" @submit.prevent="handleRegister">
<div class="input-container">
<label for="username">QQ号</label>
<input
type="text"
id="username"
v-model="username"
placeholder="请输入QQ号"
@blur="validateUsername"
:class="{ 'error': usernameError }"
/>
<input type="text" id="username" v-model="username" placeholder="请输入QQ号" @blur="validateUsername"
:class="{ 'error': usernameError }" />
<span class="error-message" v-if="usernameError">{{ usernameError }}</span>
</div>
<div class="input-container">
<label for="password">密码</label>
<input
type="password"
id="password"
v-model="password"
placeholder="请输入密码"
@blur="validatePassword"
:class="{ 'error': passwordError }"
/>
<input type="password" id="password" v-model="password" placeholder="请输入密码" @blur="validatePassword"
:class="{ 'error': passwordError }" />
<span class="error-message" v-if="passwordError">{{ passwordError }}</span>
</div>
<div class="input-container">
<label for="confirmPassword">再次输入密码</label>
<input
type="password"
id="confirmPassword"
v-model="confirmPassword"
placeholder="请输入密码"
@blur="validateConfirmPassword"
:class="{ 'error': confirmPasswordError }"
/>
<input type="password" id="confirmPassword" v-model="confirmPassword" placeholder="请输入密码"
@blur="validateConfirmPassword" :class="{ 'error': confirmPasswordError }" />
<span class="error-message" v-if="confirmPasswordError">{{ confirmPasswordError }}</span>
</div>
<div class="input-container">
@ -94,6 +76,9 @@ const username = ref('')
const password = ref('')
const confirmPassword = ref('')
// emit
const emit = defineEmits(['login'])
//
const usernameError = ref('')
const passwordError = ref('')
@ -144,14 +129,14 @@ const validateConfirmPassword = () => {
const isVaptchaVerified = ref(false)
//
const isFormValid = computed(() => {
return !usernameError.value &&
!passwordError.value &&
!confirmPasswordError.value &&
username.value &&
password.value &&
confirmPassword.value &&
VAPTCHAObj.value && //
isVaptchaVerified.value //
return !usernameError.value &&
!passwordError.value &&
!confirmPasswordError.value &&
username.value &&
password.value &&
confirmPassword.value &&
VAPTCHAObj.value && //
isVaptchaVerified.value //
})
@ -189,54 +174,44 @@ onMounted(() => {
})
const handleRegister = async () => {
try {
//
if (!validateUsername() || !validatePassword() || !validateConfirmPassword()) {
return
}
//
if (!isVaptchaVerified.value || !VAPTCHAObj.value) {
alert('请完成人机验证')
return
}
// token
try {
const serverToken = await VAPTCHAObj.value.getServerToken()
console.log('获取到的 serverToken:', serverToken)
if (!serverToken || !serverToken.token || !serverToken.server) {
alert('获取验证token失败请重新验证')
return
}
//
const registerData = {
qq_code: username.value,
password: password.value,
server: serverToken.server,
token: serverToken.token
}
console.log('注册请求参数:', registerData)
//
await userRegister(
registerData.qq_code,
registerData.password,
registerData.server,
registerData.token
)
alert('注册成功')
//
emit('login')
} catch (tokenError) {
console.error('获取验证token失败:', tokenError)
alert('获取验证token失败请重新验证')
}
} catch (error) {
console.error('注册失败:', error)
alert(error.message || '注册失败')
//
if (!validateUsername() || !validatePassword() || !validateConfirmPassword()) {
return
}
//
if (!isVaptchaVerified.value || !VAPTCHAObj.value) {
alert('请完成人机验证')
return
}
// token
const serverToken = await VAPTCHAObj.value.getServerToken()
console.log('获取到的 serverToken:', serverToken)
if (!serverToken || !serverToken.token || !serverToken.server) {
alert('获取验证token失败请重新验证')
return
}
//
const registerData = {
qq_code: username.value,
password: password.value,
server: serverToken.server,
token: serverToken.token
}
console.log('注册请求参数:', registerData)
//
await userRegister(
registerData.qq_code,
registerData.password,
registerData.server,
registerData.token
)
alert('注册成功')
//
emit('login')
}
</script>

View File

@ -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 }
}
]
},

View File

View File

@ -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>

View File

@ -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>

View 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>

View File

@ -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="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 class="table-container">
<table class="maps-table">
<!-- 错误提示 -->
<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

View 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>

View File

@ -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><!--GOBeditgameobject--->
</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" ?>';
// TagsIncludes
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,15 +720,69 @@
const selectAll = ref(false)
const filteredAttributes = ref([])
//
const allAttributes = computed(() => {
const attrs = new Set()
allWeapons.value.forEach(w => {
Array.from(w.node.attributes).forEach(attr => attrs.add(attr.name))
})
return Array.from(attrs)
})
//
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 => {
extractAttributes(w.node);
});
return Array.from(attrs);
});
const pagedWeapons = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return allWeapons.value.slice(start, start + pageSize.value)
@ -342,12 +815,47 @@
if (isTransposed.value) return 'transposed'
return 'normal'
})
//
function getWeaponAttribute(weapon, attr, searchChildren = true) {
//
// 1. **WeaponSpeed**
// 2. WeaponSpeed
// 3. Speed()
// 4. WeaponSpeed()
//
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>