library-picturebook-activity/frontend/src/views/contests/components/ViewWorkDrawer.vue

684 lines
17 KiB
Vue
Raw Normal View History

2026-01-09 18:14:35 +08:00
<template>
<a-drawer
v-model:open="visible"
title="参赛作品"
placement="right"
2026-01-15 09:28:22 +08:00
width="600px"
2026-01-09 18:14:35 +08:00
:footer-style="{ textAlign: 'right', padding: '16px 24px' }"
@close="handleCancel"
>
<template #title>
<div class="drawer-title">
<span>参赛作品</span>
</div>
</template>
<a-spin :spinning="loading">
<div v-if="!work" class="empty-container">
<a-empty description="暂无作品" />
</div>
<div v-else class="work-detail">
2026-01-16 14:18:32 +08:00
<!-- 多模型预览网格 -->
<div class="models-preview-grid">
2026-01-15 09:28:22 +08:00
<div
2026-01-16 14:18:32 +08:00
v-for="(model, index) in modelItems"
:key="index"
class="model-preview-card"
@mouseenter="hoveredIndex = index"
@mouseleave="hoveredIndex = -1"
2026-01-15 09:28:22 +08:00
>
<!-- 预览图 -->
<img
2026-01-16 14:18:32 +08:00
v-if="model.previewUrl"
:src="model.previewUrl"
alt="模型预览"
2026-01-15 09:28:22 +08:00
class="preview-image"
2026-01-16 14:18:32 +08:00
@error="(e) => handlePreviewError(e, index)"
2026-01-15 09:28:22 +08:00
/>
<div v-else class="preview-placeholder">
<FileImageOutlined class="placeholder-icon" />
2026-01-16 14:18:32 +08:00
<span>模型 {{ index + 1 }}</span>
2026-01-15 09:28:22 +08:00
</div>
2026-01-09 18:14:35 +08:00
2026-01-15 09:28:22 +08:00
<!-- 悬浮操作按钮 -->
<transition name="fade">
2026-01-16 14:18:32 +08:00
<div v-show="hoveredIndex === index" class="actions-overlay">
<a-button
v-if="model.fileUrl && is3DModelFile(model.fileUrl)"
type="primary"
size="small"
@click="handlePreview3DModel(model.fileUrl, index)"
>
<template #icon><EyeOutlined /></template>
3D预览
</a-button>
2026-01-09 18:14:35 +08:00
</div>
2026-01-15 09:28:22 +08:00
</transition>
2026-01-16 14:18:32 +08:00
<!-- 模型序号 -->
<div class="model-index">{{ index + 1 }}</div>
2026-01-09 18:14:35 +08:00
</div>
</div>
2026-01-16 14:18:32 +08:00
<!-- 下载按钮 -->
<div v-if="modelItems.length > 0" class="download-section">
<a-button @click="handleDownloadWork">
<template #icon><DownloadOutlined /></template>
下载全部模型
</a-button>
</div>
2026-01-09 18:14:35 +08:00
<!-- 作品信息 -->
2026-01-15 09:28:22 +08:00
<div class="work-info-section">
<div class="info-row">
<span class="info-label">作品名称</span>
<span class="info-value">{{ work.title }}</span>
</div>
<div class="info-row">
<span class="info-label">作品介绍</span>
<span class="info-value description">{{ work.description || "暂无介绍" }}</span>
</div>
<div class="info-row">
<span class="info-label">作品编号</span>
<span class="info-value">{{ work.workNo || "-" }}</span>
</div>
<div class="info-row">
<span class="info-label">提交时间</span>
<span class="info-value">{{ formatDateTime(work.submitTime) }}</span>
</div>
<div class="info-row">
<span class="info-label">作品状态</span>
<a-tag :color="getStatusColor(work.status)">
{{ getStatusText(work.status) }}
</a-tag>
</div>
<div v-if="work.version" class="info-row">
<span class="info-label">版本号</span>
<span class="info-value">v{{ work.version }}</span>
2026-01-09 18:14:35 +08:00
</div>
</div>
<!-- 上传的附件 -->
<div
v-if="work.attachments && work.attachments.length > 0"
2026-01-15 09:28:22 +08:00
class="attachments-section"
2026-01-09 18:14:35 +08:00
>
2026-01-15 09:28:22 +08:00
<div class="section-title">上传附件</div>
2026-01-09 18:14:35 +08:00
<div class="attachments-list">
<div
v-for="attachment in work.attachments"
:key="attachment.id"
class="attachment-item"
>
<div class="attachment-info">
<FileOutlined class="attachment-icon" />
<span class="attachment-name">{{ attachment.fileName }}</span>
<span v-if="attachment.size" class="attachment-size">
({{ formatFileSize(attachment.size) }})
</span>
</div>
<a-button
type="link"
size="small"
@click="handleDownloadAttachment(attachment)"
>
<template #icon><DownloadOutlined /></template>
下载
</a-button>
</div>
</div>
</div>
</div>
</a-spin>
<template #footer>
2026-01-15 09:28:22 +08:00
<a-button @click="handleCancel">关闭</a-button>
2026-01-09 18:14:35 +08:00
</template>
</a-drawer>
</template>
<script setup lang="ts">
import { ref, watch, computed } from "vue"
2026-01-14 14:29:16 +08:00
import { useRouter, useRoute } from "vue-router"
2026-01-09 18:14:35 +08:00
import { message } from "ant-design-vue"
import {
FileOutlined,
2026-01-15 09:28:22 +08:00
FileImageOutlined,
2026-01-09 18:14:35 +08:00
DownloadOutlined,
EyeOutlined,
} from "@ant-design/icons-vue"
import { worksApi, registrationsApi, type ContestWork } from "@/api/contests"
import { useAuthStore } from "@/stores/auth"
import dayjs from "dayjs"
interface Props {
open: boolean
contestId: number
}
interface Emits {
(e: "update:open", value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
2026-01-14 14:29:16 +08:00
const router = useRouter()
const route = useRoute()
2026-01-09 18:14:35 +08:00
const authStore = useAuthStore()
const visible = ref(false)
const loading = ref(false)
const work = ref<ContestWork | null>(null)
2026-01-16 14:18:32 +08:00
const hoveredIndex = ref(-1)
const previewErrors = ref<Record<number, boolean>>({})
2026-01-09 18:14:35 +08:00
// 监听抽屉打开状态
watch(
() => props.open,
async (newVal) => {
visible.value = newVal
if (newVal) {
2026-01-16 14:18:32 +08:00
previewErrors.value = {}
2026-01-09 18:14:35 +08:00
await fetchUserWork()
} else {
work.value = null
2026-01-16 14:18:32 +08:00
hoveredIndex.value = -1
2026-01-09 18:14:35 +08:00
}
},
{ immediate: true }
)
watch(visible, (newVal) => {
emit("update:open", newVal)
})
// 获取当前用户的作品
const fetchUserWork = async () => {
loading.value = true
work.value = null
try {
const userId = authStore.user?.id
if (!userId) {
message.error("用户未登录,无法查看作品")
visible.value = false
return
}
// 先获取用户的报名记录
const registrationResponse = await registrationsApi.getList({
contestId: props.contestId,
userId: userId,
registrationType: "individual",
registrationState: "passed",
page: 1,
pageSize: 10,
})
if (registrationResponse.list.length === 0) {
message.warning("您尚未报名该赛事或报名未通过,无法查看作品")
visible.value = false
return
}
const registrationId = registrationResponse.list[0].id
// 获取该报名的所有作品版本,取最新版本
const works = await worksApi.getVersions(registrationId)
if (works && works.length > 0) {
// 找到最新版本的作品
const latestWork = works.find((w) => w.isLatest) || works[0]
work.value = latestWork
} else {
message.warning("您尚未提交作品")
}
} catch (error: any) {
message.error(error?.response?.data?.message || "获取作品信息失败")
} finally {
loading.value = false
}
}
2026-01-16 14:18:32 +08:00
// 模型项目列表 - 解析 files 和 previewUrls 数组
interface ModelItem {
fileUrl: string
previewUrl: string
}
const modelItems = computed<ModelItem[]>(() => {
if (!work.value) return []
2026-01-15 09:28:22 +08:00
2026-01-16 14:18:32 +08:00
// 解析 files 数组
let files = work.value.files || []
if (typeof files === "string") {
try {
files = JSON.parse(files)
} catch {
files = []
}
2026-01-15 09:28:22 +08:00
}
2026-01-16 14:18:32 +08:00
if (!Array.isArray(files)) files = []
2026-01-15 09:28:22 +08:00
2026-01-16 14:18:32 +08:00
// 解析 previewUrls 数组
let previewUrls = work.value.previewUrls || []
if (typeof previewUrls === "string") {
try {
previewUrls = JSON.parse(previewUrls)
} catch {
previewUrls = []
}
}
if (!Array.isArray(previewUrls)) previewUrls = []
// 如果没有 previewUrls 但有单个 previewUrl使用它
if (previewUrls.length === 0 && work.value.previewUrl) {
previewUrls = [work.value.previewUrl]
}
// 过滤出3D模型文件排除附件等
const modelFiles = files.filter((f: any) => {
const url = typeof f === "object" && f?.fileUrl ? f.fileUrl : f
return url && is3DModelFile(url)
})
// 构建模型项目列表
return modelFiles.map((f: any, index: number) => {
const fileUrl = typeof f === "object" && f?.fileUrl ? f.fileUrl : f
const previewUrl = previewUrls[index] || previewUrls[0] || ""
return {
fileUrl: getFileUrl(fileUrl),
previewUrl: previewUrl && !previewErrors.value[index] ? getFileUrl(previewUrl) : "",
}
})
2026-01-15 09:28:22 +08:00
})
2026-01-16 14:18:32 +08:00
// 作品文件列表(用于下载)
const workFiles = computed(() => {
if (!work.value) return []
2026-01-09 18:14:35 +08:00
let files = work.value.files || []
2026-01-15 09:28:22 +08:00
if (typeof files === "string") {
2026-01-09 18:14:35 +08:00
try {
files = JSON.parse(files)
2026-01-12 20:04:11 +08:00
} catch {
2026-01-16 14:18:32 +08:00
return []
2026-01-09 18:14:35 +08:00
}
}
2026-01-16 14:18:32 +08:00
if (!Array.isArray(files)) return []
2026-01-09 18:14:35 +08:00
2026-01-16 14:18:32 +08:00
return files.map((f: any) => {
return typeof f === "object" && f?.fileUrl ? f.fileUrl : f
}).filter(Boolean)
2026-01-09 18:14:35 +08:00
})
// 判断是否为3D模型文件
2026-01-12 20:04:11 +08:00
const is3DModelFile = (fileUrl: string): boolean => {
2026-01-09 18:14:35 +08:00
const modelExtensions = [
".glb",
".gltf",
".obj",
".fbx",
".3ds",
".dae",
".stl",
".ply",
2026-01-15 09:28:22 +08:00
".zip",
2026-01-09 18:14:35 +08:00
]
const lowerUrl = fileUrl.toLowerCase()
return modelExtensions.some((ext) => lowerUrl.includes(ext))
}
// 获取文件URL处理相对路径
const getFileUrl = (fileUrl: string): string => {
if (!fileUrl) return ""
// 如果已经是完整URL直接返回
if (fileUrl.startsWith("http://") || fileUrl.startsWith("https://")) {
return fileUrl
}
// 如果是相对路径拼接API基础URL
const baseURL = import.meta.env.VITE_API_BASE_URL || ""
if (fileUrl.startsWith("/api") && baseURL.includes("/api")) {
const urlWithoutApi = baseURL.replace(/\/api$/, "")
return `${urlWithoutApi}${fileUrl}`
}
return `${baseURL}${fileUrl.startsWith("/") ? "" : "/"}${fileUrl}`
}
2026-01-15 09:28:22 +08:00
// 预览图加载错误
2026-01-16 14:18:32 +08:00
const handlePreviewError = (_e: Event, index: number) => {
previewErrors.value[index] = true
2026-01-09 18:14:35 +08:00
}
2026-01-16 14:18:32 +08:00
// 预览3D模型 - 支持多模型预览
const handlePreview3DModel = (fileUrl: string, index: number) => {
2026-01-15 09:28:22 +08:00
if (!fileUrl) {
message.error("文件路径无效")
return
2026-01-09 18:14:35 +08:00
}
2026-01-16 14:18:32 +08:00
2026-01-15 09:28:22 +08:00
const tenantCode = route.params.tenantCode as string
2026-01-16 14:18:32 +08:00
// 收集所有模型URL用于多模型切换
const allModelUrls = modelItems.value.map((m) => m.fileUrl)
if (allModelUrls.length > 1) {
sessionStorage.setItem("model-viewer-urls", JSON.stringify(allModelUrls))
sessionStorage.setItem("model-viewer-index", String(index))
} else {
sessionStorage.removeItem("model-viewer-urls")
sessionStorage.removeItem("model-viewer-index")
}
2026-01-15 09:28:22 +08:00
router.push({
path: `/${tenantCode}/workbench/model-viewer`,
2026-01-16 14:18:32 +08:00
query: { url: fileUrl },
2026-01-15 09:28:22 +08:00
})
2026-01-09 18:14:35 +08:00
}
2026-01-16 14:18:32 +08:00
// 下载作品 - 下载所有模型文件
2026-01-15 09:28:22 +08:00
const handleDownloadWork = () => {
2026-01-16 14:18:32 +08:00
if (workFiles.value.length === 0) {
2026-01-15 09:28:22 +08:00
message.error("无作品文件")
return
}
2026-01-16 14:18:32 +08:00
// 下载所有3D模型文件
workFiles.value.forEach((file, index) => {
if (is3DModelFile(file)) {
setTimeout(() => {
const fileUrl = getFileUrl(file)
const link = document.createElement("a")
link.href = fileUrl
link.download = `${work.value?.title || "作品"}_${index + 1}`
link.target = "_blank"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}, index * 500) // 错开下载时间
}
})
2026-01-15 09:28:22 +08:00
message.success("开始下载作品")
2026-01-09 18:14:35 +08:00
}
// 下载附件
const handleDownloadAttachment = async (attachment: any) => {
try {
const fileUrl = getFileUrl(attachment.fileUrl)
const link = document.createElement("a")
link.href = fileUrl
link.download = attachment.fileName
link.target = "_blank"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
message.success("开始下载附件")
2026-01-15 09:28:22 +08:00
} catch {
2026-01-09 18:14:35 +08:00
message.error("下载附件失败")
}
}
// 格式化日期时间
const formatDateTime = (dateStr?: string) => {
if (!dateStr) return "-"
return dayjs(dateStr).format("YYYY-MM-DD HH:mm:ss")
}
// 格式化文件大小
const formatFileSize = (size: string | number): string => {
if (!size) return "-"
const numSize = typeof size === "string" ? parseFloat(size) : size
if (numSize < 1024) {
return `${numSize} B`
} else if (numSize < 1024 * 1024) {
return `${(numSize / 1024).toFixed(2)} KB`
} else if (numSize < 1024 * 1024 * 1024) {
return `${(numSize / (1024 * 1024)).toFixed(2)} MB`
} else {
return `${(numSize / (1024 * 1024 * 1024)).toFixed(2)} GB`
}
}
// 获取状态颜色
const getStatusColor = (
status?: "submitted" | "locked" | "reviewing" | "rejected" | "accepted"
): string => {
const colorMap: Record<string, string> = {
submitted: "blue",
locked: "default",
reviewing: "processing",
rejected: "error",
accepted: "success",
}
return colorMap[status || "submitted"] || "default"
}
// 获取状态文本
const getStatusText = (
status?: "submitted" | "locked" | "reviewing" | "rejected" | "accepted"
): string => {
const textMap: Record<string, string> = {
submitted: "已提交",
locked: "已锁定",
reviewing: "评审中",
rejected: "已拒绝",
accepted: "已通过",
}
return textMap[status || "submitted"] || "未知"
}
// 取消
const handleCancel = () => {
visible.value = false
}
</script>
<style scoped lang="scss">
.drawer-title {
font-size: 16px;
font-weight: 500;
}
.empty-container {
padding: 40px 0;
text-align: center;
}
.work-detail {
padding: 0;
}
2026-01-16 14:18:32 +08:00
// 多模型预览网格
.models-preview-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 16px;
2026-01-09 18:14:35 +08:00
}
2026-01-16 14:18:32 +08:00
.model-preview-card {
2026-01-09 18:14:35 +08:00
position: relative;
2026-01-15 09:28:22 +08:00
aspect-ratio: 1;
border-radius: 12px;
2026-01-09 18:14:35 +08:00
overflow: hidden;
2026-01-15 09:28:22 +08:00
background: #f5f5f5;
2026-01-09 18:14:35 +08:00
cursor: pointer;
2026-01-16 14:18:32 +08:00
transition: all 0.3s ease;
border: 2px solid transparent;
2026-01-09 18:14:35 +08:00
2026-01-16 14:18:32 +08:00
&:hover {
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.2);
}
2026-01-09 18:14:35 +08:00
2026-01-16 14:18:32 +08:00
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(0, 0, 0, 0.25);
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
.placeholder-icon {
font-size: 36px;
margin-bottom: 8px;
}
2026-01-09 18:14:35 +08:00
2026-01-16 14:18:32 +08:00
span {
font-size: 12px;
}
2026-01-09 18:14:35 +08:00
}
2026-01-16 14:18:32 +08:00
.model-index {
position: absolute;
top: 8px;
left: 8px;
width: 24px;
height: 24px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 500;
2026-01-15 09:28:22 +08:00
}
2026-01-09 18:14:35 +08:00
}
2026-01-15 09:28:22 +08:00
// 悬浮操作层
.actions-overlay {
2026-01-09 18:14:35 +08:00
position: absolute;
2026-01-15 09:28:22 +08:00
inset: 0;
background: rgba(0, 0, 0, 0.5);
2026-01-09 18:14:35 +08:00
display: flex;
align-items: center;
justify-content: center;
}
2026-01-16 14:18:32 +08:00
// 下载区域
.download-section {
margin-bottom: 20px;
2026-01-15 09:28:22 +08:00
display: flex;
2026-01-16 14:18:32 +08:00
justify-content: center;
2026-01-09 18:14:35 +08:00
}
2026-01-15 09:28:22 +08:00
// 淡入淡出动画
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
2026-01-09 18:14:35 +08:00
}
2026-01-15 09:28:22 +08:00
.fade-enter-from,
.fade-leave-to {
opacity: 0;
2026-01-09 18:14:35 +08:00
}
2026-01-15 09:28:22 +08:00
// 作品信息区域
.work-info-section {
background: #fafafa;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}
.info-row {
2026-01-09 18:14:35 +08:00
display: flex;
2026-01-15 09:28:22 +08:00
align-items: flex-start;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
2026-01-09 18:14:35 +08:00
}
.info-label {
2026-01-15 09:28:22 +08:00
width: 80px;
flex-shrink: 0;
color: rgba(0, 0, 0, 0.45);
font-size: 14px;
2026-01-09 18:14:35 +08:00
}
.info-value {
2026-01-15 09:28:22 +08:00
flex: 1;
2026-01-09 18:14:35 +08:00
color: rgba(0, 0, 0, 0.85);
2026-01-15 09:28:22 +08:00
font-size: 14px;
word-break: break-word;
&.description {
white-space: pre-wrap;
line-height: 1.6;
}
}
// 附件区域
.attachments-section {
margin-top: 24px;
}
.section-title {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
margin-bottom: 12px;
2026-01-09 18:14:35 +08:00
}
.attachments-list {
display: flex;
flex-direction: column;
2026-01-15 09:28:22 +08:00
gap: 8px;
2026-01-09 18:14:35 +08:00
}
.attachment-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border: 1px solid #d9d9d9;
2026-01-15 09:28:22 +08:00
border-radius: 8px;
2026-01-09 18:14:35 +08:00
transition: all 0.3s;
&:hover {
border-color: #1890ff;
background: #f0f7ff;
}
}
.attachment-info {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.attachment-icon {
font-size: 16px;
color: #1890ff;
margin-right: 8px;
}
.attachment-name {
font-size: 14px;
color: rgba(0, 0, 0, 0.85);
margin-right: 8px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-size {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
white-space: nowrap;
}
</style>