修改样式

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
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) /// 最终得分(根据规则计算)

View File

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

View File

@ -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,
};

View File

@ -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,

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;
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;
}

View File

@ -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
}
/**

View File

@ -1,22 +1,45 @@
<template>
<div class="contests-activities-page">
<a-card class="mb-4">
<template #title>
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange">
<a-tab-pane key="my" :tab="myTabTitle" />
<a-tab-pane key="all" tab="全部赛事" />
</a-tabs>
</template>
<template #extra>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索赛事"
style="width: 300px"
@search="handleSearch"
@press-enter="handleSearch"
<!-- 顶部导航栏 -->
<div class="page-header">
<div class="header-left">
<!-- 自定义 Tab 切换 -->
<div class="custom-tabs">
<div
class="tab-item"
:class="{ active: activeTab === 'my' }"
@click="switchTab('my')"
>
<TrophyOutlined />
<span>{{ myTabTitle }}</span>
</div>
<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>
</a-card>
<div v-if="searchKeyword" class="search-clear" @click="clearSearch">
<CloseCircleFilled />
</div>
</div>
</div>
</div>
<!-- 赛事列表 -->
<div v-if="loading" class="loading-container">
@ -34,35 +57,59 @@
class="contest-card"
@click="handleViewDetail(contest.id)"
>
<!-- 卡片图标 -->
<div class="card-icon">
<TrophyOutlined />
<!-- 海报区域 -->
<div class="card-poster">
<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 class="card-content">
<!-- 卡片标题 -->
<div class="card-title">{{ contest.contestName }}</div>
<!-- 卡片描述 -->
<div class="card-desc">
赛事时间{{ formatDate(contest.startTime) }} ~
{{ formatDate(contest.endTime) }}
<!-- 时间信息 -->
<div class="card-meta">
<div class="meta-item">
<CalendarOutlined />
<span
>{{ formatDate(contest.startTime) }} ~
{{ formatDate(contest.endTime) }}</span
>
</div>
</div>
<!-- 卡片标签 -->
<div class="card-tags">
<span class="tag tag-type">
{{ contest.contestType === "individual" ? "个人赛" : "团队赛" }}
</span>
<!-- 底部区域 -->
<div class="card-footer">
<span
class="tag tag-status"
:class="{ 'tag-ongoing': contest.status === 'ongoing' }"
>
{{ getStatusText(contest) }}
</span>
<span v-if="getStageText(contest)" class="tag tag-stage">
{{ getStageText(contest) }}
</span>
</div>
class="status-dot"
:class="{ 'status-ongoing': contest.status === 'ongoing' }"
></span>
<span class="status-text">{{ getStatusText(contest) }}</span>
<!-- 操作按钮区域 - 我的赛事tab显示 -->
<div v-if="activeTab === 'my'" class="card-actions" @click.stop>
@ -122,6 +169,8 @@
</template>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination-container">
@ -157,7 +206,13 @@
import { ref, reactive, computed, onMounted } from "vue"
import { useRouter, useRoute } from "vue-router"
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 {
contestsApi,
@ -257,6 +312,20 @@ const handleTabChange = () => {
fetchList()
}
// Tab
const switchTab = (tab: "all" | "my") => {
if (activeTab.value !== tab) {
activeTab.value = tab
handleTabChange()
}
}
//
const clearSearch = () => {
searchKeyword.value = ""
handleSearch()
}
//
const handleSearch = () => {
searchParams.contestName = searchKeyword.value || undefined
@ -330,8 +399,36 @@ const handlePresetComments = (id: number) => {
//
const imageErrors = ref<Record<number, boolean>>({})
const handleImageError = (_event: Event, contestId: number) => {
const handleImageError = (event: Event, contestId: number) => {
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)) {
return "评审中"
}
//
if (contest.status === "finished") {
return "已结束"
}
return ""
}
@ -429,6 +530,128 @@ $primary-light: #1677ff;
.contests-activities-page {
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,
.empty-container {
display: flex;
@ -439,112 +662,262 @@ $primary-light: #1677ff;
.contests-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 24px;
.contest-card {
background: #fff;
border-radius: 16px;
padding: 24px;
cursor: pointer;
transition: all 0.3s ease;
border: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
gap: 16px;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
&:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
transform: translateY(-6px);
.card-poster {
.poster-image,
.poster-placeholder {
transform: scale(1.05);
}
}
}
.card-icon {
width: 48px;
height: 48px;
background: rgba($primary, 0.08);
border-radius: 12px;
//
.card-poster {
position: relative;
width: 100%;
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;
align-items: center;
justify-content: center;
font-size: 22px;
color: $primary;
transition: all 0.3s ease;
transition: transform 0.4s 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 {
font-size: 16px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
line-height: 1.4;
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-desc {
font-size: 14px;
.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);
line-height: 1.6;
.anticon {
font-size: 14px;
color: rgba(0, 0, 0, 0.35);
}
}
}
.card-tags {
.card-footer {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding-top: 12px;
border-top: 1px solid #f5f5f5;
margin-top: auto;
.tag {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 16px;
font-size: 12px;
font-weight: 500;
transition: all 0.2s;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #d9d9d9;
margin-right: 8px;
.tag-type {
background: rgba($primary, 0.08);
color: $primary;
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);
&.status-ongoing {
background: #52c41a;
box-shadow: 0 0 0 3px rgba(82, 196, 26, 0.2);
}
}
.tag-stage {
background: rgba(250, 173, 20, 0.08);
color: #d48806;
border: 1px solid rgba(250, 173, 20, 0.2);
}
.status-text {
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
}
.card-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
margin-top: 4px;
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 {
grid-column: 1 / -1;
margin-top: 24px;
margin-top: 32px;
display: flex;
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>

File diff suppressed because it is too large Load Diff

View File

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

View File

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