修复团队赛事相关功能

1. 修复团队成员无法在"我参与的赛事"中看到团队赛事的问题
2. 修复教师作为指导老师无法看到团队赛事的问题
3. 修复上传作品/参赛作品/我的队伍按钮500错误(userId获取方式错误)
4. 修复管理端成员弹框队长名称和成员数显示问题
5. 新增getMyRegistration接口支持团队成员查询报名状态
6. 优化赛事卡片按钮布局

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
zhangxiaohua 2026-01-22 15:27:06 +08:00
parent 0cdc5d1ceb
commit ac0c38c04a
11 changed files with 390 additions and 94 deletions

View File

@ -363,6 +363,7 @@ export class ContestsService {
contestIds = judgeRecords.map((r) => r.contestId);
} else if (role === 'teacher') {
// 教师:查询作为指导老师参与的赛事
// 1. 从报名指导老师关联表查询(个人赛)
const teacherRecords = await this.prisma.contestRegistrationTeacher.findMany({
where: {
userId,
@ -375,9 +376,29 @@ export class ContestsService {
},
},
});
contestIds = Array.from(new Set(teacherRecords.map((r) => r.registration.contestId as number)));
const contestIdsFromRegistration = teacherRecords.map((r) => r.registration.contestId as number);
// 2. 从团队成员表查询团队赛role='mentor'
const mentorRecords = await this.prisma.contestTeamMember.findMany({
where: {
userId,
role: 'mentor',
},
select: {
team: {
select: {
contestId: true,
},
},
},
});
const contestIdsFromTeam = mentorRecords.map((r) => r.team.contestId);
// 合并去重
contestIds = Array.from(new Set([...contestIdsFromRegistration, ...contestIdsFromTeam]));
} else {
// 学生/默认:查询报名的赛事
// 1. 从报名记录查询(个人赛报名或团队赛队长)
const registrationWhere: any = {
userId,
};
@ -393,7 +414,32 @@ export class ContestsService {
},
distinct: ['contestId'],
});
contestIds = registrations.map((r) => r.contestId);
const contestIdsFromRegistration = registrations.map((r) => r.contestId);
// 2. 从团队成员表查询团队赛成员role='leader' 或 'member'
const teamMemberWhere: any = {
userId,
role: { in: ['leader', 'member'] },
};
if (tenantId) {
teamMemberWhere.tenantId = tenantId;
}
const teamMembers = await this.prisma.contestTeamMember.findMany({
where: teamMemberWhere,
select: {
team: {
select: {
contestId: true,
},
},
},
});
const contestIdsFromTeam = teamMembers.map((r) => r.team.contestId);
// 合并去重
contestIds = Array.from(new Set([...contestIdsFromRegistration, ...contestIdsFromTeam]));
}
if (contestIds.length === 0) {

View File

@ -10,6 +10,7 @@ import {
UseGuards,
Request,
ParseIntPipe,
BadRequestException,
} from '@nestjs/common';
import { RegistrationsService } from './registrations.service';
import { CreateRegistrationDto } from './dto/create-registration.dto';
@ -28,9 +29,9 @@ export class RegistrationsController {
create(@Body() createRegistrationDto: CreateRegistrationDto, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
if (!tenantId) {
throw new Error('无法确定租户信息');
throw new BadRequestException('无法确定租户信息');
}
const creatorId = req.user?.id;
const creatorId = req.user?.userId;
return this.registrationsService.create(
createRegistrationDto,
tenantId,
@ -45,6 +46,22 @@ export class RegistrationsController {
return this.registrationsService.findAll(queryDto, tenantId);
}
/**
*
*/
@Get('my/:contestId')
@RequirePermission('contest:read')
getMyRegistration(
@Param('contestId', ParseIntPipe) contestId: number,
@Request() req,
) {
const userId = req.user?.userId;
if (!userId) {
throw new BadRequestException('用户未登录');
}
return this.registrationsService.getMyRegistration(contestId, userId);
}
@Get(':id')
@RequirePermission('contest:read')
findOne(@Param('id', ParseIntPipe) id: number, @Request() req) {
@ -60,7 +77,7 @@ export class RegistrationsController {
@Request() req,
) {
const tenantId = req.tenantId || req.user?.tenantId;
const operatorId = req.user?.id;
const operatorId = req.user?.userId;
return this.registrationsService.review(id, reviewDto, operatorId, tenantId);
}
@ -73,9 +90,9 @@ export class RegistrationsController {
) {
const tenantId = req.tenantId || req.user?.tenantId;
if (!tenantId) {
throw new Error('无法确定租户信息');
throw new BadRequestException('无法确定租户信息');
}
const creatorId = req.user?.id;
const creatorId = req.user?.userId;
return this.registrationsService.addTeacher(
id,
body.teacherUserId,
@ -93,7 +110,7 @@ export class RegistrationsController {
) {
const tenantId = req.tenantId || req.user?.tenantId;
if (!tenantId) {
throw new Error('无法确定租户信息');
throw new BadRequestException('无法确定租户信息');
}
return this.registrationsService.removeTeacher(id, teacherUserId, tenantId);
}

View File

@ -626,5 +626,111 @@ export class RegistrationsService {
where: { id: teacherRecord.id },
});
}
/**
*
* @param contestId ID
* @param userId ID
* @returns null
*/
async getMyRegistration(contestId: number, userId: number) {
try {
// 1. 先查询个人赛报名(直接按 userId 匹配)
const individualReg = await this.prisma.contestRegistration.findFirst({
where: {
contestId,
userId,
registrationType: 'individual',
registrationState: 'passed',
},
include: {
contest: {
select: {
id: true,
contestName: true,
contestType: true,
},
},
works: {
where: { validState: 1, isLatest: true },
orderBy: { submitTime: 'desc' },
},
},
});
if (individualReg) {
return individualReg;
}
// 2. 查询用户所属的团队(该比赛的)
const teamMemberships = await this.prisma.contestTeamMember.findMany({
where: {
userId,
},
select: {
teamId: true,
team: {
select: {
id: true,
contestId: true,
validState: true,
},
},
},
});
// 过滤出该比赛且有效的团队
const validTeamIds = teamMemberships
.filter((m) => m.team?.contestId === contestId && m.team?.validState === 1)
.map((m) => m.teamId);
if (validTeamIds.length === 0) {
return null;
}
// 3. 通过团队ID查询团队报名记录
const teamReg = await this.prisma.contestRegistration.findFirst({
where: {
contestId,
teamId: { in: validTeamIds },
registrationType: 'team',
registrationState: 'passed',
},
include: {
contest: {
select: {
id: true,
contestName: true,
contestType: true,
},
},
team: {
include: {
members: {
include: {
user: {
select: {
id: true,
username: true,
nickname: true,
},
},
},
},
},
},
works: {
where: { validState: 1, isLatest: true },
orderBy: { submitTime: 'desc' },
},
},
});
return teamReg;
} catch (error) {
console.error('getMyRegistration error:', error);
throw error;
}
}
}

View File

@ -9,6 +9,7 @@ import {
UseGuards,
Request,
ParseIntPipe,
BadRequestException,
} from '@nestjs/common';
import { TeamsService } from './teams.service';
import { CreateTeamDto } from './dto/create-team.dto';
@ -27,9 +28,9 @@ export class TeamsController {
create(@Body() createTeamDto: CreateTeamDto, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
if (!tenantId) {
throw new Error('无法确定租户信息');
throw new BadRequestException('无法确定租户信息');
}
const creatorId = req.user?.id;
const creatorId = req.user?.userId;
return this.teamsService.create(createTeamDto, tenantId, creatorId);
}
@ -59,9 +60,9 @@ export class TeamsController {
) {
const tenantId = req.tenantId || req.user?.tenantId;
if (!tenantId) {
throw new Error('无法确定租户信息');
throw new BadRequestException('无法确定租户信息');
}
const modifierId = req.user?.id;
const modifierId = req.user?.userId;
return this.teamsService.update(id, updateTeamDto, tenantId, modifierId);
}
@ -74,9 +75,9 @@ export class TeamsController {
) {
const tenantId = req.tenantId || req.user?.tenantId;
if (!tenantId) {
throw new Error('无法确定租户信息');
throw new BadRequestException('无法确定租户信息');
}
const creatorId = req.user?.id;
const creatorId = req.user?.userId;
return this.teamsService.inviteMember(
teamId,
inviteMemberDto,
@ -94,7 +95,7 @@ export class TeamsController {
) {
const tenantId = req.tenantId || req.user?.tenantId;
if (!tenantId) {
throw new Error('无法确定租户信息');
throw new BadRequestException('无法确定租户信息');
}
return this.teamsService.removeMember(teamId, userId, tenantId);
}
@ -104,7 +105,7 @@ export class TeamsController {
remove(@Param('id', ParseIntPipe) id: number, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
if (!tenantId) {
throw new Error('无法确定租户信息');
throw new BadRequestException('无法确定租户信息');
}
return this.teamsService.remove(id, tenantId);
}

View File

@ -206,7 +206,7 @@ export class TeamsService {
where.tenantId = tenantId;
}
return this.prisma.contestTeam.findMany({
const teams = await this.prisma.contestTeam.findMany({
where,
orderBy: {
createTime: 'desc',
@ -230,6 +230,13 @@ export class TeamsService {
},
},
},
registrations: {
select: {
id: true,
registrationState: true,
},
take: 1,
},
_count: {
select: {
members: true,
@ -238,15 +245,24 @@ export class TeamsService {
},
},
});
// 将报名状态扁平化到团队对象上
return teams.map((team) => ({
...team,
registrationState: team.registrations?.[0]?.registrationState || 'pending',
registrationId: team.registrations?.[0]?.id,
}));
}
async findOne(id: number, tenantId?: number) {
async findOne(id: number, tenantId?: number, strictTenantCheck = false) {
const where: any = {
id,
validState: 1,
};
if (tenantId) {
// 只有明确要求严格租户检查时才限制 tenantId
// 通过 ID 查询单个团队时ID 已经是唯一的,不需要再限制 tenantId
if (tenantId && strictTenantCheck) {
where.tenantId = tenantId;
}

View File

@ -257,6 +257,8 @@ export interface ContestTeam {
createTime?: string;
modifyTime?: string;
validState?: number;
registrationState?: string; // 报名状态
registrationId?: number; // 报名记录ID
leader?: {
id: number;
username: string;
@ -730,6 +732,16 @@ export const registrationsApi = {
return response;
},
// 获取当前用户在某比赛中的报名记录(包括作为团队成员的情况)
getMyRegistration: async (
contestId: number
): Promise<ContestRegistration | null> => {
const response = await request.get<any, ContestRegistration | null>(
`/contests/registrations/my/${contestId}`
);
return response;
},
// 创建报名
create: async (
data: CreateRegistrationForm

View File

@ -105,37 +105,36 @@
<!-- 底部区域 -->
<div class="card-footer">
<span
class="status-dot"
:class="{ 'status-ongoing': contest.status === 'ongoing' }"
></span>
<span class="status-text">{{ getStatusText(contest) }}</span>
<div class="status-row">
<span
class="status-dot"
:class="{ 'status-ongoing': contest.status === 'ongoing' }"
></span>
<span class="status-text">{{ getStatusText(contest) }}</span>
</div>
<!-- 操作按钮区域 - 我的赛事tab显示 -->
<div v-if="activeTab === 'my'" class="card-actions" @click.stop>
<!-- 学生角色按钮 -->
<template v-if="userRole === 'student'">
<template v-if="contest.contestType === 'individual'">
<a-button
v-if="isSubmitting(contest)"
type="primary"
size="small"
@click="handleUploadWork(contest.id)"
>
上传作品
</a-button>
<a-button size="small" @click="handleViewWorks(contest.id)">
参赛作品
</a-button>
</template>
<template v-else>
<a-button size="small" @click="handleViewWorks(contest.id)">
参赛作品
</a-button>
<a-button size="small" @click="handleViewTeam(contest.id)">
我的队伍
</a-button>
</template>
<a-button
v-if="isSubmitting(contest)"
type="primary"
size="small"
@click="handleUploadWork(contest.id)"
>
上传作品
</a-button>
<a-button size="small" @click="handleViewWorks(contest.id)">
参赛作品
</a-button>
<a-button
v-if="contest.contestType === 'team'"
size="small"
@click="handleViewTeam(contest)"
>
我的队伍
</a-button>
</template>
<!-- 教师角色按钮 -->
@ -199,6 +198,52 @@
v-model:open="viewWorkDrawerVisible"
:contest-id="currentContestIdForView"
/>
<!-- 我的队伍弹窗 -->
<a-modal
v-model:open="teamModalVisible"
:title="`我的队伍 - ${currentTeamContest?.contestName || ''}`"
:footer="null"
width="600px"
>
<a-spin :spinning="teamLoading">
<div v-if="myTeamInfo" class="team-info">
<a-descriptions :column="2" bordered style="margin-bottom: 16px">
<a-descriptions-item label="团队名称">
{{ myTeamInfo.teamName }}
</a-descriptions-item>
<a-descriptions-item label="成员数">
{{ myTeamInfo.members?.length || 0 }}
</a-descriptions-item>
</a-descriptions>
<div class="team-members">
<div class="members-title">团队成员</div>
<a-table
:columns="memberColumns"
:data-source="myTeamInfo.members || []"
:pagination="false"
row-key="id"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'nickname'">
{{ record.user?.nickname || '-' }}
</template>
<template v-else-if="column.key === 'username'">
{{ record.user?.username || '-' }}
</template>
<template v-else-if="column.key === 'role'">
<a-tag :color="getMemberRoleColor(record.role)">
{{ getMemberRoleText(record.role) }}
</a-tag>
</template>
</template>
</a-table>
</div>
</div>
<a-empty v-else description="暂无团队信息" />
</a-spin>
</a-modal>
</div>
</template>
@ -216,8 +261,10 @@ import {
import dayjs from "dayjs"
import {
contestsApi,
registrationsApi,
type Contest,
type QueryContestParams,
type ContestTeam,
} from "@/api/contests"
import { useAuthStore } from "@/stores/auth"
import SubmitWorkDrawer from "./components/SubmitWorkDrawer.vue"
@ -374,9 +421,59 @@ const handleViewWorks = (id: number) => {
}
//
const handleViewTeam = (id: number) => {
// TODO:
message.info("查看我的队伍功能开发中")
const teamModalVisible = ref(false)
const teamLoading = ref(false)
const currentTeamContest = ref<Contest | null>(null)
const myTeamInfo = ref<ContestTeam | null>(null)
//
const memberColumns = [
{ title: "姓名", key: "nickname", width: 120 },
{ title: "账号", key: "username", width: 150 },
{ title: "角色", key: "role", width: 100 },
]
//
const getMemberRoleColor = (role?: string) => {
switch (role) {
case "leader":
return "gold"
case "mentor":
return "purple"
default:
return "blue"
}
}
//
const getMemberRoleText = (role?: string) => {
switch (role) {
case "leader":
return "队长"
case "mentor":
return "指导老师"
default:
return "成员"
}
}
const handleViewTeam = async (contest: Contest) => {
currentTeamContest.value = contest
teamModalVisible.value = true
teamLoading.value = true
myTeamInfo.value = null
try {
//
const registration = await registrationsApi.getMyRegistration(contest.id)
if (registration?.team) {
myTeamInfo.value = registration.team
}
} catch (error: any) {
message.error(error?.response?.data?.message || "获取团队信息失败")
} finally {
teamLoading.value = false
}
}
// ===== =====
@ -797,11 +894,17 @@ $primary-light: #40a9ff;
.card-footer {
display: flex;
align-items: center;
flex-direction: column;
gap: 12px;
padding-top: 12px;
border-top: 1px solid #f5f5f5;
margin-top: auto;
.status-row {
display: flex;
align-items: center;
}
.status-dot {
width: 8px;
height: 8px;
@ -823,16 +926,15 @@ $primary-light: #40a9ff;
.card-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-left: auto;
gap: 6px;
// -
:deep(.ant-btn-primary) {
border: none;
border-radius: 16px;
padding: 6px 16px;
border-radius: 14px;
padding: 4px 12px;
height: auto;
font-size: 13px;
font-size: 12px;
background: linear-gradient(
135deg,
$primary 0%,
@ -860,10 +962,10 @@ $primary-light: #40a9ff;
//
:deep(.ant-btn-default) {
border: none;
border-radius: 16px;
padding: 6px 16px;
border-radius: 14px;
padding: 4px 12px;
height: auto;
font-size: 13px;
font-size: 12px;
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf0 100%);
color: rgba(0, 0, 0, 0.75);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
@ -895,6 +997,18 @@ $primary-light: #40a9ff;
}
}
//
.team-info {
.members-title {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
}
//
@media (max-width: 768px) {
.contests-activities-page {

View File

@ -360,23 +360,13 @@ const fetchRegistrationId = async () => {
}
try {
const response = await registrationsApi.getList({
contestId: props.contestId,
userId: authStore.user.id,
page: 1,
pageSize: 1,
})
//
const registration = await registrationsApi.getMyRegistration(props.contestId)
if (response.list && response.list.length > 0) {
const registration = response.list[0]
if (registration.registrationState === "passed") {
registrationIdRef.value = registration.id
} else {
message.warning("您的报名尚未通过审核,无法上传作品")
visible.value = false
}
if (registration) {
registrationIdRef.value = registration.id
} else {
message.warning("您尚未报名该赛事,无法上传作品")
message.warning("您尚未报名该赛事或报名未通过,无法上传作品")
visible.value = false
}
} catch (error: any) {

View File

@ -205,23 +205,16 @@ const fetchUserWork = async () => {
return
}
//
const registrationResponse = await registrationsApi.getList({
contestId: props.contestId,
userId: userId,
registrationType: "individual",
registrationState: "passed",
page: 1,
pageSize: 10,
})
//
const registration = await registrationsApi.getMyRegistration(props.contestId)
if (registrationResponse.list.length === 0) {
if (!registration) {
message.warning("您尚未报名该赛事或报名未通过,无法查看作品")
visible.value = false
return
}
const registrationId = registrationResponse.list[0].id
const registrationId = registration.id
//
const works = await worksApi.getVersions(registrationId)

View File

@ -355,7 +355,7 @@
<a-descriptions :column="3" bordered style="margin-bottom: 16px">
<a-descriptions-item label="团队名称">{{ currentTeam.teamName }}</a-descriptions-item>
<a-descriptions-item label="队长">{{ currentTeam.leader?.nickname || "-" }}</a-descriptions-item>
<a-descriptions-item label="成员数">{{ currentTeam._count?.members || 0 }}</a-descriptions-item>
<a-descriptions-item label="成员数">{{ teamMembers.length || currentTeam._count?.members || 0 }}</a-descriptions-item>
</a-descriptions>
<a-table
:columns="memberColumns"
@ -778,15 +778,19 @@ const handleViewMembers = async (record: ContestRegistration) => {
message.warning("暂无团队信息")
return
}
currentTeam.value = record.team
membersModalVisible.value = true
membersLoading.value = true
currentTeam.value = null
teamMembers.value = []
try {
const teamDetail = await teamsApi.getDetail(record.team.id)
// leader
currentTeam.value = teamDetail
teamMembers.value = teamDetail.members || []
} catch (error: any) {
message.error("获取团队成员失败")
currentTeam.value = record.team // 使
teamMembers.value = []
} finally {
membersLoading.value = false

View File

@ -74,7 +74,7 @@
</div>
<div class="loading-info">
<div class="loading-title">
{{ task?.status === 'pending' ? '排队中' : 'AI 生成中' }}
{{ task?.status === "pending" ? "排队中" : "AI 生成中" }}
</div>
<div v-if="task?.status === 'pending'" class="loading-text">
<p>
@ -83,9 +83,9 @@
</p>
<p>
预计时间:
<span class="highlight"
>{{ formatEstimatedTime(queueInfo.estimatedTime) }}</span
>
<span class="highlight">{{
formatEstimatedTime(queueInfo.estimatedTime)
}}</span>
</p>
</div>
<div v-else class="loading-text">
@ -290,10 +290,7 @@ const handleCardClick = (index: number) => {
// sessionStorageURL
if (allResultUrls.length > 1) {
sessionStorage.setItem(
"model-viewer-urls",
JSON.stringify(allResultUrls)
)
sessionStorage.setItem("model-viewer-urls", JSON.stringify(allResultUrls))
sessionStorage.setItem("model-viewer-index", String(index))
// URL
sessionStorage.removeItem("model-viewer-url")
@ -777,7 +774,7 @@ $gradient-card: linear-gradient(
}
.progress-bar {
width: 120px;
width: 200px;
height: 4px;
background: rgba($primary, 0.2);
border-radius: 2px;