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

788 lines
19 KiB
Vue
Raw Normal View History

2026-01-09 18:14:35 +08:00
<template>
<a-drawer
v-model:open="visible"
title="参赛作品"
placement="right"
width="500px"
: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"
:class="{ 'is-hovering': isHovering }"
@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"
:class="{ 'btn-visible': isHovering }"
@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
v-if="isImageFile(workFile) && isHovering"
class="preview-overlay"
>
<a-button
type="primary"
size="large"
class="preview-overlay-btn"
@click.stop="handlePreviewImage(workFile)"
>
<template #icon><EyeOutlined /></template>
预览图片
</a-button>
</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 { useRouter } from "vue-router"
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 router = useRouter()
const authStore = useAuthStore()
const visible = ref(false)
const loading = ref(false)
const work = ref<ContestWork | null>(null)
const previewImage = ref<string | null>(null)
const isHovering = ref(false)
// 监听抽屉打开状态
watch(
() => props.open,
async (newVal) => {
visible.value = newVal
if (newVal) {
await fetchUserWork()
} else {
work.value = null
previewImage.value = null
isHovering.value = false
}
},
{ 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 (e) {
console.error("解析文件列表失败:", e)
files = []
}
}
// 确保 files 是数组
if (!Array.isArray(files)) {
files = []
}
const file = files.length > 0 ? files[0] : null
// 调试信息
if (file) {
console.log("当前文件:", file)
console.log("是否为图片:", isImageFile(file))
console.log("是否为3D模型:", is3DModelFile(file))
}
return file
})
// 判断是否为图片文件
const isImageFile = (fileUrl: string | null): boolean => {
if (!fileUrl || typeof fileUrl !== "string") return false
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 | null): boolean => {
if (!fileUrl || typeof fileUrl !== "string") return false
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 | null) => {
if (!file) return
console.log("鼠标移入,文件:", file)
console.log("isHovering 设置为 true")
isHovering.value = true
if (isImageFile(file)) {
previewImage.value = file
}
}
// 图片鼠标移出
const handleImageLeave = () => {
isHovering.value = false
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) => {
if (!fileUrl) {
message.error("文件路径无效")
return
}
const fullUrl = getFileUrl(fileUrl)
console.log("预览3D模型文件URL:", fullUrl)
// 直接使用 location 跳转
const viewerUrl = `/model-viewer?url=${encodeURIComponent(fullUrl)}`
console.log("跳转URL:", viewerUrl)
window.location.href = viewerUrl
}
// 预览图片
const handlePreviewImage = (fileUrl: string) => {
previewImage.value = fileUrl
}
// 取消
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;
position: relative;
&:hover,
&.is-hovering {
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%);
opacity: 0.7;
transition: all 0.3s ease-in-out;
z-index: 10;
&.btn-visible,
&:hover {
opacity: 1;
transform: translateX(-50%) scale(1.05);
}
}
.preview-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
animation: fadeIn 0.3s ease-in-out;
z-index: 10;
}
.preview-overlay-btn {
animation: scaleIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scaleIn {
from {
transform: scale(0.8);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.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>