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

974 lines
26 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>
<div class="contest-detail-page">
<a-spin :spinning="loading">
<!-- 顶部海报区域 -->
<div class="header-section">
<!-- 返回按钮 -->
<a-button class="back-button" type="text" @click="$router.back()">
<template #icon>
<ArrowLeftOutlined />
</template>
返回
</a-button>
<!-- 海报图片 -->
<div
class="poster-container"
:style="{
backgroundImage:
contest?.posterUrl || contest?.coverUrl
? `url(${contest.posterUrl || contest.coverUrl})`
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}"
>
<div class="poster-placeholder">
<span>赛事海报</span>
</div>
</div>
<!-- 导航栏和报名信息 -->
<div class="nav-bar-section">
<div class="nav-tabs-wrapper">
<a-tabs
v-model:activeKey="activeTab"
class="nav-tabs"
@change="handleTabChange"
>
<a-tab-pane key="info" tab="赛事信息" />
<a-tab-pane
key="notices"
:tab="`通知公告${
notices.length > 0 ? ` (${notices.length})` : ''
}`"
/>
<a-tab-pane
key="results"
:tab="`赛事结果${
results.length > 0 ? ` (${results.length})` : ''
}`"
/>
</a-tabs>
</div>
<div class="registration-info-bar">
<div class="registration-time-info">
<span class="time-label">报名时间</span>
<span v-if="isRegistering" class="countdown-text">
距离报名截止还有 {{ daysRemaining }} 天
</span>
</div>
<div class="registration-time-range">
{{
formatDateRange(
contest?.registerStartTime,
contest?.registerEndTime
)
}}
</div>
<a-button
v-if="isRegistering && !hasRegistered"
type="primary"
size="large"
class="register-button"
@click="handleRegister"
>
立即报名
</a-button>
<a-button
v-else-if="hasRegistered && canViewRegistration"
type="default"
size="large"
class="register-button"
@click="handleViewRegistration"
>
查看报名
</a-button>
<a-button
v-else
type="default"
size="large"
class="register-button"
disabled
>
立即报名
</a-button>
</div>
</div>
</div>
<!-- 主标题 -->
<div v-if="contest" class="title-section">
<h1 class="contest-title">{{ contest.contestName }}</h1>
</div>
<!-- 内容区域 - 两列布局 -->
<div v-if="contest" class="content-section">
<a-row :gutter="24">
<!-- 左侧:竞赛信息 -->
<a-col :xs="24" :lg="16">
<div class="info-content">
<div class="section-header">| 竞赛信息</div>
<!-- 赛事信息 Tab 内容 -->
<div v-if="activeTab === 'info'" class="info-detail">
<div v-if="contest.content" class="info-section">
<h3 class="section-title">一、大赛介绍</h3>
<div class="section-content" v-html="contest.content"></div>
</div>
<div v-if="(contest as any).theme" class="info-section">
<h3 class="section-title">二、大赛主题</h3>
<div class="section-content">
{{ (contest as any).theme }}
</div>
</div>
<div class="info-section">
<h3 class="section-title">三、参赛对象</h3>
<div class="section-content">
在创新创业应用领域具有先进技术解决方案和成功实践经验的创业团队、优秀人才均可报名参赛。
</div>
</div>
<div
v-if="
(contest as any).tracks && (contest as any).tracks.length
"
class="info-section"
>
<h3 class="section-title">四、大赛赛道</h3>
<div class="section-content">
<a-tag
v-for="track in (contest as any).tracks"
:key="track"
class="track-tag"
>
{{ track }}
</a-tag>
</div>
</div>
<div class="info-section">
<h3 class="section-title">五、参赛规则</h3>
<div class="section-content">
<div class="rule-item">
<strong>(一)参赛团队:</strong>
<p>
1.参赛团队应由1-8人组成一名参赛人员仅允许参与一支参赛队伍。
</p>
</div>
</div>
</div>
</div>
<!-- 通知公告 Tab 内容 -->
<div v-if="activeTab === 'notices'" class="notices-content">
<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>
</div>
<!-- 赛事结果 Tab 内容 -->
<div v-if="activeTab === 'results'" class="results-content">
<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 || "-" }}
</a-tag>
</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>
</template>
</a-table>
</a-spin>
</div>
<a-empty v-else description="结果尚未公布" />
</div>
</div>
</a-col>
<!-- 右侧:组织信息 -->
<a-col :xs="24" :lg="8">
<div class="org-info-card">
<div class="info-item">
<div class="info-label">| 发布者</div>
<div class="info-value">
<div v-if="(contest as any).publisher">
{{ (contest as any).publisher }}
</div>
<div v-else>-</div>
</div>
</div>
<div class="info-item">
<div class="info-label">| 类型</div>
<div class="info-value">
<a-tag
:color="
contest.contestType === 'individual' ? 'blue' : 'green'
"
>
{{
contest.contestType === "individual" ? "个人赛" : "团队赛"
}}
</a-tag>
</div>
</div>
<div class="info-item">
<div class="info-label">| 主办单位</div>
<div class="info-value">
<template
v-if="contest.organizers && contest.organizers.length"
>
<div
v-for="org in contest.organizers"
:key="org"
class="org-item"
>
{{ org }}
</div>
</template>
<span v-else>-</span>
</div>
</div>
<div class="info-item">
<div class="info-label">| 协办单位</div>
<div class="info-value">
<template
v-if="contest.coOrganizers && contest.coOrganizers.length"
>
<div
v-for="org in contest.coOrganizers"
:key="org"
class="org-item"
>
{{ org }}
</div>
</template>
<span v-else>-</span>
</div>
</div>
<div class="info-item">
<div class="info-label">| 赞助单位</div>
<div class="info-value">
<template v-if="contest.sponsors && contest.sponsors.length">
<div
v-for="sp in contest.sponsors"
:key="sp"
class="org-item"
>
{{ sp }}
</div>
</template>
<span v-else>-</span>
</div>
</div>
<div class="info-item">
<div class="info-label">| 报名时间</div>
<div class="info-value">
{{ formatDateTime(contest.registerStartTime) }} 至
{{ formatDateTime(contest.registerEndTime) }}
</div>
</div>
<div class="info-item">
<div class="info-label">| 比赛时间</div>
<div class="info-value">
{{ formatDateTime(contest.startTime) }} 至
{{ formatDateTime(contest.endTime) }}
</div>
</div>
</div>
</a-col>
</a-row>
</div>
<a-empty v-else description="比赛不存在" />
</a-spin>
</div>
</template>
<script setup lang="ts">
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"
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const tenantCode = route.params.tenantCode as string
const loading = ref(false)
const noticesLoading = ref(false)
const resultsLoading = ref(false)
const contest = ref<Contest | null>(null)
const notices = ref<ContestNotice[]>([])
const results = ref<ContestResult[]>([])
const activeTab = ref("info")
const hasRegistered = ref(false)
const myRegistration = ref<any>(null)
// 检查是否有查看报名的权限
const canViewRegistration = computed(() => {
const permissions = authStore.user?.permissions || []
return permissions.includes('registration:read') || permissions.includes('registration:create')
})
const contestId = Number(route.params.id)
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")
}
// 格式化日期时间
const formatDateTime = (dateStr?: string) => {
if (!dateStr) return "-"
return dayjs(dateStr).format("YYYY-MM-DD HH:mm")
}
// 格式化日期范围
const formatDateRange = (startDate?: string, endDate?: string) => {
if (!startDate || !endDate) return "-"
return `${formatDate(startDate)} ~ ${formatDate(endDate)}`
}
// 判断是否在报名中
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 || "-"
}
}
// 获取公告类型颜色
const getNoticeTypeColor = (type?: string) => {
switch (type) {
case "urgent":
return "red"
case "system":
return "blue"
default:
return "default"
}
}
// 获取公告类型文本
const getNoticeTypeText = (type?: string) => {
switch (type) {
case "urgent":
return "紧急通知"
case "system":
return "系统公告"
default:
return "普通公告"
}
}
// 计算报名阶段状态
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"
}
// 加载比赛详情
const fetchContestDetail = async () => {
loading.value = true
try {
contest.value = await contestsApi.getDetail(contestId)
// 检查是否已报名
await checkRegistration()
} catch (error: any) {
message.error(error?.response?.data?.message || "获取比赛详情失败")
} finally {
loading.value = false
}
}
// 检查是否已报名
const checkRegistration = async () => {
if (!authStore.user) return
try {
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)
}
}
// 加载公告列表
const fetchNotices = async () => {
noticesLoading.value = true
try {
notices.value = await noticesApi.getList(contestId)
} catch (error: any) {
message.error("获取公告列表失败")
} finally {
noticesLoading.value = false
}
}
// 加载赛事结果
const fetchResults = async () => {
if (!contest.value || contest.value.resultState !== "published") return
resultsLoading.value = true
try {
const response = await resultsApi.getResults(
contestId,
resultsPagination.value.current,
resultsPagination.value.pageSize
)
results.value = response.list || []
resultsPagination.value.total = response.total || 0
} catch (error: any) {
message.error("获取赛事结果失败")
} finally {
resultsLoading.value = false
}
}
// 结果表格变化处理
const handleResultsTableChange = (pag: any) => {
resultsPagination.value.current = pag.current || 1
resultsPagination.value.pageSize = pag.pageSize || 20
fetchResults()
}
// 立即报名 - 跳转到报名页面
const handleRegister = () => {
if (!authStore.user) {
message.warning("请先登录")
router.push(`/${tenantCode}/login`)
return
}
if (!contest.value) return
// 根据比赛类型跳转到对应的报名页面
if (contest.value.contestType === "team") {
router.push(`/${tenantCode}/contests/${contestId}/register/team`)
} else {
router.push(`/${tenantCode}/contests/${contestId}/register/individual`)
}
}
// 查看报名 - 跳转到报名页面
const handleViewRegistration = () => {
if (!contest.value) return
// 根据比赛类型跳转到对应的报名页面
if (contest.value.contestType === "team") {
router.push(`/${tenantCode}/contests/${contestId}/register/team`)
} else {
router.push(`/${tenantCode}/contests/${contestId}/register/individual`)
}
}
onMounted(() => {
fetchContestDetail()
fetchNotices()
})
// 监听tab切换切换到结果tab时加载结果
const handleTabChange = (key: string) => {
activeTab.value = key
if (key === "results" && contest.value?.resultState === "published") {
fetchResults()
}
}
</script>
<style lang="scss" scoped>
.contest-detail-page {
min-height: 100vh;
background-color: #f0f2f5;
.header-section {
position: relative;
background: #fff;
margin-bottom: 0;
.back-button {
position: absolute;
top: 24px;
left: 24px;
z-index: 100;
color: #fff;
font-size: 16px;
&:hover {
color: #1890ff;
}
}
.poster-container {
width: 100%;
height: 400px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
display: flex;
align-items: center;
justify-content: center;
position: relative;
.poster-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
color: #fff;
font-size: 18px;
}
}
.nav-bar-section {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
.nav-tabs-wrapper {
flex: 1;
:deep(.ant-tabs-nav) {
margin-bottom: 0;
}
:deep(.ant-tabs-tab) {
padding: 12px 24px;
font-size: 16px;
}
}
.registration-info-bar {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
.registration-time-info {
display: flex;
align-items: center;
gap: 12px;
.time-label {
font-size: 14px;
color: #8c8c8c;
}
.countdown-text {
font-size: 14px;
color: #1890ff;
font-weight: 500;
}
}
.registration-time-range {
font-size: 14px;
color: #595959;
}
.register-button {
min-width: 120px;
}
}
}
}
.title-section {
max-width: 1200px;
margin: 0 auto;
padding: 24px 24px 0;
.contest-title {
font-size: 32px;
font-weight: 600;
color: #262626;
margin: 0;
line-height: 1.5;
}
}
.content-section {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
.info-content {
background: #fff;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.section-header {
font-size: 18px;
font-weight: 600;
color: #262626;
margin-bottom: 24px;
padding-bottom: 12px;
border-bottom: 2px solid #1890ff;
}
.info-detail {
.info-section {
margin-bottom: 32px;
&:last-child {
margin-bottom: 0;
}
.section-title {
font-size: 18px;
font-weight: 600;
color: #262626;
margin-bottom: 16px;
}
.section-content {
font-size: 15px;
color: #595959;
line-height: 1.8;
.track-tag {
margin-right: 8px;
margin-bottom: 8px;
}
.rule-item {
margin-bottom: 16px;
strong {
color: #262626;
}
p {
margin: 8px 0 0 24px;
color: #595959;
}
}
}
}
}
.notices-content,
.results-content {
padding-top: 16px;
}
}
.org-info-card {
background: #fff;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.info-item {
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid #f0f0f0;
&:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.info-label {
font-size: 14px;
font-weight: 600;
color: #262626;
margin-bottom: 12px;
}
.info-value {
font-size: 14px;
color: #595959;
line-height: 1.8;
.org-item {
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
}
}
}
}
}
}
// 响应式设计
@media (max-width: 992px) {
.contest-detail-page {
.header-section {
.nav-bar-section {
flex-direction: column;
align-items: flex-start;
gap: 16px;
.registration-info-bar {
width: 100%;
align-items: flex-start;
}
}
}
.content-section {
.info-content,
.org-info-card {
margin-bottom: 24px;
}
}
}
}
@media (max-width: 768px) {
.contest-detail-page {
.header-section {
.poster-container {
height: 250px;
}
.nav-bar-section {
padding: 12px 16px;
.nav-tabs-wrapper {
:deep(.ant-tabs-tab) {
padding: 8px 16px;
font-size: 14px;
}
}
}
}
.title-section {
padding: 16px 16px 0;
.contest-title {
font-size: 24px;
}
}
.content-section {
padding: 16px;
.info-content,
.org-info-card {
padding: 16px;
}
}
}
}
</style>