新增预设评语功能及评审相关优化
- 新增预设评语表(PresetComment)及相关API - 新增预设评语管理页面 - 优化评审作品弹框,支持预设评语选择 - 优化赛果发布列表页面 - 更新路由和菜单配置 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2981449353
commit
f6b292bebf
@ -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 {}
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
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
@ -227,10 +227,10 @@
|
|||||||
>
|
>
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.key === 'nickname'">
|
<template v-if="column.key === 'nickname'">
|
||||||
{{ record.user?.nickname || '-' }}
|
{{ record.user?.nickname || "-" }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'username'">
|
<template v-else-if="column.key === 'username'">
|
||||||
{{ record.user?.username || '-' }}
|
{{ record.user?.username || "-" }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'role'">
|
<template v-else-if="column.key === 'role'">
|
||||||
<a-tag :color="getMemberRoleColor(record.role)">
|
<a-tag :color="getMemberRoleColor(record.role)">
|
||||||
@ -490,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}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 图片加载错误记录
|
// 图片加载错误记录
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user