Compare commits
10 Commits
0cdc5d1ceb
...
435df2bf16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
435df2bf16 | ||
|
|
00491c7ac4 | ||
|
|
fe91f88350 | ||
|
|
f6b292bebf | ||
|
|
2981449353 | ||
|
|
c1f8ac072a | ||
|
|
9f22a20a2a | ||
|
|
1010c764cc | ||
|
|
f96b59e25e | ||
|
|
ac0c38c04a |
2
.gitignore
vendored
2
.gitignore
vendored
@ -31,7 +31,7 @@ build/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs/
|
/logs/
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
|
|||||||
2
backend/.gitignore
vendored
2
backend/.gitignore
vendored
@ -3,7 +3,7 @@
|
|||||||
/node_modules
|
/node_modules
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
/logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|||||||
@ -80,6 +80,7 @@
|
|||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jest": "^29.5.11",
|
"@types/jest": "^29.5.11",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^20.11.5",
|
"@types/node": "^20.11.5",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/passport-local": "^1.0.36",
|
"@types/passport-local": "^1.0.36",
|
||||||
|
|||||||
@ -158,6 +158,8 @@ model User {
|
|||||||
modifiedHomeworkScores HomeworkScore[] @relation("HomeworkScoreModifier")
|
modifiedHomeworkScores HomeworkScore[] @relation("HomeworkScoreModifier")
|
||||||
// AI 3D 生成关联
|
// AI 3D 生成关联
|
||||||
ai3dTasks AI3DTask[] @relation("AI3DTaskUser") /// 用户的 AI 3D 生成任务
|
ai3dTasks AI3DTask[] @relation("AI3DTaskUser") /// 用户的 AI 3D 生成任务
|
||||||
|
// 预设评语关联
|
||||||
|
presetComments PresetComment[] @relation("PresetCommentJudge") /// 评委的预设评语
|
||||||
|
|
||||||
@@unique([tenantId, username])
|
@@unique([tenantId, username])
|
||||||
@@unique([tenantId, email])
|
@@unique([tenantId, email])
|
||||||
@ -592,6 +594,7 @@ model Contest {
|
|||||||
workAssignments ContestWorkJudgeAssignment[] @relation("ContestWorkJudgeAssignmentContest") /// 作品分配
|
workAssignments ContestWorkJudgeAssignment[] @relation("ContestWorkJudgeAssignmentContest") /// 作品分配
|
||||||
workScores ContestWorkScore[] @relation("ContestWorkScoreContest") /// 作品评分
|
workScores ContestWorkScore[] @relation("ContestWorkScoreContest") /// 作品评分
|
||||||
notices ContestNotice[] /// 赛事公告
|
notices ContestNotice[] /// 赛事公告
|
||||||
|
presetComments PresetComment[] /// 预设评语
|
||||||
creatorUser User? @relation("ContestCreator", fields: [creator], references: [id], onDelete: SetNull)
|
creatorUser User? @relation("ContestCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||||
modifierUser User? @relation("ContestModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
modifierUser User? @relation("ContestModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@ -1098,3 +1101,25 @@ model AI3DTask {
|
|||||||
@@index([createTime])
|
@@index([createTime])
|
||||||
@@map("t_ai_3d_task")
|
@@map("t_ai_3d_task")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 预设评语表
|
||||||
|
model PresetComment {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
contestId Int @map("contest_id") /// 赛事ID
|
||||||
|
judgeId Int @map("judge_id") /// 评委用户ID
|
||||||
|
content String @db.Text /// 评语内容
|
||||||
|
score Decimal? @db.Decimal(10, 2) /// 关联评审分数
|
||||||
|
sortOrder Int @default(0) @map("sort_order") /// 排序顺序
|
||||||
|
useCount Int @default(0) @map("use_count") /// 使用次数
|
||||||
|
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
||||||
|
creator Int? /// 创建人ID
|
||||||
|
modifier Int? /// 修改人ID
|
||||||
|
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||||
|
modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间
|
||||||
|
|
||||||
|
contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade)
|
||||||
|
judge User @relation("PresetCommentJudge", fields: [judgeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([contestId, judgeId])
|
||||||
|
@@map("t_preset_comment")
|
||||||
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { ReviewsModule } from './reviews/reviews.module';
|
|||||||
import { NoticesModule } from './notices/notices.module';
|
import { NoticesModule } from './notices/notices.module';
|
||||||
import { JudgesModule } from './judges/judges.module';
|
import { JudgesModule } from './judges/judges.module';
|
||||||
import { ResultsModule } from './results/results.module';
|
import { ResultsModule } from './results/results.module';
|
||||||
|
import { PresetCommentsModule } from './preset-comments/preset-comments.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -23,6 +24,7 @@ import { ResultsModule } from './results/results.module';
|
|||||||
NoticesModule,
|
NoticesModule,
|
||||||
JudgesModule,
|
JudgesModule,
|
||||||
ResultsModule,
|
ResultsModule,
|
||||||
|
PresetCommentsModule,
|
||||||
// ContestsCoreModule 放在最后,因为它有通配符路由 /contests/:id
|
// ContestsCoreModule 放在最后,因为它有通配符路由 /contests/:id
|
||||||
ContestsCoreModule,
|
ContestsCoreModule,
|
||||||
],
|
],
|
||||||
@ -37,6 +39,7 @@ import { ResultsModule } from './results/results.module';
|
|||||||
NoticesModule,
|
NoticesModule,
|
||||||
JudgesModule,
|
JudgesModule,
|
||||||
ResultsModule,
|
ResultsModule,
|
||||||
|
PresetCommentsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ContestsModule {}
|
export class ContestsModule {}
|
||||||
|
|||||||
@ -273,22 +273,23 @@ export class ContestsService {
|
|||||||
// 解析 contestTenants JSON 字符串为数组,并计算评审统计数据
|
// 解析 contestTenants JSON 字符串为数组,并计算评审统计数据
|
||||||
const parsedList = await Promise.all(
|
const parsedList = await Promise.all(
|
||||||
filteredList.map(async (contest) => {
|
filteredList.map(async (contest) => {
|
||||||
// 计算总作品数(已提交的作品)
|
// 计算总作品数(已提交或评审中的作品)
|
||||||
const totalWorksCount = await this.prisma.contestWork.count({
|
const totalWorksCount = await this.prisma.contestWork.count({
|
||||||
where: {
|
where: {
|
||||||
contestId: contest.id,
|
contestId: contest.id,
|
||||||
status: 'submitted',
|
status: { in: ['submitted', 'reviewing'] },
|
||||||
isLatest: true,
|
isLatest: true,
|
||||||
|
validState: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 计算已完成评审的作品数(所有评委都评分的作品)
|
// 计算已完成评审的作品数(有评分记录的作品)
|
||||||
// 简化逻辑:统计有评分记录的作品数
|
|
||||||
const reviewedCount = await this.prisma.contestWork.count({
|
const reviewedCount = await this.prisma.contestWork.count({
|
||||||
where: {
|
where: {
|
||||||
contestId: contest.id,
|
contestId: contest.id,
|
||||||
status: 'submitted',
|
status: { in: ['submitted', 'reviewing'] },
|
||||||
isLatest: true,
|
isLatest: true,
|
||||||
|
validState: 1,
|
||||||
scores: {
|
scores: {
|
||||||
some: {
|
some: {
|
||||||
validState: 1,
|
validState: 1,
|
||||||
@ -302,6 +303,11 @@ export class ContestsService {
|
|||||||
contestTenants: this.parseContestTenants(contest.contestTenants),
|
contestTenants: this.parseContestTenants(contest.contestTenants),
|
||||||
totalWorksCount,
|
totalWorksCount,
|
||||||
reviewedCount,
|
reviewedCount,
|
||||||
|
// 覆盖 _count.works 为正确的统计数据(只统计 isLatest=true 且 validState=1 的作品)
|
||||||
|
_count: {
|
||||||
|
...contest._count,
|
||||||
|
works: totalWorksCount,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -363,6 +369,7 @@ export class ContestsService {
|
|||||||
contestIds = judgeRecords.map((r) => r.contestId);
|
contestIds = judgeRecords.map((r) => r.contestId);
|
||||||
} else if (role === 'teacher') {
|
} else if (role === 'teacher') {
|
||||||
// 教师:查询作为指导老师参与的赛事
|
// 教师:查询作为指导老师参与的赛事
|
||||||
|
// 1. 从报名指导老师关联表查询(个人赛)
|
||||||
const teacherRecords = await this.prisma.contestRegistrationTeacher.findMany({
|
const teacherRecords = await this.prisma.contestRegistrationTeacher.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
@ -375,9 +382,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 {
|
} else {
|
||||||
// 学生/默认:查询报名的赛事
|
// 学生/默认:查询报名的赛事
|
||||||
|
// 1. 从报名记录查询(个人赛报名或团队赛队长)
|
||||||
const registrationWhere: any = {
|
const registrationWhere: any = {
|
||||||
userId,
|
userId,
|
||||||
};
|
};
|
||||||
@ -393,7 +420,32 @@ export class ContestsService {
|
|||||||
},
|
},
|
||||||
distinct: ['contestId'],
|
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) {
|
if (contestIds.length === 0) {
|
||||||
|
|||||||
@ -0,0 +1,20 @@
|
|||||||
|
import { IsInt, IsString, IsOptional, IsNumber, Min } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class CreatePresetCommentDto {
|
||||||
|
@IsInt()
|
||||||
|
contestId: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Type(() => Number)
|
||||||
|
score?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
import { IsInt, IsArray, ArrayMinSize } from 'class-validator';
|
||||||
|
|
||||||
|
export class SyncPresetCommentsDto {
|
||||||
|
@IsInt()
|
||||||
|
sourceContestId: number;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@IsInt({ each: true })
|
||||||
|
targetContestIds: number[];
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
import { IsString, IsOptional, IsNumber, IsInt, Min } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class UpdatePresetCommentDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
content?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Type(() => Number)
|
||||||
|
score?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Patch,
|
||||||
|
Param,
|
||||||
|
Delete,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
ParseIntPipe,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PresetCommentsService } from './preset-comments.service';
|
||||||
|
import { CreatePresetCommentDto } from './dto/create-preset-comment.dto';
|
||||||
|
import { UpdatePresetCommentDto } from './dto/update-preset-comment.dto';
|
||||||
|
import { SyncPresetCommentsDto } from './dto/sync-preset-comments.dto';
|
||||||
|
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
@Controller('contests/preset-comments')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class PresetCommentsController {
|
||||||
|
constructor(private readonly presetCommentsService: PresetCommentsService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() createDto: CreatePresetCommentDto, @Request() req) {
|
||||||
|
const judgeId = req.user?.userId;
|
||||||
|
return this.presetCommentsService.create(createDto, judgeId, judgeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll(
|
||||||
|
@Query('contestId', ParseIntPipe) contestId: number,
|
||||||
|
@Request() req,
|
||||||
|
) {
|
||||||
|
const judgeId = req.user?.userId;
|
||||||
|
return this.presetCommentsService.findAll(contestId, judgeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('judge/contests')
|
||||||
|
getJudgeContests(@Request() req) {
|
||||||
|
const judgeId = req.user?.userId;
|
||||||
|
return this.presetCommentsService.getJudgeContests(judgeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@Param('id', ParseIntPipe) id: number, @Request() req) {
|
||||||
|
const judgeId = req.user?.userId;
|
||||||
|
return this.presetCommentsService.findOne(id, judgeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() updateDto: UpdatePresetCommentDto,
|
||||||
|
@Request() req,
|
||||||
|
) {
|
||||||
|
const judgeId = req.user?.userId;
|
||||||
|
return this.presetCommentsService.update(id, updateDto, judgeId, judgeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@Param('id', ParseIntPipe) id: number, @Request() req) {
|
||||||
|
const judgeId = req.user?.userId;
|
||||||
|
return this.presetCommentsService.remove(id, judgeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('batch-delete')
|
||||||
|
batchDelete(@Body() body: { ids: number[] }, @Request() req) {
|
||||||
|
const judgeId = req.user?.userId;
|
||||||
|
return this.presetCommentsService.batchDelete(body.ids, judgeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sync')
|
||||||
|
sync(@Body() syncDto: SyncPresetCommentsDto, @Request() req) {
|
||||||
|
const judgeId = req.user?.userId;
|
||||||
|
return this.presetCommentsService.sync(syncDto, judgeId, judgeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/use')
|
||||||
|
incrementUseCount(@Param('id', ParseIntPipe) id: number, @Request() req) {
|
||||||
|
const judgeId = req.user?.userId;
|
||||||
|
return this.presetCommentsService.incrementUseCount(id, judgeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PresetCommentsService } from './preset-comments.service';
|
||||||
|
import { PresetCommentsController } from './preset-comments.controller';
|
||||||
|
import { PrismaModule } from '../../prisma/prisma.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [PresetCommentsController],
|
||||||
|
providers: [PresetCommentsService],
|
||||||
|
exports: [PresetCommentsService],
|
||||||
|
})
|
||||||
|
export class PresetCommentsModule {}
|
||||||
234
backend/src/contests/preset-comments/preset-comments.service.ts
Normal file
234
backend/src/contests/preset-comments/preset-comments.service.ts
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ForbiddenException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { CreatePresetCommentDto } from './dto/create-preset-comment.dto';
|
||||||
|
import { UpdatePresetCommentDto } from './dto/update-preset-comment.dto';
|
||||||
|
import { SyncPresetCommentsDto } from './dto/sync-preset-comments.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PresetCommentsService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
createDto: CreatePresetCommentDto,
|
||||||
|
judgeId: number,
|
||||||
|
creatorId?: number,
|
||||||
|
) {
|
||||||
|
// 验证赛事是否存在
|
||||||
|
const contest = await this.prisma.contest.findUnique({
|
||||||
|
where: { id: createDto.contestId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!contest) {
|
||||||
|
throw new NotFoundException('赛事不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证用户是否是该赛事的评委
|
||||||
|
const isJudge = await this.prisma.contestJudge.findFirst({
|
||||||
|
where: {
|
||||||
|
contestId: createDto.contestId,
|
||||||
|
judgeId,
|
||||||
|
validState: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isJudge) {
|
||||||
|
throw new ForbiddenException('您不是该赛事的评委');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.presetComment.create({
|
||||||
|
data: {
|
||||||
|
contestId: createDto.contestId,
|
||||||
|
judgeId,
|
||||||
|
content: createDto.content,
|
||||||
|
score: createDto.score,
|
||||||
|
sortOrder: createDto.sortOrder ?? 0,
|
||||||
|
creator: creatorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(contestId: number, judgeId: number) {
|
||||||
|
return this.prisma.presetComment.findMany({
|
||||||
|
where: {
|
||||||
|
contestId,
|
||||||
|
judgeId,
|
||||||
|
validState: 1,
|
||||||
|
},
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { createTime: 'desc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: number, judgeId: number) {
|
||||||
|
const comment = await this.prisma.presetComment.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
judgeId,
|
||||||
|
validState: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!comment) {
|
||||||
|
throw new NotFoundException('预设评语不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
return comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: number,
|
||||||
|
updateDto: UpdatePresetCommentDto,
|
||||||
|
judgeId: number,
|
||||||
|
modifierId?: number,
|
||||||
|
) {
|
||||||
|
await this.findOne(id, judgeId);
|
||||||
|
|
||||||
|
return this.prisma.presetComment.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...updateDto,
|
||||||
|
modifier: modifierId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: number, judgeId: number) {
|
||||||
|
await this.findOne(id, judgeId);
|
||||||
|
|
||||||
|
return this.prisma.presetComment.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
validState: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchDelete(ids: number[], judgeId: number) {
|
||||||
|
// 验证所有评语都属于该评委
|
||||||
|
const comments = await this.prisma.presetComment.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: ids },
|
||||||
|
judgeId,
|
||||||
|
validState: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (comments.length !== ids.length) {
|
||||||
|
throw new ForbiddenException('部分评语不存在或无权操作');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.presetComment.updateMany({
|
||||||
|
where: {
|
||||||
|
id: { in: ids },
|
||||||
|
judgeId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
validState: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sync(syncDto: SyncPresetCommentsDto, judgeId: number, creatorId?: number) {
|
||||||
|
// 获取源赛事的评语
|
||||||
|
const sourceComments = await this.prisma.presetComment.findMany({
|
||||||
|
where: {
|
||||||
|
contestId: syncDto.sourceContestId,
|
||||||
|
judgeId,
|
||||||
|
validState: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sourceComments.length === 0) {
|
||||||
|
throw new NotFoundException('源赛事没有预设评语');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证目标赛事存在且当前用户是评委
|
||||||
|
for (const targetContestId of syncDto.targetContestIds) {
|
||||||
|
const contest = await this.prisma.contest.findUnique({
|
||||||
|
where: { id: targetContestId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!contest) {
|
||||||
|
throw new NotFoundException(`赛事 ${targetContestId} 不存在`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isJudge = await this.prisma.contestJudge.findFirst({
|
||||||
|
where: {
|
||||||
|
contestId: targetContestId,
|
||||||
|
judgeId,
|
||||||
|
validState: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isJudge) {
|
||||||
|
throw new ForbiddenException(`您不是赛事 ${contest.contestName} 的评委`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为每个目标赛事创建评语副本
|
||||||
|
const results = [];
|
||||||
|
for (const targetContestId of syncDto.targetContestIds) {
|
||||||
|
for (const comment of sourceComments) {
|
||||||
|
const created = await this.prisma.presetComment.create({
|
||||||
|
data: {
|
||||||
|
contestId: targetContestId,
|
||||||
|
judgeId,
|
||||||
|
content: comment.content,
|
||||||
|
score: comment.score,
|
||||||
|
sortOrder: comment.sortOrder,
|
||||||
|
creator: creatorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
results.push(created);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: '同步成功',
|
||||||
|
count: results.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getJudgeContests(judgeId: number) {
|
||||||
|
const contestJudges = await this.prisma.contestJudge.findMany({
|
||||||
|
where: {
|
||||||
|
judgeId,
|
||||||
|
validState: 1,
|
||||||
|
contest: {
|
||||||
|
validState: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
contest: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
contestName: true,
|
||||||
|
contestState: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createTime: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return contestJudges.map((cj) => cj.contest);
|
||||||
|
}
|
||||||
|
|
||||||
|
async incrementUseCount(id: number, judgeId: number) {
|
||||||
|
await this.findOne(id, judgeId);
|
||||||
|
|
||||||
|
return this.prisma.presetComment.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
useCount: {
|
||||||
|
increment: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { RegistrationsService } from './registrations.service';
|
import { RegistrationsService } from './registrations.service';
|
||||||
import { CreateRegistrationDto } from './dto/create-registration.dto';
|
import { CreateRegistrationDto } from './dto/create-registration.dto';
|
||||||
@ -28,9 +29,9 @@ export class RegistrationsController {
|
|||||||
create(@Body() createRegistrationDto: CreateRegistrationDto, @Request() req) {
|
create(@Body() createRegistrationDto: CreateRegistrationDto, @Request() req) {
|
||||||
const tenantId = req.tenantId || req.user?.tenantId;
|
const tenantId = req.tenantId || req.user?.tenantId;
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
throw new Error('无法确定租户信息');
|
throw new BadRequestException('无法确定租户信息');
|
||||||
}
|
}
|
||||||
const creatorId = req.user?.id;
|
const creatorId = req.user?.userId;
|
||||||
return this.registrationsService.create(
|
return this.registrationsService.create(
|
||||||
createRegistrationDto,
|
createRegistrationDto,
|
||||||
tenantId,
|
tenantId,
|
||||||
@ -45,6 +46,22 @@ export class RegistrationsController {
|
|||||||
return this.registrationsService.findAll(queryDto, tenantId);
|
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')
|
@Get(':id')
|
||||||
@RequirePermission('contest:read')
|
@RequirePermission('contest:read')
|
||||||
findOne(@Param('id', ParseIntPipe) id: number, @Request() req) {
|
findOne(@Param('id', ParseIntPipe) id: number, @Request() req) {
|
||||||
@ -60,7 +77,7 @@ export class RegistrationsController {
|
|||||||
@Request() req,
|
@Request() req,
|
||||||
) {
|
) {
|
||||||
const tenantId = req.tenantId || req.user?.tenantId;
|
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);
|
return this.registrationsService.review(id, reviewDto, operatorId, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,9 +90,9 @@ export class RegistrationsController {
|
|||||||
) {
|
) {
|
||||||
const tenantId = req.tenantId || req.user?.tenantId;
|
const tenantId = req.tenantId || req.user?.tenantId;
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
throw new Error('无法确定租户信息');
|
throw new BadRequestException('无法确定租户信息');
|
||||||
}
|
}
|
||||||
const creatorId = req.user?.id;
|
const creatorId = req.user?.userId;
|
||||||
return this.registrationsService.addTeacher(
|
return this.registrationsService.addTeacher(
|
||||||
id,
|
id,
|
||||||
body.teacherUserId,
|
body.teacherUserId,
|
||||||
@ -93,7 +110,7 @@ export class RegistrationsController {
|
|||||||
) {
|
) {
|
||||||
const tenantId = req.tenantId || req.user?.tenantId;
|
const tenantId = req.tenantId || req.user?.tenantId;
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
throw new Error('无法确定租户信息');
|
throw new BadRequestException('无法确定租户信息');
|
||||||
}
|
}
|
||||||
return this.registrationsService.removeTeacher(id, teacherUserId, tenantId);
|
return this.registrationsService.removeTeacher(id, teacherUserId, tenantId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -626,5 +626,114 @@ export class RegistrationsService {
|
|||||||
where: { id: teacherRecord.id },
|
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: {
|
||||||
|
where: {
|
||||||
|
role: { in: ['leader', 'member'] }, // 只查询队长和队员,不包含指导老师
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -537,6 +537,15 @@ export class ResultsService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
scores: {
|
||||||
|
where: { validState: 1 },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
totalScore: true,
|
||||||
|
judgeName: true,
|
||||||
|
scoreTime: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{ finalScore: 'desc' },
|
{ finalScore: 'desc' },
|
||||||
@ -547,6 +556,25 @@ export class ResultsService {
|
|||||||
this.prisma.contestWork.count({ where }),
|
this.prisma.contestWork.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 计算每个作品的评委平均分(用于显示)
|
||||||
|
const enrichedWorks = works.map((work) => {
|
||||||
|
let judgeScore: number | null = work.finalScore
|
||||||
|
? Number(work.finalScore)
|
||||||
|
: null;
|
||||||
|
// 如果没有最终得分但有评分记录,则计算平均分作为评委评分
|
||||||
|
if (judgeScore === null && work.scores && work.scores.length > 0) {
|
||||||
|
const totalScores = work.scores.reduce(
|
||||||
|
(sum, s) => sum + Number(s.totalScore),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
judgeScore = Number((totalScores / work.scores.length).toFixed(2));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...work,
|
||||||
|
judgeScore, // 评委评分(平均分)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contest: {
|
contest: {
|
||||||
id: contest.id,
|
id: contest.id,
|
||||||
@ -554,7 +582,7 @@ export class ResultsService {
|
|||||||
resultState: contest.resultState,
|
resultState: contest.resultState,
|
||||||
resultPublishTime: contest.resultPublishTime,
|
resultPublishTime: contest.resultPublishTime,
|
||||||
},
|
},
|
||||||
list: works,
|
list: enrichedWorks,
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { IsInt, IsObject, IsString, IsOptional, IsNumber, Min, Max } from 'class-validator';
|
import { IsInt, IsArray, IsString, IsOptional, IsNumber, Min, Max } from 'class-validator';
|
||||||
|
|
||||||
export class CreateScoreDto {
|
export class CreateScoreDto {
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@ -7,9 +7,13 @@ export class CreateScoreDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
assignmentId: number;
|
assignmentId: number;
|
||||||
|
|
||||||
@IsObject()
|
@IsArray()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
dimensionScores?: any; // JSON object
|
dimensionScores?: Array<{
|
||||||
|
name: string;
|
||||||
|
score: number;
|
||||||
|
maxScore: number;
|
||||||
|
}>; // 维度评分数组
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
|
|||||||
@ -157,7 +157,7 @@ export class ReviewsService {
|
|||||||
assignmentId: createScoreDto.assignmentId,
|
assignmentId: createScoreDto.assignmentId,
|
||||||
judgeId,
|
judgeId,
|
||||||
judgeName: judge?.nickname || judge?.username || '',
|
judgeName: judge?.nickname || judge?.username || '',
|
||||||
dimensionScores: createScoreDto.dimensionScores || {},
|
dimensionScores: createScoreDto.dimensionScores || [],
|
||||||
totalScore: createScoreDto.totalScore,
|
totalScore: createScoreDto.totalScore,
|
||||||
comments: createScoreDto.comments || '',
|
comments: createScoreDto.comments || '',
|
||||||
scoreTime: new Date(),
|
scoreTime: new Date(),
|
||||||
@ -543,7 +543,8 @@ export class ReviewsService {
|
|||||||
workId: assignment.workId,
|
workId: assignment.workId,
|
||||||
judgeId: assignment.judgeId,
|
judgeId: assignment.judgeId,
|
||||||
judge: assignment.judge,
|
judge: assignment.judge,
|
||||||
score: latestScore?.totalScore ?? null,
|
totalScore: latestScore?.totalScore ?? null,
|
||||||
|
dimensionScores: latestScore?.dimensionScores ?? null,
|
||||||
scoreTime: latestScore?.scoreTime ?? null,
|
scoreTime: latestScore?.scoreTime ?? null,
|
||||||
comments: latestScore?.comments ?? null,
|
comments: latestScore?.comments ?? null,
|
||||||
status: assignment.status,
|
status: assignment.status,
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { TeamsService } from './teams.service';
|
import { TeamsService } from './teams.service';
|
||||||
import { CreateTeamDto } from './dto/create-team.dto';
|
import { CreateTeamDto } from './dto/create-team.dto';
|
||||||
@ -27,9 +28,9 @@ export class TeamsController {
|
|||||||
create(@Body() createTeamDto: CreateTeamDto, @Request() req) {
|
create(@Body() createTeamDto: CreateTeamDto, @Request() req) {
|
||||||
const tenantId = req.tenantId || req.user?.tenantId;
|
const tenantId = req.tenantId || req.user?.tenantId;
|
||||||
if (!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);
|
return this.teamsService.create(createTeamDto, tenantId, creatorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,9 +60,9 @@ export class TeamsController {
|
|||||||
) {
|
) {
|
||||||
const tenantId = req.tenantId || req.user?.tenantId;
|
const tenantId = req.tenantId || req.user?.tenantId;
|
||||||
if (!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);
|
return this.teamsService.update(id, updateTeamDto, tenantId, modifierId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,9 +75,9 @@ export class TeamsController {
|
|||||||
) {
|
) {
|
||||||
const tenantId = req.tenantId || req.user?.tenantId;
|
const tenantId = req.tenantId || req.user?.tenantId;
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
throw new Error('无法确定租户信息');
|
throw new BadRequestException('无法确定租户信息');
|
||||||
}
|
}
|
||||||
const creatorId = req.user?.id;
|
const creatorId = req.user?.userId;
|
||||||
return this.teamsService.inviteMember(
|
return this.teamsService.inviteMember(
|
||||||
teamId,
|
teamId,
|
||||||
inviteMemberDto,
|
inviteMemberDto,
|
||||||
@ -94,7 +95,7 @@ export class TeamsController {
|
|||||||
) {
|
) {
|
||||||
const tenantId = req.tenantId || req.user?.tenantId;
|
const tenantId = req.tenantId || req.user?.tenantId;
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
throw new Error('无法确定租户信息');
|
throw new BadRequestException('无法确定租户信息');
|
||||||
}
|
}
|
||||||
return this.teamsService.removeMember(teamId, userId, tenantId);
|
return this.teamsService.removeMember(teamId, userId, tenantId);
|
||||||
}
|
}
|
||||||
@ -104,7 +105,7 @@ export class TeamsController {
|
|||||||
remove(@Param('id', ParseIntPipe) id: number, @Request() req) {
|
remove(@Param('id', ParseIntPipe) id: number, @Request() req) {
|
||||||
const tenantId = req.tenantId || req.user?.tenantId;
|
const tenantId = req.tenantId || req.user?.tenantId;
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
throw new Error('无法确定租户信息');
|
throw new BadRequestException('无法确定租户信息');
|
||||||
}
|
}
|
||||||
return this.teamsService.remove(id, tenantId);
|
return this.teamsService.remove(id, tenantId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -206,7 +206,7 @@ export class TeamsService {
|
|||||||
where.tenantId = tenantId;
|
where.tenantId = tenantId;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.contestTeam.findMany({
|
const teams = await this.prisma.contestTeam.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: {
|
orderBy: {
|
||||||
createTime: 'desc',
|
createTime: 'desc',
|
||||||
@ -230,6 +230,13 @@ export class TeamsService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
registrations: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
registrationState: true,
|
||||||
|
},
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
members: true,
|
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 = {
|
const where: any = {
|
||||||
id,
|
id,
|
||||||
validState: 1,
|
validState: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (tenantId) {
|
// 只有明确要求严格租户检查时才限制 tenantId
|
||||||
|
// 通过 ID 查询单个团队时,ID 已经是唯一的,不需要再限制 tenantId
|
||||||
|
if (tenantId && strictTenantCheck) {
|
||||||
where.tenantId = tenantId;
|
where.tenantId = tenantId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -268,6 +284,9 @@ export class TeamsService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
members: {
|
members: {
|
||||||
|
where: {
|
||||||
|
role: { in: ['leader', 'member'] }, // 只查询队长和队员,不包含指导老师
|
||||||
|
},
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
@ -1,4 +1,21 @@
|
|||||||
import { IsString, IsInt, IsOptional, IsObject, IsArray } from 'class-validator';
|
import { IsString, IsInt, IsOptional, IsObject, IsArray, ValidateNested } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class AttachmentDto {
|
||||||
|
@IsString()
|
||||||
|
fileName: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
fileUrl: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
fileType?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
size?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class SubmitWorkDto {
|
export class SubmitWorkDto {
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@ -28,5 +45,11 @@ export class SubmitWorkDto {
|
|||||||
@IsObject()
|
@IsObject()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
aiModelMeta?: any;
|
aiModelMeta?: any;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => AttachmentDto)
|
||||||
|
@IsOptional()
|
||||||
|
attachments?: AttachmentDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -120,8 +120,33 @@ export class WorksService {
|
|||||||
creator: submitterUserId,
|
creator: submitterUserId,
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.prisma.contestWork.create({
|
// 使用事务创建作品和附件
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const work = await tx.contestWork.create({
|
||||||
data,
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建附件记录
|
||||||
|
if (submitWorkDto.attachments && submitWorkDto.attachments.length > 0) {
|
||||||
|
for (const attachment of submitWorkDto.attachments) {
|
||||||
|
await tx.contestWorkAttachment.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
contestId: registration.contestId,
|
||||||
|
workId: work.id,
|
||||||
|
fileName: attachment.fileName,
|
||||||
|
fileUrl: attachment.fileUrl,
|
||||||
|
fileType: attachment.fileType,
|
||||||
|
size: attachment.size,
|
||||||
|
creator: submitterUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回完整的作品信息
|
||||||
|
return tx.contestWork.findUnique({
|
||||||
|
where: { id: work.id },
|
||||||
include: {
|
include: {
|
||||||
contest: {
|
contest: {
|
||||||
select: {
|
select: {
|
||||||
@ -143,6 +168,7 @@ export class WorksService {
|
|||||||
attachments: true,
|
attachments: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
22
backend/src/logs/dto/create-log.dto.ts
Normal file
22
backend/src/logs/dto/create-log.dto.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { IsNumber, IsString, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateLogDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
userId?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
action: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
content?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
ip?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
userAgent?: string;
|
||||||
|
}
|
||||||
39
backend/src/logs/dto/query-log.dto.ts
Normal file
39
backend/src/logs/dto/query-log.dto.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { IsNumber, IsString, IsOptional, IsDateString } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class QueryLogDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
pageSize?: number = 20;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
userId?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
action?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
keyword?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
ip?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
startTime?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
endTime?: string;
|
||||||
|
}
|
||||||
64
backend/src/logs/logs.controller.ts
Normal file
64
backend/src/logs/logs.controller.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
ParseIntPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { LogsService } from './logs.service';
|
||||||
|
import { QueryLogDto } from './dto/query-log.dto';
|
||||||
|
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||||
|
|
||||||
|
@Controller('logs')
|
||||||
|
export class LogsController {
|
||||||
|
constructor(private readonly logsService: LogsService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询日志列表(分页)
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('log:read')
|
||||||
|
async findAll(@Query() queryLogDto: QueryLogDto) {
|
||||||
|
return this.logsService.findAll(queryLogDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取日志统计信息
|
||||||
|
*/
|
||||||
|
@Get('statistics')
|
||||||
|
@RequirePermission('log:read')
|
||||||
|
async getStatistics(@Query('days') days?: string) {
|
||||||
|
const daysNum = days ? parseInt(days, 10) : 7;
|
||||||
|
return this.logsService.getStatistics(daysNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询单条日志详情
|
||||||
|
*/
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermission('log:read')
|
||||||
|
async findOne(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.logsService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除日志
|
||||||
|
*/
|
||||||
|
@Delete()
|
||||||
|
@RequirePermission('log:delete')
|
||||||
|
async remove(@Body('ids') ids: number[]) {
|
||||||
|
return this.logsService.remove(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期日志
|
||||||
|
*/
|
||||||
|
@Post('clean')
|
||||||
|
@RequirePermission('log:delete')
|
||||||
|
async cleanOldLogs(@Body('daysToKeep') daysToKeep?: number) {
|
||||||
|
return this.logsService.cleanOldLogs(daysToKeep || 90);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/logs/logs.module.ts
Normal file
13
backend/src/logs/logs.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Module, Global } from '@nestjs/common';
|
||||||
|
import { LogsService } from './logs.service';
|
||||||
|
import { LogsController } from './logs.controller';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
|
@Global() // 设为全局模块,让 LoggingInterceptor 可以注入 LogsService
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [LogsController],
|
||||||
|
providers: [LogsService],
|
||||||
|
exports: [LogsService],
|
||||||
|
})
|
||||||
|
export class LogsModule {}
|
||||||
227
backend/src/logs/logs.service.ts
Normal file
227
backend/src/logs/logs.service.ts
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateLogDto } from './dto/create-log.dto';
|
||||||
|
import { QueryLogDto } from './dto/query-log.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LogsService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建日志记录
|
||||||
|
*/
|
||||||
|
async create(createLogDto: CreateLogDto) {
|
||||||
|
return this.prisma.log.create({
|
||||||
|
data: {
|
||||||
|
userId: createLogDto.userId,
|
||||||
|
action: createLogDto.action,
|
||||||
|
content: createLogDto.content,
|
||||||
|
ip: createLogDto.ip,
|
||||||
|
userAgent: createLogDto.userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询日志列表(分页)
|
||||||
|
*/
|
||||||
|
async findAll(queryLogDto: QueryLogDto) {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
userId,
|
||||||
|
action,
|
||||||
|
keyword,
|
||||||
|
ip,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
} = queryLogDto;
|
||||||
|
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
// 构建查询条件
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
where.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action) {
|
||||||
|
where.action = {
|
||||||
|
contains: action,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyword) {
|
||||||
|
where.OR = [
|
||||||
|
{ action: { contains: keyword } },
|
||||||
|
{ content: { contains: keyword } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ip) {
|
||||||
|
where.ip = {
|
||||||
|
contains: ip,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间范围筛选
|
||||||
|
if (startTime || endTime) {
|
||||||
|
where.createTime = {};
|
||||||
|
if (startTime) {
|
||||||
|
where.createTime.gte = new Date(startTime);
|
||||||
|
}
|
||||||
|
if (endTime) {
|
||||||
|
where.createTime.lte = new Date(endTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [list, total] = await Promise.all([
|
||||||
|
this.prisma.log.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
nickname: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createTime: 'desc' },
|
||||||
|
}),
|
||||||
|
this.prisma.log.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
list,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询单条日志详情
|
||||||
|
*/
|
||||||
|
async findOne(id: number) {
|
||||||
|
return this.prisma.log.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
nickname: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除日志(支持批量删除)
|
||||||
|
*/
|
||||||
|
async remove(ids: number[]) {
|
||||||
|
return this.prisma.log.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: { in: ids },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期日志(默认保留90天)
|
||||||
|
*/
|
||||||
|
async cleanOldLogs(daysToKeep: number = 90) {
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep);
|
||||||
|
|
||||||
|
const result = await this.prisma.log.deleteMany({
|
||||||
|
where: {
|
||||||
|
createTime: {
|
||||||
|
lt: cutoffDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
deleted: result.count,
|
||||||
|
cutoffDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取日志统计信息
|
||||||
|
*/
|
||||||
|
async getStatistics(days: number = 7) {
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setDate(startDate.getDate() - days);
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// 总日志数
|
||||||
|
const totalCount = await this.prisma.log.count();
|
||||||
|
|
||||||
|
// 近N天日志数
|
||||||
|
const recentCount = await this.prisma.log.count({
|
||||||
|
where: {
|
||||||
|
createTime: {
|
||||||
|
gte: startDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按操作类型统计(取前10)
|
||||||
|
const actionStats = await this.prisma.log.groupBy({
|
||||||
|
by: ['action'],
|
||||||
|
_count: {
|
||||||
|
action: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
createTime: {
|
||||||
|
gte: startDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
_count: {
|
||||||
|
action: 'desc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
take: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按天统计近N天的日志数量
|
||||||
|
const dailyStats = await this.prisma.$queryRaw`
|
||||||
|
SELECT
|
||||||
|
DATE(create_time) as date,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM logs
|
||||||
|
WHERE create_time >= ${startDate}
|
||||||
|
GROUP BY DATE(create_time)
|
||||||
|
ORDER BY date DESC
|
||||||
|
` as Array<{ date: Date; count: bigint }>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalCount,
|
||||||
|
recentCount,
|
||||||
|
days,
|
||||||
|
actionStats: actionStats.map((item) => ({
|
||||||
|
action: item.action,
|
||||||
|
count: item._count.action,
|
||||||
|
})),
|
||||||
|
dailyStats: dailyStats.map((item) => ({
|
||||||
|
date: item.date,
|
||||||
|
count: Number(item.count),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户操作日志
|
||||||
|
*/
|
||||||
|
async getUserLogs(userId: number, page: number = 1, pageSize: number = 20) {
|
||||||
|
return this.findAll({ userId, page, pageSize });
|
||||||
|
}
|
||||||
|
}
|
||||||
2
frontend/.gitignore
vendored
2
frontend/.gitignore
vendored
@ -1,5 +1,5 @@
|
|||||||
# Logs
|
# Logs
|
||||||
logs
|
/logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
|
|||||||
@ -257,6 +257,8 @@ export interface ContestTeam {
|
|||||||
createTime?: string;
|
createTime?: string;
|
||||||
modifyTime?: string;
|
modifyTime?: string;
|
||||||
validState?: number;
|
validState?: number;
|
||||||
|
registrationState?: string; // 报名状态
|
||||||
|
registrationId?: number; // 报名记录ID
|
||||||
leader?: {
|
leader?: {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@ -365,6 +367,13 @@ export interface ContestWorkAttachment {
|
|||||||
modifyTime?: string;
|
modifyTime?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubmitWorkAttachment {
|
||||||
|
fileName: string;
|
||||||
|
fileUrl: string;
|
||||||
|
fileType?: string;
|
||||||
|
size?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SubmitWorkForm {
|
export interface SubmitWorkForm {
|
||||||
registrationId: number;
|
registrationId: number;
|
||||||
title: string;
|
title: string;
|
||||||
@ -373,6 +382,7 @@ export interface SubmitWorkForm {
|
|||||||
previewUrl?: string;
|
previewUrl?: string;
|
||||||
previewUrls?: string[];
|
previewUrls?: string[];
|
||||||
aiModelMeta?: any;
|
aiModelMeta?: any;
|
||||||
|
attachments?: SubmitWorkAttachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueryWorkParams extends PaginationParams {
|
export interface QueryWorkParams extends PaginationParams {
|
||||||
@ -730,6 +740,16 @@ export const registrationsApi = {
|
|||||||
return response;
|
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 (
|
create: async (
|
||||||
data: CreateRegistrationForm
|
data: CreateRegistrationForm
|
||||||
|
|||||||
139
frontend/src/api/preset-comments.ts
Normal file
139
frontend/src/api/preset-comments.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
export interface PresetComment {
|
||||||
|
id: number;
|
||||||
|
contestId: number;
|
||||||
|
judgeId: number;
|
||||||
|
content: string;
|
||||||
|
score?: number;
|
||||||
|
sortOrder: number;
|
||||||
|
useCount: number;
|
||||||
|
validState: number;
|
||||||
|
creator?: number;
|
||||||
|
modifier?: number;
|
||||||
|
createTime: string;
|
||||||
|
modifyTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePresetCommentParams {
|
||||||
|
contestId: number;
|
||||||
|
content: string;
|
||||||
|
score?: number;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePresetCommentParams {
|
||||||
|
content?: string;
|
||||||
|
score?: number;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncPresetCommentsParams {
|
||||||
|
sourceContestId: number;
|
||||||
|
targetContestIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JudgeContest {
|
||||||
|
id: number;
|
||||||
|
contestName: string;
|
||||||
|
contestState: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取预设评语列表
|
||||||
|
export async function getPresetCommentsList(
|
||||||
|
contestId: number
|
||||||
|
): Promise<PresetComment[]> {
|
||||||
|
const response = await request.get<any, PresetComment[]>(
|
||||||
|
"/contests/preset-comments",
|
||||||
|
{
|
||||||
|
params: { contestId },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取单个预设评语详情
|
||||||
|
export async function getPresetCommentDetail(
|
||||||
|
id: number
|
||||||
|
): Promise<PresetComment> {
|
||||||
|
const response = await request.get<any, PresetComment>(
|
||||||
|
`/contests/preset-comments/${id}`
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建预设评语
|
||||||
|
export async function createPresetComment(
|
||||||
|
data: CreatePresetCommentParams
|
||||||
|
): Promise<PresetComment> {
|
||||||
|
const response = await request.post<any, PresetComment>(
|
||||||
|
"/contests/preset-comments",
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新预设评语
|
||||||
|
export async function updatePresetComment(
|
||||||
|
id: number,
|
||||||
|
data: UpdatePresetCommentParams
|
||||||
|
): Promise<PresetComment> {
|
||||||
|
const response = await request.patch<any, PresetComment>(
|
||||||
|
`/contests/preset-comments/${id}`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除预设评语
|
||||||
|
export async function deletePresetComment(id: number): Promise<void> {
|
||||||
|
return await request.delete<any, void>(`/contests/preset-comments/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量删除预设评语
|
||||||
|
export async function batchDeletePresetComments(ids: number[]): Promise<void> {
|
||||||
|
return await request.post<any, void>("/contests/preset-comments/batch-delete", {
|
||||||
|
ids,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步预设评语到其他赛事
|
||||||
|
export async function syncPresetComments(
|
||||||
|
data: SyncPresetCommentsParams
|
||||||
|
): Promise<{ message: string; count: number }> {
|
||||||
|
const response = await request.post<any, { message: string; count: number }>(
|
||||||
|
"/contests/preset-comments/sync",
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取评委的赛事列表
|
||||||
|
export async function getJudgeContests(): Promise<JudgeContest[]> {
|
||||||
|
const response = await request.get<any, JudgeContest[]>(
|
||||||
|
"/contests/preset-comments/judge/contests"
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增加使用次数
|
||||||
|
export async function incrementUseCount(id: number): Promise<PresetComment> {
|
||||||
|
const response = await request.post<any, PresetComment>(
|
||||||
|
`/contests/preset-comments/${id}/use`
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容性导出:保留 presetCommentsApi 对象
|
||||||
|
export const presetCommentsApi = {
|
||||||
|
getList: getPresetCommentsList,
|
||||||
|
getDetail: getPresetCommentDetail,
|
||||||
|
create: createPresetComment,
|
||||||
|
update: updatePresetComment,
|
||||||
|
delete: deletePresetComment,
|
||||||
|
batchDelete: batchDeletePresetComments,
|
||||||
|
sync: syncPresetComments,
|
||||||
|
getJudgeContests: getJudgeContests,
|
||||||
|
incrementUseCount: incrementUseCount,
|
||||||
|
};
|
||||||
@ -120,7 +120,6 @@ const baseRoutes: RouteRecordRaw[] = [
|
|||||||
meta: {
|
meta: {
|
||||||
title: "评审进度详情",
|
title: "评审进度详情",
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
permissions: ["review:read"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// 赛果发布详情路由
|
// 赛果发布详情路由
|
||||||
@ -131,7 +130,6 @@ const baseRoutes: RouteRecordRaw[] = [
|
|||||||
meta: {
|
meta: {
|
||||||
title: "赛果发布详情",
|
title: "赛果发布详情",
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
permissions: ["result:read"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// 参赛作品详情列表路由
|
// 参赛作品详情列表路由
|
||||||
@ -188,6 +186,16 @@ const baseRoutes: RouteRecordRaw[] = [
|
|||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// 预设评语页面
|
||||||
|
{
|
||||||
|
path: "activities/preset-comments",
|
||||||
|
name: "PresetComments",
|
||||||
|
component: () => import("@/views/activities/PresetComments.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "预设评语",
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
// 3D建模实验室路由(工作台模块下)
|
// 3D建模实验室路由(工作台模块下)
|
||||||
{
|
{
|
||||||
path: "workbench/3d-lab",
|
path: "workbench/3d-lab",
|
||||||
@ -539,6 +547,15 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
// 如果访问的是主路由,重定向到第一个菜单
|
// 如果访问的是主路由,重定向到第一个菜单
|
||||||
const isMainRoute = to.name === "Main"
|
const isMainRoute = to.name === "Main"
|
||||||
|
|
||||||
|
console.log('Route guard debug:', {
|
||||||
|
targetPath,
|
||||||
|
resolvedName: resolved.name,
|
||||||
|
resolvedPath: resolved.path,
|
||||||
|
isMainRoute,
|
||||||
|
toName: to.name,
|
||||||
|
toPath: to.path,
|
||||||
|
})
|
||||||
|
|
||||||
// 如果解析后的路由不是404,说明路由存在,重新导航
|
// 如果解析后的路由不是404,说明路由存在,重新导航
|
||||||
if (resolved.name !== "NotFound" && !isMainRoute) {
|
if (resolved.name !== "NotFound" && !isMainRoute) {
|
||||||
next({ path: targetPath, replace: true })
|
next({ path: targetPath, replace: true })
|
||||||
|
|||||||
@ -53,6 +53,7 @@ const componentMap: Record<string, () => Promise<any>> = {
|
|||||||
"activities/Review": () => import("@/views/activities/Review.vue"),
|
"activities/Review": () => import("@/views/activities/Review.vue"),
|
||||||
"activities/ReviewDetail": () => import("@/views/activities/ReviewDetail.vue"),
|
"activities/ReviewDetail": () => import("@/views/activities/ReviewDetail.vue"),
|
||||||
"activities/Comments": () => import("@/views/activities/Comments.vue"),
|
"activities/Comments": () => import("@/views/activities/Comments.vue"),
|
||||||
|
"activities/PresetComments": () => import("@/views/activities/PresetComments.vue"),
|
||||||
// 系统管理模块
|
// 系统管理模块
|
||||||
"system/users/Index": () => import("@/views/system/users/Index.vue"),
|
"system/users/Index": () => import("@/views/system/users/Index.vue"),
|
||||||
"system/roles/Index": () => import("@/views/system/roles/Index.vue"),
|
"system/roles/Index": () => import("@/views/system/roles/Index.vue"),
|
||||||
|
|||||||
479
frontend/src/views/activities/PresetComments.vue
Normal file
479
frontend/src/views/activities/PresetComments.vue
Normal file
@ -0,0 +1,479 @@
|
|||||||
|
<template>
|
||||||
|
<div class="preset-comments-page">
|
||||||
|
<a-card class="mb-4">
|
||||||
|
<template #title>预设评语管理</template>
|
||||||
|
<template #extra>
|
||||||
|
<a-space>
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!currentContestId"
|
||||||
|
@click="handleAdd"
|
||||||
|
>
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
新增
|
||||||
|
</a-button>
|
||||||
|
<a-popconfirm
|
||||||
|
title="确定要删除选中的评语吗?"
|
||||||
|
:disabled="selectedRowKeys.length === 0"
|
||||||
|
@confirm="handleBatchDelete"
|
||||||
|
>
|
||||||
|
<a-button danger :disabled="selectedRowKeys.length === 0">
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
删除
|
||||||
|
</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
<a-button
|
||||||
|
:disabled="!currentContestId || dataSource.length === 0"
|
||||||
|
@click="handleOpenSync"
|
||||||
|
>
|
||||||
|
<template #icon><SyncOutlined /></template>
|
||||||
|
同步到其他赛事
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<!-- 数据表格 -->
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="dataSource"
|
||||||
|
:loading="loading"
|
||||||
|
:row-selection="rowSelection"
|
||||||
|
row-key="id"
|
||||||
|
:pagination="false"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record, index }">
|
||||||
|
<template v-if="column.key === 'index'">
|
||||||
|
{{ index + 1 }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'content'">
|
||||||
|
<a-tooltip :title="record.content">
|
||||||
|
<span class="content-cell">{{ record.content }}</span>
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'useCount'">
|
||||||
|
<a-tag v-if="record.useCount > 0" color="blue">
|
||||||
|
{{ record.useCount }}次
|
||||||
|
</a-tag>
|
||||||
|
<span v-else>0次</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'action'">
|
||||||
|
<a-space>
|
||||||
|
<a-button type="link" size="small" @click="handleEdit(record)">
|
||||||
|
编辑
|
||||||
|
</a-button>
|
||||||
|
<a-popconfirm
|
||||||
|
title="确定要删除这条评语吗?"
|
||||||
|
@confirm="handleDelete(record.id)"
|
||||||
|
>
|
||||||
|
<a-button type="link" danger size="small">删除</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
|
||||||
|
<!-- 新增/编辑评语弹框 -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="modalVisible"
|
||||||
|
:title="isEditing ? '编辑评语' : '新增评语'"
|
||||||
|
:confirm-loading="submitLoading"
|
||||||
|
@ok="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
>
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
:label-col="{ span: 6 }"
|
||||||
|
:wrapper-col="{ span: 18 }"
|
||||||
|
>
|
||||||
|
<a-form-item label="评语内容" name="content">
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="form.content"
|
||||||
|
placeholder="请输入评语内容"
|
||||||
|
:rows="4"
|
||||||
|
:maxlength="500"
|
||||||
|
show-count
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
|
||||||
|
<!-- 同步评语弹框 -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="syncModalVisible"
|
||||||
|
title="同步评语到其他赛事"
|
||||||
|
:confirm-loading="syncLoading"
|
||||||
|
@ok="handleSync"
|
||||||
|
@cancel="syncModalVisible = false"
|
||||||
|
>
|
||||||
|
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
|
||||||
|
<a-form-item label="目标赛事">
|
||||||
|
<a-select
|
||||||
|
v-model:value="syncTargetContestIds"
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="请选择要同步到的赛事"
|
||||||
|
style="width: 100%"
|
||||||
|
:options="syncContestOptions"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
<a-alert
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
message="提示"
|
||||||
|
description="同步将把当前赛事的所有预设评语复制到选中的目标赛事中"
|
||||||
|
style="margin-top: 16px"
|
||||||
|
/>
|
||||||
|
</a-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, onMounted } from "vue"
|
||||||
|
import { useRoute } from "vue-router"
|
||||||
|
import { message } from "ant-design-vue"
|
||||||
|
import type { FormInstance, TableProps } from "ant-design-vue"
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
} from "@ant-design/icons-vue"
|
||||||
|
import {
|
||||||
|
presetCommentsApi,
|
||||||
|
type PresetComment,
|
||||||
|
type JudgeContest,
|
||||||
|
} from "@/api/preset-comments"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
// 赛事相关
|
||||||
|
const contestsList = ref<JudgeContest[]>([])
|
||||||
|
const contestsLoading = ref(false)
|
||||||
|
const currentContestId = ref<number | undefined>(undefined)
|
||||||
|
|
||||||
|
// 表格数据
|
||||||
|
const loading = ref(false)
|
||||||
|
const dataSource = ref<PresetComment[]>([])
|
||||||
|
|
||||||
|
// 表格选择
|
||||||
|
const selectedRowKeys = ref<number[]>([])
|
||||||
|
const rowSelection = computed<TableProps["rowSelection"]>(() => ({
|
||||||
|
selectedRowKeys: selectedRowKeys.value,
|
||||||
|
onChange: (keys: any) => {
|
||||||
|
selectedRowKeys.value = keys
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 弹框相关
|
||||||
|
const modalVisible = ref(false)
|
||||||
|
const isEditing = ref(false)
|
||||||
|
const editingId = ref<number | null>(null)
|
||||||
|
const submitLoading = ref(false)
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
|
||||||
|
const form = reactive<{
|
||||||
|
content: string
|
||||||
|
}>({
|
||||||
|
content: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
// 表单验证规则
|
||||||
|
const rules = {
|
||||||
|
content: [{ required: true, message: "请输入评语内容", trigger: "blur" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步弹框相关
|
||||||
|
const syncModalVisible = ref(false)
|
||||||
|
const syncLoading = ref(false)
|
||||||
|
const syncTargetContestIds = ref<number[]>([])
|
||||||
|
|
||||||
|
const syncContestOptions = computed(() => {
|
||||||
|
return contestsList.value
|
||||||
|
.filter((c) => c.id !== currentContestId.value)
|
||||||
|
.map((c) => ({
|
||||||
|
value: c.id,
|
||||||
|
label: c.contestName,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 表格列定义
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: "序号",
|
||||||
|
key: "index",
|
||||||
|
width: 70,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "评语内容",
|
||||||
|
key: "content",
|
||||||
|
dataIndex: "content",
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "使用次数",
|
||||||
|
key: "useCount",
|
||||||
|
dataIndex: "useCount",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "action",
|
||||||
|
width: 150,
|
||||||
|
fixed: "right" as const,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 加载评委的赛事列表
|
||||||
|
const loadContests = async () => {
|
||||||
|
contestsLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = await presetCommentsApi.getJudgeContests()
|
||||||
|
contestsList.value = data
|
||||||
|
|
||||||
|
// 从 URL 参数获取 contestId
|
||||||
|
const urlContestId = route.query.contestId
|
||||||
|
? Number(route.query.contestId)
|
||||||
|
: null
|
||||||
|
|
||||||
|
// 如果 URL 有 contestId 且在列表中存在,选中它;否则选第一个
|
||||||
|
if (urlContestId && data.some((c) => c.id === urlContestId)) {
|
||||||
|
currentContestId.value = urlContestId
|
||||||
|
} else if (data.length > 0) {
|
||||||
|
currentContestId.value = data[0].id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentContestId.value) {
|
||||||
|
loadComments()
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.response?.data?.message || "获取赛事列表失败")
|
||||||
|
} finally {
|
||||||
|
contestsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载评语列表
|
||||||
|
const loadComments = async () => {
|
||||||
|
if (!currentContestId.value) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await presetCommentsApi.getList(currentContestId.value)
|
||||||
|
dataSource.value = data
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.response?.data?.message || "获取评语列表失败")
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增
|
||||||
|
const handleAdd = () => {
|
||||||
|
isEditing.value = false
|
||||||
|
editingId.value = null
|
||||||
|
modalVisible.value = true
|
||||||
|
form.content = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑
|
||||||
|
const handleEdit = (record: PresetComment) => {
|
||||||
|
isEditing.value = true
|
||||||
|
editingId.value = record.id
|
||||||
|
modalVisible.value = true
|
||||||
|
form.content = record.content
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await presetCommentsApi.delete(id)
|
||||||
|
message.success("删除成功")
|
||||||
|
loadComments()
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.response?.data?.message || "删除失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量删除
|
||||||
|
const handleBatchDelete = async () => {
|
||||||
|
if (selectedRowKeys.value.length === 0) return
|
||||||
|
try {
|
||||||
|
await presetCommentsApi.batchDelete(selectedRowKeys.value)
|
||||||
|
message.success("批量删除成功")
|
||||||
|
selectedRowKeys.value = []
|
||||||
|
loadComments()
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.response?.data?.message || "批量删除失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await formRef.value?.validate()
|
||||||
|
submitLoading.value = true
|
||||||
|
|
||||||
|
if (isEditing.value && editingId.value) {
|
||||||
|
await presetCommentsApi.update(editingId.value, {
|
||||||
|
content: form.content,
|
||||||
|
})
|
||||||
|
message.success("编辑成功")
|
||||||
|
} else {
|
||||||
|
await presetCommentsApi.create({
|
||||||
|
contestId: currentContestId.value!,
|
||||||
|
content: form.content,
|
||||||
|
})
|
||||||
|
message.success("创建成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
modalVisible.value = false
|
||||||
|
loadComments()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.errorFields) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message.error(
|
||||||
|
error?.response?.data?.message ||
|
||||||
|
(isEditing.value ? "编辑失败" : "创建失败"),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
submitLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消
|
||||||
|
const handleCancel = () => {
|
||||||
|
modalVisible.value = false
|
||||||
|
formRef.value?.resetFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开同步弹框
|
||||||
|
const handleOpenSync = () => {
|
||||||
|
syncTargetContestIds.value = []
|
||||||
|
syncModalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步评语
|
||||||
|
const handleSync = async () => {
|
||||||
|
if (syncTargetContestIds.value.length === 0) {
|
||||||
|
message.warning("请选择目标赛事")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syncLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await presetCommentsApi.sync({
|
||||||
|
sourceContestId: currentContestId.value!,
|
||||||
|
targetContestIds: syncTargetContestIds.value,
|
||||||
|
})
|
||||||
|
message.success(`${result.message},共同步 ${result.count} 条评语`)
|
||||||
|
syncModalVisible.value = false
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.response?.data?.message || "同步失败")
|
||||||
|
} finally {
|
||||||
|
syncLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadContests()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
// 主色调
|
||||||
|
$primary: #1890ff;
|
||||||
|
$primary-dark: #0958d9;
|
||||||
|
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||||
|
|
||||||
|
.preset-comments-page {
|
||||||
|
// 标题卡片样式
|
||||||
|
:deep(.ant-card) {
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.ant-card-head {
|
||||||
|
border-bottom: none;
|
||||||
|
padding: 16px 24px;
|
||||||
|
|
||||||
|
.ant-card-head-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-card-body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渐变主按钮样式
|
||||||
|
:deep(.ant-btn-primary) {
|
||||||
|
background: $gradient-primary;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 4px 12px rgba($primary, 0.35);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
$primary-dark 0%,
|
||||||
|
darken($primary-dark, 8%) 100%
|
||||||
|
);
|
||||||
|
box-shadow: 0 6px 16px rgba($primary, 0.45);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格样式
|
||||||
|
:deep(.ant-table-wrapper) {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.ant-table {
|
||||||
|
.ant-table-thead > tr > th {
|
||||||
|
background: #fafafa;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-tbody > tr {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover > td {
|
||||||
|
background: rgba($primary, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
> td {
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-pagination {
|
||||||
|
padding: 16px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-cell {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -97,7 +97,11 @@
|
|||||||
v-model:open="reviewModalVisible"
|
v-model:open="reviewModalVisible"
|
||||||
:assignment-id="currentAssignmentId"
|
:assignment-id="currentAssignmentId"
|
||||||
:work-id="currentWorkId"
|
:work-id="currentWorkId"
|
||||||
|
:contest-id="contestId"
|
||||||
|
:work-list="workListForNav"
|
||||||
|
:current-index="currentWorkIndex"
|
||||||
@success="handleReviewSuccess"
|
@success="handleReviewSuccess"
|
||||||
|
@navigate="handleNavigate"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 作品详情弹框 -->
|
<!-- 作品详情弹框 -->
|
||||||
@ -190,6 +194,15 @@ const columns = [
|
|||||||
const reviewModalVisible = ref(false)
|
const reviewModalVisible = ref(false)
|
||||||
const currentAssignmentId = ref<number | null>(null)
|
const currentAssignmentId = ref<number | null>(null)
|
||||||
const currentWorkId = ref<number | null>(null)
|
const currentWorkId = ref<number | null>(null)
|
||||||
|
const currentWorkIndex = ref<number>(0)
|
||||||
|
|
||||||
|
// 用于作品切换导航的列表
|
||||||
|
const workListForNav = computed(() => {
|
||||||
|
return dataSource.value.map((item: any) => ({
|
||||||
|
workId: item.workId,
|
||||||
|
assignmentId: item.id,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
// 作品详情弹框
|
// 作品详情弹框
|
||||||
const workDetailModalVisible = ref(false)
|
const workDetailModalVisible = ref(false)
|
||||||
@ -257,9 +270,22 @@ const handleViewWork = (record: any) => {
|
|||||||
const handleReview = (record: any) => {
|
const handleReview = (record: any) => {
|
||||||
currentAssignmentId.value = record.id
|
currentAssignmentId.value = record.id
|
||||||
currentWorkId.value = record.workId
|
currentWorkId.value = record.workId
|
||||||
|
// 查找当前作品在列表中的索引
|
||||||
|
const index = dataSource.value.findIndex((item: any) => item.id === record.id)
|
||||||
|
currentWorkIndex.value = index >= 0 ? index : 0
|
||||||
reviewModalVisible.value = true
|
reviewModalVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 导航到其他作品
|
||||||
|
const handleNavigate = (index: number) => {
|
||||||
|
const item = dataSource.value[index]
|
||||||
|
if (item) {
|
||||||
|
currentAssignmentId.value = item.id
|
||||||
|
currentWorkId.value = item.workId
|
||||||
|
currentWorkIndex.value = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 评审成功回调
|
// 评审成功回调
|
||||||
const handleReviewSuccess = () => {
|
const handleReviewSuccess = () => {
|
||||||
fetchList()
|
fetchList()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -105,17 +105,18 @@
|
|||||||
|
|
||||||
<!-- 底部区域 -->
|
<!-- 底部区域 -->
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
|
<div class="status-row">
|
||||||
<span
|
<span
|
||||||
class="status-dot"
|
class="status-dot"
|
||||||
:class="{ 'status-ongoing': contest.status === 'ongoing' }"
|
:class="{ 'status-ongoing': contest.status === 'ongoing' }"
|
||||||
></span>
|
></span>
|
||||||
<span class="status-text">{{ getStatusText(contest) }}</span>
|
<span class="status-text">{{ getStatusText(contest) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 操作按钮区域 - 我的赛事tab显示 -->
|
<!-- 操作按钮区域 - 我的赛事tab显示 -->
|
||||||
<div v-if="activeTab === 'my'" class="card-actions" @click.stop>
|
<div v-if="activeTab === 'my'" class="card-actions" @click.stop>
|
||||||
<!-- 学生角色按钮 -->
|
<!-- 学生角色按钮 -->
|
||||||
<template v-if="userRole === 'student'">
|
<template v-if="userRole === 'student'">
|
||||||
<template v-if="contest.contestType === 'individual'">
|
|
||||||
<a-button
|
<a-button
|
||||||
v-if="isSubmitting(contest)"
|
v-if="isSubmitting(contest)"
|
||||||
type="primary"
|
type="primary"
|
||||||
@ -127,16 +128,14 @@
|
|||||||
<a-button size="small" @click="handleViewWorks(contest.id)">
|
<a-button size="small" @click="handleViewWorks(contest.id)">
|
||||||
参赛作品
|
参赛作品
|
||||||
</a-button>
|
</a-button>
|
||||||
</template>
|
<a-button
|
||||||
<template v-else>
|
v-if="contest.contestType === 'team'"
|
||||||
<a-button size="small" @click="handleViewWorks(contest.id)">
|
size="small"
|
||||||
参赛作品
|
@click="handleViewTeam(contest)"
|
||||||
</a-button>
|
>
|
||||||
<a-button size="small" @click="handleViewTeam(contest.id)">
|
|
||||||
我的队伍
|
我的队伍
|
||||||
</a-button>
|
</a-button>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 教师角色按钮 -->
|
<!-- 教师角色按钮 -->
|
||||||
<template v-if="userRole === 'teacher'">
|
<template v-if="userRole === 'teacher'">
|
||||||
@ -199,6 +198,52 @@
|
|||||||
v-model:open="viewWorkDrawerVisible"
|
v-model:open="viewWorkDrawerVisible"
|
||||||
:contest-id="currentContestIdForView"
|
: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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -216,8 +261,10 @@ import {
|
|||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import {
|
import {
|
||||||
contestsApi,
|
contestsApi,
|
||||||
|
registrationsApi,
|
||||||
type Contest,
|
type Contest,
|
||||||
type QueryContestParams,
|
type QueryContestParams,
|
||||||
|
type ContestTeam,
|
||||||
} from "@/api/contests"
|
} from "@/api/contests"
|
||||||
import { useAuthStore } from "@/stores/auth"
|
import { useAuthStore } from "@/stores/auth"
|
||||||
import SubmitWorkDrawer from "./components/SubmitWorkDrawer.vue"
|
import SubmitWorkDrawer from "./components/SubmitWorkDrawer.vue"
|
||||||
@ -374,9 +421,59 @@ const handleViewWorks = (id: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 查看我的队伍
|
// 查看我的队伍
|
||||||
const handleViewTeam = (id: number) => {
|
const teamModalVisible = ref(false)
|
||||||
// TODO: 跳转到我的队伍页面或打开抽屉
|
const teamLoading = ref(false)
|
||||||
message.info("查看我的队伍功能开发中")
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 教师相关操作 =====
|
// ===== 教师相关操作 =====
|
||||||
@ -393,7 +490,11 @@ const handleReviewWorks = (id: number) => {
|
|||||||
|
|
||||||
// 预设评语
|
// 预设评语
|
||||||
const handlePresetComments = (id: number) => {
|
const handlePresetComments = (id: number) => {
|
||||||
router.push(`/${tenantCode}/student-activities/comments?contestId=${id}`)
|
console.log(
|
||||||
|
"预设评审",
|
||||||
|
`/${tenantCode}/activities/preset-comments?contestId=${id}`,
|
||||||
|
)
|
||||||
|
router.push(`/${tenantCode}/activities/preset-comments?contestId=${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 图片加载错误记录
|
// 图片加载错误记录
|
||||||
@ -797,11 +898,17 @@ $primary-light: #40a9ff;
|
|||||||
|
|
||||||
.card-footer {
|
.card-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
border-top: 1px solid #f5f5f5;
|
border-top: 1px solid #f5f5f5;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
|
|
||||||
|
.status-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
@ -823,16 +930,15 @@ $primary-light: #40a9ff;
|
|||||||
.card-actions {
|
.card-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
margin-left: auto;
|
|
||||||
|
|
||||||
// 渐变主要按钮 - 蓝色系
|
// 渐变主要按钮 - 蓝色系
|
||||||
:deep(.ant-btn-primary) {
|
:deep(.ant-btn-primary) {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 16px;
|
border-radius: 14px;
|
||||||
padding: 6px 16px;
|
padding: 4px 12px;
|
||||||
height: auto;
|
height: auto;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
$primary 0%,
|
$primary 0%,
|
||||||
@ -860,10 +966,10 @@ $primary-light: #40a9ff;
|
|||||||
// 渐变次要按钮
|
// 渐变次要按钮
|
||||||
:deep(.ant-btn-default) {
|
:deep(.ant-btn-default) {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 16px;
|
border-radius: 14px;
|
||||||
padding: 6px 16px;
|
padding: 4px 12px;
|
||||||
height: auto;
|
height: auto;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf0 100%);
|
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf0 100%);
|
||||||
color: rgba(0, 0, 0, 0.75);
|
color: rgba(0, 0, 0, 0.75);
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||||
@ -895,6 +1001,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) {
|
@media (max-width: 768px) {
|
||||||
.contests-activities-page {
|
.contests-activities-page {
|
||||||
|
|||||||
@ -360,23 +360,13 @@ const fetchRegistrationId = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await registrationsApi.getList({
|
// 获取用户的报名记录(包括作为团队成员的情况)
|
||||||
contestId: props.contestId,
|
const registration = await registrationsApi.getMyRegistration(props.contestId)
|
||||||
userId: authStore.user.id,
|
|
||||||
page: 1,
|
|
||||||
pageSize: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.list && response.list.length > 0) {
|
if (registration) {
|
||||||
const registration = response.list[0]
|
|
||||||
if (registration.registrationState === "passed") {
|
|
||||||
registrationIdRef.value = registration.id
|
registrationIdRef.value = registration.id
|
||||||
} else {
|
} else {
|
||||||
message.warning("您的报名尚未通过审核,无法上传作品")
|
message.warning("您尚未报名该赛事或报名未通过,无法上传作品")
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
message.warning("您尚未报名该赛事,无法上传作品")
|
|
||||||
visible.value = false
|
visible.value = false
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -563,7 +553,6 @@ const handleSubmit = async () => {
|
|||||||
let modelFiles: string[] = []
|
let modelFiles: string[] = []
|
||||||
let previewUrl = ""
|
let previewUrl = ""
|
||||||
let previewUrlsList: string[] = []
|
let previewUrlsList: string[] = []
|
||||||
const attachmentUrls: string[] = []
|
|
||||||
|
|
||||||
if (uploadMode.value === "history") {
|
if (uploadMode.value === "history") {
|
||||||
// 从创作历史选择模式
|
// 从创作历史选择模式
|
||||||
@ -621,11 +610,22 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传附件
|
// 上传附件并收集附件信息
|
||||||
|
const attachments: Array<{
|
||||||
|
fileName: string
|
||||||
|
fileUrl: string
|
||||||
|
fileType?: string
|
||||||
|
size?: string
|
||||||
|
}> = []
|
||||||
for (const file of form.attachmentFiles) {
|
for (const file of form.attachmentFiles) {
|
||||||
try {
|
try {
|
||||||
const url = await uploadFile(file)
|
const url = await uploadFile(file)
|
||||||
attachmentUrls.push(url)
|
attachments.push({
|
||||||
|
fileName: file.name,
|
||||||
|
fileUrl: url,
|
||||||
|
fileType: file.type || undefined,
|
||||||
|
size: String(file.size),
|
||||||
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("附件上传失败:", error)
|
console.error("附件上传失败:", error)
|
||||||
}
|
}
|
||||||
@ -635,9 +635,10 @@ const handleSubmit = async () => {
|
|||||||
registrationId: registrationIdRef.value,
|
registrationId: registrationIdRef.value,
|
||||||
title: form.title,
|
title: form.title,
|
||||||
description: form.description,
|
description: form.description,
|
||||||
files: [...modelFiles, ...attachmentUrls],
|
files: modelFiles, // 只包含模型文件,不包含附件
|
||||||
previewUrl: previewUrl,
|
previewUrl: previewUrl,
|
||||||
previewUrls: previewUrlsList.length > 0 ? previewUrlsList : undefined,
|
previewUrls: previewUrlsList.length > 0 ? previewUrlsList : undefined,
|
||||||
|
attachments: attachments.length > 0 ? attachments : undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
await worksApi.submit(submitData)
|
await worksApi.submit(submitData)
|
||||||
|
|||||||
@ -205,23 +205,16 @@ const fetchUserWork = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 先获取用户的报名记录
|
// 获取用户的报名记录(包括作为团队成员的情况)
|
||||||
const registrationResponse = await registrationsApi.getList({
|
const registration = await registrationsApi.getMyRegistration(props.contestId)
|
||||||
contestId: props.contestId,
|
|
||||||
userId: userId,
|
|
||||||
registrationType: "individual",
|
|
||||||
registrationState: "passed",
|
|
||||||
page: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (registrationResponse.list.length === 0) {
|
if (!registration) {
|
||||||
message.warning("您尚未报名该赛事或报名未通过,无法查看作品")
|
message.warning("您尚未报名该赛事或报名未通过,无法查看作品")
|
||||||
visible.value = false
|
visible.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const registrationId = registrationResponse.list[0].id
|
const registrationId = registration.id
|
||||||
|
|
||||||
// 获取该报名的所有作品版本,取最新版本
|
// 获取该报名的所有作品版本,取最新版本
|
||||||
const works = await worksApi.getVersions(registrationId)
|
const works = await worksApi.getVersions(registrationId)
|
||||||
|
|||||||
@ -98,8 +98,8 @@
|
|||||||
<div class="review-card">
|
<div class="review-card">
|
||||||
<div class="review-item">
|
<div class="review-item">
|
||||||
<span class="review-label">作品评分:</span>
|
<span class="review-label">作品评分:</span>
|
||||||
<span v-if="record.score !== null && record.score !== undefined" class="review-score">
|
<span v-if="record.totalScore !== null && record.totalScore !== undefined" class="review-score">
|
||||||
{{ record.score }} 分
|
{{ record.totalScore }} 分
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="not-reviewed">未评审</span>
|
<span v-else class="not-reviewed">未评审</span>
|
||||||
</div>
|
</div>
|
||||||
@ -113,9 +113,9 @@
|
|||||||
{{ record.scoreTime ? formatDateTime(record.scoreTime) : '-' }}
|
{{ record.scoreTime ? formatDateTime(record.scoreTime) : '-' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="record.comment" class="review-item comment">
|
<div v-if="record.comments" class="review-item comment">
|
||||||
<span class="review-label">老师评语:</span>
|
<span class="review-label">老师评语:</span>
|
||||||
<span class="review-value">{{ record.comment }}</span>
|
<span class="review-value">{{ record.comments }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
@ -174,6 +174,21 @@ const drawerTitle = computed(() => {
|
|||||||
return "作品详情"
|
return "作品详情"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 解析 files 字段(可能是 JSON 字符串)
|
||||||
|
const parsedFiles = computed(() => {
|
||||||
|
if (!workDetail.value) return []
|
||||||
|
let files = workDetail.value.files || []
|
||||||
|
if (typeof files === "string") {
|
||||||
|
try {
|
||||||
|
files = JSON.parse(files)
|
||||||
|
} catch {
|
||||||
|
files = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Array.isArray(files)) files = []
|
||||||
|
return files
|
||||||
|
})
|
||||||
|
|
||||||
// 预览图URL
|
// 预览图URL
|
||||||
const previewImageUrl = computed(() => {
|
const previewImageUrl = computed(() => {
|
||||||
if (!workDetail.value) return ""
|
if (!workDetail.value) return ""
|
||||||
@ -182,8 +197,8 @@ const previewImageUrl = computed(() => {
|
|||||||
return workDetail.value.previewUrl
|
return workDetail.value.previewUrl
|
||||||
}
|
}
|
||||||
// 其次从 files 数组中查找图片
|
// 其次从 files 数组中查找图片
|
||||||
const imageFromFiles = workDetail.value.files?.find(
|
const imageFromFiles = parsedFiles.value.find(
|
||||||
(url) => /\.(jpg|jpeg|png|gif|webp)$/i.test(url)
|
(url: string) => /\.(jpg|jpeg|png|gif|webp)$/i.test(url)
|
||||||
)
|
)
|
||||||
if (imageFromFiles) return imageFromFiles
|
if (imageFromFiles) return imageFromFiles
|
||||||
// 最后从 attachments 中查找
|
// 最后从 attachments 中查找
|
||||||
@ -204,7 +219,7 @@ const isModelFile = (urlOrFileName: string): boolean => {
|
|||||||
const hasModelFile = computed(() => {
|
const hasModelFile = computed(() => {
|
||||||
if (!workDetail.value) return false
|
if (!workDetail.value) return false
|
||||||
// 检查 files 数组
|
// 检查 files 数组
|
||||||
const hasInFiles = workDetail.value.files?.some((url) => isModelFile(url))
|
const hasInFiles = parsedFiles.value.some((url: string) => isModelFile(url))
|
||||||
if (hasInFiles) return true
|
if (hasInFiles) return true
|
||||||
// 检查 attachments 数组
|
// 检查 attachments 数组
|
||||||
const hasInAttachments = workDetail.value.attachments?.some(
|
const hasInAttachments = workDetail.value.attachments?.some(
|
||||||
@ -217,7 +232,7 @@ const hasModelFile = computed(() => {
|
|||||||
const modelFileUrl = computed(() => {
|
const modelFileUrl = computed(() => {
|
||||||
if (!workDetail.value) return ""
|
if (!workDetail.value) return ""
|
||||||
// 优先从 files 数组中查找
|
// 优先从 files 数组中查找
|
||||||
const modelFromFiles = workDetail.value.files?.find((url) => isModelFile(url))
|
const modelFromFiles = parsedFiles.value.find((url: string) => isModelFile(url))
|
||||||
if (modelFromFiles) return modelFromFiles
|
if (modelFromFiles) return modelFromFiles
|
||||||
// 其次从 attachments 中查找
|
// 其次从 attachments 中查找
|
||||||
const modelAtt = workDetail.value.attachments?.find(
|
const modelAtt = workDetail.value.attachments?.find(
|
||||||
@ -272,12 +287,27 @@ const handleImageError = (e: Event) => {
|
|||||||
const handleView3DModel = () => {
|
const handleView3DModel = () => {
|
||||||
const tenantCode = route.params.tenantCode as string
|
const tenantCode = route.params.tenantCode as string
|
||||||
console.log("3D模型预览 - modelFileUrl:", modelFileUrl.value)
|
console.log("3D模型预览 - modelFileUrl:", modelFileUrl.value)
|
||||||
console.log("3D模型预览 - files:", workDetail.value?.files)
|
console.log("3D模型预览 - parsedFiles:", parsedFiles.value)
|
||||||
console.log("3D模型预览 - attachments:", workDetail.value?.attachments)
|
console.log("3D模型预览 - attachments:", workDetail.value?.attachments)
|
||||||
if (modelFileUrl.value) {
|
if (modelFileUrl.value) {
|
||||||
const url = `/${tenantCode}/workbench/model-viewer?url=${encodeURIComponent(modelFileUrl.value)}`
|
// 收集所有3D模型URL
|
||||||
console.log("3D模型预览 - 跳转URL:", url)
|
const allModelUrls = parsedFiles.value.filter((url: string) => isModelFile(url))
|
||||||
window.open(url, "_blank")
|
|
||||||
|
// 使用 sessionStorage 存储模型URL(与学生端保持一致)
|
||||||
|
if (allModelUrls.length > 1) {
|
||||||
|
sessionStorage.setItem("model-viewer-urls", JSON.stringify(allModelUrls))
|
||||||
|
sessionStorage.setItem("model-viewer-index", "0")
|
||||||
|
sessionStorage.removeItem("model-viewer-url")
|
||||||
|
} else {
|
||||||
|
sessionStorage.setItem("model-viewer-url", modelFileUrl.value)
|
||||||
|
sessionStorage.removeItem("model-viewer-urls")
|
||||||
|
sessionStorage.removeItem("model-viewer-index")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 router.push 跳转
|
||||||
|
router.push({
|
||||||
|
path: `/${tenantCode}/workbench/model-viewer`,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
message.warning("未找到3D模型文件")
|
message.warning("未找到3D模型文件")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -355,7 +355,7 @@
|
|||||||
<a-descriptions :column="3" bordered style="margin-bottom: 16px">
|
<a-descriptions :column="3" bordered style="margin-bottom: 16px">
|
||||||
<a-descriptions-item label="团队名称">{{ currentTeam.teamName }}</a-descriptions-item>
|
<a-descriptions-item label="团队名称">{{ currentTeam.teamName }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="队长">{{ currentTeam.leader?.nickname || "-" }}</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-descriptions>
|
||||||
<a-table
|
<a-table
|
||||||
:columns="memberColumns"
|
:columns="memberColumns"
|
||||||
@ -778,15 +778,19 @@ const handleViewMembers = async (record: ContestRegistration) => {
|
|||||||
message.warning("暂无团队信息")
|
message.warning("暂无团队信息")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
currentTeam.value = record.team
|
|
||||||
membersModalVisible.value = true
|
membersModalVisible.value = true
|
||||||
membersLoading.value = true
|
membersLoading.value = true
|
||||||
|
currentTeam.value = null
|
||||||
|
teamMembers.value = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const teamDetail = await teamsApi.getDetail(record.team.id)
|
const teamDetail = await teamsApi.getDetail(record.team.id)
|
||||||
|
// 更新完整的团队信息(包含 leader 和成员数)
|
||||||
|
currentTeam.value = teamDetail
|
||||||
teamMembers.value = teamDetail.members || []
|
teamMembers.value = teamDetail.members || []
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error("获取团队成员失败")
|
message.error("获取团队成员失败")
|
||||||
|
currentTeam.value = record.team // 降级使用列表中的数据
|
||||||
teamMembers.value = []
|
teamMembers.value = []
|
||||||
} finally {
|
} finally {
|
||||||
membersLoading.value = false
|
membersLoading.value = false
|
||||||
|
|||||||
@ -72,7 +72,10 @@
|
|||||||
<a @click="handleViewWorkDetail(record)">{{ record.workNo || "-" }}</a>
|
<a @click="handleViewWorkDetail(record)">{{ record.workNo || "-" }}</a>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'finalScore'">
|
<template v-else-if="column.key === 'finalScore'">
|
||||||
<span v-if="record.finalScore !== null" class="score">
|
<span v-if="record.judgeScore !== null && record.judgeScore !== undefined" class="score">
|
||||||
|
{{ Number(record.judgeScore).toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="record.finalScore !== null" class="score">
|
||||||
{{ Number(record.finalScore).toFixed(2) }}
|
{{ Number(record.finalScore).toFixed(2) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
|
|||||||
@ -51,7 +51,7 @@
|
|||||||
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
|
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'contestName'">
|
<template v-else-if="column.key === 'contestName'">
|
||||||
<a @click="handleViewDetail(record)">{{ record.contestName }}</a>
|
{{ record.contestName }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'registrationCount'">
|
<template v-else-if="column.key === 'registrationCount'">
|
||||||
{{ record._count?.registrations || 0 }}
|
{{ record._count?.registrations || 0 }}
|
||||||
@ -230,7 +230,11 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
|||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: linear-gradient(135deg, $primary-dark 0%, darken($primary-dark, 8%) 100%);
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
$primary-dark 0%,
|
||||||
|
darken($primary-dark, 8%) 100%
|
||||||
|
);
|
||||||
box-shadow: 0 6px 16px rgba($primary, 0.45);
|
box-shadow: 0 6px 16px rgba($primary, 0.45);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -134,8 +134,8 @@
|
|||||||
{{ record.judge?.tenant?.name || "-" }}
|
{{ record.judge?.tenant?.name || "-" }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'score'">
|
<template v-else-if="column.key === 'score'">
|
||||||
<span v-if="record.score !== undefined && record.score !== null">
|
<span v-if="record.totalScore !== undefined && record.totalScore !== null">
|
||||||
{{ record.score }}
|
{{ record.totalScore }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-gray">未评分</span>
|
<span v-else class="text-gray">未评分</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
504
frontend/src/views/system/logs/Index.vue
Normal file
504
frontend/src/views/system/logs/Index.vue
Normal file
@ -0,0 +1,504 @@
|
|||||||
|
<template>
|
||||||
|
<div class="logs-page">
|
||||||
|
<!-- 统计卡片 -->
|
||||||
|
<a-row :gutter="16" class="mb-4">
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-card>
|
||||||
|
<a-statistic
|
||||||
|
title="日志总数"
|
||||||
|
:value="statistics.totalCount"
|
||||||
|
:loading="statsLoading"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<FileTextOutlined />
|
||||||
|
</template>
|
||||||
|
</a-statistic>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-card>
|
||||||
|
<a-statistic
|
||||||
|
:title="`近${statistics.days}天日志`"
|
||||||
|
:value="statistics.recentCount"
|
||||||
|
:loading="statsLoading"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<ClockCircleOutlined />
|
||||||
|
</template>
|
||||||
|
</a-statistic>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-card>
|
||||||
|
<a-statistic
|
||||||
|
title="操作类型数"
|
||||||
|
:value="statistics.actionStats.length"
|
||||||
|
:loading="statsLoading"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<TagOutlined />
|
||||||
|
</template>
|
||||||
|
</a-statistic>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-card>
|
||||||
|
<a-statistic
|
||||||
|
title="今日日志"
|
||||||
|
:value="todayCount"
|
||||||
|
:loading="statsLoading"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<CalendarOutlined />
|
||||||
|
</template>
|
||||||
|
</a-statistic>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<!-- 搜索区域 -->
|
||||||
|
<a-card class="mb-4">
|
||||||
|
<a-form layout="inline" :model="searchForm" class="search-form">
|
||||||
|
<a-form-item label="关键字">
|
||||||
|
<a-input
|
||||||
|
v-model:value="searchForm.keyword"
|
||||||
|
placeholder="搜索操作/内容"
|
||||||
|
allow-clear
|
||||||
|
style="width: 200px"
|
||||||
|
@press-enter="handleSearch"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="操作类型">
|
||||||
|
<a-input
|
||||||
|
v-model:value="searchForm.action"
|
||||||
|
placeholder="如: GET /api/users"
|
||||||
|
allow-clear
|
||||||
|
style="width: 200px"
|
||||||
|
@press-enter="handleSearch"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="IP地址">
|
||||||
|
<a-input
|
||||||
|
v-model:value="searchForm.ip"
|
||||||
|
placeholder="搜索IP"
|
||||||
|
allow-clear
|
||||||
|
style="width: 150px"
|
||||||
|
@press-enter="handleSearch"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="时间范围">
|
||||||
|
<a-range-picker
|
||||||
|
v-model:value="dateRange"
|
||||||
|
:placeholder="['开始时间', '结束时间']"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
style="width: 240px"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<a-space>
|
||||||
|
<a-button type="primary" @click="handleSearch">
|
||||||
|
<template #icon><SearchOutlined /></template>
|
||||||
|
搜索
|
||||||
|
</a-button>
|
||||||
|
<a-button @click="handleReset">
|
||||||
|
<template #icon><ReloadOutlined /></template>
|
||||||
|
重置
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<!-- 操作栏和表格 -->
|
||||||
|
<a-card>
|
||||||
|
<template #title>日志列表</template>
|
||||||
|
<template #extra>
|
||||||
|
<a-space>
|
||||||
|
<a-button
|
||||||
|
v-permission="'log:delete'"
|
||||||
|
:disabled="selectedRowKeys.length === 0"
|
||||||
|
danger
|
||||||
|
@click="handleBatchDelete"
|
||||||
|
>
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
批量删除 {{ selectedRowKeys.length > 0 ? `(${selectedRowKeys.length})` : '' }}
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
v-permission="'log:delete'"
|
||||||
|
type="primary"
|
||||||
|
danger
|
||||||
|
ghost
|
||||||
|
@click="handleCleanOldLogs"
|
||||||
|
>
|
||||||
|
<template #icon><ClearOutlined /></template>
|
||||||
|
清理过期日志
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="dataSource"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="pagination"
|
||||||
|
:row-selection="{ selectedRowKeys, onChange: onSelectChange }"
|
||||||
|
:row-key="(record) => record.id"
|
||||||
|
@change="handleTableChange"
|
||||||
|
:scroll="{ x: 1200 }"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'user'">
|
||||||
|
<span v-if="record.user">
|
||||||
|
{{ record.user.nickname || record.user.username }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-gray-400">-</span>
|
||||||
|
</template>
|
||||||
|
<template v-if="column.key === 'action'">
|
||||||
|
<a-tag :color="getActionColor(record.action)">
|
||||||
|
{{ record.action }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-if="column.key === 'content'">
|
||||||
|
<a-typography-paragraph
|
||||||
|
:ellipsis="{ rows: 1, expandable: false }"
|
||||||
|
:content="record.content || '-'"
|
||||||
|
style="margin: 0; max-width: 300px"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-if="column.key === 'ip'">
|
||||||
|
<span>{{ record.ip || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-if="column.key === 'createTime'">
|
||||||
|
<span>{{ formatDate(record.createTime) }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-if="column.key === 'operations'">
|
||||||
|
<a-button type="link" size="small" @click="handleViewDetail(record)">
|
||||||
|
详情
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<!-- 日志详情弹窗 -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="detailModalVisible"
|
||||||
|
title="日志详情"
|
||||||
|
:footer="null"
|
||||||
|
width="700px"
|
||||||
|
>
|
||||||
|
<a-descriptions :column="2" bordered v-if="currentLog">
|
||||||
|
<a-descriptions-item label="日志ID">{{ currentLog.id }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="操作时间">{{ formatDate(currentLog.createTime) }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="操作用户">
|
||||||
|
{{ currentLog.user?.nickname || currentLog.user?.username || '-' }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="用户ID">{{ currentLog.userId || '-' }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="操作类型" :span="2">
|
||||||
|
<a-tag :color="getActionColor(currentLog.action)">{{ currentLog.action }}</a-tag>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="IP地址">{{ currentLog.ip || '-' }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="User-Agent" :span="2">
|
||||||
|
<a-typography-paragraph
|
||||||
|
:ellipsis="{ rows: 2, expandable: true }"
|
||||||
|
:content="currentLog.userAgent || '-'"
|
||||||
|
style="margin: 0"
|
||||||
|
/>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="操作内容" :span="2">
|
||||||
|
<div class="log-content-wrapper">
|
||||||
|
<pre class="log-content">{{ formatContent(currentLog.content) }}</pre>
|
||||||
|
</div>
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</a-modal>
|
||||||
|
|
||||||
|
<!-- 清理过期日志弹窗 -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="cleanModalVisible"
|
||||||
|
title="清理过期日志"
|
||||||
|
:confirm-loading="cleanLoading"
|
||||||
|
@ok="handleConfirmClean"
|
||||||
|
>
|
||||||
|
<a-form :label-col="{ span: 8 }" :wrapper-col="{ span: 14 }">
|
||||||
|
<a-form-item label="保留天数">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="cleanDays"
|
||||||
|
:min="7"
|
||||||
|
:max="365"
|
||||||
|
style="width: 150px"
|
||||||
|
/>
|
||||||
|
<span class="ml-2">天</span>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item :wrapper-col="{ offset: 8 }">
|
||||||
|
<a-alert
|
||||||
|
type="warning"
|
||||||
|
:message="`将删除 ${cleanDays} 天前的所有日志,此操作不可恢复!`"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
|
import { message, Modal } from 'ant-design-vue'
|
||||||
|
import type { TableColumnsType } from 'ant-design-vue'
|
||||||
|
import type { Dayjs } from 'dayjs'
|
||||||
|
import {
|
||||||
|
FileTextOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
TagOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
ClearOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
import {
|
||||||
|
logsApi,
|
||||||
|
type Log,
|
||||||
|
type LogQueryParams,
|
||||||
|
type LogStatistics,
|
||||||
|
} from '@/api/logs'
|
||||||
|
import { useListRequest } from '@/composables/useListRequest'
|
||||||
|
|
||||||
|
// 统计数据
|
||||||
|
const statsLoading = ref(false)
|
||||||
|
const statistics = ref<LogStatistics>({
|
||||||
|
totalCount: 0,
|
||||||
|
recentCount: 0,
|
||||||
|
days: 7,
|
||||||
|
actionStats: [],
|
||||||
|
dailyStats: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算今日日志数
|
||||||
|
const todayCount = computed(() => {
|
||||||
|
const today = new Date().toISOString().split('T')[0]
|
||||||
|
const todayStat = statistics.value.dailyStats.find((s) => {
|
||||||
|
const dateStr = typeof s.date === 'string' ? s.date.split('T')[0] : new Date(s.date).toISOString().split('T')[0]
|
||||||
|
return dateStr === today
|
||||||
|
})
|
||||||
|
return todayStat?.count || 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 搜索表单
|
||||||
|
const searchForm = reactive<LogQueryParams>({
|
||||||
|
keyword: '',
|
||||||
|
action: '',
|
||||||
|
ip: '',
|
||||||
|
})
|
||||||
|
const dateRange = ref<[Dayjs | string, Dayjs | string] | null>(null)
|
||||||
|
|
||||||
|
// 选中行
|
||||||
|
const selectedRowKeys = ref<number[]>([])
|
||||||
|
|
||||||
|
// 详情弹窗
|
||||||
|
const detailModalVisible = ref(false)
|
||||||
|
const currentLog = ref<Log | null>(null)
|
||||||
|
|
||||||
|
// 清理弹窗
|
||||||
|
const cleanModalVisible = ref(false)
|
||||||
|
const cleanLoading = ref(false)
|
||||||
|
const cleanDays = ref(90)
|
||||||
|
|
||||||
|
// 使用列表请求组合函数
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
dataSource,
|
||||||
|
pagination,
|
||||||
|
handleTableChange,
|
||||||
|
refresh: refreshList,
|
||||||
|
search,
|
||||||
|
resetSearch,
|
||||||
|
} = useListRequest<Log>({
|
||||||
|
requestFn: (params) => logsApi.getList(params),
|
||||||
|
errorMessage: '获取日志列表失败',
|
||||||
|
defaultPageSize: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 表格列定义
|
||||||
|
const columns: TableColumnsType = [
|
||||||
|
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||||
|
{ title: '操作用户', dataIndex: 'user', key: 'user', width: 120 },
|
||||||
|
{ title: '操作类型', dataIndex: 'action', key: 'action', width: 200 },
|
||||||
|
{ title: '操作内容', dataIndex: 'content', key: 'content', ellipsis: true },
|
||||||
|
{ title: 'IP地址', dataIndex: 'ip', key: 'ip', width: 130 },
|
||||||
|
{ title: '操作时间', dataIndex: 'createTime', key: 'createTime', width: 180 },
|
||||||
|
{ title: '操作', key: 'operations', width: 80, fixed: 'right' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化内容
|
||||||
|
const formatContent = (content?: string) => {
|
||||||
|
if (!content) return '-'
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content)
|
||||||
|
return JSON.stringify(parsed, null, 2)
|
||||||
|
} catch {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据操作类型返回颜色
|
||||||
|
const getActionColor = (action: string) => {
|
||||||
|
if (action.startsWith('GET')) return 'blue'
|
||||||
|
if (action.startsWith('POST')) return 'green'
|
||||||
|
if (action.startsWith('PUT') || action.startsWith('PATCH')) return 'orange'
|
||||||
|
if (action.startsWith('DELETE')) return 'red'
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载统计数据
|
||||||
|
const fetchStatistics = async () => {
|
||||||
|
statsLoading.value = true
|
||||||
|
try {
|
||||||
|
statistics.value = await logsApi.getStatistics(7)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取日志统计失败:', error)
|
||||||
|
} finally {
|
||||||
|
statsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
const params: LogQueryParams = { ...searchForm }
|
||||||
|
if (dateRange.value && dateRange.value[0] && dateRange.value[1]) {
|
||||||
|
params.startTime = typeof dateRange.value[0] === 'string' ? dateRange.value[0] : dateRange.value[0].format('YYYY-MM-DD')
|
||||||
|
params.endTime = typeof dateRange.value[1] === 'string' ? dateRange.value[1] : dateRange.value[1].format('YYYY-MM-DD')
|
||||||
|
}
|
||||||
|
search(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
const handleReset = () => {
|
||||||
|
searchForm.keyword = ''
|
||||||
|
searchForm.action = ''
|
||||||
|
searchForm.ip = ''
|
||||||
|
dateRange.value = null
|
||||||
|
resetSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择行变化
|
||||||
|
const onSelectChange = (keys: number[]) => {
|
||||||
|
selectedRowKeys.value = keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看详情
|
||||||
|
const handleViewDetail = (record: Log) => {
|
||||||
|
currentLog.value = record
|
||||||
|
detailModalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量删除
|
||||||
|
const handleBatchDelete = () => {
|
||||||
|
if (selectedRowKeys.value.length === 0) {
|
||||||
|
message.warning('请选择要删除的日志')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除',
|
||||||
|
content: `确定要删除选中的 ${selectedRowKeys.value.length} 条日志吗?此操作不可恢复。`,
|
||||||
|
okText: '确定',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await logsApi.delete(selectedRowKeys.value)
|
||||||
|
message.success('删除成功')
|
||||||
|
selectedRowKeys.value = []
|
||||||
|
refreshList()
|
||||||
|
fetchStatistics()
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.response?.data?.message || '删除失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理过期日志
|
||||||
|
const handleCleanOldLogs = () => {
|
||||||
|
cleanDays.value = 90
|
||||||
|
cleanModalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认清理
|
||||||
|
const handleConfirmClean = async () => {
|
||||||
|
cleanLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await logsApi.clean(cleanDays.value)
|
||||||
|
message.success(`成功清理 ${result.deleted} 条过期日志`)
|
||||||
|
cleanModalVisible.value = false
|
||||||
|
refreshList()
|
||||||
|
fetchStatistics()
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.response?.data?.message || '清理失败')
|
||||||
|
} finally {
|
||||||
|
cleanLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
fetchStatistics()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.logs-page {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-content-wrapper {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow: auto;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-content {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-gray-400 {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-4 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-2 {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -74,7 +74,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="loading-info">
|
<div class="loading-info">
|
||||||
<div class="loading-title">
|
<div class="loading-title">
|
||||||
{{ task?.status === 'pending' ? '排队中' : 'AI 生成中' }}
|
{{ task?.status === "pending" ? "排队中" : "AI 生成中" }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="task?.status === 'pending'" class="loading-text">
|
<div v-if="task?.status === 'pending'" class="loading-text">
|
||||||
<p>
|
<p>
|
||||||
@ -83,9 +83,9 @@
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
预计时间:
|
预计时间:
|
||||||
<span class="highlight"
|
<span class="highlight">{{
|
||||||
>{{ formatEstimatedTime(queueInfo.estimatedTime) }}</span
|
formatEstimatedTime(queueInfo.estimatedTime)
|
||||||
>
|
}}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="loading-text">
|
<div v-else class="loading-text">
|
||||||
@ -290,10 +290,7 @@ const handleCardClick = (index: number) => {
|
|||||||
|
|
||||||
// 存储到 sessionStorage(避免URL过长)
|
// 存储到 sessionStorage(避免URL过长)
|
||||||
if (allResultUrls.length > 1) {
|
if (allResultUrls.length > 1) {
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem("model-viewer-urls", JSON.stringify(allResultUrls))
|
||||||
"model-viewer-urls",
|
|
||||||
JSON.stringify(allResultUrls)
|
|
||||||
)
|
|
||||||
sessionStorage.setItem("model-viewer-index", String(index))
|
sessionStorage.setItem("model-viewer-index", String(index))
|
||||||
// 清除单URL存储
|
// 清除单URL存储
|
||||||
sessionStorage.removeItem("model-viewer-url")
|
sessionStorage.removeItem("model-viewer-url")
|
||||||
@ -777,7 +774,7 @@ $gradient-card: linear-gradient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
width: 120px;
|
width: 200px;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
background: rgba($primary, 0.2);
|
background: rgba($primary, 0.2);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -92,6 +92,9 @@ importers:
|
|||||||
'@types/jest':
|
'@types/jest':
|
||||||
specifier: ^29.5.11
|
specifier: ^29.5.11
|
||||||
version: 29.5.14
|
version: 29.5.14
|
||||||
|
'@types/multer':
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.0.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^20.11.5
|
specifier: ^20.11.5
|
||||||
version: 20.19.25
|
version: 20.19.25
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user