library-picturebook-activity/frontend/src/views/public/ActivityDetail.vue

938 lines
26 KiB
Vue
Raw Normal View History

<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>