修改样式,删除无用的代码

This commit is contained in:
zhangxiaohua 2026-01-16 16:35:43 +08:00
parent 8411df8ad6
commit e7819fc1c2
42 changed files with 2829 additions and 893 deletions

View File

@ -1,5 +1,6 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os';
import AdmZip from 'adm-zip'; import AdmZip from 'adm-zip';
import axios from 'axios'; import axios from 'axios';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
@ -10,8 +11,8 @@ export class ZipHandler {
/** /**
* .zip文件3D模型文件 * .zip文件3D模型文件
* @param zipUrl ZIP文件的URL * @param zipUrl ZIP文件的URL
* @param outputDir backend/uploads/ai-3d * @param outputDir
* @returns 3D模型文件路径和预览图路径 * @returns 3D模型文件路径Buffer
*/ */
static async downloadAndExtract( static async downloadAndExtract(
zipUrl: string, zipUrl: string,
@ -19,31 +20,32 @@ export class ZipHandler {
): Promise<{ ): Promise<{
modelPath: string; modelPath: string;
previewPath?: string; previewPath?: string;
modelUrl: string; modelBuffer: Buffer;
previewUrl?: string; previewBuffer?: Buffer;
}> { }> {
try { // 使用系统临时目录
// 1. 设置输出目录
const baseDir = const baseDir =
outputDir || outputDir ||
path.join(process.cwd(), 'uploads', 'ai-3d', Date.now().toString()); path.join(os.tmpdir(), 'ai-3d', Date.now().toString());
try {
if (!fs.existsSync(baseDir)) { if (!fs.existsSync(baseDir)) {
fs.mkdirSync(baseDir, { recursive: true }); fs.mkdirSync(baseDir, { recursive: true });
} }
// 2. 下载ZIP文件 // 1. 下载ZIP文件
this.logger.log(`开始下载ZIP文件: ${zipUrl}`); this.logger.log(`开始下载ZIP文件: ${zipUrl}`);
const zipPath = path.join(baseDir, 'model.zip'); const zipPath = path.join(baseDir, 'model.zip');
await this.downloadFile(zipUrl, zipPath); await this.downloadFile(zipUrl, zipPath);
this.logger.log(`ZIP文件下载完成: ${zipPath}`); this.logger.log(`ZIP文件下载完成: ${zipPath}`);
// 3. 解压ZIP文件 // 2. 解压ZIP文件
this.logger.log(`开始解压ZIP文件`); this.logger.log(`开始解压ZIP文件`);
const extractDir = path.join(baseDir, 'extracted'); const extractDir = path.join(baseDir, 'extracted');
await this.extractZip(zipPath, extractDir); await this.extractZip(zipPath, extractDir);
this.logger.log(`ZIP文件解压完成: ${extractDir}`); this.logger.log(`ZIP文件解压完成: ${extractDir}`);
// 4. 查找3D模型文件和预览图 // 3. 查找3D模型文件和预览图
const files = this.getAllFiles(extractDir); const files = this.getAllFiles(extractDir);
const modelFile = this.findModelFile(files); const modelFile = this.findModelFile(files);
const previewFile = this.findPreviewImage(files); const previewFile = this.findPreviewImage(files);
@ -57,41 +59,29 @@ export class ZipHandler {
this.logger.log(`找到预览图: ${previewFile}`); this.logger.log(`找到预览图: ${previewFile}`);
} }
// 5. 生成可访问的URL // 4. 读取文件Buffer用于上传到COS
// 假设模型文件在 uploads/ai-3d/timestamp/extracted/model.glb const modelBuffer = fs.readFileSync(modelFile);
// URL应该是 /api/uploads/ai-3d/timestamp/extracted/model.glb const previewBuffer = previewFile ? fs.readFileSync(previewFile) : undefined;
const relativeModelPath = path.relative(
path.join(process.cwd(), 'uploads'),
modelFile,
);
const modelUrl = `/api/uploads/${relativeModelPath.replace(/\\/g, '/')}`;
let previewUrl: string | undefined;
if (previewFile) {
const relativePreviewPath = path.relative(
path.join(process.cwd(), 'uploads'),
previewFile,
);
previewUrl = `/api/uploads/${relativePreviewPath.replace(/\\/g, '/')}`;
}
// 6. 删除原始ZIP文件以节省空间
try {
fs.unlinkSync(zipPath);
this.logger.log(`已删除原始ZIP文件: ${zipPath}`);
} catch (err) {
this.logger.warn(`删除ZIP文件失败: ${err.message}`);
}
return { return {
modelPath: modelFile, modelPath: modelFile,
previewPath: previewFile, previewPath: previewFile,
modelUrl, modelBuffer,
previewUrl, previewBuffer,
}; };
} catch (error) { } catch (error) {
this.logger.error(`处理ZIP文件失败: ${error.message}`, error.stack); this.logger.error(`处理ZIP文件失败: ${error.message}`, error.stack);
throw error; throw error;
} finally {
// 清理临时目录
try {
if (fs.existsSync(baseDir)) {
fs.rmSync(baseDir, { recursive: true, force: true });
this.logger.log(`已清理临时目录: ${baseDir}`);
}
} catch (err) {
this.logger.warn(`清理临时目录失败: ${err.message}`);
}
} }
} }

View File

@ -1,8 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ServeStaticModule } from '@nestjs/serve-static';
import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from '@nestjs/core'; import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from '@nestjs/core';
import { join } from 'path';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
@ -37,14 +35,6 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
`.env.${process.env.NODE_ENV || 'development'}`, // 优先加载 `.env.${process.env.NODE_ENV || 'development'}`, // 优先加载
], ],
}), }),
// 静态文件服务 - 提供 uploads 目录的访问
ServeStaticModule.forRoot({
rootPath: join(process.cwd(), 'uploads'),
serveRoot: '/api/uploads',
serveStaticOptions: {
index: false,
},
}),
PrismaModule, PrismaModule,
AuthModule, AuthModule,
UsersModule, UsersModule,

View File

