feat: 竞赛分组页面添加队伍展开功能,显示选手签到状态和异常标记

- 点击队伍行可展开显示选手详情
- 显示选手签到状态:未签到/已签到/异常
- 支持标记异常和取消异常操作
- 优化评分页面代码

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
DevOps
2025-12-24 12:49:33 +08:00
parent 5e75688e13
commit 352727b4fb
2 changed files with 457 additions and 185 deletions

View File

@@ -71,97 +71,148 @@
</el-button> </el-button>
</div> </div>
<div v-for="(group, index) in filteredCompetitionGroups" :key="group.id" class="competition-group"> <!-- 项目卡片列表 -->
<div class="group-header"> <div v-for="(group, groupIndex) in filteredCompetitionGroups" :key="group.id" class="project-card">
<div class="group-info"> <!-- 项目头部 -->
<span class="group-title">{{ group.title }}</span> <div class="project-header">
<span class="group-meta">{{ group.type }}</span> <div class="project-info">
<span class="group-meta">{{ group.count }}</span> <span class="project-index">{{ groupIndex + 1 }}</span>
<span class="group-meta">{{ group.code }}</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>
<div class="group-actions"> <div class="project-actions">
<el-button size="small" type="warning" @click="handleMoveGroup(group)"> <el-popover
移动 placement="bottom"
</el-button> :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> </div>
<el-table :data="groupItemsByTeam(group.items)" border stripe size="small" row-key="id"> <!-- 队伍列表 -->
<!-- 展开列 --> <div class="team-list">
<el-table-column type="expand" width="30"> <div
<template #default="{ row }"> v-for="(team, teamIndex) in groupItemsByTeam(group.items)"
<div v-if="row.players.length > 1" class="player-expand-list"> :key="team.id"
<div v-for="player in row.players" :key="player.id" class="player-row"> class="team-row"
<span class="player-name">{{ player.playerName }}</span> :class="{ 'team-row-expanded': isTeamExpanded(group.id, team.id) }"
<el-tag :type="player.status === '已签到' ? 'success' : player.status === '异常' ? 'danger' : 'info'" size="small"> >
<!-- 队伍主行 - 可点击展开 -->
<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 || '未签到' }} {{ player.status || '未签到' }}
</el-tag> </el-tag>
</span>
<span class="player-detail-actions">
<el-button <el-button
v-if="(player.status || '未签到') === '未签到'" v-if="(player.status || '未签到') === '未签到'"
link link
size="small" size="small"
@click="markPlayerAsException(group, player)" @click="markPlayerAsException(group, team, playerIndex)"
:disabled="isScheduleCompleted" :disabled="isScheduleCompleted"
style="color: #f56c6c;" style="color: #f56c6c;"
> >
异常 标记异常
</el-button> </el-button>
</div> <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 v-else class="player-expand-list"> </div>
<span style="color: #909399;">单人队伍</span> </div>
</div> </div>
</template>
</el-table-column>
<el-table-column label="序号" type="index" width="60" align="center"></el-table-column>
<el-table-column prop="schoolUnit" label="学校/单位" min-width="150"></el-table-column>
<el-table-column label="选手" min-width="120">
<template #default="{ row }">
{{ row.players.map(p => p.playerName).join('、') }}
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="getTeamStatusType(row)" size="small">
{{ getTeamStatus(row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" align="center">
<template #default="scope">
<el-button
link
size="small"
@click="handleTeamMoveUp(group, scope.$index)"
:disabled="scope.$index === 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, scope.$index)"
:disabled="scope.$index === groupItemsByTeam(group.items).length - 1 || isScheduleCompleted"
title="下移"
class="move-btn"
>
<img src="/img/图标 4@3x.png" class="move-icon" alt="下移" />
</el-button>
<el-button
v-if="scope.row.players.length === 1 && (scope.row.players[0].status || '未签到') === '未签到'"
link
size="small"
@click="markPlayerAsException(group, scope.row.players[0])"
:disabled="isScheduleCompleted"
style="color: #f56c6c;"
>
异常
</el-button>
</template>
</el-table-column>
</el-table>
</div> </div>
</div> </div>
@@ -303,12 +354,17 @@
</template> </template>
<script> <script>
import { ArrowDown, ArrowRight } from '@element-plus/icons-vue'
import { getVenuesByCompetition } from '@/api/martial/venue' import { getVenuesByCompetition } from '@/api/martial/venue'
import { getCompetitionDetail } from '@/api/martial/competition' import { getCompetitionDetail } from '@/api/martial/competition'
import { getScheduleResult, saveAndLockSchedule, saveDraftSchedule, triggerAutoArrange, moveScheduleGroup } from '@/api/martial/activitySchedule' import { getScheduleResult, saveAndLockSchedule, saveDraftSchedule, triggerAutoArrange, moveScheduleGroup } from '@/api/martial/activitySchedule'
export default { export default {
name: 'MartialScheduleList', name: 'MartialScheduleList',
components: {
ArrowDown,
ArrowRight
},
data() { data() {
return { return {
competitionId: null, competitionId: null,
@@ -337,7 +393,8 @@ export default {
// 异常组相关 // 异常组相关
exceptionDialogVisible: false, exceptionDialogVisible: false,
exceptionList: [] // 异常参赛人员列表 exceptionList: [], // 异常参赛人员列表
expandedTeams: {} // 展开的队伍 { 'groupId-teamId': true }
} }
}, },
computed: { computed: {
@@ -403,6 +460,59 @@ export default {
} }
}, },
methods: { methods: {
// 检查队伍是否展开
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) {
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: '异常'
})
this.$message.success('已标记为异常')
}
},
// 取消选手异常状态
removePlayerException(group, team, playerIndex) {
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)
}
this.$message.success('已取消异常标记')
}
},
// 将选手按学校/单位分组为队伍 // 将选手按学校/单位分组为队伍
groupItemsByTeam(items) { groupItemsByTeam(items) {
if (!items || items.length === 0) return [] if (!items || items.length === 0) return []
@@ -423,6 +533,45 @@ export default {
return Array.from(teamMap.values()) 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) { getTeamStatus(team) {
if (!team || !team.players) return '未签到' if (!team || !team.players) return '未签到'
@@ -1117,6 +1266,178 @@ export default {
} }
} }
// 新的项目卡片样式
.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;
.project-info {
display: flex;
align-items: center;
gap: 12px;
.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 { .group-footer-hints {
margin-top: 15px; margin-top: 15px;
padding: 8px 12px; padding: 8px 12px;

View File

@@ -154,6 +154,11 @@
<div class="total-score-display"> <div class="total-score-display">
<span class="label">总分</span> <span class="label">总分</span>
<span class="value">{{ formatScore(currentDetail.totalScore) }}</span> <span class="value">{{ formatScore(currentDetail.totalScore) }}</span>
<div class="calculation-note">
<span v-if="currentDetail.judgeScores.length > 2">
(去掉最高分和最低分后的平均分)
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -182,96 +187,6 @@ export default {
projectOptions: [], projectOptions: [],
venueOptions: [], venueOptions: [],
scoreList: [], scoreList: [],
allTableData: [
{
id: 1,
projectName: '男子组陈氏太极拳',
venueName: '第一场地',
playerName: '张三',
teamName: '少林寺武术大学院',
idCard: '123456789000000000',
playerNo: '123-4567898275',
judgeScores: [8.906, 8.905, 8.908, 8.907, 8.906],
totalScore: 8.907
},
{
id: 2,
projectName: '女子组长拳',
venueName: '第一场地',
playerName: '李四',
teamName: '武当武术学院',
idCard: '123456789000000001',
playerNo: '123-4567898276',
judgeScores: [9.125, 9.130, 9.128, 9.126, 9.129],
totalScore: 9.128
},
{
id: 3,
projectName: '男子组陈氏太极拳',
venueName: '第二场地',
playerName: '王五',
teamName: '峨眉武术协会',
idCard: '123456789000000002',
playerNo: '123-4567898277',
judgeScores: [8.550, 8.548, 8.552, 8.551, 8.549],
totalScore: 8.550
},
{
id: 4,
projectName: '女子组双剑(含长穗双剑)',
venueName: '第一场地',
playerName: '赵六',
teamName: '昆仑武术馆',
idCard: '123456789000000003',
playerNo: '123-4567898278',
judgeScores: [9.245, 9.248, 9.246, 9.247, 9.249],
totalScore: 9.247
},
{
id: 5,
projectName: '男子组杨氏太极拳',
venueName: '第三场地',
playerName: '孙七',
teamName: '华山武术学校',
idCard: '123456789000000004',
playerNo: '123-4567898279',
judgeScores: [8.785, 8.788, 8.786, 8.787, 8.785],
totalScore: 8.786
},
{
id: 6,
projectName: '女子组刀术',
venueName: '第二场地',
playerName: '周八',
teamName: '少林寺武术大学院',
idCard: '123456789000000005',
playerNo: '123-4567898280',
judgeScores: [8.925, 8.928, 8.926, 8.927, 8.925],
totalScore: 8.926
},
{
id: 7,
projectName: '男子组棍术',
venueName: '第四场地',
playerName: '吴九',
teamName: '武当武术学院',
idCard: '123456789000000006',
playerNo: '123-4567898281',
judgeScores: [9.015, 9.018, 9.016, 9.017, 9.015],
totalScore: 9.016
},
{
id: 8,
projectName: '女子组枪术',
venueName: '第三场地',
playerName: '郑十',
teamName: '峨眉武术协会',
idCard: '123456789000000007',
playerNo: '123-4567898282',
judgeScores: [8.665, 8.668, 8.666, 8.667, 8.665],
totalScore: 8.666
}
],
tableData: [], tableData: [],
pagination: { pagination: {
current: 1, current: 1,
@@ -345,25 +260,22 @@ export default {
try { try {
const res = await getScoreList(this.pagination.current, this.pagination.size, params) const res = await getScoreList(this.pagination.current, this.pagination.size, params)
console.log('评分列表返回数据:', res) console.log('评分列表返回数据:', res)
console.log('===== 调试:后端返回的数据结构 =====')
const responseData = res.data?.data const responseData = res.data?.data
if (responseData && responseData.records && responseData.records.length > 0) {
console.log('第一条评分记录:', responseData.records[0])
console.log('记录字段:', Object.keys(responseData.records[0]))
console.log('是否包含 projectName:', 'projectName' in responseData.records[0])
console.log('是否包含 venueName:', 'venueName' in responseData.records[0])
console.log('是否包含 playerName:', 'playerName' in responseData.records[0])
console.log('projectId 值:', responseData.records[0].projectId)
console.log('venueId 值:', responseData.records[0].venueId)
console.log('athleteId 值:', responseData.records[0].athleteId)
}
console.log('======================================')
if (responseData && responseData.records) { if (responseData && responseData.records) {
this.scoreList = responseData.records // 过滤掉 projectId 为 null 的无效记录
const validScores = responseData.records.filter(score => {
if (!score.projectId) {
console.warn('发现无效评分记录projectId为空:', score)
return false
}
return true
})
this.scoreList = validScores
// 补充关联数据(项目名称、场地名称、选手名称) // 补充关联数据(项目名称、场地名称、选手名称)
await this.enrichScoreData(responseData.records) await this.enrichScoreData(validScores)
// 按选手分组评分数据 // 按选手分组评分数据
this.processScoreData(this.scoreList) this.processScoreData(this.scoreList)
@@ -466,6 +378,12 @@ export default {
const athleteMap = new Map() const athleteMap = new Map()
scores.forEach(score => { scores.forEach(score => {
// 确保 projectId 存在
if (!score.projectId) {
console.warn('跳过无效评分记录:', score)
return
}
const key = `${score.athleteId}-${score.projectId}` const key = `${score.athleteId}-${score.projectId}`
if (!athleteMap.has(key)) { if (!athleteMap.has(key)) {
athleteMap.set(key, { athleteMap.set(key, {
@@ -495,11 +413,10 @@ export default {
}) })
}) })
// 计算总分(平均分) // 计算总分(去掉最高最低分后的平均分)
this.tableData = Array.from(athleteMap.values()).map(athlete => { this.tableData = Array.from(athleteMap.values()).map(athlete => {
if (athlete.judgeScores.length > 0) { if (athlete.judgeScores.length > 0) {
const sum = athlete.judgeScores.reduce((a, b) => a + b, 0) athlete.totalScore = this.calculateFinalScore(athlete.judgeScores)
athlete.totalScore = sum / athlete.judgeScores.length
} }
return athlete return athlete
}) })
@@ -516,6 +433,34 @@ export default {
this.judgeColumns = Array(maxJudges).fill(null) this.judgeColumns = Array(maxJudges).fill(null)
}, },
/**
* 计算最终得分
* 规则:
* - 如果裁判数 <= 2直接取平均值
* - 如果裁判数 > 2去掉最高分和最低分后取平均值
*/
calculateFinalScore(scores) {
if (!scores || scores.length === 0) {
return 0
}
// 如果只有1-2个裁判直接取平均值
if (scores.length <= 2) {
const sum = scores.reduce((a, b) => a + b, 0)
return sum / scores.length
}
// 3个及以上裁判去掉最高分和最低分
const sortedScores = [...scores].sort((a, b) => a - b)
// 去掉第一个(最低分)和最后一个(最高分)
const validScores = sortedScores.slice(1, -1)
// 计算平均值
const sum = validScores.reduce((a, b) => a + b, 0)
return sum / validScores.length
},
// 查询 // 查询
handleSearch() { handleSearch() {
this.pagination.current = 1 this.pagination.current = 1
@@ -663,6 +608,12 @@ export default {
font-weight: 700; font-weight: 700;
color: #1b7c5e; color: #1b7c5e;
} }
.calculation-note {
margin-top: 8px;
font-size: 12px;
color: #999;
}
} }
} }
} }