修改样式
This commit is contained in:
parent
ffd1d6bbe5
commit
b002e3ca1c
@ -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) /// 最终得分(根据规则计算)
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 |
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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
@ -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)
|
||||||
|
|||||||
@ -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
|
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)
|
|
||||||
|
// 下载所有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,28 +487,36 @@ 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 {
|
||||||
|
border-color: #1890ff;
|
||||||
|
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-placeholder {
|
.preview-placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 淡入淡出动画
|
// 淡入淡出动画
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user