library-picturebook-activity/frontend/src/views/workbench/ai-3d/Index.vue
2026-01-16 14:48:14 +08:00

1887 lines
42 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="ai-3d-container">
<!-- Animated Background -->
<div class="bg-animation">
<div class="bg-gradient bg-gradient-1"></div>
<div class="bg-gradient bg-gradient-2"></div>
<div class="bg-gradient bg-gradient-3"></div>
</div>
<!-- 左侧生成栏 -->
<div class="left-panel">
<!-- Header with Logo -->
<div class="panel-logo">
<a-button type="text" class="back-btn" @click="handleBack">
<template #icon><ArrowLeftOutlined /></template>
</a-button>
<div class="logo-ring">
<div class="logo-icon">
<ThunderboltOutlined />
</div>
</div>
<div class="logo-text">
<h3>AI 3D Studio</h3>
<p>智能建模工作室</p>
</div>
</div>
<div class="panel-header">
<a-segmented
v-model:value="inputType"
:options="inputTypeOptions"
block
/>
</div>
<div class="panel-content">
<!-- 文生3D输入 -->
<div v-if="inputType === 'text'" class="text-input-section">
<div class="input-label">
<EditOutlined class="label-icon" />
<span>创意描述</span>
</div>
<div class="input-hint">
用文字描述你的想法AI 将为你生成精美的 3D 模型
</div>
<div class="textarea-wrapper">
<a-textarea
v-model:value="textContent"
placeholder="例如:一只卡通风格的橙色小猫,蓝色的大眼睛,尾巴卷曲..."
:rows="5"
:maxlength="1024"
class="text-input"
/>
<span class="char-count">{{ textContent.length }}/1024</span>
</div>
<div class="sample-section">
<div class="sample-header">
<span class="sample-title">灵感推荐</span>
<ReloadOutlined class="refresh-icon" @click="refreshSamples" />
</div>
<div class="sample-prompts">
<span
v-for="(sample, index) in currentSamples"
:key="index"
class="sample-tag"
@click="applySample(sample)"
>
{{ sample }}
</span>
</div>
</div>
<!-- 模型设置 -->
<div class="model-settings">
<div class="setting-row">
<div class="setting-item">
<span class="setting-label">模型类型</span>
<a-select v-model:value="generateType" class="setting-select">
<a-select-option value="Normal">带纹理</a-select-option>
<a-select-option value="LowPoly">低多边形</a-select-option>
<a-select-option value="Geometry">白模</a-select-option>
<a-select-option value="Sketch">草图</a-select-option>
</a-select>
</div>
<div class="setting-item">
<span class="setting-label">模型面数</span>
<a-select v-model:value="faceCount" class="setting-select">
<a-select-option :value="50000">5万面</a-select-option>
<a-select-option :value="100000">10万面</a-select-option>
<a-select-option :value="300000">30万面</a-select-option>
<a-select-option :value="500000">50万面</a-select-option>
<a-select-option :value="1000000">100万面</a-select-option>
</a-select>
</div>
</div>
</div>
</div>
<!-- 图生3D上传 -->
<div v-else class="image-input-section">
<div class="input-label">
<PictureOutlined class="label-icon" />
<span>参考图片</span>
</div>
<div class="input-hint">
上传参考图片AI 将智能识别并生成 3D 模型
</div>
<a-upload-dragger
:file-list="[]"
:before-upload="handleBeforeUpload"
:show-upload-list="false"
:max-count="1"
accept="image/*"
class="image-upload"
>
<template v-if="imageUrl">
<img :src="imageUrl" alt="预览" class="upload-preview-image" />
<div class="upload-change-hint">点击更换图片</div>
</template>
<template v-else>
<p class="upload-icon">
<PictureOutlined />
</p>
<p class="upload-text">点击或拖拽图片到此处</p>
<p class="upload-hint">支持 JPGPNG 格式最大 10MB</p>
</template>
</a-upload-dragger>
<!-- 模型类型选择 -->
<div class="model-settings">
<div class="setting-row">
<div class="setting-item full-width">
<span class="setting-label">模型类型</span>
<a-select v-model:value="generateType" class="setting-select">
<a-select-option value="Normal">带纹理</a-select-option>
<a-select-option value="LowPoly">低多边形</a-select-option>
<a-select-option value="Geometry">白模</a-select-option>
<a-select-option value="Sketch">草图</a-select-option>
</a-select>
</div>
</div>
</div>
</div>
</div>
<div class="panel-footer">
<button
class="generate-btn"
:class="{ 'is-loading': generating, 'is-disabled': !canGenerate }"
:disabled="!canGenerate"
@click="handleGenerate"
>
<span class="btn-bg"></span>
<span class="btn-content">
<LoadingOutlined v-if="generating" class="spin-icon" />
<ThunderboltOutlined v-else />
<span>{{ generating ? "生成中..." : "立即生成" }}</span>
</span>
</button>
</div>
</div>
<!-- 右侧内容区 -->
<div class="right-panel">
<!-- 介绍区 -->
<div class="intro-section">
<h1 class="intro-title">用一句话、一张图,创造你的 3D 世界</h1>
<p class="intro-desc">
借助先进的 AI 技术,将文字描述或图片瞬间转化为专业级 3D 模型
</p>
<div class="intro-features">
<div class="feature-card">
<div class="feature-icon gradient-1">
<BulbOutlined />
</div>
<div class="feature-info">
<h3>AI 智能建模</h3>
<p>输入文字或图片,自动生成 3D 模型</p>
</div>
</div>
<div class="feature-card">
<div class="feature-icon gradient-2">
<EyeOutlined />
</div>
<div class="feature-info">
<h3>实时预览</h3>
<p>支持旋转、缩放,全方位查看细节</p>
</div>
</div>
<div class="feature-card">
<div class="feature-icon gradient-3">
<FolderOutlined />
</div>
<div class="feature-info">
<h3>作品管理</h3>
<p>自动保存至个人作品库,随时访问</p>
</div>
</div>
<div class="feature-card">
<div class="feature-icon gradient-4">
<SyncOutlined />
</div>
<div class="feature-info">
<h3>迭代优化</h3>
<p>基于已有作品再次生成与优化</p>
</div>
</div>
</div>
</div>
<!-- 创作历史区 -->
<div class="history-section">
<div class="history-header">
<div class="header-left">
<h2 class="history-title">创作历史</h2>
<span class="history-count"
>{{ allHistoryTotal || historyList.length }} 个作品</span
>
</div>
<a class="view-all" @click="goToHistory">
查看全部
<ArrowRightOutlined />
</a>
</div>
<div v-if="historyLoading" class="history-loading">
<div class="loading-spinner">
<div class="spinner-ring"></div>
<div class="spinner-ring"></div>
<div class="spinner-ring"></div>
</div>
<p>加载中...</p>
</div>
<div v-else-if="historyList.length === 0" class="history-empty">
<div class="empty-icon">
<FileImageOutlined />
</div>
<p class="empty-title">还没有创作记录</p>
<p class="empty-text">开始你的第一次 3D 创作之旅吧</p>
</div>
<div v-else ref="historyGridRef" class="history-grid">
<div
v-for="task in displayedHistoryList"
:key="task.id"
class="history-card"
@click="handleViewTask(task)"
>
<div class="card-preview">
<img
v-if="task.status === 'completed' && task.previewUrl"
:src="getPreviewUrl(task)"
alt="预览"
class="preview-image"
@error="handleImageError"
@load="handleImageLoad"
/>
<div
v-else-if="
task.status === 'processing' || task.status === 'pending'
"
class="preview-loading"
>
<div class="loading-dots">
<span></span>
<span></span>
<span></span>
</div>
<span class="loading-text">生成中</span>
</div>
<div
v-else-if="
task.status === 'failed' || task.status === 'timeout'
"
class="preview-failed"
>
<div class="failed-icon">
<CloseOutlined />
</div>
</div>
<div v-else class="preview-placeholder">
<FileImageOutlined />
</div>
<!-- Status Badge -->
<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-desc" :title="task.inputContent">
{{ task.inputContent }}
</div>
<div class="card-meta">
<span class="card-time">
<ClockCircleOutlined />
{{ formatTime(task.createTime) }}
</span>
<span class="card-type">
{{ task.inputType === "text" ? "文生3D" : "图生3D" }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from "vue"
import { useRouter, useRoute } from "vue-router"
import { message, Modal } from "ant-design-vue"
import {
ReloadOutlined,
PictureOutlined,
ThunderboltOutlined,
LoadingOutlined,
CloseOutlined,
FileImageOutlined,
EyeOutlined,
DeleteOutlined,
ArrowRightOutlined,
ArrowLeftOutlined,
ClockCircleOutlined,
BulbOutlined,
FolderOutlined,
SyncOutlined,
EditOutlined,
} from "@ant-design/icons-vue"
import {
createAI3DTask,
getAI3DTasks,
getAI3DTask,
retryAI3DTask,
deleteAI3DTask,
type AI3DTask,
} from "@/api/ai-3d"
import { uploadFile } from "@/api/upload"
import { useAuthStore } from "@/stores/auth"
import dayjs from "dayjs"
// 获取认证状态
const authStore = useAuthStore()
const router = useRouter()
const route = useRoute()
// 返回首页
const handleBack = () => {
const tenantCode = route.params.tenantCode as string
router.push(`/${tenantCode}`)
}
// 跳转到历史记录页面
const goToHistory = () => {
const tenantCode = route.params.tenantCode as string
router.push(`/${tenantCode}/workbench/3d-lab/history`)
}
// 输入类型选项
const inputTypeOptions = [
{ label: "文生3D", value: "text" },
{ label: "图生3D", value: "image" },
]
// 示例提示词
const samplePrompts = [
["啄木鸟", "尖锐的嘴", "金黄色"],
["可爱的猫咪", "卡通风格", "蓝色眼睛"],
["机器人", "金属质感", "未来风格"],
["中式花瓶", "青花瓷", "精致纹理"],
["小恐龙", "Q版造型", "绿色皮肤"],
["宇航员", "太空服", "写实风格"],
]
// 状态
const inputType = ref<"text" | "image">("text")
const textContent = ref("")
const imageUrl = ref("")
const generating = ref(false)
const currentSampleIndex = ref(0)
// 模型设置
const generateType = ref<"Normal" | "LowPoly" | "Geometry" | "Sketch">("Normal")
const faceCount = ref(500000)
// 历史记录
const historyList = ref<AI3DTask[]>([])
const historyLoading = ref(false)
const allHistoryTotal = ref(0)
// 历史网格容器引用和宽度
const historyGridRef = ref<HTMLElement | null>(null)
const historyGridWidth = ref(0)
// 轮询定时器
let pollingTimer: number | null = null
// ResizeObserver 用于监听容器宽度变化
let resizeObserver: ResizeObserver | null = null
// 计算属性
const currentSamples = computed(() => samplePrompts[currentSampleIndex.value])
const canGenerate = computed(() => {
// 首先检查用户是否有创建权限
if (!authStore.hasPermission("ai-3d:create")) {
return false
}
// 检查输入内容是否有效
if (inputType.value === "text") {
return textContent.value.trim().length > 0
} else {
return imageUrl.value.length > 0
}
})
// 计算一行最多能展示的卡片数量
// 卡片宽度 240px + 间距 20px = 260px
const maxCardsPerRow = computed(() => {
if (historyGridWidth.value === 0) return 6 // 默认值
const cardWidth = 240
const gap = 20
// 可用宽度 = 容器宽度 - 左右 padding (48px * 2)
const availableWidth = historyGridWidth.value - 96
// 计算能放多少个:可用宽度 / (卡片宽度 + 间距)
const count = Math.floor(availableWidth / (cardWidth + gap))
return Math.max(1, count) // 至少显示1个
})
// 限制显示的历史记录数量
const displayedHistoryList = computed(() => {
return historyList.value.slice(0, maxCardsPerRow.value)
})
// 刷新示例
const refreshSamples = () => {
currentSampleIndex.value =
(currentSampleIndex.value + 1) % samplePrompts.length
}
// 应用示例
const applySample = (sample: string) => {
if (textContent.value) {
textContent.value += "" + sample
} else {
textContent.value = sample
}
}
// 聚焦输入
const focusInput = () => {
// 滚动到左侧面板
}
// 图片上传前处理
const handleBeforeUpload = async (file: File) => {
const isImage = file.type.startsWith("image/")
if (!isImage) {
message.error("只能上传图片文件")
return false
}
const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) {
message.error("图片大小不能超过 10MB")
return false
}
// 上传图片
try {
const result: any = await uploadFile(file)
// 兼容不同的响应格式
const url = result.data?.url || result.url
if (url) {
imageUrl.value = url
message.success("图片上传成功")
} else {
message.error("上传失败:无法获取图片地址")
}
} catch (error) {
console.error("上传失败:", error)
message.error("图片上传失败")
}
return false // 阻止默认上传行为
}
// 生成3D模型
const handleGenerate = async () => {
if (!canGenerate.value) return
generating.value = true
try {
const content =
inputType.value === "text" ? textContent.value.trim() : imageUrl.value
// 构建请求参数
const params: any = {
inputType: inputType.value,
inputContent: content,
generateType: generateType.value,
}
// 文生3D时添加模型面数参数
if (inputType.value === "text") {
params.faceCount = faceCount.value
}
const task = await createAI3DTask(params)
// 清空输入
if (inputType.value === "text") {
textContent.value = ""
} else {
imageUrl.value = ""
}
// 跳转到生成页面
const taskData = task.data || task // 兼容不同的响应格式
router.push({
name: "AI3DGenerate",
params: { taskId: taskData.id },
})
} catch (error: any) {
message.error(error.response?.data?.message || "提交失败,请重试")
} finally {
generating.value = false
}
}
// 获取历史记录最近6条
const fetchHistory = async () => {
historyLoading.value = true
try {
const res = await getAI3DTasks({ page: 1, pageSize: 6 })
const data = res.data || res // 兼容不同的响应格式
historyList.value = data.list || []
// 如果还没有获取过总数,则保存总数
if (!allHistoryTotal.value && data.total) {
allHistoryTotal.value = data.total
}
} catch (error) {
console.error("获取历史记录失败:", error)
} finally {
historyLoading.value = false
}
}
// 开始轮询(检查处理中的任务)
const startPolling = () => {
if (pollingTimer) return
pollingTimer = window.setInterval(async () => {
const processingTasks = historyList.value.filter(
(t) => t.status === "pending" || t.status === "processing"
)
if (processingTasks.length === 0) {
stopPolling()
return
}
// 刷新历史记录
await fetchHistory()
}, 3000)
}
// 停止轮询
const stopPolling = () => {
if (pollingTimer) {
clearInterval(pollingTimer)
pollingTimer = null
}
}
// 预览3D模型
const handlePreview = (task: AI3DTask) => {
if (task.resultUrl || (task.resultUrls && task.resultUrls.length > 0)) {
const tenantCode = route.params.tenantCode as string
const urls = task.resultUrls || [task.resultUrl]
// 存储到 sessionStorage支持多模型
if (urls.length > 1) {
sessionStorage.setItem("model-viewer-urls", JSON.stringify(urls))
sessionStorage.setItem("model-viewer-index", "0")
sessionStorage.removeItem("model-viewer-url")
} else {
sessionStorage.setItem("model-viewer-url", urls[0] || "")
sessionStorage.removeItem("model-viewer-urls")
sessionStorage.removeItem("model-viewer-index")
}
router.push({
path: `/${tenantCode}/workbench/model-viewer`,
})
}
}
// 查看任务详情
const handleViewTask = (task: AI3DTask) => {
// 跳转到生成页面查看详情
router.push({
name: "AI3DGenerate",
params: { taskId: task.id },
})
}
// 重试任务
const handleRetry = async (task: AI3DTask) => {
if (task.retryCount >= 3) {
message.warning("已达到最大重试次数,请创建新任务")
return
}
try {
await retryAI3DTask(task.id)
message.success("重试已提交")
fetchHistory()
startPolling()
} catch (error: any) {
message.error(error.response?.data?.message || "重试失败")
}
}
// 删除任务
const handleDelete = (task: AI3DTask) => {
Modal.confirm({
title: "确认删除",
content: "确定要删除这条创作记录吗?",
okText: "删除",
okType: "danger",
cancelText: "取消",
async onOk() {
try {
await deleteAI3DTask(task.id)
message.success("删除成功")
fetchHistory()
} catch (error) {
message.error("删除失败")
}
},
})
}
// 获取预览图URL
const getPreviewUrl = (task: AI3DTask) => {
if (task.previewUrl) {
// 自己的COS桶直接访问已配置公有读
if (task.previewUrl.includes("competition-ms-1325825530.cos")) {
return task.previewUrl
}
// 混元返回的临时链接通过代理访问解决CORS问题
if (
task.previewUrl.includes("tencentcos.cn") ||
task.previewUrl.includes("qcloud.com")
) {
return `/api/ai-3d/proxy-preview?url=${encodeURIComponent(task.previewUrl)}`
}
// 其他URL直接返回
return task.previewUrl
}
return ""
}
// 获取状态文本
const getStatusText = (status: string) => {
const texts: Record<string, string> = {
pending: "等待中",
processing: "生成中",
completed: "已完成",
failed: "失败",
timeout: "超时",
}
return texts[status] || status
}
// 图片加载错误处理
const handleImageError = (event: Event) => {
const img = event.target as HTMLImageElement
console.error("预览图加载失败:", img.src)
// 可以在这里添加错误提示或显示占位图
}
// 图片加载成功处理
const handleImageLoad = () => {
// 图片加载成功,可以在这里做一些处理
console.log("预览图加载成功")
}
// 格式化时间
const formatTime = (time: string) => {
return dayjs(time).format("MM-DD HH:mm")
}
// 更新容器宽度
const updateGridWidth = () => {
if (historyGridRef.value) {
historyGridWidth.value = historyGridRef.value.offsetWidth
}
}
// 页面加载
onMounted(async () => {
await fetchHistory()
// 等待 DOM 更新后计算容器宽度
await nextTick()
updateGridWidth()
// 监听容器宽度变化
if (historyGridRef.value) {
resizeObserver = new ResizeObserver(() => {
updateGridWidth()
})
resizeObserver.observe(historyGridRef.value)
}
// 检查是否有处理中的任务,有则开启轮询
const hasProcessing = historyList.value.some(
(t) => t.status === "pending" || t.status === "processing"
)
if (hasProcessing) {
startPolling()
}
})
// 页面卸载
onUnmounted(() => {
stopPolling()
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
})
</script>
<style scoped lang="scss">
// ==========================================
// 蓝色主题色彩方案 - 统一色系
// ==========================================
// 主色调 - 蓝色系
$primary: #1890ff;
$primary-dark: #0958d9;
$primary-light: #40a9ff;
$secondary: #4096ff;
$accent: #40a9ff;
$success: #52c41a;
$warning: #faad14;
$error: #ff4d4f;
// 背景色
$background: #f5f5f5; // 浅灰背景与系统统一
$surface: #ffffff; // 白色卡片
$surface-light: #fafafa;
// 文字色
$text: rgba(0, 0, 0, 0.85); // 深色文字
$text-secondary: rgba(0, 0, 0, 0.65);
$text-muted: rgba(0, 0, 0, 0.45);
$text-light: #ffffff; // 深色区域的白色文字
// 渐变 - 与导航栏选中按钮一致
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
$gradient-secondary: linear-gradient(135deg, $primary-light 0%, $primary 100%);
.ai-3d-container {
display: flex;
width: 100%;
min-height: 100vh;
background: $background;
position: relative;
overflow: hidden;
align-items: stretch;
}
// ==========================================
// 简化背景动画 - 保留微妙效果
// ==========================================
.bg-animation {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
}
.bg-gradient {
position: absolute;
border-radius: 50%;
filter: blur(100px);
opacity: 0.15;
animation: float 30s ease-in-out infinite;
&.bg-gradient-1 {
width: 600px;
height: 600px;
background: $primary;
top: -200px;
left: -100px;
}
&.bg-gradient-2 {
width: 500px;
height: 500px;
background: $primary-light;
bottom: -150px;
right: -100px;
animation-delay: -10s;
}
&.bg-gradient-3 {
width: 400px;
height: 400px;
background: $secondary;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation-delay: -20s;
opacity: 0.1;
}
}
@keyframes float {
0%,
100% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(30px, -30px) scale(1.05);
}
66% {
transform: translate(-20px, 20px) scale(0.95);
}
}
// ==========================================
// Left Panel - 浅色变体
// ==========================================
.left-panel {
width: 380px;
min-height: 100vh;
background: rgba($surface, 0.5);
backdrop-filter: blur(20px);
display: flex;
flex-direction: column;
position: relative;
z-index: 1;
align-self: stretch;
border-right: 1px solid rgba($primary, 0.1);
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.06);
}
.panel-logo {
padding: 24px;
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
.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 !important;
flex-shrink: 0;
&:hover {
background: rgba($primary, 0.2) !important;
border-color: $primary !important;
transform: translateY(-1px);
}
}
.logo-ring {
position: relative;
width: 56px;
height: 56px;
border: 2px solid transparent;
border-radius: 50%;
background:
linear-gradient($surface, $surface) padding-box,
$gradient-primary border-box;
animation: rotateBorder 8s linear infinite;
}
.logo-icon {
position: absolute;
inset: 6px;
background: $gradient-primary;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: white;
}
.logo-text {
h3 {
margin: 0;
font-size: 18px;
font-weight: 700;
background: $gradient-primary;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
p {
margin: 4px 0 0;
font-size: 12px;
color: $text-muted;
}
}
}
@keyframes rotateBorder {
to {
transform: rotate(360deg);
}
}
.panel-header {
padding: 20px 24px;
flex-shrink: 0;
:deep(.ant-segmented) {
background: rgba($surface-light, 0.6);
border: 1px solid rgba($primary, 0.2);
.ant-segmented-item {
color: $text-muted;
&-selected {
background: $gradient-primary;
color: #fff;
}
}
}
}
.panel-content {
flex: 1;
padding: 24px;
overflow-y: auto;
min-height: 0;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: rgba($primary, 0.05);
}
&::-webkit-scrollbar-thumb {
background: rgba($primary, 0.3);
border-radius: 3px;
&:hover {
background: rgba($primary, 0.5);
}
}
}
.input-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: $text;
margin-bottom: 8px;
.label-icon {
font-size: 16px;
color: $primary;
}
}
.input-hint {
font-size: 13px;
color: $text-muted;
margin-bottom: 16px;
line-height: 1.6;
}
.textarea-wrapper {
position: relative;
.char-count {
position: absolute;
bottom: 8px;
right: 12px;
font-size: 12px;
color: $text-muted;
pointer-events: none;
}
}
.text-input {
background: rgba($surface-light, 0.6) !important;
border: 2px solid rgba($primary, 0.15) !important;
border-radius: 12px;
color: $text;
resize: none;
transition: all 0.3s;
padding-bottom: 28px !important;
&::placeholder {
color: rgba($text-muted, 0.5);
}
&:hover {
border-color: rgba($primary, 0.4) !important;
}
&:focus {
border-color: $primary !important;
box-shadow: 0 0 0 4px rgba($primary, 0.1) !important;
background: rgba($surface-light, 0.8) !important;
}
}
// 模型设置
.model-settings {
margin-top: 16px;
padding: 16px;
background: rgba($primary, 0.05);
border: 1px solid rgba($primary, 0.15);
border-radius: 12px;
.setting-row {
display: flex;
gap: 12px;
}
.setting-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
&.full-width {
flex: none;
width: 100%;
}
}
.setting-label {
font-size: 12px;
font-weight: 500;
color: $text-secondary;
}
.setting-select {
width: 100%;
:deep(.ant-select-selector) {
background: rgba($surface, 0.8) !important;
border: 1px solid rgba($primary, 0.2) !important;
border-radius: 8px !important;
height: 36px !important;
.ant-select-selection-item {
line-height: 34px !important;
font-size: 13px;
}
}
&:hover :deep(.ant-select-selector) {
border-color: rgba($primary, 0.4) !important;
}
&.ant-select-focused :deep(.ant-select-selector) {
border-color: $primary !important;
box-shadow: 0 0 0 2px rgba($primary, 0.1) !important;
}
}
}
.sample-section {
margin-top: 16px;
}
.sample-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.sample-title {
font-size: 13px;
font-weight: 600;
color: $text;
}
.refresh-icon {
color: $text-muted;
cursor: pointer;
transition: all 0.3s;
font-size: 14px;
&:hover {
color: $primary-light;
transform: rotate(180deg);
}
}
}
.sample-prompts {
display: flex;
flex-wrap: wrap;
gap: 8px;
.sample-tag {
padding: 6px 14px;
background: rgba($primary, 0.1);
border: 1px solid rgba($primary, 0.2);
border-radius: 20px;
font-size: 12px;
color: $text;
cursor: pointer;
transition: all 0.3s;
&:hover {
background: rgba($primary-light, 0.2);
border-color: $primary-light;
transform: translateY(-2px);
}
}
}
.image-upload {
:deep(.ant-upload-drag) {
background: rgba($surface-light, 0.6) !important;
border: 2px dashed rgba($primary, 0.3) !important;
border-radius: 12px;
transition: all 0.3s;
overflow: hidden;
&:hover {
border-color: $primary-light !important;
background: rgba($surface-light, 0.8) !important;
}
}
:deep(.ant-upload) {
padding: 24px !important;
}
:deep(.upload-icon) {
color: $primary-light;
font-size: 56px;
margin-bottom: 12px;
}
:deep(.upload-text) {
color: $text;
font-size: 15px;
margin-bottom: 8px;
}
:deep(.upload-hint) {
color: $text-muted;
font-size: 12px;
}
:deep(.upload-preview-image) {
max-width: 100%;
max-height: 180px;
object-fit: contain;
border-radius: 8px;
}
:deep(.upload-change-hint) {
margin-top: 12px;
font-size: 13px;
color: $text-muted;
transition: color 0.3s;
}
:deep(.ant-upload-drag:hover .upload-change-hint) {
color: $primary-light;
}
}
.panel-footer {
padding: 24px;
flex-shrink: 0;
}
.generate-btn {
width: 100%;
height: 56px;
border: none;
border-radius: 14px;
cursor: pointer;
position: relative;
overflow: hidden;
background: transparent;
.btn-bg {
position: absolute;
inset: 0;
background: $gradient-primary;
transition: all 0.4s;
}
.btn-content {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: white;
font-size: 16px;
font-weight: 600;
}
.spin-icon {
animation: spin 1s linear infinite;
}
&:hover:not(.is-disabled) {
transform: translateY(-2px);
box-shadow: 0 15px 35px rgba($primary, 0.4);
.btn-bg {
transform: scale(1.02);
}
}
&:active:not(.is-disabled) {
transform: translateY(0);
}
&.is-disabled {
cursor: not-allowed;
opacity: 0.5;
}
&.is-loading {
.btn-bg {
animation: gradientShift 2s ease infinite;
}
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes gradientShift {
0%,
100% {
background: $gradient-primary;
}
50% {
background: $gradient-secondary;
}
}
// ==========================================
// Right Panel
// ==========================================
.right-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
z-index: 1;
height: 100vh;
background: rgba($surface, 0.3);
backdrop-filter: blur(20px);
padding-top: 30px;
}
.intro-section {
padding: 48px;
flex-shrink: 0;
text-align: center;
}
.intro-title {
font-size: 36px;
font-weight: 700;
margin-bottom: 16px;
line-height: 1.3;
background: linear-gradient(
135deg,
$primary-dark 0%,
$primary 35%,
#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 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.feature-card {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 20px;
background: $surface-light;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 8px;
transition: all 0.3s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
}
.feature-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: #fff;
flex-shrink: 0;
&.gradient-1 {
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
}
&.gradient-2 {
background: linear-gradient(135deg, $primary-dark 0%, darken($primary-dark, 10%) 100%);
}
&.gradient-3 {
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
}
&.gradient-4 {
background: linear-gradient(135deg, $primary-dark 0%, $primary 100%);
}
}
.feature-info {
flex: 1;
h3 {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
color: $text;
}
p {
margin: 0;
font-size: 13px;
color: $text-muted;
line-height: 1.5;
}
}
// ==========================================
// History Section
// ==========================================
.history-section {
flex: 1;
min-height: 0;
padding: 32px 48px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: rgba($primary, 0.05);
}
&::-webkit-scrollbar-thumb {
background: rgba($primary, 0.3);
border-radius: 4px;
&:hover {
background: rgba($primary, 0.5);
}
}
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.header-left {
display: flex;
align-items: baseline;
gap: 12px;
}
.history-title {
font-size: 24px;
font-weight: 600;
color: $text;
margin: 0;
}
.history-count {
font-size: 14px;
color: $text-muted;
}
.view-all {
display: flex;
align-items: center;
gap: 6px;
color: $primary-light;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.3s;
&:hover {
color: $primary;
transform: translateX(4px);
}
}
.history-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: 16px;
color: $text-muted;
}
.loading-spinner {
position: relative;
width: 60px;
height: 60px;
.spinner-ring {
position: absolute;
inset: 0;
border: 2px solid transparent;
border-radius: 50%;
animation: spin 2s linear infinite;
&:nth-child(1) {
border-top-color: $primary;
animation-duration: 1.5s;
}
&:nth-child(2) {
border-right-color: $primary-light;
animation-duration: 2s;
}
&:nth-child(3) {
border-bottom-color: $accent;
animation-duration: 2.5s;
}
}
}
.history-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: 12px;
.empty-icon {
width: 80px;
height: 80px;
background: rgba($primary, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: $primary-light;
margin-bottom: 8px;
}
.empty-title {
font-size: 16px;
font-weight: 600;
color: $text;
margin: 0;
}
.empty-text {
font-size: 14px;
color: $text-muted;
margin: 0;
}
}
.history-grid {
display: flex;
flex-direction: row;
gap: 20px;
overflow: hidden; // 移除滚动只展示一行
flex-wrap: nowrap; // 不换行
}
.history-card {
flex-shrink: 0;
width: 240px;
background: $surface;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
position: relative;
// border: 1px solid rgba(0, 0, 0, 0.06);
&:hover {
.card-preview {
transform: scale(1.03);
}
.card-preview .preview-image {
transform: scale(1.05);
}
.card-actions-overlay {
opacity: 1;
}
}
}
.card-preview {
height: 160px;
// 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 cubic-bezier(0.4, 0, 0.2, 1);
}
.preview-loading,
.preview-failed,
.preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: $text-muted;
font-size: 32px;
.loading-text {
font-size: 13px;
}
}
.preview-failed {
.failed-icon {
width: 56px;
height: 56px;
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;
align-items: center;
justify-content: center;
font-size: 24px;
color: $error;
animation: pulse-error 2s ease-in-out infinite;
}
}
}
// 悬停时显示的操作按钮遮罩层
.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);
box-shadow: 0 0 0 0 rgba($error, 0.3);
}
50% {
transform: scale(1.05);
box-shadow: 0 0 20px 5px rgba($error, 0.15);
}
}
.loading-dots {
display: flex;
gap: 6px;
span {
width: 8px;
height: 8px;
background: $primary-light;
border-radius: 50%;
animation: dotPulse 1.4s ease-in-out infinite;
&:nth-child(1) {
animation-delay: 0s;
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
@keyframes dotPulse {
0%,
60%,
100% {
transform: scale(1);
opacity: 1;
}
30% {
transform: scale(1.5);
opacity: 0.7;
}
}
.status-badge {
position: absolute;
top: 12px;
right: 12px;
padding: 4px 12px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
&.status-completed {
background: rgba($success, 0.2);
color: $success;
border-color: rgba($success, 0.3);
}
&.status-processing,
&.status-pending {
background: rgba($primary-light, 0.2);
color: $primary-light;
border-color: rgba($primary-light, 0.3);
}
&.status-failed,
&.status-timeout {
background: rgba($error, 0.2);
color: $error;
border-color: rgba($error, 0.3);
}
}
.card-info {
padding: 16px;
background: $surface;
border-top: 1px solid rgba($primary, 0.06);
}
.card-desc {
font-size: 14px;
color: $text;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 12px;
font-weight: 500;
line-height: 1.4;
}
.card-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-time {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: $text-muted;
}
.card-type {
display: inline-flex;
align-items: center;
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;
}
// ==========================================
// Responsive
// ==========================================
@media (max-width: 1024px) {
.left-panel {
width: 100%;
}
.intro-features {
grid-template-columns: 1fr;
}
.history-card {
width: 200px;
}
}
@media (max-width: 768px) {
.ai-3d-container {
flex-direction: column;
}
.left-panel {
width: 100%;
}
.intro-section {
padding: 32px 24px;
}
.history-section {
padding: 24px;
}
.history-card {
width: 180px;
.card-actions-overlay {
.overlay-btn {
padding: 6px 10px;
font-size: 12px;
}
}
}
}
</style>