diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 601e1f7..2a8a2d3 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -782,7 +782,8 @@ model ContestWork { submitterUserId Int? @map("submitter_user_id") /// 提交人用户id submitterAccountNo String? @map("submitter_account_no") /// 提交人账号 submitSource String @default("teacher") @map("submit_source") /// 提交来源:teacher/student/team_leader - previewUrl String? @map("preview_url") @db.Text /// 作品预览URL + previewUrl String? @map("preview_url") @db.Text /// 作品预览URL(兼容单预览图) + previewUrls Json? @map("preview_urls") /// 作品预览图URL列表(多模型场景) aiModelMeta Json? @map("ai_model_meta") /// AI建模元数据 // 赛果相关字段 finalScore Decimal? @map("final_score") @db.Decimal(10, 2) /// 最终得分(根据规则计算) diff --git a/backend/src/contests/works/dto/submit-work.dto.ts b/backend/src/contests/works/dto/submit-work.dto.ts index a48fdc2..e0bd917 100644 --- a/backend/src/contests/works/dto/submit-work.dto.ts +++ b/backend/src/contests/works/dto/submit-work.dto.ts @@ -20,6 +20,11 @@ export class SubmitWorkDto { @IsOptional() previewUrl?: string; + @IsArray() + @IsString({ each: true }) + @IsOptional() + previewUrls?: string[]; + @IsObject() @IsOptional() aiModelMeta?: any; diff --git a/backend/src/contests/works/works.service.ts b/backend/src/contests/works/works.service.ts index 573133d..2c92a1b 100644 --- a/backend/src/contests/works/works.service.ts +++ b/backend/src/contests/works/works.service.ts @@ -115,6 +115,7 @@ export class WorksService { submitterAccountNo: submitter?.username, submitSource: 'student', // 可以根据实际情况判断 previewUrl: submitWorkDto.previewUrl, + previewUrls: submitWorkDto.previewUrls || null, aiModelMeta: submitWorkDto.aiModelMeta || null, creator: submitterUserId, }; diff --git a/backend/src/upload/upload.controller.ts b/backend/src/upload/upload.controller.ts index 9ad4e23..03de0da 100644 --- a/backend/src/upload/upload.controller.ts +++ b/backend/src/upload/upload.controller.ts @@ -13,6 +13,7 @@ import { } 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'; @@ -24,7 +25,14 @@ export class UploadController { @Post() @UseGuards(JwtAuthGuard) - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), // 使用内存存储,确保 file.buffer 可用 + limits: { + fileSize: 100 * 1024 * 1024, // 限制文件大小为 100MB + }, + }), + ) async uploadFile( @UploadedFile() file: Express.Multer.File, @Request() req, diff --git a/backend/uploads/tenant_1/user_1/bdfabf2ad6aaf080eb11e99c55ba52cb.png b/backend/uploads/tenant_1/user_1/bdfabf2ad6aaf080eb11e99c55ba52cb.png new file mode 100644 index 0000000..b351085 Binary files /dev/null and b/backend/uploads/tenant_1/user_1/bdfabf2ad6aaf080eb11e99c55ba52cb.png differ diff --git a/backend/uploads/tenant_1/user_1/fb661034af3721f15d62ad90507a44a2.png b/backend/uploads/tenant_1/user_1/fb661034af3721f15d62ad90507a44a2.png new file mode 100644 index 0000000..b351085 Binary files /dev/null and b/backend/uploads/tenant_1/user_1/fb661034af3721f15d62ad90507a44a2.png differ diff --git a/frontend/src/api/contests.ts b/frontend/src/api/contests.ts index 676db5a..b3d3221 100644 --- a/frontend/src/api/contests.ts +++ b/frontend/src/api/contests.ts @@ -320,6 +320,7 @@ export interface ContestWork { submitterAccountNo?: string; submitSource: string; previewUrl?: string; + previewUrls?: string[]; aiModelMeta?: any; creator?: number; modifier?: number; @@ -371,6 +372,7 @@ export interface SubmitWorkForm { description?: string; files?: string[]; previewUrl?: string; + previewUrls?: string[]; aiModelMeta?: any; } diff --git a/frontend/src/utils/menu.ts b/frontend/src/utils/menu.ts index 777b018..7e4204d 100644 --- a/frontend/src/utils/menu.ts +++ b/frontend/src/utils/menu.ts @@ -99,13 +99,45 @@ function getRouteNameFromPath( /** * 将数据库菜单转换为 Ant Design Vue Menu 的 items 格式 * key 使用路由名称而不是路径 + * 当父菜单只有一个子菜单时,直接显示子菜单,不显示父级折叠结构 */ export function convertMenusToMenuItems( menus: Menu[], isChild: boolean = false ): MenuProps["items"] { - return menus.map((menu) => { - // 使用路由名称作为 key + const result: any[] = [] + + menus.forEach((menu) => { + // 如果只有一个子菜单,直接提升子菜单到当前层级 + if (menu.children && menu.children.length === 1) { + const onlyChild = menu.children[0] + const childRouteName = getRouteNameFromPath(onlyChild.path, onlyChild.id, true) + + const item: any = { + key: childRouteName, + label: onlyChild.name, + title: onlyChild.name, + } + + // 优先使用父菜单的图标,如果没有则使用子菜单的图标 + const iconName = menu.icon || onlyChild.icon + if (iconName) { + const IconComponent = getIconComponent(iconName) + if (IconComponent) { + item.icon = IconComponent + } + } + + // 如果这个唯一的子菜单还有子菜单,继续递归处理 + if (onlyChild.children && onlyChild.children.length > 0) { + item.children = convertMenusToMenuItems(onlyChild.children, true) + } + + result.push(item) + return + } + + // 正常处理:使用路由名称作为 key const routeName = getRouteNameFromPath(menu.path, menu.id, isChild) const item: any = { @@ -122,13 +154,15 @@ export function convertMenusToMenuItems( } } - // 如果有子菜单,递归处理 - if (menu.children && menu.children.length > 0) { + // 如果有多个子菜单,递归处理 + if (menu.children && menu.children.length > 1) { item.children = convertMenusToMenuItems(menu.children, true) } - return item + result.push(item) }) + + return result } /** diff --git a/frontend/src/views/contests/Activities.vue b/frontend/src/views/contests/Activities.vue index 47094fd..1d48717 100644 --- a/frontend/src/views/contests/Activities.vue +++ b/frontend/src/views/contests/Activities.vue @@ -1,22 +1,45 @@ @@ -348,7 +302,22 @@ import { ref, onMounted, computed } from "vue" import { useRoute, useRouter } from "vue-router" import { message } from "ant-design-vue" -import { ArrowLeftOutlined } from "@ant-design/icons-vue" +import { + ArrowLeftOutlined, + CalendarOutlined, + ClockCircleOutlined, + FormOutlined, + EyeOutlined, + FileTextOutlined, + BellOutlined, + TrophyOutlined, + TeamOutlined, + BankOutlined, + ApartmentOutlined, + GiftOutlined, + PhoneOutlined, + UserOutlined, +} from "@ant-design/icons-vue" import dayjs from "dayjs" import { useAuthStore } from "@/stores/auth" import { @@ -376,19 +345,12 @@ const activeTab = ref("info") const hasRegistered = ref(false) const myRegistration = ref(null) -// 检查是否有查看报名的权限 const canViewRegistration = computed(() => { const permissions = authStore.user?.permissions || [] - return ( - permissions.includes("registration:read") || - permissions.includes("registration:create") - ) + return permissions.includes("registration:read") || permissions.includes("registration:create") }) -// 检查是否是教师角色 -const isTeacher = computed(() => { - return authStore.hasRole("teacher") -}) +const isTeacher = computed(() => authStore.hasRole("teacher")) const contestId = Number(route.params.id) @@ -399,56 +361,23 @@ const resultsPagination = ref({ }) const resultColumns = [ - { - title: "排名", - key: "rank", - dataIndex: "rank", - width: 80, - }, - { - title: "作品名称", - key: "title", - dataIndex: "title", - width: 200, - }, - { - title: "作者", - key: "author", - width: 150, - }, - { - title: "最终得分", - key: "finalScore", - dataIndex: "finalScore", - width: 120, - sorter: true, - }, - { - title: "奖项", - key: "award", - width: 120, - }, + { title: "排名", key: "rank", dataIndex: "rank", width: 80 }, + { title: "作品名称", key: "title", dataIndex: "title", width: 200 }, + { title: "作者", key: "author", width: 150 }, + { title: "最终得分", key: "finalScore", dataIndex: "finalScore", width: 120, sorter: true }, + { title: "奖项", key: "award", width: 120 }, ] -// 格式化日期 const formatDate = (dateStr?: string) => { if (!dateStr) return "-" return dayjs(dateStr).format("YYYY-MM-DD") } -// 格式化日期时间 const formatDateTime = (dateStr?: string) => { if (!dateStr) return "-" return dayjs(dateStr).format("YYYY-MM-DD HH:mm") } -// 格式化日期范围 -const formatDateRange = (startDate?: string, endDate?: string) => { - if (!startDate || !endDate) return "-" - return `${formatDate(startDate)} ~ ${formatDate(endDate)}` -} - -// 判断是否在报名中 const isRegistering = computed(() => { if (!contest.value) return false const now = dayjs() @@ -457,63 +386,67 @@ const isRegistering = computed(() => { return now.isAfter(start) && now.isBefore(end) }) -// 计算距离报名截止还有几天 +const isSubmitting = computed(() => { + if (!contest.value?.submitStartTime || !contest.value?.submitEndTime) return false + const now = dayjs() + return now.isAfter(dayjs(contest.value.submitStartTime)) && now.isBefore(dayjs(contest.value.submitEndTime)) +}) + +const isReviewing = computed(() => { + if (!contest.value?.reviewStartTime || !contest.value?.reviewEndTime) return false + const now = dayjs() + return now.isAfter(dayjs(contest.value.reviewStartTime)) && now.isBefore(dayjs(contest.value.reviewEndTime)) +}) + const daysRemaining = computed(() => { if (!contest.value || !isRegistering.value) return 0 - const now = dayjs() - const end = dayjs(contest.value.registerEndTime) - const diff = end.diff(now, "day") + const diff = dayjs(contest.value.registerEndTime).diff(dayjs(), "day") return diff > 0 ? diff : 0 }) -// 获取公告类型颜色 -const getNoticeTypeColor = (type?: string) => { - switch (type) { - case "urgent": - return "red" - case "system": - return "blue" - default: - return "default" - } +const getStageText = () => { + if (isRegistering.value) return "报名中" + if (isSubmitting.value) return "征稿中" + if (isReviewing.value) return "评审中" + if (contest.value?.status === "finished") return "已结束" + return "" +} + +const getStageClass = () => { + if (isRegistering.value) return "tag-registering" + if (isSubmitting.value) return "tag-submitting" + if (isReviewing.value) return "tag-reviewing" + if (contest.value?.status === "finished") return "tag-finished" + return "" } -// 获取公告类型文本 const getNoticeTypeText = (type?: string) => { switch (type) { - case "urgent": - return "紧急通知" - case "system": - return "系统公告" - default: - return "普通公告" + case "urgent": return "紧急" + case "system": return "系统" + default: return "公告" } } -// 获取排名颜色 -const getRankColor = (rank?: number) => { - if (!rank) return "default" - if (rank === 1) return "gold" - if (rank === 2) return "default" - if (rank === 3) return "orange" - return "blue" +const getRankClass = (rank?: number) => { + if (rank === 1) return "rank-1" + if (rank === 2) return "rank-2" + if (rank === 3) return "rank-3" + return "" } -// 获取奖项颜色 -const getAwardColor = (award?: string) => { - if (!award) return "default" - if (award.includes("一等奖") || award.includes("金奖")) return "gold" - if (award.includes("二等奖") || award.includes("银奖")) return "default" - if (award.includes("三等奖") || award.includes("铜奖")) return "orange" - return "blue" +const getAwardClass = (award?: string) => { + if (!award) return "" + if (award.includes("一等奖") || award.includes("金奖")) return "award-gold" + if (award.includes("二等奖") || award.includes("银奖")) return "award-silver" + if (award.includes("三等奖") || award.includes("铜奖")) return "award-bronze" + return "" } -// 加载比赛详情 const fetchContestDetail = async () => { loading.value = true try { contest.value = await contestsApi.getDetail(contestId) - // 检查是否已报名 await checkRegistration() } catch (error: any) { message.error(error?.response?.data?.message || "获取比赛详情失败") @@ -522,7 +455,6 @@ const fetchContestDetail = async () => { } } -// 检查是否已报名 const checkRegistration = async () => { if (!authStore.user) return try { @@ -541,7 +473,6 @@ const checkRegistration = async () => { } } -// 加载公告列表 const fetchNotices = async () => { noticesLoading.value = true try { @@ -553,16 +484,11 @@ const fetchNotices = async () => { } } -// 加载赛事结果 const fetchResults = async () => { if (!contest.value || contest.value.resultState !== "published") return resultsLoading.value = true try { - const response = await resultsApi.getResults( - contestId, - resultsPagination.value.current, - resultsPagination.value.pageSize - ) + const response = await resultsApi.getResults(contestId, resultsPagination.value.current, resultsPagination.value.pageSize) results.value = response.list || [] resultsPagination.value.total = response.total || 0 } catch (error: any) { @@ -572,24 +498,19 @@ const fetchResults = async () => { } } -// 结果表格变化处理 const handleResultsTableChange = (pag: any) => { resultsPagination.value.current = pag.current || 1 resultsPagination.value.pageSize = pag.pageSize || 20 fetchResults() } -// 立即报名 - 跳转到报名页面 const handleRegister = () => { if (!authStore.user) { message.warning("请先登录") router.push(`/${tenantCode}/login`) return } - if (!contest.value) return - - // 根据比赛类型跳转到对应的报名页面 if (contest.value.contestType === "team") { router.push(`/${tenantCode}/contests/${contestId}/register/team`) } else { @@ -597,11 +518,8 @@ const handleRegister = () => { } } -// 查看报名 - 跳转到报名页面 const handleViewRegistration = () => { if (!contest.value) return - - // 根据比赛类型跳转到对应的报名页面 if (contest.value.contestType === "team") { router.push(`/${tenantCode}/contests/${contestId}/register/team`) } else { @@ -609,304 +527,552 @@ const handleViewRegistration = () => { } } +const handleTabChange = (key: string) => { + activeTab.value = key + if (key === "results" && contest.value?.resultState === "published") { + fetchResults() + } +} + onMounted(() => { fetchContestDetail() fetchNotices() }) - -// 监听tab切换,切换到结果tab时加载结果 -const handleTabChange = (key: string) => { - activeTab.value = key - if (key === "results" && contest.value?.resultState === "published") { - fetchResults() - } -}