remove AI3D module files

This commit is contained in:
RockYang
2025-09-06 12:38:49 +08:00
parent 1ff0636745
commit a60ffca135
23 changed files with 0 additions and 4475 deletions

View File

@@ -1,145 +0,0 @@
.admin-threed-jobs {
padding: 20px;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h2 {
margin: 0;
color: var(--theme-text-color-primary);
}
}
.search-section {
background: var(--card-bg);
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: var(--el-box-shadow, 0 2px 4px rgba(0, 0, 0, 0.1));
.el-form-item {
margin-bottom: 0;
.el-select__wrapper {
height: 36px;
line-height: 36px;
}
}
}
.stats-section {
margin-bottom: 20px;
.stat-card {
background: var(--card-bg);
padding: 20px;
border-radius: 8px;
box-shadow: var(--el-box-shadow, 0 2px 4px rgba(0, 0, 0, 0.1));
display: flex;
align-items: center;
gap: 16px;
.stat-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
i {
font-size: 24px;
color: white;
}
&.pending {
background: #e6a23c;
}
&.processing {
background: #409eff;
}
&.completed {
background: #67c23a;
}
&.failed {
background: #f56c6c;
}
}
.stat-content {
.stat-number {
font-size: 24px;
font-weight: bold;
color: var(--theme-text-color-primary);
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: var(--theme-text-color-secondary);
}
}
}
}
.table-section {
background: var(--card-bg);
border-radius: 8px;
box-shadow: var(--el-box-shadow, 0 2px 4px rgba(0, 0, 0, 0.1));
overflow: hidden;
}
.pagination-section {
padding: 20px;
text-align: center;
}
.task-detail {
.task-params,
.task-result,
.task-error {
margin-top: 20px;
h4 {
margin: 0 0 12px 0;
color: var(--theme-text-color-primary);
font-size: 16px;
}
.params-content {
background: var(--card-bg);
padding: 12px;
border-radius: 6px;
border: 1px solid var(--line-box);
}
}
.result-links {
display: flex;
gap: 12px;
}
}
.preview-container {
text-align: center;
}
// 3D 模型预览弹窗
.model-preview-dialog {
.el-dialog__body {
padding: 0 0 16px 0;
background: var(--el-bg-color-overlay);
}
.model-preview-wrapper {
height: calc(100vh - 125px);
padding: 12px;
background: var(--card-bg);
}
}
}

View File

@@ -1,634 +0,0 @@
.page-threed {
display: flex;
height: 100vh;
background: var(--theme-bg-all);
}
.params-panel {
width: 400px;
background: var(--card-bg);
border-right: 1px solid var(--line-box);
padding: 20px;
overflow-y: auto;
}
.platform-tabs {
margin-bottom: 20px;
}
.platform-info {
display: flex;
align-items: center;
gap: 8px;
i {
font-size: 18px;
color: var(--text-color-primary);
}
}
.params-container {
.param-line {
margin-bottom: 16px;
&.pt {
margin-top: 20px;
}
.label {
display: block;
font-weight: 600;
color: var(--theme-text-color-primary);
}
}
.advanced-toggle-btn {
padding: 0;
font-size: 14px;
color: var(--text-color-primary);
border: none;
background: none;
display: flex;
align-items: center;
gap: 4px;
&:hover {
color: var(--text-color-primary);
background: var(--el-color-primary-light-9);
border-radius: 4px;
}
}
.advanced-params {
padding: 10px 16px;
border-radius: 8px;
border-left: 4px solid var(--text-color-primary);
}
}
.power-display {
display: flex;
align-items: center;
gap: 8px;
.power-value {
font-size: 24px;
font-weight: bold;
color: var(--text-color-primary);
}
.power-unit {
color: var(--theme-text-color-secondary);
}
}
.generate-section {
margin-top: 30px;
.generate-btn {
width: 100%;
height: 44px;
font-size: 16px;
}
}
.content-panel {
flex: 1;
padding: 20px;
overflow-y: auto;
}
.task-list {
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding: 20px;
background: var(--panel-bg);
border-radius: 12px;
color: var(--theme-text-color-primary);
h3 {
margin: 0;
color: var(--theme-text-color-primary);
font-size: 24px;
font-weight: 600;
display: flex;
align-items: center;
&::before {
content: '';
width: 4px;
height: 24px;
background: var(--theme-text-color-primary);
margin-right: 12px;
border-radius: 2px;
}
}
.el-button {
// background: rgba(255, 255, 255, 0.2);
// border: 1px solid rgba(255, 255, 255, 0.3);
// color: white;
// &:hover {
// background: rgba(255, 255, 255, 0.3);
// border-color: rgba(255, 255, 255, 0.5);
// }
}
}
}
.task-items {
.task-card {
background: var(--card-bg);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
border: 1px solid var(--line-box);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
position: relative;
overflow: hidden;
&:hover {
transform: translateY(-5px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
&.task-card-completed {
border-left: 4px solid #67c23a;
background: var(--el-fill-color-light);
}
&.task-card-processing {
border-left: 4px solid var(--text-color-primary);
background: var(--el-fill-color-light);
}
&.task-card-failed {
border-left: 4px solid #f56c6c;
background: var(--el-fill-color-light);
}
&.task-card-default {
border-left: 4px solid #909399;
background: var(--el-fill-color-light);
}
}
.task-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px dashed #eee;
.task-info {
display: flex;
align-items: center;
gap: 10px;
.task-id {
font-size: 18px;
font-weight: bold;
color: var(--theme-text-color-primary);
display: flex;
align-items: center;
i {
font-size: 20px;
color: var(--text-color-primary);
}
}
.task-platform {
font-size: 14px;
color: var(--theme-text-color-secondary);
display: flex;
align-items: center;
i {
font-size: 16px;
color: var(--text-color-primary);
}
}
}
.task-status-wrapper {
display: flex;
align-items: center;
gap: 10px;
.task-status {
padding: 4px 10px;
border-radius: 6px;
font-size: 13px;
font-weight: bold;
display: flex;
align-items: center;
&.pending {
background: var(--el-fill-color-light);
color: #e6a23c;
}
&.processing {
background: var(--el-fill-color-light);
color: #67c23a;
}
&.completed {
background: var(--el-fill-color-light);
color: #67c23a;
}
&.failed {
background: var(--el-fill-color-light);
color: #f56c6c;
}
i {
font-size: 14px;
margin-right: 4px;
}
}
.task-power {
font-size: 14px;
color: var(--theme-text-color-secondary);
display: flex;
align-items: center;
i {
font-size: 14px;
margin-right: 4px;
color: var(--text-color-primary);
}
}
}
}
.task-card-content {
display: flex;
gap: 16px;
margin-bottom: 12px;
.task-preview {
flex: 1;
position: relative;
border-radius: 8px;
overflow: hidden;
background: var(--chat-wel-bg);
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-text-color-secondary);
min-height: 200px;
max-height: 200px;
min-width: 200px;
max-width: 200px;
border: 1px solid var(--line-box);
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
position: relative;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-text-color-primary);
font-size: 24px;
opacity: 0;
transition: opacity 0.3s ease;
&:hover {
opacity: 1;
}
}
}
.input-image {
width: 100%;
height: 100%;
position: relative;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.input-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
opacity: 0;
transition: opacity 0.3s ease;
&:hover {
opacity: 1;
}
}
}
.prompt-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--theme-text-color-secondary);
i {
font-size: 48px;
margin-bottom: 12px;
}
span {
font-size: 14px;
}
}
}
.task-details {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
.task-model {
font-size: 16px;
font-weight: bold;
color: var(--theme-text-color-primary);
display: flex;
align-items: center;
i {
font-size: 18px;
margin-right: 6px;
color: var(--text-color-primary);
}
}
.task-prompt {
font-size: 14px;
color: var(--theme-text-color-secondary);
display: flex;
align-items: center;
margin-top: 4px;
i {
font-size: 14px;
margin-right: 6px;
color: var(--theme-text-color-secondary);
}
}
.task-params {
font-size: 14px;
color: var(--theme-text-color-secondary);
display: flex;
align-items: center;
margin-top: 4px;
i {
font-size: 14px;
margin-right: 6px;
color: var(--theme-text-color-secondary);
}
}
.task-time {
font-size: 12px;
color: var(--theme-text-color-secondary);
display: flex;
align-items: center;
margin-top: 4px;
i {
font-size: 12px;
margin-right: 6px;
}
}
.task-error {
font-size: 12px;
color: #f56c6c;
display: flex;
align-items: center;
margin-top: 4px;
i {
font-size: 12px;
margin-right: 6px;
}
}
}
}
.task-card-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 12px;
border-top: 1px dashed var(--line-box);
.task-actions {
display: flex;
gap: 8px;
.action-btn {
padding: 6px 12px;
font-size: 13px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
&.preview-btn {
background: var(--text-color-primary);
// color: var(--theme-text-color-primary);
border: 1px solid var(--text-color-primary);
&:hover {
background: var(--text-color-primary);
border-color: var(--border-active);
}
}
&.download-btn {
background: #67c23a;
// color: var(--theme-text-color-primary);
border: 1px solid #67c23a;
&:hover {
background: #85ce61;
border-color: #85ce61;
}
}
&.delete-btn {
background: #f56c6c;
// color: var(--theme-text-color-primary);
border: 1px solid #f56c6c;
&:hover {
background: #f78989;
border-color: #f78989;
}
}
&.processing-btn {
background: #909399;
// color: var(--theme-text-color-primary);
border: 1px solid #909399;
cursor: not-allowed;
}
}
}
}
.empty-state {
width: 100%;
height: 200px;
background: var(--chat-wel-bg);
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--theme-text-color-secondary);
font-size: 14px;
i {
font-size: 48px;
margin-bottom: 12px;
}
}
}
.pagination {
display: flex;
justify-content: center;
margin-top: 20px;
}
.preview-container {
width: 100%;
height: calc(100vh - 125px);
background: var(--chat-wel-bg);
border-radius: 8px;
position: relative;
.three-container {
width: 100%;
height: 100%;
background: var(--chat-wel-bg);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-text-color-secondary);
}
.preview-placeholder {
width: 100%;
min-height: 500px;
background: var(--chat-wel-bg);
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--theme-text-color-secondary);
i {
font-size: 48px;
margin-bottom: 12px;
color: var(--theme-text-color-secondary);
}
p {
margin: 0;
font-size: 14px;
}
}
}
@media (max-width: 768px) {
.page-threed {
flex-direction: column;
}
.params-panel {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--line-box);
}
.task-card-content {
flex-direction: column;
gap: 12px;
}
.task-preview {
max-width: 100%;
min-height: 150px;
}
.task-card-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.task-status-wrapper {
align-self: flex-end;
}
}
@media (max-width: 480px) {
.task-card {
padding: 12px;
}
.task-actions {
flex-wrap: wrap;
justify-content: center;
}
.action-btn {
flex: 1;
min-width: 80px;
}
}