@ -270,11 +270,41 @@ export class ContestsService {
} }
} }
// 解析 contestTenants JSON 字符串为数组 // 解析 contestTenants JSON 字符串为数组,并计算评审统计数据
const parsedList = filteredList.map((contest) => ({ const parsedList = await Promise.all(
filteredList.map(async (contest) => {
// 计算总作品数(已提交的作品)
const totalWorksCount = await this.prisma.contestWork.count({
where: {
contestId: contest.id,
status: 'submitted',
isLatest: true,
},
});
// 计算已完成评审的作品数(所有评委都评分的作品)
// 简化逻辑:统计有评分记录的作品数
const reviewedCount = await this.prisma.contestWork.count({
where: {
contestId: contest.id,
status: 'submitted',
isLatest: true,
scores: {
some: {
validState: 1,
},
},
},
});
return {
...contest, ...contest,
contestTenants: this.parseContestTenants(contest.contestTenants), contestTenants: this.parseContestTenants(contest.contestTenants),
})); totalWorksCount,
reviewedCount,
};
}),
);
return { return {
list: parsedList, list: parsedList,
@ -526,14 +556,32 @@ export class ContestsService {
} }
} }
// 特殊处理:开始评审时的友好提示
const dto = updateContestDto as any;
if (dto.reviewStartTime && Object.keys(dto).length === 1) {
const newReviewStartTime = new Date(dto.reviewStartTime);
if (newReviewStartTime < contest.submitEndTime) {
const submitEndTimeStr = contest.submitEndTime.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
throw new BadRequestException(
`还未到评审时间,作品提交将于 ${submitEndTimeStr} 结束`,
);
}
}
// 验证时间顺序(如果提供了时间字段) // 验证时间顺序(如果提供了时间字段)
if ( if (
(updateContestDto as any).registerStartTime || dto.registerStartTime ||
(updateContestDto as any).registerEndTime || dto.registerEndTime ||
(updateContestDto as any).submitStartTime || dto.submitStartTime ||
(updateContestDto as any).submitEndTime || dto.submitEndTime ||
(updateContestDto as any).reviewStartTime || dto.reviewStartTime ||
(updateContestDto as any).reviewEndTime dto.reviewEndTime
) { ) {
// 合并现有数据和更新数据,确保所有必需字段都存在 // 合并现有数据和更新数据,确保所有必需字段都存在
const mergedDto = { const mergedDto = {
@ -568,7 +616,6 @@ export class ContestsService {
} }
const data: any = {}; const data: any = {};
const dto = updateContestDto as any;
if (dto.contestName !== undefined) { if (dto.contestName !== undefined) {
data.contestName = dto.contestName; data.contestName = dto.contestName;

View File

@ -8,7 +8,8 @@ export class CreateScoreDto {
assignmentId: number; assignmentId: number;
@IsObject() @IsObject()
dimensionScores: any; // JSON object @IsOptional()
dimensionScores?: any; // JSON object
@IsNumber() @IsNumber()
@Min(0) @Min(0)

View File

@ -29,7 +29,7 @@ export class ReviewsController {
@Query('contestId', ParseIntPipe) contestId: number, @Query('contestId', ParseIntPipe) contestId: number,
@Request() req, @Request() req,
) { ) {
const creatorId = req.user?.id; const creatorId = req.user?.userId;
return this.reviewsService.assignWork(assignWorkDto, contestId, creatorId); return this.reviewsService.assignWork(assignWorkDto, contestId, creatorId);
} }
@ -40,7 +40,7 @@ export class ReviewsController {
@Query('contestId', ParseIntPipe) contestId: number, @Query('contestId', ParseIntPipe) contestId: number,
@Request() req, @Request() req,
) { ) {
const creatorId = req.user?.id; const creatorId = req.user?.userId;
return this.reviewsService.batchAssignWorks( return this.reviewsService.batchAssignWorks(
contestId, contestId,
batchAssignDto.workIds, batchAssignDto.workIds,
@ -55,7 +55,7 @@ export class ReviewsController {
@Query('contestId', ParseIntPipe) contestId: number, @Query('contestId', ParseIntPipe) contestId: number,
@Request() req, @Request() req,
) { ) {
const creatorId = req.user?.id; const creatorId = req.user?.userId;
return this.reviewsService.autoAssignWorks(contestId, creatorId); return this.reviewsService.autoAssignWorks(contestId, creatorId);
} }
@ -66,7 +66,10 @@ export class ReviewsController {
if (!tenantId) { if (!tenantId) {
throw new Error('无法确定租户信息'); throw new Error('无法确定租户信息');
} }
const judgeId = req.user?.id; const judgeId = req.user?.userId;
if (!judgeId) {
throw new Error('无法确定评委信息');
}
return this.reviewsService.score(createScoreDto, judgeId, tenantId); return this.reviewsService.score(createScoreDto, judgeId, tenantId);
} }
@ -77,7 +80,7 @@ export class ReviewsController {
@Body() updateScoreDto: Partial<CreateScoreDto>, @Body() updateScoreDto: Partial<CreateScoreDto>,
@Request() req, @Request() req,
) { ) {
const judgeId = req.user?.id; const judgeId = req.user?.userId;
return this.reviewsService.updateScore(scoreId, updateScoreDto, judgeId); return this.reviewsService.updateScore(scoreId, updateScoreDto, judgeId);
} }
@ -87,10 +90,42 @@ export class ReviewsController {
@Query('contestId', ParseIntPipe) contestId: number, @Query('contestId', ParseIntPipe) contestId: number,
@Request() req, @Request() req,
) { ) {
const judgeId = req.user?.id; const judgeId = req.user?.userId;
return this.reviewsService.getAssignedWorks(judgeId, contestId); return this.reviewsService.getAssignedWorks(judgeId, contestId);
} }
@Get('judge/contests')
@RequirePermission('review:score')
getJudgeContests(@Request() req) {
const judgeId = req.user?.userId;
console.log('getJudgeContests - judgeId:', judgeId, 'user:', req.user);
if (!judgeId) {
throw new Error('无法确定评委信息');
}
return this.reviewsService.getJudgeContests(judgeId);
}
@Get('judge/contests/:contestId/works')
@RequirePermission('review:score')
getJudgeContestWorks(
@Param('contestId', ParseIntPipe) contestId: number,
@Query('page') page: string,
@Query('pageSize') pageSize: string,
@Query('workNo') workNo: string,
@Query('accountNo') accountNo: string,
@Query('reviewStatus') reviewStatus: string,
@Request() req,
) {
const judgeId = req.user?.userId;
return this.reviewsService.getJudgeContestWorks(judgeId, contestId, {
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 10,
workNo,
accountNo,
reviewStatus,
});
}
@Get('progress/:contestId') @Get('progress/:contestId')
@RequirePermission('review:read') @RequirePermission('review:read')
getReviewProgress(@Param('contestId', ParseIntPipe) contestId: number) { getReviewProgress(@Param('contestId', ParseIntPipe) contestId: number) {

View File

@ -157,9 +157,9 @@ export class ReviewsService {
assignmentId: createScoreDto.assignmentId, assignmentId: createScoreDto.assignmentId,
judgeId, judgeId,
judgeName: judge?.nickname || judge?.username || '', judgeName: judge?.nickname || judge?.username || '',
dimensionScores: JSON.stringify(createScoreDto.dimensionScores), dimensionScores: createScoreDto.dimensionScores || {},
totalScore: createScoreDto.totalScore, totalScore: createScoreDto.totalScore,
comments: createScoreDto.comments, comments: createScoreDto.comments || '',
scoreTime: new Date(), scoreTime: new Date(),
creator: judgeId, creator: judgeId,
}; };
@ -311,14 +311,103 @@ export class ReviewsService {
}); });
} }
async getWorkScores(workId: number) { // 获取评委参与的赛事列表
return this.prisma.contestWorkScore.findMany({ async getJudgeContests(judgeId: number) {
console.log('getJudgeContests - judgeId:', judgeId);
// 获取评委参与的所有赛事(通过作品分配表)
const assignments = await this.prisma.contestWorkJudgeAssignment.findMany({
where: { where: {
workId, judgeId,
},
select: {
contestId: true,
},
distinct: ['contestId'],
});
console.log('getJudgeContests - assignments:', assignments);
const contestIds = assignments.map((a) => a.contestId);
if (contestIds.length === 0) {
return [];
}
// 获取赛事详情和评审统计
const contests = await this.prisma.contest.findMany({
where: {
id: { in: contestIds },
validState: 1, validState: 1,
}, },
include: { include: {
judge: { reviewRule: true,
},
orderBy: {
createTime: 'desc',
},
});
// 计算每个赛事的评审进度
const result = await Promise.all(
contests.map(async (contest) => {
// 获取分配给该评委的作品数量
const totalAssigned = await this.prisma.contestWorkJudgeAssignment.count({
where: {
contestId: contest.id,
judgeId,
},
});
// 获取已评审的作品数量
const reviewed = await this.prisma.contestWorkScore.count({
where: {
contestId: contest.id,
judgeId,
validState: 1,
},
});
return {
...contest,
totalAssigned,
reviewed,
pending: totalAssigned - reviewed,
};
}),
);
return result;
}
// 获取评委在某个赛事下分配的作品列表(带分页和搜索)
async getJudgeContestWorks(
judgeId: number,
contestId: number,
query: {
page?: number;
pageSize?: number;
workNo?: string;
accountNo?: string;
reviewStatus?: string; // 'reviewed' | 'pending'
},
) {
const { page = 1, pageSize = 10, workNo, accountNo, reviewStatus } = query;
// 构建查询条件
const where: any = {
judgeId,
contestId,
};
// 如果需要按评审状态筛选,需要特殊处理
const allAssignments = await this.prisma.contestWorkJudgeAssignment.findMany({
where,
include: {
work: {
include: {
registration: {
include: {
user: {
select: { select: {
id: true, id: true,
username: true, username: true,
@ -326,9 +415,140 @@ export class ReviewsService {
}, },
}, },
}, },
},
},
},
scores: {
where: { validState: 1 },
orderBy: { scoreTime: 'desc' },
take: 1,
},
},
orderBy: {
assignmentTime: 'desc',
},
});
// 按 workId 去重,保留第一条(最新的)
const workIdSet = new Set<number>();
let assignments = allAssignments.filter((a) => {
if (workIdSet.has(a.workId)) {
return false;
}
workIdSet.add(a.workId);
return true;
});
// 应用搜索条件
if (workNo) {
assignments = assignments.filter((a) =>
a.work.workNo?.toLowerCase().includes(workNo.toLowerCase()),
);
}
if (accountNo) {
assignments = assignments.filter(
(a) =>
a.work.registration?.user?.username
?.toLowerCase()
.includes(accountNo.toLowerCase()) ||
a.work.submitterAccountNo
?.toLowerCase()
.includes(accountNo.toLowerCase()),
);
}
// 应用评审状态筛选
if (reviewStatus === 'reviewed') {
assignments = assignments.filter((a) => a.scores.length > 0);
} else if (reviewStatus === 'pending') {
assignments = assignments.filter((a) => a.scores.length === 0);
}
const total = assignments.length;
// 分页
const start = (page - 1) * pageSize;
const paginatedAssignments = assignments.slice(start, start + pageSize);
// 转换为前端期望的格式
const list = paginatedAssignments.map((assignment) => {
const latestScore = assignment.scores[0];
return {
id: assignment.id,
workId: assignment.workId,
workNo: assignment.work.workNo,
accountNo:
assignment.work.submitterAccountNo ||
assignment.work.registration?.user?.username ||
'-',
nickname: assignment.work.registration?.user?.nickname || '-',
score: latestScore?.totalScore ?? null,
reviewStatus: latestScore ? 'reviewed' : 'pending',
assignmentTime: assignment.assignmentTime,
work: assignment.work,
};
});
return {
list,
total,
page,
pageSize,
};
}
async getWorkScores(workId: number) {
// 获取分配给作品的所有评委及其评分记录
const assignments = await this.prisma.contestWorkJudgeAssignment.findMany({
where: {
workId,
},
include: {
judge: {
select: {
id: true,
username: true,
nickname: true,
phone: true,
tenant: {
select: {
id: true,
name: true,
},
},
},
},
scores: {
where: {
validState: 1,
},
orderBy: { orderBy: {
scoreTime: 'desc', scoreTime: 'desc',
}, },
take: 1, // 只取最新的有效评分
},
},
orderBy: {
assignmentTime: 'desc',
},
});
// 转换为前端期望的格式
return assignments.map((assignment) => {
const latestScore = assignment.scores[0];
return {
id: assignment.id,
assignmentId: assignment.id,
workId: assignment.workId,
judgeId: assignment.judgeId,
judge: assignment.judge,
score: latestScore?.totalScore ?? null,
scoreTime: latestScore?.scoreTime ?? null,
comments: latestScore?.comments ?? null,
status: assignment.status,
assignmentTime: assignment.assignmentTime,
};
}); });
} }

View File

@ -1,23 +1,16 @@
import { import {
Controller, Controller,
Post, Post,
Get,
Param,
Res,
UseInterceptors, UseInterceptors,
UploadedFile, UploadedFile,
UseGuards, UseGuards,
Request, Request,
BadRequestException, BadRequestException,
NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { Response } from 'express';
import { memoryStorage } from 'multer'; import { memoryStorage } from 'multer';
import { UploadService } from './upload.service'; import { UploadService } from './upload.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import * as path from 'path';
import * as fs from 'fs';
@Controller('upload') @Controller('upload')
export class UploadController { export class UploadController {
@ -27,7 +20,7 @@ export class UploadController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@UseInterceptors( @UseInterceptors(
FileInterceptor('file', { FileInterceptor('file', {
storage: memoryStorage(), // 使用内存存储,确保 file.buffer 可用 storage: memoryStorage(),
limits: { limits: {
fileSize: 100 * 1024 * 1024, // 限制文件大小为 100MB fileSize: 100 * 1024 * 1024, // 限制文件大小为 100MB
}, },
@ -54,61 +47,3 @@ export class UploadController {
} }
} }
/**
*
* /api/uploads/*
*/
@Controller('uploads')
export class UploadsController {
private readonly uploadDir: string;
constructor() {
this.uploadDir = path.join(process.cwd(), 'uploads');
}
@Get('*')
async serveFile(@Param() params: any, @Res() res: Response) {
// 获取文件路径(从通配符参数中)
const filePath = params[0] || '';
const fullPath = path.join(this.uploadDir, filePath);
// 安全检查:防止路径遍历攻击
const normalizedPath = path.normalize(fullPath);
if (!normalizedPath.startsWith(this.uploadDir)) {
throw new BadRequestException('无效的文件路径');
}
// 检查文件是否存在
if (!fs.existsSync(normalizedPath)) {
console.error('文件不存在:', normalizedPath);
throw new NotFoundException('文件不存在');
}
// 获取文件扩展名以设置正确的 Content-Type
const ext = path.extname(normalizedPath).toLowerCase();
const mimeTypes: Record<string, string> = {
'.glb': 'model/gltf-binary',
'.gltf': 'model/gltf+json',
'.obj': 'text/plain',
'.fbx': 'application/octet-stream',
'.stl': 'model/stl',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.pdf': 'application/pdf',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
};
const contentType = mimeTypes[ext] || 'application/octet-stream';
res.setHeader('Content-Type', contentType);
res.setHeader('Access-Control-Allow-Origin', '*');
// 发送文件
res.sendFile(normalizedPath);
}
}

View File

@ -1,11 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { UploadController, UploadsController } from './upload.controller'; import { UploadController } from './upload.controller';
import { UploadService } from './upload.service'; import { UploadService } from './upload.service';
import { OssModule } from '../oss/oss.module'; import { OssModule } from '../oss/oss.module';
@Module({ @Module({
imports: [OssModule], imports: [OssModule],
controllers: [UploadController, UploadsController], controllers: [UploadController],
providers: [UploadService], providers: [UploadService],
exports: [UploadService], exports: [UploadService],
}) })

View File

@ -1,34 +1,11 @@
import { Injectable, BadRequestException } from '@nestjs/common'; import { Injectable, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OssService } from '../oss/oss.service'; import { OssService } from '../oss/oss.service';
import * as fs from 'fs';
import * as path from 'path';
import { randomBytes } from 'crypto';
@Injectable() @Injectable()
export class UploadService { export class UploadService {
private readonly uploadDir: string; constructor(private ossService: OssService) {
private readonly useOss: boolean; if (!this.ossService.isEnabled()) {
console.warn('警告: COS 未配置,文件上传功能可能无法正常使用');
constructor(
private configService: ConfigService,
private ossService: OssService,
) {
// 本地上传文件存储目录(作为 COS 的备用方案)
this.uploadDir = path.join(process.cwd(), 'uploads');
// 确保本地上传目录存在
if (!fs.existsSync(this.uploadDir)) {
fs.mkdirSync(this.uploadDir, { recursive: true });
}
// 检查是否使用 COS
this.useOss = this.ossService.isEnabled();
if (this.useOss) {
console.log('文件上传将使用腾讯云 COS');
} else {
console.log('文件上传将使用本地存储,目录:', this.uploadDir);
} }
} }
@ -41,24 +18,10 @@ export class UploadService {
throw new BadRequestException('文件不存在'); throw new BadRequestException('文件不存在');
} }
// 优先使用 COS if (!this.ossService.isEnabled()) {
if (this.useOss) { throw new BadRequestException('文件存储服务未配置,请联系管理员');
return this.uploadToOss(file, tenantId, userId);
} }
// 备用方案:本地存储
return this.uploadToLocal(file, tenantId, userId);
}
/**
* COS
*/
private async uploadToOss(
file: Express.Multer.File,
tenantId?: number,
userId?: number,
): Promise<{ url: string; fileName: string; size: number }> {
try {
const result = await this.ossService.uploadFile( const result = await this.ossService.uploadFile(
file.buffer, file.buffer,
file.originalname, file.originalname,
@ -71,63 +34,5 @@ export class UploadService {
fileName: result.fileName, fileName: result.fileName,
size: file.size, size: file.size,
}; };
} catch (error: any) {
console.error('COS 上传失败,尝试本地存储:', error.message);
// COS 失败时回退到本地存储
return this.uploadToLocal(file, tenantId, userId);
}
}
/**
*
*/
private async uploadToLocal(
file: Express.Multer.File,
tenantId?: number,
userId?: number,
): Promise<{ url: string; fileName: string; size: number }> {
// 生成唯一文件名
const fileExt = path.extname(file.originalname);
const uniqueId = randomBytes(16).toString('hex');
const fileName = `${uniqueId}${fileExt}`;
// 根据租户ID和用户ID创建目录结构uploads/tenantId/userId/
let targetDir = this.uploadDir;
if (tenantId) {
targetDir = path.join(targetDir, `tenant_${tenantId}`);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
if (userId) {
targetDir = path.join(targetDir, `user_${userId}`);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
}
}
// 文件保存路径
const filePath = path.join(targetDir, fileName);
// 保存文件
fs.writeFileSync(filePath, file.buffer);
// 生成访问URL
// 格式:/api/uploads/tenantId/userId/fileName 或 /api/uploads/fileName
let urlPath = '/api/uploads';
if (tenantId) {
urlPath += `/tenant_${tenantId}`;
if (userId) {
urlPath += `/user_${userId}`;
}
}
urlPath += `/${fileName}`;
return {
url: urlPath,
fileName: file.originalname,
size: file.size,
};
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

View File

@ -439,7 +439,7 @@ export interface AssignWorkForm {
export interface CreateScoreForm { export interface CreateScoreForm {
workId: number; workId: number;
assignmentId: number; assignmentId: number;
dimensionScores: any; dimensionScores?: any;
totalScore: number; totalScore: number;
comments?: string; comments?: string;
} }
@ -1116,6 +1116,32 @@ export const reviewsApi = {
{ assignmentId, newJudgeId } { assignmentId, newJudgeId }
); );
}, },
// 获取评委参与的赛事列表
getJudgeContests: async (): Promise<any[]> => {
const response = await request.get<any, any[]>(
`/contests/reviews/judge/contests`
);
return response;
},
// 获取评委在某个赛事下分配的作品列表
getJudgeContestWorks: async (
contestId: number,
params: {
page?: number;
pageSize?: number;
workNo?: string;
accountNo?: string;
reviewStatus?: string;
}
): Promise<{ list: any[]; total: number; page: number; pageSize: number }> => {
const response = await request.get<
any,
{ list: any[]; total: number; page: number; pageSize: number }
>(`/contests/reviews/judge/contests/${contestId}/works`, { params });
return response;
},
}; };
// 公告管理 // 公告管理

View File

@ -178,6 +178,16 @@ const baseRoutes: RouteRecordRaw[] = [
permissions: ["activity:read"], permissions: ["activity:read"],
}, },
}, },
// 评委评审详情页
{
path: "activities/review/:id",
name: "ReviewDetail",
component: () => import("@/views/activities/ReviewDetail.vue"),
meta: {
title: "作品评审",
requiresAuth: true,
},
},
// 3D建模实验室路由工作台模块下 // 3D建模实验室路由工作台模块下
{ {
path: "workbench/3d-lab", path: "workbench/3d-lab",

View File

@ -51,6 +51,7 @@ const componentMap: Record<string, () => Promise<any>> = {
// 赛事活动模块(教师/评委) // 赛事活动模块(教师/评委)
"activities/Guidance": () => import("@/views/activities/Guidance.vue"), "activities/Guidance": () => import("@/views/activities/Guidance.vue"),
"activities/Review": () => import("@/views/activities/Review.vue"), "activities/Review": () => import("@/views/activities/Review.vue"),
"activities/ReviewDetail": () => import("@/views/activities/ReviewDetail.vue"),
"activities/Comments": () => import("@/views/activities/Comments.vue"), "activities/Comments": () => import("@/views/activities/Comments.vue"),
// 系统管理模块 // 系统管理模块
"system/users/Index": () => import("@/views/system/users/Index.vue"), "system/users/Index": () => import("@/views/system/users/Index.vue"),

View File

@ -4,73 +4,38 @@
<template #title>评审作品</template> <template #title>评审作品</template>
</a-card> </a-card>
<!-- 搜索表单 -->
<a-form
:model="searchParams"
layout="inline"
class="search-form"
@finish="handleSearch"
>
<a-form-item label="赛事名称">
<a-input
v-model:value="searchParams.contestName"
placeholder="请输入赛事名称"
allow-clear
style="width: 200px"
/>
</a-form-item>
<a-form-item label="作品名称">
<a-input
v-model:value="searchParams.workName"
placeholder="请输入作品名称"
allow-clear
style="width: 150px"
/>
</a-form-item>
<a-form-item label="评审状态">
<a-select
v-model:value="searchParams.status"
placeholder="请选择状态"
allow-clear
style="width: 120px"
>
<a-select-option value="pending">待评审</a-select-option>
<a-select-option value="reviewed">已评审</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button style="margin-left: 8px" @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-form-item>
</a-form>
<!-- 数据表格 --> <!-- 数据表格 -->
<a-table <a-table
:columns="columns" :columns="columns"
:data-source="dataSource" :data-source="dataSource"
:loading="loading" :loading="loading"
:pagination="pagination" :pagination="false"
row-key="id" row-key="id"
@change="handleTableChange"
> >
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'status'"> <template v-if="column.key === 'index'">
<a-tag :color="record.status === 'reviewed' ? 'success' : 'warning'"> {{ index + 1 }}
{{ record.status === "reviewed" ? "已评审" : "待评审" }} </template>
</a-tag> <template v-else-if="column.key === 'contestName'">
<a @click="handleViewDetail(record)">{{ record.contestName }}</a>
</template>
<template v-else-if="column.key === 'progress'">
<a-progress
:percent="getProgressPercent(record)"
:status="getProgressStatus(record)"
size="small"
style="width: 120px"
/>
<span class="progress-text">{{ record.reviewed }}/{{ record.totalAssigned }}</span>
</template>
<template v-else-if="column.key === 'reviewStatus'">
<a-tag v-if="record.pending === 0" color="success">已完成</a-tag>
<a-tag v-else color="processing">评审中</a-tag>
</template> </template>
<template v-else-if="column.key === 'action'"> <template v-else-if="column.key === 'action'">
<a-space> <a-button type="link" size="small" @click="handleViewDetail(record)">
<a-button type="link" size="small" @click="handleReview(record)"> 进入评审
{{ record.status === "reviewed" ? "查看" : "评审" }}
</a-button> </a-button>
</a-space>
</template> </template>
</template> </template>
</a-table> </a-table>
@ -78,120 +43,84 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from "vue" import { ref, onMounted } from "vue"
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons-vue" import { useRouter, useRoute } from "vue-router"
import type { TablePaginationConfig } from "ant-design-vue" import { message } from "ant-design-vue"
import { reviewsApi } from "@/api/contests"
// const router = useRouter()
const searchParams = reactive({ const route = useRoute()
contestName: "", const tenantCode = route.params.tenantCode as string
workName: "",
status: undefined as string | undefined,
})
// //
const loading = ref(false) const loading = ref(false)
const dataSource = ref<any[]>([]) const dataSource = ref<any[]>([])
const pagination = reactive<TablePaginationConfig>({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total: number) => `${total}`,
})
// //
const columns = [ const columns = [
{ {
title: "序号", title: "序号",
key: "index", key: "index",
width: 60, width: 70,
customRender: ({ index }: { index: number }) => index + 1,
}, },
{ {
title: "赛事名称", title: "赛事名称",
key: "contestName", key: "contestName",
dataIndex: "contestName", dataIndex: "contestName",
width: 300,
},
{
title: "评审进度",
key: "progress",
width: 200, width: 200,
}, },
{ {
title: "作品编号", title: "待评审",
key: "workNo", key: "pending",
dataIndex: "workNo", dataIndex: "pending",
width: 120,
},
{
title: "作品名称",
key: "workName",
dataIndex: "workName",
width: 200,
},
{
title: "参赛者",
key: "participant",
dataIndex: "participant",
width: 120,
},
{
title: "评审状态",
key: "status",
dataIndex: "status",
width: 100, width: 100,
}, },
{ {
title: "评分", title: "状态",
key: "score", key: "reviewStatus",
dataIndex: "score", width: 100,
width: 80,
}, },
{ {
title: "操作", title: "操作",
key: "action", key: "action",
width: 100, width: 120,
fixed: "right" as const, fixed: "right" as const,
}, },
] ]
//
const getProgressPercent = (record: any) => {
if (record.totalAssigned === 0) return 0
return Math.round((record.reviewed / record.totalAssigned) * 100)
}
//
const getProgressStatus = (record: any) => {
if (record.pending === 0) return "success"
return "active"
}
// //
const fetchList = async () => { const fetchList = async () => {
loading.value = true loading.value = true
try { try {
// TODO: API const data = await reviewsApi.getJudgeContests()
dataSource.value = [] dataSource.value = data
pagination.total = 0 } catch (error: any) {
} catch (error) { message.error(error?.response?.data?.message || "获取数据失败")
console.error("获取数据失败", error)
} finally { } finally {
loading.value = false loading.value = false
} }
} }
// //
const handleSearch = () => { const handleViewDetail = (record: any) => {
pagination.current = 1 router.push(`/${tenantCode}/activities/review/${record.id}`)
fetchList()
}
//
const handleReset = () => {
searchParams.contestName = ""
searchParams.workName = ""
searchParams.status = undefined
pagination.current = 1
fetchList()
}
//
const handleTableChange = (pag: TablePaginationConfig) => {
pagination.current = pag.current || 1
pagination.pageSize = pag.pageSize || 10
fetchList()
}
//
const handleReview = (record: any) => {
console.log("评审", record)
} }
onMounted(() => { onMounted(() => {
@ -199,16 +128,91 @@ onMounted(() => {
}) })
</script> </script>
<style scoped> <style scoped lang="scss">
$primary: #1890ff;
$primary-dark: #0958d9;
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
.review-page { .review-page {
padding: 0; :deep(.ant-card) {
border: none;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
.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);
}
} }
.search-form { .ant-card-body {
margin-bottom: 16px; 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;
}
}
} }
.mb-4 { .mb-4 {
margin-bottom: 16px; margin-bottom: 16px;
} }
.progress-text {
margin-left: 8px;
font-size: 12px;
color: #666;
}
</style> </style>

View File

@ -0,0 +1,365 @@
<template>
<div class="review-detail-page">
<a-card class="mb-4">
<template #title>
<a-breadcrumb>
<a-breadcrumb-item>
<router-link :to="`/${tenantCode}/activities/review`">评审作品</router-link>
</a-breadcrumb-item>
<a-breadcrumb-item>{{ contestName || '作品评审' }}</a-breadcrumb-item>
</a-breadcrumb>
</template>
</a-card>
<!-- 搜索表单 -->
<a-form
:model="searchParams"
layout="inline"
class="search-form"
@finish="handleSearch"
>
<a-form-item label="作品编号">
<a-input
v-model:value="searchParams.workNo"
placeholder="请输入作品编号"
allow-clear
style="width: 150px"
/>
</a-form-item>
<a-form-item label="报名账号">
<a-input
v-model:value="searchParams.accountNo"
placeholder="请输入报名账号"
allow-clear
style="width: 150px"
/>
</a-form-item>
<a-form-item label="评审状态">
<a-select
v-model:value="searchParams.reviewStatus"
placeholder="请选择状态"
allow-clear
style="width: 120px"
>
<a-select-option value="pending">未评审</a-select-option>
<a-select-option value="reviewed">已评审</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button style="margin-left: 8px" @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-form-item>
</a-form>
<!-- 数据表格 -->
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
:row-selection="rowSelection"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'index'">
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
</template>
<template v-else-if="column.key === 'workNo'">
<a @click="handleReview(record)">{{ record.workNo || "-" }}</a>
</template>
<template v-else-if="column.key === 'score'">
<span v-if="record.score !== null" class="score">
{{ Number(record.score).toFixed(2) }}
</span>
<span v-else class="text-gray">-</span>
</template>
<template v-else-if="column.key === 'reviewStatus'">
<a-tag v-if="record.reviewStatus === 'reviewed'" color="success">已评审</a-tag>
<a-tag v-else color="default">未评审</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click="handleReview(record)">
{{ record.reviewStatus === 'reviewed' ? '查看' : '评审' }}
</a-button>
</template>
</template>
</a-table>
<!-- 作品评审弹框 -->
<ReviewWorkModal
v-model:open="reviewModalVisible"
:assignment-id="currentAssignmentId"
:work-id="currentWorkId"
@success="handleReviewSuccess"
/>
</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 { TableProps } from "ant-design-vue"
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons-vue"
import { reviewsApi, contestsApi } from "@/api/contests"
import ReviewWorkModal from "./components/ReviewWorkModal.vue"
const route = useRoute()
const tenantCode = route.params.tenantCode as string
const contestId = Number(route.params.id)
//
const contestName = ref("")
//
const loading = ref(false)
const dataSource = ref<any[]>([])
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
})
//
const searchParams = reactive({
workNo: "",
accountNo: "",
reviewStatus: undefined as string | undefined,
})
//
const selectedRowKeys = ref<number[]>([])
const rowSelection = computed<TableProps["rowSelection"]>(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys: any) => {
selectedRowKeys.value = keys
},
}))
//
const columns = [
{
title: "序号",
key: "index",
width: 70,
},
{
title: "作品编号",
key: "workNo",
width: 150,
},
{
title: "报名账号",
key: "accountNo",
dataIndex: "accountNo",
width: 150,
},
{
title: "评分",
key: "score",
width: 100,
},
{
title: "评审状态",
key: "reviewStatus",
width: 100,
},
{
title: "操作",
key: "action",
width: 100,
fixed: "right" as const,
},
]
//
const reviewModalVisible = ref(false)
const currentAssignmentId = ref<number | null>(null)
const currentWorkId = ref<number | null>(null)
//
const fetchContestInfo = async () => {
try {
const contest = await contestsApi.getDetail(contestId)
contestName.value = contest.contestName
} catch (error) {
console.error("获取赛事信息失败", error)
}
}
//
const fetchList = async () => {
loading.value = true
try {
const response = await reviewsApi.getJudgeContestWorks(contestId, {
page: pagination.current,
pageSize: pagination.pageSize,
workNo: searchParams.workNo || undefined,
accountNo: searchParams.accountNo || undefined,
reviewStatus: searchParams.reviewStatus || undefined,
})
dataSource.value = response.list
pagination.total = response.total
} catch (error: any) {
message.error(error?.response?.data?.message || "获取数据失败")
} finally {
loading.value = false
}
}
//
const handleSearch = () => {
pagination.current = 1
fetchList()
}
//
const handleReset = () => {
searchParams.workNo = ""
searchParams.accountNo = ""
searchParams.reviewStatus = undefined
pagination.current = 1
fetchList()
}
//
const handleTableChange = (pag: any) => {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
fetchList()
}
//
const handleReview = (record: any) => {
currentAssignmentId.value = record.id
currentWorkId.value = record.workId
reviewModalVisible.value = true
}
//
const handleReviewSuccess = () => {
fetchList()
}
onMounted(() => {
fetchContestInfo()
fetchList()
})
</script>
<style scoped lang="scss">
$primary: #1890ff;
$primary-dark: #0958d9;
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
.review-detail-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;
}
}
}
.search-form {
margin-bottom: 16px;
padding: 20px 24px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 16px 24px;
:deep(.ant-form-item) {
margin-bottom: 0;
margin-right: 0;
}
}
.mb-4 {
margin-bottom: 16px;
}
.score {
font-weight: bold;
color: #52c41a;
}
.text-gray {
color: #999;
}
</style>

