691 lines
17 KiB
Vue
691 lines
17 KiB
Vue
<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-spin :spinning="loading">
|
||
<div v-if="!work" class="empty-container">
|
||
<a-empty description="暂无作品" />
|
||
</div>
|
||
|
||
<div v-else class="work-detail">
|
||
<!-- 作品名称 -->
|
||
<div class="work-section">
|
||
<div class="section-label">作品名称</div>
|
||
<div class="section-content">{{ work.title }}</div>
|
||
</div>
|
||
|
||
<!-- 作品介绍 -->
|
||
<div class="work-section">
|
||
<div class="section-label">作品介绍</div>
|
||
<div class="section-content description-text">
|
||
{{ work.description || "暂无介绍" }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 参赛作品 -->
|
||
<div class="work-section">
|
||
<div class="section-label">参赛作品</div>
|
||
<div class="work-file-container">
|
||
<div v-if="workFile" class="work-image-item">
|
||
<div
|
||
class="image-wrapper"
|
||
@mouseenter="handleImageHover(workFile)"
|
||
@mouseleave="handleImageLeave"
|
||
>
|
||
<img
|
||
v-if="isImageFile(workFile)"
|
||
:src="getFileUrl(workFile)"
|
||
alt="作品图片"
|
||
class="work-image"
|
||
@error="handleImageError"
|
||
/>
|
||
<div
|
||
v-else-if="is3DModelFile(workFile)"
|
||
class="file-placeholder model-file"
|
||
>
|
||
<FileOutlined class="file-icon" />
|
||
<span class="file-name">{{ getFileName(workFile) }}</span>
|
||
<a-button
|
||
type="primary"
|
||
size="small"
|
||
class="preview-btn"
|
||
@click.stop="handlePreview3DModel(workFile)"
|
||
>
|
||
<template #icon><EyeOutlined /></template>
|
||
预览3D模型
|
||
</a-button>
|
||
</div>
|
||
<div v-else class="file-placeholder">
|
||
<FileOutlined class="file-icon" />
|
||
<span class="file-name">{{ getFileName(workFile) }}</span>
|
||
</div>
|
||
</div>
|
||
<!-- 图片预览遮罩 -->
|
||
<div
|
||
v-if="previewImage && previewImage === workFile"
|
||
class="image-preview-overlay"
|
||
@mouseleave="handleImageLeave"
|
||
>
|
||
<img
|
||
:src="getFileUrl(workFile)"
|
||
alt="作品预览"
|
||
class="preview-image"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div v-else class="no-files">暂无作品文件</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 作品信息 -->
|
||
<div class="work-section">
|
||
<div class="section-label">作品信息</div>
|
||
<div class="work-info">
|
||
<div class="info-item">
|
||
<span class="info-label">作品编号:</span>
|
||
<span class="info-value">{{ work.workNo || "-" }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">提交时间:</span>
|
||
<span class="info-value">{{
|
||
formatDateTime(work.submitTime)
|
||
}}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">作品状态:</span>
|
||
<a-tag :color="getStatusColor(work.status)">
|
||
{{ getStatusText(work.status) }}
|
||
</a-tag>
|
||
</div>
|
||
<div v-if="work.version" class="info-item">
|
||
<span class="info-label">版本号:</span>
|
||
<span class="info-value">v{{ work.version }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 上传的附件 -->
|
||
<div
|
||
v-if="work.attachments && work.attachments.length > 0"
|
||
class="work-section"
|
||
>
|
||
<div class="section-label">上传附件</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-space>
|
||
<a-button @click="handleCancel">关闭</a-button>
|
||
</a-space>
|
||
</template>
|
||
|
||
</a-drawer>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, watch, computed } from "vue"
|
||
import { message } from "ant-design-vue"
|
||
import {
|
||
FileOutlined,
|
||
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 authStore = useAuthStore()
|
||
const visible = ref(false)
|
||
const loading = ref(false)
|
||
const work = ref<ContestWork | null>(null)
|
||
const previewImage = ref<string | null>(null)
|
||
|
||
// 监听抽屉打开状态
|
||
watch(
|
||
() => props.open,
|
||
async (newVal) => {
|
||
visible.value = newVal
|
||
if (newVal) {
|
||
await fetchUserWork()
|
||
} else {
|
||
work.value = null
|
||
previewImage.value = null
|
||
}
|
||
},
|
||
{ 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
|
||
}
|
||
}
|
||
|
||
// 作品文件(只取第一个)
|
||
const workFile = computed(() => {
|
||
if (!work.value) return null
|
||
let files = work.value.files || []
|
||
|
||
// 如果 files 是字符串(JSON),需要解析
|
||
if (typeof files === 'string') {
|
||
try {
|
||
files = JSON.parse(files)
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
if (!Array.isArray(files) || files.length === 0) return null
|
||
|
||
// 处理可能是对象 {fileUrl: string} 或字符串的情况
|
||
const firstFile = files[0]
|
||
return typeof firstFile === 'object' && firstFile?.fileUrl
|
||
? firstFile.fileUrl
|
||
: firstFile
|
||
})
|
||
|
||
// 判断是否为图片文件
|
||
const isImageFile = (fileUrl: string): boolean => {
|
||
const imageExtensions = [
|
||
".jpg",
|
||
".jpeg",
|
||
".png",
|
||
".gif",
|
||
".bmp",
|
||
".webp",
|
||
".svg",
|
||
]
|
||
const lowerUrl = fileUrl.toLowerCase()
|
||
return imageExtensions.some((ext) => lowerUrl.includes(ext))
|
||
}
|
||
|
||
// 判断是否为3D模型文件
|
||
const is3DModelFile = (fileUrl: string): boolean => {
|
||
const modelExtensions = [
|
||
".glb",
|
||
".gltf",
|
||
".obj",
|
||
".fbx",
|
||
".3ds",
|
||
".dae",
|
||
".stl",
|
||
".ply",
|
||
]
|
||
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 || ""
|
||
// 如果 fileUrl 已经以 /api 开头,且 baseURL 也包含 /api,需要避免重复
|
||
if (fileUrl.startsWith("/api") && baseURL.includes("/api")) {
|
||
// fileUrl 已经包含 /api,直接拼接 baseURL 的协议和域名部分
|
||
const urlWithoutApi = baseURL.replace(/\/api$/, "")
|
||
return `${urlWithoutApi}${fileUrl}`
|
||
}
|
||
// 正常拼接
|
||
return `${baseURL}${fileUrl.startsWith("/") ? "" : "/"}${fileUrl}`
|
||
}
|
||
|
||
// 获取文件名
|
||
const getFileName = (fileUrl: string): string => {
|
||
if (!fileUrl) return "文件"
|
||
|
||
try {
|
||
// 移除查询参数和锚点
|
||
const urlWithoutQuery = fileUrl.split("?")[0].split("#")[0]
|
||
|
||
// 提取文件名
|
||
const parts = urlWithoutQuery.split("/")
|
||
let fileName = parts[parts.length - 1] || "文件"
|
||
|
||
// 如果文件名是空的或者是特殊字符,返回默认值
|
||
if (!fileName || fileName.trim() === "" || fileName === "[") {
|
||
return "文件"
|
||
}
|
||
|
||
// 解码 URL 编码的文件名
|
||
try {
|
||
fileName = decodeURIComponent(fileName)
|
||
} catch {
|
||
// 如果解码失败,使用原始文件名
|
||
}
|
||
|
||
// 如果文件名太长,截断并添加省略号
|
||
const maxLength = 20
|
||
if (fileName.length > maxLength) {
|
||
const ext = fileName.substring(fileName.lastIndexOf("."))
|
||
const nameWithoutExt = fileName.substring(0, fileName.lastIndexOf("."))
|
||
if (nameWithoutExt.length > maxLength - ext.length - 3) {
|
||
return (
|
||
nameWithoutExt.substring(0, maxLength - ext.length - 3) + "..." + ext
|
||
)
|
||
}
|
||
}
|
||
|
||
return fileName
|
||
} catch (error) {
|
||
console.error("获取文件名失败:", error)
|
||
return "文件"
|
||
}
|
||
}
|
||
|
||
// 图片鼠标移入
|
||
const handleImageHover = (file: string) => {
|
||
if (isImageFile(file)) {
|
||
previewImage.value = file
|
||
}
|
||
}
|
||
|
||
// 图片鼠标移出
|
||
const handleImageLeave = () => {
|
||
previewImage.value = null
|
||
}
|
||
|
||
// 图片加载错误
|
||
const handleImageError = (event: Event) => {
|
||
const img = event.target as HTMLImageElement
|
||
img.style.display = "none"
|
||
}
|
||
|
||
// 下载附件
|
||
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 (error: any) {
|
||
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"] || "未知"
|
||
}
|
||
|
||
// 预览3D模型
|
||
const handlePreview3DModel = (fileUrl: string) => {
|
||
console.log("handlePreview3DModel called with:", fileUrl)
|
||
if (!fileUrl) {
|
||
message.error("文件路径无效")
|
||
return
|
||
}
|
||
const fullUrl = getFileUrl(fileUrl)
|
||
console.log("预览3D模型,原始URL:", fileUrl, "完整URL:", fullUrl)
|
||
// 直接在新标签页打开模型查看器
|
||
const viewerUrl = `/model-viewer?url=${encodeURIComponent(fullUrl)}`
|
||
window.open(viewerUrl, "_blank")
|
||
}
|
||
|
||
// 取消
|
||
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;
|
||
}
|
||
|
||
.work-section {
|
||
margin-bottom: 24px;
|
||
|
||
&:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
}
|
||
|
||
.section-label {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: rgba(0, 0, 0, 0.85);
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.section-content {
|
||
font-size: 14px;
|
||
color: rgba(0, 0, 0, 0.65);
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.description-text {
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.work-file-container {
|
||
display: flex;
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.work-image-item {
|
||
position: relative;
|
||
width: 400px;
|
||
height: 400px;
|
||
}
|
||
|
||
.image-wrapper {
|
||
width: 100%;
|
||
height: 100%;
|
||
border: 1px solid #d9d9d9;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
transition: all 0.3s;
|
||
background: #fafafa;
|
||
|
||
&:hover {
|
||
border-color: #1890ff;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||
}
|
||
}
|
||
|
||
.work-image {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.file-placeholder {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 16px;
|
||
color: rgba(0, 0, 0, 0.45);
|
||
position: relative;
|
||
|
||
&.model-file {
|
||
padding-bottom: 60px;
|
||
}
|
||
}
|
||
|
||
.file-icon {
|
||
font-size: 48px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.file-name {
|
||
font-size: 12px;
|
||
text-align: center;
|
||
word-break: break-word;
|
||
overflow-wrap: break-word;
|
||
margin-bottom: 8px;
|
||
max-width: 100%;
|
||
padding: 0 4px;
|
||
line-height: 1.4;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.preview-btn {
|
||
position: absolute;
|
||
bottom: 12px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
}
|
||
|
||
.image-preview-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.8);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 10000;
|
||
cursor: zoom-out;
|
||
}
|
||
|
||
.preview-image {
|
||
max-width: 90%;
|
||
max-height: 90%;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.no-files {
|
||
color: rgba(0, 0, 0, 0.45);
|
||
font-size: 14px;
|
||
padding: 20px 0;
|
||
}
|
||
|
||
.work-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.info-item {
|
||
display: flex;
|
||
align-items: center;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.info-label {
|
||
color: rgba(0, 0, 0, 0.65);
|
||
margin-right: 8px;
|
||
min-width: 80px;
|
||
}
|
||
|
||
.info-value {
|
||
color: rgba(0, 0, 0, 0.85);
|
||
}
|
||
|
||
.attachments-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.attachment-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px;
|
||
border: 1px solid #d9d9d9;
|
||
border-radius: 4px;
|
||
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>
|