library-picturebook-activity/java-frontend/src/views/contests/components/WorkDetailModal.vue
2026-04-01 19:30:33 +08:00

464 lines
11 KiB
Vue

<template>
<a-drawer
v-model:open="visible"
:title="drawerTitle"
placement="right"
width="600px"
:footer="null"
class="work-detail-drawer"
>
<a-spin :spinning="loading">
<template v-if="workDetail">
<!-- 提交时间 -->
<div class="submit-time">
{{ formatDateTime(workDetail.submitTime) }}
</div>
<!-- 作品介绍 -->
<div class="section">
<div class="section-title">作品介绍</div>
<div class="section-content description">
<template v-if="workDetail.description">
<div v-html="workDetail.description"></div>
</template>
<span v-else class="empty-text">暂无介绍</span>
</div>
</div>
<!-- 作品详情 - 预览图 -->
<div class="section">
<div class="section-title">作品详情</div>
<div class="preview-container">
<div class="preview-image" @mouseenter="showPreviewBtn = true" @mouseleave="showPreviewBtn = false">
<img
v-if="previewImageUrl"
:src="previewImageUrl"
alt="作品预览"
@error="handleImageError"
/>
<div v-else class="preview-placeholder">
<FileImageOutlined />
<span>暂无预览图</span>
</div>
</div>
</div>
</div>
<!-- 作品附件 -->
<div class="section">
<div class="section-title">作品附件</div>
<div class="attachments-list">
<template v-if="workDetail.attachments && workDetail.attachments.length > 0">
<div
v-for="attachment in workDetail.attachments"
:key="attachment.id"
class="attachment-item"
>
<div class="attachment-name">
<span class="label">附件名称:</span>
<span>{{ attachment.fileName?.split('.')[0] || attachment.fileName }}</span>
</div>
<div class="attachment-file">
<PaperClipOutlined class="file-icon" />
<span class="file-name">{{ attachment.fileName }}</span>
<a-button
type="link"
size="small"
@click="handleDownload(attachment)"
>
<template #icon><DownloadOutlined /></template>
</a-button>
</div>
</div>
</template>
<a-empty v-else description="暂无附件" :image="false" />
</div>
</div>
<!-- 评审记录 -->
<div class="section">
<div class="section-title">评审记录</div>
<div class="review-records">
<template v-if="reviewRecords && reviewRecords.length > 0">
<a-tabs v-model:activeKey="activeReviewTab">
<a-tab-pane
v-for="record in reviewRecords"
:key="record.id"
:tab="record.judge?.nickname || record.judge?.username || '评委'"
>
<div class="review-card">
<div class="review-item">
<span class="review-label">作品评分:</span>
<span v-if="record.totalScore !== null && record.totalScore !== undefined" class="review-score">
{{ record.totalScore }} 分
</span>
<span v-else class="not-reviewed">未评审</span>
</div>
<div class="review-item">
<span class="review-label">评委老师:</span>
<span class="review-value">{{ record.judge?.nickname || record.judge?.username || '-' }}</span>
</div>
<div class="review-item">
<span class="review-label">评分时间:</span>
<span class="review-value">
{{ record.scoreTime ? formatDateTime(record.scoreTime) : '-' }}
</span>
</div>
<div v-if="record.comments" class="review-item comment">
<span class="review-label">老师评语:</span>
<span class="review-value">{{ record.comments }}</span>
</div>
</div>
</a-tab-pane>
</a-tabs>
</template>
<a-empty v-else description="暂无评审记录" :image="false" />
</div>
</div>
</template>
<a-empty v-else-if="!loading" description="暂无数据" />
</a-spin>
</a-drawer>
</template>
<script setup lang="ts">
import { ref, watch, computed } from "vue"
import { useRoute, useRouter } from "vue-router"
import { message } from "ant-design-vue"
import {
FileImageOutlined,
PaperClipOutlined,
DownloadOutlined
} from "@ant-design/icons-vue"
import dayjs from "dayjs"
import { worksApi, reviewsApi, type ContestWork } from "@/api/contests"
interface Props {
open: boolean
workId?: number | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
"update:open": [value: boolean]
}>()
const route = useRoute()
const router = useRouter()
// 弹框显示状态
const visible = ref(false)
const loading = ref(false)
const workDetail = ref<ContestWork | null>(null)
const reviewRecords = ref<any[]>([])
const activeReviewTab = ref<number | string>("")
const showPreviewBtn = ref(false)
// 抽屉标题
const drawerTitle = computed(() => {
if (workDetail.value) {
const workNo = workDetail.value.workNo || ""
const title = workDetail.value.title || ""
return workNo && title ? `${workNo} ${title}` : workNo || title || "作品详情"
}
return "作品详情"
})
// 解析 files 字段(可能是 JSON 字符串)
const parsedFiles = computed(() => {
if (!workDetail.value) return []
let files = workDetail.value.files || []
if (typeof files === "string") {
try {
files = JSON.parse(files)
} catch {
files = []
}
}
if (!Array.isArray(files)) files = []
return files
})
// 预览图URL
const previewImageUrl = computed(() => {
if (!workDetail.value) return ""
// 优先使用预览图
if (workDetail.value.previewUrl) {
return workDetail.value.previewUrl
}
// 其次从 files 数组中查找图片
const imageFromFiles = parsedFiles.value.find(
(url: string) => /\.(jpg|jpeg|png|gif|webp)$/i.test(url)
)
if (imageFromFiles) return imageFromFiles
// 最后从 attachments 中查找
const imageAttachment = workDetail.value.attachments?.find(
(att) => att.fileType?.startsWith("image/") || /\.(jpg|jpeg|png|gif|webp)$/i.test(att.fileName || "")
)
return imageAttachment?.fileUrl || ""
})
// 格式化日期时间
const formatDateTime = (dateStr?: string) => {
if (!dateStr) return "-"
return dayjs(dateStr).format("YYYY.MM.DD HH:mm")
}
// 获取作品详情
const fetchWorkDetail = async (id: number) => {
loading.value = true
try {
const detail = await worksApi.getDetail(id)
workDetail.value = detail
// 获取评审记录
await fetchReviewRecords(id)
} catch (error: any) {
message.error(error?.response?.data?.message || "获取作品详情失败")
workDetail.value = null
} finally {
loading.value = false
}
}
// 获取评审记录
const fetchReviewRecords = async (workId: number) => {
try {
const records = await reviewsApi.getWorkScores(workId)
reviewRecords.value = records || []
if (records && records.length > 0) {
activeReviewTab.value = records[0].id
}
} catch (error) {
console.error("获取评审记录失败", error)
reviewRecords.value = []
}
}
// 处理图片加载错误
const handleImageError = (e: Event) => {
const target = e.target as HTMLImageElement
target.style.display = "none"
}
// 下载附件
const handleDownload = (attachment: any) => {
if (attachment.fileUrl) {
window.open(attachment.fileUrl, "_blank")
}
}
// 监听 open 属性变化
watch(
() => props.open,
(newVal) => {
visible.value = newVal
if (newVal && props.workId) {
fetchWorkDetail(props.workId)
} else if (!newVal) {
workDetail.value = null
reviewRecords.value = []
}
}
)
// 监听 visible 变化同步到父组件
watch(visible, (newVal) => {
emit("update:open", newVal)
})
// 监听 workId 变化
watch(
() => props.workId,
(newVal) => {
if (newVal && props.open) {
fetchWorkDetail(newVal)
}
}
)
</script>
<style scoped lang="scss">
.work-detail-drawer {
:deep(.ant-drawer-body) {
padding: 16px 24px;
}
}
.submit-time {
color: #666;
font-size: 14px;
margin-bottom: 20px;
}
.section {
margin-bottom: 24px;
.section-title {
font-size: 15px;
font-weight: 500;
color: #333;
margin-bottom: 12px;
}
.section-content {
color: #666;
font-size: 14px;
line-height: 1.8;
}
.description {
background: #fafafa;
padding: 12px 16px;
border-radius: 6px;
}
.empty-text {
color: #999;
}
}
.preview-container {
.preview-image {
position: relative;
width: 100%;
height: 280px;
background: #f5f5f5;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #bbb;
font-size: 14px;
:deep(.anticon) {
font-size: 48px;
margin-bottom: 12px;
}
}
.preview-btn-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;
cursor: pointer;
}
}
}
.attachments-list {
.attachment-item {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
.attachment-name {
margin-bottom: 8px;
font-size: 14px;
.label {
color: #0958d9;
}
}
.attachment-file {
display: flex;
align-items: center;
padding: 8px 12px;
background: #fafafa;
border-radius: 6px;
.file-icon {
color: #52c41a;
margin-right: 8px;
}
.file-name {
flex: 1;
color: #333;
font-size: 14px;
}
}
}
}
.review-records {
:deep(.ant-tabs-nav) {
margin-bottom: 16px;
}
.review-card {
background: #fff;
border-left: 3px solid #52c41a;
padding: 16px;
border-radius: 0 8px 8px 0;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
.review-item {
margin-bottom: 12px;
font-size: 14px;
line-height: 1.6;
&:last-child {
margin-bottom: 0;
}
.review-label {
color: #666;
}
.review-value {
color: #333;
}
.review-score {
color: #0958d9;
font-size: 18px;
font-weight: 600;
}
.not-reviewed {
color: #999;
}
&.comment {
.review-value {
color: #52c41a;
}
}
}
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>