Compare commits

...

10 Commits

Author SHA1 Message Date
zhangxiaohua
435df2bf16 添加@types/multer类型依赖
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 17:04:43 +08:00
zhangxiaohua
00491c7ac4 修复backend的gitignore规则并添加日志模块
- 修复.gitignore中logs规则匹配范围过大的问题
- 添加后端日志管理模块源代码

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 16:58:50 +08:00
zhangxiaohua
fe91f88350 修复gitignore规则并添加系统日志页面
- 修复.gitignore中logs规则匹配范围过大的问题
- 添加系统日志管理页面

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 14:56:25 +08:00
zhangxiaohua
f6b292bebf 新增预设评语功能及评审相关优化
- 新增预设评语表(PresetComment)及相关API
- 新增预设评语管理页面
- 优化评审作品弹框,支持预设评语选择
- 优化赛果发布列表页面
- 更新路由和菜单配置

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 14:46:25 +08:00
zhangxiaohua
2981449353 修复评审相关数据显示问题
- 修复作品详情弹框评审记录字段名(score->totalScore, comment->comments)
- 修复评审详情弹框评分字段名(score->totalScore)
- 修复赛果发布详情评委评分不显示问题(添加judgeScore计算)
- 修复赛事列表已递交数统计(添加status in条件和覆盖_count.works)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 14:44:27 +08:00
zhangxiaohua
c1f8ac072a 修复管理端作品详情3D模型预览失败问题
1. 添加 parsedFiles 计算属性正确解析 files 字段(可能是 JSON 字符串)
2. 修改 hasModelFile 和 modelFileUrl 使用解析后的数组
3. 修改跳转方式使用 sessionStorage + router.push(与学生端一致)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:54:25 +08:00
zhangxiaohua
9f22a20a2a 修复作品附件上传和显示功能
1. 后端 DTO 添加 attachments 字段
2. 后端 submit 方法使用事务创建作品和附件记录
3. 前端 SubmitWorkForm 添加 attachments 类型
4. 前端上传时将附件信息单独传递(不再合并到 files 中)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:43:10 +08:00
zhangxiaohua
1010c764cc 修复管理端团队成员列表不包含指导老师
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:33:51 +08:00
zhangxiaohua
f96b59e25e 修复我的队伍成员列表不包含指导老师
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:32:32 +08:00
zhangxiaohua
ac0c38c04a 修复团队赛事相关功能
1. 修复团队成员无法在"我参与的赛事"中看到团队赛事的问题
2. 修复教师作为指导老师无法看到团队赛事的问题
3. 修复上传作品/参赛作品/我的队伍按钮500错误(userId获取方式错误)
4. 修复管理端成员弹框队长名称和成员数显示问题
5. 新增getMyRegistration接口支持团队成员查询报名状态
6. 优化赛事卡片按钮布局

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:27:06 +08:00
45 changed files with 3429 additions and 385 deletions

2
.gitignore vendored
View File

@ -31,7 +31,7 @@ build/
Thumbs.db
# Logs
logs/
/logs/
*.log
npm-debug.log*
yarn-debug.log*

2
backend/.gitignore vendored
View File

@ -3,7 +3,7 @@
/node_modules
# Logs
logs
/logs
*.log
npm-debug.log*
pnpm-debug.log*

View File

@ -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",

View File

@ -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")
}

View File

@ -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 {}

View File

@ -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) {

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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 {}

View 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,
},
},
});
}
}

View File

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

View File

@ -626,5 +626,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;
}
}
}

View File

@ -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,

View File

@ -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)

View File

@ -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,

View File

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

View File

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

View File

@ -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[];
}

View File

@ -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,
},
});
});
}

View 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;
}

View 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;
}

View 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);
}
}

View 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 {}

View 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
View File

@ -1,5 +1,5 @@
# Logs
logs
/logs
*.log
npm-debug.log*
yarn-debug.log*

View File

@ -257,6 +257,8 @@ export interface ContestTeam {
createTime?: string;
modifyTime?: string;
validState?: number;
registrationState?: string; // 报名状态
registrationId?: number; // 报名记录ID
leader?: {
id: number;
username: string;
@ -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

View 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,
};

View File

@ -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 })

View File

@ -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"),

View 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>

View File

@ -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

View File

