修改样式,删除无用的代码
This commit is contained in:
parent
8411df8ad6
commit
e7819fc1c2
@ -1,5 +1,6 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import AdmZip from 'adm-zip';
|
||||
import axios from 'axios';
|
||||
import { Logger } from '@nestjs/common';
|
||||
@ -10,8 +11,8 @@ export class ZipHandler {
|
||||
/**
|
||||
* 下载并解压.zip文件,提取3D模型文件
|
||||
* @param zipUrl ZIP文件的URL
|
||||
* @param outputDir 输出目录(默认为 backend/uploads/ai-3d)
|
||||
* @returns 提取的3D模型文件路径和预览图路径
|
||||
* @param outputDir 输出目录(默认为系统临时目录)
|
||||
* @returns 提取的3D模型文件路径、预览图路径和文件Buffer
|
||||
*/
|
||||
static async downloadAndExtract(
|
||||
zipUrl: string,
|
||||
@ -19,31 +20,32 @@ export class ZipHandler {
|
||||
): Promise<{
|
||||
modelPath: string;
|
||||
previewPath?: string;
|
||||
modelUrl: string;
|
||||
previewUrl?: string;
|
||||
modelBuffer: Buffer;
|
||||
previewBuffer?: Buffer;
|
||||
}> {
|
||||
try {
|
||||
// 1. 设置输出目录
|
||||
// 使用系统临时目录
|
||||
const baseDir =
|
||||
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)) {
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 2. 下载ZIP文件
|
||||
// 1. 下载ZIP文件
|
||||
this.logger.log(`开始下载ZIP文件: ${zipUrl}`);
|
||||
const zipPath = path.join(baseDir, 'model.zip');
|
||||
await this.downloadFile(zipUrl, zipPath);
|
||||
this.logger.log(`ZIP文件下载完成: ${zipPath}`);
|
||||
|
||||
// 3. 解压ZIP文件
|
||||
// 2. 解压ZIP文件
|
||||
this.logger.log(`开始解压ZIP文件`);
|
||||
const extractDir = path.join(baseDir, 'extracted');
|
||||
await this.extractZip(zipPath, extractDir);
|
||||
this.logger.log(`ZIP文件解压完成: ${extractDir}`);
|
||||
|
||||
// 4. 查找3D模型文件和预览图
|
||||
// 3. 查找3D模型文件和预览图
|
||||
const files = this.getAllFiles(extractDir);
|
||||
const modelFile = this.findModelFile(files);
|
||||
const previewFile = this.findPreviewImage(files);
|
||||
@ -57,41 +59,29 @@ export class ZipHandler {
|
||||
this.logger.log(`找到预览图: ${previewFile}`);
|
||||
}
|
||||
|
||||
// 5. 生成可访问的URL
|
||||
// 假设模型文件在 uploads/ai-3d/timestamp/extracted/model.glb
|
||||
// URL应该是 /api/uploads/ai-3d/timestamp/extracted/model.glb
|
||||
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}`);
|
||||
}
|
||||
// 4. 读取文件Buffer(用于上传到COS)
|
||||
const modelBuffer = fs.readFileSync(modelFile);
|
||||
const previewBuffer = previewFile ? fs.readFileSync(previewFile) : undefined;
|
||||
|
||||
return {
|
||||
modelPath: modelFile,
|
||||
previewPath: previewFile,
|
||||
modelUrl,
|
||||
previewUrl,
|
||||
modelBuffer,
|
||||
previewBuffer,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`处理ZIP文件失败: ${error.message}`, error.stack);
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from '@nestjs/core';
|
||||
import { join } from 'path';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.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'}`, // 优先加载
|
||||
],
|
||||
}),
|
||||
// 静态文件服务 - 提供 uploads 目录的访问
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: join(process.cwd(), 'uploads'),
|
||||
serveRoot: '/api/uploads',
|
||||
serveStaticOptions: {
|
||||
index: false,
|
||||
},
|
||||
}),
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
|
||||
@ -270,11 +270,41 @@ export class ContestsService {
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 contestTenants JSON 字符串为数组
|
||||
const parsedList = filteredList.map((contest) => ({
|
||||
// 解析 contestTenants JSON 字符串为数组,并计算评审统计数据
|
||||
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,
|
||||
contestTenants: this.parseContestTenants(contest.contestTenants),
|
||||
}));
|
||||
totalWorksCount,
|
||||
reviewedCount,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
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 (
|
||||
(updateContestDto as any).registerStartTime ||
|
||||
(updateContestDto as any).registerEndTime ||
|
||||
(updateContestDto as any).submitStartTime ||
|
||||
(updateContestDto as any).submitEndTime ||
|
||||
(updateContestDto as any).reviewStartTime ||
|
||||
(updateContestDto as any).reviewEndTime
|
||||
dto.registerStartTime ||
|
||||
dto.registerEndTime ||
|
||||
dto.submitStartTime ||
|
||||
dto.submitEndTime ||
|
||||
dto.reviewStartTime ||
|
||||
dto.reviewEndTime
|
||||
) {
|
||||
// 合并现有数据和更新数据,确保所有必需字段都存在
|
||||
const mergedDto = {
|
||||
@ -568,7 +616,6 @@ export class ContestsService {
|
||||
}
|
||||
|
||||
const data: any = {};
|
||||
const dto = updateContestDto as any;
|
||||
|
||||
if (dto.contestName !== undefined) {
|
||||
data.contestName = dto.contestName;
|
||||
|
||||
@ -8,7 +8,8 @@ export class CreateScoreDto {
|
||||
assignmentId: number;
|
||||
|
||||
@IsObject()
|
||||
dimensionScores: any; // JSON object
|
||||
@IsOptional()
|
||||
dimensionScores?: any; // JSON object
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
|
||||
@ -29,7 +29,7 @@ export class ReviewsController {
|
||||
@Query('contestId', ParseIntPipe) contestId: number,
|
||||
@Request() req,
|
||||
) {
|
||||
const creatorId = req.user?.id;
|
||||
const creatorId = req.user?.userId;
|
||||
return this.reviewsService.assignWork(assignWorkDto, contestId, creatorId);
|
||||
}
|
||||
|
||||
@ -40,7 +40,7 @@ export class ReviewsController {
|
||||
@Query('contestId', ParseIntPipe) contestId: number,
|
||||
@Request() req,
|
||||
) {
|
||||
const creatorId = req.user?.id;
|
||||
const creatorId = req.user?.userId;
|
||||
return this.reviewsService.batchAssignWorks(
|
||||
contestId,
|
||||
batchAssignDto.workIds,
|
||||
@ -55,7 +55,7 @@ export class ReviewsController {
|
||||
@Query('contestId', ParseIntPipe) contestId: number,
|
||||
@Request() req,
|
||||
) {
|
||||
const creatorId = req.user?.id;
|
||||
const creatorId = req.user?.userId;
|
||||
return this.reviewsService.autoAssignWorks(contestId, creatorId);
|
||||
}
|
||||
|
||||
@ -66,7 +66,10 @@ export class ReviewsController {
|
||||
if (!tenantId) {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -77,7 +80,7 @@ export class ReviewsController {
|
||||
@Body() updateScoreDto: Partial<CreateScoreDto>,
|
||||
@Request() req,
|
||||
) {
|
||||
const judgeId = req.user?.id;
|
||||
const judgeId = req.user?.userId;
|
||||
return this.reviewsService.updateScore(scoreId, updateScoreDto, judgeId);
|
||||
}
|
||||
|
||||
@ -87,10 +90,42 @@ export class ReviewsController {
|
||||
@Query('contestId', ParseIntPipe) contestId: number,
|
||||
@Request() req,
|
||||
) {
|
||||
const judgeId = req.user?.id;
|
||||
const judgeId = req.user?.userId;
|
||||
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')
|
||||
@RequirePermission('review:read')
|
||||
getReviewProgress(@Param('contestId', ParseIntPipe) contestId: number) {
|
||||
|
||||
@ -157,9 +157,9 @@ export class ReviewsService {
|
||||
assignmentId: createScoreDto.assignmentId,
|
||||
judgeId,
|
||||
judgeName: judge?.nickname || judge?.username || '',
|
||||
dimensionScores: JSON.stringify(createScoreDto.dimensionScores),
|
||||
dimensionScores: createScoreDto.dimensionScores || {},
|
||||
totalScore: createScoreDto.totalScore,
|
||||
comments: createScoreDto.comments,
|
||||
comments: createScoreDto.comments || '',
|
||||
scoreTime: new Date(),
|
||||
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: {
|
||||
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,
|
||||
},
|
||||
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: {
|
||||
id: 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: {
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1,23 +1,16 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Param,
|
||||
Res,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
Request,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Response } from 'express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { UploadService } from './upload.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
@Controller('upload')
|
||||
export class UploadController {
|
||||
@ -27,7 +20,7 @@ export class UploadController {
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(), // 使用内存存储,确保 file.buffer 可用
|
||||
storage: memoryStorage(),
|
||||
limits: {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UploadController, UploadsController } from './upload.controller';
|
||||
import { UploadController } from './upload.controller';
|
||||
import { UploadService } from './upload.service';
|
||||
import { OssModule } from '../oss/oss.module';
|
||||
|
||||
@Module({
|
||||
imports: [OssModule],
|
||||
controllers: [UploadController, UploadsController],
|
||||
controllers: [UploadController],
|
||||
providers: [UploadService],
|
||||
exports: [UploadService],
|
||||
})
|
||||
|
||||
@ -1,34 +1,11 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { OssService } from '../oss/oss.service';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class UploadService {
|
||||
private readonly uploadDir: string;
|
||||
private readonly useOss: boolean;
|
||||
|
||||
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);
|
||||
constructor(private ossService: OssService) {
|
||||
if (!this.ossService.isEnabled()) {
|
||||
console.warn('警告: COS 未配置,文件上传功能可能无法正常使用');
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,24 +18,10 @@ export class UploadService {
|
||||
throw new BadRequestException('文件不存在');
|
||||
}
|
||||
|
||||
// 优先使用 COS
|
||||
if (this.useOss) {
|
||||
return this.uploadToOss(file, tenantId, userId);
|
||||
if (!this.ossService.isEnabled()) {
|
||||
throw new BadRequestException('文件存储服务未配置,请联系管理员');
|
||||
}
|
||||
|
||||
// 备用方案:本地存储
|
||||
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(
|
||||
file.buffer,
|
||||
file.originalname,
|
||||
@ -71,63 +34,5 @@ export class UploadService {
|
||||
fileName: result.fileName,
|
||||
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 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -439,7 +439,7 @@ export interface AssignWorkForm {
|
||||
export interface CreateScoreForm {
|
||||
workId: number;
|
||||
assignmentId: number;
|
||||
dimensionScores: any;
|
||||
dimensionScores?: any;
|
||||
totalScore: number;
|
||||
comments?: string;
|
||||
}
|
||||
@ -1116,6 +1116,32 @@ export const reviewsApi = {
|
||||
{ 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;
|
||||
},
|
||||
};
|
||||
|
||||
// 公告管理
|
||||
|
||||
@ -178,6 +178,16 @@ const baseRoutes: RouteRecordRaw[] = [
|
||||
permissions: ["activity:read"],
|
||||
},
|
||||
},
|
||||
// 评委评审详情页
|
||||
{
|
||||
path: "activities/review/:id",
|
||||
name: "ReviewDetail",
|
||||
component: () => import("@/views/activities/ReviewDetail.vue"),
|
||||
meta: {
|
||||
title: "作品评审",
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
// 3D建模实验室路由(工作台模块下)
|
||||
{
|
||||
path: "workbench/3d-lab",
|
||||
|
||||
@ -51,6 +51,7 @@ const componentMap: Record<string, () => Promise<any>> = {
|
||||
// 赛事活动模块(教师/评委)
|
||||
"activities/Guidance": () => import("@/views/activities/Guidance.vue"),
|
||||
"activities/Review": () => import("@/views/activities/Review.vue"),
|
||||
"activities/ReviewDetail": () => import("@/views/activities/ReviewDetail.vue"),
|
||||
"activities/Comments": () => import("@/views/activities/Comments.vue"),
|
||||
// 系统管理模块
|
||||
"system/users/Index": () => import("@/views/system/users/Index.vue"),
|
||||
|
||||
@ -4,73 +4,38 @@
|
||||
<template #title>评审作品</template>
|
||||
</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
|
||||
:columns="columns"
|
||||
:data-source="dataSource"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'reviewed' ? 'success' : 'warning'">
|
||||
{{ record.status === "reviewed" ? "已评审" : "待评审" }}
|
||||
</a-tag>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'index'">
|
||||
{{ index + 1 }}
|
||||
</template>
|
||||
<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 v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleReview(record)">
|
||||
{{ record.status === "reviewed" ? "查看" : "评审" }}
|
||||
<a-button type="link" size="small" @click="handleViewDetail(record)">
|
||||
进入评审
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
@ -78,120 +43,84 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from "vue"
|
||||
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons-vue"
|
||||
import type { TablePaginationConfig } from "ant-design-vue"
|
||||
import { ref, onMounted } from "vue"
|
||||
import { useRouter, useRoute } from "vue-router"
|
||||
import { message } from "ant-design-vue"
|
||||
import { reviewsApi } from "@/api/contests"
|
||||
|
||||
// 搜索参数
|
||||
const searchParams = reactive({
|
||||
contestName: "",
|
||||
workName: "",
|
||||
status: undefined as string | undefined,
|
||||
})
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
|
||||
// 表格数据
|
||||
const loading = ref(false)
|
||||
const dataSource = ref<any[]>([])
|
||||
const pagination = reactive<TablePaginationConfig>({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`,
|
||||
})
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: "序号",
|
||||
key: "index",
|
||||
width: 60,
|
||||
customRender: ({ index }: { index: number }) => index + 1,
|
||||
width: 70,
|
||||
},
|
||||
{
|
||||
title: "赛事名称",
|
||||
key: "contestName",
|
||||
dataIndex: "contestName",
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
title: "评审进度",
|
||||
key: "progress",
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: "作品编号",
|
||||
key: "workNo",
|
||||
dataIndex: "workNo",
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: "作品名称",
|
||||
key: "workName",
|
||||
dataIndex: "workName",
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: "参赛者",
|
||||
key: "participant",
|
||||
dataIndex: "participant",
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: "评审状态",
|
||||
key: "status",
|
||||
dataIndex: "status",
|
||||
title: "待评审",
|
||||
key: "pending",
|
||||
dataIndex: "pending",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: "评分",
|
||||
key: "score",
|
||||
dataIndex: "score",
|
||||
width: 80,
|
||||
title: "状态",
|
||||
key: "reviewStatus",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 100,
|
||||
width: 120,
|
||||
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 () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// TODO: 调用API获取数据
|
||||
dataSource.value = []
|
||||
pagination.total = 0
|
||||
} catch (error) {
|
||||
console.error("获取数据失败", error)
|
||||
const data = await reviewsApi.getJudgeContests()
|
||||
dataSource.value = data
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "获取数据失败")
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
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)
|
||||
// 查看详情
|
||||
const handleViewDetail = (record: any) => {
|
||||
router.push(`/${tenantCode}/activities/review/${record.id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@ -199,16 +128,91 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.review-page {
|
||||
padding: 0;
|
||||
}
|
||||
<style scoped lang="scss">
|
||||
$primary: #1890ff;
|
||||
$primary-dark: #0958d9;
|
||||
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
.review-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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
|
||||
365
frontend/src/views/activities/ReviewDetail.vue
Normal file
365
frontend/src/views/activities/ReviewDetail.vue
Normal 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>
|
||||
331
frontend/src/views/activities/components/ReviewWorkModal.vue
Normal file
331
frontend/src/views/activities/components/ReviewWorkModal.vue
Normal 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) {
|
||||
// 保存模型URL到sessionStorage
|
||||
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>
|
||||
@ -388,7 +388,7 @@ const handleMyGuidance = (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;
|
||||
|
||||
.contests-activities-page {
|
||||
padding: 24px;
|
||||
|
||||
// 顶部导航栏
|
||||
.page-header {
|
||||
display: flex;
|
||||
@ -835,12 +833,20 @@ $primary-light: #40a9ff;
|
||||
padding: 6px 16px;
|
||||
height: auto;
|
||||
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);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&: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);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
@ -892,8 +898,6 @@ $primary-light: #40a9ff;
|
||||
// 响应式适配
|
||||
@media (max-width: 768px) {
|
||||
.contests-activities-page {
|
||||
padding: 16px;
|
||||
|
||||
.contests-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
|
||||
@ -17,7 +17,9 @@
|
||||
>赛事管理</router-link
|
||||
>
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>{{ isEdit ? '编辑比赛' : '创建比赛' }}</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>{{
|
||||
isEdit ? "编辑比赛" : "创建比赛"
|
||||
}}</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</a-space>
|
||||
</template>
|
||||
@ -274,7 +276,9 @@ const route = useRoute()
|
||||
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 pageLoading = ref(false)
|
||||
|
||||
@ -326,12 +330,12 @@ const reviewRuleOptions = ref<{ value: number; label: string }[]>([])
|
||||
const fetchReviewRules = async () => {
|
||||
try {
|
||||
const rules = await reviewRulesApi.getForSelect()
|
||||
reviewRuleOptions.value = rules.map(rule => ({
|
||||
reviewRuleOptions.value = rules.map((rule) => ({
|
||||
value: rule.id,
|
||||
label: rule.ruleName,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('获取评审规则列表失败:', error)
|
||||
console.error("获取评审规则列表失败:", error)
|
||||
}
|
||||
}
|
||||
|
||||
@ -649,45 +653,54 @@ const loadContestData = async () => {
|
||||
const contest = await contestsApi.getDetail(contestId.value)
|
||||
|
||||
// 填充表单数据
|
||||
form.contestName = contest.contestName || ''
|
||||
form.contestType = contest.contestType || 'individual'
|
||||
form.startTime = contest.startTime || ''
|
||||
form.endTime = contest.endTime || ''
|
||||
form.content = contest.content || ''
|
||||
form.coverUrl = contest.coverUrl || ''
|
||||
form.posterUrl = contest.posterUrl || ''
|
||||
form.contestName = contest.contestName || ""
|
||||
form.contestType = contest.contestType || "individual"
|
||||
form.startTime = contest.startTime || ""
|
||||
form.endTime = contest.endTime || ""
|
||||
form.content = contest.content || ""
|
||||
form.coverUrl = contest.coverUrl || ""
|
||||
form.posterUrl = contest.posterUrl || ""
|
||||
// 处理主办/协办/赞助单位(后端返回数组,表单需要字符串)
|
||||
form.organizers = Array.isArray(contest.organizers)
|
||||
? contest.organizers.join('、')
|
||||
: (contest.organizers || '')
|
||||
? contest.organizers.join("、")
|
||||
: contest.organizers || ""
|
||||
form.coOrganizers = Array.isArray(contest.coOrganizers)
|
||||
? contest.coOrganizers.join('、')
|
||||
: (contest.coOrganizers || '')
|
||||
? contest.coOrganizers.join("、")
|
||||
: contest.coOrganizers || ""
|
||||
form.sponsors = Array.isArray(contest.sponsors)
|
||||
? contest.sponsors.join('、')
|
||||
: (contest.sponsors || '')
|
||||
form.registerStartTime = contest.registerStartTime || ''
|
||||
form.registerEndTime = contest.registerEndTime || ''
|
||||
form.submitRule = contest.submitRule || 'once'
|
||||
form.submitStartTime = contest.submitStartTime || ''
|
||||
form.submitEndTime = contest.submitEndTime || ''
|
||||
? contest.sponsors.join("、")
|
||||
: contest.sponsors || ""
|
||||
form.registerStartTime = contest.registerStartTime || ""
|
||||
form.registerEndTime = contest.registerEndTime || ""
|
||||
form.submitRule = contest.submitRule || "once"
|
||||
form.submitStartTime = contest.submitStartTime || ""
|
||||
form.submitEndTime = contest.submitEndTime || ""
|
||||
form.reviewRuleId = contest.reviewRuleId || undefined
|
||||
form.reviewStartTime = contest.reviewStartTime || ''
|
||||
form.reviewEndTime = contest.reviewEndTime || ''
|
||||
form.resultPublishTime = contest.resultPublishTime || ''
|
||||
form.reviewStartTime = contest.reviewStartTime || ""
|
||||
form.reviewEndTime = contest.reviewEndTime || ""
|
||||
form.resultPublishTime = contest.resultPublishTime || ""
|
||||
|
||||
// 设置时间范围
|
||||
if (contest.startTime && contest.endTime) {
|
||||
timeRange.value = [dayjs(contest.startTime), dayjs(contest.endTime)]
|
||||
}
|
||||
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) {
|
||||
submitTimeRange.value = [dayjs(contest.submitStartTime), dayjs(contest.submitEndTime)]
|
||||
submitTimeRange.value = [
|
||||
dayjs(contest.submitStartTime),
|
||||
dayjs(contest.submitEndTime),
|
||||
]
|
||||
}
|
||||
if (contest.reviewStartTime && contest.reviewEndTime) {
|
||||
reviewTimeRange.value = [dayjs(contest.reviewStartTime), dayjs(contest.reviewEndTime)]
|
||||
reviewTimeRange.value = [
|
||||
dayjs(contest.reviewStartTime),
|
||||
dayjs(contest.reviewEndTime),
|
||||
]
|
||||
}
|
||||
if (contest.resultPublishTime) {
|
||||
resultPublishTime.value = dayjs(contest.resultPublishTime)
|
||||
@ -695,35 +708,41 @@ const loadContestData = async () => {
|
||||
|
||||
// 设置封面图片
|
||||
if (contest.coverUrl) {
|
||||
coverFileList.value = [{
|
||||
uid: '-1',
|
||||
name: 'cover',
|
||||
status: 'done',
|
||||
coverFileList.value = [
|
||||
{
|
||||
uid: "-1",
|
||||
name: "cover",
|
||||
status: "done",
|
||||
url: contest.coverUrl,
|
||||
}]
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// 设置海报图片
|
||||
if (contest.posterUrl) {
|
||||
posterFileList.value = [{
|
||||
uid: '-2',
|
||||
name: 'poster',
|
||||
status: 'done',
|
||||
posterFileList.value = [
|
||||
{
|
||||
uid: "-2",
|
||||
name: "poster",
|
||||
status: "done",
|
||||
url: contest.posterUrl,
|
||||
}]
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// 加载附件
|
||||
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}`,
|
||||
name: att.fileName,
|
||||
status: 'done',
|
||||
status: "done",
|
||||
url: att.fileUrl,
|
||||
}))
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || '加载赛事数据失败')
|
||||
message.error(error?.response?.data?.message || "加载赛事数据失败")
|
||||
router.back()
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
@ -738,24 +757,24 @@ const handleSubmit = async () => {
|
||||
|
||||
// 构建提交数据,确保所有字符串字段不为 null/undefined
|
||||
const submitData: CreateContestForm = {
|
||||
contestName: form.contestName || '',
|
||||
contestType: form.contestType || 'individual',
|
||||
startTime: form.startTime || '',
|
||||
endTime: form.endTime || '',
|
||||
content: form.content || '',
|
||||
coverUrl: form.coverUrl || '',
|
||||
posterUrl: form.posterUrl || '',
|
||||
organizers: form.organizers || '',
|
||||
coOrganizers: form.coOrganizers || '',
|
||||
sponsors: form.sponsors || '',
|
||||
registerStartTime: form.registerStartTime || '',
|
||||
registerEndTime: form.registerEndTime || '',
|
||||
submitRule: form.submitRule || 'once',
|
||||
submitStartTime: form.submitStartTime || '',
|
||||
submitEndTime: form.submitEndTime || '',
|
||||
contestName: form.contestName || "",
|
||||
contestType: form.contestType || "individual",
|
||||
startTime: form.startTime || "",
|
||||
endTime: form.endTime || "",
|
||||
content: form.content || "",
|
||||
coverUrl: form.coverUrl || "",
|
||||
posterUrl: form.posterUrl || "",
|
||||
organizers: form.organizers || "",
|
||||
coOrganizers: form.coOrganizers || "",
|
||||
sponsors: form.sponsors || "",
|
||||
registerStartTime: form.registerStartTime || "",
|
||||
registerEndTime: form.registerEndTime || "",
|
||||
submitRule: form.submitRule || "once",
|
||||
submitStartTime: form.submitStartTime || "",
|
||||
submitEndTime: form.submitEndTime || "",
|
||||
reviewRuleId: form.reviewRuleId || undefined,
|
||||
reviewStartTime: form.reviewStartTime || '',
|
||||
reviewEndTime: form.reviewEndTime || '',
|
||||
reviewStartTime: form.reviewStartTime || "",
|
||||
reviewEndTime: form.reviewEndTime || "",
|
||||
resultPublishTime: form.resultPublishTime || undefined,
|
||||
}
|
||||
|
||||
@ -801,7 +820,9 @@ const handleSubmit = async () => {
|
||||
// 表单验证错误
|
||||
return
|
||||
}
|
||||
message.error(error?.response?.data?.message || (isEdit.value ? "保存失败" : "创建失败"))
|
||||
message.error(
|
||||
error?.response?.data?.message || (isEdit.value ? "保存失败" : "创建失败")
|
||||
)
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
@ -827,6 +848,7 @@ onMounted(() => {
|
||||
.create-contest-page {
|
||||
padding: 16px 24px;
|
||||
max-width: 1200px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.create-contest-page :deep(.ant-card) {
|
||||
|
||||
@ -497,12 +497,94 @@ onMounted(() => {
|
||||
})
|
||||
</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 {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
padding: 20px 24px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
|
||||
@ -1,19 +1,7 @@
|
||||
<template>
|
||||
<div class="judges-page">
|
||||
<a-card class="mb-4">
|
||||
<template #title>
|
||||
<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 #title>评委管理</template>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button
|
||||
@ -302,12 +290,12 @@ const contestName = ref("")
|
||||
|
||||
// 表格选择
|
||||
const selectedRowKeys = ref<number[]>([])
|
||||
const rowSelection: TableProps["rowSelection"] = {
|
||||
const rowSelection = computed<TableProps["rowSelection"]>(() => ({
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
onChange: (keys: any) => {
|
||||
selectedRowKeys.value = keys
|
||||
},
|
||||
}
|
||||
}))
|
||||
|
||||
// 抽屉相关
|
||||
const drawerVisible = ref(false)
|
||||
@ -546,8 +534,94 @@ onMounted(() => {
|
||||
})
|
||||
</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 {
|
||||
margin-bottom: 16px;
|
||||
padding: 20px 24px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,14 +1,8 @@
|
||||
<template>
|
||||
<div class="notices-page">
|
||||
<a-card>
|
||||
<template #title>
|
||||
<a-breadcrumb>
|
||||
<a-breadcrumb-item>
|
||||
<router-link :to="`/${tenantCode}/contests`">赛事管理</router-link>
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>公告管理</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</template>
|
||||
<!-- 标题卡片 -->
|
||||
<a-card class="mb-4">
|
||||
<template #title>公告管理</template>
|
||||
<template #extra>
|
||||
<a-button
|
||||
v-permission="'notice:create'"
|
||||
@ -19,9 +13,10 @@
|
||||
新建公告
|
||||
</a-button>
|
||||
</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-input
|
||||
v-model:value="searchForm.title"
|
||||
@ -39,10 +34,14 @@
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleSearch">搜索</a-button>
|
||||
<a-button @click="handleReset">重置</a-button>
|
||||
</a-space>
|
||||
<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>
|
||||
|
||||
@ -102,7 +101,6 @@
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 公告详情弹窗 -->
|
||||
<a-modal
|
||||
@ -236,7 +234,7 @@
|
||||
import { ref, onMounted, reactive } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
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 type { FormInstance, UploadFile } from "ant-design-vue"
|
||||
import {
|
||||
@ -596,19 +594,107 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notices-page :deep(.ant-card) {
|
||||
<style scoped lang="scss">
|
||||
// 主色调
|
||||
$primary: #1890ff;
|
||||
$primary-dark: #0958d9;
|
||||
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
|
||||
.notices-page {
|
||||
// 标题卡片样式
|
||||
:deep(.ant-card) {
|
||||
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;
|
||||
padding: 12px 0;
|
||||
min-height: auto;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notices-page :deep(.ant-card-body) {
|
||||
padding: 16px 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>
|
||||
|
||||
@ -396,10 +396,113 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="scss">
|
||||
// 主色调
|
||||
$primary: #1890ff;
|
||||
$primary-dark: #0958d9;
|
||||
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
|
||||
.registrations-page {
|
||||
.search-form {
|
||||
// 标题卡片样式
|
||||
: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 {
|
||||
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>
|
||||
|
||||
@ -946,10 +946,105 @@ onMounted(async () => {
|
||||
})
|
||||
</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 {
|
||||
.search-form {
|
||||
// 标题卡片样式
|
||||
: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);
|
||||
|
||||
// 自适应换行 - 使用 flex wrap
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 16px 24px;
|
||||
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -284,18 +284,71 @@ onMounted(() => {
|
||||
})
|
||||
</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 {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@ -306,15 +359,26 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
padding: 20px 24px;
|
||||
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 {
|
||||
|
||||
@ -188,11 +188,107 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="scss">
|
||||
$primary: #1890ff;
|
||||
$primary-dark: #0958d9;
|
||||
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
|
||||
.results-page {
|
||||
.search-form {
|
||||
: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 {
|
||||
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 {
|
||||
|
||||
@ -572,9 +572,100 @@ const handleCancel = () => {
|
||||
}
|
||||
</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 {
|
||||
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 {
|
||||
|
||||
@ -207,22 +207,107 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.review-progress-page {
|
||||
padding: 0;
|
||||
}
|
||||
<style scoped lang="scss">
|
||||
$primary: #1890ff;
|
||||
$primary-dark: #0958d9;
|
||||
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
|
||||
.contest-tabs {
|
||||
.review-progress-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-tabs) {
|
||||
background: #fff;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
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 {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
padding: 20px 24px;
|
||||
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>
|
||||
|
||||
@ -2,12 +2,12 @@
|
||||
<div class="progress-detail-page">
|
||||
<a-card class="mb-4">
|
||||
<template #title>
|
||||
<a-space>
|
||||
<a-button type="text" @click="handleBack">
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
</a-button>
|
||||
<span>{{ contestName }}作品评审进度</span>
|
||||
</a-space>
|
||||
<a-breadcrumb>
|
||||
<a-breadcrumb-item>
|
||||
<router-link :to="`/${tenantCode}/contests/reviews/progress`">评审进度</router-link>
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>{{ contestName || '作品评审进度' }}</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
@ -257,7 +257,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from "vue"
|
||||
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 {
|
||||
ArrowLeftOutlined,
|
||||
@ -272,6 +272,7 @@ import WorkDetailModal from "../components/WorkDetailModal.vue"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
const contestId = Number(route.params.id)
|
||||
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 {
|
||||
await contestsApi.update(contestId, {
|
||||
reviewStartTime: new Date().toISOString(),
|
||||
@ -491,10 +498,18 @@ const handleStartReview = async () => {
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "操作失败")
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 结束评审
|
||||
const handleEndReview = async () => {
|
||||
const handleEndReview = () => {
|
||||
Modal.confirm({
|
||||
title: "确认结束评审",
|
||||
content: "确定要结束评审吗?结束后评委将无法继续评分。",
|
||||
okText: "确定",
|
||||
cancelText: "取消",
|
||||
async onOk() {
|
||||
try {
|
||||
await contestsApi.update(contestId, {
|
||||
reviewEndTime: new Date().toISOString(),
|
||||
@ -503,6 +518,8 @@ const handleEndReview = async () => {
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "操作失败")
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 查看未提交作品
|
||||
@ -596,22 +613,110 @@ onMounted(() => {
|
||||
})
|
||||
</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 {
|
||||
: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: 16px;
|
||||
padding: 20px 24px;
|
||||
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 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
@ -177,23 +177,107 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.works-page {
|
||||
padding: 0;
|
||||
}
|
||||
<style scoped lang="scss">
|
||||
$primary: #1890ff;
|
||||
$primary-dark: #0958d9;
|
||||
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
|
||||
.contest-tabs {
|
||||
margin-bottom: 16px;
|
||||
.works-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 16px;
|
||||
border-radius: 8px;
|
||||
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 {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
padding: 20px 24px;
|
||||
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 {
|
||||
|
||||
@ -2,12 +2,12 @@
|
||||
<div class="works-detail-page">
|
||||
<a-card class="mb-4">
|
||||
<template #title>
|
||||
<a-space>
|
||||
<a-button type="text" @click="handleBack">
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
</a-button>
|
||||
<span>{{ contestName }}参赛作品</span>
|
||||
</a-space>
|
||||
<a-breadcrumb>
|
||||
<a-breadcrumb-item>
|
||||
<router-link :to="`/${tenantCode}/contests/works`">作品管理</router-link>
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>{{ contestName || '参赛作品' }}</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-button
|
||||
@ -334,6 +334,7 @@ interface Tenant {
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
const contestId = Number(route.params.id)
|
||||
const contestType = (route.query.type as string) || "individual"
|
||||
|
||||
@ -658,15 +659,99 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$primary: #1890ff;
|
||||
$primary-dark: #0958d9;
|
||||
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
: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: 16px;
|
||||
padding: 20px 24px;
|
||||
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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user