library-picturebook-activity/frontend/src/views/contests/components/ViewWorkDrawer.vue
2026-01-12 20:04:11 +08:00

691 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="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>