View File

@ -0,0 +1,331 @@
<template>
<a-modal
:open="open"
:title="isReviewed ? '查看评审' : '作品评审'"
width="900px"
:footer="null"
@cancel="handleClose"
>
<a-spin :spinning="loading">
<div class="review-work-modal">
<!-- 作品信息 -->
<a-card title="作品信息" size="small" class="mb-4">
<a-descriptions :column="2" size="small">
<a-descriptions-item label="作品编号">
{{ workDetail?.workNo || '-' }}
</a-descriptions-item>
<a-descriptions-item label="作品名称">
{{ workDetail?.title || '-' }}
</a-descriptions-item>
<a-descriptions-item label="参赛者">
{{ workDetail?.registration?.user?.nickname || '-' }}
</a-descriptions-item>
<a-descriptions-item label="报名账号">
{{ workDetail?.submitterAccountNo || workDetail?.registration?.user?.username || '-' }}
</a-descriptions-item>
<a-descriptions-item label="提交时间" :span="2">
{{ formatDate(workDetail?.submitTime) }}
</a-descriptions-item>
<a-descriptions-item label="作品描述" :span="2">
{{ workDetail?.description || '-' }}
</a-descriptions-item>
</a-descriptions>
<!-- 作品附件 -->
<div v-if="workDetail?.attachments?.length" class="attachments-section">
<div class="section-title">作品附件</div>
<div class="attachment-list">
<a
v-for="attachment in workDetail.attachments"
:key="attachment.id"
:href="attachment.fileUrl"
target="_blank"
class="attachment-item"
>
<FileOutlined />
<span>{{ attachment.fileName }}</span>
</a>
</div>
</div>
<!-- 3D模型预览 -->
<div v-if="has3DModel" class="model-preview-section">
<div class="section-title">3D模型预览</div>
<a-button type="primary" @click="handlePreview3D">
<template #icon><EyeOutlined /></template>
预览3D模型
</a-button>
</div>
</a-card>
<!-- 评分表单 -->
<a-card title="评分" size="small">
<a-form
ref="formRef"
:model="scoreForm"
:rules="rules"
layout="vertical"
>
<a-form-item label="评分" name="score">
<a-input-number
v-model:value="scoreForm.score"
:min="0"
:max="100"
:precision="2"
:disabled="isReviewed"
style="width: 200px"
placeholder="请输入分数0-100"
/>
<span class="score-hint">满分100分</span>
</a-form-item>
<a-form-item label="评语" name="comments">
<a-textarea
v-model:value="scoreForm.comments"
:rows="4"
:disabled="isReviewed"
placeholder="请输入评语(可选)"
:maxlength="500"
show-count
/>
</a-form-item>
</a-form>
<div v-if="!isReviewed" class="form-actions">
<a-button @click="handleClose">取消</a-button>
<a-button type="primary" :loading="submitLoading" @click="handleSubmit">
提交评分
</a-button>
</div>
</a-card>
</div>
</a-spin>
</a-modal>
</template>
<script setup lang="ts">
import { ref, reactive, watch, computed } from "vue"
import { useRoute, useRouter } from "vue-router"
import { message } from "ant-design-vue"
import type { FormInstance, Rule } from "ant-design-vue/es/form"
import { FileOutlined, EyeOutlined } from "@ant-design/icons-vue"
import dayjs from "dayjs"
import { worksApi, reviewsApi } from "@/api/contests"
const props = defineProps<{
open: boolean
assignmentId: number | null
workId: number | null
}>()
const emit = defineEmits<{
(e: "update:open", value: boolean): void
(e: "success"): void
}>()
const route = useRoute()
const router = useRouter()
const tenantCode = route.params.tenantCode as string
//
const formRef = ref<FormInstance>()
//
const loading = ref(false)
const submitLoading = ref(false)
//
const workDetail = ref<any>(null)
//
const existingScore = ref<any>(null)
//
const isReviewed = computed(() => existingScore.value !== null)
//
const scoreForm = reactive({
score: undefined as number | undefined,
comments: "",
})
//
const rules: Record<string, Rule[]> = {
score: [
{ required: true, message: "请输入评分", trigger: "blur" },
{ type: "number", min: 0, max: 100, message: "评分范围为0-100", trigger: "blur" },
],
}
// 3D
const has3DModel = computed(() => {
if (!workDetail.value?.attachments) return false
return workDetail.value.attachments.some((a: any) =>
['.glb', '.gltf', '.obj', '.fbx', '.stl'].some(ext =>
a.fileName?.toLowerCase().endsWith(ext) || a.fileUrl?.toLowerCase().endsWith(ext)
)
)
})
//
const formatDate = (dateStr?: string) => {
if (!dateStr) return "-"
return dayjs(dateStr).format("YYYY-MM-DD HH:mm")
}
//
const fetchWorkDetail = async () => {
if (!props.workId) return
loading.value = true
try {
const detail = await worksApi.getDetail(props.workId)
workDetail.value = detail
//
const scores = await reviewsApi.getWorkScores(props.workId)
//
const myScore = scores.find((s: any) => s.assignmentId === props.assignmentId)
if (myScore && myScore.score !== null) {
existingScore.value = myScore
scoreForm.score = Number(myScore.score)
scoreForm.comments = myScore.comments || ""
} else {
existingScore.value = null
scoreForm.score = undefined
scoreForm.comments = ""
}
} catch (error: any) {
message.error(error?.response?.data?.message || "获取作品详情失败")
} finally {
loading.value = false
}
}
// 3D
const handlePreview3D = () => {
if (!workDetail.value?.attachments) return
const modelAttachment = workDetail.value.attachments.find((a: any) =>
['.glb', '.gltf', '.obj', '.fbx', '.stl'].some(ext =>
a.fileName?.toLowerCase().endsWith(ext) || a.fileUrl?.toLowerCase().endsWith(ext)
)
)
if (modelAttachment) {
// URLsessionStorage
sessionStorage.setItem('previewModelUrl', modelAttachment.fileUrl)
//
window.open(`/${tenantCode}/workbench/model-viewer`, '_blank')
}
}
//
const handleSubmit = async () => {
if (!props.assignmentId || !props.workId) return
try {
await formRef.value?.validate()
} catch {
return
}
submitLoading.value = true
try {
await reviewsApi.score({
assignmentId: props.assignmentId,
workId: props.workId,
totalScore: scoreForm.score!,
comments: scoreForm.comments || undefined,
})
message.success("评分提交成功")
emit("success")
handleClose()
} catch (error: any) {
message.error(error?.response?.data?.message || "提交失败")
} finally {
submitLoading.value = false
}
}
//
const handleClose = () => {
emit("update:open", false)
}
//
watch(
() => props.open,
(val) => {
if (val && props.workId) {
fetchWorkDetail()
} else {
//
workDetail.value = null
existingScore.value = null
scoreForm.score = undefined
scoreForm.comments = ""
}
}
)
</script>
<style scoped lang="scss">
.review-work-modal {
max-height: 70vh;
overflow-y: auto;
}
.mb-4 {
margin-bottom: 16px;
}
.attachments-section,
.model-preview-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.section-title {
font-weight: 600;
margin-bottom: 12px;
color: rgba(0, 0, 0, 0.85);
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.attachment-item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
background: #f5f5f5;
border-radius: 4px;
color: #1890ff;
text-decoration: none;
transition: all 0.2s;
&:hover {
background: #e6f7ff;
}
}
.score-hint {
margin-left: 12px;
color: #999;
font-size: 12px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
</style>

View File

@ -388,7 +388,7 @@ const handleMyGuidance = (id: number) => {
// ===== ===== // ===== =====
// //
const handleReviewWorks = (id: number) => { const handleReviewWorks = (id: number) => {
router.push(`/${tenantCode}/student-activities/review?contestId=${id}`) router.push(`/${tenantCode}/activities/review/${id}`)
} }
// //
@ -529,8 +529,6 @@ $primary-dark: #0958d9;
$primary-light: #40a9ff; $primary-light: #40a9ff;
.contests-activities-page { .contests-activities-page {
padding: 24px;
// //
.page-header { .page-header {
display: flex; display: flex;
@ -835,12 +833,20 @@ $primary-light: #40a9ff;
padding: 6px 16px; padding: 6px 16px;
height: auto; height: auto;
font-size: 13px; font-size: 13px;
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%); background: linear-gradient(
135deg,
$primary 0%,
$primary-dark 100%
);
box-shadow: 0 2px 8px rgba($primary, 0.3); box-shadow: 0 2px 8px rgba($primary, 0.3);
transition: all 0.3s ease; transition: all 0.3s ease;
&:hover { &:hover {
background: linear-gradient(135deg, $primary-light 0%, $primary 100%); background: linear-gradient(
135deg,
$primary-light 0%,
$primary 100%
);
box-shadow: 0 4px 12px rgba($primary, 0.4); box-shadow: 0 4px 12px rgba($primary, 0.4);
transform: translateY(-1px); transform: translateY(-1px);
} }
@ -892,8 +898,6 @@ $primary-light: #40a9ff;
// //
@media (max-width: 768px) { @media (max-width: 768px) {
.contests-activities-page { .contests-activities-page {
padding: 16px;
.contests-grid { .contests-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 16px; gap: 16px;

View File

@ -17,7 +17,9 @@
>赛事管理</router-link >赛事管理</router-link
> >
</a-breadcrumb-item> </a-breadcrumb-item>
<a-breadcrumb-item>{{ isEdit ? '编辑比赛' : '创建比赛' }}</a-breadcrumb-item> <a-breadcrumb-item>{{
isEdit ? "编辑比赛" : "创建比赛"
}}</a-breadcrumb-item>
</a-breadcrumb> </a-breadcrumb>
</a-space> </a-space>
</template> </template>
@ -274,7 +276,9 @@ const route = useRoute()
const tenantCode = route.params.tenantCode as string const tenantCode = route.params.tenantCode as string
// //
const contestId = computed(() => route.params.id ? Number(route.params.id) : null) const contestId = computed(() =>
route.params.id ? Number(route.params.id) : null
)
const isEdit = computed(() => !!contestId.value) const isEdit = computed(() => !!contestId.value)
const pageLoading = ref(false) const pageLoading = ref(false)
@ -326,12 +330,12 @@ const reviewRuleOptions = ref<{ value: number; label: string }[]>([])
const fetchReviewRules = async () => { const fetchReviewRules = async () => {
try { try {
const rules = await reviewRulesApi.getForSelect() const rules = await reviewRulesApi.getForSelect()
reviewRuleOptions.value = rules.map(rule => ({ reviewRuleOptions.value = rules.map((rule) => ({
value: rule.id, value: rule.id,
label: rule.ruleName, label: rule.ruleName,
})) }))
} catch (error) { } catch (error) {
console.error('获取评审规则列表失败:', error) console.error("获取评审规则列表失败:", error)
} }
} }
@ -649,45 +653,54 @@ const loadContestData = async () => {
const contest = await contestsApi.getDetail(contestId.value) const contest = await contestsApi.getDetail(contestId.value)
// //
form.contestName = contest.contestName || '' form.contestName = contest.contestName || ""
form.contestType = contest.contestType || 'individual' form.contestType = contest.contestType || "individual"
form.startTime = contest.startTime || '' form.startTime = contest.startTime || ""
form.endTime = contest.endTime || '' form.endTime = contest.endTime || ""
form.content = contest.content || '' form.content = contest.content || ""
form.coverUrl = contest.coverUrl || '' form.coverUrl = contest.coverUrl || ""
form.posterUrl = contest.posterUrl || '' form.posterUrl = contest.posterUrl || ""
// // // //
form.organizers = Array.isArray(contest.organizers) form.organizers = Array.isArray(contest.organizers)
? contest.organizers.join('、') ? contest.organizers.join("、")
: (contest.organizers || '') : contest.organizers || ""
form.coOrganizers = Array.isArray(contest.coOrganizers) form.coOrganizers = Array.isArray(contest.coOrganizers)
? contest.coOrganizers.join('、') ? contest.coOrganizers.join("、")
: (contest.coOrganizers || '') : contest.coOrganizers || ""
form.sponsors = Array.isArray(contest.sponsors) form.sponsors = Array.isArray(contest.sponsors)
? contest.sponsors.join('、') ? contest.sponsors.join("、")
: (contest.sponsors || '') : contest.sponsors || ""
form.registerStartTime = contest.registerStartTime || '' form.registerStartTime = contest.registerStartTime || ""
form.registerEndTime = contest.registerEndTime || '' form.registerEndTime = contest.registerEndTime || ""
form.submitRule = contest.submitRule || 'once' form.submitRule = contest.submitRule || "once"
form.submitStartTime = contest.submitStartTime || '' form.submitStartTime = contest.submitStartTime || ""
form.submitEndTime = contest.submitEndTime || '' form.submitEndTime = contest.submitEndTime || ""
form.reviewRuleId = contest.reviewRuleId || undefined form.reviewRuleId = contest.reviewRuleId || undefined
form.reviewStartTime = contest.reviewStartTime || '' form.reviewStartTime = contest.reviewStartTime || ""
form.reviewEndTime = contest.reviewEndTime || '' form.reviewEndTime = contest.reviewEndTime || ""
form.resultPublishTime = contest.resultPublishTime || '' form.resultPublishTime = contest.resultPublishTime || ""
// //
if (contest.startTime && contest.endTime) { if (contest.startTime && contest.endTime) {
timeRange.value = [dayjs(contest.startTime), dayjs(contest.endTime)] timeRange.value = [dayjs(contest.startTime), dayjs(contest.endTime)]
} }
if (contest.registerStartTime && contest.registerEndTime) { if (contest.registerStartTime && contest.registerEndTime) {
registerTimeRange.value = [dayjs(contest.registerStartTime), dayjs(contest.registerEndTime)] registerTimeRange.value = [
dayjs(contest.registerStartTime),
dayjs(contest.registerEndTime),
]
} }
if (contest.submitStartTime && contest.submitEndTime) { if (contest.submitStartTime && contest.submitEndTime) {
submitTimeRange.value = [dayjs(contest.submitStartTime), dayjs(contest.submitEndTime)] submitTimeRange.value = [
dayjs(contest.submitStartTime),
dayjs(contest.submitEndTime),
]
} }
if (contest.reviewStartTime && contest.reviewEndTime) { if (contest.reviewStartTime && contest.reviewEndTime) {
reviewTimeRange.value = [dayjs(contest.reviewStartTime), dayjs(contest.reviewEndTime)] reviewTimeRange.value = [
dayjs(contest.reviewStartTime),
dayjs(contest.reviewEndTime),
]
} }
if (contest.resultPublishTime) { if (contest.resultPublishTime) {
resultPublishTime.value = dayjs(contest.resultPublishTime) resultPublishTime.value = dayjs(contest.resultPublishTime)
@ -695,35 +708,41 @@ const loadContestData = async () => {
// //
if (contest.coverUrl) { if (contest.coverUrl) {
coverFileList.value = [{ coverFileList.value = [
uid: '-1', {
name: 'cover', uid: "-1",
status: 'done', name: "cover",
status: "done",
url: contest.coverUrl, url: contest.coverUrl,
}] },
]
} }
// //
if (contest.posterUrl) { if (contest.posterUrl) {
posterFileList.value = [{ posterFileList.value = [
uid: '-2', {
name: 'poster', uid: "-2",
status: 'done', name: "poster",
status: "done",
url: contest.posterUrl, url: contest.posterUrl,
}] },
]
} }
// //
if (contest.attachments && contest.attachments.length > 0) { if (contest.attachments && contest.attachments.length > 0) {
attachmentFileList.value = contest.attachments.map((att: any, index: number) => ({ attachmentFileList.value = contest.attachments.map(
(att: any, index: number) => ({
uid: `-${index + 3}`, uid: `-${index + 3}`,
name: att.fileName, name: att.fileName,
status: 'done', status: "done",
url: att.fileUrl, url: att.fileUrl,
})) })
)
} }
} catch (error: any) { } catch (error: any) {
message.error(error?.response?.data?.message || '加载赛事数据失败') message.error(error?.response?.data?.message || "加载赛事数据失败")
router.back() router.back()
} finally { } finally {
pageLoading.value = false pageLoading.value = false
@ -738,24 +757,24 @@ const handleSubmit = async () => {
// null/undefined // null/undefined
const submitData: CreateContestForm = { const submitData: CreateContestForm = {
contestName: form.contestName || '', contestName: form.contestName || "",
contestType: form.contestType || 'individual', contestType: form.contestType || "individual",
startTime: form.startTime || '', startTime: form.startTime || "",
endTime: form.endTime || '', endTime: form.endTime || "",
content: form.content || '', content: form.content || "",
coverUrl: form.coverUrl || '', coverUrl: form.coverUrl || "",
posterUrl: form.posterUrl || '', posterUrl: form.posterUrl || "",
organizers: form.organizers || '', organizers: form.organizers || "",
coOrganizers: form.coOrganizers || '', coOrganizers: form.coOrganizers || "",
sponsors: form.sponsors || '', sponsors: form.sponsors || "",
registerStartTime: form.registerStartTime || '', registerStartTime: form.registerStartTime || "",
registerEndTime: form.registerEndTime || '', registerEndTime: form.registerEndTime || "",
submitRule: form.submitRule || 'once', submitRule: form.submitRule || "once",
submitStartTime: form.submitStartTime || '', submitStartTime: form.submitStartTime || "",
submitEndTime: form.submitEndTime || '', submitEndTime: form.submitEndTime || "",
reviewRuleId: form.reviewRuleId || undefined, reviewRuleId: form.reviewRuleId || undefined,
reviewStartTime: form.reviewStartTime || '', reviewStartTime: form.reviewStartTime || "",
reviewEndTime: form.reviewEndTime || '', reviewEndTime: form.reviewEndTime || "",
resultPublishTime: form.resultPublishTime || undefined, resultPublishTime: form.resultPublishTime || undefined,
} }
@ -801,7 +820,9 @@ const handleSubmit = async () => {
// //
return return
} }
message.error(error?.response?.data?.message || (isEdit.value ? "保存失败" : "创建失败")) message.error(
error?.response?.data?.message || (isEdit.value ? "保存失败" : "创建失败")
)
} finally { } finally {
submitLoading.value = false submitLoading.value = false
} }
@ -827,6 +848,7 @@ onMounted(() => {
.create-contest-page { .create-contest-page {
padding: 16px 24px; padding: 16px 24px;
max-width: 1200px; max-width: 1200px;
background-color: #fff;
} }
.create-contest-page :deep(.ant-card) { .create-contest-page :deep(.ant-card) {

View File

@ -497,12 +497,94 @@ onMounted(() => {
}) })
</script> </script>
<style scoped> <style scoped lang="scss">
//
$primary: #1890ff;
$primary-dark: #0958d9;
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
.contests-page {
//
:deep(.ant-card) {
border: none;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
.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;
}
}
}
.search-form { .search-form {
margin-bottom: 16px; margin-bottom: 16px;
padding: 16px; padding: 20px 24px;
background: #fff; background: #fff;
border-radius: 8px; border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
} }
.mb-4 { .mb-4 {

View File

@ -1,19 +1,7 @@
<template> <template>
<div class="judges-page"> <div class="judges-page">
<a-card class="mb-4"> <a-card class="mb-4">
<template #title> <template #title>评委管理</template>
<a-breadcrumb>
<a-breadcrumb-item>
<router-link :to="`/${tenantCode}/contests`">赛事管理</router-link>
</a-breadcrumb-item>
<a-breadcrumb-item v-if="isValidContestId">
<router-link :to="`/${tenantCode}/contests/${contestId}`">{{
contestName || "赛事详情"
}}</router-link>
</a-breadcrumb-item>
<a-breadcrumb-item>评委管理</a-breadcrumb-item>
</a-breadcrumb>
</template>
<template #extra> <template #extra>
<a-space> <a-space>
<a-button <a-button
@ -302,12 +290,12 @@ const contestName = ref("")
// //
const selectedRowKeys = ref<number[]>([]) const selectedRowKeys = ref<number[]>([])
const rowSelection: TableProps["rowSelection"] = { const rowSelection = computed<TableProps["rowSelection"]>(() => ({
selectedRowKeys: selectedRowKeys.value, selectedRowKeys: selectedRowKeys.value,
onChange: (keys: any) => { onChange: (keys: any) => {
selectedRowKeys.value = keys selectedRowKeys.value = keys
}, },
} }))
// //
const drawerVisible = ref(false) const drawerVisible = ref(false)
@ -546,8 +534,94 @@ onMounted(() => {
}) })
</script> </script>
<style scoped> <style scoped lang="scss">
//
$primary: #1890ff;
$primary-dark: #0958d9;
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
.judges-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;
}
}
}
.search-form { .search-form {
margin-bottom: 16px; margin-bottom: 16px;
padding: 20px 24px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
} }
</style> </style>

View File

@ -1,14 +1,8 @@
<template> <template>
<div class="notices-page"> <div class="notices-page">
<a-card> <!-- 标题卡片 -->
<template #title> <a-card class="mb-4">
<a-breadcrumb> <template #title>公告管理</template>
<a-breadcrumb-item>
<router-link :to="`/${tenantCode}/contests`">赛事管理</router-link>
</a-breadcrumb-item>
<a-breadcrumb-item>公告管理</a-breadcrumb-item>
</a-breadcrumb>
</template>
<template #extra> <template #extra>
<a-button <a-button
v-permission="'notice:create'" v-permission="'notice:create'"
@ -19,9 +13,10 @@
新建公告 新建公告
</a-button> </a-button>
</template> </template>
</a-card>
<!-- 搜索区域 --> <!-- 搜索表单 -->
<a-form layout="inline" :model="searchForm" style="margin-bottom: 16px"> <a-form layout="inline" :model="searchForm" class="search-form" @finish="handleSearch">
<a-form-item label="标题名称"> <a-form-item label="标题名称">
<a-input <a-input
v-model:value="searchForm.title" v-model:value="searchForm.title"
@ -39,10 +34,14 @@
/> />
</a-form-item> </a-form-item>
<a-form-item> <a-form-item>
<a-space> <a-button type="primary" html-type="submit">
<a-button type="primary" @click="handleSearch">搜索</a-button> <template #icon><SearchOutlined /></template>
<a-button @click="handleReset">重置</a-button> 搜索
</a-space> </a-button>
<a-button style="margin-left: 8px" @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-form-item> </a-form-item>
</a-form> </a-form>
@ -102,7 +101,6 @@
</template> </template>
</template> </template>
</a-table> </a-table>
</a-card>
<!-- 公告详情弹窗 --> <!-- 公告详情弹窗 -->
<a-modal <a-modal
@ -236,7 +234,7 @@
import { ref, onMounted, reactive } from "vue" import { ref, onMounted, reactive } from "vue"
import { useRoute } from "vue-router" import { useRoute } from "vue-router"
import { message } from "ant-design-vue" import { message } from "ant-design-vue"
import { PlusOutlined, UploadOutlined } from "@ant-design/icons-vue" import { PlusOutlined, UploadOutlined, SearchOutlined, ReloadOutlined } from "@ant-design/icons-vue"
import dayjs, { type Dayjs } from "dayjs" import dayjs, { type Dayjs } from "dayjs"
import type { FormInstance, UploadFile } from "ant-design-vue" import type { FormInstance, UploadFile } from "ant-design-vue"
import { import {
@ -596,19 +594,107 @@ onMounted(() => {
}) })
</script> </script>
<style scoped> <style scoped lang="scss">
.notices-page :deep(.ant-card) { //
$primary: #1890ff;
$primary-dark: #0958d9;
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
.notices-page {
//
:deep(.ant-card) {
border: none; border: none;
box-shadow: none; border-radius: 12px;
} box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
.notices-page :deep(.ant-card-head) { .ant-card-head {
border-bottom: none; border-bottom: none;
padding: 12px 0; padding: 16px 24px;
min-height: auto;
.ant-card-head-title {
font-size: 18px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
} }
.notices-page :deep(.ant-card-body) { .ant-card-body {
padding: 16px 0; 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;
}
}
}
.search-form {
margin-bottom: 16px;
padding: 20px 24px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
//
:deep(.ant-form-item) {
margin-bottom: 16px;
margin-right: 16px;
&:last-child {
margin-right: 0;
}
}
}
.mb-4 {
margin-bottom: 16px;
} }
</style> </style>

View File

@ -396,10 +396,113 @@ onMounted(() => {
}) })
</script> </script>
<style scoped> <style scoped lang="scss">
//
$primary: #1890ff;
$primary-dark: #0958d9;
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
.registrations-page { .registrations-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;
}
}
// Tab
:deep(.ant-tabs) {
background: #fff;
padding: 0 24px;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
margin-bottom: 16px;
}
//
: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;
}
}
}
.search-form { .search-form {
margin-bottom: 16px; margin-bottom: 16px;
padding: 20px 24px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
//
:deep(.ant-form-item) {
margin-bottom: 16px;
margin-right: 16px;
&:last-child {
margin-right: 0;
}
} }
} }
</style> </style>

View File

@ -946,10 +946,105 @@ onMounted(async () => {
}) })
</script> </script>
<style scoped> <style scoped lang="scss">
//
$primary: #1890ff;
$primary-dark: #0958d9;
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
.registration-records-page { .registration-records-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;
}
}
}
.search-form { .search-form {
margin-bottom: 16px; margin-bottom: 16px;
padding: 20px 24px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
// - 使 flex wrap
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 16px 24px;
:deep(.ant-form-item) {
margin-bottom: 0;
margin-right: 0;
} }
} }

