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

1681 lines
37 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 class="bg-grid"></div>
</div>
<!-- 左侧生成栏 -->
<div class="left-panel">
<!-- Header with Logo -->
<div class="panel-logo">
<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">
<span class="label-icon">✨</span>
<span>创意描述</span>
</div>
<div class="input-hint">
用文字描述你的想法AI 将为你生成精美的 3D 模型
</div>
<a-textarea
v-model:value="textContent"
placeholder="例如:一只卡通风格的橙色小猫,蓝色的大眼睛,尾巴卷曲..."
:rows="6"
:maxlength="150"
show-count
class="text-input"
/>
<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>
<!-- 图生3D上传 -->
<div v-else class="image-input-section">
<div class="input-label">
<span class="label-icon">🖼️</span>
<span>参考图片</span>
</div>
<div class="input-hint">
上传参考图片AI 将智能识别并生成 3D 模型
</div>
<a-upload-dragger
v-model:file-list="imageFileList"
:before-upload="handleBeforeUpload"
:max-count="1"
accept="image/*"
class="image-upload"
>
<p class="upload-icon">
<PictureOutlined />
</p>
<p class="upload-text">点击或拖拽图片到此处</p>
<p class="upload-hint">支持 JPG、PNG 格式,最大 10MB</p>
</a-upload-dragger>
</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">
<div class="intro-badge">
<span class="badge-dot"></span>
<span>AI Powered</span>
</div>
<h1 class="intro-title">用一句话、一张图<br/>创造你的 3D 世界</h1>
<p class="intro-desc">
借助先进的 AI 技术,将文字描述或图片瞬间转化为专业级 3D 模型
</p>
<div class="intro-features">
<div class="feature-card">
<div class="feature-icon gradient-1">
<span>✨</span>
</div>
<div class="feature-info">
<h3>AI 智能建模</h3>
<p>输入文字或图片,自动生成 3D 模型</p>
</div>
</div>
<div class="feature-card">
<div class="feature-icon gradient-2">
<span>👁</span>
</div>
<div class="feature-info">
<h3>实时预览</h3>
<p>支持旋转、缩放,全方位查看细节</p>
</div>
</div>
<div class="feature-card">
<div class="feature-icon gradient-3">
<span>📁</span>
</div>
<div class="feature-info">
<h3>作品管理</h3>
<p>自动保存至个人作品库,随时访问</p>
</div>
</div>
<div class="feature-card">
<div class="feature-icon gradient-4">
<span>🔄</span>
</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">{{ historyList.length }} 个作品</span>
</div>
<a class="view-all" @click="showAllHistory = true">
查看全部
<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 class="history-grid">
<div
v-for="task in historyList"
:key="task.id"
class="history-card"
@click="handleViewTask(task)"
>
<div class="card-glow"></div>
<div class="card-preview">
<img
v-if="task.status === 'completed' && task.previewUrl"
:src="getPreviewUrl(task)"
alt="预览"
class="preview-image"
/>
<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"
>
<ExclamationCircleOutlined />
<span>{{ task.status === 'timeout' ? '已超时' : '生成失败' }}</span>
</div>
<div v-else class="preview-placeholder">
<FileImageOutlined />
</div>
<!-- Status Badge -->
<div class="status-badge" :class="`status-${task.status}`">
{{ getStatusText(task.status) }}
</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>
<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>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 全部历史记录抽屉 -->
<a-drawer
v-model:open="showAllHistory"
title="全部创作历史"
placement="right"
width="600px"
class="history-drawer"
>
<a-list
:data-source="allHistoryList"
:loading="allHistoryLoading"
:pagination="pagination"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #avatar>
<div class="list-preview">
<img
v-if="item.status === 'completed' && item.previewUrl"
:src="getPreviewUrl(item)"
alt="预览"
/>
<div v-else class="list-preview-placeholder">
<LoadingOutlined
v-if="['pending', 'processing'].includes(item.status)"
spin
/>
<ExclamationCircleOutlined
v-else-if="['failed', 'timeout'].includes(item.status)"
/>
<FileImageOutlined v-else />
</div>
</div>
</template>
<template #title>
<span class="list-desc">{{ item.inputContent }}</span>
</template>
<template #description>
<div class="list-meta">
<a-tag :color="getStatusColor(item.status)">{{
getStatusText(item.status)
}}</a-tag>
<span>{{ formatTime(item.createTime) }}</span>
</div>
</template>
</a-list-item-meta>
<template #actions>
<a v-if="item.status === 'completed'" @click="handlePreview(item)"
>预览</a
>
<a
v-if="
['failed', 'timeout'].includes(item.status) &&
item.retryCount < 3
"
@click="handleRetry(item)"
>重试</a
>
<a class="danger-link" @click="handleDelete(item)">删除</a>
</template>
</a-list-item>
</template>
</a-list>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue"
import { useRouter } from "vue-router"
import { message, Modal } from "ant-design-vue"
import {
ReloadOutlined,
PictureOutlined,
ThunderboltOutlined,
LoadingOutlined,
ExclamationCircleOutlined,
FileImageOutlined,
EyeOutlined,
DeleteOutlined,
ArrowRightOutlined,
ClockCircleOutlined,
} from "@ant-design/icons-vue"
import type { UploadFile } from "ant-design-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 inputTypeOptions = [
{ label: "文生3D", value: "text" },
{ label: "图生3D", value: "image" },
]
// 示例提示词
const samplePrompts = [
["啄木鸟", "尖锐的嘴", "金黄色"],
["可爱的猫咪", "卡通风格", "蓝色眼睛"],
["机器人", "金属质感", "未来风格"],
["中式花瓶", "青花瓷", "精致纹理"],
["小恐龙", "Q版造型", "绿色皮肤"],
["宇航员", "太空服", "写实风格"],
]
// 状态
const inputType = ref<"text" | "image">("text")
const textContent = ref("")
const imageFileList = ref<UploadFile[]>()
const imageUrl = ref("")
const generating = ref(false)
const currentSampleIndex = ref(0)
// 历史记录
const historyList = ref<AI3DTask[]>([])
const historyLoading = ref(false)
const showAllHistory = ref(false)
const allHistoryList = ref<AI3DTask[]>([])
const allHistoryLoading = ref(false)
const allHistoryTotal = ref(0)
const allHistoryPage = ref(1)
// 轮询定时器
let pollingTimer: number | 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
}
})
const pagination = computed(() => ({
current: allHistoryPage.value,
pageSize: 10,
total: allHistoryTotal.value,
onChange: (page: number) => {
allHistoryPage.value = page
fetchAllHistory()
},
}))
// 刷新示例
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 = await uploadFile(file)
imageUrl.value = result.url
message.success("图片上传成功")
} catch (error) {
message.error("图片上传失败")
return false
}
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 task = await createAI3DTask({
inputType: inputType.value,
inputContent: content,
})
// 清空输入
if (inputType.value === "text") {
textContent.value = ""
} else {
imageFileList.value = []
imageUrl.value = ""
}
// 跳转到生成页面
router.push({
name: "AI3DGenerate",
params: { taskId: task.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 })
historyList.value = res.list || []
} catch (error) {
console.error("获取历史记录失败:", error)
} finally {
historyLoading.value = false
}
}
// 获取全部历史记录
const fetchAllHistory = async () => {
allHistoryLoading.value = true
try {
const res = await getAI3DTasks({ page: allHistoryPage.value, pageSize: 10 })
allHistoryList.value = res.list || []
allHistoryTotal.value = res.total || 0
} catch (error) {
console.error("获取全部历史记录失败:", error)
} finally {
allHistoryLoading.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) {
const viewerUrl = `/model-viewer?url=${encodeURIComponent(task.resultUrl)}`
window.open(viewerUrl, "_blank")
}
}
// 查看任务详情
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()
if (showAllHistory.value) {
fetchAllHistory()
}
} catch (error) {
message.error("删除失败")
}
},
})
}
// 获取预览图URL
const getPreviewUrl = (task: AI3DTask) => {
if (task.previewUrl) {
return task.previewUrl.startsWith("http")
? task.previewUrl
: task.previewUrl
}
return ""
}
// 获取状态颜色
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
pending: "default",
processing: "processing",
completed: "success",
failed: "error",
timeout: "warning",
}
return colors[status] || "default"
}
// 获取状态文本
const getStatusText = (status: string) => {
const texts: Record<string, string> = {
pending: "等待中",
processing: "生成中",
completed: "已完成",
failed: "失败",
timeout: "超时",
}
return texts[status] || status
}
// 格式化时间
const formatTime = (time: string) => {
return dayjs(time).format("MM-DD HH:mm")
}
// 页面加载
onMounted(() => {
fetchHistory()
// 检查是否有处理中的任务,有则开启轮询
const hasProcessing = historyList.value.some(
(t) => t.status === "pending" || t.status === "processing"
)
if (hasProcessing) {
startPolling()
}
})
// 页面卸载
onUnmounted(() => {
stopPolling()
})
</script>
<style scoped lang="scss">
// ==========================================
// Energetic Modern Color Palette
// ==========================================
$primary: #7c3aed;
$primary-light: #a78bfa;
$primary-dark: #5b21b6;
$secondary: #06b6d4;
$accent: #f43f5e;
$success: #10b981;
$background: #0a0a12;
$surface: #12121e;
$surface-light: #1a1a2e;
$text: #e2e8f0;
$text-muted: #94a3b8;
// Gradients
$gradient-primary: linear-gradient(135deg, $primary 0%, #ec4899 100%);
$gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
.ai-3d-container {
display: flex;
min-height: calc(100vh - 64px);
background: $background;
position: relative;
overflow: hidden;
}
// ==========================================
// Animated Background
// ==========================================
.bg-animation {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
}
.bg-gradient {
position: absolute;
border-radius: 50%;
filter: blur(120px);
opacity: 0.4;
animation: float 25s ease-in-out infinite;
&.bg-gradient-1 {
width: 800px;
height: 800px;
background: $primary;
top: -300px;
left: -200px;
animation-delay: 0s;
}
&.bg-gradient-2 {
width: 600px;
height: 600px;
background: $secondary;
bottom: -200px;
right: -150px;
animation-delay: -8s;
}
&.bg-gradient-3 {
width: 500px;
height: 500px;
background: $accent;
top: 40%;
right: 30%;
animation-delay: -16s;
opacity: 0.3;
}
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba($primary, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba($primary, 0.03) 1px, transparent 1px);
background-size: 50px 50px;
animation: gridMove 20s linear infinite;
}
@keyframes float {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(50px, -50px) scale(1.1); }
50% { transform: translate(-30px, 30px) scale(0.9); }
75% { transform: translate(-50px, -30px) scale(1.05); }
}
@keyframes gridMove {
0% { transform: translate(0, 0); }
100% { transform: translate(50px, 50px); }
}
// ==========================================
// Left Panel
// ==========================================
.left-panel {
width: 380px;
height: 100%;
background: rgba($surface, 0.8);
backdrop-filter: blur(40px);
border-right: 1px solid rgba($primary, 0.1);
display: flex;
flex-direction: column;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.panel-logo {
padding: 24px;
display: flex;
align-items: center;
gap: 16px;
border-bottom: 1px solid rgba($primary, 0.1);
.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;
border-bottom: 1px solid rgba($primary, 0.1);
: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;
}
}
.input-hint {
font-size: 13px;
color: $text-muted;
margin-bottom: 16px;
line-height: 1.6;
}
.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;
&::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;
}
:deep(.ant-input-data-count) {
color: $text-muted;
}
}
.sample-section {
margin-top: 20px;
}
.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: $secondary;
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($secondary, 0.2);
border-color: $secondary;
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;
&:hover {
border-color: $secondary !important;
background: rgba($surface-light, 0.8) !important;
}
.upload-icon {
color: $primary-light;
font-size: 56px;
margin-bottom: 12px;
}
.upload-text {
color: $text;
font-size: 15px;
margin-bottom: 8px;
}
.upload-hint {
color: $text-muted;
font-size: 12px;
}
}
}
.panel-footer {
padding: 24px;
border-top: 1px solid rgba($primary, 0.1);
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;
}
.intro-section {
padding: 48px;
background: rgba($surface, 0.4);
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba($primary, 0.1);
}
.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 {
font-size: 36px;
font-weight: 700;
color: $text;
margin-bottom: 16px;
line-height: 1.3;
background: $gradient-primary;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.intro-desc {
font-size: 16px;
color: $text-muted;
margin-bottom: 32px;
line-height: 1.6;
}
.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: rgba($surface-light, 0.4);
backdrop-filter: blur(10px);
border: 1px solid rgba($primary, 0.1);
border-radius: 16px;
transition: all 0.3s;
&:hover {
background: rgba($surface-light, 0.6);
border-color: rgba($primary, 0.3);
transform: translateY(-4px);
}
}
.feature-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
&.gradient-1 { background: linear-gradient(135deg, $primary 0%, #ec4899 100%); }
&.gradient-2 { background: linear-gradient(135deg, $secondary 0%, #10b981 100%); }
&.gradient-3 { background: linear-gradient(135deg, $accent 0%, #f59e0b 100%); }
&.gradient-4 { background: linear-gradient(135deg, #8b5cf6 0%, $secondary 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;
padding: 32px 48px;
overflow-y: auto;
background: rgba($background, 0.5);
&::-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: $secondary;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.3s;
&:hover {
color: $primary-light;
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: $secondary;
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: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 20px;
}
.history-card {
background: rgba($surface, 0.6);
backdrop-filter: blur(20px);
border-radius: 16px;
overflow: hidden;
cursor: pointer;
transition: all 0.4s;
border: 1px solid rgba($primary, 0.1);
position: relative;
&:hover {
transform: translateY(-8px);
border-color: rgba($primary, 0.3);
.card-glow {
opacity: 0.6;
}
}
}
.card-glow {
position: absolute;
inset: -2px;
background: $gradient-primary;
border-radius: 18px;
z-index: -1;
opacity: 0;
filter: blur(20px);
transition: opacity 0.4s;
}
.card-preview {
height: 160px;
background: rgba($surface-light, 0.8);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s;
}
.history-card:hover & .preview-image {
transform: scale(1.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 {
color: $accent;
}
}
.loading-dots {
display: flex;
gap: 6px;
span {
width: 8px;
height: 8px;
background: $secondary;
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($secondary, 0.2);
color: $secondary;
border-color: rgba($secondary, 0.3);
}
&.status-failed,
&.status-timeout {
background: rgba($accent, 0.2);
color: $accent;
border-color: rgba($accent, 0.3);
}
}
.card-info {
padding: 16px;
}
.card-desc {
font-size: 14px;
color: $text;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 12px;
font-weight: 500;
}
.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-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;
align-items: center;
justify-content: center;
color: $primary-light;
cursor: pointer;
transition: all 0.3s;
&:hover {
background: rgba($secondary, 0.2);
border-color: $secondary;
color: $secondary;
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;
}
}
}
// ==========================================
// Drawer
// ==========================================
.history-drawer {
:deep(.ant-drawer-header) {
background: $surface;
border-bottom-color: rgba($primary, 0.1);
}
:deep(.ant-drawer-body) {
background: $background;
}
}
.list-preview {
width: 72px;
height: 72px;
background: $surface-light;
border-radius: 12px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.list-preview-placeholder {
color: $text-muted;
font-size: 24px;
}
}
.list-desc {
max-width: 340px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: $text;
}
.list-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: $text-muted;
}
.danger-link {
color: $accent;
&:hover {
color: #ff7875;
}
}
// ==========================================
// Responsive
// ==========================================
@media (max-width: 1024px) {
.left-panel {
width: 320px;
}
.intro-features {
grid-template-columns: 1fr;
}
.history-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
}
@media (max-width: 768px) {
.ai-3d-container {
flex-direction: column;
}
.left-panel {
width: 100%;
border-right: none;
border-bottom: 1px solid rgba($primary, 0.1);
}
.intro-section {
padding: 32px 24px;
}
.history-section {
padding: 24px;
}
.history-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
}
</style>