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

1312 lines
37 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 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>