Files
competition-website/src/views/Dashboard.vue

490 lines
14 KiB
Vue
Raw Normal View History

<template>
<div class="dashboard-page">
<div class="dashboard-header">
<el-button text @click="$router.push('/')" class="back-btn">
<el-icon><HomeFilled /></el-icon><span>返回首页</span>
</el-button>
<h1 class="welcome-title">欢迎回来,{{ authStore.userName }}</h1>
</div>
<div class="dashboard-grid">
<!-- ==================== 进入比赛 ==================== -->
<el-card class="dash-card dash-card--contest" shadow="hover">
<div class="card-header">
<h3 class="card-title">{{ isUploadGroup ? '在线提交' : '在线答题' }}</h3>
<el-tag :type="examStatusTag.type" size="small">
{{ examStatusTag.text }}
</el-tag>
</div>
<div class="card-body">
<p class="exam-desc" v-if="contestStatus === 'ended'">考试已结束,无法再次进入</p>
<p class="exam-desc" v-else-if="(!contestTime || contestStatus === 'not_started') && !hasSubmitted">
{{ isUploadGroup ? '点击下方按钮进入比赛页面,请确保网络稳定,准备好后开始提交作品。' : '点击下方按钮进入比赛页面,请确保网络稳定,准备好后开始答题。' }}
</p>
<p class="exam-desc" v-else-if="!hasSubmitted">请关注比赛时间安排</p>
<el-button
type="primary"
size="large"
class="card-action-btn"
v-guard="() => enterContest()"
:disabled="contestStatus === 'ended'"
>
{{ enterBtnText }}
</el-button>
</div>
</el-card>
<!-- ==================== 模拟考试(仅中职/高职) ==================== -->
<el-card v-if="showMockExam" class="dash-card dash-card--mock" shadow="hover">
<div class="card-header">
<h3 class="card-title">模拟考试</h3>
</div>
<div class="card-body">
<p class="exam-desc">
模拟考试系统,模拟选拔赛的完整流程,仅作考试流程演示,提供习题并非考试真题。
</p>
<el-button type="primary" size="large" class="card-action-btn" @click="$router.push('/mock-exam')">
进入模拟考试
</el-button>
</div>
</el-card>
<!-- ==================== 我的成绩 ==================== -->
<el-card class="dash-card dash-card--score" shadow="hover">
<div class="card-header">
<h3 class="card-title">我的成绩</h3>
</div>
<div class="card-body">
<p class="exam-desc" v-if="noScore">
{{ scoreMessage }}
</p>
<div v-else class="score-display">
<div class="score-number">{{ scoreData.score || 0 }}</div>
<span class="score-unit">分</span>
</div>
<el-button type="primary" size="large" class="card-action-btn" @click="viewScore">
查看详情
</el-button>
</div>
</el-card>
<!-- ==================== 资料下载 ==================== -->
<el-card class="dash-card dash-card--materials" shadow="hover">
<div class="card-header">
<h3 class="card-title">资料下载</h3>
</div>
<div class="card-body">
<p class="exam-desc">
关于中职组/高职组/本科组/教师组比赛相关的说明资料,点击按钮进入下载界面
</p>
<!-- 百度网盘提示词暂时从前端隐藏(需求:测试反馈第4项)
<div class="materials-links">
<p class="materials-link-item">
<el-link type="primary" href="https://pan.baidu.com" target="_blank">百度网盘:https://pan.baidu.com/s/xxxxxx</el-link>
<span class="materials-code">(提取码:2026)</span>
</p>
</div>
-->
<el-button type="primary" size="large" class="card-action-btn" @click="$router.push('/materials')">
查看备赛资料
</el-button>
</div>
</el-card>
</div>
<!-- ==================== 公告区 ==================== -->
<div class="dashboard-news" v-if="newsList.length > 0">
<h3 class="news-title">{{ groupName }}通知公告</h3>
<el-card class="news-card" shadow="never">
<div
v-for="item in newsList"
:key="item.id"
class="news-item"
@click="viewNewsDetail(item.id)"
>
<span class="news-dot"></span>
<span class="news-item-title">{{ item.title }}</span>
<span class="news-item-time">{{ formatDate(item.publish_time) }}</span>
</div>
</el-card>
</div>
</div>
</template>
<script setup>
/**
* Dashboard.vue - 选手工作台
*
* 功能:
* 1. 在线答题
* 2. 模拟考试(仅中职/高职组)
* 3. 我的成绩
* 4. 资料下载
* 5. 公告通知
*/
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { HomeFilled } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import * as examApi from '@/api/exam'
import * as newsApi from '@/api/news'
const router = useRouter()
const authStore = useAuthStore()
// ==================== 状态 ====================
const contestStatus = ref('not_started')
const contestTime = ref(null)
const hasSubmitted = ref(false)
const scoreData = ref(null)
const newsList = ref([])
// ==================== 计算属性 ====================
const showMockExam = computed(() => {
const group = authStore.userGroup
return group === 'zhongzhi' || group === 'gaozhi'
})
const groupName = computed(() => {
const map = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' }
return map[authStore.userGroup] || '全部'
})
// 本科组/教师组为「作品提交」模式,在线答题提示词应指引「开始提交作品」;
// 中职组/高职组为在线考试,提示「开始答题」。
const isUploadGroup = computed(() => {
const g = authStore.userGroup
return g === 'benke' || g === 'jiaoshi'
})
const noScore = computed(() => !scoreData.value || scoreData.value.score === undefined || scoreData.value.score === null)
const scoreMessage = computed(() => {
if (contestStatus.value === 'ended') return '成绩暂未发布,请关注公告'
return '暂无成绩,请耐心等待成绩发布后查看'
})
// 进入比赛按钮文案:截止后→「已结束」;已提交→ 提交组「重新提交」/考试组「继续考试」;
// 其余→「进入比赛」。考试组(中职/高职)已提交也可在截止前重进(应对意外退出)。
const enterBtnText = computed(() => {
if (contestStatus.value === 'ended') return '已结束'
if (hasSubmitted.value) {
return isUploadGroup.value ? '重新提交' : '继续考试'
}
return '进入比赛'
})
// 在线答题卡片右上角状态标签:随「考试窗口状态 + 本人是否提交」联动
// 后端 getExamStatus 返回 status ∈ {not_set, not_started, in_progress, ended},
// 此前前端误判 === 'open' 导致活动期恒定显示「未开始」;现统一在此映射并纳入已提交态。
const examStatusTag = computed(() => {
if (hasSubmitted.value) {
return { text: '已提交', type: 'primary' }
}
switch (contestStatus.value) {
case 'in_progress':
return { text: '进行中', type: 'success' }
case 'not_started':
return { text: '未开始', type: 'warning' }
case 'ended':
return { text: '已结束', type: 'danger' }
case 'not_set':
default:
return { text: '未配置', type: 'info' }
}
})
// ==================== 方法 ====================
function formatDate(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
async function enterContest() {
// 所有组别统一:仅考试窗口内(isActive)允许进入;截止后(ended)统一禁止。
// 考试组/提交组即便已提交,也可在截止前重新进入(应对断网/断电/误操作退出)。
try {
const group = authStore.userGroup || 'zhongzhi'
const data = await examApi.getExamStatus(group)
if (!data.isActive) {
ElMessage.warning(data.message || (data.status === 'ended' ? '考试已结束,无法进入' : '考试未在进行中,请等待考试开始'))
return
}
} catch (e) {
ElMessage.warning('获取考试状态失败,请稍后重试')
return
}
router.push('/competition')
}
function viewScore() {
router.push('/score')
}
function viewNewsDetail(id) {
router.push(`/news/${id}`)
}
async function fetchContestStatus() {
try {
const group = authStore.userGroup || 'zhongzhi'
const data = await examApi.getExamStatus(group)
if (data) {
contestStatus.value = data.status || 'not_started'
contestTime.value = data.time || null
hasSubmitted.value = data.submitted || false
}
} catch (err) {
contestStatus.value = 'not_started'
}
}
async function fetchScore() {
try {
const group = authStore.userGroup || 'zhongzhi'
const data = await examApi.getScore(group)
if (data) {
scoreData.value = data
}
} catch (err) {
// 接口可能不存在
}
}
async function fetchNews() {
try {
const group = authStore.userGroup || 'zhongzhi'
const data = await newsApi.getNewsList({ group, page: 1, pageSize: 5 })
if (data && data.list) {
newsList.value = data.list
} else if (Array.isArray(data)) {
newsList.value = data.slice(0, 5)
}
} catch (err) {
// 接口可能不存在
}
}
onMounted(() => {
fetchContestStatus()
fetchScore()
fetchNews()
})
</script>
<style scoped>
.dashboard-page {
min-height: 100vh;
background: #f5f6f8;
padding: 24px 20px 60px;
}
.dashboard-header {
max-width: 1200px;
margin: 0 auto 28px;
}
.back-btn {
font-size: 15px;
font-weight: 600;
color: #1a3a5c;
margin-bottom: 12px;
padding: 6px 14px;
border: 1px solid #1a3a5c;
border-radius: 8px;
transition: all 0.2s;
}
/* 本按钮的设计意图是 hover「深蓝实底 + 白字」。
必须提高特异性才能压过 Element Plus 的
.el-button.is-text:not(.is-disabled):hover(4 个类级、近白不透明底),
否则深蓝底被覆盖、只剩白字 → 白底白字不可读(已复现的 UI 缺陷)。
此处 .dashboard-header + .el-button + .back-btn + :hover + scoped 属性 = 5 个类级,稳定胜出。 */
.dashboard-header .el-button.back-btn:hover,
.dashboard-header .el-button.back-btn:focus {
color: #fff;
background-color: #1a3a5c;
}
.welcome-title {
font-size: 28px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
/* ==================== Grid ==================== */
.dashboard-grid {
max-width: 1200px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 24px;
}
/* ==================== Card ==================== */
.dash-card {
border-radius: 12px;
border: 1px solid #e5e7eb;
transition: transform 0.3s, box-shadow 0.3s;
}
.dash-card :deep(.el-card__body) {
display: flex;
flex-direction: column;
height: 100%;
}
.dash-card:hover {
transform: translateY(-4px);
}
.dash-card--contest {
border-left: 4px solid #3b82f6;
}
.dash-card--training {
border-left: 4px solid #10b981;
}
.dash-card--mock {
border-left: 4px solid #f59e0b;
}
.dash-card--materials {
border-left: 4px solid #8b5cf6;
}
.dash-card--score {
border-left: 4px solid #ef4444;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.card-title {
font-size: 18px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.card-body {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.exam-desc {
font-size: 14px;
color: #6b7280;
line-height: 1.7;
margin-bottom: 16px;
min-height: 48px;
}
.card-action-btn {
width: 100%;
max-width: 200px;
font-weight: 600;
letter-spacing: 1px;
margin-top: auto;
}
/* ==================== Materials ==================== */
.materials-links {
margin-bottom: 12px;
text-align: center;
}
.materials-link-item {
font-size: 13px;
color: #6b7280;
margin-bottom: 4px;
}
.materials-code {
color: #909399;
font-size: 12px;
margin-left: 4px;
}
/* ==================== Score ==================== */
.score-display {
display: flex;
align-items: baseline;
justify-content: center;
margin-bottom: 16px;
}
.score-number {
font-size: 48px;
font-weight: 800;
color: #1a3a5c;
line-height: 1;
}
.score-unit {
font-size: 18px;
color: #6b7280;
margin-left: 2px;
}
/* ==================== News ==================== */
.dashboard-news {
max-width: 1200px;
margin: 32px auto 0;
}
.news-title {
font-size: 18px;
font-weight: 700;
color: #1f2937;
margin-bottom: 16px;
}
.news-card {
border-radius: 12px;
border: 1px solid #e5e7eb;
}
.news-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 8px;
cursor: pointer;
border-radius: 8px;
transition: background 0.2s;
border-bottom: 1px solid #e5e7eb;
}
.news-item:last-child {
border-bottom: none;
}
.news-item:hover {
background: #f5f6f8;
}
.news-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #1a3a5c;
flex-shrink: 0;
}
.news-item-title {
font-size: 15px;
font-weight: 500;
color: #1f2937;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.news-item-time {
font-size: 13px;
color: #6b7280;
flex-shrink: 0;
white-space: nowrap;
}
/* ==================== Responsive ==================== */
@media (max-width: 768px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
.welcome-title {
font-size: 22px;
}
}
</style>