修改样式

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)",
"WebSearch",
"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 使
margin-bottom: 8px;
border-radius: 0; //
padding: 12px;
padding: 20px 12px;
img {
max-width: 100%;
max-height: 40px;
max-height: 30px;
object-fit: contain;
}

View File

@ -27,165 +27,95 @@
<a-empty description="暂无赛事" />
</div>
<div v-else class="contests-list">
<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
v-if="!contest.coverUrl || imageErrors[contest.id]"
class="cover-placeholder"
<div v-else class="contests-grid">
<div
v-for="contest in dataSource"
:key="contest.id"
class="contest-card"
@click="handleViewDetail(contest.id)"
>
<!-- 卡片图标 -->
<div class="card-icon">
<TrophyOutlined />
</div>
<!-- 卡片标题 -->
<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" ? "个人赛" : "团队赛" }}
</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>
<!-- 操作按钮区域 - 我的赛事tab显示 -->
<div v-if="activeTab === 'my'" class="card-actions" @click.stop>
<!-- 学生角色按钮 -->
<template v-if="userRole === 'student'">
<template v-if="contest.contestType === 'individual'">
<a-button
v-if="isSubmitting(contest)"
type="primary"
size="small"
@click="handleUploadWork(contest.id)"
>
<span>赛事封面</span>
</div>
<a-tag
class="contest-type-tag"
:color="contest.contestType === 'individual' ? 'blue' : 'green'"
>
{{ contest.contestType === "individual" ? "个人赛" : "团队赛" }}
</a-tag>
</div>
<!-- 图片下方按钮 - 根据角色显示不同按钮 -->
<div v-if="activeTab === 'my'" class="cover-buttons">
<!-- 学生角色按钮 -->
<template v-if="userRole === 'student'">
<template v-if="contest.contestType === 'individual'">
<a-button
v-if="isSubmitting(contest)"
type="primary"
size="small"
@click.stop="handleUploadWork(contest.id)"
>
上传作品
</a-button>
<a-button
size="small"
@click.stop="handleViewWorks(contest.id)"
>
参赛作品
</a-button>
</template>
<template v-else>
<a-button
size="small"
@click.stop="handleViewWorks(contest.id)"
>
参赛作品
</a-button>
<a-button
size="small"
@click.stop="handleViewTeam(contest.id)"
>
我的队伍
</a-button>
</template>
</template>
上传作品
</a-button>
<a-button size="small" @click="handleViewWorks(contest.id)">
参赛作品
</a-button>
</template>
<template v-else>
<a-button size="small" @click="handleViewWorks(contest.id)">
参赛作品
</a-button>
<a-button size="small" @click="handleViewTeam(contest.id)">
我的队伍
</a-button>
</template>
</template>
<!-- 教师角色按钮 -->
<template v-if="userRole === 'teacher'">
<a-button
type="primary"
size="small"
@click.stop="handleMyGuidance(contest.id)"
>
我的指导
</a-button>
</template>
<!-- 评委角色按钮 -->
<template v-if="userRole === 'judge'">
<a-button
type="primary"
size="small"
:disabled="isReviewEnded(contest)"
@click.stop="handleReviewWorks(contest.id)"
>
评审作品
</a-button>
<a-button
size="small"
:disabled="isReviewEnded(contest)"
@click.stop="handlePresetComments(contest.id)"
>
预设评语
</a-button>
</template>
</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)">
查看活动
<!-- 教师角色按钮 -->
<template v-if="userRole === 'teacher'">
<a-button
type="primary"
size="small"
@click="handleMyGuidance(contest.id)"
>
我的指导
</a-button>
</div>
</template>
<!-- 评委角色按钮 -->
<template v-if="userRole === 'judge'">
<a-button
type="primary"
size="small"
:disabled="isReviewEnded(contest)"
@click="handleReviewWorks(contest.id)"
>
评审作品
</a-button>
<a-button
size="small"
:disabled="isReviewEnded(contest)"
@click="handlePresetComments(contest.id)"
>
预设评语
</a-button>
</template>
</div>
</div>
@ -223,6 +153,7 @@
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 dayjs from "dayjs"
import {
contestsApi,
@ -487,6 +418,10 @@ onMounted(() => {
</script>
<style lang="scss" scoped>
//
$primary: #0958d9;
$primary-light: #1677ff;
.contests-activities-page {
padding: 24px;
@ -498,159 +433,115 @@ onMounted(() => {
min-height: 400px;
}
.contests-list {
display: flex;
flex-direction: column;
gap: 16px;
.contests-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
.contest-card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s;
overflow: hidden;
border-radius: 16px;
padding: 24px;
cursor: pointer;
transition: all 0.3s ease;
border: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
gap: 16px;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
.contest-card-content {
display: flex;
align-items: flex-start;
padding: 16px;
gap: 16px;
}
.contest-cover-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
}
.contest-cover {
position: relative;
width: 200px;
height: 150px;
overflow: hidden;
background-color: #f5f5f5;
border-radius: 4px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 14px;
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
}
.contest-type-tag {
position: absolute;
top: 8px;
left: 8px;
margin: 0;
.card-icon {
background: linear-gradient(135deg, #ff7a45 0%, #fa541c 100%);
color: #fff;
}
}
.cover-buttons {
.card-icon {
width: 48px;
height: 48px;
background: rgba($primary, 0.08);
border-radius: 12px;
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
width: 200px;
flex-wrap: wrap;
font-size: 22px;
color: $primary;
transition: all 0.3s ease;
}
.contest-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.contest-title {
.card-title {
font-size: 16px;
font-weight: 500;
color: #262626;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.contest-status {
.card-desc {
font-size: 14px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.6;
}
.card-tags {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
.status-row {
display: flex;
.tag {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border-radius: 16px;
font-size: 12px;
font-weight: 500;
transition: all 0.2s;
}
.status-tag {
margin: 0;
}
.tag-type {
background: rgba($primary, 0.08);
color: $primary;
border: 1px solid rgba($primary, 0.2);
}
.stage-text {
.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;
font-size: 14px;
font-weight: 500;
border-color: rgba(82, 196, 26, 0.2);
}
}
.time-info {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 14px;
.time-item {
display: flex;
align-items: center;
gap: 8px;
.stage-tag {
margin: 0;
}
.time-label {
color: #8c8c8c;
white-space: nowrap;
}
.time-value {
color: #595959;
}
}
.tag-stage {
background: rgba(250, 173, 20, 0.08);
color: #d48806;
border: 1px solid rgba(250, 173, 20, 0.2);
}
}
.contest-actions {
.card-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
flex-shrink: 0;
.action-buttons {
display: flex;
gap: 8px;
}
flex-wrap: wrap;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
margin-top: 4px;
}
}
.pagination-container {
grid-column: 1 / -1;
margin-top: 24px;
display: flex;
justify-content: center;

View File

@ -3,7 +3,7 @@
v-model:open="visible"
title="参赛作品"
placement="right"
width="850px"
width="600px"
:footer-style="{ textAlign: 'right', padding: '16px 24px' }"
@close="handleCancel"
>
@ -19,108 +19,87 @@
</div>
<div v-else class="work-detail">
<!-- 作品名称 -->
<div class="work-section">
<div class="section-label">作品名称</div>
<div class="section-content">{{ work.title }}</div>
</div>
<!-- 作品预览卡片 -->
<div class="work-preview-card">
<div
class="preview-wrapper"
@mouseenter="showActions = true"
@mouseleave="showActions = false"
>
<!-- 预览图 -->
<img
v-if="previewImageUrl"
:src="previewImageUrl"
alt="作品预览"
class="preview-image"
@error="handlePreviewError"
/>
<div v-else class="preview-placeholder">
<FileImageOutlined class="placeholder-icon" />
<span>暂无预览图</span>
</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
class="image-wrapper"
@mouseenter="handleImageHover(workFile)"
@mouseleave="handleImageLeave"
>
<img
v-if="isImageFile(workFile)"
:src="getFileUrl(workFile)"
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>
<!-- 悬浮操作按钮 -->
<transition name="fade">
<div v-show="showActions" class="actions-overlay">
<div class="actions-buttons">
<a-button
v-if="workFile && is3DModelFile(workFile)"
type="primary"
size="small"
class="preview-btn"
@click.stop="handlePreview3DModel(workFile)"
@click="handlePreview3DModel(workFile)"
>
<template #icon><EyeOutlined /></template>
预览3D模型
预览模型
</a-button>
<a-button
v-if="workFile"
@click="handleDownloadWork"
>
<template #icon><DownloadOutlined /></template>
下载作品
</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="作品预览"
class="preview-image"
/>
</div>
</div>
<div v-else class="no-files">暂无作品文件</div>
</transition>
</div>
</div>
<!-- 作品信息 -->
<div class="work-section">
<div class="section-label">作品信息</div>
<div class="work-info">
<div class="info-item">
<span class="info-label">作品编号</span>
<span class="info-value">{{ work.workNo || "-" }}</span>
</div>
<div class="info-item">
<span class="info-label">提交时间</span>
<span class="info-value">{{
formatDateTime(work.submitTime)
}}</span>
</div>
<div class="info-item">
<span class="info-label">作品状态</span>
<a-tag :color="getStatusColor(work.status)">
{{ getStatusText(work.status) }}
</a-tag>
</div>
<div v-if="work.version" class="info-item">
<span class="info-label">版本号</span>
<span class="info-value">v{{ work.version }}</span>
</div>
<div class="work-info-section">
<div class="info-row">
<span class="info-label">作品名称</span>
<span class="info-value">{{ work.title }}</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>
</div>
<div class="info-row">
<span class="info-label">提交时间</span>
<span class="info-value">{{ formatDateTime(work.submitTime) }}</span>
</div>
<div class="info-row">
<span class="info-label">作品状态</span>
<a-tag :color="getStatusColor(work.status)">
{{ getStatusText(work.status) }}
</a-tag>
</div>
<div v-if="work.version" class="info-row">
<span class="info-label">版本号</span>
<span class="info-value">v{{ work.version }}</span>
</div>
</div>
<!-- 上传的附件 -->
<div
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
v-for="attachment in work.attachments"
@ -149,11 +128,8 @@
</a-spin>
<template #footer>
<a-space>
<a-button @click="handleCancel">关闭</a-button>
</a-space>
<a-button @click="handleCancel">关闭</a-button>
</template>
</a-drawer>
</template>
@ -163,6 +139,7 @@ import { useRouter, useRoute } from "vue-router"
import { message } from "ant-design-vue"
import {
FileOutlined,
FileImageOutlined,
DownloadOutlined,
EyeOutlined,
} from "@ant-design/icons-vue"
@ -188,7 +165,8 @@ const authStore = useAuthStore()
const visible = ref(false)
const loading = ref(false)
const work = ref<ContestWork | null>(null)
const previewImage = ref<string | null>(null)
const showActions = ref(false)
const previewError = ref(false)
//
watch(
@ -196,10 +174,11 @@ watch(
async (newVal) => {
visible.value = newVal
if (newVal) {
previewError.value = false
await fetchUserWork()
} else {
work.value = null
previewImage.value = null
showActions.value = false
}
},
{ 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(() => {
if (!work.value) return null
let files = work.value.files || []
// files JSON
if (typeof files === 'string') {
if (typeof files === "string") {
try {
files = JSON.parse(files)
} catch {
@ -273,26 +264,11 @@ const workFile = computed(() => {
// {fileUrl: string}
const firstFile = files[0]
return typeof firstFile === 'object' && firstFile?.fileUrl
return typeof firstFile === "object" && firstFile?.fileUrl
? firstFile.fileUrl
: 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
const is3DModelFile = (fileUrl: string): boolean => {
const modelExtensions = [
@ -304,6 +280,7 @@ const is3DModelFile = (fileUrl: string): boolean => {
".dae",
".stl",
".ply",
".zip",
]
const lowerUrl = fileUrl.toLowerCase()
return modelExtensions.some((ext) => lowerUrl.includes(ext))
@ -318,82 +295,53 @@ const getFileUrl = (fileUrl: string): string => {
}
// APIURL
const baseURL = import.meta.env.VITE_API_BASE_URL || ""
// fileUrl /api baseURL /api
if (fileUrl.startsWith("/api") && baseURL.includes("/api")) {
// fileUrl /api baseURL
const urlWithoutApi = baseURL.replace(/\/api$/, "")
return `${urlWithoutApi}${fileUrl}`
}
//
return `${baseURL}${fileUrl.startsWith("/") ? "" : "/"}${fileUrl}`
}
//
const getFileName = (fileUrl: string): string => {
if (!fileUrl) return "文件"
//
const handlePreviewError = () => {
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 handlePreview3DModel = (fileUrl: string) => {
if (!fileUrl) {
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 handleImageHover = (file: string) => {
if (isImageFile(file)) {
previewImage.value = file
//
const handleDownloadWork = () => {
if (!workFile.value) {
message.error("无作品文件")
return
}
}
//
const handleImageLeave = () => {
previewImage.value = null
}
//
const handleImageError = (event: Event) => {
const img = event.target as HTMLImageElement
img.style.display = "none"
const fileUrl = getFileUrl(workFile.value)
const link = document.createElement("a")
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) => {
try {
const fileUrl = getFileUrl(attachment.fileUrl)
//
const link = document.createElement("a")
link.href = fileUrl
link.download = attachment.fileName
@ -402,7 +350,7 @@ const handleDownloadAttachment = async (attachment: any) => {
link.click()
document.body.removeChild(link)
message.success("开始下载附件")
} catch (error: any) {
} catch {
message.error("下载附件失败")
}
}
@ -456,23 +404,6 @@ const getStatusText = (
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 = () => {
visible.value = false
@ -494,160 +425,132 @@ const handleCancel = () => {
padding: 0;
}
.work-section {
//
.work-preview-card {
margin-bottom: 24px;
}
&:last-child {
margin-bottom: 0;
.preview-wrapper {
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-weight: 500;
color: rgba(0, 0, 0, 0.85);
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 {
display: flex;
flex-direction: column;
gap: 12px;
gap: 8px;
}
.attachment-item {
@ -656,7 +559,7 @@ const handleCancel = () => {
justify-content: space-between;
padding: 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
border-radius: 8px;
transition: all 0.3s;
&:hover {

View File

@ -8,13 +8,16 @@
</div>
<!-- Header -->
<div class="viewer-header">
<div class="page-header">
<div class="header-left">
<a-button type="text" class="back-btn" @click="handleBack">
<template #icon><ArrowLeftOutlined /></template>
</a-button>
<span class="title">3D 模型预览</span>
<span class="badge">LIVE</span>
<span class="live-badge">
<span class="pulse-dot"></span>
LIVE
</span>
</div>
<div class="header-right">
<a-button type="text" class="action-btn" @click="resetCamera">
@ -1168,76 +1171,87 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
// ==========================================
// Header
// ==========================================
.viewer-header {
.page-header {
height: 64px;
padding: 0 24px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
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);
justify-content: space-between;
flex-shrink: 0;
position: relative;
z-index: 10;
}
.header-left {
.header-left {
display: flex;
align-items: center;
gap: 16px;
.back-btn {
color: $text !important;
width: 40px;
height: 40px;
border-radius: 10px !important;
border: 1px solid rgba($primary, 0.3) !important;
display: flex;
align-items: center;
gap: 16px;
justify-content: center;
transition: all 0.3s !important;
flex-shrink: 0;
.title {
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;
&:hover {
background: rgba($primary, 0.2) !important;
border-color: $primary !important;
transform: translateY(-1px);
}
}
.header-right {
.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;
gap: 8px;
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.7;
}
}
.back-btn {
color: $text !important;
width: 40px;
height: 40px;
border-radius: 10px !important;
border: 1px solid rgba($primary, 0.3) !important;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease !important;
flex-shrink: 0;
&:hover {
background: rgba($primary, 0.2) !important;
border-color: $primary !important;
transform: translateY(-1px);
opacity: 0.5;
transform: scale(0.8);
}
}

View File

@ -210,7 +210,10 @@ const modelCards = computed(() => {
const resultUrls = task.value.resultUrls || []
// 1
if ((status === "pending" || status === "processing") && previewUrls.length === 0) {
if (
(status === "pending" || status === "processing") &&
previewUrls.length === 0
) {
return [{ status: status, previewUrl: "" }]
}
@ -436,10 +439,6 @@ $gradient-card: linear-gradient(
display: flex;
align-items: center;
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;
position: relative;
z-index: 10;
@ -871,7 +870,11 @@ $gradient-card: linear-gradient(
.failed-icon {
width: 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-radius: 50%;
display: flex;
@ -884,7 +887,8 @@ $gradient-card: linear-gradient(
}
@keyframes pulse-error {
0%, 100% {
0%,
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba($error, 0.3);
}
@ -978,19 +982,67 @@ $gradient-card: linear-gradient(
// ==========================================
// 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) {
.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 {
grid-template-columns: 1fr;
gap: 16px;
}
.model-card {
width: 100%;
max-width: 300px;
max-width: 320px;
height: 180px;
}
.tips-section {
flex-direction: column;
gap: 12px;
}
.action-buttons {
flex-direction: column;
gap: 12px;
.action-btn {
width: 100%;
}
}
}
</style>

View File

@ -67,7 +67,6 @@
class="history-card"
@click="handleViewTask(task)"
>
<div class="card-glow"></div>
<div class="card-preview">
<img
v-if="task.status === 'completed' && task.previewUrl"
@ -102,14 +101,37 @@
<div class="status-badge" :class="`status-${task.status}`">
{{ getStatusText(task.status) }}
</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 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">
{{ task.inputContent }}
</div>
@ -118,30 +140,9 @@
<ClockCircleOutlined />
{{ formatTime(task.createTime) }}
</span>
<div class="card-actions" @click.stop>
<a-tooltip v-if="task.status === 'completed'" title="预览模型">
<div class="action-btn" @click="handlePreview(task)">
<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>
<span class="card-type">
{{ task.inputType === 'text' ? '文生3D' : '图生3D' }}
</span>
</div>
</div>
</div>
@ -472,22 +473,37 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
display: flex;
align-items: center;
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;
position: relative;
z-index: 10;
flex-shrink: 0;
}
.header-left {
.header-left {
display: flex;
align-items: center;
gap: 16px;
.back-btn {
color: $text !important;
width: 40px;
height: 40px;
border-radius: 10px !important;
border: 1px solid rgba($primary, 0.3) !important;
display: flex;
align-items: center;
gap: 16px;
justify-content: center;
transition: all 0.3s !important;
flex-shrink: 0;
&:hover {
background: rgba($primary, 0.2) !important;
border-color: $primary !important;
transform: translateY(-1px);
}
}
.title {
font-size: 18px;
font-size: 20px;
font-weight: 600;
background: $gradient-primary;
-webkit-background-clip: text;
@ -496,33 +512,22 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
}
.count-badge {
padding: 4px 12px;
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;
font-size: 12px;
color: $primary;
font-weight: 500;
font-size: 11px;
font-weight: 600;
}
}
.back-btn {
color: $text !important;
width: 40px;
height: 40px;
border-radius: 10px !important;
border: 1px solid rgba($primary, 0.3) !important;
.header-right {
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease !important;
flex-shrink: 0;
&:hover {
background: rgba($primary, 0.1) !important;
border-color: $primary !important;
transform: translateY(-1px);
}
gap: 8px;
}
// ==========================================
@ -658,53 +663,47 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
.history-card {
background: $surface;
border-radius: 8px;
border-radius: 12px;
overflow: hidden;
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;
// border: 1px solid rgba(0, 0, 0, 0.06);
&:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
.card-glow {
opacity: 0.3;
.card-preview {
transform: scale(1.03);
}
.preview-image {
transform: scale(1.1);
.card-preview .preview-image {
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 {
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;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
transition: transform 0.3s ease;
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s ease;
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.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 {
0%, 100% {
transform: scale(1);
@ -812,22 +867,19 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
.card-info {
padding: 16px;
background: rgba($primary, 0.15);
}
.card-type {
margin-bottom: 8px;
background: $surface;
border-top: 1px solid rgba($primary, 0.06);
}
.card-desc {
font-size: 14px;
color: $text;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 12px;
line-height: 1.5;
font-weight: 500;
line-height: 1.4;
}
.card-meta {
@ -844,46 +896,20 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
color: $text-muted;
}
.card-actions {
display: 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;
.card-type {
display: inline-flex;
align-items: center;
justify-content: center;
color: $primary-light;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: rgba($primary-light, 0.2);
border-color: $primary-light;
color: $primary-light;
transform: scale(1.1);
}
&.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);
}
}
padding: 4px 10px;
background: linear-gradient(
135deg,
rgba($primary, 0.1) 0%,
rgba($primary-light, 0.15) 100%
);
border: 1px solid rgba($primary, 0.2);
border-radius: 12px;
font-size: 11px;
font-weight: 500;
color: $primary;
}
// ==========================================

View File

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