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

526 lines
16 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="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="hasSubmitted && !isUploadGroup">
您已提交试卷,本次考试已结束,无法再次进入。
</p>
<p class="exam-desc" v-else-if="hasSubmitted">您已提交作品,截止前可重新提交。</p>
<p class="exam-desc" v-else-if="!contestTime || contestStatus === 'not_started'">
{{ isUploadGroup ? '点击下方按钮进入比赛页面,请确保网络稳定,准备好后开始提交作品。' : '点击下方按钮进入比赛页面,请确保网络稳定,准备好后开始答题。' }}
</p>
<p class="exam-desc" v-else>请关注比赛时间安排</p>
<el-button
type="primary"
size="large"
class="card-action-btn"
v-guard="() => enterContest()"
:disabled="enterDisabled"
>
{{ 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>
// ===== 错误提示口径(R65 收敛)=====
// 请求失败的提示一律由 src/api/request.js 响应拦截器统一给出(覆盖全部失败路径),
// 本文件 **不在 catch 内重复弹窗**(同族双弹)。例外:客户端校验(未发请求)/
// 已带 silent(调用方即唯一提示方)/ 非 axios 路径(原生 fetch、独立 axios 实例)。
/**
* 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'
})
// 🔴 成绩发布门控(铁律15):**必须**同时满足 published === true 才渲染分数。
// 历史实现只判 score 是否为 null —— 这与后端「未发布则 score 置 null」的约定形成
// 隐式耦合:一旦后端任一通道在未发布状态下带上数值 score,本卡片即直接泄露。
// Score.vue 早已用 `v-if="scoreData && scoreData.published"` 同源判定,此处对齐,
// 形成「两条展示通道同一判据」的纵深防御。
const noScore = computed(() =>
!scoreData.value ||
scoreData.value.published !== true ||
scoreData.value.score === undefined ||
scoreData.value.score === null
)
const scoreMessage = computed(() => {
if (contestStatus.value === 'ended') return '成绩暂未发布,请关注公告'
return '暂无成绩,请耐心等待成绩发布后查看'
})
// 进入比赛按钮文案:
// 截止后 → 「已结束」(禁用)
// 考试组 + 已提交 → 「已提交」(禁用,不可再进入;测试反馈 #3)
// 提交组 + 已提交 → 「重新提交」(截止前可重传,用户既定需求)
// 其余 → 「进入比赛」
// ⚠️ 考试组(中职/高职)为一次性答题:正式交卷后不允许重进(防改答案再交卷)。
// 未交卷的意外退出(断网/断电)不受影响——submitted 仅在 submit_time 落库后为 true。
const enterBtnText = computed(() => {
if (contestStatus.value === 'ended') return '已结束'
if (hasSubmitted.value) {
return isUploadGroup.value ? '重新提交' : '已提交'
}
return '进入比赛'
})
// 考试组已交卷 ⇒ 禁止再次进入
const enterDisabled = computed(() =>
contestStatus.value === 'ended' || (hasSubmitted.value && !isUploadGroup.value)
)
// 在线答题卡片右上角状态标签:随「考试窗口状态 + 本人是否提交」联动
// 后端 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)统一禁止。
// 提交组(本科/教师)即便已提交,也可在截止前重新进入(应对断网/断电/误操作退出)。
// 考试组(中职/高职)为一次性答题:**正式交卷后禁止再进入**(测试反馈 #3),
// 退出重进只对「未交卷」的情况开放。
try {
const group = authStore.userGroup || 'zhongzhi'
const isExam = group === 'zhongzhi' || group === 'gaozhi'
const data = await examApi.getExamStatus(group)
if (isExam && data.submitted) {
hasSubmitted.value = true
ElMessage.warning('您已提交试卷,无法再次进入考试')
return
}
if (!data.isActive) {
ElMessage.warning(data.message || (data.status === 'ended' ? '考试已结束,无法进入' : '考试未在进行中,请等待考试开始'))
return
}
} catch {
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
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>