library-picturebook-activity/frontend/src/views/contests/Detail.vue

1080 lines
28 KiB
Vue
Raw Normal View History

2025-12-09 11:10:36 +08:00
<template>
<div class="contest-detail-page">
<a-spin :spinning="loading">
2026-01-08 09:17:46 +08:00
<!-- 顶部海报区域 -->
2026-01-16 14:18:32 +08:00
<div class="hero-section">
<!-- 背景图 -->
2026-01-08 09:17:46 +08:00
<div
2026-01-16 14:18:32 +08:00
class="hero-bg"
2026-01-08 09:17:46 +08:00
:style="{
backgroundImage:
contest?.posterUrl || contest?.coverUrl
? `url(${contest.posterUrl || contest.coverUrl})`
2026-01-16 14:18:32 +08:00
: undefined,
2026-01-08 09:17:46 +08:00
}"
>
2026-01-16 14:18:32 +08:00
<div class="hero-overlay"></div>
2026-01-08 09:17:46 +08:00
</div>
2026-01-16 14:18:32 +08:00
<!-- 返回按钮 -->
<div class="hero-nav">
<button class="back-btn" @click="$router.back()">
<ArrowLeftOutlined />
<span>返回</span>
</button>
</div>
2026-01-09 18:14:35 +08:00
2026-01-16 14:18:32 +08:00
<!-- 海报内容 -->
<div class="hero-content">
<div class="hero-inner">
<!-- 状态标签 -->
<div class="status-tags">
<span class="tag tag-type">
{{ contest?.contestType === "individual" ? "个人赛" : "团队赛" }}
2026-01-09 18:14:35 +08:00
</span>
2026-01-16 14:18:32 +08:00
<span v-if="getStageText()" class="tag" :class="getStageClass()">
{{ getStageText() }}
</span>
</div>
<!-- 标题 -->
<h1 class="hero-title">{{ contest?.contestName }}</h1>
<!-- 时间信息 -->
<div class="hero-meta">
<div class="meta-item">
<CalendarOutlined />
<span>比赛时间{{ formatDate(contest?.startTime) }} ~ {{ formatDate(contest?.endTime) }}</span>
</div>
<div class="meta-item">
<ClockCircleOutlined />
<span>报名时间{{ formatDate(contest?.registerStartTime) }} ~ {{ formatDate(contest?.registerEndTime) }}</span>
</div>
2026-01-08 09:17:46 +08:00
</div>
2026-01-16 14:18:32 +08:00
<!-- 报名按钮 -->
<div class="hero-actions">
<button
v-if="isTeacher && isRegistering && !hasRegistered"
class="action-btn primary"
@click="handleRegister"
>
<FormOutlined />
立即报名
</button>
<button
v-else-if="isTeacher && hasRegistered && canViewRegistration"
class="action-btn secondary"
@click="handleViewRegistration"
>
<EyeOutlined />
查看报名
</button>
<button
v-else-if="isTeacher"
class="action-btn disabled"
disabled
>
<FormOutlined />
{{ isRegistering ? '立即报名' : '报名已截止' }}
</button>
<span v-if="isRegistering" class="countdown">
距离报名截止还有 <strong>{{ daysRemaining }}</strong>
</span>
2026-01-08 09:17:46 +08:00
</div>
2026-01-16 14:18:32 +08:00
</div>
</div>
</div>
<!-- Tab 导航 -->
<div class="tab-section">
<div class="tab-container">
<div class="custom-tabs">
<div
class="tab-item"
:class="{ active: activeTab === 'info' }"
@click="handleTabChange('info')"
2025-12-09 11:10:36 +08:00
>
2026-01-16 14:18:32 +08:00
<FileTextOutlined />
<span>赛事信息</span>
</div>
<div
class="tab-item"
:class="{ active: activeTab === 'notices' }"
@click="handleTabChange('notices')"
2026-01-08 09:17:46 +08:00
>
2026-01-16 14:18:32 +08:00
<BellOutlined />
<span>通知公告</span>
<span v-if="notices.length > 0" class="badge">{{ notices.length }}</span>
</div>
<div
class="tab-item"
:class="{ active: activeTab === 'results' }"
@click="handleTabChange('results')"
2026-01-09 18:14:35 +08:00
>
2026-01-16 14:18:32 +08:00
<TrophyOutlined />
<span>赛事结果</span>
</div>
2026-01-08 09:17:46 +08:00
</div>
</div>
</div>
2025-12-09 11:10:36 +08:00
2026-01-16 14:18:32 +08:00
<!-- 主内容区域 -->
<div v-if="contest" class="main-section">
<div class="main-container">
<div class="content-grid">
<!-- 左侧主要内容 -->
<div class="content-left">
<!-- 赛事信息 Tab -->
<div v-if="activeTab === 'info'" class="content-card">
<div class="card-header">
<div class="header-icon">
<FileTextOutlined />
2026-01-09 18:14:35 +08:00
</div>
2026-01-16 14:18:32 +08:00
<h2>竞赛详情</h2>
2026-01-09 18:14:35 +08:00
</div>
2026-01-16 14:18:32 +08:00
<div class="card-body">
<div v-if="contest.content" class="rich-content" v-html="contest.content"></div>
<a-empty v-else description="暂无详情内容" />
2026-01-09 18:14:35 +08:00
</div>
2026-01-16 14:18:32 +08:00
</div>
2026-01-09 18:14:35 +08:00
2026-01-16 14:18:32 +08:00
<!-- 通知公告 Tab -->
<div v-if="activeTab === 'notices'" class="content-card">
<div class="card-header">
<div class="header-icon">
<BellOutlined />
2026-01-09 18:14:35 +08:00
</div>
2026-01-16 14:18:32 +08:00
<h2>通知公告</h2>
2026-01-09 18:14:35 +08:00
</div>
2026-01-16 14:18:32 +08:00
<div class="card-body">
<div v-if="noticesLoading" class="loading-placeholder">
<a-spin />
</div>
<div v-else-if="notices.length === 0" class="empty-placeholder">
<a-empty description="暂无公告" />
</div>
<div v-else class="notice-list">
<div v-for="item in notices" :key="item.id" class="notice-item">
<div class="notice-header">
<span class="notice-title">{{ item.title }}</span>
<span class="notice-type" :class="item.noticeType">
{{ getNoticeTypeText(item.noticeType) }}
</span>
</div>
<div class="notice-content">{{ item.content }}</div>
<div class="notice-time">
<ClockCircleOutlined />
{{ formatDateTime(item.publishTime) }}
</div>
2026-01-09 18:14:35 +08:00
</div>
</div>
</div>
</div>
2026-01-16 14:18:32 +08:00
<!-- 赛事结果 Tab -->
<div v-if="activeTab === 'results'" class="content-card">
<div class="card-header">
<div class="header-icon">
<TrophyOutlined />
2026-01-09 18:14:35 +08:00
</div>
2026-01-16 14:18:32 +08:00
<h2>赛事结果</h2>
2026-01-09 18:14:35 +08:00
</div>
2026-01-16 14:18:32 +08:00
<div class="card-body">
<div v-if="contest.resultState === 'published'">
<a-spin :spinning="resultsLoading">
<a-table
:columns="resultColumns"
:data-source="results"
:pagination="resultsPagination"
row-key="id"
@change="handleResultsTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'rank'">
<div class="rank-badge" :class="getRankClass(record.rank)">
{{ record.rank || "-" }}
</div>
</template>
<template v-else-if="column.key === 'author'">
{{ record.registration?.user?.nickname || record.registration?.team?.teamName || "-" }}
</template>
<template v-else-if="column.key === 'award'">
<span v-if="record.awardName" class="award-tag" :class="getAwardClass(record.awardName)">
{{ record.awardName }}
</span>
<span v-else>-</span>
</template>
</template>
</a-table>
</a-spin>
</div>
<div v-else class="empty-placeholder">
<a-empty description="结果尚未公布" />
</div>
2026-01-09 18:14:35 +08:00
</div>
</div>
2026-01-16 14:18:32 +08:00
</div>
2026-01-08 09:17:46 +08:00
2026-01-16 14:18:32 +08:00
<!-- 右侧组织信息 -->
<div class="content-right">
<div class="sidebar-card">
<div class="sidebar-header">
<TeamOutlined />
<span>组织信息</span>
2026-01-09 18:14:35 +08:00
</div>
2026-01-16 14:18:32 +08:00
<div class="sidebar-body">
<div class="sidebar-item">
<div class="item-label">
<BankOutlined />
主办单位
</div>
<div class="item-value">
<template v-if="contest.organizers && contest.organizers.length">
<div v-for="org in contest.organizers" :key="org" class="org-name">{{ org }}</div>
</template>
<span v-else class="empty-text">暂无</span>
</div>
</div>
2026-01-09 18:14:35 +08:00
2026-01-16 14:18:32 +08:00
<div class="sidebar-item">
<div class="item-label">
<ApartmentOutlined />
协办单位
2026-01-09 18:14:35 +08:00
</div>
2026-01-16 14:18:32 +08:00
<div class="item-value">
<template v-if="contest.coOrganizers && contest.coOrganizers.length">
<div v-for="org in contest.coOrganizers" :key="org" class="org-name">{{ org }}</div>
</template>
<span v-else class="empty-text">暂无</span>
</div>
</div>
2026-01-09 18:14:35 +08:00
2026-01-16 14:18:32 +08:00
<div class="sidebar-item">
<div class="item-label">
<GiftOutlined />
赞助单位
</div>
<div class="item-value">
<template v-if="contest.sponsors && contest.sponsors.length">
<div v-for="sp in contest.sponsors" :key="sp" class="org-name">{{ sp }}</div>
</template>
<span v-else class="empty-text">暂无</span>
2026-01-09 18:14:35 +08:00
</div>
2026-01-16 14:18:32 +08:00
</div>
2026-01-09 18:14:35 +08:00
</div>
</div>
2026-01-16 14:18:32 +08:00
<!-- 联系方式 -->
<div v-if="contest.contactName || contest.contactPhone" class="sidebar-card">
<div class="sidebar-header">
<PhoneOutlined />
<span>联系方式</span>
2026-01-09 18:14:35 +08:00
</div>
2026-01-16 14:18:32 +08:00
<div class="sidebar-body">
<div v-if="contest.contactName" class="sidebar-item">
<div class="item-label">
<UserOutlined />
联系人
</div>
<div class="item-value">{{ contest.contactName }}</div>
</div>
<div v-if="contest.contactPhone" class="sidebar-item">
<div class="item-label">
<PhoneOutlined />
电话
</div>
<div class="item-value">{{ contest.contactPhone }}</div>
</div>
2026-01-09 18:14:35 +08:00
</div>
</div>
</div>
2026-01-16 14:18:32 +08:00
</div>
</div>
2026-01-08 09:17:46 +08:00
</div>
2026-01-16 14:18:32 +08:00
<a-empty v-else-if="!loading" description="比赛不存在" style="padding: 100px 0" />
2025-12-09 11:10:36 +08:00
</a-spin>
</div>
</template>
<script setup lang="ts">
2026-01-08 09:17:46 +08:00
import { ref, onMounted, computed } from "vue"
import { useRoute, useRouter } from "vue-router"
import { message } from "ant-design-vue"
2026-01-16 14:18:32 +08:00
import {
ArrowLeftOutlined,
CalendarOutlined,
ClockCircleOutlined,
FormOutlined,
EyeOutlined,
FileTextOutlined,
BellOutlined,
TrophyOutlined,
TeamOutlined,
BankOutlined,
ApartmentOutlined,
GiftOutlined,
PhoneOutlined,
UserOutlined,
} from "@ant-design/icons-vue"
2026-01-08 09:17:46 +08:00
import dayjs from "dayjs"
import { useAuthStore } from "@/stores/auth"
import {
contestsApi,
noticesApi,
resultsApi,
registrationsApi,
type Contest,
type ContestNotice,
type ContestResult,
} from "@/api/contests"
2025-12-09 11:10:36 +08:00
const route = useRoute()
const router = useRouter()
2026-01-08 09:17:46 +08:00
const authStore = useAuthStore()
const tenantCode = route.params.tenantCode as string
2025-12-09 11:10:36 +08:00
const loading = ref(false)
const noticesLoading = ref(false)
2026-01-08 09:17:46 +08:00
const resultsLoading = ref(false)
2025-12-09 11:10:36 +08:00
const contest = ref<Contest | null>(null)
const notices = ref<ContestNotice[]>([])
2026-01-08 09:17:46 +08:00
const results = ref<ContestResult[]>([])
const activeTab = ref("info")
const hasRegistered = ref(false)
const myRegistration = ref<any>(null)
2025-12-09 11:10:36 +08:00
2026-01-12 20:04:11 +08:00
const canViewRegistration = computed(() => {
const permissions = authStore.user?.permissions || []
2026-01-16 14:18:32 +08:00
return permissions.includes("registration:read") || permissions.includes("registration:create")
2026-01-15 16:35:00 +08:00
})
2026-01-16 14:18:32 +08:00
const isTeacher = computed(() => authStore.hasRole("teacher"))
2026-01-12 20:04:11 +08:00
2025-12-09 11:10:36 +08:00
const contestId = Number(route.params.id)
2026-01-08 09:17:46 +08:00
const resultsPagination = ref({
current: 1,
pageSize: 20,
total: 0,
})
const resultColumns = [
2026-01-16 14:18:32 +08:00
{ title: "排名", key: "rank", dataIndex: "rank", width: 80 },
{ title: "作品名称", key: "title", dataIndex: "title", width: 200 },
{ title: "作者", key: "author", width: 150 },
{ title: "最终得分", key: "finalScore", dataIndex: "finalScore", width: 120, sorter: true },
{ title: "奖项", key: "award", width: 120 },
2026-01-08 09:17:46 +08:00
]
const formatDate = (dateStr?: string) => {
if (!dateStr) return "-"
return dayjs(dateStr).format("YYYY-MM-DD")
}
2025-12-09 11:10:36 +08:00
const formatDateTime = (dateStr?: string) => {
2026-01-08 09:17:46 +08:00
if (!dateStr) return "-"
return dayjs(dateStr).format("YYYY-MM-DD HH:mm")
}
const isRegistering = computed(() => {
if (!contest.value) return false
const now = dayjs()
const start = dayjs(contest.value.registerStartTime)
const end = dayjs(contest.value.registerEndTime)
return now.isAfter(start) && now.isBefore(end)
})
2026-01-16 14:18:32 +08:00
const isSubmitting = computed(() => {
if (!contest.value?.submitStartTime || !contest.value?.submitEndTime) return false
const now = dayjs()
return now.isAfter(dayjs(contest.value.submitStartTime)) && now.isBefore(dayjs(contest.value.submitEndTime))
})
const isReviewing = computed(() => {
if (!contest.value?.reviewStartTime || !contest.value?.reviewEndTime) return false
const now = dayjs()
return now.isAfter(dayjs(contest.value.reviewStartTime)) && now.isBefore(dayjs(contest.value.reviewEndTime))
})
2026-01-08 09:17:46 +08:00
const daysRemaining = computed(() => {
if (!contest.value || !isRegistering.value) return 0
2026-01-16 14:18:32 +08:00
const diff = dayjs(contest.value.registerEndTime).diff(dayjs(), "day")
2026-01-08 09:17:46 +08:00
return diff > 0 ? diff : 0
})
2026-01-16 14:18:32 +08:00
const getStageText = () => {
if (isRegistering.value) return "报名中"
if (isSubmitting.value) return "征稿中"
if (isReviewing.value) return "评审中"
if (contest.value?.status === "finished") return "已结束"
return ""
}
const getStageClass = () => {
if (isRegistering.value) return "tag-registering"
if (isSubmitting.value) return "tag-submitting"
if (isReviewing.value) return "tag-reviewing"
if (contest.value?.status === "finished") return "tag-finished"
return ""
2025-12-09 11:10:36 +08:00
}
const getNoticeTypeText = (type?: string) => {
switch (type) {
2026-01-16 14:18:32 +08:00
case "urgent": return "紧急"
case "system": return "系统"
default: return "公告"
2025-12-09 11:10:36 +08:00
}
}
2026-01-16 14:18:32 +08:00
const getRankClass = (rank?: number) => {
if (rank === 1) return "rank-1"
if (rank === 2) return "rank-2"
if (rank === 3) return "rank-3"
return ""
2026-01-08 09:17:46 +08:00
}
2026-01-16 14:18:32 +08:00
const getAwardClass = (award?: string) => {
if (!award) return ""
if (award.includes("一等奖") || award.includes("金奖")) return "award-gold"
if (award.includes("二等奖") || award.includes("银奖")) return "award-silver"
if (award.includes("三等奖") || award.includes("铜奖")) return "award-bronze"
return ""
2026-01-08 09:17:46 +08:00
}
2025-12-09 11:10:36 +08:00
const fetchContestDetail = async () => {
loading.value = true
try {
contest.value = await contestsApi.getDetail(contestId)
2026-01-08 09:17:46 +08:00
await checkRegistration()
2025-12-09 11:10:36 +08:00
} catch (error: any) {
2026-01-08 09:17:46 +08:00
message.error(error?.response?.data?.message || "获取比赛详情失败")
2025-12-09 11:10:36 +08:00
} finally {
loading.value = false
}
}
2026-01-08 09:17:46 +08:00
const checkRegistration = async () => {
if (!authStore.user) return
2025-12-09 11:10:36 +08:00
try {
2026-01-08 09:17:46 +08:00
const response = await registrationsApi.getList({
contestId,
userId: authStore.user.id,
page: 1,
pageSize: 1,
})
if (response.list && response.list.length > 0) {
hasRegistered.value = true
myRegistration.value = response.list[0]
}
} catch (error) {
console.error("检查报名状态失败:", error)
2025-12-09 11:10:36 +08:00
}
}
const fetchNotices = async () => {
noticesLoading.value = true
try {
notices.value = await noticesApi.getList(contestId)
} catch (error: any) {
2026-01-08 09:17:46 +08:00
message.error("获取公告列表失败")
2025-12-09 11:10:36 +08:00
} finally {
noticesLoading.value = false
}
}
2026-01-08 09:17:46 +08:00
const fetchResults = async () => {
if (!contest.value || contest.value.resultState !== "published") return
resultsLoading.value = true
2025-12-09 11:10:36 +08:00
try {
2026-01-16 14:18:32 +08:00
const response = await resultsApi.getResults(contestId, resultsPagination.value.current, resultsPagination.value.pageSize)
2026-01-08 09:17:46 +08:00
results.value = response.list || []
resultsPagination.value.total = response.total || 0
2025-12-09 11:10:36 +08:00
} catch (error: any) {
2026-01-08 09:17:46 +08:00
message.error("获取赛事结果失败")
} finally {
resultsLoading.value = false
2025-12-09 11:10:36 +08:00
}
}
2026-01-08 09:17:46 +08:00
const handleResultsTableChange = (pag: any) => {
resultsPagination.value.current = pag.current || 1
resultsPagination.value.pageSize = pag.pageSize || 20
fetchResults()
2025-12-09 11:10:36 +08:00
}
2026-01-09 18:14:35 +08:00
const handleRegister = () => {
2026-01-08 09:17:46 +08:00
if (!authStore.user) {
message.warning("请先登录")
router.push(`/${tenantCode}/login`)
return
}
2026-01-09 18:14:35 +08:00
if (!contest.value) return
if (contest.value.contestType === "team") {
router.push(`/${tenantCode}/contests/${contestId}/register/team`)
} else {
router.push(`/${tenantCode}/contests/${contestId}/register/individual`)
2025-12-09 11:10:36 +08:00
}
}
2026-01-08 09:17:46 +08:00
const handleViewRegistration = () => {
2026-01-09 18:14:35 +08:00
if (!contest.value) return
if (contest.value.contestType === "team") {
router.push(`/${tenantCode}/contests/${contestId}/register/team`)
2026-01-08 09:17:46 +08:00
} else {
2026-01-09 18:14:35 +08:00
router.push(`/${tenantCode}/contests/${contestId}/register/individual`)
2025-12-09 11:10:36 +08:00
}
}
2026-01-08 09:17:46 +08:00
const handleTabChange = (key: string) => {
activeTab.value = key
if (key === "results" && contest.value?.resultState === "published") {
fetchResults()
}
}
2026-01-16 14:18:32 +08:00
onMounted(() => {
fetchContestDetail()
fetchNotices()
})
2025-12-09 11:10:36 +08:00
</script>
2026-01-08 09:17:46 +08:00
<style lang="scss" scoped>
2026-01-16 14:18:32 +08:00
$primary: #1890ff;
$primary-dark: #0958d9;
2025-12-09 11:10:36 +08:00
.contest-detail-page {
2026-01-08 09:17:46 +08:00
min-height: 100vh;
2026-01-16 14:18:32 +08:00
background: #f5f7fa;
}
2026-01-08 09:17:46 +08:00
2026-01-16 14:18:32 +08:00
// Hero 区域
.hero-section {
position: relative;
height: 420px;
overflow: hidden;
.hero-bg {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background-size: cover;
background-position: center;
}
.hero-overlay {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.6) 100%);
}
.hero-nav {
2026-01-08 09:17:46 +08:00
position: relative;
2026-01-16 14:18:32 +08:00
z-index: 10;
padding: 20px 24px;
.back-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: rgba(255,255,255,0.15);
backdrop-filter: blur(10px);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 20px;
2026-01-09 18:14:35 +08:00
color: #fff;
2026-01-16 14:18:32 +08:00
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
2026-01-09 18:14:35 +08:00
&:hover {
2026-01-16 14:18:32 +08:00
background: rgba(255,255,255,0.25);
2026-01-09 18:14:35 +08:00
}
}
2026-01-16 14:18:32 +08:00
}
2026-01-08 09:17:46 +08:00
2026-01-16 14:18:32 +08:00
.hero-content {
position: relative;
z-index: 10;
height: calc(100% - 60px);
display: flex;
align-items: center;
padding: 0 24px;
.hero-inner {
max-width: 1200px;
margin: 0 auto;
2026-01-08 09:17:46 +08:00
width: 100%;
2026-01-16 14:18:32 +08:00
}
}
.status-tags {
display: flex;
gap: 10px;
margin-bottom: 16px;
.tag {
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
color: #fff;
backdrop-filter: blur(10px);
}
.tag-type {
background: rgba(255,255,255,0.2);
border: 1px solid rgba(255,255,255,0.3);
}
.tag-registering { background: linear-gradient(135deg, #52c41a, #73d13d); }
.tag-submitting { background: linear-gradient(135deg, #1890ff, #40a9ff); }
.tag-reviewing { background: linear-gradient(135deg, #faad14, #ffc53d); }
.tag-finished { background: linear-gradient(135deg, #8c8c8c, #bfbfbf); }
}
.hero-title {
font-size: 36px;
font-weight: 700;
color: #fff;
margin: 0 0 20px;
text-shadow: 0 2px 10px rgba(0,0,0,0.3);
line-height: 1.3;
}
.hero-meta {
display: flex;
flex-wrap: wrap;
gap: 24px;
margin-bottom: 24px;
.meta-item {
2026-01-09 18:14:35 +08:00
display: flex;
align-items: center;
2026-01-16 14:18:32 +08:00
gap: 8px;
font-size: 14px;
color: rgba(255,255,255,0.9);
2026-01-08 09:17:46 +08:00
2026-01-16 14:18:32 +08:00
.anticon { font-size: 16px; }
2026-01-09 18:14:35 +08:00
}
2026-01-16 14:18:32 +08:00
}
2026-01-09 18:14:35 +08:00
2026-01-16 14:18:32 +08:00
.hero-actions {
display: flex;
align-items: center;
gap: 16px;
.action-btn {
display: inline-flex;
2026-01-09 18:14:35 +08:00
align-items: center;
2026-01-16 14:18:32 +08:00
gap: 8px;
padding: 12px 28px;
border-radius: 24px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
border: none;
2026-01-09 18:14:35 +08:00
2026-01-16 14:18:32 +08:00
&.primary {
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
color: #fff;
box-shadow: 0 4px 15px rgba($primary, 0.4);
2026-01-08 09:17:46 +08:00
2026-01-16 14:18:32 +08:00
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba($primary, 0.5);
2026-01-08 09:17:46 +08:00
}
2026-01-16 14:18:32 +08:00
}
2026-01-08 09:17:46 +08:00
2026-01-16 14:18:32 +08:00
&.secondary {
background: rgba(255,255,255,0.9);
color: $primary;
&:hover {
background: #fff;
2026-01-08 09:17:46 +08:00
}
2026-01-09 18:14:35 +08:00
}
2026-01-16 14:18:32 +08:00
&.disabled {
background: rgba(255,255,255,0.3);
color: rgba(255,255,255,0.6);
cursor: not-allowed;
}
}
2026-01-08 09:17:46 +08:00
2026-01-16 14:18:32 +08:00
.countdown {
font-size: 14px;
color: rgba(255,255,255,0.85);
2026-01-08 09:17:46 +08:00
2026-01-16 14:18:32 +08:00
strong {
color: #ffc53d;
font-size: 18px;
2026-01-08 09:17:46 +08:00
}
}
}
2026-01-16 14:18:32 +08:00
}
2026-01-08 09:17:46 +08:00
2026-01-16 14:18:32 +08:00
// Tab 导航
.tab-section {
background: #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
position: sticky;
top: 0;
z-index: 100;
2026-01-09 18:14:35 +08:00
2026-01-16 14:18:32 +08:00
.tab-container {
padding: 0 24px;
2026-01-09 18:14:35 +08:00
}
2026-01-16 14:18:32 +08:00
.custom-tabs {
display: flex;
gap: 8px;
padding: 12px 0;
2026-01-08 09:17:46 +08:00
2026-01-16 14:18:32 +08:00
.tab-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
color: rgba(0,0,0,0.65);
cursor: pointer;
transition: all 0.3s;
position: relative;
2026-01-09 18:14:35 +08:00
2026-01-16 14:18:32 +08:00
&:hover {
color: $primary;
background: rgba($primary, 0.06);
2026-01-09 18:14:35 +08:00
}
2026-01-16 14:18:32 +08:00
&.active {
color: #fff;
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
box-shadow: 0 4px 12px rgba($primary, 0.3);
2026-01-09 18:14:35 +08:00
}
2026-01-16 14:18:32 +08:00
.badge {
position: absolute;
top: 4px;
right: 4px;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: #ff4d4f;
color: #fff;
font-size: 11px;
font-weight: 600;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
2026-01-09 18:14:35 +08:00
}
}
2026-01-16 14:18:32 +08:00
}
}
2026-01-09 18:14:35 +08:00
2026-01-16 14:18:32 +08:00
// 主内容区域
.main-section {
padding: 24px 0 48px;
2026-01-09 18:14:35 +08:00
2026-01-16 14:18:32 +08:00
.main-container {
padding: 0 24px;
}
2026-01-09 18:14:35 +08:00
2026-01-16 14:18:32 +08:00
.content-grid {
display: grid;
grid-template-columns: 1fr 320px;
gap: 24px;
}
}
2026-01-09 18:14:35 +08:00
2026-01-16 14:18:32 +08:00
// 内容卡片
.content-card {
background: #fff;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
overflow: hidden;
.card-header {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 24px;
border-bottom: 1px solid #f0f0f0;
.header-icon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 18px;
}
2026-01-09 18:14:35 +08:00
2026-01-16 14:18:32 +08:00
h2 {
font-size: 18px;
font-weight: 600;
color: rgba(0,0,0,0.85);
margin: 0;
2026-01-08 09:17:46 +08:00
}
}
2026-01-16 14:18:32 +08:00
.card-body {
padding: 24px;
}
2025-12-09 11:10:36 +08:00
}
2026-01-16 14:18:32 +08:00
// 富文本内容
.rich-content {
font-size: 14px;
color: rgba(0,0,0,0.75);
line-height: 1.8;
:deep(p) {
margin-bottom: 16px;
&:last-child { margin-bottom: 0; }
}
:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
color: rgba(0,0,0,0.85);
font-weight: 600;
margin-top: 24px;
margin-bottom: 12px;
&:first-child { margin-top: 0; }
}
:deep(img) {
max-width: 100%;
border-radius: 8px;
margin: 16px 0;
}
:deep(ul), :deep(ol) {
padding-left: 24px;
margin-bottom: 16px;
}
:deep(li) {
margin-bottom: 8px;
}
:deep(table) {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
th, td {
border: 1px solid #e8e8e8;
padding: 12px;
text-align: left;
2026-01-09 18:14:35 +08:00
}
2026-01-16 14:18:32 +08:00
th {
background: #fafafa;
font-weight: 600;
2026-01-09 18:14:35 +08:00
}
}
2026-01-16 14:18:32 +08:00
:deep(blockquote) {
margin: 16px 0;
padding: 12px 20px;
background: #f9f9f9;
border-left: 4px solid $primary;
color: rgba(0,0,0,0.65);
}
:deep(a) {
color: $primary;
text-decoration: none;
&:hover { text-decoration: underline; }
}
2026-01-09 18:14:35 +08:00
}
2026-01-16 14:18:32 +08:00
// 公告列表
.notice-list {
.notice-item {
padding: 16px;
background: #fafafa;
border-radius: 12px;
margin-bottom: 12px;
&:last-child { margin-bottom: 0; }
.notice-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
.notice-title {
font-size: 15px;
font-weight: 600;
color: rgba(0,0,0,0.85);
2026-01-09 18:14:35 +08:00
}
2026-01-16 14:18:32 +08:00
.notice-type {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
2026-01-08 09:17:46 +08:00
2026-01-16 14:18:32 +08:00
&.urgent { background: #fff2f0; color: #ff4d4f; }
&.system { background: #e6f7ff; color: #1890ff; }
2026-01-08 09:17:46 +08:00
}
}
2026-01-16 14:18:32 +08:00
.notice-content {
font-size: 14px;
color: rgba(0,0,0,0.65);
line-height: 1.6;
margin-bottom: 10px;
}
.notice-time {
font-size: 12px;
color: rgba(0,0,0,0.45);
display: flex;
align-items: center;
gap: 6px;
}
}
}
2026-01-09 18:14:35 +08:00
2026-01-16 14:18:32 +08:00
// 排名徽章
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
font-weight: 700;
font-size: 14px;
background: #f0f0f0;
color: rgba(0,0,0,0.65);
&.rank-1 { background: linear-gradient(135deg, #ffd700, #ffb800); color: #fff; }
&.rank-2 { background: linear-gradient(135deg, #c0c0c0, #a0a0a0); color: #fff; }
&.rank-3 { background: linear-gradient(135deg, #cd7f32, #b8860b); color: #fff; }
}
// 奖项标签
.award-tag {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
&.award-gold { background: #fffbe6; color: #d48806; }
&.award-silver { background: #f5f5f5; color: #595959; }
&.award-bronze { background: #fff7e6; color: #d46b08; }
}
// 侧边栏卡片
.sidebar-card {
background: #fff;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
overflow: hidden;
margin-bottom: 20px;
&:last-child { margin-bottom: 0; }
.sidebar-header {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 20px;
background: linear-gradient(135deg, rgba($primary, 0.05), rgba($primary-dark, 0.08));
border-bottom: 1px solid #f0f0f0;
font-size: 15px;
font-weight: 600;
color: rgba(0,0,0,0.85);
.anticon { color: $primary; font-size: 16px; }
}
.sidebar-body {
padding: 16px 20px;
}
.sidebar-item {
margin-bottom: 16px;
&:last-child { margin-bottom: 0; }
.item-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: rgba(0,0,0,0.45);
margin-bottom: 8px;
.anticon { font-size: 14px; }
2026-01-08 09:17:46 +08:00
}
2026-01-16 14:18:32 +08:00
.item-value {
font-size: 14px;
color: rgba(0,0,0,0.85);
line-height: 1.6;
.org-name {
padding: 4px 0;
border-bottom: 1px dashed #f0f0f0;
2026-01-08 09:17:46 +08:00
2026-01-16 14:18:32 +08:00
&:last-child { border-bottom: none; }
2026-01-08 09:17:46 +08:00
}
2026-01-16 14:18:32 +08:00
.empty-text { color: rgba(0,0,0,0.25); }
}
}
}
// 占位状态
.loading-placeholder,
.empty-placeholder {
padding: 48px 0;
text-align: center;
}
// 响应式
@media (max-width: 992px) {
.main-section .content-grid {
grid-template-columns: 1fr;
}
.content-right {
order: -1;
}
}
@media (max-width: 768px) {
.hero-section {
height: auto;
min-height: 360px;
.hero-title { font-size: 24px; }
.hero-meta { flex-direction: column; gap: 12px; }
.hero-actions { flex-wrap: wrap; }
}
.tab-section .custom-tabs {
overflow-x: auto;
padding-bottom: 8px;
.tab-item {
white-space: nowrap;
padding: 8px 16px;
2026-01-08 09:17:46 +08:00
}
}
}
</style>