library-picturebook-activity/frontend/src/views/contests/components/ViewWorkDrawer.vue
2026-01-16 14:48:14 +08:00

689 lines
17 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="600px"
: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">
<!-- 多模型预览网格 -->
<div class="models-preview-grid">
<div
v-for="(model, index) in modelItems"
:key="index"
class="model-preview-card"
@mouseenter="hoveredIndex = index"
@mouseleave="hoveredIndex = -1"
>
<!-- 预览图 -->
<img
v-if="model.previewUrl"
:src="model.previewUrl"
alt="模型预览"
class="preview-image"
@error="(e) => handlePreviewError(e, index)"
/>
<div v-else class="preview-placeholder">
<FileImageOutlined class="placeholder-icon" />
<span>模型 {{ index + 1 }}</span>
</div>
<!-- 悬浮操作按钮 -->
<transition name="fade">
<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>
</div>
</transition>
<!-- 模型序号 -->
<div class="model-index">{{ index + 1 }}</div>
</div>
</div>
<!-- 下载按钮 -->
<div v-if="modelItems.length > 0" class="download-section">
<a-button @click="handleDownloadWork">
<template #icon><DownloadOutlined /></template>
下载全部模型
</a-button>
</div>
<!-- 作品信息 -->
<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>
</div>
</div>
<!-- 上传的附件 -->
<div
v-if="work.attachments && work.attachments.length > 0"
class="attachments-section"
>
<div class="section-title">上传附件</div>
<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>
<a-button @click="handleCancel">关闭</a-button>
</template>
</a-drawer>
</template>
<script setup lang="ts">
import { ref, watch, computed } from "vue"
import { useRouter, useRoute } from "vue-router"
import { message } from "ant-design-vue"
import {
FileOutlined,
FileImageOutlined,
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>()
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const visible = ref(false)
const loading = ref(false)
const work = ref<ContestWork | null>(null)
const hoveredIndex = ref(-1)
const previewErrors = ref<Record<number, boolean>>({})
// 监听抽屉打开状态
watch(
() => props.open,
async (newVal) => {
visible.value = newVal
if (newVal) {
previewErrors.value = {}
await fetchUserWork()
} else {
work.value = null
hoveredIndex.value = -1
}
},
{ 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
}
}
// 模型项目列表 - 解析 files 和 previewUrls 数组
interface ModelItem {
fileUrl: string
previewUrl: string
}
const modelItems = computed<ModelItem[]>(() => {
if (!work.value) return []
// 解析 files 数组
let files = work.value.files || []
if (typeof files === "string") {
try {
files = JSON.parse(files)
} catch {
files = []
}
}
if (!Array.isArray(files)) files = []
// 解析 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) : "",
}
})
})
// 作品文件列表(用于下载)
const workFiles = computed(() => {
if (!work.value) return []
let files = work.value.files || []
if (typeof files === "string") {
try {
files = JSON.parse(files)
} catch {
return []
}
}
if (!Array.isArray(files)) return []
return files.map((f: any) => {
return typeof f === "object" && f?.fileUrl ? f.fileUrl : f
}).filter(Boolean)
})
// 判断是否为3D模型文件
const is3DModelFile = (fileUrl: string): boolean => {
const modelExtensions = [
".glb",
".gltf",
".obj",
".fbx",
".3ds",
".dae",
".stl",
".ply",
".zip",
]
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}`
}
// 预览图加载错误
const handlePreviewError = (_e: Event, index: number) => {
previewErrors.value[index] = true
}
// 预览3D模型 - 支持多模型预览
const handlePreview3DModel = (fileUrl: string, index: number) => {
if (!fileUrl) {
message.error("文件路径无效")
return
}
const tenantCode = route.params.tenantCode as string
// 收集所有模型URL用于多模型切换
const allModelUrls = modelItems.value.map((m) => m.fileUrl)
// 存储到 sessionStorage避免URL过长
if (allModelUrls.length > 1) {
sessionStorage.setItem("model-viewer-urls", JSON.stringify(allModelUrls))
sessionStorage.setItem("model-viewer-index", String(index))
// 清除单URL存储
sessionStorage.removeItem("model-viewer-url")
} else {
sessionStorage.setItem("model-viewer-url", fileUrl)
sessionStorage.removeItem("model-viewer-urls")
sessionStorage.removeItem("model-viewer-index")
}
// 不在URL上携带参数
router.push({
path: `/${tenantCode}/workbench/model-viewer`,
})
}
// 下载作品 - 下载所有模型文件
const handleDownloadWork = () => {
if (workFiles.value.length === 0) {
message.error("无作品文件")
return
}
// 下载所有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) // 错开下载时间
}
})
message.success("开始下载作品")
}
// 下载附件
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("开始下载附件")
} catch {
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;
}
// 多模型预览网格
.models-preview-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.model-preview-card {
position: relative;
aspect-ratio: 1;
border-radius: 12px;
overflow: hidden;
background: #f5f5f5;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
&:hover {
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.2);
}
.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;
}
span {
font-size: 12px;
}
}
.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;
}
}
// 悬浮操作层
.actions-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
// 下载区域
.download-section {
margin-bottom: 20px;
display: flex;
justify-content: center;
}
// 淡入淡出动画
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
// 作品信息区域
.work-info-section {
background: #fafafa;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}
.info-row {
display: flex;
align-items: flex-start;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
.info-label {
width: 80px;
flex-shrink: 0;
color: rgba(0, 0, 0, 0.45);
font-size: 14px;
}
.info-value {
flex: 1;
color: rgba(0, 0, 0, 0.85);
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;
}
.attachments-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.attachment-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border: 1px solid #d9d9d9;
border-radius: 8px;
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>