View File

@ -284,18 +284,71 @@ onMounted(() => {
}) })
</script> </script>
<style scoped> <style scoped lang="scss">
$primary: #1890ff;
$primary-dark: #0958d9;
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
.results-detail-page { .results-detail-page {
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;
}
}
} }
.page-header { .page-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 16px; padding: 16px 24px;
background: #fff; background: #fff;
border-radius: 8px; border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
margin-bottom: 16px; margin-bottom: 16px;
} }
@ -306,15 +359,26 @@ onMounted(() => {
} }
.page-title { .page-title {
font-size: 16px; font-size: 18px;
font-weight: 500; font-weight: 600;
color: rgba(0, 0, 0, 0.85);
} }
.search-form { .search-form {
margin-bottom: 16px; margin-bottom: 16px;
padding: 16px; padding: 20px 24px;
background: #fff; background: #fff;
border-radius: 8px; border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 16px 24px;
:deep(.ant-form-item) {
margin-bottom: 0;
margin-right: 0;
}
} }
.org-detail { .org-detail {

View File

@ -188,10 +188,106 @@ onMounted(() => {
}) })
</script> </script>
<style scoped> <style scoped lang="scss">
$primary: #1890ff;
$primary-dark: #0958d9;
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
.results-page { .results-page {
:deep(.ant-card) {
border: none;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
.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-tabs) {
background: #fff;
padding: 0 24px;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
margin-bottom: 16px;
}
: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;
}
}
}
.search-form { .search-form {
margin-bottom: 16px; margin-bottom: 16px;
padding: 20px 24px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
:deep(.ant-form-item) {
margin-bottom: 16px;
margin-right: 16px;
&:last-child {
margin-right: 0;
}
} }
} }

