修改样式

This commit is contained in:
zhangxiaohua 2026-01-15 09:28:22 +08:00
parent 5be24bdc31
commit 9d3537ce53
8 changed files with 840 additions and 935 deletions

View File

@ -8,7 +8,8 @@
"WebFetch(domain:3d.hunyuan.tencent.com)", "WebFetch(domain:3d.hunyuan.tencent.com)",
"WebSearch", "WebSearch",
"WebFetch(domain:cloud.tencent.com)", "WebFetch(domain:cloud.tencent.com)",
"WebFetch(domain:cloud.tencent.com.cn)" "WebFetch(domain:cloud.tencent.com.cn)",
"WebFetch(domain:ui-ux-pro-max-skill.nextlevelbuilder.io)"
] ]
} }
} }

View File

@ -323,11 +323,11 @@ const handleLogout = async () => {
color: var(--sidebar-menu-text-selected, #01412b); // Logo 使 color: var(--sidebar-menu-text-selected, #01412b); // Logo 使
margin-bottom: 8px; margin-bottom: 8px;
border-radius: 0; // border-radius: 0; //
padding: 12px; padding: 20px 12px;
img { img {
max-width: 100%; max-width: 100%;
max-height: 40px; max-height: 30px;
object-fit: contain; object-fit: contain;
} }

View File

@ -27,33 +27,41 @@
<a-empty description="暂无赛事" /> <a-empty description="暂无赛事" />
</div> </div>
<div v-else class="contests-list"> <div v-else class="contests-grid">
<div v-for="contest in dataSource" :key="contest.id" class="contest-card">
<div class="contest-card-content">
<!-- 左侧封面 -->
<div class="contest-cover-wrapper">
<div class="contest-cover">
<img
v-if="contest.coverUrl && !imageErrors[contest.id]"
:src="contest.coverUrl"
:alt="contest.contestName"
@error="(e) => handleImageError(e, contest.id)"
/>
<div <div
v-if="!contest.coverUrl || imageErrors[contest.id]" v-for="contest in dataSource"
class="cover-placeholder" :key="contest.id"
class="contest-card"
@click="handleViewDetail(contest.id)"
> >
<span>赛事封面</span> <!-- 卡片图标 -->
<div class="card-icon">
<TrophyOutlined />
</div> </div>
<a-tag
class="contest-type-tag" <!-- 卡片标题 -->
:color="contest.contestType === 'individual' ? 'blue' : 'green'" <div class="card-title">{{ contest.contestName }}</div>
>
<!-- 卡片描述 -->
<div class="card-desc">
赛事时间{{ formatDate(contest.startTime) }} ~ {{ formatDate(contest.endTime) }}
</div>
<!-- 卡片标签 -->
<div class="card-tags">
<span class="tag tag-type">
{{ contest.contestType === "individual" ? "个人赛" : "团队赛" }} {{ contest.contestType === "individual" ? "个人赛" : "团队赛" }}
</a-tag> </span>
<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> </div>
<!-- 图片下方按钮 - 根据角色显示不同按钮 -->
<div v-if="activeTab === 'my'" class="cover-buttons"> <!-- 操作按钮区域 - 我的赛事tab显示 -->
<div v-if="activeTab === 'my'" class="card-actions" @click.stop>
<!-- 学生角色按钮 --> <!-- 学生角色按钮 -->
<template v-if="userRole === 'student'"> <template v-if="userRole === 'student'">
<template v-if="contest.contestType === 'individual'"> <template v-if="contest.contestType === 'individual'">
@ -61,28 +69,19 @@
v-if="isSubmitting(contest)" v-if="isSubmitting(contest)"
type="primary" type="primary"
size="small" size="small"
@click.stop="handleUploadWork(contest.id)" @click="handleUploadWork(contest.id)"
> >
上传作品 上传作品
</a-button> </a-button>
<a-button <a-button size="small" @click="handleViewWorks(contest.id)">
size="small"
@click.stop="handleViewWorks(contest.id)"
>
参赛作品 参赛作品
</a-button> </a-button>
</template> </template>
<template v-else> <template v-else>
<a-button <a-button size="small" @click="handleViewWorks(contest.id)">
size="small"
@click.stop="handleViewWorks(contest.id)"
>
参赛作品 参赛作品
</a-button> </a-button>
<a-button <a-button size="small" @click="handleViewTeam(contest.id)">
size="small"
@click.stop="handleViewTeam(contest.id)"
>
我的队伍 我的队伍
</a-button> </a-button>
</template> </template>
@ -93,7 +92,7 @@
<a-button <a-button
type="primary" type="primary"
size="small" size="small"
@click.stop="handleMyGuidance(contest.id)" @click="handleMyGuidance(contest.id)"
> >
我的指导 我的指导
</a-button> </a-button>
@ -105,14 +104,14 @@
type="primary" type="primary"
size="small" size="small"
:disabled="isReviewEnded(contest)" :disabled="isReviewEnded(contest)"
@click.stop="handleReviewWorks(contest.id)" @click="handleReviewWorks(contest.id)"
> >
评审作品 评审作品
</a-button> </a-button>
<a-button <a-button
size="small" size="small"
:disabled="isReviewEnded(contest)" :disabled="isReviewEnded(contest)"
@click.stop="handlePresetComments(contest.id)" @click="handlePresetComments(contest.id)"
> >
预设评语 预设评语
</a-button> </a-button>
@ -120,75 +119,6 @@
</div> </div>
</div> </div>
<!-- 右侧内容 -->
<div class="contest-content">
<div class="contest-title">{{ contest.contestName }}</div>
<div class="contest-status">
<div class="status-row">
<a-tag color="success" class="status-tag">
{{ getStatusText(contest) }}
</a-tag>
<span v-if="contest.status === 'ongoing'" class="stage-text">
{{ getStageText(contest) }}
</span>
</div>
<div class="time-info">
<div class="time-item">
<span class="time-label">赛事时间:</span>
<span class="time-value">
{{ formatDate(contest.startTime) }} ~
{{ formatDate(contest.endTime) }}
</span>
</div>
<div v-if="isRegistering(contest)" class="time-item">
<a-tag color="success" class="stage-tag">报名中</a-tag>
<span class="time-label">报名时间:</span>
<span class="time-value">
{{ formatDate(contest.registerStartTime) }} ~
{{ formatDate(contest.registerEndTime) }}
</span>
</div>
<div v-if="isSubmitting(contest)" class="time-item">
<a-tag color="success" class="stage-tag">征稿中</a-tag>
<span class="time-label">提交作品:</span>
<span class="time-value">
{{ formatDate(contest.submitStartTime) }} ~
{{ formatDate(contest.submitEndTime) }}
</span>
</div>
<div v-if="isReviewing(contest)" class="time-item">
<a-tag color="processing" class="stage-tag">评审中</a-tag>
<span class="time-label">评审作品:</span>
<span class="time-value">
{{ formatDate(contest.reviewStartTime) }} ~
{{ formatDate(contest.reviewEndTime) }}
</span>
</div>
<div
v-if="
contest.resultState === 'published' &&
contest.resultPublishTime
"
class="time-item"
>
<span class="time-label">结果公布:</span>
<span class="time-value">
{{ formatDate(contest.resultPublishTime) }}
</span>
</div>
</div>
</div>
</div>
<!-- 右侧操作按钮 -->
<div class="contest-actions">
<a-button type="primary" @click.stop="handleViewDetail(contest.id)">
查看活动
</a-button>
</div>
</div>
</div>
<!-- 分页 --> <!-- 分页 -->
<div class="pagination-container"> <div class="pagination-container">
<a-pagination <a-pagination
@ -223,6 +153,7 @@
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 dayjs from "dayjs" import dayjs from "dayjs"
import { import {
contestsApi, contestsApi,
@ -487,6 +418,10 @@ onMounted(() => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
//
$primary: #0958d9;
$primary-light: #1677ff;
.contests-activities-page { .contests-activities-page {
padding: 24px; padding: 24px;
@ -498,159 +433,115 @@ onMounted(() => {
min-height: 400px; min-height: 400px;
} }
.contests-list { .contests-grid {
display: flex; display: grid;
flex-direction: column; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px; gap: 20px;
.contest-card { .contest-card {
background: #fff; background: #fff;
border-radius: 8px; border-radius: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); padding: 24px;
transition: all 0.3s; cursor: pointer;
overflow: hidden; transition: all 0.3s ease;
border: 1px solid #f0f0f0;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.contest-card-content {
display: flex;
align-items: flex-start;
padding: 16px;
gap: 16px;
}
.contest-cover-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 16px;
flex-shrink: 0;
&:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
.card-icon {
background: linear-gradient(135deg, #ff7a45 0%, #fa541c 100%);
color: #fff;
}
} }
.contest-cover { .card-icon {
position: relative; width: 48px;
width: 200px; height: 48px;
height: 150px; background: rgba($primary, 0.08);
overflow: hidden; border-radius: 12px;
background-color: #f5f5f5;
border-radius: 4px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-placeholder {
width: 100%;
height: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #999; font-size: 22px;
font-size: 14px; color: $primary;
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%); transition: all 0.3s ease;
} }
.contest-type-tag { .card-title {
position: absolute;
top: 8px;
left: 8px;
margin: 0;
}
}
.cover-buttons {
display: flex;
gap: 8px;
justify-content: center;
width: 200px;
flex-wrap: wrap;
}
.contest-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.contest-title {
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 600;
color: #262626; color: rgba(0, 0, 0, 0.85);
overflow: hidden; line-height: 1.4;
text-overflow: ellipsis;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden;
} }
.contest-status { .card-desc {
display: flex;
flex-direction: column;
gap: 8px;
.status-row {
display: flex;
align-items: center;
gap: 8px;
.status-tag {
margin: 0;
}
.stage-text {
color: #52c41a;
font-size: 14px; font-size: 14px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.6;
}
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
.tag {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 16px;
font-size: 12px;
font-weight: 500; font-weight: 500;
transition: all 0.2s;
}
.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);
} }
} }
.time-info { .tag-stage {
display: flex; background: rgba(250, 173, 20, 0.08);
flex-direction: column; color: #d48806;
gap: 6px; border: 1px solid rgba(250, 173, 20, 0.2);
font-size: 14px; }
}
.time-item { .card-actions {
display: flex; display: flex;
align-items: center; flex-wrap: wrap;
gap: 8px; gap: 8px;
padding-top: 12px;
.stage-tag { border-top: 1px solid #f0f0f0;
margin: 0; margin-top: 4px;
}
.time-label {
color: #8c8c8c;
white-space: nowrap;
}
.time-value {
color: #595959;
}
}
}
}
.contest-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
flex-shrink: 0;
.action-buttons {
display: flex;
gap: 8px;
}
} }
} }
.pagination-container { .pagination-container {
grid-column: 1 / -1;
margin-top: 24px; margin-top: 24px;
display: flex; display: flex;
justify-content: center; justify-content: center;

View File

@ -3,7 +3,7 @@
v-model:open="visible" v-model:open="visible"
title="参赛作品" title="参赛作品"
placement="right" placement="right"
width="850px" width="600px"
:footer-style="{ textAlign: 'right', padding: '16px 24px' }" :footer-style="{ textAlign: 'right', padding: '16px 24px' }"
@close="handleCancel" @close="handleCancel"
> >
@ -19,108 +19,87 @@
</div> </div>
<div v-else class="work-detail"> <div v-else class="work-detail">
<!-- 作品名称 --> <!-- 作品预览卡片 -->
<div class="work-section"> <div class="work-preview-card">
<div class="section-label">作品名称</div>
<div class="section-content">{{ work.title }}</div>
</div>
<!-- 作品介绍 -->
<div class="work-section">
<div class="section-label">作品介绍</div>
<div class="section-content description-text">
{{ work.description || "暂无介绍" }}
</div>
</div>
<!-- 参赛作品 -->
<div class="work-section">
<div class="section-label">参赛作品</div>
<div class="work-file-container">
<div v-if="workFile" class="work-image-item">
<div <div
class="image-wrapper" class="preview-wrapper"
@mouseenter="handleImageHover(workFile)" @mouseenter="showActions = true"
@mouseleave="handleImageLeave" @mouseleave="showActions = false"
> >
<!-- 预览图 -->
<img <img
v-if="isImageFile(workFile)" v-if="previewImageUrl"
:src="getFileUrl(workFile)" :src="previewImageUrl"
alt="作品图片"
class="work-image"
@error="handleImageError"
/>
<div
v-else-if="is3DModelFile(workFile)"
class="file-placeholder model-file"
>
<FileOutlined class="file-icon" />
<span class="file-name">{{ getFileName(workFile) }}</span>
<a-button
type="primary"
size="small"
class="preview-btn"
@click.stop="handlePreview3DModel(workFile)"
>
<template #icon><EyeOutlined /></template>
预览3D模型
</a-button>
</div>
<div v-else class="file-placeholder">
<FileOutlined class="file-icon" />
<span class="file-name">{{ getFileName(workFile) }}</span>
</div>
</div>
<!-- 图片预览遮罩 -->
<div
v-if="previewImage && previewImage === workFile"
class="image-preview-overlay"
@mouseleave="handleImageLeave"
>
<img
:src="getFileUrl(workFile)"
alt="作品预览" alt="作品预览"
class="preview-image" class="preview-image"
@error="handlePreviewError"
/> />
<div v-else class="preview-placeholder">
<FileImageOutlined class="placeholder-icon" />
<span>暂无预览图</span>
</div>
<!-- 悬浮操作按钮 -->
<transition name="fade">
<div v-show="showActions" class="actions-overlay">
<div class="actions-buttons">
<a-button
v-if="workFile && is3DModelFile(workFile)"
type="primary"
@click="handlePreview3DModel(workFile)"
>
<template #icon><EyeOutlined /></template>
预览模型
</a-button>
<a-button
v-if="workFile"
@click="handleDownloadWork"
>
<template #icon><DownloadOutlined /></template>
下载作品
</a-button>
</div> </div>
</div> </div>
<div v-else class="no-files">暂无作品文件</div> </transition>
</div> </div>
</div> </div>
<!-- 作品信息 --> <!-- 作品信息 -->
<div class="work-section"> <div class="work-info-section">
<div class="section-label">作品信息</div> <div class="info-row">
<div class="work-info"> <span class="info-label">作品名称</span>
<div class="info-item"> <span class="info-value">{{ work.title }}</span>
<span class="info-label">作品编号</span> </div>
<div class="info-row">
<span class="info-label">作品介绍</span>
<span class="info-value description">{{ work.description || "暂无介绍" }}</span>
</div>
<div class="info-row">
<span class="info-label">作品编号</span>
<span class="info-value">{{ work.workNo || "-" }}</span> <span class="info-value">{{ work.workNo || "-" }}</span>
</div> </div>
<div class="info-item"> <div class="info-row">
<span class="info-label">提交时间</span> <span class="info-label">提交时间</span>
<span class="info-value">{{ <span class="info-value">{{ formatDateTime(work.submitTime) }}</span>
formatDateTime(work.submitTime)
}}</span>
</div> </div>
<div class="info-item"> <div class="info-row">
<span class="info-label">作品状态</span> <span class="info-label">作品状态</span>
<a-tag :color="getStatusColor(work.status)"> <a-tag :color="getStatusColor(work.status)">
{{ getStatusText(work.status) }} {{ getStatusText(work.status) }}
</a-tag> </a-tag>
</div> </div>
<div v-if="work.version" class="info-item"> <div v-if="work.version" class="info-row">
<span class="info-label">版本号</span> <span class="info-label">版本号</span>
<span class="info-value">v{{ work.version }}</span> <span class="info-value">v{{ work.version }}</span>
</div> </div>
</div> </div>
</div>
<!-- 上传的附件 --> <!-- 上传的附件 -->
<div <div
v-if="work.attachments && work.attachments.length > 0" v-if="work.attachments && work.attachments.length > 0"
class="work-section" class="attachments-section"
> >
<div class="section-label">上传附件</div> <div class="section-title">上传附件</div>
<div class="attachments-list"> <div class="attachments-list">
<div <div
v-for="attachment in work.attachments" v-for="attachment in work.attachments"
@ -149,11 +128,8 @@
</a-spin> </a-spin>
<template #footer> <template #footer>
<a-space>
<a-button @click="handleCancel">关闭</a-button> <a-button @click="handleCancel">关闭</a-button>
</a-space>
</template> </template>
</a-drawer> </a-drawer>
</template> </template>
@ -163,6 +139,7 @@ import { useRouter, useRoute } from "vue-router"
import { message } from "ant-design-vue" import { message } from "ant-design-vue"
import { import {
FileOutlined, FileOutlined,
FileImageOutlined,
DownloadOutlined, DownloadOutlined,
EyeOutlined, EyeOutlined,
} from "@ant-design/icons-vue" } from "@ant-design/icons-vue"
@ -188,7 +165,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 previewImage = ref<string | null>(null) const showActions = ref(false)
const previewError = ref(false)
// //
watch( watch(
@ -196,10 +174,11 @@ watch(
async (newVal) => { async (newVal) => {
visible.value = newVal visible.value = newVal
if (newVal) { if (newVal) {
previewError.value = false
await fetchUserWork() await fetchUserWork()
} else { } else {
work.value = null work.value = null
previewImage.value = null showActions.value = false
} }
}, },
{ immediate: true } { immediate: true }
@ -255,13 +234,25 @@ const fetchUserWork = async () => {
} }
} }
// URL
const previewImageUrl = computed(() => {
if (!work.value || previewError.value) return null
// 使 previewUrl
if (work.value.previewUrl) {
return getFileUrl(work.value.previewUrl)
}
return null
})
// //
const workFile = computed(() => { const workFile = computed(() => {
if (!work.value) return null if (!work.value) return null
let files = work.value.files || [] let files = work.value.files || []
// files JSON // files JSON
if (typeof files === 'string') { if (typeof files === "string") {
try { try {
files = JSON.parse(files) files = JSON.parse(files)
} catch { } catch {
@ -273,26 +264,11 @@ const workFile = computed(() => {
// {fileUrl: string} // {fileUrl: string}
const firstFile = files[0] const firstFile = files[0]
return typeof firstFile === 'object' && firstFile?.fileUrl return typeof firstFile === "object" && firstFile?.fileUrl
? firstFile.fileUrl ? firstFile.fileUrl
: firstFile : firstFile
}) })
//
const isImageFile = (fileUrl: string): boolean => {
const imageExtensions = [
".jpg",
".jpeg",
".png",
".gif",
".bmp",
".webp",
".svg",
]
const lowerUrl = fileUrl.toLowerCase()
return imageExtensions.some((ext) => lowerUrl.includes(ext))
}
// 3D // 3D
const is3DModelFile = (fileUrl: string): boolean => { const is3DModelFile = (fileUrl: string): boolean => {
const modelExtensions = [ const modelExtensions = [
@ -304,6 +280,7 @@ const is3DModelFile = (fileUrl: string): boolean => {
".dae", ".dae",
".stl", ".stl",
".ply", ".ply",
".zip",
] ]
const lowerUrl = fileUrl.toLowerCase() const lowerUrl = fileUrl.toLowerCase()
return modelExtensions.some((ext) => lowerUrl.includes(ext)) return modelExtensions.some((ext) => lowerUrl.includes(ext))
@ -318,82 +295,53 @@ const getFileUrl = (fileUrl: string): string => {
} }
// APIURL // APIURL
const baseURL = import.meta.env.VITE_API_BASE_URL || "" const baseURL = import.meta.env.VITE_API_BASE_URL || ""
// fileUrl /api baseURL /api
if (fileUrl.startsWith("/api") && baseURL.includes("/api")) { if (fileUrl.startsWith("/api") && baseURL.includes("/api")) {
// fileUrl /api baseURL
const urlWithoutApi = baseURL.replace(/\/api$/, "") const urlWithoutApi = baseURL.replace(/\/api$/, "")
return `${urlWithoutApi}${fileUrl}` return `${urlWithoutApi}${fileUrl}`
} }
//
return `${baseURL}${fileUrl.startsWith("/") ? "" : "/"}${fileUrl}` return `${baseURL}${fileUrl.startsWith("/") ? "" : "/"}${fileUrl}`
} }
// //
const getFileName = (fileUrl: string): string => { const handlePreviewError = () => {
if (!fileUrl) return "文件" previewError.value = true
try {
//
const urlWithoutQuery = fileUrl.split("?")[0].split("#")[0]
//
const parts = urlWithoutQuery.split("/")
let fileName = parts[parts.length - 1] || "文件"
//
if (!fileName || fileName.trim() === "" || fileName === "[") {
return "文件"
}
// URL
try {
fileName = decodeURIComponent(fileName)
} catch {
// 使
}
//
const maxLength = 20
if (fileName.length > maxLength) {
const ext = fileName.substring(fileName.lastIndexOf("."))
const nameWithoutExt = fileName.substring(0, fileName.lastIndexOf("."))
if (nameWithoutExt.length > maxLength - ext.length - 3) {
return (
nameWithoutExt.substring(0, maxLength - ext.length - 3) + "..." + ext
)
}
}
return fileName
} catch (error) {
console.error("获取文件名失败:", error)
return "文件"
}
} }
// // 3D
const handleImageHover = (file: string) => { const handlePreview3DModel = (fileUrl: string) => {
if (isImageFile(file)) { if (!fileUrl) {
previewImage.value = file message.error("文件路径无效")
return
} }
const fullUrl = getFileUrl(fileUrl)
const tenantCode = route.params.tenantCode as string
router.push({
path: `/${tenantCode}/workbench/model-viewer`,
query: { url: fullUrl },
})
} }
// //
const handleImageLeave = () => { const handleDownloadWork = () => {
previewImage.value = null if (!workFile.value) {
} message.error("无作品文件")
return
// }
const handleImageError = (event: Event) => { const fileUrl = getFileUrl(workFile.value)
const img = event.target as HTMLImageElement const link = document.createElement("a")
img.style.display = "none" link.href = fileUrl
link.download = work.value?.title || "作品"
link.target = "_blank"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
message.success("开始下载作品")
} }
// //
const handleDownloadAttachment = async (attachment: any) => { const handleDownloadAttachment = async (attachment: any) => {
try { try {
const fileUrl = getFileUrl(attachment.fileUrl) const fileUrl = getFileUrl(attachment.fileUrl)
//
const link = document.createElement("a") const link = document.createElement("a")
link.href = fileUrl link.href = fileUrl
link.download = attachment.fileName link.download = attachment.fileName
@ -402,7 +350,7 @@ const handleDownloadAttachment = async (attachment: any) => {
link.click() link.click()
document.body.removeChild(link) document.body.removeChild(link)
message.success("开始下载附件") message.success("开始下载附件")
} catch (error: any) { } catch {
message.error("下载附件失败") message.error("下载附件失败")
} }
} }
@ -456,23 +404,6 @@ const getStatusText = (
return textMap[status || "submitted"] || "未知" return textMap[status || "submitted"] || "未知"
} }
// 3D
const handlePreview3DModel = (fileUrl: string) => {
console.log("handlePreview3DModel called with:", fileUrl)
if (!fileUrl) {
message.error("文件路径无效")
return
}
const fullUrl = getFileUrl(fileUrl)
console.log("预览3D模型原始URL:", fileUrl, "完整URL:", fullUrl)
//
const tenantCode = route.params.tenantCode as string
router.push({
path: `/${tenantCode}/workbench/model-viewer`,
query: { url: fullUrl },
})
}
// //
const handleCancel = () => { const handleCancel = () => {
visible.value = false visible.value = false
@ -494,160 +425,132 @@ const handleCancel = () => {
padding: 0; padding: 0;
} }
.work-section { //
.work-preview-card {
margin-bottom: 24px; margin-bottom: 24px;
}
&:last-child { .preview-wrapper {
margin-bottom: 0; position: relative;
width: 100%;
aspect-ratio: 1;
border-radius: 12px;
overflow: hidden;
background: #f5f5f5;
cursor: pointer;
}
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(0, 0, 0, 0.25);
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
.placeholder-icon {
font-size: 64px;
margin-bottom: 16px;
}
span {
font-size: 14px;
} }
} }
.section-label { //
.actions-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.actions-buttons {
display: flex;
flex-direction: column;
gap: 12px;
:deep(.ant-btn) {
min-width: 140px;
}
}
//
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
//
.work-info-section {
background: #fafafa;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}
.info-row {
display: flex;
align-items: flex-start;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
.info-label {
width: 80px;
flex-shrink: 0;
color: rgba(0, 0, 0, 0.45);
font-size: 14px;
}
.info-value {
flex: 1;
color: rgba(0, 0, 0, 0.85);
font-size: 14px;
word-break: break-word;
&.description {
white-space: pre-wrap;
line-height: 1.6;
}
}
//
.attachments-section {
margin-top: 24px;
}
.section-title {
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: rgba(0, 0, 0, 0.85); color: rgba(0, 0, 0, 0.85);
margin-bottom: 12px; margin-bottom: 12px;
} }
.section-content {
font-size: 14px;
color: rgba(0, 0, 0, 0.65);
line-height: 1.6;
}
.description-text {
white-space: pre-wrap;
word-break: break-word;
}
.work-file-container {
display: flex;
justify-content: flex-start;
}
.work-image-item {
position: relative;
width: 400px;
height: 400px;
}
.image-wrapper {
width: 100%;
height: 100%;
border: 1px solid #d9d9d9;
border-radius: 4px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s;
background: #fafafa;
&:hover {
border-color: #1890ff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
}
.work-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.file-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px;
color: rgba(0, 0, 0, 0.45);
position: relative;
&.model-file {
padding-bottom: 60px;
}
}
.file-icon {
font-size: 48px;
margin-bottom: 8px;
}
.file-name {
font-size: 12px;
text-align: center;
word-break: break-word;
overflow-wrap: break-word;
margin-bottom: 8px;
max-width: 100%;
padding: 0 4px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.preview-btn {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
}
.image-preview-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
cursor: zoom-out;
}
.preview-image {
max-width: 90%;
max-height: 90%;
object-fit: contain;
}
.no-files {
color: rgba(0, 0, 0, 0.45);
font-size: 14px;
padding: 20px 0;
}
.work-info {
display: flex;
flex-direction: column;
gap: 12px;
}
.info-item {
display: flex;
align-items: center;
font-size: 14px;
}
.info-label {
color: rgba(0, 0, 0, 0.65);
margin-right: 8px;
min-width: 80px;
}
.info-value {
color: rgba(0, 0, 0, 0.85);
}
.attachments-list { .attachments-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 8px;
} }
.attachment-item { .attachment-item {
@ -656,7 +559,7 @@ const handleCancel = () => {
justify-content: space-between; justify-content: space-between;
padding: 12px; padding: 12px;
border: 1px solid #d9d9d9; border: 1px solid #d9d9d9;
border-radius: 4px; border-radius: 8px;
transition: all 0.3s; transition: all 0.3s;
&:hover { &:hover {

View File

@ -8,13 +8,16 @@
</div> </div>
<!-- Header --> <!-- Header -->
<div class="viewer-header"> <div class="page-header">
<div class="header-left"> <div class="header-left">
<a-button type="text" class="back-btn" @click="handleBack"> <a-button type="text" class="back-btn" @click="handleBack">
<template #icon><ArrowLeftOutlined /></template> <template #icon><ArrowLeftOutlined /></template>
</a-button> </a-button>
<span class="title">3D 模型预览</span> <span class="title">3D 模型预览</span>
<span class="badge">LIVE</span> <span class="live-badge">
<span class="pulse-dot"></span>
LIVE
</span>
</div> </div>
<div class="header-right"> <div class="header-right">
<a-button type="text" class="action-btn" @click="resetCamera"> <a-button type="text" class="action-btn" @click="resetCamera">
@ -1168,61 +1171,23 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
// ========================================== // ==========================================
// Header // Header
// ========================================== // ==========================================
.viewer-header { .page-header {
height: 64px;
padding: 0 24px;
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
padding: 12px 20px; justify-content: space-between;
background: rgba($surface, 0.7); flex-shrink: 0;
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba($primary, 0.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
position: relative; position: relative;
z-index: 10; z-index: 10;
}
.header-left { .header-left {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
.title { .back-btn {
color: $text;
font-size: 18px;
font-weight: 600;
background: $gradient-primary;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.badge {
padding: 2px 8px;
font-size: 10px;
font-weight: 600;
color: #fff;
background: $success;
border-radius: 4px;
animation: pulse 2s ease-in-out infinite;
}
}
.header-right {
display: flex;
gap: 8px;
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.back-btn {
color: $text !important; color: $text !important;
width: 40px; width: 40px;
height: 40px; height: 40px;
@ -1231,7 +1196,7 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.3s ease !important; transition: all 0.3s !important;
flex-shrink: 0; flex-shrink: 0;
&:hover { &:hover {
@ -1239,6 +1204,55 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
border-color: $primary !important; border-color: $primary !important;
transform: translateY(-1px); transform: translateY(-1px);
} }
}
.title {
font-size: 20px;
font-weight: 600;
background: $gradient-primary;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.live-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: rgba($success, 0.1);
border: 1px solid rgba($success, 0.3);
border-radius: 20px;
color: $success;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.5px;
.pulse-dot {
width: 6px;
height: 6px;
background: $success;
border-radius: 50%;
animation: pulse 1.5s ease-in-out infinite;
}
}
}
.header-right {
display: flex;
gap: 8px;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(0.8);
}
} }
.action-btn { .action-btn {

View File

@ -210,7 +210,10 @@ const modelCards = computed(() => {
const resultUrls = task.value.resultUrls || [] const resultUrls = task.value.resultUrls || []
// 1 // 1
if ((status === "pending" || status === "processing") && previewUrls.length === 0) { if (
(status === "pending" || status === "processing") &&
previewUrls.length === 0
) {
return [{ status: status, previewUrl: "" }] return [{ status: status, previewUrl: "" }]
} }
@ -436,10 +439,6 @@ $gradient-card: linear-gradient(
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
background: rgba($surface, 0.7);
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba($primary, 0.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
flex-shrink: 0; flex-shrink: 0;
position: relative; position: relative;
z-index: 10; z-index: 10;
@ -871,7 +870,11 @@ $gradient-card: linear-gradient(
.failed-icon { .failed-icon {
width: 64px; width: 64px;
height: 64px; height: 64px;
background: linear-gradient(135deg, rgba($error, 0.15) 0%, rgba($error, 0.25) 100%); background: linear-gradient(
135deg,
rgba($error, 0.15) 0%,
rgba($error, 0.25) 100%
);
border: 2px solid rgba($error, 0.3); border: 2px solid rgba($error, 0.3);
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
@ -884,7 +887,8 @@ $gradient-card: linear-gradient(
} }
@keyframes pulse-error { @keyframes pulse-error {
0%, 100% { 0%,
100% {
transform: scale(1); transform: scale(1);
box-shadow: 0 0 0 0 rgba($error, 0.3); box-shadow: 0 0 0 0 rgba($error, 0.3);
} }
@ -978,19 +982,67 @@ $gradient-card: linear-gradient(
// ========================================== // ==========================================
// Responsive // Responsive
// ========================================== // ==========================================
@media (max-width: 1024px) {
.page-content {
padding: 24px;
}
.model-grid {
grid-template-columns: repeat(2, 280px);
gap: 20px;
}
.model-card {
width: 280px;
height: 200px;
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.page-header {
padding: 0 16px;
}
.header-left {
gap: 12px;
.title {
font-size: 16px;
}
.live-badge,
.pbr-tag {
display: none;
}
}
.page-content {
padding: 16px;
}
.model-grid { .model-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 16px;
} }
.model-card { .model-card {
width: 100%; width: 100%;
max-width: 300px; max-width: 320px;
height: 180px;
} }
.tips-section { .tips-section {
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
} }
.action-buttons {
flex-direction: column;
gap: 12px;
.action-btn {
width: 100%;
}
}
} }
</style> </style>

View File

@ -67,7 +67,6 @@
class="history-card" class="history-card"
@click="handleViewTask(task)" @click="handleViewTask(task)"
> >
<div class="card-glow"></div>
<div class="card-preview"> <div class="card-preview">
<img <img
v-if="task.status === 'completed' && task.previewUrl" v-if="task.status === 'completed' && task.previewUrl"
@ -102,14 +101,37 @@
<div class="status-badge" :class="`status-${task.status}`"> <div class="status-badge" :class="`status-${task.status}`">
{{ getStatusText(task.status) }} {{ getStatusText(task.status) }}
</div> </div>
<!-- 悬停显示的操作按钮 -->
<div class="card-actions-overlay" @click.stop>
<button
v-if="task.status === 'completed'"
class="overlay-btn btn-primary"
@click="handlePreview(task)"
>
<EyeOutlined />
<span>预览</span>
</button>
<button
v-if="['failed', 'timeout'].includes(task.status)"
class="overlay-btn btn-primary"
:disabled="task.retryCount >= 3"
@click="handleRetry(task)"
>
<ReloadOutlined />
<span>重试</span>
</button>
<button
class="overlay-btn btn-secondary"
@click="handleDelete(task)"
>
<DeleteOutlined />
<span>删除</span>
</button>
</div>
</div> </div>
<div class="card-info"> <div class="card-info">
<div class="card-type">
<a-tag :color="task.inputType === 'text' ? 'blue' : 'green'">
{{ task.inputType === 'text' ? '文生3D' : '图生3D' }}
</a-tag>
</div>
<div class="card-desc" :title="task.inputContent"> <div class="card-desc" :title="task.inputContent">
{{ task.inputContent }} {{ task.inputContent }}
</div> </div>
@ -118,30 +140,9 @@
<ClockCircleOutlined /> <ClockCircleOutlined />
{{ formatTime(task.createTime) }} {{ formatTime(task.createTime) }}
</span> </span>
<div class="card-actions" @click.stop> <span class="card-type">
<a-tooltip v-if="task.status === 'completed'" title="预览模型"> {{ task.inputType === 'text' ? '文生3D' : '图生3D' }}
<div class="action-btn" @click="handlePreview(task)"> </span>
<EyeOutlined />
</div>
</a-tooltip>
<a-tooltip
v-if="['failed', 'timeout'].includes(task.status)"
title="重试"
>
<div
class="action-btn"
:class="{ disabled: task.retryCount >= 3 }"
@click="handleRetry(task)"
>
<ReloadOutlined />
</div>
</a-tooltip>
<a-tooltip title="删除">
<div class="action-btn danger" @click="handleDelete(task)">
<DeleteOutlined />
</div>
</a-tooltip>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -472,41 +473,17 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
background: rgba($surface, 0.7); flex-shrink: 0;
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba($primary, 0.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
position: relative; position: relative;
z-index: 10; z-index: 10;
flex-shrink: 0; }
.header-left { .header-left {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
}
.title { .back-btn {
font-size: 18px;
font-weight: 600;
background: $gradient-primary;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.count-badge {
padding: 4px 12px;
background: rgba($primary, 0.1);
border: 1px solid rgba($primary, 0.2);
border-radius: 20px;
font-size: 12px;
color: $primary;
font-weight: 500;
}
}
.back-btn {
color: $text !important; color: $text !important;
width: 40px; width: 40px;
height: 40px; height: 40px;
@ -515,14 +492,42 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.3s ease !important; transition: all 0.3s !important;
flex-shrink: 0; flex-shrink: 0;
&:hover { &:hover {
background: rgba($primary, 0.1) !important; background: rgba($primary, 0.2) !important;
border-color: $primary !important; border-color: $primary !important;
transform: translateY(-1px); transform: translateY(-1px);
} }
}
.title {
font-size: 20px;
font-weight: 600;
background: $gradient-primary;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.count-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: rgba($primary, 0.1);
border: 1px solid rgba($primary, 0.2);
border-radius: 20px;
color: $primary;
font-size: 11px;
font-weight: 600;
}
}
.header-right {
display: flex;
gap: 8px;
} }
// ========================================== // ==========================================
@ -658,53 +663,47 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
.history-card { .history-card {
background: $surface; background: $surface;
border-radius: 8px; border-radius: 12px;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
// border: 1px solid rgba(0, 0, 0, 0.06);
&:hover { &:hover {
transform: translateY(-4px); .card-preview {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); transform: scale(1.03);
.card-glow {
opacity: 0.3;
} }
.preview-image { .card-preview .preview-image {
transform: scale(1.1); transform: scale(1.05);
} }
}
}
.card-glow { .card-actions-overlay {
position: absolute; opacity: 1;
inset: -2px; }
background: $primary; }
border-radius: 10px;
z-index: -1;
opacity: 0;
filter: blur(8px);
transition: opacity 0.3s;
} }
.card-preview { .card-preview {
height: 160px; height: 160px;
background: rgba($surface-light, 0.8); // border-radius: 8px;
background: linear-gradient(
135deg,
rgba($surface-light, 0.9) 0%,
rgba($primary, 0.05) 100%
);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
transition: transform 0.3s ease;
.preview-image { .preview-image {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
transition: transform 0.5s ease; transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
} }
.preview-loading, .preview-loading,
@ -739,6 +738,62 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
} }
} }
//
.card-actions-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.6) 0%,
rgba(0, 0, 0, 0.2) 50%,
transparent 100%
);
display: flex;
align-items: flex-end;
justify-content: center;
padding-bottom: 16px;
gap: 10px;
opacity: 0;
transition: opacity 0.3s ease;
z-index: 10;
.overlay-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
&.btn-primary {
background: $primary;
color: white;
&:hover {
background: $primary-light;
}
}
&.btn-secondary {
background: rgba(255, 255, 255, 0.9);
color: $text;
&:hover {
background: white;
}
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
@keyframes pulse-error { @keyframes pulse-error {
0%, 100% { 0%, 100% {
transform: scale(1); transform: scale(1);
@ -812,22 +867,19 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
.card-info { .card-info {
padding: 16px; padding: 16px;
background: rgba($primary, 0.15); background: $surface;
} border-top: 1px solid rgba($primary, 0.06);
.card-type {
margin-bottom: 8px;
} }
.card-desc { .card-desc {
font-size: 14px; font-size: 14px;
color: $text; color: $text;
font-weight: 500;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
margin-bottom: 12px; margin-bottom: 12px;
line-height: 1.5; font-weight: 500;
line-height: 1.4;
} }
.card-meta { .card-meta {
@ -844,46 +896,20 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
color: $text-muted; color: $text-muted;
} }
.card-actions { .card-type {
display: flex; display: inline-flex;
gap: 8px;
}
.action-btn {
width: 32px;
height: 32px;
background: rgba($primary, 0.1);
border: 1px solid rgba($primary, 0.2);
border-radius: 8px;
display: flex;
align-items: center; align-items: center;
justify-content: center; padding: 4px 10px;
color: $primary-light; background: linear-gradient(
cursor: pointer; 135deg,
transition: all 0.3s ease; rgba($primary, 0.1) 0%,
rgba($primary-light, 0.15) 100%
&:hover { );
background: rgba($primary-light, 0.2); border: 1px solid rgba($primary, 0.2);
border-color: $primary-light; border-radius: 12px;
color: $primary-light; font-size: 11px;
transform: scale(1.1); font-weight: 500;
} color: $primary;
&.danger:hover {
background: rgba($error, 0.15);
border-color: $error;
color: $error;
}
&.disabled {
opacity: 0.4;
cursor: not-allowed;
&:hover {
transform: none;
background: rgba($primary, 0.08);
}
}
} }
// ========================================== // ==========================================

View File

@ -37,7 +37,7 @@
<!-- 文生3D输入 --> <!-- 文生3D输入 -->
<div v-if="inputType === 'text'" class="text-input-section"> <div v-if="inputType === 'text'" class="text-input-section">
<div class="input-label"> <div class="input-label">
<span class="label-icon"></span> <EditOutlined class="label-icon" />
<span>创意描述</span> <span>创意描述</span>
</div> </div>
<div class="input-hint"> <div class="input-hint">
@ -100,7 +100,7 @@
<!-- 图生3D上传 --> <!-- 图生3D上传 -->
<div v-else class="image-input-section"> <div v-else class="image-input-section">
<div class="input-label"> <div class="input-label">
<span class="label-icon">🖼</span> <PictureOutlined class="label-icon" />
<span>参考图片</span> <span>参考图片</span>
</div> </div>
<div class="input-hint"> <div class="input-hint">
@ -165,11 +165,7 @@
<div class="right-panel"> <div class="right-panel">
<!-- 介绍区 --> <!-- 介绍区 -->
<div class="intro-section"> <div class="intro-section">
<div class="intro-badge"> <h1 class="intro-title">用一句话一张图创造你的 3D 世界</h1>
<span class="badge-dot"></span>
<span>AI Powered</span>
</div>
<h1 class="intro-title">用一句话一张图<br />创造你的 3D 世界</h1>
<p class="intro-desc"> <p class="intro-desc">
借助先进的 AI 技术将文字描述或图片瞬间转化为专业级 3D 模型 借助先进的 AI 技术将文字描述或图片瞬间转化为专业级 3D 模型
</p> </p>
@ -177,7 +173,7 @@
<div class="intro-features"> <div class="intro-features">
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon gradient-1"> <div class="feature-icon gradient-1">
<span></span> <BulbOutlined />
</div> </div>
<div class="feature-info"> <div class="feature-info">
<h3>AI 智能建模</h3> <h3>AI 智能建模</h3>
@ -187,7 +183,7 @@
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon gradient-2"> <div class="feature-icon gradient-2">
<span>👁</span> <EyeOutlined />
</div> </div>
<div class="feature-info"> <div class="feature-info">
<h3>实时预览</h3> <h3>实时预览</h3>
@ -197,7 +193,7 @@
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon gradient-3"> <div class="feature-icon gradient-3">
<span>📁</span> <FolderOutlined />
</div> </div>
<div class="feature-info"> <div class="feature-info">
<h3>作品管理</h3> <h3>作品管理</h3>
@ -207,7 +203,7 @@
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon gradient-4"> <div class="feature-icon gradient-4">
<span>🔄</span> <SyncOutlined />
</div> </div>
<div class="feature-info"> <div class="feature-info">
<h3>迭代优化</h3> <h3>迭代优化</h3>
@ -256,7 +252,6 @@
class="history-card" class="history-card"
@click="handleViewTask(task)" @click="handleViewTask(task)"
> >
<div class="card-glow"></div>
<div class="card-preview"> <div class="card-preview">
<img <img
v-if="task.status === 'completed' && task.previewUrl" v-if="task.status === 'completed' && task.previewUrl"
@ -297,6 +292,34 @@
<div class="status-badge" :class="`status-${task.status}`"> <div class="status-badge" :class="`status-${task.status}`">
{{ getStatusText(task.status) }} {{ getStatusText(task.status) }}
</div> </div>
<!-- 悬停显示的操作按钮 -->
<div class="card-actions-overlay" @click.stop>
<button
v-if="task.status === 'completed'"
class="overlay-btn btn-primary"
@click="handlePreview(task)"
>
<EyeOutlined />
<span>预览</span>
</button>
<button
v-if="['failed', 'timeout'].includes(task.status)"
class="overlay-btn btn-primary"
:disabled="task.retryCount >= 3"
@click="handleRetry(task)"
>
<ReloadOutlined />
<span>重试</span>
</button>
<button
class="overlay-btn btn-secondary"
@click="handleDelete(task)"
>
<DeleteOutlined />
<span>删除</span>
</button>
</div>
</div> </div>
<div class="card-info"> <div class="card-info">
@ -308,29 +331,9 @@
<ClockCircleOutlined /> <ClockCircleOutlined />
{{ formatTime(task.createTime) }} {{ formatTime(task.createTime) }}
</span> </span>
<div class="card-actions" @click.stop> <span class="card-type">
<a-tooltip v-if="task.status === 'completed'" title="预览"> {{ task.inputType === "text" ? "文生3D" : "图生3D" }}
<div class="action-btn" @click="handlePreview(task)"> </span>
<EyeOutlined />
</div>
</a-tooltip>
<a-tooltip
v-if="['failed', 'timeout'].includes(task.status)"
title="重试"
>
<div
class="action-btn"
:class="{ disabled: task.retryCount >= 3 }"
@click="handleRetry(task)"
>
<ReloadOutlined />
</div>
</a-tooltip>
<a-tooltip title="删除">
<div class="action-btn danger" @click="handleDelete(task)">
<DeleteOutlined />
</div>
</a-tooltip>
</div> </div>
</div> </div>
</div> </div>
@ -338,8 +341,6 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -358,6 +359,10 @@ import {
ArrowRightOutlined, ArrowRightOutlined,
ArrowLeftOutlined, ArrowLeftOutlined,
ClockCircleOutlined, ClockCircleOutlined,
BulbOutlined,
FolderOutlined,
SyncOutlined,
EditOutlined,
} from "@ant-design/icons-vue" } from "@ant-design/icons-vue"
import { import {
createAI3DTask, createAI3DTask,
@ -873,7 +878,6 @@ $gradient-secondary: linear-gradient(135deg, $accent 0%, $primary 100%);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
border-bottom: 1px solid rgba($primary, 0.1);
flex-shrink: 0; flex-shrink: 0;
.back-btn { .back-btn {
@ -946,7 +950,6 @@ $gradient-secondary: linear-gradient(135deg, $accent 0%, $primary 100%);
.panel-header { .panel-header {
padding: 20px 24px; padding: 20px 24px;
border-bottom: 1px solid rgba($primary, 0.1);
flex-shrink: 0; flex-shrink: 0;
:deep(.ant-segmented) { :deep(.ant-segmented) {
@ -999,6 +1002,7 @@ $gradient-secondary: linear-gradient(135deg, $accent 0%, $primary 100%);
.label-icon { .label-icon {
font-size: 16px; font-size: 16px;
color: $primary;
} }
} }
@ -1211,7 +1215,6 @@ $gradient-secondary: linear-gradient(135deg, $accent 0%, $primary 100%);
.panel-footer { .panel-footer {
padding: 24px; padding: 24px;
border-top: 1px solid rgba($primary, 0.1);
flex-shrink: 0; flex-shrink: 0;
} }
@ -1302,66 +1305,46 @@ $gradient-secondary: linear-gradient(135deg, $accent 0%, $primary 100%);
height: 100vh; height: 100vh;
background: rgba($surface, 0.3); background: rgba($surface, 0.3);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
padding-top: 30px;
} }
.intro-section { .intro-section {
padding: 48px; padding: 48px;
flex-shrink: 0; flex-shrink: 0;
} text-align: center;
.intro-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
background: rgba($primary, 0.15);
border: 1px solid rgba($primary, 0.3);
border-radius: 20px;
font-size: 11px;
font-weight: 600;
color: $primary-light;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 20px;
.badge-dot {
width: 6px;
height: 6px;
background: $accent;
border-radius: 50%;
animation: pulse 1.5s ease-in-out infinite;
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(0.8);
}
} }
.intro-title { .intro-title {
font-size: 36px; font-size: 36px;
font-weight: 700; font-weight: 700;
color: $text;
margin-bottom: 16px; margin-bottom: 16px;
line-height: 1.3; line-height: 1.3;
background: $gradient-primary; background: linear-gradient(
135deg,
$primary 0%,
$primary-light 40%,
#8b5cf6 70%,
#a855f7 100%
);
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
} }
@media (max-width: 900px) {
.intro-title {
font-size: 28px;
}
}
.intro-desc { .intro-desc {
font-size: 16px; font-size: 16px;
color: $text-muted; color: $text-muted;
margin-bottom: 32px; margin-bottom: 32px;
line-height: 1.6; line-height: 1.6;
max-width: 500px;
margin-left: auto;
margin-right: auto;
} }
.intro-features { .intro-features {
@ -1389,7 +1372,8 @@ $gradient-secondary: linear-gradient(135deg, $accent 0%, $primary 100%);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 20px; font-size: 22px;
color: #fff;
flex-shrink: 0; flex-shrink: 0;
&.gradient-1 { &.gradient-1 {
@ -1578,53 +1562,47 @@ $gradient-secondary: linear-gradient(135deg, $accent 0%, $primary 100%);
flex-shrink: 0; flex-shrink: 0;
width: 240px; width: 240px;
background: $surface; background: $surface;
border-radius: 8px; border-radius: 12px;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
transition: all 0.3s;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
// border: 1px solid rgba(0, 0, 0, 0.06);
&:hover { &:hover {
transform: translateY(-4px); .card-preview {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); transform: scale(1.03);
}
.card-glow { .card-preview .preview-image {
opacity: 0.3; transform: scale(1.05);
}
.card-actions-overlay {
opacity: 1;
} }
} }
} }
.card-glow {
position: absolute;
inset: -2px;
background: $primary;
border-radius: 10px;
z-index: -1;
opacity: 0;
filter: blur(8px);
transition: opacity 0.3s;
}
.card-preview { .card-preview {
height: 160px; height: 160px;
background: rgba($surface-light, 0.8); // border-radius: 8px;
background: linear-gradient(
135deg,
rgba($surface-light, 0.9) 0%,
rgba($primary, 0.05) 100%
);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
transition: transform 0.3s ease;
.preview-image { .preview-image {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
transition: transform 0.5s; transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.history-card:hover & .preview-image {
transform: scale(1.1);
} }
.preview-loading, .preview-loading,
@ -1646,7 +1624,11 @@ $gradient-secondary: linear-gradient(135deg, $accent 0%, $primary 100%);
.failed-icon { .failed-icon {
width: 56px; width: 56px;
height: 56px; height: 56px;
background: linear-gradient(135deg, rgba($error, 0.15) 0%, rgba($error, 0.25) 100%); background: linear-gradient(
135deg,
rgba($error, 0.15) 0%,
rgba($error, 0.25) 100%
);
border: 2px solid rgba($error, 0.3); border: 2px solid rgba($error, 0.3);
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
@ -1659,8 +1641,65 @@ $gradient-secondary: linear-gradient(135deg, $accent 0%, $primary 100%);
} }
} }
//
.card-actions-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.6) 0%,
rgba(0, 0, 0, 0.2) 50%,
transparent 100%
);
display: flex;
align-items: flex-end;
justify-content: center;
padding-bottom: 16px;
gap: 10px;
opacity: 0;
transition: opacity 0.3s ease;
z-index: 10;
.overlay-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
&.btn-primary {
background: $primary;
color: white;
&:hover {
background: $primary-light;
}
}
&.btn-secondary {
background: rgba(255, 255, 255, 0.9);
color: $text;
&:hover {
background: white;
}
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
@keyframes pulse-error { @keyframes pulse-error {
0%, 100% { 0%,
100% {
transform: scale(1); transform: scale(1);
box-shadow: 0 0 0 0 rgba($error, 0.3); box-shadow: 0 0 0 0 rgba($error, 0.3);
} }
@ -1740,7 +1779,8 @@ $gradient-secondary: linear-gradient(135deg, $accent 0%, $primary 100%);
.card-info { .card-info {
padding: 16px; padding: 16px;
background: rgba(9, 88, 217, 0.15); background: $surface;
border-top: 1px solid rgba($primary, 0.06);
} }
.card-desc { .card-desc {
@ -1751,6 +1791,7 @@ $gradient-secondary: linear-gradient(135deg, $accent 0%, $primary 100%);
text-overflow: ellipsis; text-overflow: ellipsis;
margin-bottom: 12px; margin-bottom: 12px;
font-weight: 500; font-weight: 500;
line-height: 1.4;
} }
.card-meta { .card-meta {
@ -1767,50 +1808,20 @@ $gradient-secondary: linear-gradient(135deg, $accent 0%, $primary 100%);
color: $text-muted; color: $text-muted;
} }
.card-actions { .card-type {
display: flex; display: inline-flex;
gap: 8px;
}
.action-btn {
width: 32px;
height: 32px;
background: rgba($primary, 0.1);
border: 1px solid rgba($primary, 0.2);
border-radius: 8px;
display: flex;
align-items: center; align-items: center;
justify-content: center; padding: 4px 10px;
color: $primary-light; background: linear-gradient(
cursor: pointer; 135deg,
transition: all 0.3s; rgba($primary, 0.1) 0%,
rgba($primary-light, 0.15) 100%
&:hover { );
background: rgba($primary-light, 0.2); border: 1px solid rgba($primary, 0.2);
border-color: $primary-light; border-radius: 12px;
color: $primary-light; font-size: 11px;
transform: scale(1.1); font-weight: 500;
} color: $primary;
&.danger {
&:hover {
background: rgba($accent, 0.2);
border-color: $accent;
color: $accent;
}
}
&.disabled {
opacity: 0.4;
cursor: not-allowed;
&:hover {
background: rgba($primary, 0.1);
border-color: rgba($primary, 0.2);
color: $primary-light;
transform: none;
}
}
} }
// ========================================== // ==========================================
@ -1849,6 +1860,13 @@ $gradient-secondary: linear-gradient(135deg, $accent 0%, $primary 100%);
.history-card { .history-card {
width: 180px; width: 180px;
.card-actions-overlay {
.overlay-btn {
padding: 6px 10px;
font-size: 12px;
}
}
} }
} }
</style> </style>