修改作品详情页
This commit is contained in:
parent
464f5389a4
commit
252ba92266
@ -381,13 +381,13 @@ router.beforeEach(async (to, _from, next) => {
|
||||
userTenantCode,
|
||||
to.path.replace(`/${tenantCodeFromUrl}`, "")
|
||||
)
|
||||
next({ path: correctedPath, replace: true })
|
||||
next({ path: correctedPath, query: to.query, replace: true })
|
||||
return
|
||||
}
|
||||
// 如果URL中没有租户编码,添加租户编码
|
||||
if (!tenantCodeFromUrl) {
|
||||
const correctedPath = buildPathWithTenantCode(userTenantCode, to.path)
|
||||
next({ path: correctedPath, replace: true })
|
||||
next({ path: correctedPath, query: to.query, replace: true })
|
||||
return
|
||||
}
|
||||
}
|
||||
@ -493,7 +493,7 @@ router.beforeEach(async (to, _from, next) => {
|
||||
userTenantCode,
|
||||
to.path.replace(`/${tenantCodeFromUrl}`, "")
|
||||
)
|
||||
next({ path: correctedPath, replace: true })
|
||||
next({ path: correctedPath, query: to.query, replace: true })
|
||||
return
|
||||
}
|
||||
// 如果URL中没有租户编码,添加租户编码(排除不需要认证的特殊路由)
|
||||
@ -501,7 +501,7 @@ router.beforeEach(async (to, _from, next) => {
|
||||
const shouldSkipTenantCode = skipTenantCodePaths.some(p => to.path.startsWith(p))
|
||||
if (!tenantCodeFromUrl && !shouldSkipTenantCode) {
|
||||
const correctedPath = buildPathWithTenantCode(userTenantCode, to.path)
|
||||
next({ path: correctedPath, replace: true })
|
||||
next({ path: correctedPath, query: to.query, replace: true })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
505
frontend/src/views/contests/components/WorkDetailModal.vue
Normal file
505
frontend/src/views/contests/components/WorkDetailModal.vue
Normal file
@ -0,0 +1,505 @@
|
||||
<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>
|
||||
<!-- 3D预览按钮 -->
|
||||
<transition name="fade">
|
||||
<div v-if="showPreviewBtn && hasModelFile" class="preview-btn-overlay" @click="handleView3DModel">
|
||||
<a-button type="primary">
|
||||
<template #icon><EyeOutlined /></template>
|
||||
3D模型预览
|
||||
</a-button>
|
||||
</div>
|
||||
</transition>
|
||||
</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.score !== null && record.score !== undefined" class="review-score">
|
||||
{{ record.score }} 分
|
||||
</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.comment" class="review-item comment">
|
||||
<span class="review-label">老师评语:</span>
|
||||
<span class="review-value">{{ record.comment }}</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,
|
||||
EyeOutlined,
|
||||
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 "作品详情"
|
||||
})
|
||||
|
||||
// 预览图URL
|
||||
const previewImageUrl = computed(() => {
|
||||
if (!workDetail.value) return ""
|
||||
// 优先使用预览图
|
||||
if (workDetail.value.previewUrl) {
|
||||
return workDetail.value.previewUrl
|
||||
}
|
||||
// 其次从 files 数组中查找图片
|
||||
const imageFromFiles = workDetail.value.files?.find(
|
||||
(url) => /\.(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 || ""
|
||||
})
|
||||
|
||||
// 检查URL或文件名是否是3D模型文件(支持带查询参数的URL)
|
||||
const isModelFile = (urlOrFileName: string): boolean => {
|
||||
// 移除查询参数后检查扩展名
|
||||
const pathWithoutQuery = urlOrFileName.split("?")[0]
|
||||
return /\.(glb|gltf|obj|fbx|stl|zip)$/i.test(pathWithoutQuery)
|
||||
}
|
||||
|
||||
// 是否有3D模型文件
|
||||
const hasModelFile = computed(() => {
|
||||
if (!workDetail.value) return false
|
||||
// 检查 files 数组
|
||||
const hasInFiles = workDetail.value.files?.some((url) => isModelFile(url))
|
||||
if (hasInFiles) return true
|
||||
// 检查 attachments 数组
|
||||
const hasInAttachments = workDetail.value.attachments?.some(
|
||||
(att) => isModelFile(att.fileName || "") || isModelFile(att.fileUrl || "")
|
||||
)
|
||||
return hasInAttachments || false
|
||||
})
|
||||
|
||||
// 获取3D模型文件URL
|
||||
const modelFileUrl = computed(() => {
|
||||
if (!workDetail.value) return ""
|
||||
// 优先从 files 数组中查找
|
||||
const modelFromFiles = workDetail.value.files?.find((url) => isModelFile(url))
|
||||
if (modelFromFiles) return modelFromFiles
|
||||
// 其次从 attachments 中查找
|
||||
const modelAtt = workDetail.value.attachments?.find(
|
||||
(att) => isModelFile(att.fileName || "") || isModelFile(att.fileUrl || "")
|
||||
)
|
||||
return modelAtt?.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"
|
||||
}
|
||||
|
||||
// 跳转3D模型预览
|
||||
const handleView3DModel = () => {
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
console.log("3D模型预览 - modelFileUrl:", modelFileUrl.value)
|
||||
console.log("3D模型预览 - files:", workDetail.value?.files)
|
||||
console.log("3D模型预览 - attachments:", workDetail.value?.attachments)
|
||||
if (modelFileUrl.value) {
|
||||
const url = `/${tenantCode}/workbench/model-viewer?url=${encodeURIComponent(modelFileUrl.value)}`
|
||||
console.log("3D模型预览 - 跳转URL:", url)
|
||||
window.open(url, "_blank")
|
||||
} else {
|
||||
message.warning("未找到3D模型文件")
|
||||
}
|
||||
}
|
||||
|
||||
// 下载附件
|
||||
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>
|
||||
@ -68,6 +68,9 @@
|
||||
<template v-if="column.key === 'index'">
|
||||
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'workNo'">
|
||||
<a @click="handleViewWorkDetail(record)">{{ record.workNo || "-" }}</a>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'finalScore'">
|
||||
<span v-if="record.finalScore !== null" class="score">
|
||||
{{ Number(record.finalScore).toFixed(2) }}
|
||||
@ -94,6 +97,12 @@
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 作品详情弹框 -->
|
||||
<WorkDetailModal
|
||||
v-model:open="workDetailModalVisible"
|
||||
:work-id="currentWorkId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -107,6 +116,7 @@ import {
|
||||
ReloadOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
import { resultsApi } from "@/api/contests"
|
||||
import WorkDetailModal from "../components/WorkDetailModal.vue"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@ -120,6 +130,10 @@ const contestInfo = ref<{
|
||||
resultState: string
|
||||
} | null>(null)
|
||||
|
||||
// 作品详情弹框
|
||||
const workDetailModalVisible = ref(false)
|
||||
const currentWorkId = ref<number | null>(null)
|
||||
|
||||
// 列表状态
|
||||
const loading = ref(false)
|
||||
const publishLoading = ref(false)
|
||||
@ -145,7 +159,6 @@ const columns = [
|
||||
},
|
||||
{
|
||||
title: "作品编号",
|
||||
dataIndex: "workNo",
|
||||
key: "workNo",
|
||||
width: 120,
|
||||
},
|
||||
@ -231,6 +244,12 @@ const handleBack = () => {
|
||||
router.push(`/${tenantCode}/contests/results`)
|
||||
}
|
||||
|
||||
// 查看作品详情
|
||||
const handleViewWorkDetail = (record: any) => {
|
||||
currentWorkId.value = record.id
|
||||
workDetailModalVisible.value = true
|
||||
}
|
||||
|
||||
// 发布/撤回赛果
|
||||
const handlePublish = () => {
|
||||
const isPublished = contestInfo.value?.resultState === "published"
|
||||
|
||||
@ -83,6 +83,9 @@
|
||||
<template v-if="column.key === 'index'">
|
||||
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'workNo'">
|
||||
<a @click="handleViewWorkDetail(record)">{{ record.workNo || "-" }}</a>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'username'">
|
||||
{{ record.submitterAccountNo || record.registration?.user?.username || "-" }}
|
||||
</template>
|
||||
@ -214,6 +217,12 @@
|
||||
</template>
|
||||
</a-drawer>
|
||||
|
||||
<!-- 作品详情弹框 -->
|
||||
<WorkDetailModal
|
||||
v-model:open="workDetailModalVisible"
|
||||
:work-id="currentWorkId"
|
||||
/>
|
||||
|
||||
<!-- 未提交作品弹框 -->
|
||||
<a-modal
|
||||
v-model:open="notSubmittedModalVisible"
|
||||
@ -259,6 +268,7 @@ import {
|
||||
import dayjs from "dayjs"
|
||||
import { contestsApi, worksApi, reviewsApi, type ContestWork } from "@/api/contests"
|
||||
import { judgesManagementApi, type Judge } from "@/api/judges-management"
|
||||
import WorkDetailModal from "../components/WorkDetailModal.vue"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@ -268,6 +278,10 @@ const contestType = (route.query.type as string) || "individual"
|
||||
// 赛事名称
|
||||
const contestName = ref("")
|
||||
|
||||
// 作品详情弹框
|
||||
const workDetailModalVisible = ref(false)
|
||||
const currentWorkId = ref<number | null>(null)
|
||||
|
||||
// 列表状态
|
||||
const loading = ref(false)
|
||||
const dataSource = ref<ContestWork[]>([])
|
||||
@ -287,7 +301,7 @@ const searchParams = reactive({
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{ title: "序号", key: "index", width: 70 },
|
||||
{ title: "作品编号", dataIndex: "workNo", key: "workNo", width: 120 },
|
||||
{ title: "作品编号", key: "workNo", width: 120 },
|
||||
{ title: "报名账号", key: "username", width: 150 },
|
||||
{ title: "评委评分", key: "judgeScore", width: 100 },
|
||||
{ title: "评审进度", key: "reviewProgress", width: 100 },
|
||||
@ -461,6 +475,12 @@ const handleBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 查看作品详情
|
||||
const handleViewWorkDetail = (record: ContestWork) => {
|
||||
currentWorkId.value = record.id
|
||||
workDetailModalVisible.value = true
|
||||
}
|
||||
|
||||
// 开始评审
|
||||
const handleStartReview = async () => {
|
||||
try {
|
||||
|
||||
@ -21,11 +21,18 @@
|
||||
</a-card>
|
||||
|
||||
<!-- 搜索表单 -->
|
||||
<a-form :model="searchParams" layout="inline" class="search-form" @finish="handleSearch">
|
||||
<a-form
|
||||
:model="searchParams"
|
||||
layout="inline"
|
||||
class="search-form"
|
||||
@finish="handleSearch"
|
||||
>
|
||||
<a-form-item :label="contestType === 'team' ? '队伍名称' : '选手名称'">
|
||||
<a-input
|
||||
v-model:value="searchParams.name"
|
||||
:placeholder="contestType === 'team' ? '请输入队伍名称' : '请输入选手名称'"
|
||||
:placeholder="
|
||||
contestType === 'team' ? '请输入队伍名称' : '请输入选手名称'
|
||||
"
|
||||
allow-clear
|
||||
style="width: 150px"
|
||||
/>
|
||||
@ -108,31 +115,44 @@
|
||||
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'workNo'">
|
||||
<a @click="handleViewWork(record)">{{ record.workNo || '-' }}</a>
|
||||
<a @click="handleViewWork(record)">{{ record.workNo || "-" }}</a>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'username'">
|
||||
{{ record.submitterAccountNo || record.registration?.user?.username || '-' }}
|
||||
{{
|
||||
record.submitterAccountNo ||
|
||||
record.registration?.user?.username ||
|
||||
"-"
|
||||
}}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'name'">
|
||||
<template v-if="contestType === 'team'">
|
||||
{{ record.registration?.team?.teamName || '-' }}
|
||||
{{ record.registration?.team?.teamName || "-" }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ record.registration?.user?.nickname || '-' }}
|
||||
{{ record.registration?.user?.nickname || "-" }}
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'submitTime'">
|
||||
{{ formatDate(record.submitTime) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'assignStatus'">
|
||||
<a-tag v-if="record._count?.assignments > 0" color="success">已分配</a-tag>
|
||||
<a-tag v-if="record._count?.assignments > 0" color="success"
|
||||
>已分配</a-tag
|
||||
>
|
||||
<a-tag v-else color="default">未分配</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'judges'">
|
||||
<template v-if="record.assignments && record.assignments.length > 0">
|
||||
<a-space wrap>
|
||||
<a-tag v-for="assignment in record.assignments" :key="assignment.id">
|
||||
{{ assignment.judge?.nickname || assignment.judge?.username || '-' }}
|
||||
<a-tag
|
||||
v-for="assignment in record.assignments"
|
||||
:key="assignment.id"
|
||||
>
|
||||
{{
|
||||
assignment.judge?.nickname ||
|
||||
assignment.judge?.username ||
|
||||
"-"
|
||||
}}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
@ -147,122 +167,141 @@
|
||||
</a-table>
|
||||
|
||||
<!-- 作品详情弹框 -->
|
||||
<a-modal
|
||||
<WorkDetailModal
|
||||
v-model:open="workModalVisible"
|
||||
title="作品详情"
|
||||
width="700px"
|
||||
:footer="null"
|
||||
>
|
||||
<template v-if="currentWork">
|
||||
<a-descriptions :column="2" bordered>
|
||||
<a-descriptions-item label="作品编号" :span="2">
|
||||
{{ currentWork.workNo || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="作品标题" :span="2">
|
||||
{{ currentWork.title }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="所属比赛" :span="2">
|
||||
{{ currentWork.contest?.contestName || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="提交人">
|
||||
{{ currentWork.registration?.user?.nickname || currentWork.submitterAccountNo || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="报名账号">
|
||||
{{ currentWork.registration?.user?.username || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="提交时间" :span="2">
|
||||
{{ formatDate(currentWork.submitTime) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item v-if="currentWork.description" label="作品描述" :span="2">
|
||||
<div v-html="currentWork.description"></div>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item v-if="currentWork.previewUrl" label="预览链接" :span="2">
|
||||
<a :href="currentWork.previewUrl" target="_blank">{{ currentWork.previewUrl }}</a>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
:work-id="currentWorkId"
|
||||
/>
|
||||
|
||||
<!-- 附件列表 -->
|
||||
<a-divider orientation="left">附件列表</a-divider>
|
||||
<a-table
|
||||
:columns="attachmentColumns"
|
||||
:data-source="currentWork.attachments || []"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'fileName'">
|
||||
<a :href="record.fileUrl" target="_blank">
|
||||
<FileOutlined /> {{ record.fileName }}
|
||||
</a>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'size'">
|
||||
{{ formatFileSize(record.size) }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<!-- 分配评委弹框 -->
|
||||
<a-modal
|
||||
<!-- 分配评委抽屉 -->
|
||||
<a-drawer
|
||||
v-model:open="assignModalVisible"
|
||||
title="分配评委"
|
||||
placement="right"
|
||||
width="800px"
|
||||
:confirm-loading="assignLoading"
|
||||
@ok="handleConfirmAssign"
|
||||
:footer-style="{ textAlign: 'right' }"
|
||||
@close="handleAssignDrawerClose"
|
||||
>
|
||||
<!-- 搜索 -->
|
||||
<a-form layout="inline" class="mb-3">
|
||||
<a-form-item label="评委姓名">
|
||||
<a-input
|
||||
v-model:value="judgeSearchParams.nickname"
|
||||
placeholder="请输入姓名"
|
||||
allow-clear
|
||||
style="width: 150px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="机构">
|
||||
<a-input
|
||||
v-model:value="judgeSearchParams.tenantName"
|
||||
placeholder="请输入机构"
|
||||
allow-clear
|
||||
style="width: 150px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" @click="handleSearchJudges">搜索</a-button>
|
||||
<a-button class="ml-2" @click="handleResetJudgeSearch">重置</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<div class="assign-judge-drawer">
|
||||
<!-- 搜索区域 -->
|
||||
<a-card class="mb-4" size="small">
|
||||
<a-form layout="inline" :model="judgeSearchParams" @finish="handleSearchJudges">
|
||||
<a-form-item label="姓名">
|
||||
<a-input
|
||||
v-model:value="judgeSearchParams.nickname"
|
||||
placeholder="请输入姓名"
|
||||
allow-clear
|
||||
style="width: 150px"
|
||||
@press-enter="handleSearchJudges"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="所属单位">
|
||||
<a-input
|
||||
v-model:value="judgeSearchParams.tenantName"
|
||||
placeholder="请输入所属单位"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@press-enter="handleSearchJudges"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" html-type="submit">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button style="margin-left: 8px" @click="handleResetJudgeSearch">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<!-- 评委列表 -->
|
||||
<a-table
|
||||
:columns="judgeColumns"
|
||||
:data-source="judgeList"
|
||||
:loading="judgeListLoading"
|
||||
:pagination="judgePagination"
|
||||
:row-selection="judgeRowSelection"
|
||||
row-key="id"
|
||||
size="small"
|
||||
@change="handleJudgeTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'judgeName'">
|
||||
{{ record.judge?.nickname || record.judge?.username || '-' }}
|
||||
<!-- 全部评委列表 -->
|
||||
<a-card class="mb-4" size="small">
|
||||
<template #title>全部评委</template>
|
||||
<a-table
|
||||
:columns="judgeColumns"
|
||||
:data-source="judgeList"
|
||||
:loading="judgeListLoading"
|
||||
:pagination="judgePagination"
|
||||
:row-selection="{
|
||||
selectedRowKeys: selectedJudgeKeys,
|
||||
onChange: handleJudgeSelectionChange,
|
||||
}"
|
||||
row-key="id"
|
||||
size="small"
|
||||
@change="handleJudgeTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'judgeName'">
|
||||
{{ record.judge?.nickname || record.judge?.username || "-" }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'phone'">
|
||||
{{ record.judge?.phone || "-" }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'tenant'">
|
||||
{{ record.judge?.organization || record.judge?.tenant?.name || "-" }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'assignedCount'">
|
||||
{{ record._count?.assignedContestWorks || 0 }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 已选评委区域 -->
|
||||
<a-card size="small">
|
||||
<template #title>
|
||||
已选 {{ selectedJudgeRows.length }} 位评委
|
||||
</template>
|
||||
<template v-else-if="column.key === 'phone'">
|
||||
{{ record.judge?.phone || '-' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'tenant'">
|
||||
{{ record.judge?.tenant?.name || '-' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'assignedCount'">
|
||||
{{ record._count?.assignedContestWorks || 0 }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-modal>
|
||||
<a-list
|
||||
:data-source="selectedJudgeRows"
|
||||
size="small"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
{{ item.judge?.nickname || item.judge?.username || "-" }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ item.judge?.organization || item.judge?.tenant?.name || "-" }}
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<template #actions>
|
||||
<a-button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
@click="handleRemoveSelectedJudge(item.id)"
|
||||
>
|
||||
移除
|
||||
</a-button>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
<template #empty>
|
||||
<a-empty description="暂未选择评委" :image="false" />
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
|
||||
<!-- 底部操作按钮 -->
|
||||
<div class="drawer-footer">
|
||||
<a-space>
|
||||
<a-button @click="handleAssignDrawerClose">取消</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
:loading="assignLoading"
|
||||
:disabled="selectedJudgeRows.length === 0"
|
||||
@click="handleConfirmAssign"
|
||||
>
|
||||
确定
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -276,7 +315,6 @@ import {
|
||||
ArrowLeftOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
FileOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
import dayjs from "dayjs"
|
||||
import {
|
||||
@ -287,6 +325,7 @@ import {
|
||||
type ContestWork,
|
||||
type ContestJudge,
|
||||
} from "@/api/contests"
|
||||
import WorkDetailModal from "../components/WorkDetailModal.vue"
|
||||
|
||||
interface Tenant {
|
||||
id: number
|
||||
@ -344,7 +383,7 @@ const columns = computed(() => [
|
||||
{
|
||||
title: contestType === "team" ? "队伍名称" : "选手姓名",
|
||||
key: "name",
|
||||
width: 150
|
||||
width: 150,
|
||||
},
|
||||
{ title: "递交时间", key: "submitTime", width: 160 },
|
||||
{ title: "分配状态", key: "assignStatus", width: 100 },
|
||||
@ -352,16 +391,9 @@ const columns = computed(() => [
|
||||
{ title: "操作", key: "action", width: 100, fixed: "right" as const },
|
||||
])
|
||||
|
||||
// 附件表格列
|
||||
const attachmentColumns = [
|
||||
{ title: "文件名", key: "fileName", dataIndex: "fileName", width: 300 },
|
||||
{ title: "类型", key: "fileType", dataIndex: "fileType", width: 100 },
|
||||
{ title: "大小", key: "size", dataIndex: "size", width: 100 },
|
||||
]
|
||||
|
||||
// 作品详情弹框
|
||||
const workModalVisible = ref(false)
|
||||
const currentWork = ref<ContestWork | null>(null)
|
||||
const currentWorkId = ref<number | null>(null)
|
||||
|
||||
// 分配评委弹框
|
||||
const assignModalVisible = ref(false)
|
||||
@ -384,14 +416,40 @@ const judgeSearchParams = reactive({
|
||||
const selectedJudgeKeys = ref<number[]>([])
|
||||
const selectedJudgeRows = ref<ContestJudge[]>([])
|
||||
|
||||
// 评委行选择配置
|
||||
const judgeRowSelection = computed<TableProps["rowSelection"]>(() => ({
|
||||
selectedRowKeys: selectedJudgeKeys.value,
|
||||
onChange: (keys: any, rows: ContestJudge[]) => {
|
||||
selectedJudgeKeys.value = keys
|
||||
selectedJudgeRows.value = rows
|
||||
},
|
||||
}))
|
||||
// 评委选择变化
|
||||
const handleJudgeSelectionChange = (selectedKeys: number[]) => {
|
||||
// 找出新增的评委
|
||||
const newSelectedIds = selectedKeys.filter(
|
||||
(id) => !selectedJudgeKeys.value.includes(id)
|
||||
)
|
||||
// 找出移除的评委
|
||||
const removedIds = selectedJudgeKeys.value.filter(
|
||||
(id) => !selectedKeys.includes(id)
|
||||
)
|
||||
|
||||
// 更新已选评委ID列表
|
||||
selectedJudgeKeys.value = selectedKeys
|
||||
|
||||
// 更新已选评委详情列表
|
||||
// 先移除被取消选择的
|
||||
selectedJudgeRows.value = selectedJudgeRows.value.filter(
|
||||
(judge) => !removedIds.includes(judge.id)
|
||||
)
|
||||
|
||||
// 再添加新选择的(从当前页的评委列表中查找)
|
||||
const newSelectedJudges = judgeList.value.filter((judge) =>
|
||||
newSelectedIds.includes(judge.id)
|
||||
)
|
||||
selectedJudgeRows.value = [...selectedJudgeRows.value, ...newSelectedJudges]
|
||||
}
|
||||
|
||||
// 移除已选评委
|
||||
const handleRemoveSelectedJudge = (judgeId: number) => {
|
||||
selectedJudgeKeys.value = selectedJudgeKeys.value.filter((id) => id !== judgeId)
|
||||
selectedJudgeRows.value = selectedJudgeRows.value.filter(
|
||||
(judge) => judge.id !== judgeId
|
||||
)
|
||||
}
|
||||
|
||||
// 评委表格列
|
||||
const judgeColumns = [
|
||||
@ -407,16 +465,6 @@ const formatDate = (dateStr?: string) => {
|
||||
return dayjs(dateStr).format("YYYY-MM-DD HH:mm")
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (size?: string | number) => {
|
||||
if (!size) return "-"
|
||||
const bytes = typeof size === "string" ? parseInt(size) : size
|
||||
if (isNaN(bytes)) return size
|
||||
if (bytes < 1024) return bytes + " B"
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + " MB"
|
||||
}
|
||||
|
||||
// 搜索选项过滤
|
||||
const filterOption = (input: string, option: any) => {
|
||||
return option.children?.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
@ -507,14 +555,9 @@ const handleBack = () => {
|
||||
}
|
||||
|
||||
// 查看作品详情
|
||||
const handleViewWork = async (record: ContestWork) => {
|
||||
try {
|
||||
const detail = await worksApi.getDetail(record.id)
|
||||
currentWork.value = detail
|
||||
workModalVisible.value = true
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "获取作品详情失败")
|
||||
}
|
||||
const handleViewWork = (record: ContestWork) => {
|
||||
currentWorkId.value = record.id
|
||||
workModalVisible.value = true
|
||||
}
|
||||
|
||||
// 单个分配评委
|
||||
@ -540,6 +583,14 @@ const handleBatchAssign = () => {
|
||||
fetchJudgeList()
|
||||
}
|
||||
|
||||
// 关闭分配评委抽屉
|
||||
const handleAssignDrawerClose = () => {
|
||||
assignModalVisible.value = false
|
||||
selectedJudgeKeys.value = []
|
||||
selectedJudgeRows.value = []
|
||||
currentAssignWork.value = null
|
||||
}
|
||||
|
||||
// 搜索评委
|
||||
const handleSearchJudges = () => {
|
||||
judgePagination.current = 1
|
||||
@ -570,7 +621,7 @@ const handleConfirmAssign = async () => {
|
||||
|
||||
assignLoading.value = true
|
||||
try {
|
||||
const judgeIds = selectedJudgeRows.value.map(j => j.judgeId)
|
||||
const judgeIds = selectedJudgeRows.value.map((j) => j.judgeId)
|
||||
|
||||
if (isBatchAssign.value) {
|
||||
// 批量分配
|
||||
@ -606,7 +657,7 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="scss">
|
||||
.works-detail-page {
|
||||
padding: 0;
|
||||
}
|
||||
@ -629,4 +680,16 @@ onMounted(() => {
|
||||
.ml-2 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.assign-judge-drawer {
|
||||
.drawer-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding: 16px 0;
|
||||
background: #fff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -433,13 +433,37 @@ let fillLight: THREE.DirectionalLight | null = null
|
||||
let spotLight: THREE.SpotLight | null = null
|
||||
let gridHelper: THREE.GridHelper | null = null
|
||||
|
||||
// 获取模型 URL
|
||||
const modelUrl = ref((route.query.url as string) || "")
|
||||
// 获取模型 URL - 支持刷新页面保持状态
|
||||
const SESSION_KEY = "model-viewer-url"
|
||||
const getModelUrl = (): string => {
|
||||
// 优先从 query 获取
|
||||
const queryUrl = route.query.url as string
|
||||
if (queryUrl) {
|
||||
// 保存到 sessionStorage,以便刷新页面时恢复
|
||||
sessionStorage.setItem(SESSION_KEY, queryUrl)
|
||||
return queryUrl
|
||||
}
|
||||
// 如果 query 没有,尝试从 sessionStorage 恢复
|
||||
const storedUrl = sessionStorage.getItem(SESSION_KEY)
|
||||
if (storedUrl) {
|
||||
return storedUrl
|
||||
}
|
||||
return ""
|
||||
}
|
||||
const modelUrl = ref(getModelUrl())
|
||||
console.log("模型查看器 - URL:", modelUrl.value)
|
||||
|
||||
// 返回上一页
|
||||
const handleBack = () => {
|
||||
router.back()
|
||||
// 清除 sessionStorage 中的 URL,防止下次从其他入口进入时使用旧的 URL
|
||||
sessionStorage.removeItem(SESSION_KEY)
|
||||
// 检查是否有历史记录可以返回
|
||||
// 如果是从新窗口打开的(无历史),则关闭窗口
|
||||
if (window.history.length <= 1) {
|
||||
window.close()
|
||||
} else {
|
||||
router.back()
|
||||
}
|
||||
}
|
||||
|
||||
// 重置相机视角
|
||||
|
||||
Loading…
Reference in New Issue
Block a user