View File

@ -572,9 +572,100 @@ const handleCancel = () => {
} }
</script> </script>
<style scoped> <style scoped lang="scss">
$primary: #1890ff;
$primary-dark: #0958d9;
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
.review-rules-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;
}
}
}
.search-form { .search-form {
margin-bottom: 16px; margin-bottom: 16px;
padding: 20px 24px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
:deep(.ant-form-item) {
margin-bottom: 16px;
margin-right: 16px;
&:last-child {
margin-right: 0;
}
}
} }
.scoring-standards { .scoring-standards {

View File

@ -207,22 +207,107 @@ onMounted(() => {
}) })
</script> </script>
<style scoped> <style scoped lang="scss">
$primary: #1890ff;
$primary-dark: #0958d9;
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
.review-progress-page { .review-progress-page {
padding: 0; :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);
}
} }
.contest-tabs { .ant-card-body {
margin-bottom: 16px; padding: 0;
}
}
:deep(.ant-tabs) {
background: #fff; background: #fff;
padding: 0 16px; padding: 0 24px;
border-radius: 8px; border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
margin-bottom: 16px;
}
: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;
}
}
} }
.search-form { .search-form {
margin-bottom: 16px; margin-bottom: 16px;
padding: 16px; padding: 20px 24px;
background: #fff; background: #fff;
border-radius: 8px; border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
:deep(.ant-form-item) {
margin-bottom: 16px;
margin-right: 16px;
&:last-child {
margin-right: 0;
}
}
} }
</style> </style>

