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
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
/logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
2
backend/.gitignore
vendored
2
backend/.gitignore
vendored
@ -3,7 +3,7 @@
|
||||
/node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
/logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
@ -80,6 +80,7 @@
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^20.11.5",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/passport-local": "^1.0.36",
|
||||
|
||||
@ -158,6 +158,8 @@ model User {
|
||||
modifiedHomeworkScores HomeworkScore[] @relation("HomeworkScoreModifier")
|
||||
// AI 3D 生成关联
|
||||
ai3dTasks AI3DTask[] @relation("AI3DTaskUser") /// 用户的 AI 3D 生成任务
|
||||
// 预设评语关联
|
||||
presetComments PresetComment[] @relation("PresetCommentJudge") /// 评委的预设评语
|
||||
|
||||
@@unique([tenantId, username])
|
||||
@@unique([tenantId, email])
|
||||
@ -592,6 +594,7 @@ model Contest {
|
||||
workAssignments ContestWorkJudgeAssignment[] @relation("ContestWorkJudgeAssignmentContest") /// 作品分配
|
||||
workScores ContestWorkScore[] @relation("ContestWorkScoreContest") /// 作品评分
|
||||
notices ContestNotice[] /// 赛事公告
|
||||
presetComments PresetComment[] /// 预设评语
|
||||
creatorUser User? @relation("ContestCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||
modifierUser User? @relation("ContestModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||
|
||||
@ -1098,3 +1101,25 @@ model AI3DTask {
|
||||
@@index([createTime])
|
||||
@@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 { JudgesModule } from './judges/judges.module';
|
||||
import { ResultsModule } from './results/results.module';
|
||||
import { PresetCommentsModule } from './preset-comments/preset-comments.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -23,6 +24,7 @@ import { ResultsModule } from './results/results.module';
|
||||
NoticesModule,
|
||||
JudgesModule,
|
||||
ResultsModule,
|
||||
PresetCommentsModule,
|
||||
// ContestsCoreModule 放在最后,因为它有通配符路由 /contests/:id
|
||||
ContestsCoreModule,
|
||||
],
|
||||
@ -37,6 +39,7 @@ import { ResultsModule } from './results/results.module';
|
||||
NoticesModule,
|
||||
JudgesModule,
|
||||
ResultsModule,
|
||||
PresetCommentsModule,
|
||||
],
|
||||
})
|
||||
export class ContestsModule {}
|
||||
|
||||
@ -273,22 +273,23 @@ export class ContestsService {
|
||||
// 解析 contestTenants JSON 字符串为数组,并计算评审统计数据
|
||||
const parsedList = await Promise.all(
|
||||
filteredList.map(async (contest) => {
|
||||
// 计算总作品数(已提交的作品)
|
||||
// 计算总作品数(已提交或评审中的作品)
|
||||
const totalWorksCount = await this.prisma.contestWork.count({
|
||||
where: {
|
||||
contestId: contest.id,
|
||||
status: 'submitted',
|
||||
status: { in: ['submitted', 'reviewing'] },
|
||||
isLatest: true,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// 计算已完成评审的作品数(所有评委都评分的作品)
|
||||
// 简化逻辑:统计有评分记录的作品数
|
||||
// 计算已完成评审的作品数(有评分记录的作品)
|
||||
const reviewedCount = await this.prisma.contestWork.count({
|
||||
where: {
|
||||
contestId: contest.id,
|
||||
status: 'submitted',
|
||||
status: { in: ['submitted', 'reviewing'] },
|
||||
isLatest: true,
|
||||
validState: 1,
|
||||
scores: {
|
||||
some: {
|
||||
validState: 1,
|
||||
@ -302,6 +303,11 @@ export class ContestsService {
|
||||
contestTenants: this.parseContestTenants(contest.contestTenants),
|
||||
totalWorksCount,
|
||||
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);
|
||||
} else if (role === 'teacher') {
|
||||
// 教师:查询作为指导老师参与的赛事
|
||||
// 1. 从报名指导老师关联表查询(个人赛)
|
||||
const teacherRecords = await this.prisma.contestRegistrationTeacher.findMany({
|
||||
where: {
|
||||
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 {
|
||||
// 学生/默认:查询报名的赛事
|
||||
// 1. 从报名记录查询(个人赛报名或团队赛队长)
|
||||
const registrationWhere: any = {
|
||||
userId,
|
||||
};
|
||||
@ -393,7 +420,32 @@ export class ContestsService {
|
||||
},
|
||||
distinct: ['contestId'],
|
||||
});
|
||||
contestIds = registrations.map((r) => r.contestId);
|
||||
const contestIdsFromRegistration = registrations.map((r) => r.contestId);
|
||||
|
||||
// 2. 从团队成员表查询(团队赛成员,role='leader' 或 'member')
|
||||
const teamMemberWhere: any = {
|
||||
userId,
|
||||
role: { in: ['leader', 'member'] },
|
||||
};
|
||||
|
||||
if (tenantId) {
|
||||
teamMemberWhere.tenantId = tenantId;
|
||||
}
|
||||
|
||||
const teamMembers = await this.prisma.contestTeamMember.findMany({
|
||||
where: teamMemberWhere,
|
||||
select: {
|
||||
team: {
|
||||
select: {
|
||||
contestId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const contestIdsFromTeam = teamMembers.map((r) => r.team.contestId);
|
||||
|
||||
// 合并去重
|
||||
contestIds = Array.from(new Set([...contestIdsFromRegistration, ...contestIdsFromTeam]));
|
||||
}
|
||||
|
||||
if (contestIds.length === 0) {
|
||||
|
||||
@ -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,
|
||||
Request,
|
||||
ParseIntPipe,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { RegistrationsService } from './registrations.service';
|
||||
import { CreateRegistrationDto } from './dto/create-registration.dto';
|
||||
@ -28,9 +29,9 @@ export class RegistrationsController {
|
||||
create(@Body() createRegistrationDto: CreateRegistrationDto, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
throw new BadRequestException('无法确定租户信息');
|
||||
}
|
||||
const creatorId = req.user?.id;
|
||||
const creatorId = req.user?.userId;
|
||||
return this.registrationsService.create(
|
||||
createRegistrationDto,
|
||||
tenantId,
|
||||
@ -45,6 +46,22 @@ export class RegistrationsController {
|
||||
return this.registrationsService.findAll(queryDto, tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户在某比赛中的报名记录(包括作为团队成员的情况)
|
||||
*/
|
||||
@Get('my/:contestId')
|
||||
@RequirePermission('contest:read')
|
||||
getMyRegistration(
|
||||
@Param('contestId', ParseIntPipe) contestId: number,
|
||||
@Request() req,
|
||||
) {
|
||||
const userId = req.user?.userId;
|
||||
if (!userId) {
|
||||
throw new BadRequestException('用户未登录');
|
||||
}
|
||||
return this.registrationsService.getMyRegistration(contestId, userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('contest:read')
|
||||
findOne(@Param('id', ParseIntPipe) id: number, @Request() req) {
|
||||
@ -60,7 +77,7 @@ export class RegistrationsController {
|
||||
@Request() req,
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
const operatorId = req.user?.id;
|
||||
const operatorId = req.user?.userId;
|
||||
return this.registrationsService.review(id, reviewDto, operatorId, tenantId);
|
||||
}
|
||||
|
||||
@ -73,9 +90,9 @@ export class RegistrationsController {
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
throw new BadRequestException('无法确定租户信息');
|
||||
}
|
||||
const creatorId = req.user?.id;
|
||||
const creatorId = req.user?.userId;
|
||||
return this.registrationsService.addTeacher(
|
||||
id,
|
||||
body.teacherUserId,
|
||||
@ -93,7 +110,7 @@ export class RegistrationsController {
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
throw new BadRequestException('无法确定租户信息');
|
||||
}
|
||||
return this.registrationsService.removeTeacher(id, teacherUserId, tenantId);
|
||||
}
|
||||
|
||||
@ -626,5 +626,114 @@ export class RegistrationsService {
|
||||
where: { id: teacherRecord.id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户在某比赛中的报名记录(包括作为团队成员的情况)
|
||||
* @param contestId 比赛ID
|
||||
* @param userId 用户ID
|
||||
* @returns 报名记录或null
|
||||
*/
|
||||
async getMyRegistration(contestId: number, userId: number) {
|
||||
try {
|
||||
// 1. 先查询个人赛报名(直接按 userId 匹配)
|
||||
const individualReg = await this.prisma.contestRegistration.findFirst({
|
||||
where: {
|
||||
contestId,
|
||||
userId,
|
||||
registrationType: 'individual',
|
||||
registrationState: 'passed',
|
||||
},
|
||||
include: {
|
||||
contest: {
|
||||
select: {
|
||||
id: true,
|
||||
contestName: true,
|
||||
contestType: true,
|
||||
},
|
||||
},
|
||||
works: {
|
||||
where: { validState: 1, isLatest: true },
|
||||
orderBy: { submitTime: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (individualReg) {
|
||||
return individualReg;
|
||||
}
|
||||
|
||||
// 2. 查询用户所属的团队(该比赛的)
|
||||
const teamMemberships = await this.prisma.contestTeamMember.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
teamId: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
contestId: true,
|
||||
validState: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 过滤出该比赛且有效的团队
|
||||
const validTeamIds = teamMemberships
|
||||
.filter((m) => m.team?.contestId === contestId && m.team?.validState === 1)
|
||||
.map((m) => m.teamId);
|
||||
|
||||
if (validTeamIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 通过团队ID查询团队报名记录
|
||||
const teamReg = await this.prisma.contestRegistration.findFirst({
|
||||
where: {
|
||||
contestId,
|
||||
teamId: { in: validTeamIds },
|
||||
registrationType: 'team',
|
||||
registrationState: 'passed',
|
||||
},
|
||||
include: {
|
||||
contest: {
|
||||
select: {
|
||||
id: true,
|
||||
contestName: true,
|
||||
contestType: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
include: {
|
||||
members: {
|
||||
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: [
|
||||
{ finalScore: 'desc' },
|
||||
@ -547,6 +556,25 @@ export class ResultsService {
|
||||
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 {
|
||||
contest: {
|
||||
id: contest.id,
|
||||
@ -554,7 +582,7 @@ export class ResultsService {
|
||||
resultState: contest.resultState,
|
||||
resultPublishTime: contest.resultPublishTime,
|
||||
},
|
||||
list: works,
|
||||
list: enrichedWorks,
|
||||
total,
|
||||
page,
|
||||
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 {
|
||||
@IsInt()
|
||||
@ -7,9 +7,13 @@ export class CreateScoreDto {
|
||||
@IsInt()
|
||||
assignmentId: number;
|
||||
|
||||
@IsObject()
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
dimensionScores?: any; // JSON object
|
||||
dimensionScores?: Array<{
|
||||
name: string;
|
||||
score: number;
|
||||
maxScore: number;
|
||||
}>; // 维度评分数组
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
|
||||
@ -157,7 +157,7 @@ export class ReviewsService {
|
||||
assignmentId: createScoreDto.assignmentId,
|
||||
judgeId,
|
||||
judgeName: judge?.nickname || judge?.username || '',
|
||||
dimensionScores: createScoreDto.dimensionScores || {},
|
||||
dimensionScores: createScoreDto.dimensionScores || [],
|
||||
totalScore: createScoreDto.totalScore,
|
||||
comments: createScoreDto.comments || '',
|
||||
scoreTime: new Date(),
|
||||
@ -543,7 +543,8 @@ export class ReviewsService {
|
||||
workId: assignment.workId,
|
||||
judgeId: assignment.judgeId,
|
||||
judge: assignment.judge,
|
||||
score: latestScore?.totalScore ?? null,
|
||||
totalScore: latestScore?.totalScore ?? null,
|
||||
dimensionScores: latestScore?.dimensionScores ?? null,
|
||||
scoreTime: latestScore?.scoreTime ?? null,
|
||||
comments: latestScore?.comments ?? null,
|
||||
status: assignment.status,
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
UseGuards,
|
||||
Request,
|
||||
ParseIntPipe,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { TeamsService } from './teams.service';
|
||||
import { CreateTeamDto } from './dto/create-team.dto';
|
||||
@ -27,9 +28,9 @@ export class TeamsController {
|
||||
create(@Body() createTeamDto: CreateTeamDto, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
throw new BadRequestException('无法确定租户信息');
|
||||
}
|
||||
const creatorId = req.user?.id;
|
||||
const creatorId = req.user?.userId;
|
||||
return this.teamsService.create(createTeamDto, tenantId, creatorId);
|
||||
}
|
||||
|
||||
@ -59,9 +60,9 @@ export class TeamsController {
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
throw new BadRequestException('无法确定租户信息');
|
||||
}
|
||||
const modifierId = req.user?.id;
|
||||
const modifierId = req.user?.userId;
|
||||
return this.teamsService.update(id, updateTeamDto, tenantId, modifierId);
|
||||
}
|
||||
|
||||
@ -74,9 +75,9 @@ export class TeamsController {
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
throw new BadRequestException('无法确定租户信息');
|
||||
}
|
||||
const creatorId = req.user?.id;
|
||||
const creatorId = req.user?.userId;
|
||||
return this.teamsService.inviteMember(
|
||||
teamId,
|
||||
inviteMemberDto,
|
||||
@ -94,7 +95,7 @@ export class TeamsController {
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
throw new BadRequestException('无法确定租户信息');
|
||||
}
|
||||
return this.teamsService.removeMember(teamId, userId, tenantId);
|
||||
}
|
||||
@ -104,7 +105,7 @@ export class TeamsController {
|
||||
remove(@Param('id', ParseIntPipe) id: number, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
throw new BadRequestException('无法确定租户信息');
|
||||
}
|
||||
return this.teamsService.remove(id, tenantId);
|
||||
}
|
||||
|
||||
@ -206,7 +206,7 @@ export class TeamsService {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
return this.prisma.contestTeam.findMany({
|
||||
const teams = await this.prisma.contestTeam.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createTime: 'desc',
|
||||
@ -230,6 +230,13 @@ export class TeamsService {
|
||||
},
|
||||
},
|
||||
},
|
||||
registrations: {
|
||||
select: {
|
||||
id: true,
|
||||
registrationState: true,
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
@ -238,15 +245,24 @@ export class TeamsService {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 将报名状态扁平化到团队对象上
|
||||
return teams.map((team) => ({
|
||||
...team,
|
||||
registrationState: team.registrations?.[0]?.registrationState || 'pending',
|
||||
registrationId: team.registrations?.[0]?.id,
|
||||
}));
|
||||
}
|
||||
|
||||
async findOne(id: number, tenantId?: number) {
|
||||
async findOne(id: number, tenantId?: number, strictTenantCheck = false) {
|
||||
const where: any = {
|
||||
id,
|
||||
validState: 1,
|
||||
};
|
||||
|
||||
if (tenantId) {
|
||||
// 只有明确要求严格租户检查时才限制 tenantId
|
||||
// 通过 ID 查询单个团队时,ID 已经是唯一的,不需要再限制 tenantId
|
||||
if (tenantId && strictTenantCheck) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
@ -268,6 +284,9 @@ export class TeamsService {
|
||||
},
|
||||
},
|
||||
members: {
|
||||
where: {
|
||||
role: { in: ['leader', 'member'] }, // 只查询队长和队员,不包含指导老师
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
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 {
|
||||
@IsInt()
|
||||
@ -28,5 +45,11 @@ export class SubmitWorkDto {
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
aiModelMeta?: any;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AttachmentDto)
|
||||
@IsOptional()
|
||||
attachments?: AttachmentDto[];
|
||||
}
|
||||
|
||||
|
||||
@ -120,28 +120,54 @@ export class WorksService {
|
||||
creator: submitterUserId,
|
||||
};
|
||||
|
||||
return this.prisma.contestWork.create({
|
||||
data,
|
||||
include: {
|
||||
contest: {
|
||||
select: {
|
||||
id: true,
|
||||
contestName: true,
|
||||
// 使用事务创建作品和附件
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const work = await tx.contestWork.create({
|
||||
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: {
|
||||
contest: {
|
||||
select: {
|
||||
id: true,
|
||||
contestName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
registration: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
registration: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
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
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
@ -257,6 +257,8 @@ export interface ContestTeam {
|
||||
createTime?: string;
|
||||
modifyTime?: string;
|
||||
validState?: number;
|
||||
registrationState?: string; // 报名状态
|
||||
registrationId?: number; // 报名记录ID
|
||||
leader?: {
|
||||
id: number;
|
||||
username: string;
|
||||
@ -365,6 +367,13 @@ export interface ContestWorkAttachment {
|
||||
modifyTime?: string;
|
||||
}
|
||||
|
||||
export interface SubmitWorkAttachment {
|
||||
fileName: string;
|
||||
fileUrl: string;
|
||||
fileType?: string;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export interface SubmitWorkForm {
|
||||
registrationId: number;
|
||||
title: string;
|
||||
@ -373,6 +382,7 @@ export interface SubmitWorkForm {
|
||||
previewUrl?: string;
|
||||
previewUrls?: string[];
|
||||
aiModelMeta?: any;
|
||||
attachments?: SubmitWorkAttachment[];
|
||||
}
|
||||
|
||||
export interface QueryWorkParams extends PaginationParams {
|
||||
@ -730,6 +740,16 @@ export const registrationsApi = {
|
||||
return response;
|
||||
},
|
||||
|
||||
// 获取当前用户在某比赛中的报名记录(包括作为团队成员的情况)
|
||||
getMyRegistration: async (
|
||||
contestId: number
|
||||
): Promise<ContestRegistration | null> => {
|
||||
const response = await request.get<any, ContestRegistration | null>(
|
||||
`/contests/registrations/my/${contestId}`
|
||||
);
|
||||
return response;
|
||||
},
|
||||
|
||||
// 创建报名
|
||||
create: async (
|
||||
data: CreateRegistrationForm
|
||||
|
||||
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: {
|
||||
title: "评审进度详情",
|
||||
requiresAuth: true,
|
||||
permissions: ["review:read"],
|
||||
},
|
||||
},
|
||||
// 赛果发布详情路由
|
||||
@ -131,7 +130,6 @@ const baseRoutes: RouteRecordRaw[] = [
|
||||
meta: {
|
||||
title: "赛果发布详情",
|
||||
requiresAuth: true,
|
||||
permissions: ["result:read"],
|
||||
},
|
||||
},
|
||||
// 参赛作品详情列表路由
|
||||
@ -188,6 +186,16 @@ const baseRoutes: RouteRecordRaw[] = [
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
// 预设评语页面
|
||||
{
|
||||
path: "activities/preset-comments",
|
||||
name: "PresetComments",
|
||||
component: () => import("@/views/activities/PresetComments.vue"),
|
||||
meta: {
|
||||
title: "预设评语",
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
// 3D建模实验室路由(工作台模块下)
|
||||
{
|
||||
path: "workbench/3d-lab",
|
||||
@ -539,6 +547,15 @@ router.beforeEach(async (to, _from, next) => {
|
||||
// 如果访问的是主路由,重定向到第一个菜单
|
||||
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,说明路由存在,重新导航
|
||||
if (resolved.name !== "NotFound" && !isMainRoute) {
|
||||
next({ path: targetPath, replace: true })
|
||||
|
||||
@ -53,6 +53,7 @@ const componentMap: Record<string, () => Promise<any>> = {
|
||||
"activities/Review": () => import("@/views/activities/Review.vue"),
|
||||
"activities/ReviewDetail": () => import("@/views/activities/ReviewDetail.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/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"
|
||||
:assignment-id="currentAssignmentId"
|
||||
:work-id="currentWorkId"
|
||||
:contest-id="contestId"
|
||||
:work-list="workListForNav"
|
||||
:current-index="currentWorkIndex"
|
||||
@success="handleReviewSuccess"
|
||||
@navigate="handleNavigate"
|
||||
/>
|
||||
|
||||
<!-- 作品详情弹框 -->
|
||||
@ -190,6 +194,15 @@ const columns = [
|
||||
const reviewModalVisible = ref(false)
|
||||
const currentAssignmentId = 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)
|
||||
@ -257,9 +270,22 @@ const handleViewWork = (record: any) => {
|
||||
const handleReview = (record: any) => {
|
||||
currentAssignmentId.value = record.id
|
||||
currentWorkId.value = record.workId
|
||||
// 查找当前作品在列表中的索引
|
||||
const index = dataSource.value.findIndex((item: any) => item.id === record.id)
|
||||
currentWorkIndex.value = index >= 0 ? index : 0
|
||||
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 = () => {
|
||||
fetchList()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -105,37 +105,36 @@
|
||||
|
||||
<!-- 底部区域 -->
|
||||
<div class="card-footer">
|
||||
<span
|
||||
class="status-dot"
|
||||
:class="{ 'status-ongoing': contest.status === 'ongoing' }"
|
||||
></span>
|
||||
<span class="status-text">{{ getStatusText(contest) }}</span>
|
||||
<div class="status-row">
|
||||
<span
|
||||
class="status-dot"
|
||||
:class="{ 'status-ongoing': contest.status === 'ongoing' }"
|
||||
></span>
|
||||
<span class="status-text">{{ getStatusText(contest) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮区域 - 我的赛事tab显示 -->
|
||||
<div v-if="activeTab === 'my'" class="card-actions" @click.stop>
|
||||
<!-- 学生角色按钮 -->
|
||||
<template v-if="userRole === 'student'">
|
||||
<template v-if="contest.contestType === 'individual'">
|
||||
<a-button
|
||||
v-if="isSubmitting(contest)"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleUploadWork(contest.id)"
|
||||
>
|
||||
上传作品
|
||||
</a-button>
|
||||
<a-button size="small" @click="handleViewWorks(contest.id)">
|
||||
参赛作品
|
||||
</a-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-button size="small" @click="handleViewWorks(contest.id)">
|
||||
参赛作品
|
||||
</a-button>
|
||||
<a-button size="small" @click="handleViewTeam(contest.id)">
|
||||
我的队伍
|
||||
</a-button>
|
||||
</template>
|
||||
<a-button
|
||||
v-if="isSubmitting(contest)"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleUploadWork(contest.id)"
|
||||
>
|
||||
上传作品
|
||||
</a-button>
|
||||
<a-button size="small" @click="handleViewWorks(contest.id)">
|
||||
参赛作品
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="contest.contestType === 'team'"
|
||||
size="small"
|
||||
@click="handleViewTeam(contest)"
|
||||
>
|
||||
我的队伍
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<!-- 教师角色按钮 -->
|
||||
@ -199,6 +198,52 @@
|
||||
v-model:open="viewWorkDrawerVisible"
|
||||
:contest-id="currentContestIdForView"
|
||||
/>
|
||||
|
||||
<!-- 我的队伍弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="teamModalVisible"
|
||||
:title="`我的队伍 - ${currentTeamContest?.contestName || ''}`"
|
||||
:footer="null"
|
||||
width="600px"
|
||||
>
|
||||
<a-spin :spinning="teamLoading">
|
||||
<div v-if="myTeamInfo" class="team-info">
|
||||
<a-descriptions :column="2" bordered style="margin-bottom: 16px">
|
||||
<a-descriptions-item label="团队名称">
|
||||
{{ myTeamInfo.teamName }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="成员数">
|
||||
{{ myTeamInfo.members?.length || 0 }}人
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<div class="team-members">
|
||||
<div class="members-title">团队成员</div>
|
||||
<a-table
|
||||
:columns="memberColumns"
|
||||
:data-source="myTeamInfo.members || []"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'nickname'">
|
||||
{{ record.user?.nickname || "-" }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'username'">
|
||||
{{ record.user?.username || "-" }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'role'">
|
||||
<a-tag :color="getMemberRoleColor(record.role)">
|
||||
{{ getMemberRoleText(record.role) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-else description="暂无团队信息" />
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -216,8 +261,10 @@ import {
|
||||
import dayjs from "dayjs"
|
||||
import {
|
||||
contestsApi,
|
||||
registrationsApi,
|
||||
type Contest,
|
||||
type QueryContestParams,
|
||||
type ContestTeam,
|
||||
} from "@/api/contests"
|
||||
import { useAuthStore } from "@/stores/auth"
|
||||
import SubmitWorkDrawer from "./components/SubmitWorkDrawer.vue"
|
||||
@ -374,9 +421,59 @@ const handleViewWorks = (id: number) => {
|
||||
}
|
||||
|
||||
// 查看我的队伍
|
||||
const handleViewTeam = (id: number) => {
|
||||
// TODO: 跳转到我的队伍页面或打开抽屉
|
||||
message.info("查看我的队伍功能开发中")
|
||||
const teamModalVisible = ref(false)
|
||||
const teamLoading = ref(false)
|
||||
const currentTeamContest = ref<Contest | null>(null)
|
||||
const myTeamInfo = ref<ContestTeam | null>(null)
|
||||
|
||||
// 团队成员表格列
|
||||
const memberColumns = [
|
||||
{ title: "姓名", key: "nickname", width: 120 },
|
||||
{ title: "账号", key: "username", width: 150 },
|
||||
{ title: "角色", key: "role", width: 100 },
|
||||
]
|
||||
|
||||
// 获取成员角色颜色
|
||||
const getMemberRoleColor = (role?: string) => {
|
||||
switch (role) {
|
||||
case "leader":
|
||||
return "gold"
|
||||
case "mentor":
|
||||
return "purple"
|
||||
default:
|
||||
return "blue"
|
||||
}
|
||||
}
|
||||
|
||||
// 获取成员角色文本
|
||||
const getMemberRoleText = (role?: string) => {
|
||||
switch (role) {
|
||||
case "leader":
|
||||
return "队长"
|
||||
case "mentor":
|
||||
return "指导老师"
|
||||
default:
|
||||
return "成员"
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewTeam = async (contest: Contest) => {
|
||||
currentTeamContest.value = contest
|
||||
teamModalVisible.value = true
|
||||
teamLoading.value = true
|
||||
myTeamInfo.value = null
|
||||
|
||||
try {
|
||||
// 获取用户在该比赛的报名记录(包括团队信息)
|
||||
const registration = await registrationsApi.getMyRegistration(contest.id)
|
||||
if (registration?.team) {
|
||||
myTeamInfo.value = registration.team
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "获取团队信息失败")
|
||||
} finally {
|
||||
teamLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 教师相关操作 =====
|
||||
@ -393,7 +490,11 @@ const handleReviewWorks = (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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f5f5f5;
|
||||
margin-top: auto;
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
@ -823,16 +930,15 @@ $primary-light: #40a9ff;
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
gap: 6px;
|
||||
|
||||
// 渐变主要按钮 - 蓝色系
|
||||
:deep(.ant-btn-primary) {
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 14px;
|
||||
padding: 4px 12px;
|
||||
height: auto;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
$primary 0%,
|
||||
@ -860,10 +966,10 @@ $primary-light: #40a9ff;
|
||||
// 渐变次要按钮
|
||||
:deep(.ant-btn-default) {
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 14px;
|
||||
padding: 4px 12px;
|
||||
height: auto;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf0 100%);
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||
@ -895,6 +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) {
|
||||
.contests-activities-page {
|
||||
|
||||
@ -360,23 +360,13 @@ const fetchRegistrationId = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await registrationsApi.getList({
|
||||
contestId: props.contestId,
|
||||
userId: authStore.user.id,
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
})
|
||||
// 获取用户的报名记录(包括作为团队成员的情况)
|
||||
const registration = await registrationsApi.getMyRegistration(props.contestId)
|
||||
|
||||
if (response.list && response.list.length > 0) {
|
||||
const registration = response.list[0]
|
||||
if (registration.registrationState === "passed") {
|
||||
registrationIdRef.value = registration.id
|
||||
} else {
|
||||
message.warning("您的报名尚未通过审核,无法上传作品")
|
||||
visible.value = false
|
||||
}
|
||||
if (registration) {
|
||||
registrationIdRef.value = registration.id
|
||||
} else {
|
||||
message.warning("您尚未报名该赛事,无法上传作品")
|
||||
message.warning("您尚未报名该赛事或报名未通过,无法上传作品")
|
||||
visible.value = false
|
||||
}
|
||||
} catch (error: any) {
|
||||
@ -563,7 +553,6 @@ const handleSubmit = async () => {
|
||||
let modelFiles: string[] = []
|
||||
let previewUrl = ""
|
||||
let previewUrlsList: string[] = []
|
||||
const attachmentUrls: string[] = []
|
||||
|
||||
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) {
|
||||
try {
|
||||
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) {
|
||||
console.error("附件上传失败:", error)
|
||||
}
|
||||
@ -635,9 +635,10 @@ const handleSubmit = async () => {
|
||||
registrationId: registrationIdRef.value,
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
files: [...modelFiles, ...attachmentUrls],
|
||||
files: modelFiles, // 只包含模型文件,不包含附件
|
||||
previewUrl: previewUrl,
|
||||
previewUrls: previewUrlsList.length > 0 ? previewUrlsList : undefined,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
}
|
||||
|
||||
await worksApi.submit(submitData)
|
||||
|
||||
@ -205,23 +205,16 @@ const fetchUserWork = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 先获取用户的报名记录
|
||||
const registrationResponse = await registrationsApi.getList({
|
||||
contestId: props.contestId,
|
||||
userId: userId,
|
||||
registrationType: "individual",
|
||||
registrationState: "passed",
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
// 获取用户的报名记录(包括作为团队成员的情况)
|
||||
const registration = await registrationsApi.getMyRegistration(props.contestId)
|
||||
|
||||
if (registrationResponse.list.length === 0) {
|
||||
if (!registration) {
|
||||
message.warning("您尚未报名该赛事或报名未通过,无法查看作品")
|
||||
visible.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const registrationId = registrationResponse.list[0].id
|
||||
const registrationId = registration.id
|
||||
|
||||
// 获取该报名的所有作品版本,取最新版本
|
||||
const works = await worksApi.getVersions(registrationId)
|
||||
|
||||
@ -98,8 +98,8 @@
|
||||
<div class="review-card">
|
||||
<div class="review-item">
|
||||
<span class="review-label">作品评分:</span>
|
||||
<span v-if="record.score !== null && record.score !== undefined" class="review-score">
|
||||
{{ record.score }} 分
|
||||
<span v-if="record.totalScore !== null && record.totalScore !== undefined" class="review-score">
|
||||
{{ record.totalScore }} 分
|
||||
</span>
|
||||
<span v-else class="not-reviewed">未评审</span>
|
||||
</div>
|
||||
@ -113,9 +113,9 @@
|
||||
{{ record.scoreTime ? formatDateTime(record.scoreTime) : '-' }}
|
||||
</span>
|
||||
</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-value">{{ record.comment }}</span>
|
||||
<span class="review-value">{{ record.comments }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
@ -174,6 +174,21 @@ const drawerTitle = computed(() => {
|
||||
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
|
||||
const previewImageUrl = computed(() => {
|
||||
if (!workDetail.value) return ""
|
||||
@ -182,8 +197,8 @@ const previewImageUrl = computed(() => {
|
||||
return workDetail.value.previewUrl
|
||||
}
|
||||
// 其次从 files 数组中查找图片
|
||||
const imageFromFiles = workDetail.value.files?.find(
|
||||
(url) => /\.(jpg|jpeg|png|gif|webp)$/i.test(url)
|
||||
const imageFromFiles = parsedFiles.value.find(
|
||||
(url: string) => /\.(jpg|jpeg|png|gif|webp)$/i.test(url)
|
||||
)
|
||||
if (imageFromFiles) return imageFromFiles
|
||||
// 最后从 attachments 中查找
|
||||
@ -204,7 +219,7 @@ const isModelFile = (urlOrFileName: string): boolean => {
|
||||
const hasModelFile = computed(() => {
|
||||
if (!workDetail.value) return false
|
||||
// 检查 files 数组
|
||||
const hasInFiles = workDetail.value.files?.some((url) => isModelFile(url))
|
||||
const hasInFiles = parsedFiles.value.some((url: string) => isModelFile(url))
|
||||
if (hasInFiles) return true
|
||||
// 检查 attachments 数组
|
||||
const hasInAttachments = workDetail.value.attachments?.some(
|
||||
@ -217,7 +232,7 @@ const hasModelFile = computed(() => {
|
||||
const modelFileUrl = computed(() => {
|
||||
if (!workDetail.value) return ""
|
||||
// 优先从 files 数组中查找
|
||||
const modelFromFiles = workDetail.value.files?.find((url) => isModelFile(url))
|
||||
const modelFromFiles = parsedFiles.value.find((url: string) => isModelFile(url))
|
||||
if (modelFromFiles) return modelFromFiles
|
||||
// 其次从 attachments 中查找
|
||||
const modelAtt = workDetail.value.attachments?.find(
|
||||
@ -272,12 +287,27 @@ const handleImageError = (e: Event) => {
|
||||
const handleView3DModel = () => {
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
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)
|
||||
if (modelFileUrl.value) {
|
||||
const url = `/${tenantCode}/workbench/model-viewer?url=${encodeURIComponent(modelFileUrl.value)}`
|
||||
console.log("3D模型预览 - 跳转URL:", url)
|
||||
window.open(url, "_blank")
|
||||
// 收集所有3D模型URL
|
||||
const allModelUrls = parsedFiles.value.filter((url: string) => isModelFile(url))
|
||||
|
||||
// 使用 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 {
|
||||
message.warning("未找到3D模型文件")
|
||||
}
|
||||
|
||||
@ -355,7 +355,7 @@
|
||||
<a-descriptions :column="3" bordered style="margin-bottom: 16px">
|
||||
<a-descriptions-item label="团队名称">{{ currentTeam.teamName }}</a-descriptions-item>
|
||||
<a-descriptions-item label="队长">{{ currentTeam.leader?.nickname || "-" }}</a-descriptions-item>
|
||||
<a-descriptions-item label="成员数">{{ currentTeam._count?.members || 0 }}人</a-descriptions-item>
|
||||
<a-descriptions-item label="成员数">{{ teamMembers.length || currentTeam._count?.members || 0 }}人</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<a-table
|
||||
:columns="memberColumns"
|
||||
@ -778,15 +778,19 @@ const handleViewMembers = async (record: ContestRegistration) => {
|
||||
message.warning("暂无团队信息")
|
||||
return
|
||||
}
|
||||
currentTeam.value = record.team
|
||||
membersModalVisible.value = true
|
||||
membersLoading.value = true
|
||||
currentTeam.value = null
|
||||
teamMembers.value = []
|
||||
|
||||
try {
|
||||
const teamDetail = await teamsApi.getDetail(record.team.id)
|
||||
// 更新完整的团队信息(包含 leader 和成员数)
|
||||
currentTeam.value = teamDetail
|
||||
teamMembers.value = teamDetail.members || []
|
||||
} catch (error: any) {
|
||||
message.error("获取团队成员失败")
|
||||
currentTeam.value = record.team // 降级使用列表中的数据
|
||||
teamMembers.value = []
|
||||
} finally {
|
||||
membersLoading.value = false
|
||||
|
||||
@ -72,7 +72,10 @@
|
||||
<a @click="handleViewWorkDetail(record)">{{ record.workNo || "-" }}</a>
|
||||
</template>
|
||||
<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) }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
|
||||
@ -51,7 +51,7 @@
|
||||
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'contestName'">
|
||||
<a @click="handleViewDetail(record)">{{ record.contestName }}</a>
|
||||
{{ record.contestName }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'registrationCount'">
|
||||
{{ record._count?.registrations || 0 }}
|
||||
@ -230,7 +230,11 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&: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);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@ -134,8 +134,8 @@
|
||||
{{ record.judge?.tenant?.name || "-" }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'score'">
|
||||
<span v-if="record.score !== undefined && record.score !== null">
|
||||
{{ record.score }}
|
||||
<span v-if="record.totalScore !== undefined && record.totalScore !== null">
|
||||
{{ record.totalScore }}
|
||||
</span>
|
||||
<span v-else class="text-gray">未评分</span>
|
||||
</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 class="loading-info">
|
||||
<div class="loading-title">
|
||||
{{ task?.status === 'pending' ? '排队中' : 'AI 生成中' }}
|
||||
{{ task?.status === "pending" ? "排队中" : "AI 生成中" }}
|
||||
</div>
|
||||
<div v-if="task?.status === 'pending'" class="loading-text">
|
||||
<p>
|
||||
@ -83,9 +83,9 @@
|
||||
</p>
|
||||
<p>
|
||||
预计时间:
|
||||
<span class="highlight"
|
||||
>{{ formatEstimatedTime(queueInfo.estimatedTime) }}</span
|
||||
>
|
||||
<span class="highlight">{{
|
||||
formatEstimatedTime(queueInfo.estimatedTime)
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="loading-text">
|
||||
@ -290,10 +290,7 @@ const handleCardClick = (index: number) => {
|
||||
|
||||
// 存储到 sessionStorage(避免URL过长)
|
||||
if (allResultUrls.length > 1) {
|
||||
sessionStorage.setItem(
|
||||
"model-viewer-urls",
|
||||
JSON.stringify(allResultUrls)
|
||||
)
|
||||
sessionStorage.setItem("model-viewer-urls", JSON.stringify(allResultUrls))
|
||||
sessionStorage.setItem("model-viewer-index", String(index))
|
||||
// 清除单URL存储
|
||||
sessionStorage.removeItem("model-viewer-url")
|
||||
@ -777,7 +774,7 @@ $gradient-card: linear-gradient(
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 120px;
|
||||
width: 200px;
|
||||
height: 4px;
|
||||
background: rgba($primary, 0.2);
|
||||
border-radius: 2px;
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -92,6 +92,9 @@ importers:
|
||||
'@types/jest':
|
||||
specifier: ^29.5.11
|
||||
version: 29.5.14
|
||||
'@types/multer':
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
'@types/node':
|
||||
specifier: ^20.11.5
|
||||
version: 20.19.25
|
||||
|
||||
Loading…
Reference in New Issue
Block a user