library-picturebook-activity/frontend/src/views/public/ActivityDetail.vue
2026-04-08 16:31:48 +08:00

938 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="detail-page" v-if="activity">
<!-- 活动头部 -->
<div class="detail-hero">
<img v-if="activity.coverUrl" :src="activity.coverUrl" class="hero-cover" />
<div v-else class="hero-placeholder">
<span>{{ activity.contestName?.charAt(0) }}</span>
</div>
<div class="hero-overlay">
<a-button shape="round" size="small" @click="$router.back()" class="back-btn">
<arrow-left-outlined /> 返回
</a-button>
<div class="hero-badge">{{ stageLabel }}</div>
</div>
</div>
<!-- 活动信息 -->
<div class="detail-card">
<h1 class="detail-title">{{ activity.contestName }}</h1>
<div class="detail-meta">
<div class="meta-row">
<calendar-outlined />
<span>{{ formatDate(activity.startTime) }} - {{ formatDate(activity.endTime) }}</span>
</div>
<div class="meta-row">
<tag-outlined />
<span>{{ activity.contestType === 'individual' ? '个人参与' : '团队参与' }}</span>
</div>
<div class="meta-row" v-if="activity.registerStartTime">
<clock-circle-outlined />
<span>报名:{{ formatDate(activity.registerStartTime) }} - {{ formatDate(activity.registerEndTime) }}</span>
</div>
<div class="meta-row" v-if="activity.ageMin || activity.ageMax">
<team-outlined />
<span>年龄要求:{{ activity.ageMin || 0 }} - {{ activity.ageMax || '不限' }} 岁</span>
</div>
<div class="meta-row" v-if="activity.targetCities?.length">
<environment-outlined />
<span>面向城市:{{ (activity.targetCities as string[]).join('、') }}</span>
</div>
</div>
<!-- 操作按钮(根据阶段动态展示) -->
<div class="action-area">
<!-- 报名阶段 -->
<template v-if="currentStage === 'register'">
<a-button v-if="!isLoggedIn" type="primary" size="large" block class="action-btn" @click="goLogin">
登录后报名
</a-button>
<a-button v-else-if="!hasRegistered" type="primary" size="large" block class="action-btn" @click="showRegisterModal = true">
立即报名
</a-button>
<a-button v-else-if="registrationState === 'pending'" size="large" block class="action-btn-info">
<hourglass-outlined /> 报名审核中
</a-button>
<a-button v-else-if="registrationState === 'rejected'" size="large" block class="action-btn-disabled">
<close-circle-outlined /> 报名未通过
</a-button>
<a-button v-else size="large" block class="action-btn-done">
<check-circle-outlined /> 已报名
</a-button>
</template>
<!-- 提交阶段 -->
<template v-else-if="currentStage === 'submit'">
<a-button v-if="!isLoggedIn" type="primary" size="large" block class="action-btn" @click="goLogin">
登录后查看作品
</a-button>
<a-button v-else-if="!hasRegistered && isRegisterOpen" type="primary" size="large" block class="action-btn" @click="showRegisterModal = true">
立即报名
</a-button>
<a-button v-else-if="!hasRegistered" size="large" block disabled class="action-btn-disabled">
报名已截止
</a-button>
<a-button v-else-if="registrationState === 'pending'" size="large" block class="action-btn-info">
<hourglass-outlined /> 报名审核中,通过后可提交作品
</a-button>
<a-button v-else-if="registrationState === 'rejected'" size="large" block disabled class="action-btn-disabled">
<close-circle-outlined /> 报名未通过,无法提交作品
</a-button>
<a-button v-else-if="!hasSubmittedWork" type="primary" size="large" block class="action-btn" @click="openSubmitWork">
<picture-outlined /> 从作品库选择
</a-button>
<a-button v-else-if="canResubmit" type="primary" size="large" block class="action-btn" @click="openSubmitWork">
<picture-outlined /> 重新提交
</a-button>
<a-button v-else size="large" block class="action-btn-done">
<check-circle-outlined /> 作品已提交
</a-button>
</template>
<!-- 评审阶段 -->
<template v-else-if="currentStage === 'review'">
<a-button v-if="hasRegistered" size="large" block class="action-btn-info">
<hourglass-outlined /> 您已报名,评审中请耐心等待
</a-button>
<a-button v-else size="large" block disabled class="action-btn-disabled">
报名已结束
</a-button>
</template>
<!-- 已结束 -->
<template v-else-if="currentStage === 'finished'">
<a-button type="primary" size="large" block class="action-btn" @click="activeTab = 'results'">
查看成果
</a-button>
</template>
<!-- 未开始 -->
<template v-else>
<a-button size="large" block disabled class="action-btn-disabled">
活动即将开始
</a-button>
</template>
</div>
</div>
<!-- Tab 内容 -->
<div class="detail-card">
<a-tabs v-model:activeKey="activeTab">
<a-tab-pane key="info" tab="活动详情">
<div class="rich-content" v-html="activityContentHtml"></div>
<!-- 附件 -->
<div v-if="activity.attachments?.length" class="attachments">
<h4>相关附件</h4>
<div v-for="att in activity.attachments" :key="att.id" class="att-item">
<a :href="att.fileUrl" target="_blank">
<paper-clip-outlined /> {{ att.fileName }}
</a>
</div>
</div>
</a-tab-pane>
<a-tab-pane key="results" tab="活动成果" v-if="activity.resultState === 'published'">
<div class="results-panel">
<div class="results-hero">
<trophy-outlined class="results-icon" />
<p class="results-hero-text">活动成果已发布</p>
</div>
<p v-if="activity.resultPublishTime" class="results-publish-time">
发布时间:{{ formatDateTime(activity.resultPublishTime) }}
</p>
<p class="results-hint-line">以下排名与得分以主办方发布为准。</p>
<a-spin :spinning="resultsLoading">
<template v-if="resultsList.length > 0 || resultsLoading">
<div class="results-cards">
<div
v-for="record in resultsList"
:key="record.id"
class="result-card"
:class="{
'result-card--top1': record.rank === 1,
'result-card--top2': record.rank === 2,
'result-card--top3': record.rank === 3,
}"
>
<div class="result-card__rank">
<span v-if="record.rank != null" class="rank-pill">{{ record.rank }}</span>
<span v-else class="rank-pill rank-pill--muted">-</span>
</div>
<div class="result-card__body">
<div class="result-card__title-row">
<span class="result-card__name">{{ record.participantName || '-' }}</span>
<a-tag v-if="record.awardName" color="gold" class="result-card__award">
{{ record.awardName }}
</a-tag>
</div>
<div class="result-card__meta">
<span class="result-card__meta-item">
<span class="meta-label">作品编号</span>
{{ record.workNo || '-' }}
</span>
<span class="result-card__meta-item">
<span class="meta-label">得分</span>
<span v-if="record.finalScore != null" class="score-text">
{{ Number(record.finalScore).toFixed(2) }}
</span>
<span v-else class="text-muted">-</span>
</span>
</div>
</div>
</div>
</div>
<div v-if="resultsTotal > resultsPageSize" class="results-pagination-wrap">
<a-pagination
:current="resultsPage"
:total="resultsTotal"
:page-size="resultsPageSize"
:show-size-changer="false"
show-less-items
:show-total="(t: number) => `共 ${t} 条`"
@change="onResultsPageChange"
/>
</div>
</template>
<div v-else class="empty-tab">
<a-empty description="暂无公示信息" />
</div>
</a-spin>
</div>
</a-tab-pane>
<a-tab-pane key="notices" tab="活动公告">
<div v-if="!activity.notices?.length" class="empty-tab">
<a-empty description="暂无公告" />
</div>
<div v-else>
<div v-for="notice in activity.notices" :key="notice.id" class="notice-item">
<h4>{{ notice.title }}</h4>
<div class="notice-content" v-html="notice.content"></div>
<span class="notice-time">{{ formatNoticeTime(notice) }}</span>
</div>
</div>
</a-tab-pane>
</a-tabs>
</div>
<!-- 报名弹窗 -->
<a-modal
v-model:open="showRegisterModal"
title="活动报名"
:footer="null"
:width="420"
>
<div class="register-modal">
<template v-if="isChildUser">
<p class="modal-desc">将使用当前账号报名,确认参加本活动吗?</p>
</template>
<template v-else>
<p class="modal-desc">请选择参与者:</p>
<a-radio-group v-model:value="participantForm.participantType" class="participant-options">
<div class="participant-option">
<a-radio value="self">我自己</a-radio>
</div>
<template v-if="children.length">
<div v-for="child in children" :key="child.id" class="participant-option">
<a-radio :value="'child_' + child.id">
子女:{{ child.name }}
<span class="child-detail" v-if="child.grade">{{ child.grade }}</span>
</a-radio>
</div>
</template>
</a-radio-group>
<a-button type="link" @click="$router.push('/p/mine/children')" class="add-child-link">
+ 添加新的子女
</a-button>
</template>
<a-button
type="primary"
block
size="large"
:loading="registering"
@click="handleRegister"
class="confirm-btn"
>
确认报名
</a-button>
</div>
</a-modal>
<!-- 作品选择器弹窗 -->
<WorkSelector
v-model:open="showWorkSelector"
:redirect-url="route.fullPath"
@select="handleWorkSelected"
/>
</div>
<div v-else class="loading-page">
<a-spin size="large" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
ArrowLeftOutlined, CalendarOutlined, TagOutlined,
ClockCircleOutlined, PaperClipOutlined, CheckCircleOutlined,
PictureOutlined, HourglassOutlined, TrophyOutlined,
TeamOutlined, EnvironmentOutlined, CloseCircleOutlined,
} from '@ant-design/icons-vue'
import {
publicActivitiesApi,
publicChildrenApi,
type PublicActivityDetail,
type PublicActivityNotice,
type PublicActivityResultItem,
type UserWork,
} from '@/api/public'
import WorkSelector from './components/WorkSelector.vue'
import dayjs from 'dayjs'
const route = useRoute()
const router = useRouter()
const activity = ref<PublicActivityDetail | null>(null)
const activeTab = ref('info')
const children = ref<any[]>([])
const showRegisterModal = ref(false)
const registering = ref(false)
const hasRegistered = ref(false)
const registrationState = ref('')
const myRegistration = ref<any>(null)
const hasSubmittedWork = ref(false)
// 作品提交
const showWorkSelector = ref(false)
const submittingWork = ref(false)
const participantForm = ref({
participantType: 'self',
})
const formatDate = (d: string) => dayjs(d).format('YYYY-MM-DD')
const formatDateTime = (d: string) => dayjs(d).format('YYYY-MM-DD HH:mm')
/** 公示成果列表 */
const resultsLoading = ref(false)
const resultsList = ref<PublicActivityResultItem[]>([])
const resultsTotal = ref(0)
const resultsPage = ref(1)
const resultsPageSize = ref(10)
const loadPublicResults = async (page = 1) => {
if (!activity.value?.id) return
resultsLoading.value = true
try {
const res = await publicActivitiesApi.getPublishedResults(activity.value.id, {
page,
pageSize: resultsPageSize.value,
})
resultsList.value = res.list ?? []
resultsTotal.value = Number(res.total ?? 0)
resultsPage.value = Number(res.page ?? page)
} catch (err: any) {
message.error(err?.response?.data?.message || '加载成果列表失败')
resultsList.value = []
resultsTotal.value = 0
} finally {
resultsLoading.value = false
}
}
const onResultsPageChange = (page: number) => {
void loadPublicResults(page)
}
watch(activeTab, (k) => {
if (k === 'results' && activity.value?.resultState === 'published') {
void loadPublicResults(1)
}
})
const formatNoticeTime = (n: PublicActivityNotice) =>
formatDate(n.publishTime || n.createTime || '')
/** 活动详情富文本:后端字段为 content */
const activityContentHtml = computed(() => {
const a = activity.value
if (!a) return ''
return a.content || a.description || ''
})
/** 子女账号:直接报名,不选参与者、不添加子女 */
const isChildUser = computed(() => {
const raw = localStorage.getItem('public_user')
if (!raw) return false
try {
return JSON.parse(raw).userType === 'child'
} catch {
return false
}
})
const isLoggedIn = computed(() => !!localStorage.getItem('public_token'))
// 报名是否仍在开放中
const isRegisterOpen = computed(() => {
if (!activity.value) return false
const now = dayjs()
return !now.isBefore(activity.value.registerStartTime) && now.isBefore(activity.value.registerEndTime)
})
// 是否允许重新提交submitRule === 'resubmit' 且已提交过作品)
const canResubmit = computed(() => {
return hasSubmittedWork.value && activity.value?.submitRule === 'resubmit'
})
// 活动当前阶段
const currentStage = computed(() => {
if (!activity.value) return 'pending'
const now = dayjs()
const a = activity.value
if (now.isBefore(a.registerStartTime)) return 'pending'
// 提交阶段优先:如果到了提交时间且在提交截止前,优先显示提交(报名与提交可能重叠)
if (a.submitStartTime && !now.isBefore(a.submitStartTime) && a.submitEndTime && now.isBefore(a.submitEndTime)) return 'submit'
if (now.isBefore(a.registerEndTime)) return 'register'
if (a.reviewStartTime && now.isBefore(a.reviewEndTime)) return 'review'
if (a.status === 'finished' || a.resultState === 'published') return 'finished'
return 'review'
})
const stageLabel = computed(() => {
const map: Record<string, string> = {
pending: '即将开始', register: '报名中', submit: '提交中',
review: '评审中', finished: '已结束',
}
return map[currentStage.value] || '进行中'
})
const goLogin = () => router.push({ path: '/p/login', query: { redirect: route.fullPath } })
const fetchDetail = async () => {
const id = Number(route.params.id)
try {
activity.value = await publicActivitiesApi.detail(id)
} catch {
message.error('活动不存在')
router.push('/p/activities')
}
}
const fetchChildren = async () => {
if (!isLoggedIn.value) return
try {
children.value = await publicChildrenApi.list()
} catch { /* ignore */ }
}
// 检查报名状态和作品提交状态
const checkRegistrationStatus = async () => {
if (!isLoggedIn.value || !activity.value) return
try {
const reg = await publicActivitiesApi.getMyRegistration(activity.value.id)
// 只有当 reg 存在且有 id 时才认为已报名
if (reg && reg.id) {
hasRegistered.value = true
registrationState.value = reg.registrationState || ''
myRegistration.value = reg
// 检查是否已提交作品
hasSubmittedWork.value = reg.hasSubmittedWork || false
}
} catch (e) {
// 未报名或查询失败,保持 hasRegistered = false
console.log('查询报名状态失败或未报名')
}
}
// 打开作品选择器
const openSubmitWork = () => {
if (!isLoggedIn.value) { goLogin(); return }
showWorkSelector.value = true
}
// 从作品库选择作品后提交
const handleWorkSelected = async (work: UserWork) => {
if (!myRegistration.value) {
message.error('请先报名活动')
return
}
submittingWork.value = true
try {
await publicActivitiesApi.submitWork(activity.value.id, {
registrationId: myRegistration.value.id,
userWorkId: work.id,
})
message.success('作品提交成功!')
showWorkSelector.value = false
hasSubmittedWork.value = true
} catch (err: any) {
message.error(err?.response?.data?.message || '提交失败')
} finally {
submittingWork.value = false
}
}
const handleRegister = async () => {
if (!isLoggedIn.value) {
router.push({ path: '/p/login', query: { redirect: route.fullPath } })
return
}
if (!activity.value) return
registering.value = true
try {
if (isChildUser.value) {
await publicActivitiesApi.register(activity.value.id, { participantType: 'self' })
} else {
const val = participantForm.value.participantType
const isChild = val.startsWith('child_')
await publicActivitiesApi.register(activity.value.id, {
participantType: isChild ? 'child' : 'self',
childId: isChild ? parseInt(val.replace('child_', '')) : undefined,
})
}
message.success('报名成功!')
showRegisterModal.value = false
hasRegistered.value = true
// 重新查询报名状态以获取准确的 registrationState
await checkRegistrationStatus()
} catch (err: any) {
message.error(err?.response?.data?.message || '报名失败')
} finally {
registering.value = false
}
}
onMounted(async () => {
await fetchDetail()
await fetchChildren()
await checkRegistrationStatus()
})
</script>
<style scoped lang="scss">
$primary: #6366f1;
.loading-page {
display: flex;
justify-content: center;
padding: 100px 0;
}
.detail-hero {
position: relative;
height: 220px;
border-radius: 20px;
overflow: hidden;
margin-bottom: 16px;
.hero-cover {
width: 100%;
height: 100%;
object-fit: cover;
}
.hero-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #c7d2fe, #fbcfe8, #a7f3d0);
display: flex;
align-items: center;
justify-content: center;
span {
font-size: 60px;
font-weight: 800;
color: rgba(255, 255, 255, 0.6);
}
}
.hero-overlay {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0,0,0,0.3) 0%, transparent 40%, transparent 60%, rgba(0,0,0,0.3) 100%);
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 16px;
}
.back-btn {
background: rgba(255,255,255,0.2);
backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.3);
color: #fff;
}
.hero-badge {
padding: 4px 16px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
color: #fff;
background: linear-gradient(135deg, $primary, #ec4899);
}
}
.detail-card {
background: #fff;
border-radius: 16px;
padding: 24px;
margin-bottom: 16px;
border: 1px solid rgba($primary, 0.06);
}
.detail-title {
font-size: 22px;
font-weight: 800;
color: #1e1b4b;
margin: 0 0 16px;
line-height: 1.4;
}
.detail-meta {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
.meta-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #6b7280;
}
}
.action-area {
.action-btn {
height: 48px !important;
border-radius: 14px !important;
font-size: 16px !important;
font-weight: 700 !important;
background: linear-gradient(135deg, $primary, #ec4899) !important;
border: none !important;
box-shadow: 0 4px 16px rgba($primary, 0.3);
}
.action-btn-done {
height: 48px !important;
border-radius: 14px !important;
font-size: 15px !important;
font-weight: 600 !important;
background: #ecfdf5 !important;
color: #059669 !important;
border: 1px solid #a7f3d0 !important;
}
.action-btn-info {
height: 48px !important;
border-radius: 14px !important;
font-size: 15px !important;
font-weight: 600 !important;
background: #eef2ff !important;
color: $primary !important;
border: 1px solid #c7d2fe !important;
}
.action-btn-disabled {
height: 48px !important;
border-radius: 14px !important;
font-size: 15px !important;
font-weight: 600 !important;
background: #f3f4f6 !important;
color: #9ca3af !important;
border: 1px solid #e5e7eb !important;
}
}
.results-panel {
.results-hero {
text-align: center;
padding: 8px 0 16px;
.results-icon {
font-size: 40px;
color: #f59e0b;
margin-bottom: 8px;
display: block;
}
.results-hero-text {
font-size: 15px;
font-weight: 600;
color: #374151;
margin: 0;
}
}
.results-publish-time {
font-size: 13px;
color: #6b7280;
margin: 0 0 8px;
text-align: center;
}
.results-hint-line {
font-size: 12px;
color: #9ca3af;
margin: 0 0 16px;
text-align: center;
}
.results-cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.result-card {
display: flex;
gap: 14px;
align-items: stretch;
padding: 14px 16px;
background: #faf9fe;
border-radius: 14px;
border: 1px solid rgba($primary, 0.12);
transition: box-shadow 0.2s, border-color 0.2s;
&:hover {
border-color: rgba($primary, 0.22);
box-shadow: 0 4px 14px rgba($primary, 0.08);
}
&--top1 {
border-color: rgba(234, 179, 8, 0.45);
background: linear-gradient(135deg, #fffbeb 0%, #faf9fe 100%);
}
&--top2 {
border-color: rgba(148, 163, 184, 0.5);
background: linear-gradient(135deg, #f8fafc 0%, #faf9fe 100%);
}
&--top3 {
border-color: rgba(217, 119, 6, 0.35);
background: linear-gradient(135deg, #fff7ed 0%, #faf9fe 100%);
}
}
.result-card__rank {
flex-shrink: 0;
display: flex;
align-items: center;
}
.result-card__body {
flex: 1;
min-width: 0;
}
.result-card__title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.result-card__name {
font-size: 15px;
font-weight: 700;
color: #1e1b4b;
line-height: 1.4;
word-break: break-word;
}
.result-card__award {
flex-shrink: 0;
margin: 0 !important;
}
.result-card__meta {
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
font-size: 13px;
color: #6b7280;
}
.result-card__meta-item {
display: inline-flex;
align-items: baseline;
gap: 6px;
}
.meta-label {
color: #9ca3af;
font-size: 12px;
}
.results-pagination-wrap {
display: flex;
justify-content: center;
margin-top: 20px;
padding-top: 4px;
}
.rank-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
padding: 2px 8px;
border-radius: 8px;
font-weight: 700;
font-size: 13px;
background: linear-gradient(135deg, #eef2ff, #fce7f3);
color: #4f46e5;
&--muted {
background: #f3f4f6;
color: #9ca3af;
font-weight: 600;
}
}
.score-text {
font-variant-numeric: tabular-nums;
font-weight: 600;
color: #1e1b4b;
}
.text-muted {
color: #9ca3af;
}
}
.rich-content {
font-size: 14px;
line-height: 1.8;
color: #374151;
:deep(img) {
max-width: 100%;
border-radius: 8px;
}
}
.attachments {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #f0ecf9;
h4 {
font-size: 14px;
font-weight: 600;
color: #374151;
margin: 0 0 10px;
}
.att-item a {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: $primary;
padding: 6px 0;
}
}
.notice-item {
padding: 16px;
background: #faf9fe;
border-radius: 12px;
margin-bottom: 12px;
h4 {
font-size: 15px;
font-weight: 600;
color: #1e1b4b;
margin: 0 0 8px;
}
.notice-content {
font-size: 14px;
color: #6b7280;
line-height: 1.6;
}
.notice-time {
font-size: 12px;
color: #9ca3af;
margin-top: 8px;
display: block;
}
}
.empty-tab {
padding: 40px 0;
}
// 报名弹窗
.register-modal {
.modal-desc {
font-size: 14px;
color: #374151;
margin: 0 0 16px;
font-weight: 500;
}
.participant-options {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.participant-option {
padding: 12px 16px;
background: #faf9fe;
border-radius: 12px;
transition: background 0.2s;
&:hover {
background: #eef2ff;
}
.child-detail {
color: #9ca3af;
font-size: 12px;
}
}
.add-child-link {
margin: 12px 0;
padding: 0;
font-size: 13px;
}
.confirm-btn {
margin-top: 8px;
height: 44px !important;
border-radius: 12px !important;
font-weight: 600 !important;
}
}
@media (max-width: 640px) {
.detail-hero {
height: 180px;
border-radius: 16px;
}
.detail-card {
padding: 18px;
border-radius: 14px;
}
.detail-title {
font-size: 18px;
}
}
</style>