library-picturebook-activity/frontend/src/views/contests/components/SubmitWorkDrawer.vue
zhangxiaohua 38b23e7e77 修改bug
2026-01-14 16:14:15 +08:00

859 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<a-drawer
v-model:open="visible"
title="上传作品"
placement="right"
width="850px"
:footer-style="{ textAlign: 'right', padding: '16px 24px' }"
@close="handleCancel"
>
<template #title>
<div class="drawer-title">
<span>上传作品</span>
</div>
</template>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 20 }"
layout="horizontal"
>
<a-form-item label="作品名称" name="title" required>
<a-input
v-model:value="form.title"
placeholder="请输入作品名称"
:maxlength="200"
/>
</a-form-item>
<a-form-item label="作品介绍" name="description" required>
<a-textarea
v-model:value="form.description"
placeholder="请输入作品介绍"
:rows="4"
:maxlength="2000"
show-count
/>
</a-form-item>
<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">
请上传作品预览图,支持 JPG、PNG 格式,用于作品展示
</div>
</a-form-item>
</template>
<a-form-item label="上传附件">
<a-upload
v-model:file-list="attachmentFileList"
:before-upload="handleBeforeUploadAttachment"
:remove="handleRemoveAttachment"
:max-count="5"
>
<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>
<template #footer>
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button type="primary" :loading="submitLoading" @click="handleSubmit">
提交作品
</a-button>
</a-space>
</template>
</a-drawer>
</template>
<script setup lang="ts">
import { ref, reactive, watch, computed } from "vue"
import { useRouter, useRoute } from "vue-router"
import { message } from "ant-design-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
contestId: number
}
interface Emits {
(e: "update:open", value: boolean): void
(e: "success"): void
}
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
// 历史记录模式
selectedWorkId: number | null
selectedWork: AI3DTask | null
// 本地上传模式
localWorkFile: File | null
localPreviewFile: File | null
// 附件
attachmentFiles: File[]
}>({
title: "",
description: "",
selectedWorkId: null,
selectedWork: null,
localWorkFile: null,
localPreviewFile: null,
attachmentFiles: [],
})
// 文件列表
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 = computed(() => ({
title: [{ required: true, message: "请输入作品名称", trigger: "blur" }],
description: [{ required: true, message: "请输入作品介绍", trigger: "blur" }],
selectedWork: [
{
required: uploadMode.value === "history",
validator: () => {
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,
(newVal) => {
visible.value = newVal
if (newVal) {
resetForm()
fetchRegistrationId()
fetchWorks()
}
},
{ immediate: true }
)
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)
const fetchRegistrationId = async () => {
if (!authStore.user?.id) {
message.error("用户未登录")
return
}
try {
const response = await registrationsApi.getList({
contestId: props.contestId,
userId: authStore.user.id,
page: 1,
pageSize: 1,
})
if (response.list && response.list.length > 0) {
const registration = response.list[0]
if (registration.registrationState === "passed") {
registrationIdRef.value = registration.id
} else {
message.warning("您的报名尚未通过审核,无法上传作品")
visible.value = false
}
} else {
message.warning("您尚未报名该赛事,无法上传作品")
visible.value = false
}
} catch (error: any) {
message.error(error?.response?.data?.message || "获取报名信息失败")
visible.value = false
}
}
// 获取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",
]
const isValid = validExtensions.some((ext) => fileName.endsWith(ext))
if (!isValid) {
message.error("请上传支持的3D文件格式")
return false
}
const maxSize = 500 * 1024 * 1024
if (file.size > maxSize) {
message.error("文件大小不能超过500MB")
return false
}
form.localWorkFile = file
return false
}
// 移除3D文件
const handleRemoveWork = () => {
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 => {
const maxSize = 100 * 1024 * 1024
if (file.size > maxSize) {
message.error("附件大小不能超过100MB")
return false
}
form.attachmentFiles.push(file)
return false
}
// 移除附件
const handleRemoveAttachment = (file: UploadFile) => {
const index = form.attachmentFiles.findIndex(
(f) => f.name === file.name && f.size === file.size
)
if (index > -1) {
form.attachmentFiles.splice(index, 1)
}
return true
}
// 重置表单
const resetForm = () => {
uploadMode.value = "history"
form.title = ""
form.description = ""
form.selectedWorkId = null
form.selectedWork = null
form.localWorkFile = null
form.localPreviewFile = null
form.attachmentFiles = []
localWorkFileList.value = []
localPreviewFileList.value = []
attachmentFileList.value = []
worksPagination.current = 1
formRef.value?.resetFields()
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (!registrationIdRef.value) {
message.error("无法获取报名记录,请重新打开")
return
}
submitLoading.value = true
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
}
// 上传3D文件
try {
workFileUrl = await uploadFile(form.localWorkFile)
} catch (error: any) {
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
}
}
// 上传附件
for (const file of form.attachmentFiles) {
try {
const url = await uploadFile(file)
attachmentUrls.push(url)
} catch (error: any) {
console.error("附件上传失败:", error)
}
}
const submitData: SubmitWorkForm = {
registrationId: registrationIdRef.value,
title: form.title,
description: form.description,
files: [workFileUrl, ...attachmentUrls],
previewUrl: previewUrl,
}
await worksApi.submit(submitData)
message.success("作品提交成功")
emit("success")
visible.value = false
resetForm()
} catch (error: any) {
if (error?.errorFields) {
return
}
message.error(error?.response?.data?.message || "作品提交失败")
} finally {
submitLoading.value = false
}
}
// 文件上传函数
const uploadFile = async (file: File): Promise<string> => {
const formData = new FormData()
formData.append("file", file)
try {
const response = await request.post<any>("/upload", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
})
if (response && typeof response === "object" && "url" in response) {
return String(response.url)
}
throw new Error("文件上传返回格式不正确")
} catch (error: any) {
console.error("文件上传失败:", error)
throw new Error(error?.response?.data?.message || "文件上传失败")
}
}
// 取消
const handleCancel = () => {
visible.value = false
resetForm()
}
</script>
<style lang="scss" scoped>
.drawer-title {
font-size: 16px;
font-weight: 500;
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;
color: #8c8c8c;
}
:deep(.ant-upload-list) {
&.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>