修改样式
This commit is contained in:
parent
ffd1d6bbe5
commit
b002e3ca1c
@ -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) /// 最终得分(根据规则计算)
|
||||
|
||||
@ -20,6 +20,11 @@ export class SubmitWorkDto {
|
||||
@IsOptional()
|
||||
previewUrl?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
previewUrls?: string[];
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
aiModelMeta?: any;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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 |
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
// 优先使用 previewUrl
|
||||
if (work.value.previewUrl) {
|
||||
return getFileUrl(work.value.previewUrl)
|
||||
// 模型项目列表 - 解析 files 和 previewUrls 数组
|
||||
interface ModelItem {
|
||||
fileUrl: string
|
||||
previewUrl: string
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
const modelItems = computed<ModelItem[]>(() => {
|
||||
if (!work.value) return []
|
||||
|
||||
// 作品文件(只取第一个)
|
||||
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,19 +487,27 @@ 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;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
@ -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;
|
||||
}
|
||||
|
||||
// 淡入淡出动画
|
||||
|
||||
Loading…
Reference in New Issue
Block a user