View File

@ -2,12 +2,12 @@
<div class="progress-detail-page"> <div class="progress-detail-page">
<a-card class="mb-4"> <a-card class="mb-4">
<template #title> <template #title>
<a-space> <a-breadcrumb>
<a-button type="text" @click="handleBack"> <a-breadcrumb-item>
<template #icon><ArrowLeftOutlined /></template> <router-link :to="`/${tenantCode}/contests/reviews/progress`">评审进度</router-link>
</a-button> </a-breadcrumb-item>
<span>{{ contestName }}作品评审进度</span> <a-breadcrumb-item>{{ contestName || '作品评审进度' }}</a-breadcrumb-item>
</a-space> </a-breadcrumb>
</template> </template>
<template #extra> <template #extra>
<a-space> <a-space>
@ -257,7 +257,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted } from "vue" import { ref, reactive, computed, onMounted } from "vue"
import { useRoute, useRouter } from "vue-router" import { useRoute, useRouter } from "vue-router"
import { message } from "ant-design-vue" import { message, Modal } from "ant-design-vue"
import type { TableProps } from "ant-design-vue" import type { TableProps } from "ant-design-vue"
import { import {
ArrowLeftOutlined, ArrowLeftOutlined,
@ -272,6 +272,7 @@ import WorkDetailModal from "../components/WorkDetailModal.vue"
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const tenantCode = route.params.tenantCode as string
const contestId = Number(route.params.id) const contestId = Number(route.params.id)
const contestType = (route.query.type as string) || "individual" const contestType = (route.query.type as string) || "individual"
@ -482,7 +483,13 @@ const handleViewWorkDetail = (record: ContestWork) => {
} }
// //
const handleStartReview = async () => { const handleStartReview = () => {
Modal.confirm({
title: "确认开始评审",
content: "确定要开始评审吗?开始后评委可以进行评分操作。",
okText: "确定",
cancelText: "取消",
async onOk() {
try { try {
await contestsApi.update(contestId, { await contestsApi.update(contestId, {
reviewStartTime: new Date().toISOString(), reviewStartTime: new Date().toISOString(),
@ -491,10 +498,18 @@ const handleStartReview = async () => {
} catch (error: any) { } catch (error: any) {
message.error(error?.response?.data?.message || "操作失败") message.error(error?.response?.data?.message || "操作失败")
} }
},
})
} }
// //
const handleEndReview = async () => { const handleEndReview = () => {
Modal.confirm({
title: "确认结束评审",
content: "确定要结束评审吗?结束后评委将无法继续评分。",
okText: "确定",
cancelText: "取消",
async onOk() {
try { try {
await contestsApi.update(contestId, { await contestsApi.update(contestId, {
reviewEndTime: new Date().toISOString(), reviewEndTime: new Date().toISOString(),
@ -503,6 +518,8 @@ const handleEndReview = async () => {
} catch (error: any) { } catch (error: any) {
message.error(error?.response?.data?.message || "操作失败") message.error(error?.response?.data?.message || "操作失败")
} }
},
})
} }
// //
@ -596,22 +613,110 @@ onMounted(() => {
}) })
</script> </script>
<style scoped> <style scoped lang="scss">
$primary: #1890ff;
$primary-dark: #0958d9;
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
.progress-detail-page { .progress-detail-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; 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;
}
}
}
.search-form { .search-form {
margin-bottom: 16px; margin-bottom: 16px;
padding: 16px; padding: 20px 24px;
background: #fff; background: #fff;
border-radius: 8px; border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 16px 24px;
:deep(.ant-form-item) {
margin-bottom: 0;
margin-right: 0;
}
} }
.mb-3 { .mb-3 {
margin-bottom: 12px; margin-bottom: 12px;
} }
.mb-4 {
margin-bottom: 16px;
}
.ml-2 { .ml-2 {
margin-left: 8px; margin-left: 8px;
} }

View File

@ -177,23 +177,107 @@ onMounted(() => {
}) })
</script> </script>
<style scoped> <style scoped lang="scss">
$primary: #1890ff;
$primary-dark: #0958d9;
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
.works-page { .works-page {
padding: 0; :deep(.ant-card) {
border: none;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
.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);
}
} }
.contest-tabs { .ant-card-body {
margin-bottom: 16px; padding: 0;
}
}
:deep(.ant-tabs) {
background: #fff; background: #fff;
padding: 0 16px; padding: 0 24px;
border-radius: 8px; border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
margin-bottom: 16px;
}
: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;
}
}
} }
.search-form { .search-form {
margin-bottom: 16px; margin-bottom: 16px;
padding: 16px; padding: 20px 24px;
background: #fff; background: #fff;
border-radius: 8px; border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
:deep(.ant-form-item) {
margin-bottom: 16px;
margin-right: 16px;
&:last-child {
margin-right: 0;
}
}
} }
.mb-4 { .mb-4 {

View File

@ -2,12 +2,12 @@
<div class="works-detail-page"> <div class="works-detail-page">
<a-card class="mb-4"> <a-card class="mb-4">
<template #title> <template #title>
<a-space> <a-breadcrumb>
<a-button type="text" @click="handleBack"> <a-breadcrumb-item>
<template #icon><ArrowLeftOutlined /></template> <router-link :to="`/${tenantCode}/contests/works`">作品管理</router-link>
</a-button> </a-breadcrumb-item>
<span>{{ contestName }}参赛作品</span> <a-breadcrumb-item>{{ contestName || '参赛作品' }}</a-breadcrumb-item>
</a-space> </a-breadcrumb>
</template> </template>
<template #extra> <template #extra>
<a-button <a-button
@ -334,6 +334,7 @@ interface Tenant {
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const tenantCode = route.params.tenantCode as string
const contestId = Number(route.params.id) const contestId = Number(route.params.id)
const contestType = (route.query.type as string) || "individual" const contestType = (route.query.type as string) || "individual"
@ -658,15 +659,99 @@ onMounted(() => {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
$primary: #1890ff;
$primary-dark: #0958d9;
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
.works-detail-page { .works-detail-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; 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;
}
}
}
.search-form { .search-form {
margin-bottom: 16px; margin-bottom: 16px;
padding: 16px; padding: 20px 24px;
background: #fff; background: #fff;
border-radius: 8px; border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 16px 24px;
:deep(.ant-form-item) {
margin-bottom: 0;
margin-right: 0;
}
} }
.mb-3 { .mb-3 {