修改样式

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"
:class="{ active: activeTab === 'all' }"
@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"
/> />
</template> <div v-if="searchKeyword" class="search-clear" @click="clearSearch">
</a-card> <CloseCircleFilled />
</div>
</div>
</div>
</div>
<!-- 赛事列表 --> <!-- 赛事列表 -->
<div v-if="loading" class="loading-container"> <div v-if="loading" class="loading-container">
@ -34,35 +57,59 @@
class="contest-card" class="contest-card"
@click="handleViewDetail(contest.id)" @click="handleViewDetail(contest.id)"
> >
<!-- 卡片图标 --> <!-- 海报区域 -->
<div class="card-icon"> <div class="card-poster">
<TrophyOutlined /> <img
v-if="contest.posterUrl || contest.coverUrl"
:src="contest.posterUrl || contest.coverUrl"
alt="赛事海报"
class="poster-image"
@error="(e) => handleImageError(e, contest.id)"
/>
<div
v-else
class="poster-placeholder"
:style="{ background: getGradientByIndex(contest.id) }"
>
<TrophyOutlined class="placeholder-icon" />
</div>
<!-- 状态角标 -->
<div
v-if="getStageText(contest)"
class="stage-badge"
:class="getStageClass(contest)"
>
{{ getStageText(contest) }}
</div>
<!-- 赛事类型角标 -->
<div class="type-badge">
{{ contest.contestType === "individual" ? "个人赛" : "团队赛" }}
</div>
</div> </div>
<!-- 内容区域 -->
<div class="card-content">
<!-- 卡片标题 --> <!-- 卡片标题 -->
<div class="card-title">{{ contest.contestName }}</div> <div class="card-title">{{ contest.contestName }}</div>
<!-- 卡片描述 --> <!-- 时间信息 -->
<div class="card-desc"> <div class="card-meta">
赛事时间{{ formatDate(contest.startTime) }} ~ <div class="meta-item">
{{ formatDate(contest.endTime) }} <CalendarOutlined />
<span
>{{ formatDate(contest.startTime) }} ~
{{ formatDate(contest.endTime) }}</span
>
</div>
</div> </div>
<!-- 卡片标签 --> <!-- 底部区域 -->
<div class="card-tags"> <div class="card-footer">
<span class="tag tag-type">
{{ contest.contestType === "individual" ? "个人赛" : "团队赛" }}
</span>
<span <span
class="tag tag-status" class="status-dot"
:class="{ 'tag-ongoing': contest.status === 'ongoing' }" :class="{ 'status-ongoing': contest.status === 'ongoing' }"
> ></span>
{{ getStatusText(contest) }} <span class="status-text">{{ getStatusText(contest) }}</span>
</span>
<span v-if="getStageText(contest)" class="tag tag-stage">
{{ getStageText(contest) }}
</span>
</div>
<!-- 操作按钮区域 - 我的赛事tab显示 --> <!-- 操作按钮区域 - 我的赛事tab显示 -->
<div v-if="activeTab === 'my'" class="card-actions" @click.stop> <div v-if="activeTab === 'my'" class="card-actions" @click.stop>
@ -122,6 +169,8 @@
</template> </template>
</div> </div>
</div> </div>
</div>
</div>
<!-- 分页 --> <!-- 分页 -->
<div class="pagination-container"> <div class="pagination-container">
@ -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;
overflow: hidden;
.poster-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.4s ease;
}
.poster-placeholder {
width: 100%;
height: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 22px; transition: transform 0.4s ease;
color: $primary;
transition: all 0.3s ease; .placeholder-icon {
font-size: 48px;
color: rgba(255, 255, 255, 0.8);
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
} }
}
//
.stage-badge {
position: absolute;
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-content {
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
.card-title { .card-title {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: rgba(0, 0, 0, 0.85); color: rgba(0, 0, 0, 0.88);
line-height: 1.4; line-height: 1.5;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
.card-desc { .card-meta {
font-size: 14px; 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); color: rgba(0, 0, 0, 0.45);
line-height: 1.6;
.anticon {
font-size: 14px;
color: rgba(0, 0, 0, 0.35);
}
}
} }
.card-tags { .card-footer {
display: flex; display: flex;
flex-wrap: wrap; align-items: center;
gap: 8px; padding-top: 12px;
border-top: 1px solid #f5f5f5;
margin-top: auto; margin-top: auto;
.tag { .status-dot {
display: inline-flex; width: 8px;
align-items: center; height: 8px;
padding: 4px 12px; border-radius: 50%;
border-radius: 16px; background: #d9d9d9;
font-size: 12px; margin-right: 8px;
font-weight: 500;
transition: all 0.2s;
}
.tag-type { &.status-ongoing {
background: rgba($primary, 0.08); background: #52c41a;
color: $primary; box-shadow: 0 0 0 3px rgba(82, 196, 26, 0.2);
border: 1px solid rgba($primary, 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 { .status-text {
background: rgba(250, 173, 20, 0.08); font-size: 13px;
color: #d48806; color: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(250, 173, 20, 0.2);
}
} }
.card-actions { .card-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
padding-top: 12px; margin-left: auto;
border-top: 1px solid #f0f0f0;
margin-top: 4px; // -
: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
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 || "" 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="workFile && is3DModelFile(workFile)" v-if="model.fileUrl && is3DModelFile(model.fileUrl)"
type="primary" type="primary"
@click="handlePreview3DModel(workFile)" size="small"
@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
if (work.value.previewUrl) {
return getFileUrl(work.value.previewUrl)
} }
return null const modelItems = computed<ModelItem[]>(() => {
}) if (!work.value) return []
// // 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)
// 3D
workFiles.value.forEach((file, index) => {
if (is3DModelFile(file)) {
setTimeout(() => {
const fileUrl = getFileUrl(file)
const link = document.createElement("a") const link = document.createElement("a")
link.href = fileUrl link.href = fileUrl
link.download = work.value?.title || "作品" link.download = `${work.value?.title || "作品"}_${index + 1}`
link.target = "_blank" link.target = "_blank"
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
document.body.removeChild(link) document.body.removeChild(link)
}, index * 500) //
}
})
message.success("开始下载作品") message.success("开始下载作品")
} }
@ -425,19 +487,27 @@ 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;
&:hover {
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.2);
} }
.preview-image { .preview-image {
@ -457,12 +527,29 @@ const handleCancel = () => {
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%); background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
.placeholder-icon { .placeholder-icon {
font-size: 64px; font-size: 36px;
margin-bottom: 16px; margin-bottom: 8px;
} }
span { span {
font-size: 14px; 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;
}
} }
// //