View File

@@ -1,562 +0,0 @@
<template>
<div class="three-d-preview">
<div ref="container" class="preview-container"></div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-overlay">
<div class="loading-content">
<el-icon class="is-loading"><Loading /></el-icon>
<p>加载3D模型中...</p>
<div v-if="loadingProgress > 0" class="loading-progress">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: loadingProgress + '%' }"></div>
</div>
<span class="progress-text">{{ loadingProgress.toFixed(1) }}%</span>
</div>
</div>
</div>
<!-- 错误状态 -->
<div v-if="error" class="error-overlay">
<div class="error-content">
<el-icon><Warning /></el-icon>
<p>{{ error }}</p>
<el-button size="small" @click="retryLoad">重试</el-button>
</div>
</div>
</div>
</template>
<script setup>
import { Loading, Warning } from '@element-plus/icons-vue'
import { ElButton, ElIcon } from 'element-plus'
import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'
import { STLLoader } from 'three/addons/loaders/STLLoader.js'
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
// Props
const props = defineProps({
modelUrl: {
type: String,
required: false,
},
modelType: {
type: String,
default: 'glb',
},
})
// 响应式数据
const container = ref(null)
const loading = ref(true)
const error = ref('')
const loadingProgress = ref(0)
const modelType = computed(() => {
if (props.modelType) {
return props.modelType.toLowerCase()
}
// 从模型URL中获取类型
if (props.modelUrl) {
const url = new URL(props.modelUrl)
return url.pathname.split('.').pop()
}
return 'glb'
})
// Three.js 相关变量
let scene, camera, renderer, controls, model, mixer, clock
let animationId
let baseScale = 1 // 存储基础缩放值
// 初始化Three.js场景
const initThreeJS = () => {
if (!container.value) {
console.error('ThreeDPreview: 容器元素不存在')
return
}
// 创建场景
scene = new THREE.Scene()
scene.background = new THREE.Color(0x2d2d2d) // 深灰色背景,匹配截图效果
// 获取容器尺寸,完全自适应父容器
const containerRect = container.value.getBoundingClientRect()
const width = containerRect.width || 400
const height = containerRect.height || 300
// 创建相机 - 参考截图的视角(稍微俯视,从左上角观察)
camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000)
camera.position.set(3, 3, 3) // 从左上角俯视角度
// 创建渲染器
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
preserveDrawingBuffer: true,
})
renderer.setSize(width, height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.shadowMap.enabled = false // 禁用阴影
renderer.outputColorSpace = THREE.SRGBColorSpace
// 提升曝光度让模型更加高亮
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 2.2
// 添加到容器
container.value.appendChild(renderer.domElement)
// 创建控制器
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.05
// 添加光源
addLights()
// 添加地面
addGround()
// 添加坐标轴辅助线
addAxesHelper()
// 创建时钟
clock = new THREE.Clock()
// 开始渲染循环
animate()
// 监听窗口大小变化
window.addEventListener('resize', onWindowResize)
}
//
// 添加光源 - 高亮显示模型,无阴影效果
const addLights = () => {
// 强环境光 - 提供整体高亮照明
const ambientLight = new THREE.AmbientLight(0xffffff, 1.0)
scene.add(ambientLight)
// 主方向光 - 从前上方照射,高亮度无阴影
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.8)
directionalLight.position.set(5, 8, 5)
directionalLight.castShadow = false
scene.add(directionalLight)
// 补充光源 - 从左侧照射,填充光照
const fillLight = new THREE.DirectionalLight(0xffffff, 1.2)
fillLight.position.set(-5, 4, 3)
fillLight.castShadow = false
scene.add(fillLight)
// 背景光 - 从背后照射,增加轮廓高亮
const rimLight = new THREE.DirectionalLight(0xffffff, 1.0)
rimLight.position.set(0, 3, -5)
rimLight.castShadow = false
scene.add(rimLight)
// 顶部光源 - 增加顶部高亮
const topLight = new THREE.DirectionalLight(0xffffff, 0.8)
topLight.position.set(0, 10, 0)
topLight.castShadow = false
scene.add(topLight)
}
// 添加地面网格 - 简洁网格,无阴影
const addGround = () => {
// 创建网格辅助线 - 使用深色线条
const gridHelper = new THREE.GridHelper(20, 20, 0x555555, 0x555555)
gridHelper.position.y = 0
scene.add(gridHelper)
// 简单透明地面平面
const groundGeometry = new THREE.PlaneGeometry(20, 20)
const groundMaterial = new THREE.MeshBasicMaterial({
color: 0x404040,
transparent: true,
opacity: 0.1,
})
const ground = new THREE.Mesh(groundGeometry, groundMaterial)
ground.rotation.x = -Math.PI / 2
ground.position.y = -0.01
scene.add(ground)
}
// 添加坐标轴辅助线 - 匹配截图样式
const addAxesHelper = () => {
const axesHelper = new THREE.AxesHelper(2)
scene.add(axesHelper)
}
//
//
// 加载3D模型
const loadModel = async () => {
if (!props.modelUrl) {
console.warn('ThreeDPreview: 没有提供模型URL')
return
}
try {
loading.value = true
loadingProgress.value = 0
error.value = ''
// 清除现有模型
if (model) {
scene.remove(model)
model = null
}
let loadedModel
switch (modelType.value) {
case 'glb':
case 'gltf':
loadedModel = await loadGLTF(props.modelUrl)
break
case 'obj':
loadedModel = await loadOBJ(props.modelUrl)
break
case 'stl':
loadedModel = await loadSTL(props.modelUrl)
break
default:
throw new Error(`不支持的模型格式: ${modelType.value}`)
}
if (loadedModel) {
model = loadedModel
scene.add(model)
// 计算模型边界并调整相机位置
const box = new THREE.Box3().setFromObject(model)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
// 调整模型位置到原点
model.position.sub(center)
// 计算并保存基础缩放值
const maxDim = Math.max(size.x, size.y, size.z)
baseScale = maxDim > 0 ? 2 / maxDim : 1
// 应用初始缩放
model.scale.setScalar(baseScale)
// 根据模型大小调整相机距离 - 保持截图中的俯视角度
const cameraDistance = maxDim > 0 ? maxDim * 2 : 5
// 设置相机位置 - 匹配截图中的正面稍俯视角度
camera.position.set(cameraDistance * 0.3, cameraDistance * 0.4, cameraDistance * 1.2)
camera.lookAt(0, 0, 0)
if (controls) {
controls.target.set(0, 0, 0)
controls.update()
}
// 移除阴影设置,让模型高亮显示
model.traverse((child) => {
if (child.isMesh) {
child.castShadow = false
child.receiveShadow = false
// 如果材质支持,增加发光效果
if (child.material) {
child.material.emissive = new THREE.Color(0x111111) // 轻微发光
}
}
})
} else {
console.warn('ThreeDPreview: 模型加载返回空值')
}
loading.value = false
loadingProgress.value = 100
} catch (err) {
console.error('ThreeDPreview: 加载3D模型失败:', err)
error.value = `加载模型失败: ${err.message}`
loading.value = false
loadingProgress.value = 0
}
}
// 加载GLTF/GLB模型
const loadGLTF = (url) => {
return new Promise((resolve, reject) => {
const loader = new GLTFLoader()
loader.load(
url,
(gltf) => {
const model = gltf.scene
// 处理动画
if (gltf.animations && gltf.animations.length > 0) {
mixer = new THREE.AnimationMixer(model)
const action = mixer.clipAction(gltf.animations[0])
action.play()
}
resolve(model)
},
(xhr) => {
if (xhr.total > 0) {
const percent = (xhr.loaded / xhr.total) * 100
loadingProgress.value = percent
}
},
(error) => {
console.error('ThreeDPreview: GLTF模型加载失败', error)
reject(error)
}
)
})
}
// 加载OBJ模型
const loadOBJ = (url) => {
return new Promise((resolve, reject) => {
const loader = new OBJLoader()
loader.load(
url,
(obj) => {
// 为OBJ模型添加默认材质
obj.traverse((child) => {
if (child.isMesh) {
child.material = new THREE.MeshLambertMaterial({
color: 0x888888,
})
}
})
resolve(obj)
},
undefined,
reject
)
})
}
// 加载STL模型
const loadSTL = (url) => {
return new Promise((resolve, reject) => {
const loader = new STLLoader()
loader.load(
url,
(geometry) => {
const material = new THREE.MeshLambertMaterial({
color: 0x888888,
})
const mesh = new THREE.Mesh(geometry, material)
resolve(mesh)
},
undefined,
reject
)
})
}
//
// 重试加载
const retryLoad = () => {
loadModel()
}
// 窗口大小变化处理
const onWindowResize = () => {
if (!container.value || !camera || !renderer) return
const containerRect = container.value.getBoundingClientRect()
const width = containerRect.width || 400
const height = containerRect.height || 300
camera.aspect = width / height
camera.updateProjectionMatrix()
renderer.setSize(width, height)
}
// 渲染循环
const animate = () => {
animationId = requestAnimationFrame(animate)
if (controls) {
controls.update()
}
if (mixer) {
const delta = clock.getDelta()
mixer.update(delta)
}
if (renderer && scene && camera) {
renderer.render(scene, camera)
}
}
// 清理资源
const cleanup = () => {
if (animationId) {
cancelAnimationFrame(animationId)
}
if (mixer) {
mixer.stopAllAction()
mixer.uncacheRoot(model)
}
if (renderer) {
renderer.dispose()
}
if (container.value && renderer) {
container.value.removeChild(renderer.domElement)
}
window.removeEventListener('resize', onWindowResize)
}
// 监听模型URL变化
watch(
() => props.modelUrl,
(newUrl) => {
if (newUrl) {
loadModel()
}
}
)
// 监听模型类型变化
watch(
() => props.modelType,
() => {
if (props.modelUrl) {
loadModel()
}
}
)
// 生命周期
onMounted(() => {
// 使用nextTick确保DOM完全渲染
nextTick(() => {
// 延迟初始化,确保容器有正确的尺寸
setTimeout(() => {
initThreeJS()
if (props.modelUrl) {
loadModel()
}
}, 100)
})
})
onUnmounted(() => {
cleanup()
})
</script>
<style lang="scss">
.three-d-preview {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.preview-container {
width: 100%;
height: 100%;
position: relative;
background: #2d2d2d;
border-radius: 8px;
overflow: hidden;
// 移除min-height限制让高度完全自适应
// 确保在弹窗中能正确填充
canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
}
.loading-overlay,
.error-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(5px);
}
.loading-content,
.error-content {
text-align: center;
.el-icon {
font-size: 32px;
margin-bottom: 12px;
&.is-loading {
animation: rotate 1s linear infinite;
}
}
p {
margin: 0 0 16px 0;
color: #666;
font-size: 14px;
}
}
.loading-progress {
width: 200px;
margin-top: 16px;
.progress-bar {
width: 100%;
height: 4px;
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
overflow: hidden;
margin-bottom: 8px;
.progress-fill {
height: 100%;
background: #409eff;
border-radius: 2px;
transition: width 0.3s ease;
}
}
.progress-text {
font-size: 12px;
color: #666;
}
}
.error-content {
.el-icon {
color: #f56c6c;
}
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -179,23 +179,6 @@ const items = [
},
],
},
{
icon: 'cube',
index: '/admin/ai3d',
title: '3D生成',
subs: [
{
icon: 'list',
index: '/admin/ai3d/jobs',
title: '任务管理',
},
{
icon: 'config',
index: '/admin/ai3d/config',
title: '配置管理',
},
],
},
{
icon: 'moderation',

View File

@@ -92,18 +92,6 @@ const routes = [
meta: { title: 'Suno音乐创作' },
component: () => import('@/views/Suno.vue'),
},
{
name: 'ai3d',
path: '/ai3d',
meta: { title: 'AI3D模型生成' },
component: () => import('@/views/AIThreeDCreate.vue'),
},
{
name: 'test3d',
path: '/test3d',
meta: { title: '3D预览测试' },
component: () => import('@/views/test/Test3D.vue'),
},
{
name: 'ExternalLink',
path: '/external',
@@ -354,18 +342,6 @@ const routes = [
meta: { title: '即梦设置' },
component: () => import('@/views/admin/jimeng/JimengConfig.vue'),
},
{
path: '/admin/ai3d/jobs',
name: 'admin-ai3d-jobs',
meta: { title: '3D任务管理' },
component: () => import('@/views/admin/ai3d/AIThreeDJobs.vue'),
},
{
path: '/admin/ai3d/config',
name: 'admin-ai3d-config',
meta: { title: '3D配置管理' },
component: () => import('@/views/admin/ai3d/AIThreeDConfig.vue'),
},
{
path: '/admin/powerLog',
name: 'admin-power-log',
@@ -478,12 +454,6 @@ const routes = [
name: 'mobile-jimeng',
component: () => import('@/views/mobile/JimengCreate.vue'),
},
{
path: '/mobile/3d',
name: 'mobile-3d',
meta: { title: '3D模型生成' },
component: () => import('@/views/AIThreeDCreate.vue'),
},
],
},

View File

@@ -1,361 +0,0 @@
import { checkSession } from '@/store/cache'
import { showMessageError } from '@/utils/dialog'
import { httpDownload, httpGet, httpPost } from '@/utils/http'
import { replaceImg } from '@/utils/libs'
import { ElMessage, ElMessageBox } from 'element-plus'
import { defineStore } from 'pinia'
import { computed, onMounted, ref } from 'vue'
export const useAI3DStore = defineStore('ai3d', () => {
// 响应式数据
const activePlatform = ref('gitee')
const loading = ref(false)
const previewVisible = ref(false)
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const taskList = ref([])
const currentPreviewTask = ref({
downloading: false,
})
const giteeAdvancedVisible = ref(false)
const taskPulling = ref(false)
const tencentDefaultForm = {
text3d: false,
prompt: '',
image_url: '',
model: '',
file_format: '',
enable_pbr: false,
model_desc: '',
power: 0,
}
const giteeDefaultForm = {
prompt: '',
image_url: '',
model: '',
file_format: '',
texture: false,
seed: 1234,
num_inference_steps: 5,
guidance_scale: 7.5,
octree_resolution: 128,
model_desc: '',
power: 0,
}
const tencentForm = ref({ ...tencentDefaultForm })
const giteeForm = ref({ ...giteeDefaultForm })
const currentPower = ref(0)
const tencentSupportedFormats = ref([])
const giteeSupportedFormats = ref([])
// 定时器引用
let taskPullHandler = null
const configs = ref({
gitee: { models: [] },
tencent: { models: [] },
})
// 计算属性
const currentForm = computed(() =>
activePlatform.value === 'tencent' ? tencentForm.value : giteeForm.value
)
const selectedModel = computed(() => currentForm.value.model)
const currentPrompt = computed(() => currentForm.value.prompt)
const currentImage = computed(() =>
currentForm.value.image_url ? [{ url: currentForm.value.image_url }] : []
)
// 加载配置
const loadConfigs = async () => {
const response = await httpGet('/api/ai3d/configs')
configs.value = response.data
}
const handleModelChange = (value) => {
if (activePlatform.value === 'tencent') {
const model = configs.value.tencent.models.find((m) => m.name === value)
if (!model) return
currentPower.value = model.power
tencentForm.value.power = model.power
tencentForm.value.model_desc = model.desc
tencentForm.value.file_format = model.formats[0]
tencentSupportedFormats.value = model.formats
} else {
const model = configs.value.gitee.models.find((m) => m.name === value)
if (!model) return
currentPower.value = model.power
giteeForm.value.power = model.power
giteeForm.value.model_desc = model.desc
giteeForm.value.file_format = model.formats[0]
giteeSupportedFormats.value = model.formats
}
}
const handlePlatformChange = (value) => {
activePlatform.value = value
currentPower.value = value === 'tencent' ? tencentForm.value.power : giteeForm.value.power
}
const generate3D = async () => {
const requestData = {
...(activePlatform.value === 'tencent' ? tencentForm.value : giteeForm.value),
}
if (requestData.model === '') {
ElMessage.warning('请选择模型')
return
}
if (requestData.file_format === '') {
ElMessage.warning('请选择输出格式')
return
}
try {
loading.value = true
requestData.type = activePlatform.value
const response = await httpPost('/api/ai3d/generate', requestData)
ElMessage.success('任务创建成功')
await loadTasks()
} catch (error) {
ElMessage.error('创建任务失败:' + error.message)
} finally {
loading.value = false
}
}
const loadTasks = async () => {
try {
const response = await httpGet('/api/ai3d/jobs', {
page: currentPage.value,
page_size: pageSize.value,
})
if (response.code === 0) {
let needPull = false
const items = response.data.items
// 检查是否有进行中的任务
for (let item of items) {
if (item.status === 'pending' || item.status === 'processing') {
needPull = true
break
}
}
taskPulling.value = needPull
taskList.value = items
total.value = response.data.total
}
} catch (error) {
ElMessage.error('加载任务列表失败:' + error.message)
}
}
const refreshTasks = () => {
loadTasks()
}
const handlePageSizeChange = (size) => {
pageSize.value = size
currentPage.value = 1
loadTasks()
}
const handleCurrentPageChange = (page) => {
currentPage.value = page
loadTasks()
}
const deleteTask = async (taskId) => {
try {
await ElMessageBox.confirm('确定要删除这个任务吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
const response = await httpGet(`/api/ai3d/job/delete?id=${taskId}`)
if (response.code === 0) {
ElMessage.success('删除成功')
loadTasks()
} else {
ElMessage.error(response.message || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败:' + error.message)
}
}
}
const preview3D = (task) => {
currentPreviewTask.value = task
previewVisible.value = true
}
const closePreview = () => {
previewVisible.value = false
}
const downloadFile = async (item) => {
const url = replaceImg(item.file_url)
const downloadURL = `/api/download?url=${url}`
const urlObj = new URL(url)
const fileName = urlObj.pathname.split('/').pop()
item.downloading = true
try {
const response = await httpDownload(downloadURL)
const blob = new Blob([response.data])
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(link.href)
item.downloading = false
} catch (error) {
showMessageError('下载失败')
item.downloading = false
}
}
const downloadCurrentModel = () => {
if (currentPreviewTask.value) {
downloadFile(currentPreviewTask.value)
}
}
const getStatusText = (status) => {
const statusMap = {
pending: { text: '等待中', type: 'warning' },
processing: { text: '处理中', type: 'primary' },
success: { text: '已完成', type: 'success' },
failed: { text: '失败', type: 'danger' },
}
return statusMap[status] || status
}
const getTaskCardClass = (status) => {
if (status === 'success') return 'task-card-completed'
if (status === 'processing') return 'task-card-processing'
if (status === 'failed') return 'task-card-failed'
return 'task-card-default'
}
const getPlatformIcon = (type) => {
if (type === 'gitee') return 'iconfont icon-gitee'
if (type === 'tencent') return 'iconfont icon-tencent'
return 'iconfont icon-question'
}
const getPlatformName = (type) => {
if (type === 'gitee') return 'Gitee 模力方舟'
if (type === 'tencent') return '腾讯云混元3D'
return '未知平台'
}
const getTaskPrompt = (task) => {
return task.params.prompt ? task.params.prompt : '图生3D任务'
}
const getTaskImageUrl = (task) => {
try {
if (task.params) {
const parsedParams = task.params
return parsedParams.image_url || null
}
return null
} catch (e) {
return null
}
}
const getTaskParams = (task) => {
const parsedParams = task.params
const params = []
if (parsedParams.texture) params.push('纹理')
if (parsedParams.enable_pbr) params.push('PBR材质')
if (parsedParams.num_inference_steps)
params.push(`迭代次数: ${parsedParams.num_inference_steps}`)
if (parsedParams.guidance_scale) params.push(`引导系数: ${parsedParams.guidance_scale}`)
if (parsedParams.octree_resolution) params.push(`精度: ${parsedParams.octree_resolution}`)
if (parsedParams.seed) params.push(`Seed: ${parsedParams.seed}`)
return params.join('')
}
const startTaskPolling = () => {
taskPullHandler = setInterval(() => {
if (taskPulling.value) {
loadTasks()
}
}, 5000)
}
const stopTaskPolling = () => {
if (taskPullHandler) {
clearInterval(taskPullHandler)
taskPullHandler = null
}
}
// 生命周期:加载配置与任务
onMounted(() => {
loadConfigs()
checkSession()
.then(() => {
loadTasks()
startTaskPolling()
})
.catch(() => {})
})
return {
// 状态
activePlatform,
loading,
previewVisible,
currentPage,
pageSize,
total,
taskList,
currentPreviewTask,
giteeAdvancedVisible,
taskPulling,
tencentForm,
giteeForm,
currentPower,
tencentSupportedFormats,
giteeSupportedFormats,
configs,
currentForm,
selectedModel,
currentPrompt,
currentImage,
// 方法
loadConfigs,
handleModelChange,
handlePlatformChange,
generate3D,
loadTasks,
refreshTasks,
handlePageSizeChange,
handleCurrentPageChange,
deleteTask,
preview3D,
closePreview,
downloadFile,
downloadCurrentModel,
getStatusText,
getTaskCardClass,
getPlatformIcon,
getPlatformName,
getTaskPrompt,
getTaskImageUrl,
getTaskParams,
startTaskPolling,
stopTaskPolling,
}
})

View File

@@ -1,498 +0,0 @@
<template>
<div class="page-threed">
<!-- 左侧参数设置面板 -->
<div class="params-panel">
<!-- 平台选择Tab -->
<div class="platform-tabs">
<CustomTabs v-model="activePlatform" @tab-click="handlePlatformChange">
<CustomTabPane name="gitee" width="48%">
<template #label>
<div class="flex items-center justify-center">
<i class="iconfont icon-gitee mr-1"></i>
<span>Gitee 模力方舟</span>
</div>
</template>
<!-- 参数容器 -->
<div class="params-container">
<!-- 图片上传区域 -->
<div class="param-line pt">
<span class="label"><span class="text-red-500 mr-1">*</span>上传图片</span>
</div>
<div class="param-line">
<ImageUpload v-model="giteeForm.image_url" :max-count="1" :multiple="false" />
</div>
<!-- 模型选择 -->
<div class="param-line pt">
<span class="label"><span class="text-red-500 mr-1">*</span>模型选择</span>
</div>
<div class="param-line">
<el-select
v-model="giteeForm.model"
placeholder="选择模型"
@change="handleModelChange"
>
<el-option
v-for="model in configs.gitee.models"
:key="model.name"
:label="model.name"
:value="model.name"
/>
</el-select>
</div>
<div class="param-line">
<el-alert v-if="giteeForm.model_desc" type="info" :closable="false">
{{ giteeForm.model_desc }}
</el-alert>
</div>
<!-- 文件格式选择 -->
<div class="param-line">
<span class="label mb-3"><span class="text-red-500 mr-1">*</span>输出格式</span>
<el-select v-model="giteeForm.file_format" style="width: 100%">
<el-option
v-for="format in giteeSupportedFormats"
:key="format"
:label="format"
:value="format"
/>
</el-select>
</div>
<!-- 纹理开关 -->
<div class="flex justify-between param-line">
<span class="label">生成纹理</span>
<el-switch v-model="giteeForm.texture" size="large" />
</div>
<!-- 高级参数 -->
<div class="param-line pt">
<el-button
@click="giteeAdvancedVisible = !giteeAdvancedVisible"
class="advanced-toggle-btn"
>
<i
:class="
giteeAdvancedVisible ? 'iconfont icon-arrow-up' : 'iconfont icon-arrow-down'
"
></i>
<span>高级参数设置</span>
</el-button>
</div>
<!-- 高级参数内容 -->
<div v-show="giteeAdvancedVisible" class="advanced-params">
<!-- 随机种子 -->
<div class="param-line">
<span class="label mb-3">随机种子</span>
<el-input-number
v-model="giteeForm.seed"
:min="0"
:max="10000000"
controls-position="right"
style="width: 100%"
/>
</div>
<!-- 迭代次数 -->
<div class="param-line">
<span class="label mb-3">迭代次数</span>
<el-input-number
v-model="giteeForm.num_inference_steps"
:min="1"
:max="50"
controls-position="right"
style="width: 100%"
/>
</div>
<!-- 引导系数 -->
<div class="param-line">
<span class="label mb-3">引导系数</span>
<el-input-number
v-model="giteeForm.guidance_scale"
:min="1"
:max="20"
:step="0.5"
controls-position="right"
style="width: 100%"
/>
</div>
<!-- 3D渲染精度 -->
<div class="param-line">
<span class="label mb-3">3D渲染精度</span>
<el-select v-model="giteeForm.octree_resolution" style="width: 100%">
<el-option label="64 (低精度)" :value="64" />
<el-option label="128 (中精度)" :value="128" />
<el-option label="256 (高精度)" :value="256" />
</el-select>
</div>
</div>
</div>
</CustomTabPane>
<CustomTabPane name="tencent" width="48%">
<template #label>
<div class="flex items-center justify-center">
<i class="iconfont icon-tencent mr-1"></i>
<span>腾讯云混元3D</span>
</div>
</template>
<!-- 参数容器 -->
<div class="params-container">
<div class="param-line pt flex justify-between items-center">
<span class="label">生成模式</span>
<custom-switch
v-model="tencentForm.text3d"
active-color="#9c27b0"
inactive-color="#409eff"
:width="120"
size="large"
>
<template #active-text>
<div class="flex items-center justify-start pl-4 text-sm">
<i class="iconfont icon-image mr-1"></i> <span>文生3D</span>
</div>
</template>
<template #inactive-text>
<div class="flex items-center justify-end pl-4 text-sm">
<i class="iconfont icon-doc mr-1"></i> <span>图生3D</span>
</div>
</template>
</custom-switch>
</div>
<!-- 文本提示词 -->
<div v-if="tencentForm.text3d">
<div class="param-line pt">
<span class="label"><span class="text-red-500 mr-1">*</span>提示词</span>
</div>
<div class="param-line">
<el-input
v-model="tencentForm.prompt"
type="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
placeholder="请输入3D模型描述越详细越好"
maxlength="2000"
show-word-limit
/>
</div>
</div>
<div v-else>
<!-- 图片上传区域 -->
<div class="param-line pt">
<span class="label"><span class="text-red-500 mr-1">*</span>上传图片</span>
</div>
<div class="param-line">
<ImageUpload v-model="tencentForm.image_url" :max-count="1" :multiple="false" />
</div>
</div>
<!-- 模型选择 -->
<div class="param-line pt">
<span class="label mb-2"><span class="text-red-500 mr-1">*</span>模型选择</span>
</div>
<div class="param-line">
<el-select
v-model="tencentForm.model"
@change="handleModelChange"
placeholder="选择模型"
>
<el-option
v-for="model in configs.tencent.models"
:key="model.name"
:label="model.name"
:value="model.name"
/>
</el-select>
</div>
<div class="param-line">
<el-alert v-if="tencentForm.model_desc" type="info" :closable="false">
{{ tencentForm.model_desc }}
</el-alert>
</div>
<!-- 文件格式选择 -->
<div class="param-line">
<span class="label mb-3"><span class="text-red-500 mr-1">*</span>输出格式</span>
<el-select v-model="tencentForm.file_format" style="width: 100%">
<el-option
v-for="format in tencentSupportedFormats"
:key="format"
:label="format"
:value="format"
/>
</el-select>
</div>
<!-- PBR材质开关 -->
<div class="flex justify-between param-line">
<span class="label">启用PBR材质</span>
<el-switch v-model="tencentForm.enable_pbr" size="large" />
</div>
</div>
</CustomTabPane>
<!-- 生成按钮 -->
<div class="generate-section">
<button
@click="generate3D"
:disabled="loading"
type="button"
class="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-xl disabled:from-gray-400 disabled:to-gray-400 disabled:cursor-not-allowed hover:from-blue-600 hover:to-purple-700 transition-all duration-200 flex items-center justify-center space-x-2"
>
<i v-if="loading" class="iconfont icon-loading animate-spin"></i>
<i v-else class="iconfont icon-chuangzuo"></i>
<span>{{ loading ? '创作中...' : `立即生成 (${currentPower}算力)` }}</span>
</button>
</div>
</CustomTabs>
</div>
</div>
<!-- 右侧内容区域 -->
<div class="content-panel">
<!-- 任务列表 -->
<div class="task-list">
<div class="list-header">
<h3>生成任务</h3>
<el-button size="small" @click="refreshTasks">刷新</el-button>
</div>
<div class="task-items">
<div
v-for="task in taskList"
:key="task.id"
class="task-card"
:class="getTaskCardClass(task.status)"
>
<!-- 任务卡片头部 -->
<div class="task-card-header">
<div class="task-info">
<div class="task-id">
<i class="iconfont icon-renwu mr-2"></i>
#{{ task.id }}
</div>
<div class="task-platform">
<i :class="getPlatformIcon(task.type)" class="mr-1"></i>
{{ getPlatformName(task.type) }}
</div>
</div>
<div class="task-status-wrapper">
<div class="task-status">
<el-button
size="small"
:type="getStatusText(task.status).type"
class="action-btn processing-btn"
disabled
round
>
<i
class="iconfont icon-loading animate-spin mr-1"
v-if="task.status === 'processing'"
></i>
{{ getStatusText(task.status).text }}
</el-button>
</div>
<div class="task-power">
<i class="iconfont icon-power mr-1"></i>
{{ task.power }}
</div>
</div>
</div>
<!-- 任务卡片内容 -->
<div class="task-card-content">
<!-- 左侧预览图 -->
<div class="task-preview rounded-lg">
<div v-if="task.status === 'success' && task.preview_url" class="preview-image">
<img :src="task.preview_url" :alt="getTaskPrompt(task)" />
<div class="preview-overlay cursor-pointer" @click="preview3D(task)">
<i class="iconfont icon-eye-open !text-3xl"></i>
</div>
</div>
<div v-else-if="getTaskImageUrl(task)" class="input-image">
<img :src="getTaskImageUrl(task)" :alt="getTaskPrompt(task)" />
<div class="input-overlay">
<i class="iconfont icon-cube !text-3xl"></i>
</div>
</div>
<div v-else class="prompt-placeholder">
<i class="iconfont icon-doc"></i>
<span>文生3D任务</span>
</div>
</div>
<!-- 右侧任务详情 -->
<div class="task-details">
<div class="task-model">
<i class="iconfont icon-model !text-2xl mr-1"></i>
{{ task.model }}
</div>
<div class="task-prompt" v-if="getTaskPrompt(task)">
<i class="iconfont icon-info !text-lg mr-1"></i>
<span>{{ getTaskPrompt(task) }}</span>
</div>
<div class="task-params" v-if="getTaskParams(task)">
<i class="iconfont icon-tag !text-lg mr-1"></i>
<span>{{ getTaskParams(task) }}</span>
</div>
<div class="task-time">
<i class="iconfont icon-clock !text-xl mr-1"></i>
{{ dateFormat(task.created_at) }}
</div>
<div class="task-error" v-if="task.status === 'failed' && task.err_msg">
<i class="iconfont icon-error !text-base mr-1"></i>
<span>{{ task.err_msg }}</span>
</div>
</div>
</div>
<!-- 任务卡片底部操作 -->
<div class="task-card-footer">
<div class="task-actions">
<el-button
v-if="task.status === 'success'"
size="small"
type="primary"
@click="preview3D(task)"
class="action-btn preview-btn"
>
<i class="iconfont icon-eye-open mr-1"></i>
预览
</el-button>
<el-button
v-if="task.status === 'success'"
size="small"
type="success"
@click="downloadFile(task)"
:loading="task.downloading"
class="action-btn download-btn"
>
<i class="iconfont icon-download mr-1" v-if="!task.downloading"></i>
<span v-if="task.downloading">下载中...</span>
<span v-else>下载</span>
</el-button>
<el-button
size="small"
type="danger"
@click="deleteTask(task.id)"
class="action-btn delete-btn"
>
<i class="iconfont icon-remove mr-1"></i>
删除
</el-button>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="taskList.length === 0" class="empty-state">
<i class="iconfont icon-kong"></i>
<p>暂无任务开始创建你的第一个3D模型吧</p>
</div>
</div>
<!-- 分页 -->
<div class="pagination" v-if="total > 0">
<el-pagination
:current-page="currentPage"
:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handlePageSizeChange"
@current-change="handleCurrentPageChange"
/>
</div>
</div>
</div>
<!-- 3D预览弹窗 -->
<el-dialog v-model="previewVisible" title="3D模型预览" fullscreen :before-close="closePreview">
<div class="preview-container">
<ThreeDPreview
v-if="currentPreviewTask && currentPreviewTask.file_url"
:model-url="currentPreviewTask.file_url"
/>
<div v-else class="preview-placeholder">
<i class="iconfont icon-3d"></i>
<p>暂无3D模型</p>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="closePreview">关闭</el-button>
<el-button
type="primary"
@click="downloadCurrentModel"
:loading="currentPreviewTask.downloading"
>
<span v-if="!currentPreviewTask.downloading">下载模型</span>
<span v-else>下载中...</span>
</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import ImageUpload from '@/components/ImageUpload.vue'
import ThreeDPreview from '@/components/ThreeDPreview.vue'
import CustomSwitch from '@/components/ui/CustomSwitch.vue'
import CustomTabPane from '@/components/ui/CustomTabPane.vue'
import CustomTabs from '@/components/ui/CustomTabs.vue'
import { useAI3DStore } from '@/store/ai3d'
import { storeToRefs } from 'pinia'
import { dateFormat } from '../utils/libs'
const ai3d = useAI3DStore()
const {
activePlatform,
loading,
previewVisible,
currentPage,
pageSize,
total,
taskList,
currentPreviewTask,
giteeAdvancedVisible,
tencentForm,
giteeForm,
currentPower,
tencentSupportedFormats,
giteeSupportedFormats,
configs,
} = storeToRefs(ai3d)
const {
handleModelChange,
handlePlatformChange,
generate3D,
refreshTasks,
handlePageSizeChange,
handleCurrentPageChange,
deleteTask,
preview3D,
closePreview,
downloadFile,
downloadCurrentModel,
getStatusText,
getTaskCardClass,
getPlatformIcon,
getPlatformName,
getTaskPrompt,
getTaskImageUrl,
getTaskParams,
} = ai3d
</script>
<style lang="scss" scoped>
@use '@/assets/css/ai3d.scss' as ai3d;
</style>

View File

@@ -1,399 +0,0 @@
<template>
<div class="admin-threed-setting">
<!-- 配置表单 -->
<div class="settings-container">
<!-- 配置选项卡 -->
<el-card class="setting-card">
<el-tabs v-model="activeTab" type="border-card">
<!-- 腾讯混元3D配置 -->
<el-tab-pane name="tencent">
<template #label>
<div class="tab-label">
<i class="iconfont icon-tencent mr-2"></i>
<span>腾讯混元3D</span>
</div>
</template>
<div class="tab-content">
<!-- 秘钥配置 -->
<div class="config-section">
<h4>秘钥配置</h4>
<el-form :model="configs.tencent" label-width="140px" label-position="top">
<el-form-item label="SecretId">
<el-input
v-model="configs.tencent.secret_id"
placeholder="请输入腾讯云SecretId"
/>
</el-form-item>
<el-form-item label="SecretKey">
<el-input
v-model="configs.tencent.secret_key"
placeholder="请输入腾讯云SecretKey"
/>
</el-form-item>
<el-form-item label="地域(目前仅支持广州)">
<el-input v-model="configs.tencent.region" placeholder="请输入地域" />
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="configs.tencent.enabled" />
</el-form-item>
</el-form>
</div>
<!-- 模型配置 -->
<div class="config-section">
<h4>模型配置</h4>
<div class="model-config">
<div class="model-header">
<span>支持的3D模型格式和算力消耗</span>
<el-button type="primary" plain @click="addTencentModel">添加模型</el-button>
</div>
<el-table
:data="configs.tencent.models"
border
style="width: 100%"
:max-height="400"
size="small"
>
<el-table-column prop="name" label="模型名称" min-width="180">
<template #default="{ row }">
<el-input v-model="row.name" placeholder="模型名称" />
</template>
</el-table-column>
<el-table-column prop="desc" label="模型描述" min-width="180">
<template #default="{ row }">
<el-input
v-model="row.desc"
placeholder="模型描述"
type="textarea"
:rows="3"
/>
</template>
</el-table-column>
<el-table-column prop="power" label="算力消耗" min-width="120">
<template #default="{ row }">
<el-input-number v-model="row.power" :min="1" :max="1000" />
</template>
</el-table-column>
<el-table-column prop="formats" label="输出格式" min-width="200">
<template #default="{ row }">
<el-select
v-model="row.formats"
multiple
placeholder="选择输出格式"
style="width: 100%"
collapse-tags
collapse-tags-tooltip
>
<el-option
v-for="item in formatOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" min-width="100" fixed="right">
<template #default="{ $index }">
<el-button size="small" type="danger" @click="removeTencentModel($index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</el-tab-pane>
<!-- Gitee模力方舟配置 -->
<el-tab-pane name="gitee">
<template #label>
<div class="tab-label">
<i class="iconfont icon-gitee mr-2"></i>
<span>Gitee模力方舟</span>
</div>
</template>
<div class="tab-content">
<Alert type="info">
如果你不知道怎么获取这些配置信息请参考文档
<a href="https://ai.gitee.com/docs/organization/access-token" target="_blank"
>模力方舟访问令牌配置</a
>
</Alert>
<!-- 秘钥配置 -->
<div class="config-section mt-5">
<h4>秘钥配置</h4>
<el-form :model="configs.gitee" label-width="140px" label-position="top">
<el-form-item label="API密钥">
<el-input v-model="configs.gitee.api_key" placeholder="请输入Gitee API密钥" />
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="configs.gitee.enabled" />
</el-form-item>
</el-form>
</div>
<!-- 模型配置 -->
<div class="config-section">
<h4>模型配置</h4>
<div class="model-config">
<div class="model-header">
<span>支持的3D模型格式和算力消耗</span>
<el-button type="primary" plain @click="addGiteeModel">添加模型</el-button>
</div>
<el-table
:data="configs.gitee.models"
border
style="width: 100%"
:max-height="400"
size="small"
>
<el-table-column prop="name" label="模型名称" min-width="180">
<template #default="{ row }">
<el-input v-model="row.name" placeholder="模型名称" />
</template>
</el-table-column>
<el-table-column prop="desc" label="模型描述" min-width="180">
<template #default="{ row }">
<el-input
v-model="row.desc"
placeholder="模型描述"
type="textarea"
:rows="3"
/>
</template>
</el-table-column>
<el-table-column prop="power" label="算力消耗" min-width="120">
<template #default="{ row }">
<el-input-number v-model="row.power" :min="1" :max="1000" />
</template>
</el-table-column>
<el-table-column prop="formats" label="输出格式" min-width="200">
<template #default="{ row }">
<el-select
v-model="row.formats"
multiple
placeholder="选择输出格式"
style="width: 100%"
collapse-tags
collapse-tags-tooltip
>
<el-option
v-for="item in formatOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" min-width="100" fixed="right">
<template #default="{ $index }">
<el-button size="small" type="danger" @click="removeGiteeModel($index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</el-tab-pane>
<div class="flex justify-center mb-5">
<el-button type="primary" @click="saveConfig" :loading="loading">保存配置</el-button>
</div>
</el-tabs>
</el-card>
</div>
</div>
</template>
<script setup>
import Alert from '@/components/ui/Alert.vue'
import { httpGet, httpPost } from '@/utils/http'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, ref } from 'vue'
// 响应式数据
const activeTab = ref('tencent')
const loading = ref(false)
const configs = ref({
tencent: { region: 'ap-guangzhou', enabled: true, models: [] },
gitee: { models: [] },
})
const formatOptions = ref([
{ label: 'OBJ', value: 'OBJ' },
{ label: 'GLB', value: 'GLB' },
{ label: 'STL', value: 'STL' },
{ label: 'USDZ', value: 'USDZ' },
{ label: 'FBX', value: 'FBX' },
{ label: 'MP4', value: 'MP4' },
])
// 方法
const loadConfig = async () => {
try {
const res = await httpGet('/api/admin/config/get?key=ai3d')
configs.value = res.data
const models = await httpGet('/api/admin/ai3d/models')
if (!configs.value.tencent.models || configs.value.tencent.models.length === 0) {
configs.value.tencent.models = models.data.tencent
}
if (!configs.value.gitee.models || configs.value.gitee.models.length === 0) {
configs.value.gitee.models = models.data.gitee
}
} catch (error) {
ElMessage.error('加载配置失败:' + error.message)
}
}
const saveConfig = async () => {
loading.value = true
try {
const response = await httpPost('/api/admin/ai3d/config', configs.value)
ElMessage.success('所有配置保存成功')
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('保存失败:' + error.message)
}
} finally {
loading.value = false
}
}
// 模型操作 - 腾讯
const addTencentModel = () => {
configs.value.tencent.models.push({
name: '',
desc: '',
power: 1,
formats: [],
})
}
const removeTencentModel = async (index) => {
try {
await ElMessageBox.confirm('确定要删除该模型吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
configs.value.tencent.models.splice(index, 1)
ElMessage.success('删除成功')
} catch (e) {
// 用户取消
}
}
// 模型操作 - Gitee
const addGiteeModel = () => {
configs.value.gitee.models.push({
name: '',
desc: '',
power: 1,
formats: [],
})
}
const removeGiteeModel = async (index) => {
try {
await ElMessageBox.confirm('确定要删除该模型吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
configs.value.gitee.models.splice(index, 1)
ElMessage.success('删除成功')
} catch (e) {
// 用户取消
}
}
// 生命周期
onMounted(() => {
loadConfig()
})
</script>
<style lang="scss">
.admin-threed-setting {
padding: 20px;
a {
color: #409eff;
&:hover {
text-decoration: underline;
}
}
.el-form-item__label {
font-weight: 700;
}
.settings-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.setting-card {
.el-card__body {
padding: 0;
}
}
.tab-content {
padding: 20px;
}
.config-section {
margin-bottom: 30px;
h4 {
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 600;
padding-bottom: 8px;
border-bottom: 2px solid #409eff;
}
.section-actions {
margin-top: 16px;
text-align: right;
}
}
.model-config {
.model-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.el-button {
font-weight: 500;
}
}
}
.el-form-item {
margin-bottom: 20px;
}
:deep(.el-tabs__header) {
margin: 0;
}
:deep(.el-tabs__content) {
padding: 0;
}
}
</style>

View File

@@ -1,469 +0,0 @@
<template>
<div class="admin-threed-jobs">
<!-- 搜索和筛选 -->
<div class="search-section">
<el-form :model="searchForm" inline>
<el-form-item label="任务状态">
<el-select
v-model="searchForm.status"
placeholder="选择状态"
style="width: 120px"
clearable
>
<el-option label="全部" value="" />
<el-option label="等待中" value="pending" />
<el-option label="处理中" value="processing" />
<el-option label="已完成" value="success" />
<el-option label="失败" value="failed" />
</el-select>
</el-form-item>
<el-form-item label="平台类型">
<el-select
v-model="searchForm.type"
placeholder="选择平台"
style="width: 120px"
clearable
>
<el-option label="全部" value="" />
<el-option label="魔力方舟" value="gitee" />
<el-option label="腾讯混元" value="tencent" />
</el-select>
</el-form-item>
<el-form-item label="用户ID">
<el-input v-model="searchForm.userId" placeholder="输入用户ID" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 数据统计 -->
<div class="stats-section">
<el-row :gutter="20">
<el-col :span="6">
<div class="stat-card">
<div class="stat-icon pending">
<i class="iconfont icon-clock"></i>
</div>
<div class="stat-content">
<div class="stat-number">{{ stats.pending }}</div>
<div class="stat-label">等待中</div>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="stat-icon processing">
<i class="iconfont icon-loading"></i>
</div>
<div class="stat-content">
<div class="stat-number">{{ stats.processing }}</div>
<div class="stat-label">处理中</div>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="stat-icon completed">
<i class="iconfont icon-check"></i>
</div>
<div class="stat-content">
<div class="stat-number">{{ stats.success }}</div>
<div class="stat-label">已完成</div>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="stat-icon failed">
<i class="iconfont icon-error"></i>
</div>
<div class="stat-content">
<div class="stat-number">{{ stats.failed }}</div>
<div class="stat-label">失败</div>
</div>
</div>
</el-col>
</el-row>
</div>
<!-- 任务列表 -->
<div class="table-section w-full">
<el-table :data="taskList" v-loading="loading" border style="width: 100%">
<el-table-column prop="user_id" label="用户ID" width="80" />
<el-table-column prop="type" label="平台">
<template #default="{ row }">
<el-tag :type="row.type === 'gitee' ? 'success' : 'primary'">
{{ row.type === 'gitee' ? '魔力方舟' : '腾讯混元' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="model" label="模型名称" />
<el-table-column label="模型格式">
<template #default="{ row }">
{{ row.params.file_format }}
</template>
</el-table-column>
<el-table-column prop="power" label="算力消耗" />
<el-table-column prop="status" label="状态">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间">
<template #default="{ row }">
{{ dateFormat(row.created_at) }}
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间">
<template #default="{ row }">
{{ dateFormat(row.updated_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="viewTask(row)">查看</el-button>
<el-button
size="small"
type="primary"
plain
v-if="row.status === 'success'"
@click="openModelPreview(row)"
>
预览模型
</el-button>
<el-button size="small" type="danger" @click="deleteTask(row.id)"> 删除 </el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-section">
<el-pagination
:current-page="currentPage"
:page-size="pageSize"
:page-sizes="[20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
<!-- 任务详情弹窗 -->
<el-dialog
v-model="taskDetailVisible"
title="任务详情"
width="60%"
:before-close="closeTaskDetail"
>
<div v-if="currentTask" class="task-detail">
<el-descriptions :column="2" border>
<el-descriptions-item label="任务ID">{{ currentTask.id }}</el-descriptions-item>
<el-descriptions-item label="用户ID">{{ currentTask.user_id }}</el-descriptions-item>
<el-descriptions-item label="平台类型">
<el-tag :type="currentTask.type === 'gitee' ? 'success' : 'primary'">
{{ currentTask.type === 'gitee' ? '魔力方舟' : '腾讯混元' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="模型名称">{{ currentTask.model }}</el-descriptions-item>
<el-descriptions-item label="算力消耗">{{ currentTask.power }}</el-descriptions-item>
<el-descriptions-item label="任务状态">
<el-tag :type="getStatusType(currentTask.status)">
{{ getStatusText(currentTask.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{
dateFormat(currentTask.created_at)
}}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{
dateFormat(currentTask.updated_at)
}}</el-descriptions-item>
</el-descriptions>
<div class="task-params">
<h4>任务参数</h4>
<div class="params-content">
<pre>{{ JSON.stringify(currentTask.params, null, 2) }}</pre>
</div>
</div>
<div v-if="currentTask.img_url || currentTask.file_url" class="task-result">
<h4>生成结果</h4>
<div class="result-links">
<el-button type="primary" @click="downloadModel(currentTask)"> 下载3D模型 </el-button>
<el-button
v-if="currentTask.file_url"
type="success"
plain
@click="openModelPreview(currentTask)"
>
预览模型
</el-button>
</div>
</div>
<div v-if="currentTask.err_msg" class="task-error">
<h4>错误信息</h4>
<el-alert :title="currentTask.err_msg" type="error" :closable="false" show-icon />
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="closeTaskDetail">关闭</el-button>
</span>
</template>
</el-dialog>
<!-- 3D 模型预览弹窗 -->
<el-dialog
v-model="modelPreviewVisible"
:class="['model-preview-dialog', { dark: isDarkTheme }]"
title="模型预览"
fullscreen
destroy-on-close
>
<div class="model-preview-wrapper">
<ThreeDPreview :model-url="modelPreviewUrl" />
</div>
<template #footer>
<span class="dialog-footer">
<el-button
type="primary"
@click="downloadModel(currentTask)"
:loading="currentTask.downloading"
>
下载3D模型
</el-button>
<el-button @click="modelPreviewVisible = false">关闭</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import ThreeDPreview from '@/components/ThreeDPreview.vue'
import { showMessageError } from '@/utils/dialog'
import { httpDownload, httpGet } from '@/utils/http'
import { dateFormat, replaceImg } from '@/utils/libs'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
// 响应式数据
const loading = ref(false)
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
const taskList = ref([])
const taskDetailVisible = ref(false)
const currentTask = ref({
downloading: false,
})
const previewUrl = ref('')
// 3D 预览
const modelPreviewVisible = ref(false)
const modelPreviewUrl = ref('')
// 简单检测暗色主题(若全局有主题管理可替换)
const isDarkTheme = ref(
document.documentElement.classList.contains('dark') || document.body.classList.contains('dark')
)
// 搜索表单
const searchForm = reactive({
status: '',
type: '',
userId: '',
})
// 统计数据
const stats = reactive({
pending: 0,
processing: 0,
completed: 0,
failed: 0,
})
// 方法
const loadData = async () => {
try {
loading.value = true
const params = {
page: currentPage.value,
page_size: pageSize.value,
...searchForm,
}
// 移除空值
Object.keys(params).forEach((key) => {
if (params[key] === '') {
delete params[key]
}
})
const response = await httpGet('/api/admin/ai3d/jobs', params)
if (response.code === 0) {
taskList.value = response.data.items
total.value = response.data.total
} else {
ElMessage.error(response.message || '加载数据失败')
}
} catch (error) {
ElMessage.error('加载数据失败:' + error.message)
} finally {
loading.value = false
}
}
const loadStats = async () => {
try {
const response = await httpGet('/api/admin/ai3d/stats')
if (response.code === 0) {
Object.assign(stats, response.data)
}
} catch (error) {
console.error('加载统计数据失败:', error)
}
}
const handleSearch = () => {
currentPage.value = 1
loadData()
}
const resetSearch = () => {
Object.assign(searchForm, {
status: '',
type: '',
userId: '',
})
currentPage.value = 1
loadData()
}
const handleSizeChange = (size) => {
pageSize.value = size
currentPage.value = 1
loadData()
}
const handleCurrentChange = (page) => {
currentPage.value = page
loadData()
}
const refreshData = () => {
loadData()
loadStats()
}
const viewTask = (task) => {
currentTask.value = task
taskDetailVisible.value = true
}
const closeTaskDetail = () => {
taskDetailVisible.value = false
currentTask.value = null
}
const deleteTask = async (taskId) => {
try {
await ElMessageBox.confirm('确定要删除这个任务吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
const response = await httpGet(`/api/admin/ai3d/jobs/${taskId}/delete`)
if (response.code === 0) {
ElMessage.success('删除成功')
loadData()
loadStats()
} else {
ElMessage.error(response.message || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败:' + error.message)
}
}
}
const downloadModel = async (task) => {
const url = replaceImg(task.file_url)
const downloadURL = `/api/download?url=${url}`
const urlObj = new URL(url)
const fileName = urlObj.pathname.split('/').pop()
task.downloading = true
try {
const response = await httpDownload(downloadURL)
const blob = new Blob([response.data])
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(link.href)
task.downloading = false
} catch (error) {
showMessageError('下载失败:' + error.message)
task.downloading = false
}
}
const openModelPreview = (task) => {
// 优先使用文件直链,后端下载代理也可拼接
const url = task.file_url
if (!url) {
ElMessage.warning('暂无可预览的模型文件')
return
}
currentTask.value = task
modelPreviewUrl.value = url
modelPreviewVisible.value = true
}
const getStatusType = (status) => {
const typeMap = {
pending: 'warning',
processing: 'primary',
success: 'success',
failed: 'danger',
}
return typeMap[status] || 'info'
}
const getStatusText = (status) => {
const textMap = {
pending: '等待中',
processing: '处理中',
success: '已完成',
failed: '失败',
}
return textMap[status] || status
}
// 生命周期
onMounted(() => {
loadData()
loadStats()
})
</script>
<style lang="scss" scoped>
@use '@/assets/css/admin/ai3d.scss' as *;
</style>