973 lines
22 KiB
Vue
973 lines
22 KiB
Vue
|
|
<template>
|
|||
|
|
<div class="ai-3d-container">
|
|||
|
|
<!-- 左侧生成栏 -->
|
|||
|
|
<div class="left-panel">
|
|||
|
|
<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-hint">
|
|||
|
|
请输入想要生成的内容,建议以单体为主。例如:一只棕色的猫雕塑,尾巴卷曲,卡通风格
|
|||
|
|
</div>
|
|||
|
|
<a-textarea
|
|||
|
|
v-model:value="textContent"
|
|||
|
|
placeholder="请输入描述..."
|
|||
|
|
:rows="6"
|
|||
|
|
:maxlength="150"
|
|||
|
|
show-count
|
|||
|
|
class="text-input"
|
|||
|
|
/>
|
|||
|
|
<div class="sample-prompts">
|
|||
|
|
<ReloadOutlined class="refresh-icon" @click="refreshSamples" />
|
|||
|
|
<span
|
|||
|
|
v-for="(sample, index) in currentSamples"
|
|||
|
|
:key="index"
|
|||
|
|
class="sample-tag"
|
|||
|
|
@click="applySample(sample)"
|
|||
|
|
>
|
|||
|
|
{{ sample }}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 图生3D上传 -->
|
|||
|
|
<div v-else class="image-input-section">
|
|||
|
|
<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="ant-upload-drag-icon">
|
|||
|
|
<PictureOutlined />
|
|||
|
|
</p>
|
|||
|
|
<p class="ant-upload-text">点击或拖拽图片到此处</p>
|
|||
|
|
<p class="ant-upload-hint">支持 JPG、PNG 格式</p>
|
|||
|
|
</a-upload-dragger>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="panel-footer">
|
|||
|
|
<a-button
|
|||
|
|
type="primary"
|
|||
|
|
size="large"
|
|||
|
|
block
|
|||
|
|
:loading="generating"
|
|||
|
|
:disabled="!canGenerate"
|
|||
|
|
@click="handleGenerate"
|
|||
|
|
>
|
|||
|
|
<template #icon><ThunderboltOutlined /></template>
|
|||
|
|
立即生成
|
|||
|
|
</a-button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 右侧内容区 -->
|
|||
|
|
<div class="right-panel">
|
|||
|
|
<!-- 介绍区 -->
|
|||
|
|
<div class="intro-section">
|
|||
|
|
<h1 class="intro-title">用一句话、一张图,生成你的 3D 世界</h1>
|
|||
|
|
<div class="intro-features">
|
|||
|
|
<div class="feature-item">
|
|||
|
|
<span class="feature-icon">✨</span>
|
|||
|
|
<span class="feature-text"
|
|||
|
|
>AI 智能建模:输入文字描述或上传参考图,自动生成 3D 模型</span
|
|||
|
|
>
|
|||
|
|
</div>
|
|||
|
|
<div class="feature-item">
|
|||
|
|
<span class="feature-icon">👁</span>
|
|||
|
|
<span class="feature-text"
|
|||
|
|
>在线实时预览:支持模型旋转、缩放与查看细节</span
|
|||
|
|
>
|
|||
|
|
</div>
|
|||
|
|
<div class="feature-item">
|
|||
|
|
<span class="feature-icon">📁</span>
|
|||
|
|
<span class="feature-text"
|
|||
|
|
>作品统一管理:所有建模成果自动保存至个人作品库</span
|
|||
|
|
>
|
|||
|
|
</div>
|
|||
|
|
<div class="feature-item">
|
|||
|
|
<span class="feature-icon">🔄</span>
|
|||
|
|
<span class="feature-text"
|
|||
|
|
>持续创作迭代:支持基于已有作品再次生成与优化</span
|
|||
|
|
>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<a class="intro-action" @click="focusInput"> 立即开始建模 → </a>
|
|||
|
|
<p class="intro-subtitle">从一个想法开始,让 3D 创作变得更简单</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 创作历史区 -->
|
|||
|
|
<div class="history-section">
|
|||
|
|
<div class="history-header">
|
|||
|
|
<h2 class="history-title">创作历史</h2>
|
|||
|
|
<a class="view-all" @click="showAllHistory = true">查看全部 ></a>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="historyLoading" class="history-loading">
|
|||
|
|
<a-spin />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-else-if="historyList.length === 0" class="history-empty">
|
|||
|
|
<a-empty description="暂无创作记录,开始你的第一次创作吧" />
|
|||
|
|
</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-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"
|
|||
|
|
>
|
|||
|
|
<LoadingOutlined spin />
|
|||
|
|
<span>生成中...</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>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-info">
|
|||
|
|
<div class="card-desc" :title="task.inputContent">
|
|||
|
|
{{ task.inputContent }}
|
|||
|
|
</div>
|
|||
|
|
<div class="card-meta">
|
|||
|
|
<span class="card-time">{{ formatTime(task.createTime) }}</span>
|
|||
|
|
<div class="card-actions" @click.stop>
|
|||
|
|
<a-tooltip v-if="task.status === 'completed'" title="预览">
|
|||
|
|
<EyeOutlined
|
|||
|
|
class="action-icon"
|
|||
|
|
@click="handlePreview(task)"
|
|||
|
|
/>
|
|||
|
|
</a-tooltip>
|
|||
|
|
<a-tooltip
|
|||
|
|
v-if="['failed', 'timeout'].includes(task.status)"
|
|||
|
|
title="重试"
|
|||
|
|
>
|
|||
|
|
<ReloadOutlined
|
|||
|
|
class="action-icon"
|
|||
|
|
:class="{ disabled: task.retryCount >= 3 }"
|
|||
|
|
@click="handleRetry(task)"
|
|||
|
|
/>
|
|||
|
|
</a-tooltip>
|
|||
|
|
<a-tooltip title="删除">
|
|||
|
|
<DeleteOutlined
|
|||
|
|
class="action-icon danger"
|
|||
|
|
@click="handleDelete(task)"
|
|||
|
|
/>
|
|||
|
|
</a-tooltip>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 全部历史记录抽屉 -->
|
|||
|
|
<a-drawer
|
|||
|
|
v-model:open="showAllHistory"
|
|||
|
|
title="全部创作历史"
|
|||
|
|
placement="right"
|
|||
|
|
width="600px"
|
|||
|
|
>
|
|||
|
|
<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 { message, Modal } from "ant-design-vue"
|
|||
|
|
import {
|
|||
|
|
ReloadOutlined,
|
|||
|
|
PictureOutlined,
|
|||
|
|
ThunderboltOutlined,
|
|||
|
|
LoadingOutlined,
|
|||
|
|
ExclamationCircleOutlined,
|
|||
|
|
FileImageOutlined,
|
|||
|
|
EyeOutlined,
|
|||
|
|
DeleteOutlined,
|
|||
|
|
} 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 dayjs from "dayjs"
|
|||
|
|
|
|||
|
|
// 输入类型选项
|
|||
|
|
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 (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
|
|||
|
|
|
|||
|
|
await createAI3DTask({
|
|||
|
|
inputType: inputType.value,
|
|||
|
|
inputContent: content,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
message.success("任务已提交,请等待生成完成")
|
|||
|
|
|
|||
|
|
// 清空输入
|
|||
|
|
if (inputType.value === "text") {
|
|||
|
|
textContent.value = ""
|
|||
|
|
} else {
|
|||
|
|
imageFileList.value = []
|
|||
|
|
imageUrl.value = ""
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 刷新历史记录
|
|||
|
|
fetchHistory()
|
|||
|
|
|
|||
|
|
// 开始轮询
|
|||
|
|
startPolling()
|
|||
|
|
} 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) => {
|
|||
|
|
if (task.status === "completed") {
|
|||
|
|
handlePreview(task)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 重试任务
|
|||
|
|
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">
|
|||
|
|
.ai-3d-container {
|
|||
|
|
display: flex;
|
|||
|
|
height: calc(100vh - 120px);
|
|||
|
|
min-height: 600px;
|
|||
|
|
background: #f5f5f5;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 左侧面板
|
|||
|
|
.left-panel {
|
|||
|
|
width: 320px;
|
|||
|
|
background: #1a1a2e;
|
|||
|
|
color: #fff;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.panel-header {
|
|||
|
|
padding: 20px;
|
|||
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|||
|
|
|
|||
|
|
:deep(.ant-segmented) {
|
|||
|
|
background: rgba(255, 255, 255, 0.1);
|
|||
|
|
|
|||
|
|
.ant-segmented-item {
|
|||
|
|
color: rgba(255, 255, 255, 0.7);
|
|||
|
|
|
|||
|
|
&-selected {
|
|||
|
|
background: #1890ff;
|
|||
|
|
color: #fff;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.panel-content {
|
|||
|
|
flex: 1;
|
|||
|
|
padding: 20px;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.input-hint {
|
|||
|
|
font-size: 13px;
|
|||
|
|
color: rgba(255, 255, 255, 0.6);
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
line-height: 1.6;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.text-input {
|
|||
|
|
background: rgba(255, 255, 255, 0.05);
|
|||
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|||
|
|
color: #fff;
|
|||
|
|
resize: none;
|
|||
|
|
|
|||
|
|
&::placeholder {
|
|||
|
|
color: rgba(255, 255, 255, 0.4);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
&:focus {
|
|||
|
|
border-color: #1890ff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
:deep(.ant-input-data-count) {
|
|||
|
|
color: rgba(255, 255, 255, 0.5);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.sample-prompts {
|
|||
|
|
display: flex;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 8px;
|
|||
|
|
margin-top: 12px;
|
|||
|
|
align-items: center;
|
|||
|
|
|
|||
|
|
.refresh-icon {
|
|||
|
|
color: rgba(255, 255, 255, 0.5);
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: color 0.3s;
|
|||
|
|
|
|||
|
|
&:hover {
|
|||
|
|
color: #1890ff;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.sample-tag {
|
|||
|
|
padding: 4px 12px;
|
|||
|
|
background: rgba(255, 255, 255, 0.1);
|
|||
|
|
border-radius: 16px;
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: rgba(255, 255, 255, 0.7);
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all 0.3s;
|
|||
|
|
|
|||
|
|
&:hover {
|
|||
|
|
background: rgba(24, 144, 255, 0.3);
|
|||
|
|
color: #fff;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.image-upload {
|
|||
|
|
:deep(.ant-upload-drag) {
|
|||
|
|
background: rgba(255, 255, 255, 0.05);
|
|||
|
|
border-color: rgba(255, 255, 255, 0.2);
|
|||
|
|
|
|||
|
|
&:hover {
|
|||
|
|
border-color: #1890ff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.ant-upload-drag-icon {
|
|||
|
|
color: rgba(255, 255, 255, 0.5);
|
|||
|
|
font-size: 48px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.ant-upload-text {
|
|||
|
|
color: rgba(255, 255, 255, 0.8);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.ant-upload-hint {
|
|||
|
|
color: rgba(255, 255, 255, 0.5);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.panel-footer {
|
|||
|
|
padding: 20px;
|
|||
|
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 右侧面板
|
|||
|
|
.right-panel {
|
|||
|
|
flex: 1;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 介绍区
|
|||
|
|
.intro-section {
|
|||
|
|
padding: 40px;
|
|||
|
|
background: #fff;
|
|||
|
|
border-bottom: 1px solid #e8e8e8;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.intro-title {
|
|||
|
|
font-size: 28px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: #1a1a2e;
|
|||
|
|
margin-bottom: 24px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.intro-features {
|
|||
|
|
margin-bottom: 24px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.feature-item {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
font-size: 14px;
|
|||
|
|
color: #666;
|
|||
|
|
|
|||
|
|
.feature-icon {
|
|||
|
|
margin-right: 12px;
|
|||
|
|
font-size: 16px;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.intro-action {
|
|||
|
|
display: inline-block;
|
|||
|
|
color: #1890ff;
|
|||
|
|
font-size: 16px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
margin-bottom: 8px;
|
|||
|
|
|
|||
|
|
&:hover {
|
|||
|
|
text-decoration: underline;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.intro-subtitle {
|
|||
|
|
color: #999;
|
|||
|
|
font-size: 13px;
|
|||
|
|
margin: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 历史记录区
|
|||
|
|
.history-section {
|
|||
|
|
flex: 1;
|
|||
|
|
padding: 24px 40px;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
background: #fafafa;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.history-header {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
margin-bottom: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.history-title {
|
|||
|
|
font-size: 18px;
|
|||
|
|
font-weight: 500;
|
|||
|
|
color: #1a1a2e;
|
|||
|
|
margin: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.view-all {
|
|||
|
|
color: #1890ff;
|
|||
|
|
cursor: pointer;
|
|||
|
|
|
|||
|
|
&:hover {
|
|||
|
|
text-decoration: underline;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.history-loading,
|
|||
|
|
.history-empty {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: center;
|
|||
|
|
align-items: center;
|
|||
|
|
min-height: 200px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.history-grid {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|||
|
|
gap: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.history-card {
|
|||
|
|
background: #fff;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all 0.3s;
|
|||
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|||
|
|
|
|||
|
|
&:hover {
|
|||
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|||
|
|
transform: translateY(-2px);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card-preview {
|
|||
|
|
height: 140px;
|
|||
|
|
background: #1a1a2e;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
|
|||
|
|
.preview-image {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
object-fit: cover;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-loading,
|
|||
|
|
.preview-failed,
|
|||
|
|
.preview-placeholder {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
color: rgba(255, 255, 255, 0.6);
|
|||
|
|
font-size: 24px;
|
|||
|
|
|
|||
|
|
span {
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview-failed {
|
|||
|
|
color: #ff4d4f;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card-info {
|
|||
|
|
padding: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card-desc {
|
|||
|
|
font-size: 13px;
|
|||
|
|
color: #333;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
overflow: hidden;
|
|||
|
|
text-overflow: ellipsis;
|
|||
|
|
margin-bottom: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card-meta {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card-time {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: #999;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card-actions {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 8px;
|
|||
|
|
|
|||
|
|
.action-icon {
|
|||
|
|
color: #666;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: color 0.3s;
|
|||
|
|
|
|||
|
|
&:hover {
|
|||
|
|
color: #1890ff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
&.danger:hover {
|
|||
|
|
color: #ff4d4f;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
&.disabled {
|
|||
|
|
color: #ccc;
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 抽屉列表样式
|
|||
|
|
.list-preview {
|
|||
|
|
width: 60px;
|
|||
|
|
height: 60px;
|
|||
|
|
background: #1a1a2e;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
|
|||
|
|
img {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
object-fit: cover;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.list-preview-placeholder {
|
|||
|
|
color: rgba(255, 255, 255, 0.5);
|
|||
|
|
font-size: 20px;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.list-desc {
|
|||
|
|
max-width: 300px;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
overflow: hidden;
|
|||
|
|
text-overflow: ellipsis;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.list-meta {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 12px;
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: #999;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.danger-link {
|
|||
|
|
color: #ff4d4f;
|
|||
|
|
|
|||
|
|
&:hover {
|
|||
|
|
color: #ff7875;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|