1312 lines
37 KiB
Vue
1312 lines
37 KiB
Vue
<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 v-if="showHeroBack" 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>
|
||
|
||
<!-- 参赛作品被评委标记违规 -->
|
||
<a-alert v-if="showWorkViolationBanner" type="error" show-icon class="work-violation-alert" message="参赛作品被标记为违规"
|
||
:description="violationBannerDescription" />
|
||
|
||
<!-- 操作按钮(根据阶段动态展示) -->
|
||
<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="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="sanitizeNoticeContent(notice.content)"></div>
|
||
<div v-if="notice.attachments?.length" class="notice-attachments">
|
||
<div class="notice-attachments-title">公告附件</div>
|
||
<div v-for="att in notice.attachments" :key="att.id" class="att-item">
|
||
<a :href="att.fileUrl" target="_blank" rel="noopener noreferrer">
|
||
<paper-clip-outlined /> {{ att.fileName }}
|
||
</a>
|
||
</div>
|
||
</div>
|
||
<span class="notice-time">{{ formatNoticeTime(notice) }}</span>
|
||
</div>
|
||
</div>
|
||
</a-tab-pane>
|
||
<a-tab-pane key="works" tab="参赛作品">
|
||
<a-spin :spinning="contestWorksLoading">
|
||
<template v-if="contestWorksList.length > 0 || contestWorksLoading">
|
||
<div v-if="contestWorksList.length > 0" class="activity-works-panel">
|
||
<div class="works-grid">
|
||
<div v-for="work in contestWorksList" :key="work.contestWorkId" class="work-card"
|
||
:class="{ 'work-card--no-nav': work.id == null }" @click="openContestWork(work)">
|
||
<div class="card-cover">
|
||
<img v-if="work.coverUrl" :src="work.coverUrl" :alt="work.title || ''" />
|
||
<div v-else class="cover-placeholder">
|
||
<picture-outlined />
|
||
</div>
|
||
<div v-if="work.originalImageUrl && work.originalImageUrl !== work.coverUrl" class="cover-pip"
|
||
title="原图">
|
||
<img :src="work.originalImageUrl" alt="原图" />
|
||
</div>
|
||
</div>
|
||
<div class="card-body">
|
||
<h3>{{ work.title || '未命名作品' }}</h3>
|
||
<div class="card-author">
|
||
<a-avatar :size="20" :src="work.creator?.avatar">
|
||
{{ work.creator?.nickname?.charAt(0) }}
|
||
</a-avatar>
|
||
<span>{{ work.creator?.nickname }}</span>
|
||
</div>
|
||
<div class="card-stats">
|
||
<span :class="['like-btn', { liked: work.id != null && likedSet.has(work.id) }]"
|
||
@click.stop="handleContestWorkLike(work)">
|
||
<heart-filled v-if="work.id != null && likedSet.has(work.id)" />
|
||
<heart-outlined v-else />
|
||
{{ work.likeCount || 0 }}
|
||
</span>
|
||
<span><eye-outlined /> {{ work.viewCount || 0 }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="contestWorksTotal > contestWorksPageSize" class="results-pagination-wrap">
|
||
<a-pagination :current="contestWorksPage" :total="contestWorksTotal" :page-size="contestWorksPageSize"
|
||
:show-size-changer="false" show-less-items :show-total="(t: number) => `共 ${t} 条`"
|
||
@change="onContestWorksPageChange" />
|
||
</div>
|
||
</div>
|
||
<div v-else class="loading-wrap"><a-spin /></div>
|
||
</template>
|
||
<div v-else class="empty-tab">
|
||
<a-empty description="暂无参赛作品" />
|
||
</div>
|
||
</a-spin>
|
||
</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-tabs>
|
||
</div>
|
||
|
||
<!-- 报名弹窗 -->
|
||
<a-modal v-model:open="showRegisterModal" title="活动报名" :footer="null" :width="420" centered>
|
||
<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, onBeforeUnmount, reactive } 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,
|
||
HeartOutlined, HeartFilled, EyeOutlined,
|
||
} from '@ant-design/icons-vue'
|
||
import {
|
||
publicActivitiesApi,
|
||
publicChildrenApi,
|
||
publicInteractionApi,
|
||
type PublicActivityDetail,
|
||
type PublicActivityMyRegistration,
|
||
type PublicActivityNotice,
|
||
type PublicActivityResultItem,
|
||
type PublicActivityContestWorkItem,
|
||
type UserWork,
|
||
} from '@/api/public'
|
||
import WorkSelector from './components/WorkSelector.vue'
|
||
import { sanitizeHtml } from '@/utils/sanitize'
|
||
import dayjs from 'dayjs'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
|
||
/** 活动大厅仅 1 条时列表页 replace 会带 soleActivity=1,无列表可回,不显示返回 */
|
||
const showHeroBack = computed(() => route.query.soleActivity !== "1")
|
||
|
||
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<PublicActivityMyRegistration | null>(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)
|
||
|
||
/** 参赛作品 Tab(公开列表) */
|
||
const contestWorksLoading = ref(false)
|
||
const contestWorksList = ref<PublicActivityContestWorkItem[]>([])
|
||
const contestWorksTotal = ref(0)
|
||
const contestWorksPage = ref(1)
|
||
const contestWorksPageSize = ref(10)
|
||
const likedSet = reactive(new Set<number>())
|
||
|
||
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)
|
||
}
|
||
|
||
const loadActivityWorks = async (page = 1) => {
|
||
if (!activity.value?.id) return
|
||
contestWorksLoading.value = true
|
||
try {
|
||
const res = await publicActivitiesApi.getActivityWorks(activity.value.id, {
|
||
page,
|
||
pageSize: contestWorksPageSize.value,
|
||
})
|
||
contestWorksList.value = res.list ?? []
|
||
contestWorksTotal.value = Number(res.total ?? 0)
|
||
contestWorksPage.value = Number(res.page ?? page)
|
||
likedSet.clear()
|
||
if (isLoggedIn.value && contestWorksList.value.length > 0) {
|
||
const ids = contestWorksList.value
|
||
.map((w) => w.id)
|
||
.filter((x): x is number => x != null)
|
||
if (ids.length > 0) {
|
||
try {
|
||
const statuses = await publicInteractionApi.batchStatus(ids)
|
||
for (const [id, status] of Object.entries(statuses)) {
|
||
if ((status as { liked?: boolean }).liked) likedSet.add(Number(id))
|
||
}
|
||
} catch {
|
||
/* 忽略点赞状态 */
|
||
}
|
||
}
|
||
}
|
||
} catch (err: any) {
|
||
message.error(err?.response?.data?.message || '加载参赛作品失败')
|
||
contestWorksList.value = []
|
||
contestWorksTotal.value = 0
|
||
} finally {
|
||
contestWorksLoading.value = false
|
||
}
|
||
}
|
||
|
||
const onContestWorksPageChange = (page: number) => {
|
||
void loadActivityWorks(page)
|
||
}
|
||
|
||
const openContestWork = (work: PublicActivityContestWorkItem) => {
|
||
if (work.id == null) {
|
||
message.info('该作品暂无法查看详情')
|
||
return
|
||
}
|
||
router.push(`/p/works/${work.id}`)
|
||
}
|
||
|
||
const handleContestWorkLike = async (work: PublicActivityContestWorkItem) => {
|
||
if (work.id == null) return
|
||
if (!isLoggedIn.value) {
|
||
goLogin()
|
||
return
|
||
}
|
||
const wid = work.id
|
||
const wasLiked = likedSet.has(wid)
|
||
if (wasLiked) {
|
||
likedSet.delete(wid)
|
||
} else {
|
||
likedSet.add(wid)
|
||
}
|
||
work.likeCount = (work.likeCount || 0) + (wasLiked ? -1 : 1)
|
||
try {
|
||
const res = await publicInteractionApi.like(wid)
|
||
if (res.liked) {
|
||
likedSet.add(wid)
|
||
} else {
|
||
likedSet.delete(wid)
|
||
}
|
||
work.likeCount = res.likeCount
|
||
} catch {
|
||
if (wasLiked) {
|
||
likedSet.add(wid)
|
||
} else {
|
||
likedSet.delete(wid)
|
||
}
|
||
work.likeCount = (work.likeCount || 0) + (wasLiked ? 1 : -1)
|
||
message.error('操作失败')
|
||
}
|
||
}
|
||
|
||
watch(activeTab, (k) => {
|
||
if (k === 'results' && activity.value?.resultState === 'published') {
|
||
void loadPublicResults(1)
|
||
}
|
||
if (k === 'works') {
|
||
void loadActivityWorks(1)
|
||
}
|
||
})
|
||
|
||
const formatNoticeTime = (n: PublicActivityNotice) =>
|
||
formatDate(n.publishTime || n.createTime || '')
|
||
|
||
/** 安全过滤公告内容 */
|
||
const sanitizeNoticeContent = (content: string) => sanitizeHtml(content)
|
||
|
||
/** 活动详情富文本:后端字段为 content,使用 DOMPurify 过滤 XSS */
|
||
const activityContentHtml = computed(() => {
|
||
const a = activity.value
|
||
if (!a) return ''
|
||
return sanitizeHtml(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)
|
||
})
|
||
|
||
// 是否允许重新提交:活动允许多次提交,或最新参赛作品被标记违规需重提
|
||
const canResubmit = computed(() => {
|
||
if (!hasSubmittedWork.value || !activity.value) return false
|
||
const st = myRegistration.value?.latestWorkStatus
|
||
return activity.value.submitRule === 'resubmit' || st === 'violation'
|
||
})
|
||
|
||
/** 最新参赛作品为违规(报名已通过) */
|
||
const showWorkViolationBanner = computed(
|
||
() =>
|
||
hasRegistered.value &&
|
||
registrationState.value === 'passed' &&
|
||
myRegistration.value?.latestWorkStatus === 'violation',
|
||
)
|
||
|
||
const violationBannerDescription = computed(() => {
|
||
const r = myRegistration.value?.violationReason?.trim()
|
||
return r || '请根据说明修改作品后,在提交期内重新从作品库选择作品提交。'
|
||
})
|
||
|
||
// 活动当前阶段
|
||
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
|
||
await checkRegistrationStatus()
|
||
} 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
|
||
}
|
||
}
|
||
|
||
// AbortController 用于取消异步请求(防止组件卸载后的竞态更新)
|
||
let abortController: AbortController | null = null
|
||
|
||
onMounted(async () => {
|
||
// 每次挂载创建新的 AbortController,组件卸载时取消
|
||
abortController = new AbortController()
|
||
const signal = abortController.signal
|
||
|
||
try {
|
||
await fetchDetail()
|
||
// 检查是否已取消(快速切换页面时)
|
||
if (signal.aborted) return
|
||
await fetchChildren()
|
||
if (signal.aborted) return
|
||
await checkRegistrationStatus()
|
||
} catch {
|
||
// 组件已卸载导致的取消,忽略
|
||
}
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
// 组件卸载时取消所有进行中的请求
|
||
if (abortController) {
|
||
abortController.abort()
|
||
abortController = null
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
$primary: #6366f1;
|
||
|
||
.loading-page {
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 100px 0;
|
||
}
|
||
|
||
.detail-hero {
|
||
position: relative;
|
||
min-height: 150px;
|
||
display: flex;
|
||
// height: 220px;
|
||
border-radius: 20px;
|
||
overflow: hidden;
|
||
margin-bottom: 16px;
|
||
|
||
.hero-cover {
|
||
width: 100%;
|
||
// height: 100%;
|
||
object-fit: contain;
|
||
margin: auto;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
}
|
||
|
||
.work-violation-alert {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.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: #6366f1;
|
||
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;
|
||
}
|
||
}
|
||
|
||
/* 参赛作品 Tab:与作品广场 Gallery 网格一致 */
|
||
.activity-works-panel {
|
||
.results-pagination-wrap {
|
||
display: flex;
|
||
justify-content: center;
|
||
margin-top: 20px;
|
||
padding-top: 4px;
|
||
}
|
||
}
|
||
|
||
.loading-wrap {
|
||
padding: 60px 0;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
.works-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 12px;
|
||
|
||
@media (min-width: 640px) {
|
||
grid-template-columns: repeat(3, 1fr);
|
||
}
|
||
}
|
||
|
||
.work-card {
|
||
background: #fff;
|
||
border-radius: 14px;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
border: 1px solid rgba($primary, 0.04);
|
||
|
||
&:hover {
|
||
box-shadow: 0 4px 20px rgba($primary, 0.1);
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
&.work-card--no-nav {
|
||
cursor: default;
|
||
|
||
&:hover {
|
||
transform: none;
|
||
box-shadow: none;
|
||
}
|
||
}
|
||
|
||
.card-cover {
|
||
position: relative;
|
||
aspect-ratio: 3/4;
|
||
background: #f5f3ff;
|
||
|
||
img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.cover-placeholder {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100%;
|
||
font-size: 28px;
|
||
color: #d1d5db;
|
||
}
|
||
|
||
.cover-pip {
|
||
position: absolute;
|
||
right: 6px;
|
||
bottom: 6px;
|
||
width: 34%;
|
||
aspect-ratio: 1 / 1;
|
||
border-radius: 7px;
|
||
overflow: hidden;
|
||
border: 2px solid #fff;
|
||
background: #fff;
|
||
box-shadow: 0 3px 10px rgba(15, 12, 41, 0.25);
|
||
transition: transform 0.2s;
|
||
|
||
img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
}
|
||
}
|
||
|
||
&:hover .cover-pip {
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
.card-body {
|
||
padding: 10px 12px;
|
||
|
||
h3 {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #1e1b4b;
|
||
margin: 0 0 6px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.card-author {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-bottom: 6px;
|
||
|
||
span {
|
||
font-size: 11px;
|
||
color: #6b7280;
|
||
}
|
||
}
|
||
|
||
.card-stats {
|
||
display: flex;
|
||
gap: 12px;
|
||
|
||
span {
|
||
font-size: 11px;
|
||
color: #9ca3af;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
}
|
||
|
||
.like-btn {
|
||
cursor: pointer;
|
||
transition: color 0.2s;
|
||
|
||
&:hover {
|
||
color: #ec4899;
|
||
}
|
||
|
||
&.liked {
|
||
color: #ec4899;
|
||
|
||
:deep(.anticon) {
|
||
animation: activityWorkPop 0.3s ease;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@keyframes activityWorkPop {
|
||
0% {
|
||
transform: scale(1);
|
||
}
|
||
|
||
50% {
|
||
transform: scale(1.3);
|
||
}
|
||
|
||
100% {
|
||
transform: scale(1);
|
||
}
|
||
}
|
||
|
||
.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-attachments {
|
||
margin-top: 12px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid #e5e7eb;
|
||
|
||
.notice-attachments-title {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #6b7280;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.att-item a {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
color: $primary;
|
||
padding: 4px 0;
|
||
}
|
||
}
|
||
|
||
.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>
|