修改bug
This commit is contained in:
parent
b57099df15
commit
38b23e7e77
@ -17,8 +17,8 @@
|
|||||||
ref="formRef"
|
ref="formRef"
|
||||||
:model="form"
|
:model="form"
|
||||||
:rules="rules"
|
:rules="rules"
|
||||||
:label-col="{ span: 6 }"
|
:label-col="{ span: 4 }"
|
||||||
:wrapper-col="{ span: 18 }"
|
:wrapper-col="{ span: 20 }"
|
||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
>
|
>
|
||||||
<a-form-item label="作品名称" name="title" required>
|
<a-form-item label="作品名称" name="title" required>
|
||||||
@ -33,15 +33,94 @@
|
|||||||
<a-textarea
|
<a-textarea
|
||||||
v-model:value="form.description"
|
v-model:value="form.description"
|
||||||
placeholder="请输入作品介绍"
|
placeholder="请输入作品介绍"
|
||||||
:rows="6"
|
:rows="4"
|
||||||
:maxlength="2000"
|
:maxlength="2000"
|
||||||
show-count
|
show-count
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item label="上传作品" name="workFile" required>
|
<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
|
<a-upload
|
||||||
v-model:file-list="workFileList"
|
v-model:file-list="localWorkFileList"
|
||||||
:before-upload="handleBeforeUploadWork"
|
:before-upload="handleBeforeUploadWork"
|
||||||
:remove="handleRemoveWork"
|
:remove="handleRemoveWork"
|
||||||
:accept="workAcceptTypes"
|
:accept="workAcceptTypes"
|
||||||
@ -49,17 +128,34 @@
|
|||||||
>
|
>
|
||||||
<a-button>
|
<a-button>
|
||||||
<template #icon><UploadOutlined /></template>
|
<template #icon><UploadOutlined /></template>
|
||||||
选择
|
选择3D文件
|
||||||
</a-button>
|
</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-upload>
|
||||||
|
<div class="upload-tip">
|
||||||
|
支持格式:.obj, .fbx, .3ds, .dae, .blend, .stl, .ply, .gltf, .glb,最大500MB
|
||||||
|
</div>
|
||||||
</a-form-item>
|
</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">
|
||||||
|
请上传作品预览图,支持 JPG、PNG 格式,用于作品展示
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
<a-form-item label="上传附件">
|
<a-form-item label="上传附件">
|
||||||
<a-upload
|
<a-upload
|
||||||
v-model:file-list="attachmentFileList"
|
v-model:file-list="attachmentFileList"
|
||||||
@ -69,9 +165,17 @@
|
|||||||
>
|
>
|
||||||
<a-button>
|
<a-button>
|
||||||
<template #icon><UploadOutlined /></template>
|
<template #icon><UploadOutlined /></template>
|
||||||
上传
|
上传附件
|
||||||
</a-button>
|
</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>
|
</a-upload>
|
||||||
|
<div class="upload-tip">可上传补充材料,最多5个文件,单个文件不超过100MB</div>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
|
|
||||||
@ -79,7 +183,7 @@
|
|||||||
<a-space>
|
<a-space>
|
||||||
<a-button @click="handleCancel">取消</a-button>
|
<a-button @click="handleCancel">取消</a-button>
|
||||||
<a-button type="primary" :loading="submitLoading" @click="handleSubmit">
|
<a-button type="primary" :loading="submitLoading" @click="handleSubmit">
|
||||||
确定
|
提交作品
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
@ -87,13 +191,23 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { 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 type { FormInstance, UploadFile } from "ant-design-vue"
|
||||||
import { worksApi, registrationsApi, type SubmitWorkForm } from "@/api/contests"
|
import { worksApi, registrationsApi, type SubmitWorkForm } from "@/api/contests"
|
||||||
|
import { getAI3DTasks, type AI3DTask } from "@/api/ai-3d"
|
||||||
import { useAuthStore } from "@/stores/auth"
|
import { useAuthStore } from "@/stores/auth"
|
||||||
import request from "@/utils/request"
|
import request from "@/utils/request"
|
||||||
|
import dayjs from "dayjs"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean
|
open: boolean
|
||||||
@ -108,47 +222,102 @@ interface Emits {
|
|||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
const submitLoading = ref(false)
|
const submitLoading = ref(false)
|
||||||
const formRef = ref<FormInstance>()
|
const formRef = ref<FormInstance>()
|
||||||
|
|
||||||
|
// 上传模式
|
||||||
|
const uploadMode = ref<"history" | "local">("history")
|
||||||
|
|
||||||
// 3D文件格式
|
// 3D文件格式
|
||||||
const workAcceptTypes =
|
const workAcceptTypes =
|
||||||
".obj,.fbx,.3ds,.dae,.blend,.max,.c4d,.stl,.ply,.gltf,.glb"
|
".obj,.fbx,.3ds,.dae,.blend,.max,.c4d,.stl,.ply,.gltf,.glb"
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
const form = reactive<{
|
const form = reactive<{
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
workFile: File | null
|
// 历史记录模式
|
||||||
|
selectedWorkId: number | null
|
||||||
|
selectedWork: AI3DTask | null
|
||||||
|
// 本地上传模式
|
||||||
|
localWorkFile: File | null
|
||||||
|
localPreviewFile: File | null
|
||||||
|
// 附件
|
||||||
attachmentFiles: File[]
|
attachmentFiles: File[]
|
||||||
}>({
|
}>({
|
||||||
title: "",
|
title: "",
|
||||||
description: "",
|
description: "",
|
||||||
workFile: null,
|
selectedWorkId: null,
|
||||||
|
selectedWork: null,
|
||||||
|
localWorkFile: null,
|
||||||
|
localPreviewFile: null,
|
||||||
attachmentFiles: [],
|
attachmentFiles: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
const workFileList = ref<UploadFile[]>([])
|
// 文件列表
|
||||||
|
const localWorkFileList = ref<UploadFile[]>([])
|
||||||
|
const localPreviewFileList = ref<UploadFile[]>([])
|
||||||
const attachmentFileList = 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" }],
|
title: [{ required: true, message: "请输入作品名称", trigger: "blur" }],
|
||||||
description: [{ required: true, message: "请输入作品介绍", trigger: "blur" }],
|
description: [{ required: true, message: "请输入作品介绍", trigger: "blur" }],
|
||||||
workFile: [
|
selectedWork: [
|
||||||
{
|
{
|
||||||
required: true,
|
required: uploadMode.value === "history",
|
||||||
validator: () => {
|
validator: () => {
|
||||||
if (!form.workFile) {
|
if (uploadMode.value === "history" && !form.selectedWorkId) {
|
||||||
return Promise.reject(new Error("请上传作品文件"))
|
return Promise.reject(new Error("请选择一个3D作品"))
|
||||||
}
|
}
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
},
|
},
|
||||||
trigger: "change",
|
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(
|
watch(
|
||||||
() => props.open,
|
() => props.open,
|
||||||
@ -157,6 +326,7 @@ watch(
|
|||||||
if (newVal) {
|
if (newVal) {
|
||||||
resetForm()
|
resetForm()
|
||||||
fetchRegistrationId()
|
fetchRegistrationId()
|
||||||
|
fetchWorks()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
@ -166,6 +336,20 @@ watch(visible, (newVal) => {
|
|||||||
emit("update:open", 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
|
// 获取报名记录ID
|
||||||
const registrationIdRef = ref<number | null>(null)
|
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 handleBeforeUploadWork = (file: File): boolean => {
|
||||||
const fileName = file.name.toLowerCase()
|
const fileName = file.name.toLowerCase()
|
||||||
const validExtensions = [
|
const validExtensions = [
|
||||||
".obj",
|
".obj", ".fbx", ".3ds", ".dae", ".blend",
|
||||||
".fbx",
|
".max", ".c4d", ".stl", ".ply", ".gltf", ".glb",
|
||||||
".3ds",
|
|
||||||
".dae",
|
|
||||||
".blend",
|
|
||||||
".max",
|
|
||||||
".c4d",
|
|
||||||
".stl",
|
|
||||||
".ply",
|
|
||||||
".gltf",
|
|
||||||
".glb",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const isValid = validExtensions.some((ext) => fileName.endsWith(ext))
|
const isValid = validExtensions.some((ext) => fileName.endsWith(ext))
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
message.error(
|
message.error("请上传支持的3D文件格式")
|
||||||
"请上传3D文件格式(.obj, .fbx, .3ds, .dae, .blend, .max, .c4d, .stl, .ply, .gltf, .glb)"
|
|
||||||
)
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查文件大小(限制为500MB)
|
const maxSize = 500 * 1024 * 1024
|
||||||
const maxSize = 500 * 1024 * 1024 // 500MB
|
|
||||||
if (file.size > maxSize) {
|
if (file.size > maxSize) {
|
||||||
message.error("文件大小不能超过500MB")
|
message.error("文件大小不能超过500MB")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
form.workFile = file
|
form.localWorkFile = file
|
||||||
return false // 阻止自动上传
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移除作品文件
|
// 移除3D文件
|
||||||
const handleRemoveWork = () => {
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传附件前验证
|
// 上传附件前验证
|
||||||
const handleBeforeUploadAttachment = (file: File): boolean => {
|
const handleBeforeUploadAttachment = (file: File): boolean => {
|
||||||
// 检查文件大小(限制为100MB)
|
const maxSize = 100 * 1024 * 1024
|
||||||
const maxSize = 100 * 1024 * 1024 // 100MB
|
|
||||||
if (file.size > maxSize) {
|
if (file.size > maxSize) {
|
||||||
message.error("附件大小不能超过100MB")
|
message.error("附件大小不能超过100MB")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
form.attachmentFiles.push(file)
|
form.attachmentFiles.push(file)
|
||||||
return false // 阻止自动上传
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移除附件
|
// 移除附件
|
||||||
@ -270,12 +531,18 @@ const handleRemoveAttachment = (file: UploadFile) => {
|
|||||||
|
|
||||||
// 重置表单
|
// 重置表单
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
|
uploadMode.value = "history"
|
||||||
form.title = ""
|
form.title = ""
|
||||||
form.description = ""
|
form.description = ""
|
||||||
form.workFile = null
|
form.selectedWorkId = null
|
||||||
|
form.selectedWork = null
|
||||||
|
form.localWorkFile = null
|
||||||
|
form.localPreviewFile = null
|
||||||
form.attachmentFiles = []
|
form.attachmentFiles = []
|
||||||
workFileList.value = []
|
localWorkFileList.value = []
|
||||||
|
localPreviewFileList.value = []
|
||||||
attachmentFileList.value = []
|
attachmentFileList.value = []
|
||||||
|
worksPagination.current = 1
|
||||||
formRef.value?.resetFields()
|
formRef.value?.resetFields()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -291,24 +558,45 @@ const handleSubmit = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!form.workFile) {
|
submitLoading.value = true
|
||||||
message.error("请上传作品文件")
|
|
||||||
|
let workFileUrl = ""
|
||||||
|
let previewUrl = ""
|
||||||
|
const attachmentUrls: string[] = []
|
||||||
|
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
submitLoading.value = true
|
// 上传3D文件
|
||||||
|
|
||||||
// 上传文件并获取URL
|
|
||||||
const workFileUrls: string[] = []
|
|
||||||
const attachmentUrls: string[] = []
|
|
||||||
|
|
||||||
// 上传作品文件
|
|
||||||
if (form.workFile) {
|
|
||||||
try {
|
try {
|
||||||
const workUrl = await uploadFile(form.workFile)
|
workFileUrl = await uploadFile(form.localWorkFile)
|
||||||
workFileUrls.push(workUrl)
|
|
||||||
} catch (error: any) {
|
} 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
|
submitLoading.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -321,7 +609,6 @@ const handleSubmit = async () => {
|
|||||||
attachmentUrls.push(url)
|
attachmentUrls.push(url)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("附件上传失败:", error)
|
console.error("附件上传失败:", error)
|
||||||
// 附件上传失败不影响主流程,只记录错误
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -329,7 +616,8 @@ const handleSubmit = async () => {
|
|||||||
registrationId: registrationIdRef.value,
|
registrationId: registrationIdRef.value,
|
||||||
title: form.title,
|
title: form.title,
|
||||||
description: form.description,
|
description: form.description,
|
||||||
files: [...workFileUrls, ...attachmentUrls],
|
files: [workFileUrl, ...attachmentUrls],
|
||||||
|
previewUrl: previewUrl,
|
||||||
}
|
}
|
||||||
|
|
||||||
await worksApi.submit(submitData)
|
await worksApi.submit(submitData)
|
||||||
@ -340,7 +628,6 @@ const handleSubmit = async () => {
|
|||||||
resetForm()
|
resetForm()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.errorFields) {
|
if (error?.errorFields) {
|
||||||
// 表单验证错误
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
message.error(error?.response?.data?.message || "作品提交失败")
|
message.error(error?.response?.data?.message || "作品提交失败")
|
||||||
@ -355,14 +642,12 @@ const uploadFile = async (file: File): Promise<string> => {
|
|||||||
formData.append("file", file)
|
formData.append("file", file)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 使用文件上传接口
|
|
||||||
const response = await request.post<any>("/upload", formData, {
|
const response = await request.post<any>("/upload", formData, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "multipart/form-data",
|
"Content-Type": "multipart/form-data",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 返回文件URL
|
|
||||||
if (response && typeof response === "object" && "url" in response) {
|
if (response && typeof response === "object" && "url" in response) {
|
||||||
return String(response.url)
|
return String(response.url)
|
||||||
}
|
}
|
||||||
@ -388,6 +673,167 @@ const handleCancel = () => {
|
|||||||
color: #303133;
|
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 {
|
.upload-tip {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@ -395,6 +841,18 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(.ant-upload-list) {
|
: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>
|
</style>
|
||||||
|
|||||||
@ -363,6 +363,10 @@ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js"
|
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js"
|
||||||
// @ts-ignore
|
// @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"
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -682,12 +686,12 @@ const loadModel = async () => {
|
|||||||
// 检查文件扩展名(支持GLB、GLTF和ZIP格式,ZIP会在后端解压)
|
// 检查文件扩展名(支持GLB、GLTF和ZIP格式,ZIP会在后端解压)
|
||||||
// 从URL中提取文件扩展名(忽略查询参数)
|
// 从URL中提取文件扩展名(忽略查询参数)
|
||||||
const urlWithoutQuery = modelUrl.value.split("?")[0].toLowerCase()
|
const urlWithoutQuery = modelUrl.value.split("?")[0].toLowerCase()
|
||||||
const supportedExtensions = [".glb", ".gltf", ".zip"]
|
const supportedExtensions = [".glb", ".gltf", ".zip", ".obj"]
|
||||||
const isSupported = supportedExtensions.some((ext) =>
|
const isSupported = supportedExtensions.some((ext) =>
|
||||||
urlWithoutQuery.endsWith(ext)
|
urlWithoutQuery.endsWith(ext)
|
||||||
)
|
)
|
||||||
if (!isSupported) {
|
if (!isSupported) {
|
||||||
error.value = `不支持的文件格式,目前仅支持 GLB/GLTF/ZIP 格式`
|
error.value = `不支持的文件格式,目前仅支持 GLB/GLTF/ZIP/OBJ 格式`
|
||||||
loading.value = false
|
loading.value = false
|
||||||
console.error("不支持的文件格式:", modelUrl.value)
|
console.error("不支持的文件格式:", modelUrl.value)
|
||||||
return
|
return
|
||||||
@ -699,24 +703,25 @@ const loadModel = async () => {
|
|||||||
try {
|
try {
|
||||||
console.log("开始加载模型,URL:", modelUrl.value)
|
console.log("开始加载模型,URL:", modelUrl.value)
|
||||||
|
|
||||||
// 使用后端代理解决CORS问题
|
// 判断是本地URL还是外部URL
|
||||||
// 将原始URL编码后作为查询参数传递给后端代理
|
// 本地URL: /api/uploads/... 或相对路径
|
||||||
const proxyUrl = `/api/ai-3d/proxy-model?url=${encodeURIComponent(modelUrl.value)}`
|
// 外部URL: http:// 或 https:// 开头的腾讯云COS链接
|
||||||
console.log("使用代理URL:", proxyUrl)
|
const isLocalUrl =
|
||||||
|
modelUrl.value.startsWith("/api/") ||
|
||||||
|
modelUrl.value.startsWith("/uploads/") ||
|
||||||
|
(!modelUrl.value.startsWith("http://") &&
|
||||||
|
!modelUrl.value.startsWith("https://"))
|
||||||
|
|
||||||
const loader = new GLTFLoader()
|
let loadUrl: string
|
||||||
|
if (isLocalUrl) {
|
||||||
// 配置 DRACO 解码器
|
// 本地URL直接使用,不需要代理
|
||||||
if (!dracoLoader) {
|
loadUrl = modelUrl.value
|
||||||
dracoLoader = new DRACOLoader()
|
console.log("使用本地URL:", loadUrl)
|
||||||
dracoLoader.setDecoderPath(
|
} else {
|
||||||
"https://www.gstatic.com/draco/versioned/decoders/1.5.6/"
|
// 外部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) {
|
if (model && scene) {
|
||||||
@ -724,7 +729,119 @@ const loadModel = async () => {
|
|||||||
disposeModel(model)
|
disposeModel(model)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 判断模型类型
|
||||||
|
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
|
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
|
let vertexCount = 0
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user