library-picturebook-activity/frontend/src/views/contests/Detail.vue

862 lines
26 KiB
Vue
Raw Normal View History

2025-12-09 11:10:36 +08:00
<template>
<div class="contest-detail-page">
<a-spin :spinning="loading">
2026-01-08 09:17:46 +08:00
<!-- 顶部海报区域 -->
<div class="poster-section">
<div
class="poster-image"
:style="{
backgroundImage:
contest?.posterUrl || contest?.coverUrl
? `url(${contest.posterUrl || contest.coverUrl})`
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}"
>
<!-- 返回按钮 -->
<a-button
class="back-button"
type="primary"
shape="circle"
size="large"
@click="$router.back()"
>
<template #icon>
<ArrowLeftOutlined />
</template>
</a-button>
</div>
<!-- 右侧报名信息卡片桌面端 -->
<div class="registration-card desktop-only">
<div class="registration-info">
<div class="info-title">报名时间</div>
<div class="info-time">
{{ formatDate(contest?.registerStartTime) }} ~
{{ formatDate(contest?.registerEndTime) }}
</div>
<div v-if="isRegistering" class="countdown">
<a-tag color="processing" class="countdown-tag">
距离报名截止还有 {{ daysRemaining }}
</a-tag>
</div>
<div v-else-if="isRegisterEnded" class="countdown">
<a-tag color="error" class="countdown-tag">报名已截止</a-tag>
</div>
<div v-else class="countdown">
<a-tag color="default" class="countdown-tag">报名未开始</a-tag>
</div>
</div>
<div class="registration-action">
2025-12-09 11:10:36 +08:00
<a-button
2026-01-08 09:17:46 +08:00
v-if="isRegistering && !hasRegistered"
type="primary"
size="large"
block
@click="handleRegister"
2025-12-09 11:10:36 +08:00
>
2026-01-08 09:17:46 +08:00
立即报名
2025-12-09 11:10:36 +08:00
</a-button>
2026-01-08 09:17:46 +08:00
<a-button
v-else-if="hasRegistered"
type="default"
size="large"
block
@click="handleViewRegistration"
>
查看报名
</a-button>
<a-button v-else type="default" size="large" block disabled>
查看报名
</a-button>
</div>
</div>
</div>
2025-12-09 11:10:36 +08:00
2026-01-08 09:17:46 +08:00
<!-- 移动端报名信息卡片 -->
<div v-if="contest" class="registration-card mobile-only">
<div class="registration-info">
<div class="info-title">报名时间</div>
<div class="info-time">
{{ formatDate(contest?.registerStartTime) }} ~
{{ formatDate(contest?.registerEndTime) }}
</div>
<div v-if="isRegistering" class="countdown">
<a-tag color="processing" class="countdown-tag">
距离报名截止还有 {{ daysRemaining }}
</a-tag>
</div>
<div v-else-if="isRegisterEnded" class="countdown">
<a-tag color="error" class="countdown-tag">报名已截止</a-tag>
</div>
<div v-else class="countdown">
<a-tag color="default" class="countdown-tag">报名未开始</a-tag>
</div>
</div>
<div class="registration-action">
<a-button
v-if="isRegistering && !hasRegistered"
type="primary"
size="large"
block
@click="handleRegister"
>
立即报名
</a-button>
<a-button
v-else-if="hasRegistered"
type="default"
size="large"
block
@click="handleViewRegistration"
>
查看报名
</a-button>
<a-button v-else type="default" size="large" block disabled>
查看报名
</a-button>
</div>
</div>
2025-12-09 11:10:36 +08:00
2026-01-08 09:17:46 +08:00
<!-- 内容区域 -->
<div v-if="contest" class="content-section">
<a-tabs
v-model:activeKey="activeTab"
class="detail-tabs"
@change="handleTabChange"
>
<!-- 赛事信息 -->
<a-tab-pane key="info" tab="赛事信息">
<a-card>
<a-descriptions :column="2" bordered>
<a-descriptions-item label="比赛名称">
{{ contest.contestName }}
</a-descriptions-item>
<a-descriptions-item label="比赛类型">
<a-tag
:color="
contest.contestType === 'individual' ? 'blue' : 'green'
"
>
{{
contest.contestType === "individual" ? "个人赛" : "团队赛"
}}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="发布状态">
<a-tag
:color="
contest.contestState === 'published'
? 'success'
: 'default'
"
>
{{
contest.contestState === "published" ? "已发布" : "未发布"
}}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="赛事状态">
<a-tag
:color="
contest.status === 'ongoing' ? 'processing' : 'orange'
"
>
{{ contest.status === "ongoing" ? "进行中" : "已完结" }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="比赛时间" :span="2">
{{ formatDateTime(contest.startTime) }} -
{{ formatDateTime(contest.endTime) }}
</a-descriptions-item>
<a-descriptions-item
v-if="contest.address"
label="比赛地址"
:span="2"
>
{{ contest.address }}
</a-descriptions-item>
<a-descriptions-item
v-if="contest.content"
label="比赛详情"
:span="2"
>
<div v-html="contest.content"></div>
</a-descriptions-item>
</a-descriptions>
<a-divider orientation="left">联系信息</a-divider>
<a-descriptions :column="3" bordered>
<a-descriptions-item label="联系人">
{{ contest.contactName || "-" }}
</a-descriptions-item>
<a-descriptions-item label="联系电话">
{{ contest.contactPhone || "-" }}
</a-descriptions-item>
<a-descriptions-item label="联系二维码">
<a-image
v-if="contest.contactQrcode"
:src="contest.contactQrcode"
:width="80"
/>
<span v-else>-</span>
</a-descriptions-item>
</a-descriptions>
<a-divider orientation="left">主办信息</a-divider>
<a-descriptions :column="1" bordered>
<a-descriptions-item label="主办方">
<template
v-if="contest.organizers && contest.organizers.length"
>
<a-tag v-for="org in contest.organizers" :key="org">{{
org
}}</a-tag>
</template>
<span v-else>-</span>
</a-descriptions-item>
<a-descriptions-item label="协办方">
<template
v-if="contest.coOrganizers && contest.coOrganizers.length"
>
<a-tag v-for="org in contest.coOrganizers" :key="org">
{{ org }}
</a-tag>
</template>
<span v-else>-</span>
</a-descriptions-item>
<a-descriptions-item label="赞助商">
<template v-if="contest.sponsors && contest.sponsors.length">
<a-tag v-for="sp in contest.sponsors" :key="sp">{{
sp
}}</a-tag>
2025-12-09 11:10:36 +08:00
</template>
2026-01-08 09:17:46 +08:00
<span v-else>-</span>
</a-descriptions-item>
</a-descriptions>
<!-- 报名配置 -->
<a-divider orientation="left">报名配置</a-divider>
<a-descriptions :column="2" bordered>
<a-descriptions-item label="报名时间" :span="2">
{{ formatDateTime(contest.registerStartTime) }} -
{{ formatDateTime(contest.registerEndTime) }}
</a-descriptions-item>
<a-descriptions-item label="需要审核">
<a-tag :color="contest.requireAudit ? 'orange' : 'green'">
{{ contest.requireAudit ? "需要审核" : "无需审核" }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="当前报名状态">
<a-tag :color="getRegisterStateColor()">
{{ getRegisterStateText() }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item
v-if="contest.allowedGrades && contest.allowedGrades.length"
label="允许年级"
:span="2"
>
<a-tag v-for="grade in contest.allowedGrades" :key="grade">
{{ grade }}年级
</a-tag>
</a-descriptions-item>
<a-descriptions-item
v-if="contest.allowedClasses && contest.allowedClasses.length"
label="允许班级"
:span="2"
>
<a-tag v-for="cls in contest.allowedClasses" :key="cls">
{{ cls }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item
v-if="contest.contestType === 'team'"
label="团队人数限制"
:span="2"
>
{{ contest.teamMinMembers || 1 }} -
{{ contest.teamMaxMembers || "不限" }}
</a-descriptions-item>
</a-descriptions>
<!-- 作品配置 -->
<a-divider orientation="left">作品配置</a-divider>
<a-descriptions :column="2" bordered>
<a-descriptions-item label="作品提交时间" :span="2">
{{ formatDateTime(contest.submitStartTime) }} -
{{ formatDateTime(contest.submitEndTime) }}
</a-descriptions-item>
<a-descriptions-item label="提交规则">
<a-tag
:color="
contest.submitRule === 'resubmit' ? 'blue' : 'default'
"
>
{{
contest.submitRule === "once"
? "单次提交"
: "允许重新提交"
}}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="当前提交状态">
<a-tag :color="getSubmitStateColor()">
{{ getSubmitStateText() }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item v-if="contest.workType" label="作品类型">
<a-tag>{{ getWorkTypeText(contest.workType) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item
v-if="contest.workRequirement"
label="作品要求"
:span="2"
>
<div v-html="contest.workRequirement"></div>
</a-descriptions-item>
</a-descriptions>
</a-card>
2025-12-09 11:10:36 +08:00
</a-tab-pane>
2026-01-08 09:17:46 +08:00
<!-- 通知公告 -->
<a-tab-pane key="notices" tab="通知公告">
<a-card>
<a-list
:data-source="notices"
:loading="noticesLoading"
item-layout="vertical"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>
<a-space>
<span>{{ item.title }}</span>
<a-tag :color="getNoticeTypeColor(item.noticeType)">
{{ getNoticeTypeText(item.noticeType) }}
</a-tag>
<a-tag v-if="item.priority > 0" color="red">
优先级: {{ item.priority }}
</a-tag>
</a-space>
</template>
<template #description>
<div>{{ item.content }}</div>
<div style="margin-top: 8px; color: #999">
发布时间: {{ formatDateTime(item.publishTime) }}
</div>
</template>
</a-list-item-meta>
</a-list-item>
</template>
<template #empty>
<a-empty description="暂无公告" />
</template>
</a-list>
</a-card>
</a-tab-pane>
<!-- 赛事结果 -->
<a-tab-pane key="results" tab="赛事结果">
<a-card>
<div v-if="contest.resultState === 'published'">
<a-spin :spinning="resultsLoading">
<a-table
:columns="resultColumns"
:data-source="results"
:pagination="resultsPagination"
row-key="id"
@change="handleResultsTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'rank'">
<a-tag :color="getRankColor(record.rank)">
{{ record.rank || "-" }}
2025-12-09 11:10:36 +08:00
</a-tag>
2026-01-08 09:17:46 +08:00
</template>
<template v-else-if="column.key === 'author'">
{{
record.registration?.user?.nickname ||
record.registration?.team?.teamName ||
"-"
}}
</template>
<template v-else-if="column.key === 'award'">
<a-tag
v-if="record.awardName"
:color="getAwardColor(record.awardName)"
>
{{ record.awardName }}
</a-tag>
<span v-else>-</span>
</template>
2025-12-09 11:10:36 +08:00
</template>
2026-01-08 09:17:46 +08:00
</a-table>
</a-spin>
</div>
<a-empty v-else description="结果尚未公布" />
</a-card>
2025-12-09 11:10:36 +08:00
</a-tab-pane>
</a-tabs>
2026-01-08 09:17:46 +08:00
</div>
2025-12-09 11:10:36 +08:00
<a-empty v-else description="比赛不存在" />
</a-spin>
</div>
</template>
<script setup lang="ts">
2026-01-08 09:17:46 +08:00
import { ref, onMounted, computed } from "vue"
import { useRoute, useRouter } from "vue-router"
import { message } from "ant-design-vue"
import { ArrowLeftOutlined } from "@ant-design/icons-vue"
import dayjs from "dayjs"
import { useAuthStore } from "@/stores/auth"
import {
contestsApi,
noticesApi,
resultsApi,
registrationsApi,
type Contest,
type ContestNotice,
type ContestResult,
} from "@/api/contests"
2025-12-09 11:10:36 +08:00
const route = useRoute()
const router = useRouter()
2026-01-08 09:17:46 +08:00
const authStore = useAuthStore()
const tenantCode = route.params.tenantCode as string
2025-12-09 11:10:36 +08:00
const loading = ref(false)
const noticesLoading = ref(false)
2026-01-08 09:17:46 +08:00
const resultsLoading = ref(false)
2025-12-09 11:10:36 +08:00
const contest = ref<Contest | null>(null)
const notices = ref<ContestNotice[]>([])
2026-01-08 09:17:46 +08:00
const results = ref<ContestResult[]>([])
const activeTab = ref("info")
const hasRegistered = ref(false)
const myRegistration = ref<any>(null)
2025-12-09 11:10:36 +08:00
const contestId = Number(route.params.id)
2026-01-08 09:17:46 +08:00
const resultsPagination = ref({
current: 1,
pageSize: 20,
total: 0,
})
const resultColumns = [
{
title: "排名",
key: "rank",
dataIndex: "rank",
width: 80,
},
{
title: "作品名称",
key: "title",
dataIndex: "title",
width: 200,
},
{
title: "作者",
key: "author",
width: 150,
},
{
title: "最终得分",
key: "finalScore",
dataIndex: "finalScore",
width: 120,
sorter: true,
},
{
title: "奖项",
key: "award",
width: 120,
},
]
// 格式化日期
const formatDate = (dateStr?: string) => {
if (!dateStr) return "-"
return dayjs(dateStr).format("YYYY-MM-DD")
}
2025-12-09 11:10:36 +08:00
// 格式化日期时间
const formatDateTime = (dateStr?: string) => {
2026-01-08 09:17:46 +08:00
if (!dateStr) return "-"
return dayjs(dateStr).format("YYYY-MM-DD HH:mm")
}
// 判断是否在报名中
const isRegistering = computed(() => {
if (!contest.value) return false
const now = dayjs()
const start = dayjs(contest.value.registerStartTime)
const end = dayjs(contest.value.registerEndTime)
return now.isAfter(start) && now.isBefore(end)
})
// 判断报名是否已结束
const isRegisterEnded = computed(() => {
if (!contest.value) return false
const now = dayjs()
const end = dayjs(contest.value.registerEndTime)
return now.isAfter(end)
})
// 计算距离报名截止还有几天
const daysRemaining = computed(() => {
if (!contest.value || !isRegistering.value) return 0
const now = dayjs()
const end = dayjs(contest.value.registerEndTime)
const diff = end.diff(now, "day")
return diff > 0 ? diff : 0
})
// 获取作品类型文本
const getWorkTypeText = (type?: string) => {
switch (type) {
case "image":
return "图片"
case "video":
return "视频"
case "document":
return "文档"
case "code":
return "代码"
case "other":
return "其他"
default:
return type || "-"
}
2025-12-09 11:10:36 +08:00
}
// 获取公告类型颜色
const getNoticeTypeColor = (type?: string) => {
switch (type) {
2026-01-08 09:17:46 +08:00
case "urgent":
return "red"
case "system":
return "blue"
2025-12-09 11:10:36 +08:00
default:
2026-01-08 09:17:46 +08:00
return "default"
2025-12-09 11:10:36 +08:00
}
}
// 获取公告类型文本
const getNoticeTypeText = (type?: string) => {
switch (type) {
2026-01-08 09:17:46 +08:00
case "urgent":
return "紧急通知"
case "system":
return "系统公告"
2025-12-09 11:10:36 +08:00
default:
2026-01-08 09:17:46 +08:00
return "普通公告"
2025-12-09 11:10:36 +08:00
}
}
2026-01-08 09:17:46 +08:00
// 计算报名阶段状态
const getRegisterStateColor = () => {
if (!contest.value) return "default"
const now = dayjs()
const start = dayjs(contest.value.registerStartTime)
const end = dayjs(contest.value.registerEndTime)
if (now.isBefore(start)) return "default"
if (now.isAfter(end)) return "orange"
return "processing"
}
const getRegisterStateText = () => {
if (!contest.value) return "-"
const now = dayjs()
const start = dayjs(contest.value.registerStartTime)
const end = dayjs(contest.value.registerEndTime)
if (now.isBefore(start)) return "未开始"
if (now.isAfter(end)) return "已结束"
return "进行中"
}
// 计算作品提交阶段状态
const getSubmitStateColor = () => {
if (!contest.value) return "default"
const now = dayjs()
const start = dayjs(contest.value.submitStartTime)
const end = dayjs(contest.value.submitEndTime)
if (now.isBefore(start)) return "default"
if (now.isAfter(end)) return "orange"
return "processing"
}
const getSubmitStateText = () => {
if (!contest.value) return "-"
const now = dayjs()
const start = dayjs(contest.value.submitStartTime)
const end = dayjs(contest.value.submitEndTime)
if (now.isBefore(start)) return "未开始"
if (now.isAfter(end)) return "已结束"
return "进行中"
}
// 获取排名颜色
const getRankColor = (rank?: number) => {
if (!rank) return "default"
if (rank === 1) return "gold"
if (rank === 2) return "default"
if (rank === 3) return "orange"
return "blue"
}
// 获取奖项颜色
const getAwardColor = (award?: string) => {
if (!award) return "default"
if (award.includes("一等奖") || award.includes("金奖")) return "gold"
if (award.includes("二等奖") || award.includes("银奖")) return "default"
if (award.includes("三等奖") || award.includes("铜奖")) return "orange"
return "blue"
}
2025-12-09 11:10:36 +08:00
// 加载比赛详情
const fetchContestDetail = async () => {
loading.value = true
try {
contest.value = await contestsApi.getDetail(contestId)
2026-01-08 09:17:46 +08:00
// 检查是否已报名
await checkRegistration()
2025-12-09 11:10:36 +08:00
} catch (error: any) {
2026-01-08 09:17:46 +08:00
message.error(error?.response?.data?.message || "获取比赛详情失败")
2025-12-09 11:10:36 +08:00
} finally {
loading.value = false
}
}
2026-01-08 09:17:46 +08:00
// 检查是否已报名
const checkRegistration = async () => {
if (!authStore.user) return
2025-12-09 11:10:36 +08:00
try {
2026-01-08 09:17:46 +08:00
const response = await registrationsApi.getList({
contestId,
userId: authStore.user.id,
page: 1,
pageSize: 1,
})
if (response.list && response.list.length > 0) {
hasRegistered.value = true
myRegistration.value = response.list[0]
}
} catch (error) {
console.error("检查报名状态失败:", error)
2025-12-09 11:10:36 +08:00
}
}
// 加载公告列表
const fetchNotices = async () => {
noticesLoading.value = true
try {
notices.value = await noticesApi.getList(contestId)
} catch (error: any) {
2026-01-08 09:17:46 +08:00
message.error("获取公告列表失败")
2025-12-09 11:10:36 +08:00
} finally {
noticesLoading.value = false
}
}
2026-01-08 09:17:46 +08:00
// 加载赛事结果
const fetchResults = async () => {
if (!contest.value || contest.value.resultState !== "published") return
resultsLoading.value = true
2025-12-09 11:10:36 +08:00
try {
2026-01-08 09:17:46 +08:00
const response = await resultsApi.getResults(
contestId,
resultsPagination.value.current,
resultsPagination.value.pageSize
)
results.value = response.list || []
resultsPagination.value.total = response.total || 0
2025-12-09 11:10:36 +08:00
} catch (error: any) {
2026-01-08 09:17:46 +08:00
message.error("获取赛事结果失败")
} finally {
resultsLoading.value = false
2025-12-09 11:10:36 +08:00
}
}
2026-01-08 09:17:46 +08:00
// 结果表格变化处理
const handleResultsTableChange = (pag: any) => {
resultsPagination.value.current = pag.current || 1
resultsPagination.value.pageSize = pag.pageSize || 20
fetchResults()
2025-12-09 11:10:36 +08:00
}
2026-01-08 09:17:46 +08:00
// 立即报名
const handleRegister = async () => {
if (!authStore.user) {
message.warning("请先登录")
router.push(`/${tenantCode}/login`)
return
}
2025-12-09 11:10:36 +08:00
try {
2026-01-08 09:17:46 +08:00
await registrationsApi.create({
contestId,
userId: authStore.user.id,
registrationType: contest.value?.contestType || "individual",
})
message.success("报名成功")
hasRegistered.value = true
await checkRegistration()
2025-12-09 11:10:36 +08:00
} catch (error: any) {
2026-01-08 09:17:46 +08:00
message.error(error?.response?.data?.message || "报名失败,请稍后重试")
2025-12-09 11:10:36 +08:00
}
}
2026-01-08 09:17:46 +08:00
// 查看报名
const handleViewRegistration = () => {
if (myRegistration.value) {
router.push(
`/${tenantCode}/contests/registrations/${myRegistration.value.id}`
)
} else {
router.push(`/${tenantCode}/contests/registrations?contestId=${contestId}`)
2025-12-09 11:10:36 +08:00
}
}
onMounted(() => {
fetchContestDetail()
fetchNotices()
})
2026-01-08 09:17:46 +08:00
// 监听tab切换切换到结果tab时加载结果
const handleTabChange = (key: string) => {
activeTab.value = key
if (key === "results" && contest.value?.resultState === "published") {
fetchResults()
}
}
2025-12-09 11:10:36 +08:00
</script>
2026-01-08 09:17:46 +08:00
<style lang="scss" scoped>
2025-12-09 11:10:36 +08:00
.contest-detail-page {
2026-01-08 09:17:46 +08:00
min-height: 100vh;
background-color: #f0f2f5;
.poster-section {
position: relative;
width: 100%;
height: 400px;
overflow: hidden;
.poster-image {
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
position: relative;
.back-button {
position: absolute;
top: 24px;
left: 24px;
z-index: 10;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.registration-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 8px;
padding: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
&.desktop-only {
position: absolute;
top: 24px;
right: 24px;
width: 320px;
z-index: 10;
}
&.mobile-only {
display: none;
}
.registration-info {
margin-bottom: 16px;
.info-title {
font-size: 14px;
color: #8c8c8c;
margin-bottom: 8px;
}
.info-time {
font-size: 16px;
color: #262626;
font-weight: 500;
margin-bottom: 12px;
}
.countdown {
.countdown-tag {
margin: 0;
font-size: 14px;
}
}
}
.registration-action {
margin-top: 16px;
}
}
}
}
.content-section {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
.detail-tabs {
background: #fff;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
2025-12-09 11:10:36 +08:00
}
2026-01-08 09:17:46 +08:00
// 响应式设计
@media (max-width: 768px) {
.contest-detail-page {
.poster-section {
height: 300px;
.poster-image {
.back-button {
top: 16px;
left: 16px;
}
}
.registration-card.desktop-only {
display: none;
}
}
.registration-card.mobile-only {
display: block;
margin: 16px;
width: calc(100% - 32px);
}
.content-section {
padding: 16px;
.detail-tabs {
padding: 16px;
}
}
}
}
</style>