修改样式

This commit is contained in:
zhangxiaohua 2026-01-16 14:18:32 +08:00
parent ffd1d6bbe5
commit b002e3ca1c
12 changed files with 1635 additions and 941 deletions

View File

@ -782,7 +782,8 @@ model ContestWork {
submitterUserId Int? @map("submitter_user_id") /// 提交人用户id submitterUserId Int? @map("submitter_user_id") /// 提交人用户id
submitterAccountNo String? @map("submitter_account_no") /// 提交人账号 submitterAccountNo String? @map("submitter_account_no") /// 提交人账号
submitSource String @default("teacher") @map("submit_source") /// 提交来源teacher/student/team_leader 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建模元数据 aiModelMeta Json? @map("ai_model_meta") /// AI建模元数据
// 赛果相关字段 // 赛果相关字段
finalScore Decimal? @map("final_score") @db.Decimal(10, 2) /// 最终得分(根据规则计算) finalScore Decimal? @map("final_score") @db.Decimal(10, 2) /// 最终得分(根据规则计算)

View File

@ -20,6 +20,11 @@ export class SubmitWorkDto {
@IsOptional() @IsOptional()
previewUrl?: string; previewUrl?: string;
@IsArray()
@IsString({ each: true })
@IsOptional()
previewUrls?: string[];
@IsObject() @IsObject()
@IsOptional() @IsOptional()
aiModelMeta?: any; aiModelMeta?: any;

View File

@ -115,6 +115,7 @@ export class WorksService {
submitterAccountNo: submitter?.username, submitterAccountNo: submitter?.username,
submitSource: 'student', // 可以根据实际情况判断 submitSource: 'student', // 可以根据实际情况判断
previewUrl: submitWorkDto.previewUrl, previewUrl: submitWorkDto.previewUrl,
previewUrls: submitWorkDto.previewUrls || null,
aiModelMeta: submitWorkDto.aiModelMeta || null, aiModelMeta: submitWorkDto.aiModelMeta || null,
creator: submitterUserId, creator: submitterUserId,
}; };

View File

@ -13,6 +13,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { Response } from 'express'; import { Response } from 'express';
import { memoryStorage } from 'multer';
import { UploadService } from './upload.service'; import { UploadService } from './upload.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import * as path from 'path'; import * as path from 'path';
@ -24,7 +25,14 @@ export class UploadController {
@Post() @Post()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(), // 使用内存存储,确保 file.buffer 可用
limits: {
fileSize: 100 * 1024 * 1024, // 限制文件大小为 100MB
},
}),
)
async uploadFile( async uploadFile(
@UploadedFile() file: Express.Multer.File, @UploadedFile() file: Express.Multer.File,
@Request() req, @Request() req,

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

View File

@ -320,6 +320,7 @@ export interface ContestWork {
submitterAccountNo?: string; submitterAccountNo?: string;
submitSource: string; submitSource: string;
previewUrl?: string; previewUrl?: string;
previewUrls?: string[];
aiModelMeta?: any; aiModelMeta?: any;
creator?: number; creator?: number;
modifier?: number; modifier?: number;
@ -371,6 +372,7 @@ export interface SubmitWorkForm {
description?: string; description?: string;
files?: string[]; files?: string[];
previewUrl?: string; previewUrl?: string;
previewUrls?: string[];
aiModelMeta?: any; aiModelMeta?: any;
} }

View File

@ -99,13 +99,45 @@ function getRouteNameFromPath(
/** /**
* Ant Design Vue Menu items * Ant Design Vue Menu items
* key 使 * key 使
*
*/ */
export function convertMenusToMenuItems( export function convertMenusToMenuItems(
menus: Menu[], menus: Menu[],
isChild: boolean = false isChild: boolean = false
): MenuProps["items"] { ): MenuProps["items"] {
return menus.map((menu) => { const result: any[] = []
// 使用路由名称作为 key
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 routeName = getRouteNameFromPath(menu.path, menu.id, isChild)
const item: any = { 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) item.children = convertMenusToMenuItems(menu.children, true)
} }
return item result.push(item)
}) })
return result
} }
/** /**

View File

@ -1,22 +1,45 @@
<template> <template>
<div class="contests-activities-page"> <div class="contests-activities-page">
<a-card class="mb-4"> <!-- 顶部导航栏 -->
<template #title> <div class="page-header">
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange"> <div class="header-left">
<a-tab-pane key="my" :tab="myTabTitle" /> <!-- 自定义 Tab 切换 -->
<a-tab-pane key="all" tab="全部赛事" /> <div class="custom-tabs">
</a-tabs> <div
</template> class="tab-item"
<template #extra> :class="{ active: activeTab === 'my' }"
<a-input-search @click="switchTab('my')"
v-model:value="searchKeyword" >
placeholder="搜索赛事" <TrophyOutlined />
style="width: 300px" <span>{{ myTabTitle }}</span>
@search="handleSearch" </div>
@press-enter="handleSearch" <div
/> class="tab-item"
</template> :class="{ active: activeTab === 'all' }"
</a-card> @click="switchTab('all')"
>
<AppstoreOutlined />
<span>全部赛事</span>
</div>
</div>
</div>
<div class="header-right">
<!-- 自定义搜索框 -->
<div class="custom-search">
<SearchOutlined class="search-icon" />
<input
v-model="searchKeyword"
type="text"
placeholder="搜索赛事名称..."
class="search-input"
@keyup.enter="handleSearch"
/>
<div v-if="searchKeyword" class="search-clear" @click="clearSearch">
<CloseCircleFilled />
</div>
</div>
</div>
</div>
<!-- 赛事列表 --> <!-- 赛事列表 -->
<div v-if="loading" class="loading-container"> <div v-if="loading" class="loading-container">
@ -34,92 +57,118 @@
class="contest-card" class="contest-card"
@click="handleViewDetail(contest.id)" @click="handleViewDetail(contest.id)"
> >
<!-- 卡片图标 --> <!-- 海报区域 -->
<div class="card-icon"> <div class="card-poster">
<TrophyOutlined /> <img
</div> v-if="contest.posterUrl || contest.coverUrl"
:src="contest.posterUrl || contest.coverUrl"
<!-- 卡片标题 --> alt="赛事海报"
<div class="card-title">{{ contest.contestName }}</div> class="poster-image"
@error="(e) => handleImageError(e, contest.id)"
<!-- 卡片描述 --> />
<div class="card-desc"> <div
赛事时间{{ formatDate(contest.startTime) }} ~ v-else
{{ formatDate(contest.endTime) }} class="poster-placeholder"
</div> :style="{ background: getGradientByIndex(contest.id) }"
>
<!-- 卡片标签 --> <TrophyOutlined class="placeholder-icon" />
<div class="card-tags"> </div>
<span class="tag tag-type"> <!-- 状态角标 -->
{{ contest.contestType === "individual" ? "个人赛" : "团队赛" }} <div
</span> v-if="getStageText(contest)"
<span class="stage-badge"
class="tag tag-status" :class="getStageClass(contest)"
:class="{ 'tag-ongoing': contest.status === 'ongoing' }"
> >
{{ getStatusText(contest) }}
</span>
<span v-if="getStageText(contest)" class="tag tag-stage">
{{ getStageText(contest) }} {{ getStageText(contest) }}
</span> </div>
<!-- 赛事类型角标 -->
<div class="type-badge">
{{ contest.contestType === "individual" ? "个人赛" : "团队赛" }}
</div>
</div> </div>
<!-- 操作按钮区域 - 我的赛事tab显示 --> <!-- 内容区域 -->
<div v-if="activeTab === 'my'" class="card-actions" @click.stop> <div class="card-content">
<!-- 学生角色按钮 --> <!-- 卡片标题 -->
<template v-if="userRole === 'student'"> <div class="card-title">{{ contest.contestName }}</div>
<template v-if="contest.contestType === 'individual'">
<a-button <!-- 时间信息 -->
v-if="isSubmitting(contest)" <div class="card-meta">
type="primary" <div class="meta-item">
size="small" <CalendarOutlined />
@click="handleUploadWork(contest.id)" <span
>{{ formatDate(contest.startTime) }} ~
{{ formatDate(contest.endTime) }}</span
> >
上传作品 </div>
</a-button> </div>
<a-button size="small" @click="handleViewWorks(contest.id)">
参赛作品
</a-button>
</template>
<template v-else>
<a-button size="small" @click="handleViewWorks(contest.id)">
参赛作品
</a-button>
<a-button size="small" @click="handleViewTeam(contest.id)">
我的队伍
</a-button>
</template>
</template>
<!-- 教师角色按钮 --> <!-- 底部区域 -->
<template v-if="userRole === 'teacher'"> <div class="card-footer">
<a-button <span
type="primary" class="status-dot"
size="small" :class="{ 'status-ongoing': contest.status === 'ongoing' }"
@click="handleMyGuidance(contest.id)" ></span>
> <span class="status-text">{{ getStatusText(contest) }}</span>
我的指导
</a-button>
</template>
<!-- 评委角色按钮 --> <!-- 操作按钮区域 - 我的赛事tab显示 -->
<template v-if="userRole === 'judge'"> <div v-if="activeTab === 'my'" class="card-actions" @click.stop>
<a-button <!-- 学生角色按钮 -->
type="primary" <template v-if="userRole === 'student'">
size="small" <template v-if="contest.contestType === 'individual'">
:disabled="isReviewEnded(contest)" <a-button
@click="handleReviewWorks(contest.id)" v-if="isSubmitting(contest)"
> type="primary"
评审作品 size="small"
</a-button> @click="handleUploadWork(contest.id)"
<a-button >
size="small" 上传作品
:disabled="isReviewEnded(contest)" </a-button>
@click="handlePresetComments(contest.id)" <a-button size="small" @click="handleViewWorks(contest.id)">
> 参赛作品
预设评语 </a-button>
</a-button> </template>
</template> <template v-else>
<a-button size="small" @click="handleViewWorks(contest.id)">
参赛作品
</a-button>
<a-button size="small" @click="handleViewTeam(contest.id)">
我的队伍
</a-button>
</template>
</template>
<!-- 教师角色按钮 -->
<template v-if="userRole === 'teacher'">
<a-button
type="primary"
size="small"
@click="handleMyGuidance(contest.id)"
>
我的指导
</a-button>
</template>
<!-- 评委角色按钮 -->
<template v-if="userRole === 'judge'">
<a-button
type="primary"
size="small"
:disabled="isReviewEnded(contest)"
@click="handleReviewWorks(contest.id)"
>
评审作品
</a-button>
<a-button
size="small"
:disabled="isReviewEnded(contest)"
@click="handlePresetComments(contest.id)"
>
预设评语
</a-button>
</template>
</div>
</div>
</div> </div>
</div> </div>
@ -157,7 +206,13 @@
import { ref, reactive, computed, onMounted } from "vue" import { ref, reactive, computed, onMounted } from "vue"
import { useRouter, useRoute } from "vue-router" import { useRouter, useRoute } from "vue-router"
import { message } from "ant-design-vue" import { message } from "ant-design-vue"
import { TrophyOutlined } from "@ant-design/icons-vue" import {
TrophyOutlined,
CalendarOutlined,
AppstoreOutlined,
SearchOutlined,
CloseCircleFilled,
} from "@ant-design/icons-vue"
import dayjs from "dayjs" import dayjs from "dayjs"
import { import {
contestsApi, contestsApi,
@ -257,6 +312,20 @@ const handleTabChange = () => {
fetchList() fetchList()
} }
// Tab
const switchTab = (tab: "all" | "my") => {
if (activeTab.value !== tab) {
activeTab.value = tab
handleTabChange()
}
}
//
const clearSearch = () => {
searchKeyword.value = ""
handleSearch()
}
// //
const handleSearch = () => { const handleSearch = () => {
searchParams.contestName = searchKeyword.value || undefined searchParams.contestName = searchKeyword.value || undefined
@ -330,8 +399,36 @@ const handlePresetComments = (id: number) => {
// //
const imageErrors = ref<Record<number, boolean>>({}) const imageErrors = ref<Record<number, boolean>>({})
const handleImageError = (_event: Event, contestId: number) => { const handleImageError = (event: Event, contestId: number) => {
imageErrors.value[contestId] = true imageErrors.value[contestId] = true
//
const img = event.target as HTMLImageElement
img.style.display = "none"
}
// ID
const gradients = [
"linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
"linear-gradient(135deg, #f093fb 0%, #f5576c 100%)",
"linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)",
"linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)",
"linear-gradient(135deg, #fa709a 0%, #fee140 100%)",
"linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)",
"linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%)",
"linear-gradient(135deg, #96fbc4 0%, #f9f586 100%)",
]
const getGradientByIndex = (id: number): string => {
return gradients[id % gradients.length]
}
//
const getStageClass = (contest: Contest): string => {
if (isRegistering(contest)) return "stage-registering"
if (isSubmitting(contest)) return "stage-submitting"
if (isReviewing(contest)) return "stage-reviewing"
if (contest.status === "finished") return "stage-finished"
return ""
} }
// //
@ -404,6 +501,10 @@ const getStageText = (contest: Contest): string => {
if (isReviewing(contest)) { if (isReviewing(contest)) {
return "评审中" return "评审中"
} }
//
if (contest.status === "finished") {
return "已结束"
}
return "" return ""
} }
@ -429,6 +530,128 @@ $primary-light: #1677ff;
.contests-activities-page { .contests-activities-page {
padding: 24px; padding: 24px;
//
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
padding: 16px 20px;
background: #fff;
border-radius: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.header-left {
flex: 1;
}
.header-right {
flex-shrink: 0;
}
}
// Tab
.custom-tabs {
display: inline-flex;
background: #f5f7fa;
border-radius: 12px;
padding: 4px;
gap: 4px;
.tab-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.65);
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
.anticon {
font-size: 16px;
}
&:hover {
color: $primary;
background: rgba($primary, 0.08);
}
&.active {
color: #fff;
background: linear-gradient(135deg, $primary-light 0%, $primary 100%);
box-shadow: 0 4px 12px rgba($primary, 0.35);
.anticon {
color: #fff;
}
}
}
}
//
.custom-search {
position: relative;
display: flex;
align-items: center;
width: 280px;
height: 44px;
background: #f5f7fa;
border-radius: 12px;
padding: 0 16px;
transition: all 0.3s ease;
border: 2px solid transparent;
&:focus-within {
background: #fff;
border-color: $primary-light;
box-shadow: 0 0 0 4px rgba($primary, 0.1);
}
.search-icon {
font-size: 18px;
color: rgba(0, 0, 0, 0.35);
margin-right: 12px;
transition: color 0.3s ease;
}
&:focus-within .search-icon {
color: $primary;
}
.search-input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-size: 14px;
color: rgba(0, 0, 0, 0.85);
&::placeholder {
color: rgba(0, 0, 0, 0.35);
}
}
.search-clear {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-left: 8px;
color: rgba(0, 0, 0, 0.25);
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: rgba(0, 0, 0, 0.45);
}
}
}
.loading-container, .loading-container,
.empty-container { .empty-container {
display: flex; display: flex;
@ -439,112 +662,262 @@ $primary-light: #1677ff;
.contests-grid { .contests-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px; gap: 24px;
.contest-card { .contest-card {
background: #fff; background: #fff;
border-radius: 16px; border-radius: 16px;
padding: 24px;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid #f0f0f0; overflow: hidden;
display: flex; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
flex-direction: column;
gap: 16px;
&:hover { &:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
transform: translateY(-2px); transform: translateY(-6px);
.card-poster {
.poster-image,
.poster-placeholder {
transform: scale(1.05);
}
}
} }
.card-icon { //
width: 48px; .card-poster {
height: 48px; position: relative;
background: rgba($primary, 0.08); width: 100%;
border-radius: 12px; height: 180px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: $primary;
transition: all 0.3s ease;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
}
.card-desc { .poster-image {
font-size: 14px; width: 100%;
color: rgba(0, 0, 0, 0.45); height: 100%;
line-height: 1.6; object-fit: cover;
} transition: transform 0.4s ease;
}
.card-tags { .poster-placeholder {
display: flex; width: 100%;
flex-wrap: wrap; height: 100%;
gap: 8px; display: flex;
margin-top: auto;
.tag {
display: inline-flex;
align-items: center; align-items: center;
padding: 4px 12px; justify-content: center;
border-radius: 16px; transition: transform 0.4s ease;
font-size: 12px;
font-weight: 500;
transition: all 0.2s;
}
.tag-type { .placeholder-icon {
background: rgba($primary, 0.08); font-size: 48px;
color: $primary; color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba($primary, 0.2); filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
}
.tag-status {
background: rgba(0, 0, 0, 0.04);
color: rgba(0, 0, 0, 0.65);
border: 1px solid rgba(0, 0, 0, 0.08);
&.tag-ongoing {
background: rgba(82, 196, 26, 0.08);
color: #52c41a;
border-color: rgba(82, 196, 26, 0.2);
} }
} }
.tag-stage { //
background: rgba(250, 173, 20, 0.08); .stage-badge {
color: #d48806; position: absolute;
border: 1px solid rgba(250, 173, 20, 0.2); top: 12px;
left: 12px;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
color: #fff;
backdrop-filter: blur(8px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
&.stage-registering {
background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
}
&.stage-submitting {
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
}
&.stage-reviewing {
background: linear-gradient(135deg, #faad14 0%, #ffc53d 100%);
}
&.stage-finished {
background: linear-gradient(135deg, #8c8c8c 0%, #bfbfbf 100%);
}
}
//
.type-badge {
position: absolute;
top: 12px;
right: 12px;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 500;
color: #fff;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
} }
} }
.card-actions { //
.card-content {
padding: 20px;
display: flex; display: flex;
flex-wrap: wrap; flex-direction: column;
gap: 8px; gap: 12px;
padding-top: 12px;
border-top: 1px solid #f0f0f0; .card-title {
margin-top: 4px; font-size: 16px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-meta {
display: flex;
flex-direction: column;
gap: 8px;
.meta-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
.anticon {
font-size: 14px;
color: rgba(0, 0, 0, 0.35);
}
}
}
.card-footer {
display: flex;
align-items: center;
padding-top: 12px;
border-top: 1px solid #f5f5f5;
margin-top: auto;
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #d9d9d9;
margin-right: 8px;
&.status-ongoing {
background: #52c41a;
box-shadow: 0 0 0 3px rgba(82, 196, 26, 0.2);
}
}
.status-text {
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
}
.card-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-left: auto;
// -
:deep(.ant-btn-primary) {
border: none;
border-radius: 16px;
padding: 6px 16px;
height: auto;
font-size: 13px;
background: linear-gradient(135deg, #1890ff 0%, #0050b3 100%);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4);
transition: all 0.3s ease;
&:hover {
background: linear-gradient(135deg, #40a9ff 0%, #1890ff 100%);
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.5);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.4);
}
}
//
:deep(.ant-btn-default) {
border: none;
border-radius: 16px;
padding: 6px 16px;
height: auto;
font-size: 13px;
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf0 100%);
color: rgba(0, 0, 0, 0.75);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
&:hover {
background: linear-gradient(135deg, #e8ecf0 0%, #dce1e6 100%);
color: #1890ff;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
}
}
}
} }
} }
.pagination-container { .pagination-container {
grid-column: 1 / -1; grid-column: 1 / -1;
margin-top: 24px; margin-top: 32px;
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
} }
} }
//
@media (max-width: 768px) {
.contests-activities-page {
padding: 16px;
.contests-grid {
grid-template-columns: 1fr;
gap: 16px;
.contest-card {
.card-poster {
height: 160px;
}
.card-content {
padding: 16px;
.card-footer {
flex-wrap: wrap;
gap: 12px;
.card-actions {
width: 100%;
margin-left: 0;
justify-content: flex-start;
}
}
}
}
}
}
}
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@ -560,8 +560,9 @@ const handleSubmit = async () => {
submitLoading.value = true submitLoading.value = true
let workFileUrl = "" let modelFiles: string[] = []
let previewUrl = "" let previewUrl = ""
let previewUrlsList: string[] = []
const attachmentUrls: string[] = [] const attachmentUrls: string[] = []
if (uploadMode.value === "history") { if (uploadMode.value === "history") {
@ -570,8 +571,24 @@ const handleSubmit = async () => {
message.error("请选择一个3D作品") message.error("请选择一个3D作品")
return return
} }
workFileUrl = form.selectedWork.resultUrl || "" // 使 resultUrls previewUrls
previewUrl = form.selectedWork.previewUrl || "" const resultUrls = form.selectedWork.resultUrls || []
previewUrlsList = form.selectedWork.previewUrls || []
// URL files
if (resultUrls.length > 0) {
modelFiles = [...resultUrls]
} else if (form.selectedWork.resultUrl) {
// resultUrl
modelFiles = [form.selectedWork.resultUrl]
}
// 使
if (previewUrlsList.length > 0) {
previewUrl = previewUrlsList[0]
} else {
previewUrl = form.selectedWork.previewUrl || ""
}
} else { } else {
// //
if (!form.localWorkFile) { if (!form.localWorkFile) {
@ -585,7 +602,8 @@ const handleSubmit = async () => {
// 3D // 3D
try { try {
workFileUrl = await uploadFile(form.localWorkFile) const uploadedUrl = await uploadFile(form.localWorkFile)
modelFiles = [uploadedUrl]
} catch (error: any) { } catch (error: any) {
message.error("3D文件上传失败" + (error?.message || "未知错误")) message.error("3D文件上传失败" + (error?.message || "未知错误"))
submitLoading.value = false submitLoading.value = false
@ -595,6 +613,7 @@ const handleSubmit = async () => {
// //
try { try {
previewUrl = await uploadFile(form.localPreviewFile) previewUrl = await uploadFile(form.localPreviewFile)
previewUrlsList = [previewUrl]
} catch (error: any) { } catch (error: any) {
message.error("预览图上传失败:" + (error?.message || "未知错误")) message.error("预览图上传失败:" + (error?.message || "未知错误"))
submitLoading.value = false submitLoading.value = false
@ -616,8 +635,9 @@ const handleSubmit = async () => {
registrationId: registrationIdRef.value, registrationId: registrationIdRef.value,
title: form.title, title: form.title,
description: form.description, description: form.description,
files: [workFileUrl, ...attachmentUrls], files: [...modelFiles, ...attachmentUrls],
previewUrl: previewUrl, previewUrl: previewUrl,
previewUrls: previewUrlsList.length > 0 ? previewUrlsList : undefined,
} }
await worksApi.submit(submitData) await worksApi.submit(submitData)

View File

@ -19,51 +19,56 @@
</div> </div>
<div v-else class="work-detail"> <div v-else class="work-detail">
<!-- 作品预览卡片 --> <!-- 多模型预览网格 -->
<div class="work-preview-card"> <div class="models-preview-grid">
<div <div
class="preview-wrapper" v-for="(model, index) in modelItems"
@mouseenter="showActions = true" :key="index"
@mouseleave="showActions = false" class="model-preview-card"
@mouseenter="hoveredIndex = index"
@mouseleave="hoveredIndex = -1"
> >
<!-- 预览图 --> <!-- 预览图 -->
<img <img
v-if="previewImageUrl" v-if="model.previewUrl"
:src="previewImageUrl" :src="model.previewUrl"
alt="作品预览" alt="模型预览"
class="preview-image" class="preview-image"
@error="handlePreviewError" @error="(e) => handlePreviewError(e, index)"
/> />
<div v-else class="preview-placeholder"> <div v-else class="preview-placeholder">
<FileImageOutlined class="placeholder-icon" /> <FileImageOutlined class="placeholder-icon" />
<span>暂无预览图</span> <span>模型 {{ index + 1 }}</span>
</div> </div>
<!-- 悬浮操作按钮 --> <!-- 悬浮操作按钮 -->
<transition name="fade"> <transition name="fade">
<div v-show="showActions" class="actions-overlay"> <div v-show="hoveredIndex === index" class="actions-overlay">
<div class="actions-buttons"> <a-button
<a-button v-if="model.fileUrl && is3DModelFile(model.fileUrl)"
v-if="workFile && is3DModelFile(workFile)" type="primary"
type="primary" size="small"
@click="handlePreview3DModel(workFile)" @click="handlePreview3DModel(model.fileUrl, index)"
> >
<template #icon><EyeOutlined /></template> <template #icon><EyeOutlined /></template>
预览模型 3D预览
</a-button> </a-button>
<a-button
v-if="workFile"
@click="handleDownloadWork"
>
<template #icon><DownloadOutlined /></template>
下载作品
</a-button>
</div>
</div> </div>
</transition> </transition>
<!-- 模型序号 -->
<div class="model-index">{{ index + 1 }}</div>
</div> </div>
</div> </div>
<!-- 下载按钮 -->
<div v-if="modelItems.length > 0" class="download-section">
<a-button @click="handleDownloadWork">
<template #icon><DownloadOutlined /></template>
下载全部模型
</a-button>
</div>
<!-- 作品信息 --> <!-- 作品信息 -->
<div class="work-info-section"> <div class="work-info-section">
<div class="info-row"> <div class="info-row">
@ -165,8 +170,8 @@ const authStore = useAuthStore()
const visible = ref(false) const visible = ref(false)
const loading = ref(false) const loading = ref(false)
const work = ref<ContestWork | null>(null) const work = ref<ContestWork | null>(null)
const showActions = ref(false) const hoveredIndex = ref(-1)
const previewError = ref(false) const previewErrors = ref<Record<number, boolean>>({})
// //
watch( watch(
@ -174,11 +179,11 @@ watch(
async (newVal) => { async (newVal) => {
visible.value = newVal visible.value = newVal
if (newVal) { if (newVal) {
previewError.value = false previewErrors.value = {}
await fetchUserWork() await fetchUserWork()
} else { } else {
work.value = null work.value = null
showActions.value = false hoveredIndex.value = -1
} }
}, },
{ immediate: true } { immediate: true }
@ -234,39 +239,77 @@ const fetchUserWork = async () => {
} }
} }
// URL // - files previewUrls
const previewImageUrl = computed(() => { interface ModelItem {
if (!work.value || previewError.value) return null fileUrl: string
previewUrl: string
}
// 使 previewUrl const modelItems = computed<ModelItem[]>(() => {
if (work.value.previewUrl) { if (!work.value) return []
return getFileUrl(work.value.previewUrl)
}
return null // files
})
//
const workFile = computed(() => {
if (!work.value) return null
let files = work.value.files || [] let files = work.value.files || []
// files JSON
if (typeof files === "string") { if (typeof files === "string") {
try { try {
files = JSON.parse(files) files = JSON.parse(files)
} catch { } catch {
return null files = []
}
}
if (!Array.isArray(files)) files = []
// previewUrls
let previewUrls = work.value.previewUrls || []
if (typeof previewUrls === "string") {
try {
previewUrls = JSON.parse(previewUrls)
} catch {
previewUrls = []
}
}
if (!Array.isArray(previewUrls)) previewUrls = []
// previewUrls previewUrl使
if (previewUrls.length === 0 && work.value.previewUrl) {
previewUrls = [work.value.previewUrl]
}
// 3D
const modelFiles = files.filter((f: any) => {
const url = typeof f === "object" && f?.fileUrl ? f.fileUrl : f
return url && is3DModelFile(url)
})
//
return modelFiles.map((f: any, index: number) => {
const fileUrl = typeof f === "object" && f?.fileUrl ? f.fileUrl : f
const previewUrl = previewUrls[index] || previewUrls[0] || ""
return {
fileUrl: getFileUrl(fileUrl),
previewUrl: previewUrl && !previewErrors.value[index] ? getFileUrl(previewUrl) : "",
}
})
})
//
const workFiles = computed(() => {
if (!work.value) return []
let files = work.value.files || []
if (typeof files === "string") {
try {
files = JSON.parse(files)
} catch {
return []
} }
} }
if (!Array.isArray(files) || files.length === 0) return null if (!Array.isArray(files)) return []
// {fileUrl: string} return files.map((f: any) => {
const firstFile = files[0] return typeof f === "object" && f?.fileUrl ? f.fileUrl : f
return typeof firstFile === "object" && firstFile?.fileUrl }).filter(Boolean)
? firstFile.fileUrl
: firstFile
}) })
// 3D // 3D
@ -303,38 +346,57 @@ const getFileUrl = (fileUrl: string): string => {
} }
// //
const handlePreviewError = () => { const handlePreviewError = (_e: Event, index: number) => {
previewError.value = true previewErrors.value[index] = true
} }
// 3D // 3D -
const handlePreview3DModel = (fileUrl: string) => { const handlePreview3DModel = (fileUrl: string, index: number) => {
if (!fileUrl) { if (!fileUrl) {
message.error("文件路径无效") message.error("文件路径无效")
return return
} }
const fullUrl = getFileUrl(fileUrl)
const tenantCode = route.params.tenantCode as string const tenantCode = route.params.tenantCode as string
// URL
const allModelUrls = modelItems.value.map((m) => m.fileUrl)
if (allModelUrls.length > 1) {
sessionStorage.setItem("model-viewer-urls", JSON.stringify(allModelUrls))
sessionStorage.setItem("model-viewer-index", String(index))
} else {
sessionStorage.removeItem("model-viewer-urls")
sessionStorage.removeItem("model-viewer-index")
}
router.push({ router.push({
path: `/${tenantCode}/workbench/model-viewer`, path: `/${tenantCode}/workbench/model-viewer`,
query: { url: fullUrl }, query: { url: fileUrl },
}) })
} }
// // -
const handleDownloadWork = () => { const handleDownloadWork = () => {
if (!workFile.value) { if (workFiles.value.length === 0) {
message.error("无作品文件") message.error("无作品文件")
return return
} }
const fileUrl = getFileUrl(workFile.value)
const link = document.createElement("a") // 3D
link.href = fileUrl workFiles.value.forEach((file, index) => {
link.download = work.value?.title || "作品" if (is3DModelFile(file)) {
link.target = "_blank" setTimeout(() => {
document.body.appendChild(link) const fileUrl = getFileUrl(file)
link.click() const link = document.createElement("a")
document.body.removeChild(link) link.href = fileUrl
link.download = `${work.value?.title || "作品"}_${index + 1}`
link.target = "_blank"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}, index * 500) //
}
})
message.success("开始下载作品") message.success("开始下载作品")
} }
@ -425,44 +487,69 @@ const handleCancel = () => {
padding: 0; padding: 0;
} }
// //
.work-preview-card { .models-preview-grid {
margin-bottom: 24px; display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 16px;
} }
.preview-wrapper { .model-preview-card {
position: relative; position: relative;
width: 100%;
aspect-ratio: 1; aspect-ratio: 1;
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
background: #f5f5f5; background: #f5f5f5;
cursor: pointer; cursor: pointer;
} transition: all 0.3s ease;
border: 2px solid transparent;
.preview-image { &:hover {
width: 100%; border-color: #1890ff;
height: 100%; box-shadow: 0 4px 12px rgba(24, 144, 255, 0.2);
object-fit: cover;
}
.preview-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(0, 0, 0, 0.25);
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
.placeholder-icon {
font-size: 64px;
margin-bottom: 16px;
} }
span { .preview-image {
font-size: 14px; width: 100%;
height: 100%;
object-fit: cover;
}
.preview-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(0, 0, 0, 0.25);
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
.placeholder-icon {
font-size: 36px;
margin-bottom: 8px;
}
span {
font-size: 12px;
}
}
.model-index {
position: absolute;
top: 8px;
left: 8px;
width: 24px;
height: 24px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 500;
} }
} }
@ -476,14 +563,11 @@ const handleCancel = () => {
justify-content: center; justify-content: center;
} }
.actions-buttons { //
.download-section {
margin-bottom: 20px;
display: flex; display: flex;
flex-direction: column; justify-content: center;
gap: 12px;
:deep(.ant-btn) {
min-width: 140px;
}
} }
// //