3D生成服务已经完成

This commit is contained in:
GeekMaster
2025-09-02 18:55:45 +08:00
parent 85b4cc0a3c
commit f8e4d2880f
40 changed files with 4920 additions and 395 deletions

View File

@@ -1,8 +1,8 @@
@font-face {
font-family: "iconfont"; /* Project id 4125778 */
src: url('iconfont.woff2?t=1756631578371') format('woff2'),
url('iconfont.woff?t=1756631578371') format('woff'),
url('iconfont.ttf?t=1756631578371') format('truetype');
src: url('iconfont.woff2?t=1756786244728') format('woff2'),
url('iconfont.woff?t=1756786244728') format('woff'),
url('iconfont.ttf?t=1756786244728') format('truetype');
}
.iconfont {
@@ -13,6 +13,10 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-cube:before {
content: "\e876";
}
.icon-tencent:before {
content: "\e655";
}

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,13 @@
"css_prefix_text": "icon-",
"description": "",
"glyphs": [
{
"icon_id": "34453337",
"name": "3D会场",
"font_class": "cube",
"unicode": "e876",
"unicode_decimal": 59510
},
{
"icon_id": "3547761",
"name": "tencent",

Binary file not shown.

View File

@@ -0,0 +1,547 @@
<template>
<div class="three-d-preview">
<div ref="container" class="preview-container"></div>
<!-- 控制面板 -->
<div class="control-panel">
<div class="control-group">
<label>旋转速度</label>
<el-slider
v-model="rotationSpeed"
:min="0"
:max="0.1"
:step="0.01"
@change="updateRotationSpeed"
/>
</div>
<div class="control-group">
<label>缩放</label>
<el-slider v-model="scale" :min="0.1" :max="3" :step="0.1" @change="updateScale" />
</div>
<div class="control-buttons">
<el-button size="small" @click="resetCamera">重置视角</el-button>
<el-button size="small" @click="toggleAutoRotate">
{{ autoRotate ? '停止旋转' : '自动旋转' }}
</el-button>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-overlay">
<div class="loading-content">
<el-icon class="is-loading"><Loading /></el-icon>
<p>加载3D模型中...</p>
</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, ElSlider } from 'element-plus'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'
import { onMounted, onUnmounted, ref, watch } from 'vue'
// Props
const props = defineProps({
modelUrl: {
type: String,
required: true,
},
modelType: {
type: String,
default: 'glb',
},
})
// 响应式数据
const container = ref(null)
const loading = ref(true)
const error = ref('')
const rotationSpeed = ref(0.02)
const scale = ref(1)
const autoRotate = ref(true)
// Three.js 相关变量
let scene, camera, renderer, controls, model, mixer, clock
let animationId
// 初始化Three.js场景
const initThreeJS = () => {
if (!container.value) return
// 创建场景
scene = new THREE.Scene()
scene.background = new THREE.Color(0xf0f0f0)
// 创建相机
const containerRect = container.value.getBoundingClientRect()
camera = new THREE.PerspectiveCamera(75, containerRect.width / containerRect.height, 0.1, 1000)
camera.position.set(0, 0, 5)
// 创建渲染器
renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(containerRect.width, containerRect.height)
renderer.setPixelRatio(window.devicePixelRatio)
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
// 添加到容器
container.value.appendChild(renderer.domElement)
// 创建控制器
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.05
controls.autoRotate = autoRotate.value
controls.autoRotateSpeed = rotationSpeed.value
// 添加光源
addLights()
// 添加地面
addGround()
// 创建时钟
clock = new THREE.Clock()
// 开始渲染循环
animate()
// 监听窗口大小变化
window.addEventListener('resize', onWindowResize)
}
// 添加光源
const addLights = () => {
// 环境光
const ambientLight = new THREE.AmbientLight(0x404040, 0.6)
scene.add(ambientLight)
// 方向光
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
directionalLight.position.set(10, 10, 5)
directionalLight.castShadow = true
directionalLight.shadow.mapSize.width = 2048
directionalLight.shadow.mapSize.height = 2048
scene.add(directionalLight)
// 点光源
const pointLight = new THREE.PointLight(0xffffff, 0.5)
pointLight.position.set(-10, 10, -5)
scene.add(pointLight)
}
// 添加地面
const addGround = () => {
const groundGeometry = new THREE.PlaneGeometry(20, 20)
const groundMaterial = new THREE.MeshLambertMaterial({
color: 0xcccccc,
transparent: true,
opacity: 0.3,
})
const ground = new THREE.Mesh(groundGeometry, groundMaterial)
ground.rotation.x = -Math.PI / 2
ground.receiveShadow = true
scene.add(ground)
}
// 加载3D模型
const loadModel = async () => {
if (!props.modelUrl) return
try {
loading.value = true
error.value = ''
// 清除现有模型
if (model) {
scene.remove(model)
model = null
}
let loadedModel
switch (props.modelType.toLowerCase()) {
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(`不支持的模型格式: ${props.modelType}`)
}
if (loadedModel) {
model = loadedModel
scene.add(model)
// 调整模型位置和大小
centerModel()
fitCameraToModel()
// 设置阴影
model.traverse((child) => {
if (child.isMesh) {
child.castShadow = true
child.receiveShadow = true
}
})
}
loading.value = false
} catch (err) {
console.error('加载3D模型失败:', err)
error.value = `加载模型失败: ${err.message}`
loading.value = false
}
}
// 加载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)
},
undefined,
reject
)
})
}
// 加载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 centerModel = () => {
if (!model) return
const box = new THREE.Box3().setFromObject(model)
const center = box.getCenter(new THREE.Vector3())
const size = box.getSize(new THREE.Vector3())
// 居中
model.position.sub(center)
// 调整缩放
const maxDim = Math.max(size.x, size.y, size.z)
const scale = 2 / maxDim
model.scale.setScalar(scale * props.scale)
}
// 调整相机以适应模型
const fitCameraToModel = () => {
if (!model) return
const box = new THREE.Box3().setFromObject(model)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z)
const fov = camera.fov * (Math.PI / 180)
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2))
camera.position.set(center.x, center.y, center.z + cameraZ)
camera.lookAt(center)
controls.target.copy(center)
controls.update()
}
// 更新旋转速度
const updateRotationSpeed = (value) => {
if (controls) {
controls.autoRotateSpeed = value
}
}
// 更新缩放
const updateScale = (value) => {
if (model) {
const box = new THREE.Box3().setFromObject(model)
const size = box.getSize(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z)
const baseScale = 2 / maxDim
model.scale.setScalar(baseScale * value)
}
}
// 重置相机
const resetCamera = () => {
if (camera && model) {
fitCameraToModel()
}
}
// 切换自动旋转
const toggleAutoRotate = () => {
autoRotate.value = !autoRotate.value
if (controls) {
controls.autoRotate = autoRotate.value
}
}
// 重试加载
const retryLoad = () => {
loadModel()
}
// 窗口大小变化处理
const onWindowResize = () => {
if (!container.value || !camera || !renderer) return
const containerRect = container.value.getBoundingClientRect()
camera.aspect = containerRect.width / containerRect.height
camera.updateProjectionMatrix()
renderer.setSize(containerRect.width, containerRect.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(() => {
initThreeJS()
if (props.modelUrl) {
loadModel()
}
})
onUnmounted(() => {
cleanup()
})
</script>
<style lang="scss" scoped>
.three-d-preview {
position: relative;
width: 100%;
height: 100%;
}
.preview-container {
width: 100%;
height: 100%;
position: relative;
}
.control-panel {
position: absolute;
top: 20px;
right: 20px;
background: rgba(255, 255, 255, 0.9);
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
.control-group {
margin-bottom: 16px;
label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: #333;
font-weight: 500;
}
}
.control-buttons {
display: flex;
gap: 8px;
.el-button {
flex: 1;
}
}
}
.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;
}
}
.error-content {
.el-icon {
color: #f56c6c;
}
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// 响应式设计
@media (max-width: 768px) {
.control-panel {
position: relative;
top: auto;
right: auto;
margin: 16px;
border-radius: 8px;
}
}
</style>

View File

@@ -179,22 +179,39 @@ 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',
index: '/admin/config/moderation',
title: '文本审查',
subs: [
{
icon: 'config',
index: '/admin/config/moderation',
title: '审查配置',
},
{
icon: 'list',
index: '/admin/moderation/list',
title: '审核记录',
},
{
icon: 'config',
index: '/admin/moderation/config',
title: '审查配置',
},
],
},
{

View File

@@ -92,6 +92,12 @@ const routes = [
meta: { title: 'Suno音乐创作' },
component: () => import('@/views/Suno.vue'),
},
{
name: 'ai3d',
path: '/ai3d',
meta: { title: 'AI3D模型生成' },
component: () => import('@/views/AIThreeDCreate.vue'),
},
{
name: 'ExternalLink',
path: '/external',
@@ -148,12 +154,7 @@ const routes = [
meta: { title: '控制台登录' },
component: () => import('@/views/admin/Login.vue'),
},
{
path: '/payReturn',
name: 'pay-return',
meta: { title: '支付回调' },
component: () => import('@/views/PayReturn.vue'),
},
{
name: 'admin',
path: '/admin',
@@ -210,7 +211,7 @@ const routes = [
component: () => import('@/views/admin/settings/PluginConfig.vue'),
},
{
path: '/admin/config/moderation',
path: '/admin/moderation/config',
name: 'admin-config-moderation',
meta: { title: '文本审查配置' },
component: () => import('@/views/admin/moderation/ModerationConfig.vue'),
@@ -345,7 +346,19 @@ const routes = [
path: '/admin/jimeng/config',
name: 'admin-jimeng-config',
meta: { title: '即梦设置' },
component: () => import('@/views/admin/jimeng/JimengSetting.vue'),
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',
@@ -459,6 +472,12 @@ const routes = [
name: 'mobile-jimeng',
component: () => import('@/views/mobile/JimengCreate.vue'),
},
{
path: '/mobile/3d',
name: 'mobile-3d',
meta: { title: '3D模型生成' },
component: () => import('@/views/mobile/ThreeDCreate.vue'),
},
],
},

View File

@@ -0,0 +1,619 @@
<template>
<div class="page-threed">
<!-- 左侧参数设置面板 -->
<div class="params-panel">
<!-- 平台选择Tab -->
<div class="platform-tabs">
<CustomTabs v-model="activePlatform" @change="handlePlatformChange">
<CustomTabPane label="魔力方舟" name="gitee">
<div class="platform-info">
<i class="iconfont icon-gitee"></i>
<span>Gitee AI 3D生成</span>
</div>
</CustomTabPane>
<CustomTabPane label="腾讯混元" name="tencent">
<div class="platform-info">
<i class="iconfont icon-tencent"></i>
<span>腾讯云混元3D生成</span>
</div>
</CustomTabPane>
</CustomTabs>
</div>
<!-- 参数容器 -->
<div class="params-container">
<!-- 图片上传区域 -->
<div class="param-line pt">
<span class="label">上传图片</span>
</div>
<div class="param-line">
<ImageUpload
v-model="currentImage"
:max-count="1"
:multiple="false"
@change="handleImageChange"
/>
</div>
<!-- 文本提示词 -->
<div class="param-line pt">
<span class="label">提示词</span>
</div>
<div class="param-line">
<el-input
v-model="currentPrompt"
type="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
placeholder="请输入3D模型描述越详细越好"
maxlength="2000"
show-word-limit
/>
</div>
<!-- 模型选择 -->
<div class="param-line pt">
<span class="label">输出格式</span>
</div>
<div class="param-line">
<el-select v-model="selectedModel" placeholder="选择输出格式" @change="handleModelChange">
<el-option
v-for="(model, key) in availableModels"
:key="key"
:label="model.name"
:value="key"
/>
</el-select>
</div>
<!-- 算力消耗显示 -->
<div class="param-line pt">
<span class="label">算力消耗</span>
</div>
<div class="power-display">
<span class="power-value">{{ currentPower }}</span>
<span class="power-unit"></span>
</div>
<!-- 生成按钮 -->
<div class="generate-section">
<el-button
type="primary"
size="large"
:loading="generating"
:disabled="!canGenerate"
@click="generate3D"
class="generate-btn"
>
{{ generating ? '生成中...' : '开始生成' }}
</el-button>
</div>
</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-item"
:class="{ completed: task.status === 'completed' }"
>
<div class="task-header">
<span class="task-id">#{{ task.id }}</span>
<span class="task-status" :class="task.status">
{{ getStatusText(task.status) }}
</span>
</div>
<div class="task-content">
<div class="task-prompt">
{{ task.params ? getPromptFromParams(task.params) : '' }}
</div>
<div class="task-progress" v-if="task.status === 'processing'">
<el-progress :percentage="task.progress" :stroke-width="4" />
</div>
</div>
<div class="task-actions" v-if="task.status === 'completed'">
<el-button size="small" @click="preview3D(task)">预览</el-button>
<el-button size="small" type="primary" @click="download3D(task)">下载</el-button>
</div>
<div class="task-actions" v-else>
<el-button size="small" @click="deleteTask(task.id)">删除</el-button>
</div>
</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="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</div>
<!-- 3D预览弹窗 -->
<el-dialog v-model="previewVisible" title="3D模型预览" width="80%" :before-close="closePreview">
<div class="preview-container">
<ThreeDPreview
v-if="currentPreviewTask && currentPreviewTask.img_url"
:model-url="currentPreviewTask.img_url"
:model-type="currentPreviewTask.model"
/>
<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">下载模型</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import ImageUpload from '@/components/ImageUpload.vue'
import ThreeDPreview from '@/components/ThreeDPreview.vue'
import CustomTabPane from '@/components/ui/CustomTabPane.vue'
import CustomTabs from '@/components/ui/CustomTabs.vue'
import { httpGet, httpPost } from '@/utils/http'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
// 响应式数据
const activePlatform = ref('gitee')
const currentImage = ref([])
const currentPrompt = ref('')
const selectedModel = ref('obj')
const generating = ref(false)
const previewVisible = ref(false)
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const taskList = ref([])
const currentPreviewTask = ref(null)
// 平台配置
const platformConfig = {
gitee: {
name: '魔力方舟',
models: {
obj: { name: 'OBJ格式', power: 45 },
glb: { name: 'GLB格式', power: 55 },
stl: { name: 'STL格式', power: 35 },
usdz: { name: 'USDZ格式', power: 65 },
fbx: { name: 'FBX格式', power: 75 },
mp4: { name: 'MP4格式', power: 85 },
},
},
tencent: {
name: '腾讯混元',
models: {
obj: { name: 'OBJ格式', power: 50 },
glb: { name: 'GLB格式', power: 60 },
stl: { name: 'STL格式', power: 40 },
usdz: { name: 'USDZ格式', power: 70 },
fbx: { name: 'FBX格式', power: 80 },
mp4: { name: 'MP4格式', power: 90 },
},
},
}
// 计算属性
const availableModels = computed(() => {
return platformConfig[activePlatform.value]?.models || {}
})
const currentPower = computed(() => {
return availableModels.value[selectedModel.value]?.power || 0
})
const canGenerate = computed(() => {
return currentPrompt.value.trim() && currentImage.value.length > 0 && selectedModel.value
})
// 方法
const handlePlatformChange = (platform) => {
// 切换平台时重置模型选择
if (!availableModels.value[selectedModel.value]) {
selectedModel.value = Object.keys(availableModels.value)[0]
}
}
const handleImageChange = (files) => {
currentImage.value = files
}
const handleModelChange = () => {
// 模型改变时的处理逻辑
}
const generate3D = async () => {
if (!canGenerate.value) {
ElMessage.warning('请完善生成参数')
return
}
try {
generating.value = true
const requestData = {
type: activePlatform.value,
model: selectedModel.value,
prompt: currentPrompt.value,
image_url: currentImage.value[0]?.url || '',
power: currentPower.value,
}
const response = await httpPost('/api/3d/generate', requestData)
if (response.code === 0) {
ElMessage.success('任务创建成功')
// 清空表单
currentImage.value = []
currentPrompt.value = ''
// 刷新任务列表
loadTasks()
} else {
ElMessage.error(response.message || '创建任务失败')
}
} catch (error) {
ElMessage.error('创建任务失败:' + error.message)
} finally {
generating.value = false
}
}
const loadTasks = async () => {
try {
const response = await httpGet('/api/3d/jobs', {
page: currentPage.value,
page_size: pageSize.value,
})
if (response.code === 0) {
taskList.value = response.data.list
total.value = response.data.total
}
} catch (error) {
ElMessage.error('加载任务列表失败:' + error.message)
}
}
const refreshTasks = () => {
loadTasks()
}
const handleSizeChange = (size) => {
pageSize.value = size
currentPage.value = 1
loadTasks()
}
const handleCurrentChange = (page) => {
currentPage.value = page
loadTasks()
}
const deleteTask = async (taskId) => {
try {
await ElMessageBox.confirm('确定要删除这个任务吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
const response = await httpGet(`/api/3d/job/${taskId}/delete`)
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
currentPreviewTask.value = null
}
const download3D = async (task) => {
if (!task.img_url) {
ElMessage.warning('模型文件不存在')
return
}
try {
// 创建一个隐藏的a标签来下载文件
const link = document.createElement('a')
link.href = task.img_url
link.download = `3d_model_${task.id}.${task.model}`
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
ElMessage.success('开始下载3D模型')
} catch (error) {
console.error('下载失败:', error)
ElMessage.error('下载失败,请重试')
}
}
const downloadCurrentModel = () => {
if (currentPreviewTask.value) {
download3D(currentPreviewTask.value)
}
}
const getStatusText = (status) => {
const statusMap = {
pending: '等待中',
processing: '处理中',
completed: '已完成',
failed: '失败',
}
return statusMap[status] || status
}
const getPromptFromParams = (paramsStr) => {
try {
const params = JSON.parse(paramsStr)
return params.prompt || ''
} catch {
return ''
}
}
// 生命周期
onMounted(() => {
loadTasks()
})
</script>
<style lang="scss" scoped>
.page-threed {
display: flex;
height: 100vh;
background: #f5f5f5;
}
.params-panel {
width: 400px;
background: white;
border-right: 1px solid #e4e7ed;
padding: 20px;
overflow-y: auto;
}
.platform-tabs {
margin-bottom: 20px;
}
.platform-info {
display: flex;
align-items: center;
gap: 8px;
i {
font-size: 18px;
color: #409eff;
}
}
.params-container {
.param-line {
margin-bottom: 16px;
&.pt {
margin-top: 20px;
}
.label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
}
}
.power-display {
display: flex;
align-items: center;
gap: 8px;
.power-value {
font-size: 24px;
font-weight: bold;
color: #409eff;
}
.power-unit {
color: #666;
}
}
.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: 20px;
h3 {
margin: 0;
color: #333;
}
}
}
.task-items {
.task-item {
background: white;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
border: 1px solid #e4e7ed;
&.completed {
border-color: #67c23a;
background: #f0f9ff;
}
.task-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.task-id {
font-weight: 500;
color: #666;
}
.task-status {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
&.pending {
background: #fdf6ec;
color: #e6a23c;
}
&.processing {
background: #ecf5ff;
color: #409eff;
}
&.completed {
background: #f0f9ff;
color: #67c23a;
}
&.failed {
background: #fef0f0;
color: #f56c6c;
}
}
}
.task-content {
margin-bottom: 12px;
.task-prompt {
color: #666;
margin-bottom: 8px;
line-height: 1.4;
}
}
.task-actions {
display: flex;
gap: 8px;
}
}
}
.pagination {
display: flex;
justify-content: center;
margin-top: 20px;
}
.preview-container {
.three-container {
width: 100%;
height: 500px;
background: #f0f0f0;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #666;
}
.preview-placeholder {
width: 100%;
height: 500px;
background: #f0f0f0;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #666;
i {
font-size: 48px;
margin-bottom: 12px;
color: #999;
}
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 #e4e7ed;
}
}
</style>

View File

@@ -1,18 +0,0 @@
<template>
<div>
支付回调
</div>
</template>
<script setup>
import {useRouter} from "vue-router";
import {isMobile} from "@/utils/libs";
const router = useRouter()
console.log(router.currentRoute.value.query)
if (isMobile()) {
router.push('/mobile/profile')
} else {
window.close()
}
</script>

View File

@@ -0,0 +1,405 @@
<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"
show-password
/>
</el-form-item>
<el-form-item label="SecretKey">
<el-input
v-model="configs.tencent.secret_key"
placeholder="请输入腾讯云SecretKey"
show-password
/>
</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密钥"
show-password
/>
</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

@@ -0,0 +1,551 @@
<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="completed" />
<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.completed }}</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" stripe border style="width: 100%">
<el-table-column prop="user_id" label="用户ID" />
<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 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 }">
{{ formatTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间">
<template #default="{ row }">
{{ formatTime(row.updated_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="viewTask(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="创建时间">{{
formatTime(currentTask.created_at)
}}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{
formatTime(currentTask.updated_at)
}}</el-descriptions-item>
</el-descriptions>
<div class="task-params">
<h4>任务参数</h4>
<el-input v-model="taskParamsDisplay" type="textarea" :rows="6" readonly />
</div>
<div v-if="currentTask.img_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.preview_url" @click="viewPreview(currentTask.preview_url)">
查看预览
</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>
<!-- 预览图片弹窗 -->
<el-dialog v-model="previewVisible" title="预览图片" width="50%">
<div class="preview-container">
<el-image :src="previewUrl" fit="contain" style="width: 100%; height: 400px" />
</div>
</el-dialog>
</div>
</template>
<script setup>
import { httpGet } from '@/utils/http'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, 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 previewVisible = ref(false)
const currentTask = ref(null)
const previewUrl = ref('')
// 搜索表单
const searchForm = reactive({
status: '',
type: '',
userId: '',
})
// 统计数据
const stats = reactive({
pending: 0,
processing: 0,
completed: 0,
failed: 0,
})
// 计算属性
const taskParamsDisplay = computed(() => {
if (!currentTask.value?.params) return '无参数'
try {
const params = JSON.parse(currentTask.value.params)
return JSON.stringify(params, null, 2)
} catch {
return currentTask.value.params
}
})
// 方法
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.list
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 = (task) => {
if (task.img_url) {
const link = document.createElement('a')
link.href = task.img_url
link.download = `3d_model_${task.id}.${task.model}`
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
ElMessage.success('开始下载3D模型')
} else {
ElMessage.warning('模型文件不存在')
}
}
const viewPreview = (url) => {
previewUrl.value = url
previewVisible.value = true
}
const getStatusType = (status) => {
const typeMap = {
pending: 'warning',
processing: 'primary',
completed: 'success',
failed: 'danger',
}
return typeMap[status] || 'info'
}
const getStatusText = (status) => {
const textMap = {
pending: '等待中',
processing: '处理中',
completed: '已完成',
failed: '失败',
}
return textMap[status] || status
}
const getProgressStatus = (status) => {
if (status === 'failed') return 'exception'
if (status === 'completed') return 'success'
return ''
}
const formatTime = (timestamp) => {
if (!timestamp) return '-'
const date = new Date(timestamp * 1000)
return date.toLocaleString()
}
// 生命周期
onMounted(() => {
loadData()
loadStats()
})
</script>
<style lang="scss" scoped>
.admin-threed-jobs {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h2 {
margin: 0;
color: #333;
}
}
.search-section {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
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: white;
padding: 20px;
border-radius: 8px;
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: #333;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: #666;
}
}
}
}
.table-section {
background: white;
border-radius: 8px;
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: #333;
font-size: 16px;
}
}
.result-links {
display: flex;
gap: 12px;
}
}
.preview-container {
text-align: center;
}
</style>

