526 lines
16 KiB
Vue
526 lines
16 KiB
Vue
<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> |