Files
martial-web/src/views/martial/schedule/index.vue
T

1626 lines
51 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="martial-schedule-container">
<div class="page-header">
<div class="header-left">
<el-button icon="el-icon-back" size="small" @click="goBack">返回</el-button>
<h2 class="page-title">编排</h2>
</div>
<div class="header-right">
<el-button
size="small"
type="primary"
:loading="autoArrangeLoading"
@click="handleAutoArrange"
>
{{ isScheduleCompleted ? '重新编排' : '自动编排' }}
</el-button>
<el-button size="small" type="danger" @click="showExceptionDialog">
异常组 <el-badge :value="exceptionList.length" :hidden="exceptionList.length === 0" />
</el-button>
</div>
</div>
<div class="tabs-section">
<div class="tab-buttons">
<el-button
size="small"
:type="activeTab === 'competition' ? 'primary' : ''"
@click="activeTab = 'competition'"
>
竞赛分组
</el-button>
<el-button
size="small"
:type="activeTab === 'venue' ? 'primary' : ''"
@click="activeTab = 'venue'"
>
场地
</el-button>
</div>
<!-- 竞赛分组 Tab -->
<div v-show="activeTab === 'competition'" class="tab-content">
<!-- 场地列表 -->
<div class="venue-list">
<div class="venue-buttons">
<el-button
v-for="venue in venues"
:key="venue.id"
size="small"
:type="selectedVenueId === venue.id ? 'primary' : ''"
@click="selectedVenueId = venue.id"
>
{{ venue.venueName }}
</el-button>
<div v-if="venues.length === 0" class="no-venue-hint">
暂无场地信息请先在赛事管理中配置场地
</div>
</div>
</div>
<div class="time-selector">
<el-button
v-for="(time, index) in timeSlots"
:key="index"
size="small"
:type="selectedTime === index ? 'primary' : ''"
@click="selectedTime = index"
>
{{ time }}
</el-button>
</div>
<!-- 项目卡片列表 -->
<div v-for="(group, groupIndex) in filteredCompetitionGroups" :key="group.id" class="project-card">
<!-- 项目头部 - 可点击展开 -->
<div class="project-header" :class="{ 'project-header-collapsed': !isProjectExpanded(group.id) }" @click="toggleProjectExpand(group.id)">
<div class="project-info">
<!-- 展开图标 -->
<span class="project-expand-icon">
<el-icon v-if="isProjectExpanded(group.id)"><ArrowDown /></el-icon>
<el-icon v-else><ArrowRight /></el-icon>
</span>
<span class="project-index">{{ groupIndex + 1 }}</span>
<span class="project-title">{{ group.title }}</span>
<span class="project-meta">{{ group.type }}</span>
<span class="project-meta">{{ getTeamCount(group) }}</span>
<span class="project-meta">{{ group.items?.length || 0 }}</span>
<span class="project-meta">{{ group.code }}</span>
</div>
<div class="project-actions" @click.stop>
<el-popover
placement="bottom"
:width="200"
trigger="click"
:disabled="isScheduleCompleted"
>
<template #reference>
<el-button size="small" type="warning" :disabled="isScheduleCompleted">
移动
</el-button>
</template>
<div class="move-popover">
<div class="move-label">移动到</div>
<el-select v-model="moveTargetVenueId" placeholder="选择场地" size="small" style="width: 100%; margin-bottom: 10px;">
<el-option
v-for="venue in venues"
:key="venue.id"
:label="venue.venueName"
:value="venue.id"
></el-option>
</el-select>
<el-button size="small" type="primary" @click="quickMoveGroup(group)">移动</el-button>
</div>
</el-popover>
</div>
</div>
<!-- 队伍列表 - 可折叠 -->
<div v-if="isProjectExpanded(group.id)" class="team-list">
<div
v-for="(team, teamIndex) in groupItemsByTeam(group.items)"
:key="team.id"
class="team-row"
:class="{ 'team-row-expanded': isTeamExpanded(group.id, team.id) }"
>
<!-- 队伍主行 - 可点击展开 -->
<div class="team-main" @click="toggleTeamExpand(group.id, team.id)">
<!-- 展开图标 -->
<span class="expand-icon">
<el-icon v-if="isTeamExpanded(group.id, team.id)"><ArrowDown /></el-icon>
<el-icon v-else><ArrowRight /></el-icon>
</span>
<!-- 队伍信息 -->
<div class="team-info">
<span class="team-index">{{ teamIndex + 1 }}</span>
<span class="team-name">{{ team.schoolUnit }}</span>
</div>
<!-- 选手列表 -->
<div class="player-list">
<span
v-for="(player, playerIndex) in team.players"
:key="player.id"
class="player-tag"
:class="{ 'player-exception': player.status === '异常' }"
>
{{ player.playerName }}
</span>
</div>
<!-- 操作按钮 -->
<div class="team-actions" @click.stop>
<el-button
link
size="small"
@click="handleTeamMoveUp(group, teamIndex)"
:disabled="teamIndex === 0 || isScheduleCompleted"
title="上移"
class="move-btn"
>
<img src="/img/图标 3@3x.png" class="move-icon" alt="上移" />
</el-button>
<el-button
link
size="small"
@click="handleTeamMoveDown(group, teamIndex)"
:disabled="teamIndex === groupItemsByTeam(group.items).length - 1 || isScheduleCompleted"
title="下移"
class="move-btn"
>
<img src="/img/图标 4@3x.png" class="move-icon" alt="下移" />
</el-button>
</div>
</div>
<!-- 展开内容 - 选手详情 -->
<div v-if="isTeamExpanded(group.id, team.id)" class="team-expand-content">
<div
v-for="(player, playerIndex) in team.players"
:key="player.id"
class="player-detail-row"
>
<span class="player-detail-index">{{ playerIndex + 1 }}</span>
<span class="player-detail-name">{{ player.playerName }}</span>
<span class="player-detail-status">
<el-tag
:type="player.status === '已签到' ? 'success' : player.status === '异常' ? 'danger' : 'info'"
size="small"
>
{{ player.status || '未签到' }}
</el-tag>
</span>
<span class="player-detail-actions">
<el-button
v-if="(player.status || '未签到') === '未签到'"
link
size="small"
@click="markPlayerAsException(group, team, playerIndex)"
:disabled="isScheduleCompleted"
style="color: #f56c6c;"
>
标记异常
</el-button>
<el-button
v-if="player.status === '异常'"
link
size="small"
@click="removePlayerException(group, team, playerIndex)"
:disabled="isScheduleCompleted"
style="color: #67c23a;"
>
取消异常
</el-button>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 场地 Tab -->
<div v-show="activeTab === 'venue'" class="tab-content">
<!-- 场地列表 -->
<div class="venue-list">
<div class="venue-buttons">
<el-button
v-for="venue in venues"
:key="'venue-tab-' + venue.id"
size="small"
:type="selectedVenueIdForVenueTab === venue.id ? 'primary' : ''"
@click="selectedVenueIdForVenueTab = venue.id"
>
{{ venue.venueName }}
</el-button>
<div v-if="venues.length === 0" class="no-venue-hint">
暂无场地信息请先在赛事管理中配置场地
</div>
</div>
</div>
<div class="time-selector">
<el-button
v-for="(time, index) in timeSlots"
:key="index"
size="small"
:type="selectedTime === index ? 'primary' : ''"
@click="selectedTime = index"
>
{{ time }}
</el-button>
</div>
<el-table :data="venueData" border stripe size="small">
<el-table-column label="序号" type="index" width="60" align="center"></el-table-column>
<el-table-column prop="project" label="项目" min-width="180">
<template #default="scope">
<span>{{ scope.row.project }}</span>
<div v-if="scope.row.hint" class="row-hint">{{ scope.row.hint }}</div>
</template>
</el-table-column>
<el-table-column prop="participant" label="参人/单位" width="120"></el-table-column>
<el-table-column prop="team" label="队伍" width="80" align="center"></el-table-column>
<el-table-column prop="number" label="编号" width="80" align="center"></el-table-column>
<el-table-column prop="duration" label="合计时间" width="100" align="center"></el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="scope">
<span v-if="scope.row.status" class="status-active">{{ scope.row.status }}</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
<div class="footer-actions">
<el-button size="small" @click="handleSaveDraft" v-if="!isScheduleCompleted">保存草稿</el-button>
<el-button size="small" @click="handleExport" v-if="isScheduleCompleted">导出</el-button>
<el-button size="small" type="primary" @click="handleConfirm" v-if="!isScheduleCompleted">完成编排</el-button>
</div>
<!-- 确认对话框 -->
<el-dialog
title="确定完成编排"
v-model="confirmDialogVisible"
width="450px"
center
>
<p style="text-align: center; padding: 20px; color: #606266;">
完成编排后不可再次调整确定完成编排吗
</p>
<template #footer>
<span class="dialog-footer">
<el-button @click="confirmDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmComplete">确定</el-button>
</span>
</template>
</el-dialog>
<!-- 移动分组对话框 -->
<el-dialog
title="移动竞赛分组"
v-model="moveDialogVisible"
width="500px"
center
>
<el-form label-width="100px">
<el-form-item label="目标场地">
<el-select v-model="moveTargetVenueId" placeholder="请选择场地" style="width: 100%;">
<el-option
v-for="venue in venues"
:key="venue.id"
:label="venue.venueName"
:value="venue.id"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="目标时间段">
<el-select v-model="moveTargetTimeSlot" placeholder="请选择时间段" style="width: 100%;">
<el-option
v-for="(time, index) in timeSlots"
:key="index"
:label="time"
:value="index"
></el-option>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="moveDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmMoveGroup">确定</el-button>
</span>
</template>
</el-dialog>
<!-- 异常组对话框 -->
<el-dialog
title="异常组参赛人员"
v-model="exceptionDialogVisible"
width="700px"
center
>
<el-table :data="exceptionList" border stripe size="small" max-height="400">
<el-table-column label="序号" type="index" width="60" align="center"></el-table-column>
<el-table-column prop="groupTitle" label="分组" min-width="180"></el-table-column>
<el-table-column prop="schoolUnit" label="学校/单位" min-width="150"></el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="scope">
<el-tag type="danger" size="small">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="scope">
<el-button
link
size="small"
@click="removeFromException(scope.$index)"
style="color: #409eff;"
>
移除
</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="exceptionList.length === 0" style="text-align: center; padding: 40px; color: #909399;">
暂无异常参赛人员
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="exceptionDialogVisible = false">关闭</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script>
import { ArrowDown, ArrowRight } from '@element-plus/icons-vue'
import { getVenuesByCompetition } from '@/api/martial/venue'
import { getCompetitionDetail } from '@/api/martial/competition'
import { getScheduleResult, saveAndLockSchedule, saveDraftSchedule, triggerAutoArrange, moveScheduleGroup, exportSchedule } from '@/api/martial/activitySchedule'
import { updateCheckInStatus } from '@/api/martial/schedulePlan'
export default {
name: 'MartialScheduleList',
components: {
ArrowDown,
ArrowRight
},
data() {
return {
competitionId: null,
orderId: null,
activeTab: 'competition',
selectedTime: 0,
selectedVenueId: null, // 选中的场地ID(竞赛分组Tab)
selectedVenueIdForVenueTab: null, // 选中的场地ID(场地Tab
confirmDialogVisible: false,
isScheduleCompleted: false, // 是否已完成编排
loading: false,
autoArrangeLoading: false, // 自动编排加载状态
venues: [], // 场地列表(从后端加载)
competitionInfo: {
competitionName: '',
competitionStartTime: '',
competitionEndTime: ''
},
timeSlots: [], // 时间段列表(根据比赛时间动态生成)
competitionGroups: [], // 竞赛分组(从后端加载)
// 移动分组相关
moveDialogVisible: false,
moveTargetVenueId: null,
moveTargetTimeSlot: null,
moveGroupIndex: null,
// 异常组相关
exceptionDialogVisible: false,
exceptionList: [], // 异常参赛人员列表
expandedTeams: {}, // 展开的队伍 { 'groupId-teamId': true }
expandedProjects: {} // 展开的项目 { 'groupId': true },默认收起
}
},
computed: {
// 根据选中的场地和时间段过滤竞赛分组
filteredCompetitionGroups() {
console.log('=== 过滤调试信息 ===')
console.log('selectedVenueId:', this.selectedVenueId, 'type:', typeof this.selectedVenueId)
console.log('selectedTime:', this.selectedTime, 'type:', typeof this.selectedTime)
console.log('competitionGroups总数:', this.competitionGroups.length)
if (!this.selectedVenueId || this.selectedTime === null) {
console.log('场地或时间未选中,返回空数组')
return []
}
const filtered = this.competitionGroups.filter(group => {
console.log(`分组"${group.title}": venueId=${group.venueId}(${typeof group.venueId}), timeSlotIndex=${group.timeSlotIndex}(${typeof group.timeSlotIndex})`)
const venueMatch = group.venueId === this.selectedVenueId || Number(group.venueId) === Number(this.selectedVenueId)
const timeMatch = group.timeSlotIndex === this.selectedTime
console.log(` 场地匹配:${venueMatch}, 时间匹配:${timeMatch}`)
return venueMatch && timeMatch
})
console.log('过滤后的分组数量:', filtered.length)
return filtered
},
// 场地标签页的数据 - 根据选中的场地和时间段动态生成
venueData() {
if (this.selectedTime === null) {
return []
}
// 获取选中时间段的所有分组
let groupsInTimeSlot = this.competitionGroups.filter(
group => group.timeSlotIndex === this.selectedTime
)
// 如果选中了场地,进一步过滤
if (this.selectedVenueIdForVenueTab) {
groupsInTimeSlot = groupsInTimeSlot.filter(group => {
return group.venueId === this.selectedVenueIdForVenueTab ||
Number(group.venueId) === Number(this.selectedVenueIdForVenueTab)
})
}
// 将分组转换为场地视图的数据格式
return groupsInTimeSlot.map(group => ({
project: group.title,
participant: group.type,
team: 1, // 队伍数量,可以从分组中计算
number: group.items?.length || 0, // 参赛人数
duration: 0, // 合计时间,如果后端有数据可以显示
status: group.venueName, // 显示场地名称
venueName: group.venueName,
hint: '' // 可以根据需要添加提示信息
}))
}
},
mounted() {
// 从路由获取赛事ID和订单ID
this.competitionId = this.$route.query.competitionId
this.orderId = this.$route.query.orderId
if (this.competitionId) {
this.loadCompetitionInfo()
this.loadVenues()
this.loadScheduleData()
} else {
this.$message.warning('未获取到赛事ID')
}
},
methods: {
// 检查项目是否展开
isProjectExpanded(groupId) {
return this.expandedProjects[groupId] === true
},
// 切换项目展开状态
toggleProjectExpand(groupId) {
if (this.expandedProjects[groupId]) {
delete this.expandedProjects[groupId]
} else {
this.expandedProjects[groupId] = true
}
// 触发响应式更新
this.expandedProjects = { ...this.expandedProjects }
},
// 检查队伍是否展开
isTeamExpanded(groupId, teamId) {
const key = groupId + '-' + teamId
return this.expandedTeams[key] === true
},
// 切换队伍展开状态
toggleTeamExpand(groupId, teamId) {
const key = groupId + '-' + teamId
if (this.expandedTeams[key]) {
delete this.expandedTeams[key]
} else {
this.expandedTeams[key] = true
}
// 触发响应式更新
this.expandedTeams = { ...this.expandedTeams }
},
// 标记选手为异常
markPlayerAsException(group, team, playerIndex) {
if (this.isScheduleCompleted) {
this.$message.warning('编排已完成,无法标记异常')
return
}
const player = team.players[playerIndex]
if (player) {
player.status = '异常'
// 添加到异常列表
this.exceptionList.push({
groupId: group.id,
groupTitle: group.title,
teamId: team.id,
schoolUnit: team.schoolUnit,
playerId: player.id,
playerName: player.playerName,
status: '异常'
})
// 调用后端API保存状态
updateCheckInStatus(player.id, '异常').then(() => {
this.$message.success(`已将 ${player.playerName} 标记为异常`)
}).catch(err => {
console.error('保存异常状态失败:', err)
})
}
},
// 取消选手异常状态
removePlayerException(group, team, playerIndex) {
if (this.isScheduleCompleted) {
this.$message.warning('编排已完成,无法取消异常')
return
}
const player = team.players[playerIndex]
if (player) {
player.status = '未签到'
// 从异常列表中移除
const idx = this.exceptionList.findIndex(
e => e.playerId === player.id && e.groupId === group.id
)
if (idx !== -1) {
this.exceptionList.splice(idx, 1)
}
// 调用后端API恢复状态
updateCheckInStatus(player.id, '未签到').then(() => {
this.$message.success(`已将 ${player.playerName} 取消异常标记`)
}).catch(err => {
console.error('恢复状态失败:', err)
})
}
},
// 将选手按学校/单位分组为队伍
groupItemsByTeam(items) {
if (!items || items.length === 0) return []
const teamMap = new Map()
items.forEach(item => {
const key = item.schoolUnit
if (!teamMap.has(key)) {
teamMap.set(key, {
id: `team_${item.id}`,
schoolUnit: key,
players: [],
playerIds: []
})
}
teamMap.get(key).players.push(item)
teamMap.get(key).playerIds.push(item.id)
})
return Array.from(teamMap.values())
},
// 获取队伍数量
getTeamCount(group) {
if (!group.items || group.items.length === 0) return 0
const teams = this.groupItemsByTeam(group.items)
return teams.length
},
// 快速移动分组(从popover中调用)
async quickMoveGroup(group) {
if (!this.moveTargetVenueId) {
this.$message.warning('请选择目标场地')
return
}
const targetVenue = this.venues.find(v => v.id === this.moveTargetVenueId)
try {
// 调用后端API移动分组
const res = await moveScheduleGroup({
groupId: group.id,
targetVenueId: this.moveTargetVenueId,
targetTimeSlotIndex: this.selectedTime // 保持当前时间段
})
if (res.data.success) {
// 更新前端数据
group.venueId = this.moveTargetVenueId
group.venueName = targetVenue ? targetVenue.venueName : ''
this.$message.success(`已移动到 ${group.venueName}`)
} else {
this.$message.error(res.data.msg || '移动分组失败')
}
} catch (error) {
console.error('移动分组失败:', error)
this.$message.error('移动分组失败,请稍后重试')
}
},
// 获取队伍状态
getTeamStatus(team) {
if (!team || !team.players) return '未签到'
const statuses = team.players.map(p => p.status || '未签到')
if (statuses.every(s => s === '已签到')) return '已签到'
if (statuses.every(s => s === '未签到')) return '未签到'
if (statuses.some(s => s === '异常')) return '部分异常'
return '部分签到'
},
// 获取队伍状态类型
getTeamStatusType(team) {
const status = this.getTeamStatus(team)
switch(status) {
case '已签到': return 'success'
case '部分异常': return 'danger'
case '部分签到': return 'warning'
default: return 'info'
}
},
// 队伍上移
handleTeamMoveUp(group, teamIndex) {
const teams = this.groupItemsByTeam(group.items)
if (teamIndex === 0 || this.isScheduleCompleted) return
const currentTeam = teams[teamIndex]
const prevTeam = teams[teamIndex - 1]
// 找到两个队伍在原数组中的位置范围
const currentPlayerIds = currentTeam.playerIds
const prevPlayerIds = prevTeam.playerIds
// 重新排序:将当前队伍的所有选手移到前一个队伍之前
const newItems = []
let addedCurrent = false
for (const item of group.items) {
if (prevPlayerIds.includes(item.id) && !addedCurrent) {
// 先添加当前队伍的所有选手
for (const cItem of group.items) {
if (currentPlayerIds.includes(cItem.id)) {
newItems.push(cItem)
}
}
addedCurrent = true
}
if (!currentPlayerIds.includes(item.id)) {
newItems.push(item)
}
}
group.items = newItems
this.$message.success('上移成功')
},
// 队伍下移
handleTeamMoveDown(group, teamIndex) {
const teams = this.groupItemsByTeam(group.items)
if (teamIndex === teams.length - 1 || this.isScheduleCompleted) return
const currentTeam = teams[teamIndex]
const nextTeam = teams[teamIndex + 1]
const currentPlayerIds = currentTeam.playerIds
const nextPlayerIds = nextTeam.playerIds
// 重新排序:将当前队伍的所有选手移到下一个队伍之后
const newItems = []
let addedCurrent = false
for (const item of group.items) {
if (!currentPlayerIds.includes(item.id)) {
newItems.push(item)
}
if (nextPlayerIds.includes(item.id)) {
// 检查是否是下一个队伍的最后一个选手
const isLastOfNext = item.id === nextPlayerIds[nextPlayerIds.length - 1]
if (isLastOfNext && !addedCurrent) {
// 在下一个队伍最后一个选手之后添加当前队伍
for (const cItem of group.items) {
if (currentPlayerIds.includes(cItem.id)) {
newItems.push(cItem)
}
}
addedCurrent = true
}
}
}
group.items = newItems
this.$message.success('下移成功')
},
goBack() {
this.$router.go(-1)
},
// 加载赛事信息
async loadCompetitionInfo() {
try {
this.loading = true
const res = await getCompetitionDetail(this.competitionId)
const data = res.data?.data
if (data) {
this.competitionInfo.competitionName = data.competitionName || data.competition_name || ''
this.competitionInfo.competitionStartTime = data.competitionStartTime || data.competition_start_time
this.competitionInfo.competitionEndTime = data.competitionEndTime || data.competition_end_time
// 生成时间段
this.generateTimeSlots()
}
} catch (err) {
console.error('加载赛事信息失败', err)
this.$message.error('加载赛事信息失败')
} finally {
this.loading = false
}
},
// 根据开始和结束时间生成时间段列表
generateTimeSlots() {
const startTime = this.competitionInfo.competitionStartTime
const endTime = this.competitionInfo.competitionEndTime
if (!startTime || !endTime) {
this.$message.warning('赛事时间信息不完整,使用默认时间段')
this.timeSlots = [
'2025年11月6日 上午8:30',
'2025年11月6日 下午13:30'
]
return
}
const slots = []
const start = new Date(startTime)
const end = new Date(endTime)
// 遍历每一天
let currentDate = new Date(start)
let dayIndex = 1
while (currentDate <= end) {
const year = currentDate.getFullYear()
const month = currentDate.getMonth() + 1
const day = currentDate.getDate()
const dateStr = `${year}${month}${day}日`
// 添加上午时段 8:30
slots.push(`${dateStr} 上午8:30`)
// 添加下午时段 13:30
slots.push(`${dateStr} 下午13:30`)
// 下一天
currentDate.setDate(currentDate.getDate() + 1)
dayIndex++
}
this.timeSlots = slots
console.log('生成的时间段:', this.timeSlots)
},
// 加载场地列表
async loadVenues() {
try {
this.loading = true
const res = await getVenuesByCompetition(this.competitionId)
const venuesData = res.data?.data?.records || res.data?.data || []
if (venuesData.length === 0) {
this.$message.warning('该赛事暂无场地信息')
this.venues = []
} else {
this.venues = venuesData.map(v => ({
id: v.id,
venueName: v.venueName || v.venue_name,
capacity: v.capacity || 0,
location: v.location,
description: v.description
}))
// 默认选中第一个场地
if (this.venues.length > 0) {
this.selectedVenueId = this.venues[0].id
this.selectedVenueIdForVenueTab = this.venues[0].id
}
console.log('加载的场地数据:', this.venues)
}
} catch (err) {
console.error('加载场地失败', err)
this.$message.error('加载场地失败')
this.venues = []
} finally {
this.loading = false
}
},
// 加载编排数据
async loadScheduleData() {
try {
this.loading = true
const res = await getScheduleResult(this.competitionId)
const data = res.data?.data
console.log('=== API返回的编排数据 ===')
console.log('完整响应:', res.data)
console.log('data:', data)
if (data) {
// 判断是否有草稿或已完成的编排
this.isScheduleCompleted = data.isCompleted || false
// 加载竞赛分组数据
if (data.competitionGroups && data.competitionGroups.length > 0) {
this.competitionGroups = data.competitionGroups.map(group => ({
id: group.id,
title: group.title,
type: group.type,
count: group.count,
code: group.code,
venueId: group.venueId,
venueName: group.venueName,
timeSlot: group.timeSlot,
timeSlotIndex: group.timeSlotIndex,
items: (group.participants || []).map(p => ({
id: p.id,
schoolUnit: p.schoolUnit,
playerName: p.playerName,
status: p.status || '未签到',
sortOrder: p.sortOrder
}))
}))
// 加载异常组数据
this.exceptionList = []
this.competitionGroups.forEach(group => {
group.items.forEach(item => {
if (item.status === '异常') {
this.exceptionList.push({
groupId: group.id,
groupTitle: group.title,
participantId: item.id,
schoolUnit: item.schoolUnit,
status: item.status
})
}
})
})
console.log('解析后的competitionGroups:', this.competitionGroups)
console.log('当前selectedVenueId:', this.selectedVenueId)
console.log('当前selectedTime:', this.selectedTime)
this.$message.success(data.isDraft ? '已加载草稿数据' : '已加载编排数据')
} else {
// 后端还没有数据,使用默认数据进行展示
this.competitionGroups = []
console.log('后端暂无编排数据,等待后端生成')
}
}
} catch (err) {
console.error('加载编排数据失败', err)
if (err.response?.status === 404) {
console.log('编排数据不存在,等待后端生成')
} else {
this.$message.error('加载编排数据失败: ' + (err.message || ''))
}
this.competitionGroups = []
} finally {
this.loading = false
}
},
// 移动分组
handleMoveGroup(group) {
if (this.isScheduleCompleted) {
this.$message.warning('编排已完成,无法移动')
return
}
this.moveGroupIndex = this.competitionGroups.findIndex(g => g.id === group.id)
this.moveTargetVenueId = group.venueId || null
this.moveTargetTimeSlot = group.timeSlotIndex || 0
this.moveDialogVisible = true
},
// 确认移动分组
async confirmMoveGroup() {
if (!this.moveTargetVenueId) {
this.$message.warning('请选择目标场地')
return
}
if (this.moveTargetTimeSlot === null) {
this.$message.warning('请选择目标时间段')
return
}
const group = this.competitionGroups[this.moveGroupIndex]
const targetVenue = this.venues.find(v => v.id === this.moveTargetVenueId)
try {
// 调用后端API移动分组
const res = await moveScheduleGroup({
groupId: group.id,
targetVenueId: this.moveTargetVenueId,
targetTimeSlotIndex: this.moveTargetTimeSlot
})
if (res.data.success) {
// 更新前端数据
group.venueId = this.moveTargetVenueId
group.venueName = targetVenue ? targetVenue.venueName : ''
group.timeSlotIndex = this.moveTargetTimeSlot
group.timeSlot = this.timeSlots[this.moveTargetTimeSlot]
this.$message.success(`已移动到 ${group.venueName} - ${group.timeSlot}`)
this.moveDialogVisible = false
} else {
this.$message.error(res.data.msg || '移动分组失败')
}
} catch (error) {
console.error('移动分组失败:', error)
this.$message.error('移动分组失败,请稍后重试')
}
},
// 标记为异常
markAsException(group, itemIndex) {
if (this.isScheduleCompleted) {
this.$message.warning('编排已完成,无法标记异常')
return
}
const item = group.items[itemIndex]
// 修改状态为异常
item.status = '异常'
// 添加到异常组列表
this.exceptionList.push({
groupId: group.id,
groupTitle: group.title,
participantId: item.id,
schoolUnit: item.schoolUnit,
status: '异常'
})
// 调用后端API保存状态
updateCheckInStatus(item.id, '异常').then(() => {
this.$message.success(`已将 ${item.schoolUnit} 标记为异常`)
}).catch(err => {
console.error('保存异常状态失败:', err)
})
},
// 显示异常组对话框
showExceptionDialog() {
this.exceptionDialogVisible = true
},
// 从异常组移除
removeFromException(index) {
const exceptionItem = this.exceptionList[index]
// 在竞赛分组中找到对应的参赛人员并恢复状态
for (let group of this.competitionGroups) {
if (group.id === exceptionItem.groupId) {
for (let item of group.items) {
if (item.id === exceptionItem.participantId) {
item.status = '未签到'
break
}
}
break
}
}
// 从异常列表中移除
this.exceptionList.splice(index, 1)
// 调用后端API恢复状态
updateCheckInStatus(exceptionItem.participantId, '未签到').then(() => {
this.$message.success(`已将 ${exceptionItem.schoolUnit} 从异常组移除`)
}).catch(err => {
console.error('恢复状态失败:', err)
})
},
// 触发自动编排
async handleAutoArrange() {
try {
// 确认操作
await this.$confirm('自动编排将重新生成赛程安排,当前编排数据将被覆盖。确定继续吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
this.autoArrangeLoading = true
// 调用自动编排接口
const res = await triggerAutoArrange(this.competitionId)
if (res.data?.success) {
this.$message.success('自动编排完成')
// 重新加载编排数据
await this.loadScheduleData()
} else {
this.$message.error(res.data?.msg || '自动编排失败')
}
} catch (err) {
if (err !== 'cancel') {
console.error('自动编排失败', err)
this.$message.error('自动编排失败: ' + (err.message || ''))
}
} finally {
this.autoArrangeLoading = false
}
},
// 保存草稿
async handleSaveDraft() {
try {
this.loading = true
// 构建保存数据
const saveData = {
competitionId: this.competitionId,
isDraft: true,
competitionGroups: this.competitionGroups.map(group => ({
id: group.id,
title: group.title,
type: group.type,
count: group.count,
code: group.code,
venueId: group.venueId,
venueName: group.venueName,
timeSlot: group.timeSlot,
timeSlotIndex: group.timeSlotIndex,
participants: group.items.map((item, index) => ({
id: item.id,
schoolUnit: item.schoolUnit,
status: item.status,
sortOrder: index + 1
}))
}))
}
// 调用保存草稿接口
await saveDraftSchedule(saveData)
console.log('保存草稿数据:', saveData)
this.$message.success('草稿保存成功')
} catch (err) {
console.error('保存草稿失败', err)
this.$message.error('保存草稿失败: ' + (err.message || ''))
} finally {
this.loading = false
}
},
handleVenueCommand(command, groupIndex) {
// 这个方法已废弃,保留以防万一
},
handleMoveUp(group, itemIndex) {
if (itemIndex === 0 || this.isScheduleCompleted) return
const temp = group.items[itemIndex]
group.items.splice(itemIndex, 1)
group.items.splice(itemIndex - 1, 0, temp)
this.$message.success('上移成功')
},
handleMoveDown(group, itemIndex) {
if (itemIndex === group.items.length - 1 || this.isScheduleCompleted) return
const temp = group.items[itemIndex]
group.items.splice(itemIndex, 1)
group.items.splice(itemIndex + 1, 0, temp)
this.$message.success('下移成功')
},
async handleExport() {
try {
this.loading = true
const res = await exportSchedule(this.competitionId)
// axios 返回的是 response 对象,需要取 res.data
const blob = new Blob([res.data], { type: 'application/vnd.ms-excel' })
const link = document.createElement('a')
link.href = window.URL.createObjectURL(blob)
link.download = `赛程表_${this.competitionInfo.competitionName || this.competitionId}.xlsx`
link.click()
window.URL.revokeObjectURL(link.href)
this.$message.success('导出成功')
} catch (error) {
console.error('导出失败:', error)
this.$message.error('导出失败,请稍后重试')
} finally {
this.loading = false
}
},
handleConfirm() {
this.confirmDialogVisible = true
},
async confirmComplete() {
try {
this.loading = true
// 检查是否有编排数据
if (!this.competitionGroups || this.competitionGroups.length === 0) {
this.$message.warning('请先进行自动编排,生成编排数据后再完成编排')
this.confirmDialogVisible = false
return
}
console.log('开始完成编排,竞赛ID:', this.competitionId)
console.log('编排分组数量:', this.competitionGroups.length)
// 1. 先保存当前的草稿数据
const saveData = {
competitionId: this.competitionId,
isDraft: true,
competitionGroups: this.competitionGroups.map(group => ({
id: group.id,
title: group.title,
type: group.type,
count: group.count,
code: group.code,
venueId: group.venueId,
venueName: group.venueName,
timeSlot: group.timeSlot,
timeSlotIndex: group.timeSlotIndex,
participants: group.items.map((item, index) => ({
id: item.id,
schoolUnit: item.schoolUnit,
status: item.status,
sortOrder: index + 1
}))
}))
}
console.log('准备保存草稿数据:', saveData)
const draftRes = await saveDraftSchedule(saveData)
console.log('保存草稿成功,返回:', draftRes)
// 2. 然后调用锁定接口
console.log('准备锁定编排,竞赛ID:', this.competitionId)
const lockRes = await saveAndLockSchedule(this.competitionId)
console.log('锁定接口返回:', lockRes)
// 3. 更新UI状态
this.isScheduleCompleted = true
this.confirmDialogVisible = false
this.$message.success('编排已完成并锁定')
// 4. 重新加载编排数据以获取最新状态
await this.loadScheduleData()
} catch (err) {
console.error('完成编排失败', err)
console.error('错误详情:', err.response?.data || err.message)
this.$message.error('完成编排失败: ' + (err.response?.data?.msg || err.message || '未知错误'))
} finally {
this.loading = false
}
}
}
}
</script>
<style lang="scss" scoped>
.martial-schedule-container {
padding: 15px;
background: #fff;
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 15px;
gap: 10px;
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.header-right {
display: flex;
align-items: center;
gap: 10px;
}
.page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
}
.tabs-section {
margin-bottom: 15px;
.tab-buttons {
margin-bottom: 15px;
padding: 12px;
background: #fff;
border: 1px solid #dcdfe6;
}
.tab-content {
padding: 15px;
background: #fff;
border: 1px solid #dcdfe6;
.time-selector {
margin-bottom: 15px;
// padding: 8px;
// background: #f5f7fa;
}
// 场地列表样式
.venue-list {
margin-bottom: 10px;
background: #ffffff;
border: 1px solid #ffffff;
border-radius: 4px;
.venue-list-title {
font-size: 14px;
font-weight: 600;
color: #ffffff;
margin-bottom: 10px;
}
.venue-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
.no-venue-hint {
padding: 8px 12px;
color: #f59e0b;
font-size: 13px;
background: #fef3c7;
border-radius: 4px;
}
}
}
.competition-group {
margin-bottom: 15px;
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
background: #f5f7fa;
border: 1px solid #dcdfe6;
border-bottom: none;
.group-info {
display: flex;
align-items: center;
gap: 12px;
.group-title {
font-weight: 600;
color: #303133;
font-size: 13px;
}
.group-meta {
color: #909399;
font-size: 12px;
}
}
.group-hints {
flex: 1;
text-align: center;
.hint-text {
color: #f50057;
font-size: 11px;
&.secondary {
color: #ff4081;
}
}
}
.group-actions {
display: flex;
gap: 8px;
}
}
}
// 新的项目卡片样式
.project-card {
margin-bottom: 20px;
border: 1px solid #e4e7ed;
border-radius: 4px;
overflow: hidden;
.project-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #fafafa;
border-bottom: 1px solid #e4e7ed;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background: #f0f0f0;
}
&.project-header-collapsed {
border-bottom: none;
}
.project-info {
display: flex;
align-items: center;
gap: 12px;
.project-expand-icon {
width: 20px;
color: #909399;
display: flex;
align-items: center;
justify-content: center;
}
.project-index {
font-weight: 600;
color: #e6a23c;
font-size: 14px;
}
.project-title {
font-weight: 600;
color: #e6a23c;
font-size: 14px;
}
.project-meta {
color: #606266;
font-size: 13px;
}
}
.project-actions {
display: flex;
gap: 8px;
}
}
.team-list {
.team-row {
border-bottom: 1px solid #ebeef5;
background: #fff;
&:last-child {
border-bottom: none;
}
&.team-row-expanded {
background: #fafafa;
}
.team-main {
display: flex;
align-items: center;
padding: 12px 16px;
cursor: pointer;
&:hover {
background: #f5f7fa;
}
.expand-icon {
width: 20px;
color: #909399;
display: flex;
align-items: center;
justify-content: center;
}
.team-info {
display: flex;
align-items: center;
min-width: 180px;
.team-index {
color: #909399;
font-size: 13px;
margin-right: 4px;
}
.team-name {
color: #303133;
font-size: 13px;
font-weight: 500;
}
}
.player-list {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 20px;
.player-tag {
color: #606266;
font-size: 13px;
padding: 2px 8px;
background: #f4f4f5;
border-radius: 4px;
&.player-exception {
color: #f56c6c;
background: #fef0f0;
}
}
}
.team-actions {
display: flex;
gap: 4px;
min-width: 80px;
justify-content: flex-end;
}
}
.team-expand-content {
background: #fafafa;
border-top: 1px dashed #e4e7ed;
padding: 8px 16px 8px 56px;
.player-detail-row {
display: flex;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid #ebeef5;
&:last-child {
border-bottom: none;
}
.player-detail-index {
width: 30px;
color: #909399;
font-size: 12px;
}
.player-detail-name {
flex: 1;
color: #303133;
font-size: 13px;
}
.player-detail-status {
width: 80px;
text-align: center;
}
.player-detail-actions {
width: 100px;
text-align: right;
}
}
}
}
}
}
.move-popover {
.move-label {
font-size: 13px;
color: #606266;
margin-bottom: 8px;
}
}
.group-footer-hints {
margin-top: 15px;
padding: 8px 12px;
background: #fff3e0;
border-left: 3px solid #ff9800;
.footer-hint-text {
margin: 4px 0;
color: #f50057;
font-size: 11px;
&.secondary {
color: #ff4081;
}
}
}
.row-hint {
margin-top: 4px;
padding: 2px 5px;
color: #f50057;
font-size: 11px;
background: rgba(245, 0, 87, 0.05);
display: inline-block;
}
.venue-footer-hints {
margin-top: 15px;
padding: 8px 12px;
background: #fff3e0;
border-left: 3px solid #ff9800;
.footer-hint-text {
margin: 4px 0;
color: #f50057;
font-size: 11px;
}
}
.status-active {
color: #409eff;
font-weight: 600;
}
}
}
.footer-actions {
text-align: center;
padding: 15px;
background: #fff;
border: 1px solid #dcdfe6;
}
.move-btn {
padding: 4px;
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.move-icon {
width: 20px;
height: 20px;
display: inline-block;
vertical-align: middle;
}
}
// 展开行样式
.player-expand-list {
padding: 10px 20px 10px 60px;
background: #fafafa;
.player-row {
display: flex;
align-items: center;
gap: 15px;
padding: 8px 0;
border-bottom: 1px dashed #eee;
&:last-child {
border-bottom: none;
}
.player-name {
min-width: 80px;
color: #606266;
font-weight: 500;
}
}
}
</style>