View File

@@ -241,7 +241,7 @@ onMounted(() => {
//
const loadConfig = async () => {
try {
const res = await httpGet('/api/admin/jimeng/config')
const res = await httpGet('/api/admin/config/get?key=jimeng')
jimengConfig.value = res.data
} catch (e) {
ElMessage.error('加载配置失败: ' + e.message)

View File

@@ -265,7 +265,7 @@ watch(activeTab, (newTab) => {
const saveModerationConfig = async () => {
loading.value = true
try {
await httpPost('/api/admin/config/update/moderation', configs.value)
await httpPost('/api/admin/moderation/config', configs.value)
ElMessage.success('保存成功')
} catch (e) {
ElMessage.error('保存失败:' + (e.message || '未知错误'))
@@ -289,7 +289,7 @@ const testModeration = async () => {
testLoading.value = true
try {
const res = await httpPost('/api/admin/config/moderation/test', {
const res = await httpPost('/api/admin/moderation/test', {
text: testForm.value.text.trim(),
service: configs.value.active,
})

View File

@@ -167,6 +167,13 @@ const features = ref([
color: '#F97316',
url: '/mobile/jimeng',
},
{
key: '3d',
name: '3D生成',
icon: 'icon-3d',
color: '#8B5CF6',
url: '/mobile/3d',
},
{ key: 'agent', name: '智能体', icon: 'icon-app', color: '#3B82F6', url: '/mobile/apps' },
{
key: 'imgWall',

View File

@@ -0,0 +1,765 @@
<template>
<div class="mobile-threed-create">
<!-- 顶部导航 -->
<div class="top-nav">
<div class="nav-left" @click="$router.go(-1)">
<i class="iconfont icon-arrow-left"></i>
</div>
<div class="nav-title">3D模型生成</div>
<div class="nav-right"></div>
</div>
<!-- 平台选择 -->
<div class="platform-selector">
<div class="selector-tabs">
<div
v-for="platform in platforms"
:key="platform.key"
:class="['selector-tab', { active: activePlatform === platform.key }]"
@click="activePlatform = platform.key"
>
<div class="tab-icon">
<i :class="platform.icon"></i>
</div>
<div class="tab-name">{{ platform.name }}</div>
</div>
</div>
</div>
<!-- 参数设置 -->
<div class="params-section">
<!-- 图片上传 -->
<div class="param-group">
<div class="param-label">上传图片</div>
<div class="image-upload-area">
<ImageUpload
v-model="currentImage"
:max-count="1"
:multiple="false"
@change="handleImageChange"
/>
</div>
</div>
<!-- 提示词输入 -->
<div class="param-group">
<div class="param-label">提示词描述</div>
<div class="prompt-input">
<el-input
v-model="currentPrompt"
type="textarea"
:rows="4"
placeholder="请输入3D模型描述越详细越好"
maxlength="2000"
show-word-limit
/>
</div>
</div>
<!-- 模型选择 -->
<div class="param-group">
<div class="param-label">输出格式</div>
<div class="model-selector">
<div
v-for="(model, key) in availableModels"
:key="key"
:class="['model-option', { active: selectedModel === key }]"
@click="selectedModel = key"
>
<div class="model-name">{{ model.name }}</div>
<div class="model-power">{{ model.power }}</div>
</div>
</div>
</div>
<!-- 算力消耗 -->
<div class="power-info">
<div class="power-label">算力消耗</div>
<div class="power-value">{{ currentPower }} </div>
</div>
</div>
<!-- 生成按钮 -->
<div class="generate-section">
<el-button
type="primary"
size="large"
:loading="generating"
:disabled="!canGenerate"
@click="generate3D"
class="generate-btn"
>
{{ generating ? '生成中...' : '开始生成' }}
</el-button>
</div>
<!-- 任务列表 -->
<div class="task-section">
<div class="section-header">
<h3>生成任务</h3>
<el-button size="small" @click="refreshTasks">刷新</el-button>
</div>
<div class="task-list">
<div
v-for="task in taskList"
:key="task.id"
class="task-item"
:class="{ completed: task.status === 'completed' }"
>
<div class="task-main">
<div class="task-info">
<div class="task-id">#{{ task.id }}</div>
<div class="task-status" :class="task.status">
{{ getStatusText(task.status) }}
</div>
</div>
<div class="task-prompt">
{{ task.params ? getPromptFromParams(task.params) : '' }}
</div>
<div class="task-progress" v-if="task.status === 'processing'">
<el-progress :percentage="task.progress" :stroke-width="6" />
</div>
</div>
<div class="task-actions">
<template v-if="task.status === 'completed'">
<el-button size="small" @click="preview3D(task)">预览</el-button>
<el-button size="small" type="primary" @click="download3D(task)">下载</el-button>
</template>
<template v-else>
<el-button size="small" @click="deleteTask(task.id)">删除</el-button>
</template>
</div>
</div>
</div>
<!-- 加载更多 -->
<div class="load-more" v-if="hasMore">
<el-button size="small" @click="loadMoreTasks">加载更多</el-button>
</div>
</div>
<!-- 3D预览弹窗 -->
<el-dialog
v-model="previewVisible"
title="3D模型预览"
width="90%"
:before-close="closePreview"
class="mobile-dialog"
>
<div class="preview-container">
<div id="three-container" class="three-container">
<div class="preview-placeholder">
<i class="iconfont icon-3d"></i>
<p>3D模型预览</p>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="closePreview">关闭</el-button>
<el-button type="primary" @click="downloadCurrentModel">下载模型</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import ImageUpload from '@/components/ImageUpload.vue'
import { httpGet, httpPost } from '@/utils/http'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, nextTick, onMounted, ref } from 'vue'
// 响应式数据
const activePlatform = ref('gitee')
const currentImage = ref([])
const currentPrompt = ref('')
const selectedModel = ref('obj')
const generating = ref(false)
const previewVisible = ref(false)
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const taskList = ref([])
const currentPreviewTask = ref(null)
const hasMore = ref(true)
// 平台配置
const platforms = [
{
key: 'gitee',
name: '魔力方舟',
icon: 'icon-gitee',
},
{
key: 'tencent',
name: '腾讯混元',
icon: 'icon-tencent',
},
]
const platformConfig = {
gitee: {
name: '魔力方舟',
models: {
obj: { name: 'OBJ格式', power: 45 },
glb: { name: 'GLB格式', power: 55 },
stl: { name: 'STL格式', power: 35 },
usdz: { name: 'USDZ格式', power: 65 },
fbx: { name: 'FBX格式', power: 75 },
mp4: { name: 'MP4格式', power: 85 },
},
},
tencent: {
name: '腾讯混元',
models: {
obj: { name: 'OBJ格式', power: 50 },
glb: { name: 'GLB格式', power: 60 },
stl: { name: 'STL格式', power: 40 },
usdz: { name: 'USDZ格式', power: 70 },
fbx: { name: 'FBX格式', power: 80 },
mp4: { name: 'MP4格式', power: 90 },
},
},
}
// 计算属性
const availableModels = computed(() => {
return platformConfig[activePlatform.value]?.models || {}
})
const currentPower = computed(() => {
return availableModels.value[selectedModel.value]?.power || 0
})
const canGenerate = computed(() => {
return currentPrompt.value.trim() && currentImage.value.length > 0 && selectedModel.value
})
// 方法
const handleImageChange = (files) => {
currentImage.value = files
}
const generate3D = async () => {
if (!canGenerate.value) {
ElMessage.warning('请完善生成参数')
return
}
try {
generating.value = true
const requestData = {
type: activePlatform.value,
model: selectedModel.value,
prompt: currentPrompt.value,
image_url: currentImage.value[0]?.url || '',
power: currentPower.value,
}
const response = await httpPost('/api/3d/generate', requestData)
if (response.code === 0) {
ElMessage.success('任务创建成功')
// 清空表单
currentImage.value = []
currentPrompt.value = ''
// 刷新任务列表
loadTasks(true)
} else {
ElMessage.error(response.message || '创建任务失败')
}
} catch (error) {
ElMessage.error('创建任务失败:' + error.message)
} finally {
generating.value = false
}
}
const loadTasks = async (reset = false) => {
try {
if (reset) {
currentPage.value = 1
taskList.value = []
}
const response = await httpGet('/api/3d/jobs', {
page: currentPage.value,
page_size: pageSize.value,
})
if (response.code === 0) {
if (reset) {
taskList.value = response.data.list
} else {
taskList.value.push(...response.data.list)
}
total.value = response.data.total
hasMore.value = taskList.value.length < total.value
}
} catch (error) {
ElMessage.error('加载任务列表失败:' + error.message)
}
}
const refreshTasks = () => {
loadTasks(true)
}
const loadMoreTasks = () => {
if (hasMore.value) {
currentPage.value++
loadTasks()
}
}
const deleteTask = async (taskId) => {
try {
await ElMessageBox.confirm('确定要删除这个任务吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
const response = await httpGet(`/api/3d/job/${taskId}/delete`)
if (response.code === 0) {
ElMessage.success('删除成功')
loadTasks(true)
} else {
ElMessage.error(response.message || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败:' + error.message)
}
}
}
const preview3D = (task) => {
currentPreviewTask.value = task
previewVisible.value = true
nextTick(() => {
initThreeJS(task)
})
}
const closePreview = () => {
previewVisible.value = false
currentPreviewTask.value = null
}
const download3D = async (task) => {
if (!task.img_url) {
ElMessage.warning('模型文件不存在')
return
}
try {
// 创建一个隐藏的a标签来下载文件
const link = document.createElement('a')
link.href = task.img_url
link.download = `3d_model_${task.id}.${task.model}`
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
ElMessage.success('开始下载3D模型')
} catch (error) {
console.error('下载失败:', error)
ElMessage.error('下载失败,请重试')
}
}
const downloadCurrentModel = () => {
if (currentPreviewTask.value) {
download3D(currentPreviewTask.value)
}
}
const getStatusText = (status) => {
const statusMap = {
pending: '等待中',
processing: '处理中',
completed: '已完成',
failed: '失败',
}
return statusMap[status] || status
}
const getPromptFromParams = (paramsStr) => {
try {
const params = JSON.parse(paramsStr)
return params.prompt || ''
} catch {
return ''
}
}
// Three.js 初始化
const initThreeJS = (task) => {
// TODO: 实现Three.js 3D模型预览
console.log('初始化Three.js预览:', task)
}
// 生命周期
onMounted(() => {
loadTasks(true)
})
</script>
<style lang="scss" scoped>
.mobile-threed-create {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 20px;
}
.top-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: white;
border-bottom: 1px solid #e4e7ed;
position: sticky;
top: 0;
z-index: 100;
.nav-left {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
i {
font-size: 20px;
color: #333;
}
}
.nav-title {
font-size: 18px;
font-weight: 500;
color: #333;
}
.nav-right {
width: 24px;
}
}
.platform-selector {
background: white;
margin: 16px 20px;
border-radius: 12px;
padding: 20px;
.selector-tabs {
display: flex;
gap: 12px;
.selector-tab {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 12px;
border-radius: 8px;
border: 2px solid #e4e7ed;
cursor: pointer;
transition: all 0.3s;
&.active {
border-color: #409eff;
background: #ecf5ff;
}
.tab-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
i {
font-size: 24px;
color: #409eff;
}
}
.tab-name {
font-size: 14px;
color: #333;
text-align: center;
}
}
}
}
.params-section {
background: white;
margin: 16px 20px;
border-radius: 12px;
padding: 20px;
.param-group {
margin-bottom: 24px;
.param-label {
font-size: 16px;
font-weight: 500;
color: #333;
margin-bottom: 12px;
}
.image-upload-area {
border: 2px dashed #d9d9d9;
border-radius: 8px;
padding: 20px;
text-align: center;
transition: border-color 0.3s;
&:hover {
border-color: #409eff;
}
}
.prompt-input {
.el-textarea {
.el-textarea__inner {
border-radius: 8px;
border: 1px solid #d9d9d9;
}
}
}
.model-selector {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
.model-option {
padding: 16px 12px;
border: 2px solid #e4e7ed;
border-radius: 8px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
&.active {
border-color: #409eff;
background: #ecf5ff;
}
.model-name {
font-size: 14px;
color: #333;
margin-bottom: 4px;
}
.model-power {
font-size: 12px;
color: #409eff;
font-weight: 500;
}
}
}
}
}
.power-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: #f0f9ff;
border-radius: 8px;
border: 1px solid #b3d8ff;
.power-label {
font-size: 16px;
color: #333;
}
.power-value {
font-size: 20px;
font-weight: bold;
color: #409eff;
}
}
.generate-section {
margin: 16px 20px;
.generate-btn {
width: 100%;
height: 48px;
font-size: 16px;
border-radius: 12px;
}
}
.task-section {
background: white;
margin: 16px 20px;
border-radius: 12px;
padding: 20px;
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h3 {
margin: 0;
font-size: 18px;
color: #333;
}
}
}
.task-list {
.task-item {
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
&.completed {
border-color: #67c23a;
background: #f0f9ff;
}
.task-main {
margin-bottom: 16px;
.task-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.task-id {
font-weight: 500;
color: #666;
}
.task-status {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
&.pending {
background: #fdf6ec;
color: #e6a23c;
}
&.processing {
background: #ecf5ff;
color: #409eff;
}
&.completed {
background: #f0f9ff;
color: #67c23a;
}
&.failed {
background: #fef0f0;
color: #f56c6c;
}
}
}
.task-prompt {
color: #666;
margin-bottom: 12px;
line-height: 1.4;
font-size: 14px;
}
}
.task-actions {
display: flex;
gap: 8px;
.el-button {
flex: 1;
}
}
}
}
.load-more {
text-align: center;
margin-top: 20px;
}
.preview-container {
.three-container {
width: 100%;
height: 300px;
background: #f0f0f0;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
.preview-placeholder {
text-align: center;
color: #666;
i {
font-size: 48px;
margin-bottom: 12px;
display: block;
}
p {
margin: 0;
font-size: 14px;
}
}
}
}
// 移动端弹窗样式
.mobile-dialog {
:deep(.el-dialog) {
margin: 20px;
border-radius: 12px;
}
:deep(.el-dialog__header) {
padding: 20px 20px 0;
}
:deep(.el-dialog__body) {
padding: 20px;
}
:deep(.el-dialog__footer) {
padding: 0 20px 20px;
.dialog-footer {
display: flex;
gap: 12px;
.el-button {
flex: 1;
}
}
}
}
</style>