chore: 前端首次基线提交
运行必须源码:src/、public/、index.html、package.json 及构建配置。 已忽略运行非必须:node_modules/、dist/、.env*。
This commit is contained in:
490
src/views/Dashboard.vue
Normal file
490
src/views/Dashboard.vue
Normal file
@@ -0,0 +1,490 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user