@ -105,37 +105,36 @@
<!-- 底部区域 -->
<div class="card-footer">
<span
class="status-dot"
:class="{ 'status-ongoing': contest.status === 'ongoing' }"
></span>
<span class="status-text">{{ getStatusText(contest) }}</span>
<div class="status-row">
<span
class="status-dot"
:class="{ 'status-ongoing': contest.status === 'ongoing' }"
></span>
<span class="status-text">{{ getStatusText(contest) }}</span>
</div>
<!-- 操作按钮区域 - 我的赛事tab显示 -->
<div v-if="activeTab === 'my'" class="card-actions" @click.stop>
<!-- 学生角色按钮 -->
<template v-if="userRole === 'student'">
<template v-if="contest.contestType === 'individual'">
<a-button
v-if="isSubmitting(contest)"
type="primary"
size="small"
@click="handleUploadWork(contest.id)"
>
上传作品
</a-button>
<a-button size="small" @click="handleViewWorks(contest.id)">
参赛作品
</a-button>
</template>
<template v-else>
<a-button size="small" @click="handleViewWorks(contest.id)">
参赛作品
</a-button>
<a-button size="small" @click="handleViewTeam(contest.id)">
我的队伍
</a-button>
</template>
<a-button
v-if="isSubmitting(contest)"
type="primary"
size="small"
@click="handleUploadWork(contest.id)"
>
上传作品
</a-button>
<a-button size="small" @click="handleViewWorks(contest.id)">
参赛作品
</a-button>
<a-button
v-if="contest.contestType === 'team'"
size="small"
@click="handleViewTeam(contest)"
>
我的队伍
</a-button>
</template>
<!-- 教师角色按钮 -->
@ -199,6 +198,52 @@
v-model:open="viewWorkDrawerVisible"
:contest-id="currentContestIdForView"
/>
<!-- 我的队伍弹窗 -->
<a-modal
v-model:open="teamModalVisible"
:title="`我的队伍 - ${currentTeamContest?.contestName || ''}`"
:footer="null"
width="600px"
>
<a-spin :spinning="teamLoading">
<div v-if="myTeamInfo" class="team-info">
<a-descriptions :column="2" bordered style="margin-bottom: 16px">
<a-descriptions-item label="团队名称">
{{ myTeamInfo.teamName }}
</a-descriptions-item>
<a-descriptions-item label="成员数">
{{ myTeamInfo.members?.length || 0 }}
</a-descriptions-item>
</a-descriptions>
<div class="team-members">
<div class="members-title">团队成员</div>
<a-table
:columns="memberColumns"
:data-source="myTeamInfo.members || []"
:pagination="false"
row-key="id"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'nickname'">
{{ record.user?.nickname || "-" }}
</template>
<template v-else-if="column.key === 'username'">
{{ record.user?.username || "-" }}
</template>
<template v-else-if="column.key === 'role'">
<a-tag :color="getMemberRoleColor(record.role)">
{{ getMemberRoleText(record.role) }}
</a-tag>
</template>
</template>
</a-table>
</div>
</div>
<a-empty v-else description="暂无团队信息" />
</a-spin>
</a-modal>
</div>
</template>
@ -216,8 +261,10 @@ import {
import dayjs from "dayjs"
import {
contestsApi,
registrationsApi,
type Contest,
type QueryContestParams,
type ContestTeam,
} from "@/api/contests"
import { useAuthStore } from "@/stores/auth"
import SubmitWorkDrawer from "./components/SubmitWorkDrawer.vue"
@ -374,9 +421,59 @@ const handleViewWorks = (id: number) => {
}
//
const handleViewTeam = (id: number) => {
// TODO:
message.info("查看我的队伍功能开发中")
const teamModalVisible = ref(false)
const teamLoading = ref(false)
const currentTeamContest = ref<Contest | null>(null)
const myTeamInfo = ref<ContestTeam | null>(null)
//
const memberColumns = [
{ title: "姓名", key: "nickname", width: 120 },
{ title: "账号", key: "username", width: 150 },
{ title: "角色", key: "role", width: 100 },
]
//
const getMemberRoleColor = (role?: string) => {
switch (role) {
case "leader":
return "gold"
case "mentor":
return "purple"
default:
return "blue"
}
}
//
const getMemberRoleText = (role?: string) => {
switch (role) {
case "leader":
return "队长"
case "mentor":
return "指导老师"
default:
return "成员"
}
}
const handleViewTeam = async (contest: Contest) => {
currentTeamContest.value = contest
teamModalVisible.value = true
teamLoading.value = true
myTeamInfo.value = null
try {
//
const registration = await registrationsApi.getMyRegistration(contest.id)
if (registration?.team) {
myTeamInfo.value = registration.team
}
} catch (error: any) {
message.error(error?.response?.data?.message || "获取团队信息失败")
} finally {
teamLoading.value = false
}
}
// ===== =====
@ -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 {

View File

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

View File

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

View File

@ -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")
// 3DURL
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模型文件")
}

View File

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

View File

@ -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>

View File

@ -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);
}

View File

@ -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>

View 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>

View File

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

3
pnpm-lock.yaml generated
View File

@ -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