修改bug

This commit is contained in:
zhangxiaohua 2026-01-14 16:14:15 +08:00
parent b57099df15
commit 38b23e7e77
2 changed files with 674 additions and 99 deletions

View File

@ -17,8 +17,8 @@
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 20 }"
layout="horizontal"
>
<a-form-item label="作品名称" name="title" required>
@ -33,33 +33,129 @@
<a-textarea
v-model:value="form.description"
placeholder="请输入作品介绍"
:rows="6"
:rows="4"
:maxlength="2000"
show-count
/>
</a-form-item>
<a-form-item label="上传作品" name="workFile" required>
<a-upload
v-model:file-list="workFileList"
:before-upload="handleBeforeUploadWork"
:remove="handleRemoveWork"
:accept="workAcceptTypes"
:max-count="1"
>
<a-button>
<template #icon><UploadOutlined /></template>
选择
</a-button>
<template #tip>
<div class="upload-tip">
支持3D文件格式.obj, .fbx, .3ds, .dae, .blend, .max, .c4d, .stl,
.ply, .gltf, .glb
</div>
</template>
</a-upload>
<a-form-item label="选择方式" required>
<a-radio-group v-model:value="uploadMode" @change="handleModeChange">
<a-radio value="history">从创作历史选择</a-radio>
<a-radio value="local">本地上传</a-radio>
</a-radio-group>
</a-form-item>
<!-- 从创作历史选择模式 -->
<template v-if="uploadMode === 'history'">
<a-form-item label="选择作品" name="selectedWork" required>
<div class="work-selection">
<!-- 加载状态 -->
<div v-if="worksLoading" class="works-loading">
<a-spin />
<span>加载创作历史...</span>
</div>
<!-- 空状态 -->
<div v-else-if="completedWorks.length === 0" class="works-empty">
<a-empty description="暂无已完成的3D作品">
<template #image>
<FileImageOutlined style="font-size: 48px; color: #d9d9d9" />
</template>
</a-empty>
<a-button type="link" @click="goTo3DLab">
前往3D建模实验室创作
</a-button>
</div>
<!-- 作品网格 -->
<div v-else class="works-grid">
<div
v-for="work in completedWorks"
:key="work.id"
class="work-card"
:class="{ 'is-selected': form.selectedWorkId === work.id }"
@click="handleSelectWork(work)"
>
<div class="work-preview">
<img
v-if="work.previewUrl"
:src="getPreviewUrl(work.previewUrl)"
alt="预览"
@error="handleImageError"
/>
<div v-else class="preview-placeholder">
<FileImageOutlined />
</div>
<div v-if="form.selectedWorkId === work.id" class="selected-badge">
<CheckOutlined />
</div>
</div>
<div class="work-info">
<div class="work-desc" :title="work.inputContent">
{{ work.inputContent }}
</div>
<div class="work-time">
{{ formatTime(work.createTime) }}
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div v-if="worksPagination.total > worksPagination.pageSize" class="works-pagination">
<a-pagination
v-model:current="worksPagination.current"
:total="worksPagination.total"
:page-size="worksPagination.pageSize"
size="small"
@change="handleWorksPageChange"
/>
</div>
</div>
</a-form-item>
</template>
<!-- 本地上传模式 -->
<template v-else>
<a-form-item label="上传作品" name="localWorkFile" required>
<a-upload
v-model:file-list="localWorkFileList"
:before-upload="handleBeforeUploadWork"
:remove="handleRemoveWork"
:accept="workAcceptTypes"
:max-count="1"
>
<a-button>
<template #icon><UploadOutlined /></template>
选择3D文件
</a-button>
</a-upload>
<div class="upload-tip">
支持格式.obj, .fbx, .3ds, .dae, .blend, .stl, .ply, .gltf, .glb最大500MB
</div>
</a-form-item>
<a-form-item label="预览图" name="localPreviewFile" required>
<a-upload
v-model:file-list="localPreviewFileList"
:before-upload="handleBeforeUploadPreview"
:remove="handleRemovePreview"
accept="image/*"
:max-count="1"
list-type="picture-card"
>
<div v-if="localPreviewFileList.length === 0" class="upload-placeholder">
<PlusOutlined />
<div>上传预览图</div>
</div>
</a-upload>
<div class="upload-tip">
请上传作品预览图支持 JPGPNG 格式用于作品展示
</div>
</a-form-item>
</template>
<a-form-item label="上传附件">
<a-upload
v-model:file-list="attachmentFileList"
@ -69,9 +165,17 @@
>
<a-button>
<template #icon><UploadOutlined /></template>
上传
上传附件
</a-button>
<template #itemRender="{ file, actions }">
<div class="attachment-item">
<PaperClipOutlined />
<span class="file-name">{{ file.name }}</span>
<DeleteOutlined class="delete-icon" @click="actions.remove" />
</div>
</template>
</a-upload>
<div class="upload-tip">可上传补充材料最多5个文件单个文件不超过100MB</div>
</a-form-item>
</a-form>
@ -79,7 +183,7 @@
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button type="primary" :loading="submitLoading" @click="handleSubmit">
确定
提交作品
</a-button>
</a-space>
</template>
@ -87,13 +191,23 @@
</template>
<script setup lang="ts">
import { ref, reactive, watch } from "vue"
import { ref, reactive, watch, computed } from "vue"
import { useRouter, useRoute } from "vue-router"
import { message } from "ant-design-vue"
import { UploadOutlined } from "@ant-design/icons-vue"
import {
UploadOutlined,
FileImageOutlined,
CheckOutlined,
PaperClipOutlined,
DeleteOutlined,
PlusOutlined,
} from "@ant-design/icons-vue"
import type { FormInstance, UploadFile } from "ant-design-vue"
import { worksApi, registrationsApi, type SubmitWorkForm } from "@/api/contests"
import { getAI3DTasks, type AI3DTask } from "@/api/ai-3d"
import { useAuthStore } from "@/stores/auth"
import request from "@/utils/request"
import dayjs from "dayjs"
interface Props {
open: boolean
@ -108,47 +222,102 @@ interface Emits {
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const visible = ref(false)
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
//
const uploadMode = ref<"history" | "local">("history")
// 3D
const workAcceptTypes =
".obj,.fbx,.3ds,.dae,.blend,.max,.c4d,.stl,.ply,.gltf,.glb"
//
const form = reactive<{
title: string
description: string
workFile: File | null
//
selectedWorkId: number | null
selectedWork: AI3DTask | null
//
localWorkFile: File | null
localPreviewFile: File | null
//
attachmentFiles: File[]
}>({
title: "",
description: "",
workFile: null,
selectedWorkId: null,
selectedWork: null,
localWorkFile: null,
localPreviewFile: null,
attachmentFiles: [],
})
const workFileList = ref<UploadFile[]>([])
//
const localWorkFileList = ref<UploadFile[]>([])
const localPreviewFileList = ref<UploadFile[]>([])
const attachmentFileList = ref<UploadFile[]>([])
// 3D
const worksLoading = ref(false)
const worksList = ref<AI3DTask[]>([])
const worksPagination = reactive({
current: 1,
pageSize: 8,
total: 0,
})
//
const completedWorks = computed(() => {
return worksList.value.filter((work) => work.status === "completed")
})
//
const rules = {
const rules = computed(() => ({
title: [{ required: true, message: "请输入作品名称", trigger: "blur" }],
description: [{ required: true, message: "请输入作品介绍", trigger: "blur" }],
workFile: [
selectedWork: [
{
required: true,
required: uploadMode.value === "history",
validator: () => {
if (!form.workFile) {
return Promise.reject(new Error("请上传作品文件"))
if (uploadMode.value === "history" && !form.selectedWorkId) {
return Promise.reject(new Error("请选择一个3D作品"))
}
return Promise.resolve()
},
trigger: "change",
},
],
}
localWorkFile: [
{
required: uploadMode.value === "local",
validator: () => {
if (uploadMode.value === "local" && !form.localWorkFile) {
return Promise.reject(new Error("请上传3D文件"))
}
return Promise.resolve()
},
trigger: "change",
},
],
localPreviewFile: [
{
required: uploadMode.value === "local",
validator: () => {
if (uploadMode.value === "local" && !form.localPreviewFile) {
return Promise.reject(new Error("请上传预览图"))
}
return Promise.resolve()
},
trigger: "change",
},
],
}))
watch(
() => props.open,
@ -157,6 +326,7 @@ watch(
if (newVal) {
resetForm()
fetchRegistrationId()
fetchWorks()
}
},
{ immediate: true }
@ -166,6 +336,20 @@ watch(visible, (newVal) => {
emit("update:open", newVal)
})
//
const handleModeChange = () => {
if (uploadMode.value === "history") {
form.localWorkFile = null
form.localPreviewFile = null
localWorkFileList.value = []
localPreviewFileList.value = []
} else {
form.selectedWorkId = null
form.selectedWork = null
}
formRef.value?.clearValidate()
}
// ID
const registrationIdRef = ref<number | null>(null)
@ -201,60 +385,137 @@ const fetchRegistrationId = async () => {
}
}
//
// 3D
const fetchWorks = async () => {
worksLoading.value = true
try {
const res = await getAI3DTasks({
page: worksPagination.current,
pageSize: worksPagination.pageSize,
status: "completed",
})
const data = res.data || res
worksList.value = data.list || []
worksPagination.total = data.total || 0
} catch (error) {
console.error("获取创作历史失败:", error)
message.error("获取创作历史失败")
} finally {
worksLoading.value = false
}
}
//
const handleWorksPageChange = (page: number) => {
worksPagination.current = page
fetchWorks()
}
//
const handleSelectWork = (work: AI3DTask) => {
if (form.selectedWorkId === work.id) {
form.selectedWorkId = null
form.selectedWork = null
} else {
form.selectedWorkId = work.id
form.selectedWork = work
if (!form.title && work.inputContent) {
form.title = work.inputContent.substring(0, 50)
}
}
}
// URL
const getPreviewUrl = (url: string) => {
if (!url) return ""
if (url.includes("tencentcos.cn") || url.includes("qcloud.com")) {
return `/api/ai-3d/proxy-preview?url=${encodeURIComponent(url)}`
}
return url
}
//
const handleImageError = (e: Event) => {
const img = e.target as HTMLImageElement
img.style.display = "none"
}
//
const formatTime = (time: string) => {
return dayjs(time).format("MM-DD HH:mm")
}
// 3D
const goTo3DLab = () => {
const tenantCode = route.params.tenantCode as string
router.push(`/${tenantCode}/workbench/3d-lab`)
visible.value = false
}
// 3D
const handleBeforeUploadWork = (file: File): boolean => {
const fileName = file.name.toLowerCase()
const validExtensions = [
".obj",
".fbx",
".3ds",
".dae",
".blend",
".max",
".c4d",
".stl",
".ply",
".gltf",
".glb",
".obj", ".fbx", ".3ds", ".dae", ".blend",
".max", ".c4d", ".stl", ".ply", ".gltf", ".glb",
]
const isValid = validExtensions.some((ext) => fileName.endsWith(ext))
if (!isValid) {
message.error(
"请上传3D文件格式.obj, .fbx, .3ds, .dae, .blend, .max, .c4d, .stl, .ply, .gltf, .glb"
)
message.error("请上传支持的3D文件格式")
return false
}
// 500MB
const maxSize = 500 * 1024 * 1024 // 500MB
const maxSize = 500 * 1024 * 1024
if (file.size > maxSize) {
message.error("文件大小不能超过500MB")
return false
}
form.workFile = file
return false //
form.localWorkFile = file
return false
}
//
// 3D
const handleRemoveWork = () => {
form.workFile = null
form.localWorkFile = null
return true
}
//
const handleBeforeUploadPreview = (file: File): boolean => {
const isImage = file.type.startsWith("image/")
if (!isImage) {
message.error("请上传图片文件")
return false
}
const maxSize = 10 * 1024 * 1024
if (file.size > maxSize) {
message.error("图片大小不能超过10MB")
return false
}
form.localPreviewFile = file
return false
}
//
const handleRemovePreview = () => {
form.localPreviewFile = null
return true
}
//
const handleBeforeUploadAttachment = (file: File): boolean => {
// 100MB
const maxSize = 100 * 1024 * 1024 // 100MB
const maxSize = 100 * 1024 * 1024
if (file.size > maxSize) {
message.error("附件大小不能超过100MB")
return false
}
form.attachmentFiles.push(file)
return false //
return false
}
//
@ -270,12 +531,18 @@ const handleRemoveAttachment = (file: UploadFile) => {
//
const resetForm = () => {
uploadMode.value = "history"
form.title = ""
form.description = ""
form.workFile = null
form.selectedWorkId = null
form.selectedWork = null
form.localWorkFile = null
form.localPreviewFile = null
form.attachmentFiles = []
workFileList.value = []
localWorkFileList.value = []
localPreviewFileList.value = []
attachmentFileList.value = []
worksPagination.current = 1
formRef.value?.resetFields()
}
@ -291,24 +558,45 @@ const handleSubmit = async () => {
return
}
if (!form.workFile) {
message.error("请上传作品文件")
return
}
submitLoading.value = true
// URL
const workFileUrls: string[] = []
let workFileUrl = ""
let previewUrl = ""
const attachmentUrls: string[] = []
//
if (form.workFile) {
if (uploadMode.value === "history") {
//
if (!form.selectedWork) {
message.error("请选择一个3D作品")
return
}
workFileUrl = form.selectedWork.resultUrl || ""
previewUrl = form.selectedWork.previewUrl || ""
} else {
//
if (!form.localWorkFile) {
message.error("请上传3D文件")
return
}
if (!form.localPreviewFile) {
message.error("请上传预览图")
return
}
// 3D
try {
const workUrl = await uploadFile(form.workFile)
workFileUrls.push(workUrl)
workFileUrl = await uploadFile(form.localWorkFile)
} catch (error: any) {
message.error("作品文件上传失败:" + (error?.message || "未知错误"))
message.error("3D文件上传失败" + (error?.message || "未知错误"))
submitLoading.value = false
return
}
//
try {
previewUrl = await uploadFile(form.localPreviewFile)
} catch (error: any) {
message.error("预览图上传失败:" + (error?.message || "未知错误"))
submitLoading.value = false
return
}
@ -321,7 +609,6 @@ const handleSubmit = async () => {
attachmentUrls.push(url)
} catch (error: any) {
console.error("附件上传失败:", error)
//
}
}
@ -329,7 +616,8 @@ const handleSubmit = async () => {
registrationId: registrationIdRef.value,
title: form.title,
description: form.description,
files: [...workFileUrls, ...attachmentUrls],
files: [workFileUrl, ...attachmentUrls],
previewUrl: previewUrl,
}
await worksApi.submit(submitData)
@ -340,7 +628,6 @@ const handleSubmit = async () => {
resetForm()
} catch (error: any) {
if (error?.errorFields) {
//
return
}
message.error(error?.response?.data?.message || "作品提交失败")
@ -355,14 +642,12 @@ const uploadFile = async (file: File): Promise<string> => {
formData.append("file", file)
try {
// 使
const response = await request.post<any>("/upload", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
})
// URL
if (response && typeof response === "object" && "url" in response) {
return String(response.url)
}
@ -388,6 +673,167 @@ const handleCancel = () => {
color: #303133;
}
.work-selection {
border: 1px solid #d9d9d9;
border-radius: 8px;
padding: 16px;
background: #fafafa;
min-height: 200px;
}
.works-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 180px;
color: #8c8c8c;
}
.works-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 180px;
}
.works-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.work-card {
background: #fff;
border: 2px solid #e8e8e8;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: #1677ff;
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.15);
}
&.is-selected {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.2);
.work-preview {
&::after {
content: "";
position: absolute;
inset: 0;
background: rgba(22, 119, 255, 0.1);
}
}
}
.work-preview {
position: relative;
height: 100px;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-placeholder {
font-size: 32px;
color: #d9d9d9;
}
.selected-badge {
position: absolute;
top: 8px;
right: 8px;
width: 24px;
height: 24px;
background: #1677ff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14px;
z-index: 1;
}
}
.work-info {
padding: 8px;
.work-desc {
font-size: 12px;
color: #303133;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.work-time {
font-size: 11px;
color: #8c8c8c;
}
}
}
.works-pagination {
margin-top: 16px;
display: flex;
justify-content: center;
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #8c8c8c;
.anticon {
font-size: 24px;
margin-bottom: 8px;
}
}
.attachment-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
background: #f5f5f5;
border-radius: 4px;
margin-top: 8px;
.file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.delete-icon {
color: #8c8c8c;
cursor: pointer;
transition: color 0.3s;
&:hover {
color: #ff4d4f;
}
}
}
.upload-tip {
margin-top: 8px;
font-size: 12px;
@ -395,6 +841,18 @@ const handleCancel = () => {
}
:deep(.ant-upload-list) {
margin-top: 8px;
&.ant-upload-list-picture-card {
display: flex;
}
}
:deep(.ant-upload-select-picture-card) {
width: 120px !important;
height: 120px !important;
}
:deep(.ant-upload-list-item-container) {
width: 120px !important;
height: 120px !important;
}
</style>

View File

@ -363,6 +363,10 @@ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"
// @ts-ignore
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js"
// @ts-ignore
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js"
// @ts-ignore
import { MTLLoader } from "three/examples/jsm/loaders/MTLLoader.js"
// @ts-ignore
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"
const router = useRouter()
@ -682,12 +686,12 @@ const loadModel = async () => {
// GLBGLTFZIPZIP
// URL
const urlWithoutQuery = modelUrl.value.split("?")[0].toLowerCase()
const supportedExtensions = [".glb", ".gltf", ".zip"]
const supportedExtensions = [".glb", ".gltf", ".zip", ".obj"]
const isSupported = supportedExtensions.some((ext) =>
urlWithoutQuery.endsWith(ext)
)
if (!isSupported) {
error.value = `不支持的文件格式,目前仅支持 GLB/GLTF/ZIP 格式`
error.value = `不支持的文件格式,目前仅支持 GLB/GLTF/ZIP/OBJ 格式`
loading.value = false
console.error("不支持的文件格式:", modelUrl.value)
return
@ -699,24 +703,25 @@ const loadModel = async () => {
try {
console.log("开始加载模型URL:", modelUrl.value)
// 使CORS
// URL
const proxyUrl = `/api/ai-3d/proxy-model?url=${encodeURIComponent(modelUrl.value)}`
console.log("使用代理URL:", proxyUrl)
// URLURL
// URL: /api/uploads/...
// URL: http:// https:// COS
const isLocalUrl =
modelUrl.value.startsWith("/api/") ||
modelUrl.value.startsWith("/uploads/") ||
(!modelUrl.value.startsWith("http://") &&
!modelUrl.value.startsWith("https://"))
const loader = new GLTFLoader()
// DRACO
if (!dracoLoader) {
dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath(
"https://www.gstatic.com/draco/versioned/decoders/1.5.6/"
)
let loadUrl: string
if (isLocalUrl) {
// URL使
loadUrl = modelUrl.value
console.log("使用本地URL:", loadUrl)
} else {
// URL使CORS
loadUrl = `/api/ai-3d/proxy-model?url=${encodeURIComponent(modelUrl.value)}`
console.log("使用代理URL:", loadUrl)
}
loader.setDRACOLoader(dracoLoader)
// 使URL
const gltf = await loader.loadAsync(proxyUrl)
//
if (model && scene) {
@ -724,7 +729,119 @@ const loadModel = async () => {
disposeModel(model)
}
model = gltf.scene
//
let modelType: "glb" | "gltf" | "obj" = "glb" //
// URL
if (urlWithoutQuery.endsWith(".obj")) {
modelType = "obj"
} else if (urlWithoutQuery.endsWith(".gltf")) {
modelType = "gltf"
}
// ZIP
const needsTypeDetection =
urlWithoutQuery.endsWith(".zip") || loadUrl.includes("proxy-model")
if (needsTypeDetection) {
// ZIP
console.log("获取模型数据以检测类型...")
const response = await fetch(loadUrl)
if (!response.ok) {
throw new Error(`模型加载失败: ${response.status} ${response.statusText}`)
}
// X-Model-Type
const xModelType = response.headers.get("X-Model-Type")
if (xModelType === "obj") {
modelType = "obj"
} else if (xModelType === "gltf") {
modelType = "gltf"
}
console.log("检测到模型类型:", modelType)
//
const arrayBuffer = await response.arrayBuffer()
if (modelType === "obj") {
// OBJ
const decoder = new TextDecoder()
const objText = decoder.decode(arrayBuffer)
const objLoader = new OBJLoader()
model = objLoader.parse(objText)
// OBJ
model.traverse((child: THREE.Object3D) => {
if (child instanceof THREE.Mesh) {
if (
!child.material ||
(child.material as THREE.Material).type === "MeshBasicMaterial"
) {
child.material = new THREE.MeshStandardMaterial({
color: 0x888888,
metalness: 0.3,
roughness: 0.7,
})
}
}
})
} else {
// GLB/GLTF
const gltfLoader = new GLTFLoader()
if (!dracoLoader) {
dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath(
"https://www.gstatic.com/draco/versioned/decoders/1.5.6/"
)
}
gltfLoader.setDRACOLoader(dracoLoader)
// 使 parse
const gltf = await new Promise<any>((resolve, reject) => {
gltfLoader.parse(arrayBuffer, "", resolve, reject)
})
model = gltf.scene
}
} else {
// ZIP 使 loader
console.log("检测到模型类型:", modelType)
if (modelType === "obj") {
const objLoader = new OBJLoader()
model = await objLoader.loadAsync(loadUrl)
model.traverse((child: THREE.Object3D) => {
if (child instanceof THREE.Mesh) {
if (
!child.material ||
(child.material as THREE.Material).type === "MeshBasicMaterial"
) {
child.material = new THREE.MeshStandardMaterial({
color: 0x888888,
metalness: 0.3,
roughness: 0.7,
})
}
}
})
} else {
const gltfLoader = new GLTFLoader()
if (!dracoLoader) {
dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath(
"https://www.gstatic.com/draco/versioned/decoders/1.5.6/"
)
}
gltfLoader.setDRACOLoader(dracoLoader)
const gltf = await gltfLoader.loadAsync(loadUrl)
model = gltf.scene
}
}
//
let vertexCount = 0