chore: 前端首次基线提交

运行必须源码:src/、public/、index.html、package.json 及构建配置。
已忽略运行非必须:node_modules/、dist/、.env*。
This commit is contained in:
WorkBuddy
2026-08-17 12:25:42 +08:00
commit e8f0195b61
52 changed files with 16866 additions and 0 deletions

39
src/App.vue Normal file
View File

@@ -0,0 +1,39 @@
<template>
<!-- 应用根组件:使用 router-view 作为布局容器 -->
<div id="app-container">
<router-view />
</div>
</template>
<script setup>
// App.vue - 根组件
// 所有页面组件通过 <router-view> 在此处渲染
// 具体的布局(导航栏、侧边栏等)由各页面自行决定
</script>
<style>
/* 全局基础样式 */
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
font-family:
'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei',
'微软雅黑', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
}
#app-container {
width: 100%;
min-height: 100vh;
}
</style>

350
src/api/admin.js Normal file
View File

@@ -0,0 +1,350 @@
import request from './request'
// ==================== 管理员后台 API ====================
/**
* 获取管理后台统计数据
* @returns {Promise} { totalAccounts, activatedAccounts, submissionsCount, ... }
*/
export function getStats(params = {}) {
return request.get('/admin/stats', { params })
}
/**
* 获取账号列表(含筛选)
* @param {object} params - { page, pageSize, keyword, group, status }
* @returns {Promise} { list, total, page, pageSize }
*/
export function getAccounts(params) {
return request.get('/admin/accounts', { params })
}
/**
* 导入 Excel 批量创建账号
* @param {FormData} formData - 包含 Excel 文件的 FormData
* @returns {Promise}
*/
export function importExcel(formData) {
// 注意:不要手动设置 Content-Type。axios/浏览器在发送 FormData 时会自动补上
// `multipart/form-data; boundary=...`;手动写死 'multipart/form-data'(无 boundary)
// 会导致后端 getFileStream 解析失败(Missing boundary)-> 500,且永不落库。
return request.post('/admin/import-excel', formData, {
timeout: 60000,
})
}
/**
* 获取参赛作品列表(按组别)
* @param {string} group - 组别标识
* @returns {Promise} 作品列表
*/
export function getSubmissions(group) {
return request.get(`/admin/submissions/${group}`)
}
/**
* 获取比赛时间配置
* @returns {Promise}
*/
export function getTimes() {
return request.get('/admin/times')
}
/**
* 设置比赛时间
* @param {Object} data - { zhongzhi: {start, end}, gaozhi: ... }
* @returns {Promise}
*/
export function setTimes(data) {
// 后端要求 times 数组,格式:[{ group, startTime, endTime, deadline, choiceCount, judgeCount }]
// 注意:必须携带 choiceCount / judgeCount,否则后端按缺省 50/20 处理,后台「抽取数量」配置无法落库
const times = Object.entries(data).map(([group, item]) => ({
group,
startTime: item.start,
endTime: item.end,
deadline: item.deadline,
choiceCount: item.choiceCount != null ? parseInt(item.choiceCount, 10) : 50,
judgeCount: item.judgeCount != null ? parseInt(item.judgeCount, 10) : 20,
}))
return request.put('/admin/times', { times })
}
/**
* 设置评分
* @param {string} group - 组别标识
* @param {object} data - 评分数据
* @returns {Promise}
*/
export function setScores(group, data) {
return request.put(`/admin/scores/${group}`, data)
}
/**
* 获取考题列表(管理员,含答案,按题型分表返回)
* @param {string} group - 组别标识
* @returns {Promise} { choice: [...], judge: [...], word: [...] }
*/
export function getQuestions(group) {
return request.get(`/admin/exam/${group}/questions`)
}
/**
* 获取作品管理统一列表(覆盖全部 4 个组别)
* @param {string} group - 组别标识
* @returns {Promise} { list: [{ contestantId, name, idNumber, phone, group, files: [...] }] }
*/
export function getWorks(group) {
return request.get(`/admin/works/${group}`)
}
/**
* 批量删除题目(按题型)
* @param {string} group - 组别标识
* @param {string} questionType - choice / judge / word
* @param {number[]} ids - 题目 ID 数组
* @returns {Promise}
*/
export function bulkDeleteQuestions(group, questionType, ids) {
return request.put(`/admin/exam/${group}/questions`, {
question_type: questionType,
action: 'bulkDelete',
question_uids: ids,
})
}
/**
* 设置考题(按题型分表管理)
* @param {string} group - 组别标识
* @param {string} questionType - 题型:choice / judge / word
* @param {object} data - 考题数据 { questions: [...], action: 'add'|'update'|'delete' }
* @returns {Promise}
*/
export function setQuestions(group, questionType, data) {
return request.put(`/admin/exam/${group}/questions`, {
question_type: questionType,
...data,
})
}
/**
* 客观题 Excel 批量导入(单工作表:题型/题目/选项A~D/正确答案/分值)
* @param {string} group - 组别标识
* @param {FormData} formData - 包含 Excel 文件的 FormData(字段名 file)
* @returns {Promise} { imported, skipped, errors }
*/
export function importQuestionsExcel(group, formData) {
// 同上:不要手动设置 Content-Type,避免缺失 boundary 导致后端解析 500。
return request.post(`/admin/exam/${group}/questions/import-excel`, formData, {
timeout: 120000,
})
}
/**
* 上传 Word 答题模板
* @param {string} group - 组别标识
* @param {FormData} formData - 包含 Word 文件的 FormData
* @returns {Promise}
*/
export function uploadWordTemplate(group, formData) {
return request.put(`/admin/exam/${group}/word-template`, formData, {
timeout: 30000,
})
}
/**
* 获取 Word 模板信息
* @param {string} group - 组别标识
* @returns {Promise}
*/
export function getWordTemplate(group) {
return request.get(`/admin/exam/${group}/word-template`)
}
/**
* 删除 Word 模板
* @param {string} group - 组别标识
* @returns {Promise}
*/
export function deleteWordTemplate(group) {
return request.delete(`/admin/exam/${group}/word-template`)
}
// ==================== 备赛资料管理 ====================
/**
* 获取备赛资料列表
* @param {string} group - 组别
* @returns {Promise}
*/
export function getMaterials(group) {
return request.get(`/admin/materials/${group}`)
}
/**
* 上传备赛资料
* @param {string} group - 组别
* @param {FormData} formData - 文件
* @returns {Promise}
*/
export function uploadMaterial(group, formData) {
return request.post(`/admin/materials/${group}`, formData, { timeout: 60000 })
}
/**
* 删除备赛资料
* @param {string} group - 组别
* @param {string} fileName - 文件名
* @returns {Promise}
*/
export function deleteMaterial(group, fileName) {
return request.delete(`/admin/materials/${group}/${encodeURIComponent(fileName)}`)
}
// ==================== 成绩管理 ====================
/**
* 获取某组成绩列表(管理员)
* @param {string} group - 组别标识
* @returns {Promise} 成绩列表
*/
export function getScores(group) {
return request.get(`/admin/scores/${group}`)
}
/**
* 发布/撤回指定组别的成绩
*
* 发布状态按组独立存储:发布本科组不会让中职组考生看到成绩。
* 不传 group 时对全部组生效(批量操作)。
* @param {string} group - 组别标识
* @param {boolean} publish - true: 发布, false: 撤回
* @returns {Promise}
*/
export function publishScores(group, publish) {
return request.put('/admin/publish-scores', group ? { publish, group } : { publish })
}
/**
* 获取成绩发布状态
* @param {string} group - 组别标识;不传时 published 表示「全部组均已发布」
* @returns {Promise} { published, group, groups: { zhongzhi, gaozhi, benke, jiaoshi } }
*/
export function getPublishStatus(group) {
return request.get('/admin/publish-scores', group ? { params: { group } } : undefined)
}
// ==================== 新闻管理 ====================
/**
* 获取新闻列表(管理员)
* @returns {Promise}
*/
export function listNews() {
return request.get('/admin/news')
}
/**
* 创建新闻
* @param {object} data - { title, content, category, coverImage }
* @returns {Promise}
*/
export function createNews(data) {
return request.post('/admin/news', data)
}
/**
* 更新新闻
* @param {number|string} id - 新闻 ID
* @param {object} data - 更新的字段
* @returns {Promise}
*/
export function updateNews(id, data) {
return request.put(`/admin/news/${id}`, data)
}
/**
* 删除新闻
* @param {number|string} id - 新闻 ID
* @returns {Promise}
*/
export function deleteNews(id) {
return request.delete(`/admin/news/${id}`)
}
/**
* 更新新闻排序
* @param {Array<{id: number, sort_order: number}>} orders
* @returns {Promise}
*/
export function updateNewsOrder(orders) {
return request.put('/admin/news/order', { orders })
}
// ==================== 轮播图管理 ====================
/**
* 获取轮播图列表(管理员)
* @returns {Promise}
*/
export function listCarousel() {
return request.get('/admin/carousel')
}
/**
* 添加轮播图
* @param {object} data - { imageUrl, linkUrl, sortOrder, title }
* @returns {Promise}
*/
export function addCarousel(data) {
return request.post('/admin/carousel', data)
}
/**
* 更新轮播图
* @param {number|string} id - 轮播图 ID
* @param {object} data - 更新的字段
* @returns {Promise}
*/
export function updateCarousel(id, data) {
return request.put(`/admin/carousel/${id}`, data)
}
/**
* 删除轮播图
* @param {number|string} id - 轮播图 ID
* @returns {Promise}
*/
export function deleteCarousel(id) {
return request.delete(`/admin/carousel/${id}`)
}
/**
* 批量更新轮播图排序
* @param {Array} orders - [{ id, sort_order }]
* @returns {Promise}
*/
export function updateCarouselOrder(orders) {
return request.put('/admin/carousel/order', { orders })
}
// ==================== 测试数据标记与重置 ====================
/**
* 切换选手的测试数据标记
* @param {number} id - 选手ID
* @param {number} testFlag - 0=生产数据, 1=测试数据
* @returns {Promise}
*/
export function toggleTestFlag(id, testFlag) {
return request.post('/admin/toggle-test-flag', { id, test_flag: testFlag })
}
/**
* 重置测试数据(仅重置 test_flag=1 的选手及其提交记录)
* @returns {Promise}
*/
export function resetTestData() {
return request.post('/admin/reset-test-data')
}

94
src/api/auth.js Normal file
View File

@@ -0,0 +1,94 @@
import request from './request'
// ==================== 认证相关 API ====================
/**
* 获取图形验证码
* @returns {Promise} 验证码图片数据
*/
export function getCaptcha() {
return request.post('/auth/captcha')
}
/**
* 发送短信验证码(激活流程)
* 前端传入身份证号,后端自动查找绑定手机号发送短信
* @param {string} phone - 身份证号
* @param {string} captcha - 图形验证码
* @returns {Promise} { smsCode, phone, name, group }
*/
export function sendSms(phone, captcha) {
return request.post('/auth/sms', { phone, captcha })
}
/**
* 激活账号(设置密码)
* @param {object} data - { id_number, phone, password, smsCode }
* @returns {Promise}
*/
export function activate(data) {
return request.post('/auth/activate', data)
}
/**
* 参赛者登录(身份证号 + 密码)
* @param {string} idNumber - 身份证号
* @param {string} password - 密码
* @returns {Promise} { token, user }
*/
export function login(phone, password) {
return request.post('/auth/login', { phone, password })
}
/**
* 管理员登录(用户名 + 密码)
* @param {string} username - 管理员用户名
* @param {string} password - 密码
* @returns {Promise} { token, user }
*/
export function adminLogin(username, password) {
return request.post('/auth/admin-login', { username, password })
}
/**
* 获取当前登录用户信息
* @returns {Promise} 用户信息对象
*/
export function getCurrentUser() {
return request.get('/auth/current-user')
}
/**
* 忘记密码 - 发送短信验证码
* @param {string} idNumber - 身份证号
* @param {string} captcha - 图形验证码
* @returns {Promise} { smsCode, phone }
*/
export function forgotPassword(idNumber, captcha) {
return request.post('/auth/forgot-password', { id_number: idNumber, captcha })
}
/**
* 重置密码
* @param {object} data - { id_number, password, smsCode }
* @returns {Promise}
*/
export function resetPassword(data) {
return request.post('/auth/reset-password', data)
}
/**
* 退出登录
* @returns {Promise}
*/
export function logout() {
return request.post('/auth/logout')
}
/**
* 静默刷新令牌(用户无感知续期,避免被强制踢出登录)
* @returns {Promise<{token: string, user: object}>}
*/
export function refreshToken() {
return request.post('/auth/refresh', null, { silent: true })
}

11
src/api/carousel.js Normal file
View File

@@ -0,0 +1,11 @@
import request from './request'
// ==================== 首页轮播图 API ====================
/**
* 获取首页轮播图列表
* @returns {Promise} 轮播图数组
*/
export function getCarouselList() {
return request.get('/carousel')
}

92
src/api/exam.js Normal file
View File

@@ -0,0 +1,92 @@
import request from './request'
// ==================== 考试/知识竞答 API ====================
/**
* 获取考试状态
* @param {string} group - 组别标识
* @returns {Promise} { isActive, startTime, endTime, countdown, status, submitted }
*/
export function getExamStatus(group) {
return request.get(`/exam/${group}/status`)
}
/**
* 获取考试题目列表
* 抽题数量以后台 competition_times 配置为准(choice_count / judge_count),
* 题目数量不足时自动按题库实际数量封顶,题号随实际数量自适应。
* @param {string} group - 组别标识
* @returns {Promise} 题目数组
*/
export function getQuestions(group) {
return request.get(`/exam/${group}/questions`)
}
/**
* 获取 Word 答题模板(返回文件流,前端通过 window.open 直接下载)
* @param {string} group - 组别标识
* @returns {Promise} 文件流
*/
export function getWordTemplate(group) {
return request.get(`/exam/${group}/word-template`)
}
/**
* 提交单个答案
* @param {string} group - 组别标识
* @param {array} answers - 答案数组 [{ question_uid, answer_data }]
* @returns {Promise}
*/
export function submitAnswers(group, answers) {
return request.post(`/exam/${group}/submit-answers`, { answers }, { silent: true })
}
/**
* 上传 Word 答题文件
* @param {string} group - 组别标识
* @param {FormData} formData - 包含 Word 文件的 FormData
* @returns {Promise}
*/
export function uploadWord(group, formData) {
return request.post(`/exam/${group}/upload-word`, formData, {
timeout: 60000, // 上传超时 60 秒
})
}
/**
* 删除已上传的 Word 答题文件(中职/高职组)
* @param {string} group - 组别标识
* @returns {Promise}
*/
export function deleteWord(group) {
return request.delete(`/exam/${group}/word`, { timeout: 60000 })
}
/**
* 提交全部答案(结束考试)
* @param {string} group - 组别标识
* @param {object} data - 提交数据
* @returns {Promise}
*/
export function submitAll(group, data) {
return request.post(`/exam/${group}/submit-all`, data, { silent: true })
}
/**
* 获取已有答案(考试重新进入时恢复状态)
* @param {string} group - 组别标识
* @returns {Promise} { answers: [...] }
*/
export function getAnswers(group) {
return request.get(`/exam/${group}/answers`)
}
/**
* 获取当前选手个人成绩(后端按认证用户取,group 仅作透传参数)
* GET /score
* @param {string} group - 组别标识
* @returns {Promise} 成绩详情(含答题得分/作品评分/总分)
*/
export function getScore(group) {
return request.get('/score', { params: { group } })
}

13
src/api/materials.js Normal file
View File

@@ -0,0 +1,13 @@
import request from './request'
// ==================== 资料下载 API(参赛者端) ====================
/**
* 获取可下载资料列表(按组别)
* @param {string} group - 组别标识
* @returns {Promise} 资料列表 [{ id, fileName, name, size, type, uploadTime }]
*/
export function getMaterialsList(group) {
return request.get('/materials', { params: { group } })
}

29
src/api/news.js Normal file
View File

@@ -0,0 +1,29 @@
import request from './request'
// ==================== 新闻资讯 API ====================
/**
* 获取新闻列表
* @param {object} params - 查询参数 { page, pageSize, keyword, category }
* @returns {Promise} { list, total, page, pageSize }
*/
export function getNewsList(params) {
return request.get('/news', { params })
}
/**
* 获取新闻详情
* @param {number|string} id - 新闻 ID
* @returns {Promise} 新闻详情对象
*/
export function getNewsDetail(id) {
return request.get(`/news/${id}`)
}
/**
* 获取各分组新闻计数
* @returns {Promise} { all, zhongzhi, gaozhi, benke, jiaoshi }
*/
export function getNewsCounts() {
return request.get('/news/counts')
}

269
src/api/request.js Normal file
View File

@@ -0,0 +1,269 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { decodeJwtPayload } from '@/utils/jwt'
// ==================== 创建 Axios 实例 ====================
const request = axios.create({
// 所有请求的基础路径
baseURL: '/api',
// 请求超时时间(毫秒)
timeout: 30000,
// 跨域请求时携带 cookie
withCredentials: false,
})
// ==================== 请求拦截器 ====================
request.interceptors.request.use(
(config) => {
// 从 localStorage 获取 token
const token = localStorage.getItem('token')
// 如果存在 token,添加到请求头
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
// 请求错误时直接 reject
return Promise.reject(error)
},
)
// ==================== 静默刷新(防强制登出) ====================
// 当请求返回 401 时,尝试用过期/临期的 token 静默换发新 token,
// 成功后自动重试原请求;仅当刷新也失败(真正失效)才清理并跳转登录。
// 这样活跃用户永远不会被"意外踢回登录页"。
let isRefreshing = false
const refreshSubscribers = []
// 已尝试过静默刷新的 token 集合:防止 axios 克隆 config 导致 _retry 标记丢失后,
// 重试请求再次触发刷新,形成"401→刷新→重试→401→再刷新"的刷新风暴。
// 一次 401 最多:刷新 1 次 + 重试 1 次,之后该 token 直接判定为真正失效并登出。
const refreshedTokens = new Set()
function extractBearerToken(cfg) {
const h = (cfg && cfg.headers) || {}
const auth = h.Authorization || h.authorization || ''
const m = String(auth).match(/^Bearer\s+(.+)$/i)
if (m) return m[1]
return localStorage.getItem('token') || null
}
function subscribeTokenRefresh(cb) {
refreshSubscribers.push(cb)
}
function onRefreshed(newToken) {
refreshSubscribers.forEach((cb) => cb(newToken))
refreshSubscribers.length = 0
}
async function doSilentRefresh() {
// 刷新接口不挂 auth 中间件,故可直接用本实例调用
const res = await request.post('/auth/refresh', null, { silent: true })
// 防御:后端未部署 /api/auth/refresh(如返回 400/非标准响应)或刷新失败但走了 200 形态,
// 此时 res 没有 token。若不清,会把 localStorage 写成 undefined 并陷入"重试→401→再刷新"死循环。
if (!res || !res.token) {
throw new Error('刷新接口未返回有效 token(可能后端未部署 /api/auth/refresh)')
}
const { token, user } = res
localStorage.setItem('token', token)
localStorage.setItem('user', JSON.stringify(user))
// 同步 Pinia store(动态导入避免与 store 形成循环依赖)
try {
const { useAuthStore } = await import('@/stores/auth')
const authStore = useAuthStore()
authStore.persistAuth(token, user)
} catch (e) {
/* store 未初始化时忽略,localStorage 已更新即可 */
}
return token
}
function forceLogout() {
if (window.__authExpiredHandled) return
window.__authExpiredHandled = true
localStorage.removeItem('token')
localStorage.removeItem('user')
try {
import('@/stores/auth')
.then(({ useAuthStore }) => {
try { useAuthStore().clearAuth() } catch (e) {}
})
.catch(() => {})
} catch (e) {}
if (window.location.pathname !== '/login') {
ElMessage.error('登录已过期,请重新登录')
// 延迟跳转,给用户看到提示的时间
setTimeout(() => {
window.location.href = '/login?reason=expired'
window.__authExpiredHandled = false
}, 1500)
} else {
window.__authExpiredHandled = false
}
}
// ==================== 主动续期(防活跃用户被踢) ====================
// 在应用启动、定时(默认 5 分钟)、以及标签页重新可见时检查:
// 若 token 即将过期(提前 10 分钟)或已过期但仍在后端宽限期内,
// 提前静默换发新 token,使活跃用户永远不会因为 token 过期而 401 被踢回登录页。
const REFRESH_LEAD_SECONDS = 10 * 60
const PROACTIVE_INTERVAL_MS = 5 * 60 * 1000
function shouldProactivelyRefresh() {
const token = localStorage.getItem('token')
if (!token) return false
const payload = decodeJwtPayload(token)
if (!payload || typeof payload.exp !== 'number') return false
const now = Math.floor(Date.now() / 1000)
// exp 落在提前量内(含已过期但尚未超过后端宽限期的情况)
return now >= payload.exp - REFRESH_LEAD_SECONDS
}
async function proactiveRefreshOnce() {
if (!shouldProactivelyRefresh()) return
try {
await doSilentRefresh()
} catch (e) {
// 续期失败(token 真正失效/签名错误/超宽限):交还由后续请求或路由守卫处理,
// 不在此处强制跳转,避免打断用户。
console.warn('[auth] 主动续期未成功,将在下次请求时重新鉴权:', e && e.message)
}
}
let proactiveTimer = null
export function initProactiveTokenRefresh() {
// 启动即尝试一次(处理"昨天登录、今天才打开"的已过期但可续期 token)
proactiveRefreshOnce()
if (proactiveTimer) clearInterval(proactiveTimer)
proactiveTimer = setInterval(proactiveRefreshOnce, PROACTIVE_INTERVAL_MS)
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') proactiveRefreshOnce()
})
}
}
// ==================== 响应拦截器 ====================
request.interceptors.response.use(
(response) => {
// 服务端返回的数据在 response.data 中
// 根据常见的后端响应格式解包:
// 如果后端用 { code, data, message } 格式,则提取 data
const res = response.data
// 如果响应中明确包含 code 字段(说明是业务层封装)
if (res && typeof res === 'object' && 'code' in res) {
// 业务状态码非 0 / 200 时视为异常
if (res.code !== 0 && res.code !== 200) {
// 支持 silent 模式:调用方可设置 config.silent = true 来抑制错误提示
if (!response.config.silent) {
ElMessage.error(res.message || '请求失败')
}
return Promise.reject(new Error(res.message || '请求失败'))
}
// 正常返回,只暴露 data 部分
return res.data !== undefined ? res.data : res
}
// 如果后端直接返回数据(无 code 封装),原样返回
return res
},
async (error) => {
// 处理 HTTP 状态码错误
if (error.response) {
const { status } = error.response
switch (status) {
case 401: {
const cfg = error.config || {}
console.warn('[auth] 收到 401,尝试静默刷新续期(用户应无感知)')
const reqToken = extractBearerToken(cfg)
// 刷新接口自身 401:直接登出
if (cfg.url && cfg.url.includes('/auth/refresh')) {
forceLogout()
return Promise.reject(error)
}
// 该 token 已经刷新过且重试用仍 401:判定为真正失效(真过期 / 签名错 /
// 超宽限 / 后端未部署刷新接口),直接登出,绝不重复刷新。
if (reqToken && refreshedTokens.has(reqToken)) {
console.warn('[auth] 该 token 已尝试静默刷新仍 401,判定为真正失效,请重新登录')
forceLogout()
return Promise.reject(error)
}
// 并发 401:首个请求触发刷新,其余排队等待新 token 后重试
if (isRefreshing) {
return new Promise((resolve, reject) => {
subscribeTokenRefresh((newToken) => {
if (newToken) {
cfg.headers = cfg.headers || {}
cfg.headers.Authorization = `Bearer ${newToken}`
resolve(request(cfg))
} else {
reject(error)
}
})
})
}
isRefreshing = true
try {
const newToken = await doSilentRefresh()
// 标记:旧 token 与新 token 都已尝试过刷新,重试若仍 401 直接登出
if (reqToken) refreshedTokens.add(reqToken)
if (newToken) refreshedTokens.add(newToken)
onRefreshed(newToken)
cfg.headers = cfg.headers || {}
cfg.headers.Authorization = `Bearer ${newToken}`
return request(cfg)
} catch (refreshErr) {
const reason =
refreshErr && refreshErr.response && refreshErr.response.data
? refreshErr.response.data.message
: (refreshErr && refreshErr.message)
console.warn('[auth] 静默刷新失败,用户将被请去重新登录。原因:', reason)
onRefreshed(null)
forceLogout()
return Promise.reject(refreshErr)
} finally {
isRefreshing = false
}
}
case 403:
ElMessage.error('您没有权限执行此操作')
break
case 404:
ElMessage.error('请求的资源不存在')
break
case 500:
ElMessage.error('服务器内部错误,请稍后重试')
break
default:
ElMessage.error(error.response.data?.message || `请求失败 (${status})`)
}
} else if (error.code === 'ECONNABORTED') {
// 请求超时
ElMessage.error('请求超时,请检查网络连接')
} else {
// 网络错误或其他错误
ElMessage.error('网络连接异常,请检查网络')
}
return Promise.reject(error)
},
)
export default request

11
src/api/score.js Normal file
View File

@@ -0,0 +1,11 @@
import request from './request'
// ==================== 成绩查询 API ====================
/**
* 获取当前登录用户的成绩
* @returns {Promise} { score, detail, rank, published }
*/
export function getMyScore() {
return request.get('/score')
}

95
src/api/upload.js Normal file
View File

@@ -0,0 +1,95 @@
import request from './request'
// ==================== 作品上传 API ====================
/**
* 上传作品分片
* @param {string} group - 组别标识
* @param {FormData} formData - 包含文件分片的 FormData
* @param {function} onProgress - 上传进度回调 (percent)
* @returns {Promise}
*/
export function uploadChunk(group, formData, onProgress) {
return request.post(`/upload/${group}/chunk`, formData, {
timeout: 120000,
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const percent = Math.round(
(progressEvent.loaded / progressEvent.total) * 100
)
onProgress(percent)
}
},
})
}
/**
* 合并分片并创建提交记录
* @param {string} group - 组别标识
* @param {object} data - { fileId, fileName, totalChunks, work_name }
* @returns {Promise}
*/
export function mergeChunks(group, data) {
return request.post(`/upload/${group}/merge`, data, {
timeout: 60000,
})
}
/**
* 查询分片上传状态
* @param {string} group - 组别标识
* @param {string} fileId - 文件唯一标识
* @returns {Promise} { received, chunks }
*/
export function getChunkStatus(group, fileId) {
return request.get(`/upload/${group}/chunk-status`, {
params: { fileId },
})
}
/**
* 获取上传状态
* @param {string} group - 组别标识
* @returns {Promise} { submitted, deadline, canSubmit }
*/
export function getUploadStatus(group) {
return request.get(`/upload/${group}/status`)
}
/**
* 获取已提交的作品列表
* @param {string} group - 组别标识
* @returns {Promise} { list, total }
*/
export function getSubmissions(group) {
return request.get(`/upload/${group}/submissions`)
}
/**
* 单次上传作品文件(流式,适用本科/教师组答案文件)
* @param {string} group - 组别标识
* @param {FormData} formData - 含 file 与 file_type 字段
* @param {function} onProgress - 进度回调
* @returns {Promise}
*/
export function uploadWork(group, formData, onProgress) {
// 注意:不要手动设置 Content-Type: multipart/form-data(不带 boundary 会导致
// 后端 ctx.getFileStream() 报 Missing boundary 而 500)。交由 axios/浏览器自动
// 补带 boundary 的 multipart 头。字段顺序:先文本字段(file_type/work_name)再 file。
return request.post(`/upload/${group}`, formData, {
timeout: 300000,
onUploadProgress: (e) => {
if (onProgress && e.total) onProgress(Math.round((e.loaded / e.total) * 100))
},
})
}
/**
* 删除已上传的作品文件(本科/教师组)
* @param {string} group - 组别标识
* @param {number|string} id - 提交记录 ID
* @returns {Promise}
*/
export function deleteWork(group, id) {
return request.delete(`/upload/${group}/${id}`, { timeout: 60000 })
}

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="32" height="32" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"/><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

91
src/auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,91 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue').EffectScope
const ElMessage: typeof import('element-plus/es').ElMessage
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
const computed: typeof import('vue').computed
const createApp: typeof import('vue').createApp
const createPinia: typeof import('pinia').createPinia
const customRef: typeof import('vue').customRef
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
const defineComponent: typeof import('vue').defineComponent
const defineStore: typeof import('pinia').defineStore
const effectScope: typeof import('vue').effectScope
const getActivePinia: typeof import('pinia').getActivePinia
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
const h: typeof import('vue').h
const inject: typeof import('vue').inject
const isProxy: typeof import('vue').isProxy
const isReactive: typeof import('vue').isReactive
const isReadonly: typeof import('vue').isReadonly
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const mapActions: typeof import('pinia').mapActions
const mapGetters: typeof import('pinia').mapGetters
const mapState: typeof import('pinia').mapState
const mapStores: typeof import('pinia').mapStores
const mapWritableState: typeof import('pinia').mapWritableState
const markRaw: typeof import('vue').markRaw
const nextTick: typeof import('vue').nextTick
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
const onDeactivated: typeof import('vue').onDeactivated
const onErrorCaptured: typeof import('vue').onErrorCaptured
const onMounted: typeof import('vue').onMounted
const onRenderTracked: typeof import('vue').onRenderTracked
const onRenderTriggered: typeof import('vue').onRenderTriggered
const onScopeDispose: typeof import('vue').onScopeDispose
const onServerPrefetch: typeof import('vue').onServerPrefetch
const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const provide: typeof import('vue').provide
const reactive: typeof import('vue').reactive
const readonly: typeof import('vue').readonly
const ref: typeof import('vue').ref
const resolveComponent: typeof import('vue').resolveComponent
const setActivePinia: typeof import('pinia').setActivePinia
const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix
const shallowReactive: typeof import('vue').shallowReactive
const shallowReadonly: typeof import('vue').shallowReadonly
const shallowRef: typeof import('vue').shallowRef
const storeToRefs: typeof import('pinia').storeToRefs
const toRaw: typeof import('vue').toRaw
const toRef: typeof import('vue').toRef
const toRefs: typeof import('vue').toRefs
const toValue: typeof import('vue').toValue
const triggerRef: typeof import('vue').triggerRef
const unref: typeof import('vue').unref
const useAttrs: typeof import('vue').useAttrs
const useCssModule: typeof import('vue').useCssModule
const useCssVars: typeof import('vue').useCssVars
const useId: typeof import('vue').useId
const useLink: typeof import('vue-router').useLink
const useModel: typeof import('vue').useModel
const useRoute: typeof import('vue-router').useRoute
const useRouter: typeof import('vue-router').useRouter
const useSlots: typeof import('vue').useSlots
const useTemplateRef: typeof import('vue').useTemplateRef
const watch: typeof import('vue').watch
const watchEffect: typeof import('vue').watchEffect
const watchPostEffect: typeof import('vue').watchPostEffect
const watchSyncEffect: typeof import('vue').watchSyncEffect
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

54
src/components.d.ts vendored Normal file
View File

@@ -0,0 +1,54 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElResult: typeof import('element-plus/es')['ElResult']
ElRow: typeof import('element-plus/es')['ElRow']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElStep: typeof import('element-plus/es')['ElStep']
ElSteps: typeof import('element-plus/es')['ElSteps']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}

View File

@@ -0,0 +1,115 @@
<template>
<div class="rich-editor" :style="{ zIndex: 3000 }">
<Toolbar
class="rich-editor__toolbar"
:editor="editorRef"
:defaultConfig="toolbarConfig"
:mode="mode"
/>
<Editor
class="rich-editor__body"
:style="{ height: height, overflowY: 'hidden' }"
v-model="valueHtml"
:defaultConfig="editorConfig"
:mode="mode"
@onCreated="handleCreated"
@onChange="handleChange"
/>
</div>
</template>
<script setup>
import '@wangeditor/editor/dist/css/style.css'
import { onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { useAuthStore } from '@/stores/auth'
const props = defineProps({
modelValue: { type: String, default: '' },
height: { type: String, default: '320px' },
placeholder: { type: String, default: '请输入内容…' },
mode: { type: String, default: 'default' }, // 'default' | 'simple'
})
const emit = defineEmits(['update:modelValue'])
const authStore = useAuthStore()
// 编辑器实例:必须用 shallowRef
const editorRef = shallowRef()
// 内部 HTML 值
const valueHtml = ref(props.modelValue || '')
// 外部值变化时同步到编辑器(避免打开弹窗时内容不回填)
watch(
() => props.modelValue,
(val) => {
if (val !== valueHtml.value) {
valueHtml.value = val || ''
}
}
)
const toolbarConfig = {
excludeKeys: [
'group-video', // 轮播正文不需要视频
'insertTable',
'codeBlock',
'fullScreen',
],
}
const editorConfig = {
placeholder: props.placeholder,
MENU_CONF: {
// 图片上传复用后台统一图片上传接口
uploadImage: {
server: '/api/admin/upload/image',
fieldName: 'file',
maxFileSize: 5 * 1024 * 1024, // 5MB
allowedFileTypes: ['image/*'],
headers: {
get Authorization() {
return authStore.token ? `Bearer ${authStore.token}` : ''
},
},
// 适配后端返回结构 { code:0, data:{ imageUrl } }
customInsert(res, insertFn) {
const url = res?.data?.imageUrl || res?.data?.image_url || res?.data?.url || ''
if (url) {
insertFn(url, '', url)
}
},
},
},
}
function handleCreated(editor) {
editorRef.value = editor
}
function handleChange(editor) {
emit('update:modelValue', editor.getHtml())
}
// 组件销毁时务必销毁编辑器实例,防止内存泄漏
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null) return
editor.destroy()
})
</script>
<style scoped>
.rich-editor {
border: 1px solid #dcdfe6;
border-radius: 6px;
overflow: hidden;
width: 100%;
}
.rich-editor__toolbar {
border-bottom: 1px solid #e4e7ed;
}
.rich-editor__body {
background: #fff;
}
</style>

66
src/main.js Normal file
View File

@@ -0,0 +1,66 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
// Element Plus 中文语言包
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
// Element Plus 样式
import 'element-plus/dist/index.css'
// 全局样式(含 Element Plus 主题色变量,防止 getThemeColors 解构 undefined 报错)
import './styles/global.css'
import App from './App.vue'
import router from './router'
// 主动 token 续期(防活跃用户因 token 过期被强制踢回登录页)
import { initProactiveTokenRefresh } from '@/api/request'
// 按钮防抖 / 节流 / 防重入:v-guard="() => handler(...)"
import { createClickGuard } from '@/utils/antiSpam'
// 创建 Vue 应用实例
const app = createApp(App)
// 注册 Pinia 状态管理
app.use(createPinia())
// 注册 Vue Router
app.use(router)
// 启动主动 token 续期:活跃用户不会因 token 过期被强制踢出登录
initProactiveTokenRefresh()
// 注册 Element Plus(使用中文语言包)
app.use(ElementPlus, {
locale: zhCn,
})
// 全局指令 v-guard:为按钮/可点击元素提供「防重入 + 节流」,
// 防止狂点触发大量并发请求打爆连接池(2026-08-10 账号列表超时根因)。
// 用法:<el-button v-guard="() => downloadWork(row, f)">下载</el-button>
// 会自行忽略 disabled 元素、执行期间重入、以及与上次间隔 < minInterval 的连点。
app.directive('guard', {
mounted(el, binding) {
const opts = (binding.value && binding.value.__opts) || {}
el.__guardFn__ = typeof binding.value === 'function' ? binding.value : () => {}
// 间接调用:始终读取 el.__guardFn__ 的「最新」闭包,
// 避免 v-for 表格行复用时 mounted 期捕获的旧 row 被永久绑死(点删除删错行)。
const guarded = createClickGuard(function (...args) {
return typeof el.__guardFn__ === 'function' ? el.__guardFn__.apply(this, args) : undefined
}, opts)
el.__guardHandler__ = guarded
// disabled 时浏览器不会派发 click,无需额外处理
el.addEventListener('click', guarded)
},
updated(el, binding) {
// 只换 handler,不重建 guard —— 节流计时与重入锁状态得以保留
if (typeof binding.value === 'function') el.__guardFn__ = binding.value
},
unmounted(el) {
if (el.__guardHandler__) {
el.removeEventListener('click', el.__guardHandler__)
delete el.__guardHandler__
}
delete el.__guardFn__
},
})
// 挂载到 #app
app.mount('#app')

237
src/router/index.js Normal file
View File

@@ -0,0 +1,237 @@
import { createRouter, createWebHistory } from 'vue-router'
import { ElMessage } from 'element-plus'
// ==================== 页面组件导入 ====================
// 公开页面(无需登录)
const Home = () => import('@/views/Home.vue')
const Login = () => import('@/views/Login.vue')
const Activate = () => import('@/views/Activate.vue')
const ForgotPassword = () => import('@/views/ForgotPassword.vue')
const NewsDetail = () => import('@/views/NewsDetail.vue')
// 需要登录的页面
const Dashboard = () => import('@/views/Dashboard.vue')
const Competition = () => import('@/views/Competition.vue')
const MockExam = () => import('@/views/MockExam.vue')
const NmeaParser = () => import('@/views/NmeaParser.vue')
const AtSimulator = () => import('@/views/AtSimulator.vue')
const Upload = () => import('@/views/Upload.vue')
const Materials = () => import('@/views/Materials.vue')
const Score = () => import('@/views/Score.vue')
// 管理员页面
const Admin = () => import('@/views/Admin.vue')
// 404 页面
const NotFound = () => import('@/views/NotFound.vue')
// ==================== 路由配置 ====================
const routes = [
// 首页(公开访问)
{
path: '/',
name: 'Home',
component: Home,
},
// 登录页(公开访问)
{
path: '/login',
name: 'Login',
component: Login,
},
// 激活账号页(公开访问)
{
path: '/activate',
name: 'Activate',
component: Activate,
},
// 忘记密码页(公开访问)
{
path: '/forgot-password',
name: 'ForgotPassword',
component: ForgotPassword,
},
// 新闻详情页(公开访问)
{
path: '/news/:id',
name: 'NewsDetail',
component: NewsDetail,
},
// ==================== 需要登录的路由 ====================
// 参赛者仪表盘
{
path: '/dashboard',
name: 'Dashboard',
component: Dashboard,
meta: { requiresAuth: true },
},
// 赛事信息页
{
path: '/competition',
name: 'Competition',
component: Competition,
meta: { requiresAuth: true },
},
// 模拟考试页(仅中职/高职组)
{
path: '/mock-exam',
name: 'MockExam',
component: MockExam,
meta: { requiresAuth: true },
},
// NMEA-0183 协议解析工具(中职组实操)
{
path: '/nmea-parser',
name: 'NmeaParser',
component: NmeaParser,
meta: { requiresAuth: true },
},
// AT 命令模拟器(高职组实操)
{
path: '/at-simulator',
name: 'AtSimulator',
component: AtSimulator,
meta: { requiresAuth: true },
},
// 作品上传页
{
path: '/upload',
name: 'Upload',
component: Upload,
meta: { requiresAuth: true },
},
// 资料下载页
{
path: '/materials',
name: 'Materials',
component: Materials,
meta: { requiresAuth: true },
},
// 成绩查询页
{
path: '/score',
name: 'Score',
component: Score,
meta: { requiresAuth: true },
},
// ==================== 管理员路由 ====================
// 管理后台(需要管理员权限)
{
path: '/admin',
name: 'Admin',
component: Admin,
meta: { requiresAuth: true, requiresAdmin: true },
},
// ==================== 404 兜底 ====================
// 所有未匹配的路径重定向到 404
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: NotFound,
},
]
// ==================== 创建路由实例 ====================
const router = createRouter({
// 使用 HTML5 History 模式
history: createWebHistory(),
routes,
// 路由切换时滚动到页面顶部
scrollBehavior() {
return { top: 0 }
},
})
// ==================== 全局前置守卫 ====================
router.beforeEach((to, from, next) => {
// 从 localStorage 读取 token,判断是否已登录
const token = localStorage.getItem('token')
const isLoggedIn = !!token
// 1. 检查是否需要登录权限
if (to.matched.some((record) => record.meta.requiresAuth)) {
// 需要登录但未登录,重定向到登录页
if (!isLoggedIn) {
next({
path: '/login',
// 保留原目标路径,登录成功后跳转回去
query: { redirect: to.fullPath },
})
return
}
// 注:token 过期不再在导航阶段拦截。过期的 token 会由请求拦截器
// 通过 /api/auth/refresh 静默换发新令牌并重试原请求,用户无感知,
// 不会被强制踢回登录页(仅当刷新也失败才跳转登录)。
// 2. 检查是否需要管理员权限
if (to.matched.some((record) => record.meta.requiresAdmin)) {
// 从 localStorage 读取用户角色
const userStr = localStorage.getItem('user')
let isAdmin = false
if (userStr) {
try {
const user = JSON.parse(userStr)
// 检查角色是否为管理员(role 字段或在 admins 数组中)
isAdmin = user.role === 'admin' || user.isAdmin === true
} catch (e) {
isAdmin = false
}
}
// 非管理员访问管理后台,重定向到仪表盘
if (!isAdmin) {
next({ path: '/dashboard' })
return
}
}
}
// 3. 已登录用户访问登录页,根据角色重定向到对应工作台
if (isLoggedIn && to.path === '/login') {
// 从 localStorage 读取用户角色
const userStr = localStorage.getItem('user')
let userRole = null
if (userStr) {
try {
const user = JSON.parse(userStr)
userRole = user.role
} catch (e) {
userRole = null
}
}
// 管理员 → 管理后台,选手 → 参赛者仪表盘
if (userRole === 'admin') {
next({ path: '/admin' })
} else {
next({ path: '/dashboard' })
}
return
}
// 放行
next()
})
export default router

156
src/stores/auth.js Normal file
View File

@@ -0,0 +1,156 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import * as authApi from '@/api/auth'
// ==================== 认证状态管理 ====================
export const useAuthStore = defineStore('auth', () => {
// ==================== 状态 ====================
// 当前用户信息
const currentUser = ref(
JSON.parse(localStorage.getItem('user') || 'null')
)
// 认证令牌
const token = ref(localStorage.getItem('token') || '')
// ==================== 计算属性(Getters) ====================
// 是否已登录
const isLoggedIn = computed(() => !!token.value)
// 是否为管理员
const isAdmin = computed(() => {
if (!currentUser.value) return false
return (
currentUser.value.role === 'admin' ||
currentUser.value.isAdmin === true
)
})
// 用户名称(优先显示姓名,其次用户名)
const userName = computed(() => {
if (!currentUser.value) return ''
return currentUser.value.name || currentUser.value.username || ''
})
// 用户所属组别
const userGroup = computed(() => {
return currentUser.value?.group || ''
})
// ==================== 私有辅助方法 ====================
/**
* 持久化存储 token 和用户信息到 localStorage
*/
function persistAuth(tokenValue, userData) {
token.value = tokenValue
currentUser.value = userData
localStorage.setItem('token', tokenValue)
localStorage.setItem('user', JSON.stringify(userData))
}
/**
* 清除本地存储的认证信息
*/
function clearAuth() {
token.value = ''
currentUser.value = null
localStorage.removeItem('token')
localStorage.removeItem('user')
}
// ==================== 操作方法(Actions) ====================
/**
* 参赛者登录(身份证号 + 密码)
* @param {string} idNumber - 身份证号
* @param {string} password - 密码
* @returns {Promise<object>} 用户信息
*/
async function login(phone, password) {
const result = await authApi.login(phone, password)
// 后端应返回 { token, user }
persistAuth(result.token, result.user)
return result.user
}
/**
* 管理员登录
* @param {string} username - 管理员用户名
* @param {string} password - 密码
* @returns {Promise<object>} 管理员信息
*/
async function adminLogin(username, password) {
const result = await authApi.adminLogin(username, password)
// 后端应返回 { token, user }
persistAuth(result.token, result.user)
return result.user
}
/**
* 激活账号(设置密码)
* @param {object} data - { phone, password, smsCode }
* @returns {Promise}
*/
async function activate(data) {
const result = await authApi.activate(data)
// 激活成功后自动登录
if (result.token && result.user) {
persistAuth(result.token, result.user)
}
return result
}
/**
* 退出登录
*/
async function logout() {
try {
// 尝试通知后端登出
await authApi.logout()
} catch (e) {
// 即使后端请求失败,也清除本地状态
console.warn('登出请求失败,仍将清除本地状态', e)
}
clearAuth()
// 跳转到登录页
window.location.href = '/login'
}
/**
* 从服务端获取最新用户信息
* @returns {Promise<object>} 用户信息
*/
async function fetchCurrentUser() {
const user = await authApi.getCurrentUser()
currentUser.value = user
localStorage.setItem('user', JSON.stringify(user))
return user
}
// ==================== 导出 ====================
return {
// 状态
currentUser,
token,
// 计算属性
isLoggedIn,
isAdmin,
userName,
userGroup,
// 操作
login,
adminLogin,
activate,
logout,
fetchCurrentUser,
// 工具方法
clearAuth,
persistAuth,
}
})

295
src/stores/exam.js Normal file
View File

@@ -0,0 +1,295 @@
import { ref, computed, reactive } from 'vue'
import * as examApi from '@/api/exam'
import * as uploadApi from '@/api/upload'
// ==================== 考试状态管理 ====================
const currentGroup = ref('')
const questions = ref([])
const answers = ref({})
const countdown = ref(0)
const examStarted = ref(false)
const wordTemplate = ref(null)
const wordUploaded = ref(false)
const totalQuestions = ref(0)
// 考试 session 标识(防止返回重新进入)
let examSessionId = ''
let timerInterval = null
let resyncInterval = null
let finalCheckInFlight = false
// ==================== 计算属性 ====================
const countdownDisplay = computed(() => {
const total = countdown.value
if (total <= 0) return '00:00'
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
if (h > 0) return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
})
const isTimeUp = computed(() => countdown.value <= 0 && examStarted.value)
const progress = computed(() => {
if (questions.value.length === 0) return 0
return Math.round((Object.keys(answers.value).length / questions.value.length) * 100)
})
const choiceQuestions = computed(() => questions.value.filter(q => q.question_type === 'choice'))
const judgeQuestions = computed(() => questions.value.filter(q => q.question_type === 'judge'))
// ==================== 内部函数 ====================
function parseOptions(q) {
if (!q) return q
// options 可能是 JSON 字符串(TEXT 列),需要解析
if (typeof q.options === 'string') {
try { q.options = JSON.parse(q.options) } catch (e) { q.options = [] }
}
if (!Array.isArray(q.options)) q.options = []
// 后端已返回全局唯一的 question_uid(题型_自增id,如 choice_12 / judge_3)。
// 这里仅作兜底:若后端未给,则按「题型_id」重建,避免 choice#1 与 judge#1 因纯数字 id 相同而键碰撞。
if (q.id !== undefined && (q.question_uid === undefined || q.question_uid === null)) {
q.question_uid = (q.question_type ? q.question_type + '_' : '') + q.id
}
return q
}
/** 与服务器对时:返回考试/提交状态(网络异常返回 null,继续本地倒计时兜底) */
async function fetchServerStatus() {
if (!currentGroup.value) return null
try {
// 提交组(本科/教师)的剩余时间来自上传截止时间(GET /api/upload/:group/status 的 deadline),
// 与考试组(中职/高职)的 GET /api/exam/:group/status 不同口径,需按组别分支。
if (['benke', 'jiaoshi'].includes(currentGroup.value)) {
const up = await uploadApi.getUploadStatus(currentGroup.value)
if (up && up.deadline) {
const deadline = new Date(up.deadline).getTime()
const cd = Math.max(0, Math.floor((deadline - Date.now()) / 1000))
return { status: cd > 0 ? 'in_progress' : 'ended', countdown: cd }
}
return null
}
const st = await examApi.getExamStatus(currentGroup.value)
if (st && st.countdown !== undefined) return st
} catch (e) { /* 网络异常时忽略,本地倒计时继续 */ }
return null
}
function startTimer() {
if (timerInterval) clearInterval(timerInterval)
if (resyncInterval) clearInterval(resyncInterval)
timerInterval = setInterval(async () => {
if (countdown.value > 1) {
countdown.value--
} else if (!finalCheckInFlight) {
// 即将归零:以服务器为最终权威再确认一次,
// 防止管理员中途延长 end_time 后客户端仍按旧倒计时提前自动交卷
finalCheckInFlight = true
const st = await fetchServerStatus()
finalCheckInFlight = false
if (st && st.status === 'in_progress' && st.countdown > 0) {
countdown.value = st.countdown // 考试时间被延长,继续作答
} else {
countdown.value = 0 // 服务器确认已结束(或网络不可达按本地时间兜底)
clearInterval(timerInterval)
timerInterval = null
if (resyncInterval) { clearInterval(resyncInterval); resyncInterval = null }
}
}
}, 1000)
// 周期对时(30s):吸收本地计时漂移,并及时感知管理员延长/缩短考试时间
resyncInterval = setInterval(async () => {
const st = await fetchServerStatus()
if (st && st.status === 'in_progress' && typeof st.countdown === 'number') {
countdown.value = st.countdown
} else if (st && st.status === 'ended') {
countdown.value = 0
}
}, 30000)
}
function stopTimer() {
if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
}
if (resyncInterval) {
clearInterval(resyncInterval)
resyncInterval = null
}
}
// ==================== 公开方法 ====================
async function startExam(group) {
stopExam()
// 生成唯一的考试 session ID
examSessionId = group + '_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8)
sessionStorage.setItem('exam_session', examSessionId)
currentGroup.value = group
const [status, questionList] = await Promise.allSettled([
examApi.getExamStatus(group),
examApi.getQuestions(group),
])
if (status.status === 'fulfilled' && status.value) {
const stateData = status.value
if (stateData.countdown !== undefined) {
countdown.value = stateData.countdown
}
}
if (questionList.status === 'fulfilled' && questionList.value) {
// 解析 options(可能是 JSON 字符串)
// allSettled 包装后,value 是整个 response.data,再嵌套一层 data
const responseData = questionList.value
const rawQuestions = responseData.questions || []
questions.value = rawQuestions.map(q => parseOptions(q))
// 记录本次出题数量,提交时回传后端作为"应答题基准",避免与抽题数不一致
totalQuestions.value = responseData.count || rawQuestions.length
}
wordTemplate.value = { group }
examStarted.value = true
startTimer()
}
/**
* 启动提交组(本科/教师)倒计时:从上传截止时间推算剩余秒,复用同一套带 30s 对时、
* 归零最终校验的定时器。提交组无客观题,故不拉取 questions。
*/
async function startUploadCountdown(group) {
stopExam()
examSessionId = group + '_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8)
sessionStorage.setItem('exam_session', examSessionId)
currentGroup.value = group
try {
const up = await uploadApi.getUploadStatus(group)
if (up && up.deadline) {
const deadline = new Date(up.deadline).getTime()
countdown.value = Math.max(0, Math.floor((deadline - Date.now()) / 1000))
}
} catch (e) { /* 获取截止时间失败则保持 0,由对时兜底 */ }
examStarted.value = true
startTimer()
}
function stopExam() {
stopTimer()
examStarted.value = false
sessionStorage.removeItem('exam_session')
}
/**
* 从 question_uid 推导题型(后端校验强制要求合法 question_type)。
* uid 全局唯一化后形如 'choice_683' / 'judge_3',前缀即题型;
* 兜底再查 questions 列表(恢复会话等场景 questions 可能为空,前缀推导仍有效)。
*/
function typeOfUid(questionUid) {
const uid = String(questionUid)
if (uid.startsWith('choice')) return 'choice'
if (uid.startsWith('judge')) return 'judge'
const q = questions.value.find(x => String(x.question_uid) === uid)
return q ? q.question_type : undefined
}
async function submitAnswer(questionId, answer) {
// 本地先保存答案
answers.value = { ...answers.value, [questionId]: answer }
// 异步提交到服务器(必须带 question_type,否则后端校验直接拒绝:
// 「答案格式错误,需要合法的 question_type(choice/judge)」)
try {
await examApi.submitAnswers(currentGroup.value, [
{ question_uid: questionId, question_type: typeOfUid(questionId), answer_data: answer },
])
} catch (e) {
// 静默失败,不弹窗打扰用户答题
console.warn('提交答案失败', e)
}
}
async function submitAll(additionalData = {}) {
// 每条答案都必须带合法 question_type:优先用 uid 前缀推导(questions 为空
// 的恢复会话场景依然可靠),否则后端校验拒绝整卷提交。
const answersArray = Object.entries(answers.value).map(([questionUid, answer]) => ({
question_uid: questionUid,
question_type: typeOfUid(questionUid),
answer_data: answer,
}))
const data = {
answers: answersArray,
totalQuestions: totalQuestions.value,
...additionalData,
}
const result = await examApi.submitAll(currentGroup.value, data)
stopExam()
return result
}
function resetExam() {
stopTimer()
questions.value = []
answers.value = {}
countdown.value = 0
examStarted.value = false
wordTemplate.value = null
wordUploaded.value = false
currentGroup.value = ''
examSessionId = ''
sessionStorage.removeItem('exam_session')
}
/** 检查当前 session 是否有效 */
function isSessionValid() {
const saved = sessionStorage.getItem('exam_session')
return saved === examSessionId && examSessionId !== ''
}
/** 恢复已有答案(考试重新进入时使用) */
function restoreAnswers(restoredAnswers) {
answers.value = { ...restoredAnswers }
}
export function useExamStore() {
return reactive({
// 状态
currentGroup,
questions,
answers,
countdown,
examStarted,
wordTemplate,
wordUploaded,
totalQuestions,
// 计算属性
countdownDisplay,
isTimeUp,
progress,
choiceQuestions,
judgeQuestions,
// 方法
startExam,
startUploadCountdown,
stopExam,
submitAnswer,
submitAll,
resetExam,
isSessionValid,
restoreAnswers,
})
}

77
src/stores/news.js Normal file
View File

@@ -0,0 +1,77 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as newsApi from '@/api/news'
// ==================== 新闻资讯状态管理 ====================
export const useNewsStore = defineStore('news', () => {
// ==================== 状态 ====================
// 新闻列表数据
const newsList = ref([])
// 当前新闻分组/分类
const currentGroup = ref('all')
// 分页信息
const pagination = ref({
page: 1,
pageSize: 10,
total: 0,
})
// 加载状态
const loading = ref(false)
// ==================== 操作方法(Actions) ====================
/**
* 获取新闻列表
* @param {object} params - 查询参数 { page, pageSize, keyword, category }
*/
async function fetchNews(params = {}) {
loading.value = true
try {
const queryParams = {
page: pagination.value.page,
pageSize: pagination.value.pageSize,
...params,
}
const result = await newsApi.getNewsList(queryParams)
// 根据返回结构更新数据
if (result && result.list) {
newsList.value = result.list
pagination.value.total = result.total || 0
pagination.value.page = result.page || pagination.value.page
pagination.value.pageSize = result.pageSize || pagination.value.pageSize
} else if (Array.isArray(result)) {
// 如果直接返回数组
newsList.value = result
pagination.value.total = result.length
}
if (params?.category) {
currentGroup.value = params.category
}
} catch (e) {
console.warn('获取新闻列表失败', e)
newsList.value = []
} finally {
loading.value = false
}
}
// ==================== 导出 ====================
return {
// 状态
newsList,
currentGroup,
pagination,
loading,
// 操作
fetchNews,
}
})

377
src/styles/global.css Normal file
View File

@@ -0,0 +1,377 @@
/* ===========================================================
全局样式文件 (global.css)
定义 CSS 变量、基础重置样式、通用工具类
适用于整个竞赛报名系统前端项目
=========================================================== */
/* ============ CSS 变量定义 ============ */
:root {
/* 主色调:深蓝色,用于导航栏、按钮等关键元素 */
--primary-color: #1a3a5c;
/* 辅助色:金色,用于强调、高亮、获奖标识等 */
--accent-color: #c9a84c;
/* 背景色:浅灰,用于页面整体背景 */
--bg-color: #f5f6f8;
/* 文字主色 */
--text-color: #303133;
/* 文字辅助色 */
--text-secondary: #909399;
/* 边框色 */
--border-color: #dcdfe6;
/* 成功色 */
--success-color: #67c23a;
/* 警告色 */
--warning-color: #e6a23c;
/* 危险色 */
--danger-color: #f56c6c;
/* 信息色 */
--info-color: #909399;
/* 中文字体优先级 */
--font-family: 'Noto Sans CJK SC', 'WenQuanYi Micro Hei', 'Microsoft YaHei',
sans-serif;
/* 基础行高 */
--line-height: 1.6;
/* Element Plus 主题色变量(防止 getThemeColors 解构 undefined 报错) */
--el-color-primary: #1a3a5c;
--el-color-primary-light-3: #4a6a8c;
--el-color-primary-light-5: #7a9abc;
--el-color-primary-light-7: #aacadc;
--el-color-primary-light-8: #c5dae8;
--el-color-primary-light-9: #e0eaf4;
--el-color-primary-dark-2: #152e4a;
--el-color-success: #67c23a;
--el-color-warning: #e6a23c;
--el-color-danger: #f56c6c;
--el-color-error: #f56c6c;
--el-color-info: #909399;
--el-color-white: #ffffff;
--el-color-black: #000000;
--el-bg-color: #ffffff;
--el-bg-color-page: #f5f6f8;
--el-bg-color-overlay: #ffffff;
--el-text-color-primary: #303133;
--el-text-color-regular: #606266;
--el-text-color-secondary: #909399;
--el-text-color-placeholder: #a8abb2;
--el-border-color: #dcdfe6;
--el-border-color-light: #e4e7ed;
--el-border-color-lighter: #ebeef5;
--el-border-color-extra-light: #f2f6fc;
--el-fill-color: #f0f2f5;
--el-fill-color-light: #f5f7fa;
--el-fill-color-blank: #ffffff;
}
/* ============ 基础重置 ============ */
/* 全局盒模型重置:所有元素使用 border-box 计算宽高 */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* HTML 根元素设置 */
html {
/* 平滑滚动效果 */
scroll-behavior: smooth;
/* 防止 iOS 弹性滚动导致的问题 */
-webkit-overflow-scrolling: touch;
}
/* 页面主体样式 */
body {
/* 字体 */
font-family: var(--font-family);
/* 字号 */
font-size: 14px;
/* 字体颜色 */
color: var(--text-color);
/* 背景色 */
background-color: var(--bg-color);
/* 行高 */
line-height: var(--line-height);
/* 文本渲染优化 */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* 最小宽度 */
min-width: 320px;
}
/* 链接默认样式重置 */
a {
color: var(--primary-color);
text-decoration: none;
}
a:hover {
color: var(--accent-color);
}
/* 图片默认样式 */
img {
max-width: 100%;
height: auto;
/* 去除图片底部间隙 */
vertical-align: middle;
}
/* 列表样式重置 */
ul,
ol {
list-style: none;
}
/* 表单元素字体继承 */
input,
button,
textarea,
select {
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
/* 清除 input 自动填充的背景色 */
input:-webkit-autofill {
-webkit-box-shadow: 0 0 0 1000px white inset !important;
}
/* ============ 通用工具类 ============ */
/* 文字居中对齐 */
.text-center {
text-align: center;
}
/* 文字左对齐 */
.text-left {
text-align: left;
}
/* 文字右对齐 */
.text-right {
text-align: right;
}
/* 外边距工具类 - 顶部 */
.mt-10 {
margin-top: 10px;
}
.mt-20 {
margin-top: 20px;
}
.mt-30 {
margin-top: 30px;
}
/* 外边距工具类 - 底部 */
.mb-10 {
margin-bottom: 10px;
}
.mb-20 {
margin-bottom: 20px;
}
.mb-30 {
margin-bottom: 30px;
}
/* 外边距工具类 - 左侧 */
.ml-10 {
margin-left: 10px;
}
.ml-20 {
margin-left: 20px;
}
/* 外边距工具类 - 右侧 */
.mr-10 {
margin-right: 10px;
}
.mr-20 {
margin-right: 20px;
}
/* 内边距工具类 */
.p-10 {
padding: 10px;
}
.p-20 {
padding: 20px;
}
.p-30 {
padding: 30px;
}
/* 弹性布局容器:水平排列 */
.flex {
display: flex;
}
/* 弹性布局 + 水平垂直居中 */
.flex-center {
display: flex;
justify-content: center;
align-items: center;
}
/* 弹性布局 + 两端对齐(垂直居中) */
.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
/* 弹性布局 + 垂直方向 */
.flex-column {
display: flex;
flex-direction: column;
}
/* 弹性布局列居中 */
.flex-column-center {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
/* 文字溢出省略(单行) */
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 宽度工具类 */
.w-full {
width: 100%;
}
/* 隐藏元素 */
.hidden {
display: none;
}
/* 卡片容器通用样式 */
.card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
padding: 20px;
}
/* 页面标题通用样式 */
.page-title {
font-size: 24px;
font-weight: 600;
color: var(--primary-color);
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 2px solid var(--accent-color);
}
/* 内容区域包装器 */
.content-wrapper {
max-width: 1200px;
margin: 0 auto;
}
/* ============ 滚动条美化 ============ */
/* 整个滚动条 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
/* 滚动条轨道 */
::-webkit-scrollbar-track {
background: transparent;
}
/* 滚动条滑块 */
::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.15);
border-radius: 3px;
}
/* 滚动条滑块悬停 */
::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.25);
}
/* ============ Element Plus 图标+文字按钮居中对齐兜底 ============
说明:Element Plus 会把按钮 slot 包进 .el-button__content 并设
display:inline-flex;align-items:center,按规范图标与文字应垂直居中。
但本项目的全局重置 button{line-height:inherit} 与部分组件 CSS 加载
顺序可能导致个别环境下内部容器未正确居中(图标回退 baseline 对齐、
文字显得偏高或偏左)。此处用 !important 强制按钮内部内容严格居中,
并令 .el-button__content 占满按钮宽度,使 justify-content 真正生效。
覆盖范围:全项目所有 .el-button(含 el-link 型“返回”链接)。 */
.el-button,
.el-button > span,
.el-button .el-button__content {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
}
.el-button .el-button__content {
width: 100% !important;
}
.el-button .el-icon {
vertical-align: middle !important;
flex-shrink: 0 !important;
}
.el-button .el-icon + span {
margin-left: 6px !important;
}
.el-link,
.el-link > span,
.el-link .el-link__inner {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
}
/* ===========================================================
文字型按钮 hover 态对比度保障(全局,根治「白底白字」)
-----------------------------------------------------------
根因(已取证):Element Plus 的规则为
element-plus/dist/index.css:
.el-button.is-text:not(.is-disabled):hover{background-color:var(--el-fill-color-light)}
--el-fill-color-light:#f5f7fa ← 不透明的「近白」底
该选择器有 4 个类级特异性,高于页面内 .back-btn:hover
(1 个类 + scoped [data-v-x] = 2 个类级),因此必然胜出,导致两类不可读:
① 页面自定义的「深色底 + 白字」hover:深色底被近白底压掉,
白字留下 → 白底白字,完全不可读;
已复现:选手工作台 Dashboard.vue「返回首页」。
② 深色页头上的白字文字按钮(未自定义 hover 颜色):
hover 时铺上近白底,白字同样不可读;
已复现:管理后台 Admin.vue 顶栏「返回首页」(inline color 白色、无 hover 覆盖)。
全局对策:把「不透明浅色填充」换成「半透明中性底」。
· 半透明 → 深色页头上呈轻微变亮、浅色页面上呈轻微变暗,
始终与所在背景同色系,不会凭空造出与文字同色的实底;
· 中性灰蓝 → 不引入新色相,不干扰各页面既有配色;
· 仍保留清晰的 hover 反馈,不牺牲可感知性。
本规则只改背景、不动文字颜色,故不会影响任何页面的文字配色设计。
新增页面自动继承此保障,无需逐页排查。
=========================================================== */
.el-button.is-text:not(.is-disabled):hover,
.el-button.is-text:not(.is-disabled):focus {
background-color: rgba(120, 130, 145, 0.16);
}

87
src/utils/antiSpam.js Normal file
View File

@@ -0,0 +1,87 @@
// ==================== 按钮防抖 / 节流 / 防重入 ====================
// 背景(生产事故 2026-08-10):作品管理狂点「下载」会瞬间发起大量并发 blob 请求,
// 占满浏览器同域连接池(约 6 条),导致「获取账号列表」等其它请求排队 30s 超时。
// 除下载层信号量(见 utils/download.js)外,所有触发网络请求的按钮都应做
// 「防重入 + 节流」,避免同一次用户意图被重复执行。
//
// 用法:
// import { createClickGuard } from '@/utils/antiSpam'
// const guarded = createClickGuard(rawHandler, { minInterval: 600 })
// <el-button v-guard="() => downloadWork(row, f)" /> // 见 main.js 全局指令
/**
* 防抖:连续触发只在最后一次等待 wait 后执行(immediate=true 则首次立即执行)。
*/
export function debounce(fn, wait = 300, immediate = false) {
let timer = null
return function debounced(...args) {
const callNow = immediate && !timer
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
timer = null
if (!immediate) fn.apply(this, args)
}, wait)
if (callNow) fn.apply(this, args)
}
}
/**
* 节流:限制 fn 在 limit 毫秒内最多执行一次(首尾都尽量触发)。
*/
export function throttle(fn, limit = 500) {
let last = 0
let timer = null
return function throttled(...args) {
const now = Date.now()
const remaining = limit - (now - last)
if (remaining <= 0) {
if (timer) { clearTimeout(timer); timer = null }
last = now
fn.apply(this, args)
} else if (!timer) {
timer = setTimeout(() => {
last = Date.now()
timer = null
fn.apply(this, args)
}, remaining)
}
}
}
/**
* 点击守卫:包裹一个处理函数,提供「防重入 + 节流」双保险。
* - lock(默认开):上一次执行尚未结束(Promise 未 settle)时,忽略后续点击,
* 杜绝「一次点击被当成多次」或「并发重入」。
* - minInterval:两次执行最小间隔(毫秒),过频的连点被忽略(节流)。
* 适配同步与异步(Promise)处理函数。
*
* @param {Function} fn 原始点击处理函数
* @param {{minInterval?:number, lock?:boolean}} [opts]
* @returns {Function}
*/
export function createClickGuard(fn, opts = {}) {
const { minInterval = 500, lock = true } = opts
let lastRun = 0
let running = false
return function guarded(...args) {
const now = Date.now()
if (lock && running) return // 上一次还在执行 → 直接忽略
if (now - lastRun < minInterval) return // 太频繁 → 节流忽略
lastRun = now
if (lock) running = true
let ret
try {
ret = fn.apply(this, args)
} catch (e) {
ret = Promise.reject(e)
}
if (ret && typeof ret.then === 'function') {
return ret.finally(() => { running = false })
}
running = false
return ret
}
}

294
src/utils/download.js Normal file
View File

@@ -0,0 +1,294 @@
import axios from 'axios'
import { ElMessage, ElLoading } from 'element-plus'
// ==================== 安全文件下载封装 ====================
// 背景:后端业务错误信封统一为 HTTP 200 + JSON({ code, message })。
// 前端若用 responseType:'blob' 下载且不做校验,会把「文件缺失/服务端错误」的
// JSON 直接存成 .docx/.zip/.csv,导致用户打开时提示「文件已损坏/无法打开」。
//
// 本封装统一解决:
// 1. 使用独立 axios 实例,不挂全局响应拦截器,可直接拿到 response.status / headers;
// 2. 自行判断成功/失败(validateStatus 恒 true),不被 axios 把 4xx/5xx 当异常吞掉;
// 3. 即便后端误返回 200 + JSON,也依靠 content-type 识别并抛出业务错误,绝不落盘。
const dl = axios.create({
baseURL: '/api',
timeout: 300000,
withCredentials: false,
validateStatus: () => true,
})
dl.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// ==================== 并发控制(根治连接池耗尽) ====================
// 背景:浏览器对同域并发请求有上限(约 6 条)。狂点「下载」会在极短时间内发起大量
// 流式 blob 请求,占满连接池,导致「获取账号列表」等其它请求排队直至 30s 超时。
// 这里用模块级信号量把并发下载限制到 MAX_CONCURRENT_DOWNLOADS,并对同一 URL 去重
// (进行中的下载忽略重复点击),从源头掐掉连接池被挤爆的可能。
const MAX_CONCURRENT_DOWNLOADS = 3
let _activeDownloads = 0
const _downloadWaitQueue = []
function _acquireDownloadSlot() {
return new Promise((resolve) => {
if (_activeDownloads < MAX_CONCURRENT_DOWNLOADS) {
_activeDownloads++
resolve()
} else {
_downloadWaitQueue.push(resolve)
}
})
}
function _releaseDownloadSlot() {
_activeDownloads--
if (_downloadWaitQueue.length) {
_activeDownloads++
_downloadWaitQueue.shift()()
}
}
// 同一 URL 进行中的下载记录:重复点击直接忽略,避免连点产生多份下载流
const _inFlightDownloads = new Set()
/**
* 受控 GET:在信号量许可内发请求,并对同 URL 去重。
* @returns {Promise<import('axios').AxiosResponse>}
*/
async function guardedGet(url, timeout, onProgress) {
const norm = normalizeUrl(url)
if (_inFlightDownloads.has(norm)) {
// 同一文件正在下载,忽略这次重复点击(不抛错、不占连接)
throw new Error('__DUP__')
}
_inFlightDownloads.add(norm)
try {
await _acquireDownloadSlot()
try {
return await dl.get(norm, {
responseType: 'blob',
timeout,
// 透传下载进度(1M 带宽下大文件耗时久,让调用方有反馈,消除「无反应」错觉)
onDownloadProgress: onProgress,
})
} finally {
_releaseDownloadSlot()
}
} finally {
_inFlightDownloads.delete(norm)
}
}
function getHeader(headers, name) {
if (!headers) return ''
const lower = name.toLowerCase()
// axios 小写化后的键
if (headers[lower]) return headers[lower]
// 兜底:遍历一次(极少数环境下大小写保留)
for (const k of Object.keys(headers)) {
if (k.toLowerCase() === lower) return headers[k]
}
return ''
}
// 归一化路径:dl 实例已带 baseURL '/api',若调用方传入 '/api/xxx' 会重复前缀。
function normalizeUrl(url) {
if (url && url.startsWith('/api')) return url.slice(4)
return url
}
function extractFilename(disposition, fallback) {
if (!disposition) return fallback
// 百分号解码(失败时回退原串,避免把合法字面量 % 也破坏)
const safeDecode = (s) => {
try { return decodeURIComponent(s) } catch (e) { return s }
}
// 1) 优先 RFC 5987: filename*=UTF-8''<百分号编码值>
const star = disposition.match(/filename\*=(?:UTF-8'')?([^;]+)/i)
if (star) {
const v = star[1].trim().replace(/^["']|["']$/g, '')
const p = v.indexOf("''") // 去掉可能的 charset'' 前缀
return safeDecode(p !== -1 ? v.slice(p + 2) : v)
}
// 2) 兜底 filename="<可能被百分号编码的中文名>"(如后端 encodeURIComponent 后写入)
const plain = disposition.match(/filename="?([^";]+)"?/i)
if (plain) return safeDecode(plain[1].trim())
return fallback
}
function triggerDownload(blob, filename, originalUrl) {
const blobUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = blobUrl
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
// 跨设备兜底:部分移动端浏览器(如 iOS Safari)会静默忽略「blob + a.download」,
// 导致点击下载毫无反应。此时改用浏览器原生导航下载——后端已支持 ?token= 回退鉴权。
// 该分支在用户手势同步调用链内执行,不会被浏览器弹窗拦截。
const supportsDownload = 'download' in document.createElement('a')
const ua = navigator.userAgent || ''
const isIOS = /iP(ad|hone|od)/.test(ua)
if ((!supportsDownload || isIOS) && originalUrl) {
const sep = originalUrl.includes('?') ? '&' : '?'
const token = localStorage.getItem('token') || ''
window.open(originalUrl + sep + 'token=' + encodeURIComponent(token), '_blank')
}
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000)
}
// ==================== 内容真实性校验(防「假成功」落盘) ====================
// 背景(2026-08-12 生产事故):管理后台下载考生上传的大文件,得到一个 ≈2KB 的文件,
// 打开提示「文件不可用 / 已损坏 / 内容为空」;而备赛资料下载完全正常。
// 根因:作品/答案下载走后端 _sendFile 的 X-Accel-Redirect 分支(由 nginx 直接吐文件),
// 生产 nginx 缺少 /internal-downloads/ 内部 location(该配置是后加的、未 reload),
// 内部跳转落到 SPA 回退 `try_files $uri $uri/ /index.html`,nginx 把前端
// index.html(≈2140 字节 HTML)当文件吐回,且仍带着后端设置的
// Content-Disposition + office Content-Type ——
// 原有「HTTP 状态 + JSON 信封」两道校验对 HTML 完全无感,于是损坏文件被静默存盘。
// 对策(两道,任一命中即拒绝落盘并弹出可行动的错误):
// ① HTML 嗅探:Content-Type 为 text/html,或正文以 <!DOCTYPE html / <html 开头;
// ② 大小哨兵:后端透出 X-File-Size(服务端 stat 的真实字节数),与 blob.size 比对,
// 不一致即说明内容被替换或传输被截断。该头缺失时跳过此校验(优雅降级,不误伤)。
async function assertRealFile(res) {
const blob = res.data
if (!blob) return '下载失败:响应内容为空'
// ① HTML 错误页 / SPA 回退页
const ct = getHeader(res.headers, 'content-type')
const looksHtmlByCt = ct.includes('text/html')
let looksHtmlByMagic = false
try {
const head = await blob.slice(0, 256).text()
looksHtmlByMagic = /^\s*(<!doctype\s+html|<html[\s>])/i.test(head)
} catch (e) {
/* 读取头部失败不阻断正常下载 */
}
if (looksHtmlByCt || looksHtmlByMagic) {
return '服务器返回的是网页而非文件,下载通道配置异常(请联系管理员检查 nginx 的内部下载 location 是否已生效)'
}
// ② 大小哨兵:与服务端 stat 的真实字节数比对
const declared = parseInt(getHeader(res.headers, 'x-file-size'), 10)
if (Number.isFinite(declared) && declared > 0 && blob.size !== declared) {
return `文件传输不完整:应为 ${declared} 字节,实际收到 ${blob.size} 字节,请重试`
}
return null
}
async function blobToMessage(blob) {
try {
const text = await blob.text()
// 🔴 防御纵深:若响应体是 HTML(理论上 assertRealFile 已在步骤 0 拦截,这里兜底),
// 绝不直接回显标签,避免弹窗泄漏 <html>…nginx… 原文。
if (/^\s*<(!doctype\s+html|<html[\s>])/i.test(text)) {
return '服务器返回了网页而非文件,下载通道配置异常(请联系管理员检查 nginx 内部下载 location 是否已生效)'
}
try {
const j = JSON.parse(text)
if (j && j.message) return j.message
} catch (e) {
/* 非 JSON,回退原文前 200 字 */
}
return text.slice(0, 200) || '下载失败'
} catch (e) {
return '下载失败'
}
}
/**
* 安全下载:校验 HTTP 状态 + 响应类型,绝不让错误 JSON 被存成文件。
* @param {string} url 相对 /api 的路径(可带或不带 /api 前缀,实例已带 baseURL)
* @param {object} opts { fallbackName, timeout, onError, onSuccess }
* @returns {Promise<boolean>} 是否成功触发了下载
*/
export async function safeDownload(url, opts = {}) {
// 🔴 带宽适配:生产服务器仅 1M 带宽(≈125KB/s),120s 仅能传 ~15MB。
// 作品/答案文件常达数十 MB,原 120000 超时必然触发「timeout of 120000ms exceeded」。
// 改为不超时(0),由 nginx proxy_read_timeout(已放大到 600s)与用户耐心兜底;调用方仍可按需覆盖。
const { fallbackName = 'download', timeout = 0, onError, onSuccess, onProgress } = opts
const fail = (msg) => { if (onError) onError(msg); else ElMessage.error(msg); return false }
// 提前去重提示:同一文件正在下载时,给用户明确反馈,避免「点击无反应」错觉
if (_inFlightDownloads.has(normalizeUrl(url))) {
ElMessage.info('该文件正在下载中,请稍候…')
return false
}
const loading = ElLoading.service({ lock: true, text: '文件下载中,请稍候(1M 带宽下大文件较慢)' })
try {
const res = await guardedGet(url, timeout, onProgress)
// 0) 🔴 内容真实性校验必须优先于「状态码 / JSON」判断(顺序与 fetchBlobGuarded 对齐)。
// nginx 404 / SPA 回退会返回 text/html 错误页(如 <html>…404 Not Found…nginx…),
// 若先走状态码分支会把整段 HTML 原标签塞进 message,再被 ElMessage 裸漏到弹窗。
// 命中 HTML 错误页 / 与 X-File-Size 哨兵不符 → 一律返回干净提示,绝不泄漏标签。
const contentErr = await assertRealFile(res)
if (contentErr) return fail(contentErr)
// 1) HTTP 状态码非 2xx(响应体已确认非 HTML,多半是后端 JSON 错误信封)
if (res.status < 200 || res.status >= 300) {
return fail((await blobToMessage(res.data)) || `下载失败 (${res.status})`)
}
// 2) 2xx 但响应体是 JSON 错误信封(后端 HTTP 200 + {code,message})
const ct = getHeader(res.headers, 'content-type')
if (ct.includes('application/json')) {
return fail(await blobToMessage(res.data))
}
// 3) 2xx 且非 JSON,assertRealFile 已在步骤 0 挡过 HTML / 截断,这里直接落盘
// 4) 真实文件流,触发下载(originalUrl 用于跨设备兜底)
const name = extractFilename(getHeader(res.headers, 'content-disposition'), fallbackName)
triggerDownload(res.data, name, url)
if (onSuccess) onSuccess(name)
return true
} catch (err) {
// 同 URL 正在下载被去重忽略(__DUP__)——已在前置给出 info 提示,这里静默返回
if (err && err.message === '__DUP__') return false
// 网络/超时/中止等 axios 异常绝不能向上抛(调用方多无 try/catch),
// 否则会表现为「点击下载毫无反应」。统一转成可见错误提示。
return fail(err?.message || '下载失败,请检查网络后重试')
} finally {
loading.close()
}
}
/**
* 取回文件 blob(用于预览),与 safeDownload 共用同一套内容真实性守卫。
* @returns {Promise<{ok:boolean, blob?:Blob, message?:string, ignored?:boolean}>}
*/
export async function fetchBlobGuarded(url, opts = {}) {
// 同 safeDownload:1M 带宽下放大超时,避免预览大文件超时
const { timeout = 0, onProgress } = opts
try {
const res = await guardedGet(url, timeout, onProgress)
// 🔴 内容真实性校验(2026-08-12 标签泄漏修复,与 safeDownload 共用 assertRealFile):
// 必须放在「状态码 / JSON」判断之前——nginx 404 / SPA 回退会返回 status 404 + text/html
// 错误页,若先走状态码分支会把 <html>…nginx… 原标签塞进 message,再被 ElMessage 裸漏到弹窗。
// 命中(响应体是 HTML 错误页 / 与 X-File-Size 哨兵不符)一律返回干净提示,绝不泄漏标签。
const contentErr = await assertRealFile(res)
if (contentErr) return { ok: false, message: contentErr }
if (res.status < 200 || res.status >= 300) {
return { ok: false, message: (await blobToMessage(res.data)) || `下载失败 (${res.status})` }
}
const ct = getHeader(res.headers, 'content-type')
if (ct.includes('application/json')) {
return { ok: false, message: await blobToMessage(res.data) }
}
return { ok: true, blob: res.data }
} catch (err) {
// 同 URL 正在下载被去重忽略(__DUP__)——标记为 ignored,调用方可据此静默跳过
if (err && err.message === '__DUP__') return { ok: false, ignored: true }
return { ok: false, message: err?.message || '预览加载失败,请检查网络后重试' }
}
}

45
src/utils/jwt.js Normal file
View File

@@ -0,0 +1,45 @@
// ============================================================
// JWT 前端工具
// 仅用于「过期预判」——不校验签名(签名由后端 jwt.verify 负责)。
// 解决:客户端鉴权原本只判断 token 是否存在,导致过期 token 通过路由守卫,
// 在资料页等首次真实请求时才 401 并弹出"登录已过期"。
// ============================================================
/**
* 解码 JWT payload(不验证签名)
* @param {string} token
* @returns {object|null}
*/
export function decodeJwtPayload(token) {
if (!token || typeof token !== 'string') return null
const parts = token.split('.')
if (parts.length !== 3) return null
try {
let base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/')
const pad = base64.length % 4
if (pad) base64 += '='.repeat(4 - pad)
const json = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
)
return JSON.parse(json)
} catch (e) {
return null
}
}
/**
* 判断 token 是否已过期(允许 clockSkew 秒的时钟偏差,避免临界点抖动)
* - 无法解析 / 没有 exp 字段 → 视为已失效(保守策略)
* @param {string} token
* @param {number} clockSkew 秒
* @returns {boolean}
*/
export function isTokenExpired(token, clockSkew = 30) {
const payload = decodeJwtPayload(token)
if (!payload || typeof payload.exp !== 'number') return true
const now = Math.floor(Date.now() / 1000)
return now >= payload.exp - clockSkew
}

37
src/utils/testSms.js Normal file
View File

@@ -0,0 +1,37 @@
/**
* testSms.js - 测试环境短信验证码弹窗
*
* 设计原则(与生产版本差异最小化):
* - 后端仅在【测试环境】(APP_ENV=test 或缺省)的短信接口响应体中回传 smsCode 字段;
* 生产环境真实下发短信、响应体不含 smsCode。
* - 因此本弹窗由「响应是否含 smsCode」自动触发:测试环境弹窗显示验证码,
* 生产环境无 smsCode、不弹窗、走真实短信。无需前端单独判断运行模式。
*
* 用法:
* import { showTestSmsCode } from '@/utils/testSms'
* if (result?.smsCode) showTestSmsCode(result.smsCode, '账号激活')
*/
import { ElMessageBox } from 'element-plus'
/**
* 弹出测试环境验证码提示框
* @param {string} code - 6 位验证码
* @param {string} title - 标题(如 '账号激活' / '忘记密码')
*/
export function showTestSmsCode(code, title = '短信验证码') {
ElMessageBox.alert(
`<div style="text-align:center;padding:4px 0;">
<p style="font-size:13px;color:#909399;margin:0 0 10px;">【测试环境】未真实发送短信,以下为本次验证码</p>
<p style="font-size:34px;font-weight:700;letter-spacing:10px;color:#1a3a5c;margin:6px 0;">${code}</p>
<p style="font-size:12px;color:#c0c4cc;margin:10px 0 0;">验证码 5 分钟内有效;生产环境将通过短信发送至手机</p>
</div>`,
title,
{
dangerouslyUseHTMLString: true,
confirmButtonText: '我已记录',
type: 'warning',
center: true,
customClass: 'test-sms-code-dialog',
}
)
}

164
src/utils/upload.js Normal file
View File

@@ -0,0 +1,164 @@
import SparkMD5 from 'spark-md5'
// ==================== 文件上传工具 ====================
// 默认分片大小:2MB
const DEFAULT_CHUNK_SIZE = 2 * 1024 * 1024
/**
* 将文件切分为多个分片
* @param {File} file - 要切分的文件对象
* @param {number} chunkSize - 每个分片的大小(字节),默认 2MB
* @returns {Array<{ chunk: Blob, index: number, total: number }>} 分片数组
*/
export function chunkFile(file, chunkSize = DEFAULT_CHUNK_SIZE) {
const chunks = []
const total = Math.ceil(file.size / chunkSize)
for (let i = 0; i < total; i++) {
const start = i * chunkSize
const end = Math.min(start + chunkSize, file.size)
chunks.push({
// 文件分片
chunk: file.slice(start, end),
// 分片索引(从 0 开始)
index: i,
// 总分片数
total,
})
}
return chunks
}
/**
* 使用 spark-md5 计算文件的 MD5 哈希值
* 用于文件完整性校验和断点续传标识
* @param {File|Blob} file - 要计算哈希的文件
* @returns {Promise<string>} MD5 哈希值(十六进制字符串)
*/
export function calculateMd5(file) {
return new Promise((resolve, reject) => {
const chunkSize = DEFAULT_CHUNK_SIZE
const chunks = Math.ceil(file.size / chunkSize)
const spark = new SparkMD5.ArrayBuffer()
const reader = new FileReader()
let currentChunk = 0
// 文件读取完成
reader.onload = function (e) {
spark.append(e.target.result) // 追加到 spark 计算
currentChunk++
if (currentChunk < chunks) {
// 还有更多分片,继续读取下一片
loadNext()
} else {
// 所有分片处理完毕,生成最终哈希
const md5 = spark.end()
resolve(md5)
}
}
// 文件读取错误
reader.onerror = function () {
reject(new Error('文件读取失败,无法计算 MD5'))
}
// 加载下一个分片
function loadNext() {
const start = currentChunk * chunkSize
const end = Math.min(start + chunkSize, file.size)
reader.readAsArrayBuffer(file.slice(start, end))
}
// 开始读取第一个分片
loadNext()
})
}
/**
* 带重试机制的上传函数
* 使用指数退避策略,在网络不稳定时自动重试
* @param {function} uploadFn - 上传函数,应返回 Promise
* @param {number} maxRetries - 最大重试次数,默认 3
* @param {number} baseDelay - 基础重试延迟(毫秒),默认 1000
* @returns {Promise<any>} 上传函数的返回值
*/
export async function uploadWithRetry(uploadFn, maxRetries = 3, baseDelay = 1000) {
let lastError = null
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
// 首次 attempt=0 不算重试
const result = await uploadFn()
return result
} catch (error) {
lastError = error
// 已达到最大重试次数,不再重试
if (attempt >= maxRetries) {
break
}
// 指数退避延迟:delay = baseDelay * 2^attempt
// 第1次重试: 1 秒, 第2次: 2 秒, 第3次: 4 秒
const delay = baseDelay * Math.pow(2, attempt)
console.warn(
`上传第 ${attempt + 1} 次重试,等待 ${delay / 1000} 秒...`,
error
)
await sleep(delay)
}
}
// 所有重试都失败
throw lastError || new Error('上传失败:已达最大重试次数')
}
/**
* 格式化文件大小为可读字符串
* @param {number} bytes - 文件大小(字节)
* @param {number} decimals - 保留小数位数,默认 2
* @returns {string} 格式化后的文件大小字符串,如 "1.50 MB"
*/
export function formatFileSize(bytes, decimals = 2) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i]
}
// ==================== 内部辅助 ====================
/**
* 延时函数
* @param {number} ms - 延时毫秒数
* @returns {Promise<void>}
*/
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// ==================== 图片 URL 规范化 ====================
const BACKEND_BASE = ''
/**
* 将相对路径的图片 URL 补全为完整 URL
* 例如 /public/carousel/xxx.jpg → http://127.0.0.1:7001/public/carousel/xxx.jpg
* @param {string} url - 图片 URL
* @returns {string} 完整 URL
*/
export function normalizeImageUrl(url) {
if (!url) return ''
// 已经是完整 URL 或 data URL,直接返回
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) {
return url
}
// 相对路径补全
return BACKEND_BASE + url
}

89
src/utils/uploadCombo.js Normal file
View File

@@ -0,0 +1,89 @@
/**
* uploadCombo.js - 作品上传「组合 / 互斥 / 自动覆盖」纯逻辑
*
* 该模块不依赖 Vue / Element Plus,仅接收普通对象 { uid?, name, size },
* 因此可被 Node 直接单测(见 scripts/verify_upload_combo.mjs)。
*
* 规则(对应测试反馈 Issue ⑪⑫⑬):
* - zip 须单独上传,与任何其他文件互斥,且相互自动覆盖(zip 独占)。
* - pdf 与 docx 相互覆盖,word 类(pdf/doc/docx)至多保留一个。
* - 允许的终态组合:{zip} / {word} / {ppt} / {word,ppt}
* (即「zip」「pdf+ppt」「docx+ppt」三种完整组合 + 单文件退化态)。
* - 新加入的文件覆盖与之冲突的旧文件(last-write-wins)。
*/
export const ALLOWED = ['.doc', '.docx', '.pdf', '.ppt', '.pptx', '.zip']
export const ZIP_MAX = 200 * 1024 * 1024
export const OTHER_MAX = 100 * 1024 * 1024
export function fileExt(name) {
return (String(name).split('.').pop() || '').toLowerCase()
}
// 文件大类:zip / word(pdf|doc|docx) / ppt / other
export function categoryOf(name) {
const e = '.' + fileExt(name)
if (e === '.zip') return 'zip'
if (['.doc', '.docx', '.pdf'].includes(e)) return 'word'
if (['.ppt', '.pptx'].includes(e)) return 'ppt'
return 'other'
}
export function categoryLabel(c) {
return ({ zip: 'ZIP 压缩包', word: 'Word/PDF 文档', ppt: 'PPT 演示稿', other: '未知格式' })[c] || c
}
export function categoryTagType(c) {
return ({ zip: 'warning', word: 'primary', ppt: 'success', other: 'info' })[c] || 'info'
}
// 单文件基础校验:扩展名 + 大小,返回错误信息或 null
export function validateSingleFile(name, size) {
const e = '.' + fileExt(name)
if (!ALLOWED.includes(e)) return `不支持的格式:${name}`
const max = e === '.zip' ? ZIP_MAX : OTHER_MAX
if (size > max) return `${name} 超过大小限制(${e === '.zip' ? '200MB' : '100MB'})`
return null
}
// 终态合法性检查:zip 独占;word 至多一个(pdf 与 docx 互斥)
export function finalComboCheck(files) {
const cats = files.map(f => categoryOf(f.name))
const zipCount = cats.filter(c => c === 'zip').length
const wordCount = cats.filter(c => c === 'word').length
if (zipCount > 0 && files.length > 1) {
return { ok: false, error: 'zip 压缩包须单独上传,不能与其他文件共存' }
}
if (wordCount > 1) {
return { ok: false, error: 'Word/PDF 文档只能保留一个(pdf 与 docx 相互覆盖)' }
}
return { ok: true, error: null }
}
/**
* 选择文件时自动覆盖/互斥校验:新加入的文件覆盖冲突的旧文件。
* @param {Array<{uid:string|number,name:string,size:number}>} files 当前待上传列表
* @param {{uid:string|number,name:string,size:number}} added 本次新加入的文件
* @returns {{list:Array, dropped:Array, addedCat:string, isNewValid:boolean, valid:boolean, error:?string}}
*/
export function reconcileFiles(files, added) {
let list = files.filter(f => validateSingleFile(f.name, f.size) === null)
const isNewValid = validateSingleFile(added.name, added.size) === null
const addedCat = categoryOf(added.name)
let dropped = []
if (isNewValid) {
if (addedCat === 'zip') {
// zip 覆盖所有非 zip
dropped = list.filter(f => f.uid !== added.uid && categoryOf(f.name) !== 'zip')
} else if (addedCat === 'word') {
// word 覆盖其他 word(pdf 与 docx 互斥);若已有 zip,新 word 覆盖 zip
dropped = list.filter(f => f.uid !== added.uid && (categoryOf(f.name) === 'word' || categoryOf(f.name) === 'zip'))
} else if (addedCat === 'ppt') {
// ppt 不与 word 互斥;但若已存在 zip,ppt 覆盖 zip(zip 独占)
dropped = list.filter(f => f.uid !== added.uid && categoryOf(f.name) === 'zip')
}
list = list.filter(f => !dropped.includes(f))
}
const check = finalComboCheck(list)
return { list, dropped, addedCat, isNewValid, valid: check.ok, error: check.error }
}

676
src/views/Activate.vue Normal file
View File

@@ -0,0 +1,676 @@
<template>
<div class="activate-page">
<div class="activate-container">
<!-- 头部 -->
<div class="activate-header">
<div class="header-icon">&#9788;</div>
<h1 class="header-title">账号激活</h1>
<p class="header-subtitle">激活您的参赛账号,开始比赛之旅</p>
</div>
<!-- 步骤条 -->
<el-steps :active="currentStep" align-center finish-status="success" class="activate-steps">
<el-step title="身份验证" description="输入身份证号" />
<el-step title="短信验证" description="输入短信验证码" />
<el-step title="设置密码" description="设置登录密码" />
</el-steps>
<!-- ========== 步骤1:获取图形验证码 ========== -->
<el-card v-if="currentStep === 0" class="step-card" shadow="never">
<template #header>
<span class="step-card-title">第一步:身份验证</span>
</template>
<el-form
ref="step1FormRef"
:model="step1Form"
:rules="step1Rules"
label-position="top"
@keyup.enter="handleStep1"
>
<el-form-item label="身份证号" prop="idNumber">
<el-input
v-model="step1Form.idNumber"
placeholder="请输入您的身份证号"
size="large"
clearable
maxlength="18"
/>
</el-form-item>
<el-form-item label="点击验证码" prop="captchaCode">
<div class="click-captcha-area">
<div class="captcha-hint" v-if="captchaHint">{{ captchaHint }}</div>
<div class="captcha-chars">
<span
v-for="(char, idx) in captchaChars"
:key="idx"
class="captcha-char"
:class="{ clicked: captchaClicked.has(idx) }"
@click="handleCaptchaClick(idx, char)"
>{{ char }}</span>
</div>
<div class="captcha-sequence" v-if="captchaSequence">
已点击:<span class="seq-chars">{{ captchaSequence }}</span>
</div>
<el-link type="primary" v-guard="() => refreshCaptcha()" :underline="false" class="captcha-refresh">
<el-icon><Refresh /></el-icon><span>换一组</span>
</el-link>
</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="step-btn"
:loading="step1Loading"
v-guard="() => handleStep1()"
>
下一步
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- ========== 步骤2:短信验证码 ========== -->
<el-card v-if="currentStep === 1" class="step-card" shadow="never">
<template #header>
<span class="step-card-title">第二步:短信验证</span>
</template>
<el-form
ref="step2FormRef"
:model="step2Form"
:rules="step2Rules"
label-position="top"
>
<div class="phone-display">
<span class="phone-label">短信已发送至:</span>
<span class="phone-value">{{ userInfo.phone || step1Form.phone || '--' }}</span>
</div>
<el-form-item label="短信验证码" prop="smsCode">
<div class="sms-row">
<el-input
v-model="step2Form.smsCode"
placeholder="请输入 6 位短信验证码"
size="large"
maxlength="6"
class="sms-input"
/>
<el-button
size="large"
class="sms-btn"
:disabled="smsCountdown > 0"
v-guard="() => resendSms()"
>
{{ smsCountdown > 0 ? `${smsCountdown}s 后重新发送` : '重新发送' }}
</el-button>
</div>
</el-form-item>
<el-form-item>
<div class="step-btn-group">
<el-button size="large" @click="currentStep = 0">上一步</el-button>
<el-button
type="primary"
size="large"
class="step-btn"
:loading="step2Loading"
@click="handleStep2"
>
下一步
</el-button>
</div>
</el-form-item>
</el-form>
</el-card>
<!-- ========== 步骤3:设置密码 ========== -->
<el-card v-if="currentStep === 2" class="step-card" shadow="never">
<template #header>
<span class="step-card-title">第三步:设置密码</span>
</template>
<el-form
ref="step3FormRef"
:model="step3Form"
:rules="step3Rules"
label-position="top"
>
<!-- 基本信息确认 -->
<div class="info-confirm">
<el-descriptions :column="1" border size="small" title="基本信息确认">
<el-descriptions-item label="姓名">{{ userInfo.name || '--' }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ userInfo.phone || '--' }}</el-descriptions-item>
<el-descriptions-item label="参赛组别">{{ groupLabel }}</el-descriptions-item>
</el-descriptions>
</div>
<el-form-item label="设置密码" prop="password">
<el-input
v-model="step3Form.password"
type="password"
placeholder="请设置登录密码(至少6位)"
size="large"
show-password
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input
v-model="step3Form.confirmPassword"
type="password"
placeholder="请再次输入密码"
size="large"
show-password
/>
</el-form-item>
<el-form-item>
<div class="step-btn-group">
<el-button size="large" @click="currentStep = 1">上一步</el-button>
<el-button
type="primary"
size="large"
class="step-btn"
:loading="step3Loading"
v-guard="() => handleActivate()"
>
完成激活
</el-button>
</div>
</el-form-item>
</el-form>
</el-card>
<!-- 返回链接 -->
<div class="back-link">
<el-link type="info" @click="$router.push('/')">
<el-icon><HomeFilled /></el-icon><span>返回首页</span>
</el-link>
<span class="link-sep">|</span>
<el-link type="info" @click="$router.push('/login')">
<el-icon><ArrowLeft /></el-icon><span>返回登录</span>
</el-link>
</div>
</div>
</div>
</template>
<script setup>
/**
* Activate.vue - 账号激活页面(公开访问)
*
* 流程:
* 步骤1:输入身份证号 -> 获取图形验证码 -> 验证
* 步骤2:发送短信验证码 -> 输入 6 位验证码 -> 验证
* 步骤3:确认基本信息 -> 设置密码 + 确认密码 -> 完成激活
*/
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, HomeFilled, Refresh } from '@element-plus/icons-vue'
import * as authApi from '@/api/auth'
import { showTestSmsCode } from '@/utils/testSms'
const router = useRouter()
// ==================== 状态 ====================
const currentStep = ref(0)
// 点击式验证码状态
const captchaChars = ref([])
const captchaHint = ref('')
const captchaClicked = ref(new Set())
const captchaSequence = ref('')
const smsCountdown = ref(0)
let smsTimer = null
// 步骤1表单
const step1FormRef = ref(null)
const step1Form = reactive({
idNumber: '',
captchaCode: '',
phone: '',
})
const step1Loading = ref(false)
// 步骤2表单
const step2FormRef = ref(null)
const step2Form = reactive({
smsCode: '',
})
const step2Loading = ref(false)
// 步骤3表单
const step3FormRef = ref(null)
const step3Form = reactive({
password: '',
confirmPassword: '',
})
const step3Loading = ref(false)
// 用户基本信息(从服务端返回)
const userInfo = reactive({
name: '',
phone: '',
group: '',
})
// ==================== 表单验证规则 ====================
const step1Rules = {
idNumber: [
{ required: true, message: '请输入身份证号', trigger: 'blur' },
{
pattern: /^[1-9]\d{5}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/,
message: '请输入正确的身份证号',
trigger: 'blur',
},
],
captchaCode: [
{ required: true, message: '请按顺序点击验证码', trigger: 'change' },
],
}
const step2Rules = {
smsCode: [
{ required: true, message: '请输入短信验证码', trigger: 'blur' },
{ len: 6, message: '请输入 6 位短信验证码', trigger: 'blur' },
],
}
const step3Rules = {
password: [
{ required: true, message: '请设置密码', trigger: 'blur' },
{ min: 6, message: '密码至少 6 个字符', trigger: 'blur' },
{ max: 20, message: '密码不超过 20 个字符', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请确认密码', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value !== step3Form.password) {
callback(new Error('两次输入的密码不一致'))
} else {
callback()
}
},
trigger: 'blur',
},
],
}
// ==================== 计算属性 ====================
const groupLabel = computed(() => {
const map = {
zhongzhi: '中职组',
gaozhi: '高职组',
benke: '本科组',
jiaoshi: '教师组',
}
return map[userInfo.group] || userInfo.group || '--'
})
// ==================== 方法 ====================
/** 获取点击式验证码 */
async function refreshCaptcha() {
try {
captchaChars.value = []
captchaHint.value = ''
captchaClicked.value = new Set()
captchaSequence.value = ''
step1Form.captchaCode = ''
const data = await authApi.getCaptcha()
if (data && data.chars) {
captchaChars.value = data.chars
captchaHint.value = data.hint || ''
}
} catch (e) {
console.warn('获取验证码失败', e)
}
}
/** 点击验证码字符 */
function handleCaptchaClick(idx, char) {
if (captchaClicked.value.has(idx)) return // 已点击过
const newClicked = new Set(captchaClicked.value)
newClicked.add(idx)
captchaClicked.value = newClicked
captchaSequence.value += char
step1Form.captchaCode = captchaSequence.value
}
/** 步骤1:验证身份并发送短信 */
async function handleStep1() {
if (!step1FormRef.value) return
try {
await step1FormRef.value.validate()
step1Loading.value = true
// 发送短信验证码
const result = await authApi.sendSms(step1Form.idNumber, step1Form.captchaCode)
// 测试环境:后端随响应回传验证码,弹窗展示(生产环境不回传、不弹窗,走真实短信)
if (result?.smsCode) {
showTestSmsCode(result.smsCode, '账号激活')
}
// 后端可能返回用户基本信息
if (result) {
step1Form.phone = result.phone || ''
if (result.name) userInfo.name = result.name
if (result.phone) userInfo.phone = result.phone
if (result.group) userInfo.group = result.group
}
ElMessage.success('短信验证码已发送')
// 进入下一步
currentStep.value = 1
// 启动倒计时
startSmsCountdown()
} catch (err) {
if (err?.message) ElMessage.error(err.message)
// 刷新验证码
refreshCaptcha()
} finally {
step1Loading.value = false
}
}
/** 步骤2:验证短信验证码 */
async function handleStep2() {
if (!step2FormRef.value) return
try {
await step2FormRef.value.validate()
// 验证短信码(此处通过获取用户信息间接验证)
step2Loading.value = true
// 短信码的验证由后端在激活时完成,此处仅进入下一步
currentStep.value = 2
ElMessage.success('短信验证通过')
} catch (err) {
if (err?.message) ElMessage.error(err.message)
} finally {
step2Loading.value = false
}
}
/** 步骤3:完成激活 */
async function handleActivate() {
if (!step3FormRef.value) return
try {
await step3FormRef.value.validate()
step3Loading.value = true
await authApi.activate({
id_number: step1Form.idNumber,
phone: step1Form.phone || step1Form.idNumber,
password: step3Form.password,
smsCode: step2Form.smsCode,
})
ElMessage.success('账号激活成功!请登录参赛')
// 跳转到登录页
setTimeout(() => {
router.push('/login')
}, 1500)
} catch (err) {
if (err?.message) ElMessage.error(err.message)
} finally {
step3Loading.value = false
}
}
/** 重新发送短信验证码 */
async function resendSms() {
if (smsCountdown.value > 0) return
try {
await refreshCaptcha()
const result = await authApi.sendSms(step1Form.idNumber, step1Form.captchaCode)
if (result?.smsCode) {
showTestSmsCode(result.smsCode, '账号激活(重新发送)')
}
ElMessage.success('短信验证码已重新发送')
startSmsCountdown()
} catch (err) {
if (err?.message) ElMessage.error(err.message)
refreshCaptcha()
}
}
/** 启动短信倒计时 */
function startSmsCountdown() {
smsCountdown.value = 60
clearInterval(smsTimer)
smsTimer = setInterval(() => {
smsCountdown.value--
if (smsCountdown.value <= 0) {
clearInterval(smsTimer)
smsTimer = null
}
}, 1000)
}
// ==================== 生命周期 ====================
onMounted(() => {
refreshCaptcha()
})
onUnmounted(() => {
if (smsTimer) clearInterval(smsTimer)
})
</script>
<style scoped>
.activate-page {
min-height: 100vh;
background: linear-gradient(135deg, #f5f6f8 0%, #eef0f5 100%);
padding: 40px 20px;
display: flex;
justify-content: center;
}
.activate-container {
width: 100%;
max-width: 540px;
}
/* ==================== 头部 ==================== */
.activate-header {
text-align: center;
margin-bottom: 32px;
}
.header-icon {
font-size: 48px;
color: #c9a84c;
margin-bottom: 12px;
}
.header-title {
font-size: 28px;
font-weight: 700;
color: #1a3a5c;
margin-bottom: 8px;
}
.header-subtitle {
font-size: 14px;
color: #909399;
}
/* ==================== 步骤条 ==================== */
.activate-steps {
margin-bottom: 32px;
}
.activate-steps :deep(.el-step__title) {
font-size: 14px;
}
.activate-steps :deep(.el-step__description) {
font-size: 12px;
}
/* ==================== 步骤卡片 ==================== */
.step-card {
border-radius: 12px;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
.step-card-title {
font-size: 16px;
font-weight: 600;
color: #1a3a5c;
}
/* ==================== 点击验证码 ==================== */
.click-captcha-area {
width: 100%;
}
.captcha-hint {
font-size: 14px;
color: #303133;
margin-bottom: 10px;
font-weight: 500;
}
.captcha-chars {
display: flex;
gap: 12px;
margin-bottom: 8px;
}
.captcha-char {
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #dcdfe6;
border-radius: 8px;
font-size: 22px;
font-weight: bold;
color: #303133;
cursor: pointer;
user-select: none;
background: #fff;
transition: all 0.2s;
}
.captcha-char:hover {
border-color: #409eff;
background: #ecf5ff;
transform: scale(1.05);
}
.captcha-char.clicked {
border-color: #67c23a;
background: #f0f9eb;
color: #67c23a;
cursor: default;
transform: scale(1.05);
}
.captcha-sequence {
font-size: 13px;
color: #606266;
margin-bottom: 6px;
}
.seq-chars {
font-weight: bold;
color: #67c23a;
letter-spacing: 4px;
}
.captcha-refresh {
font-size: 13px;
}
/* ==================== 短信行 ==================== */
.sms-row {
display: flex;
gap: 12px;
}
.sms-input {
flex: 1;
}
.sms-btn {
flex-shrink: 0;
min-width: 130px;
}
.phone-display {
background: #f5f7fa;
border: 1px solid #e4e7ed;
border-radius: 6px;
padding: 12px 16px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
}
.phone-label {
font-size: 14px;
color: #909399;
}
.phone-value {
font-size: 14px;
color: #303133;
font-weight: 600;
}
/* ==================== 信息确认 ==================== */
.info-confirm {
margin-bottom: 24px;
}
/* ==================== 按钮 ==================== */
.step-btn {
width: 100%;
height: 46px;
font-size: 16px;
}
.step-btn-group {
display: flex;
gap: 12px;
width: 100%;
}
.step-btn-group .el-button {
flex: 1;
}
/* ==================== 返回链接 ==================== */
.back-link {
text-align: center;
margin-top: 24px;
}
/* ==================== Responsive ==================== */
@media (max-width: 480px) {
.activate-page {
padding: 20px 12px;
}
.header-title {
font-size: 22px;
}
.captcha-img {
width: 100px;
}
.sms-btn {
min-width: 100px;
font-size: 12px;
}
}
</style>

2900
src/views/Admin.vue Normal file

File diff suppressed because it is too large Load Diff

806
src/views/AtSimulator.vue Normal file
View File

@@ -0,0 +1,806 @@
<template>
<div class="at-page">
<div class="at-header">
<h1>卫星通信 AT 命令模拟器</h1>
<p class="subtitle">高职组 · 卫星通信设备操作实操训练</p>
</div>
<div class="at-body">
<!-- 左侧:知识面板 -->
<div class="at-knowledge">
<el-card shadow="hover">
<template #header>
<span class="card-title">AT 命令基础知识</span>
</template>
<el-collapse v-model="activeKnowledge">
<el-collapse-item title="什么是 AT 命令" name="1">
<p>AT 命令(Attention Commands)是用于控制调制解调器(Modem)和通信模块的指令集。在卫星通信中,通过串口向卫星通信模组发送 AT 命令来控制设备行为。</p>
<p><strong>基本格式:</strong>每条命令以 <code>AT</code> 开头,以 <code>\r\n</code> 结尾。</p>
<ul>
<li><code>AT</code> — 测试命令,返回 OK 表示模组正常</li>
<li><code>AT+XXXX=?</code> — 查询命令参数范围</li>
<li><code>AT+XXXX?</code> — 查询当前参数值</li>
<li><code>AT+XXXX=value</code> — 设置参数值</li>
</ul>
</el-collapse-item>
<el-collapse-item title="常用 AT 命令速查" name="2">
<el-table :data="atCommands" border stripe size="small">
<el-table-column prop="cmd" label="命令" width="220" />
<el-table-column prop="desc" label="功能说明" />
<el-table-column prop="resp" label="成功响应" width="140" />
</el-table>
</el-collapse-item>
<el-collapse-item title="卫星通信发送流程" name="3">
<div class="flow-steps">
<div class="flow-step">
<span class="step-num">1</span>
<div>
<strong>设备初始化</strong>
<p>发送 <code>AT</code> 确认模组正常,发送 <code>AT+CSQ</code> 查询信号强度</p>
</div>
</div>
<div class="flow-step">
<span class="step-num">2</span>
<div>
<strong>网络注册</strong>
<p>发送 <code>AT+CREG?</code> 检查网络注册状态,确保返回 +CREG: 0,1(已注册)</p>
</div>
</div>
<div class="flow-step">
<span class="step-num">3</span>
<div>
<strong>查询卫星状态</strong>
<p>发送 <code>AT+SSTATUS?</code> 获取当前卫星连接状态(是否可见、是否锁定)</p>
</div>
</div>
<div class="flow-step">
<span class="step-num">4</span>
<div>
<strong>发送消息</strong>
<p>发送 <code>AT+SEND=数据</code> 将数据通过卫星链路发送出去</p>
</div>
</div>
<div class="flow-step">
<span class="step-num">5</span>
<div>
<strong>确认发送结果</strong>
<p>等待 <code>+SEND: OK</code> 响应,确认消息已成功发送</p>
</div>
</div>
</div>
</el-collapse-item>
<el-collapse-item title="消息长度限制" name="4">
<p>卫星通信消息发送有不同的长度限制,取决于所用模组和协议:</p>
<el-table :data="lengthLimits" border stripe size="small">
<el-table-column prop="type" label="通信方式" width="150" />
<el-table-column prop="maxLen" label="最大长度" width="120" />
<el-table-column prop="note" label="说明" />
</el-table>
</el-collapse-item>
<el-collapse-item title="串口连接配置" name="5">
<p><strong>串口配置参数:</strong></p>
<el-table :data="serialConfigs" border stripe size="small">
<el-table-column prop="param" label="参数" />
<el-table-column prop="value" label="典型值" />
<el-table-column prop="desc" label="说明" />
</el-table>
</el-collapse-item>
</el-collapse>
</el-card>
</div>
<!-- 右侧:模拟器 -->
<div class="at-tool">
<!-- 设备状态面板 -->
<el-card shadow="hover">
<template #header>
<div class="device-header">
<span class="card-title">设备状态</span>
<el-tag :type="deviceState.connected ? 'success' : 'info'" size="small">
{{ deviceState.connected ? '已连接' : '未连接' }}
</el-tag>
</div>
</template>
<div class="device-info">
<el-descriptions :column="4" border size="small">
<el-descriptions-item label="模组型号">
{{ deviceState.module }}
</el-descriptions-item>
<el-descriptions-item label="信号强度">
<el-tag :type="signalType" size="small">{{ deviceState.signal }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="网络状态">
<el-tag :type="deviceState.registered ? 'success' : 'warning'" size="small">
{{ deviceState.registered ? '已注册' : '未注册' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="卫星状态">
<el-tag :type="deviceState.satelliteLocked ? 'success' : 'danger'" size="small">
{{ deviceState.satelliteLocked ? '已锁定' : '未锁定' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</div>
<div class="connect-row">
<el-button type="primary" @click="connectDevice" :disabled="deviceState.connected">
{{ deviceState.connected ? '已连接' : '连接设备' }}
</el-button>
<el-button type="danger" @click="disconnectDevice" :disabled="!deviceState.connected">
断开连接
</el-button>
<el-button @click="resetDevice">重置设备</el-button>
</div>
</el-card>
<!-- 串口终端 -->
<el-card shadow="hover" class="terminal-card">
<template #header>
<div class="terminal-header">
<span class="card-title">串口终端</span>
<span class="serial-info">COM3 · 115200-8N1</span>
</div>
</template>
<!-- 终端输出 -->
<div class="terminal-output" ref="terminalRef">
<div v-for="(line, idx) in terminalLines" :key="idx" class="terminal-line" :class="line.type">
<span class="line-time">{{ line.time }}</span>
<span class="line-content">{{ line.text }}</span>
</div>
<div v-if="terminalLines.length === 0" class="terminal-empty">
串口终端就绪,等待设备连接...
</div>
</div>
<!-- 命令输入 -->
<div class="terminal-input-area">
<div class="command-prompt">
<span class="prompt">AT&gt;</span>
<el-input
v-model="commandInput"
@keyup.enter="sendCommand"
placeholder="输入 AT 命令,如 AT+CSQ"
:disabled="!deviceState.connected"
ref="inputRef"
class="command-input"
>
<template #append>
<el-button @click="sendCommand" :disabled="!deviceState.connected">
发送
</el-button>
</template>
</el-input>
</div>
<div class="quick-commands">
<span class="quick-label">快捷命令:</span>
<el-button
v-for="qc in quickCommands"
:key="qc.cmd"
size="small"
@click="sendQuickCommand(qc.cmd)"
:disabled="!deviceState.connected"
>
{{ qc.cmd }}
</el-button>
</div>
</div>
</el-card>
<!-- 消息发送区 -->
<el-card shadow="hover" class="send-card">
<template #header>
<span class="card-title">消息发送</span>
</template>
<div class="send-area">
<el-input
v-model="messageToSend"
type="textarea"
:rows="3"
placeholder="输入要发送的消息(注意长度限制)"
:disabled="!deviceState.connected"
/>
<div class="send-row">
<el-button
type="success"
@click="sendMessage"
:disabled="!deviceState.connected || !messageToSend.trim()"
>
发送消息
</el-button>
<span class="char-count" :class="charCountClass">
{{ messageToSend.length }} / {{ maxMessageLength }} 字符
</span>
</div>
</div>
</el-card>
<!-- 练习区 -->
<el-card shadow="hover" class="practice-card">
<template #header>
<span class="card-title">练习题目</span>
</template>
<div class="practice-item">
<p><strong>1.</strong> 低轨卫星通信设备发送消息前,需要先查询什么状态?请写出对应的 AT 命令:</p>
<el-input v-model="practice1" placeholder="AT+CSQ" class="practice-input" />
</div>
<div class="practice-item">
<p><strong>2.</strong> 查询当前卫星设备与卫星连接状态的 AT 命令是什么?</p>
<el-radio-group v-model="practice2">
<el-radio label="AT+CSQ">AT+CSQ</el-radio>
<el-radio label="AT+SSTATUS?">AT+SSTATUS?</el-radio>
<el-radio label="AT+CREG?">AT+CREG?</el-radio>
<el-radio label="AT+CGATT?">AT+CGATT?</el-radio>
</el-radio-group>
</div>
<div class="practice-item">
<p><strong>3.</strong> 天启卫星通信模组单次发送消息的最大长度限制是多少?</p>
<el-input v-model="practice3" placeholder="请输入字节数" class="practice-input" />
</div>
</el-card>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, nextTick } from 'vue'
// ========== 知识面板数据 ==========
const activeKnowledge = ref(['1', '3'])
const atCommands = [
{ cmd: 'AT', desc: '测试模组是否正常,返回 OK', resp: 'OK' },
{ cmd: 'AT+CSQ', desc: '查询信号强度(0-31,越大越好)', resp: '+CSQ: 25,99' },
{ cmd: 'AT+CREG?', desc: '查询网络注册状态', resp: '+CREG: 0,1' },
{ cmd: 'AT+SSTATUS?', desc: '查询卫星连接状态', resp: '+SSTATUS: LOCKED' },
{ cmd: 'AT+CGATT?', desc: '查询 PS 附着状态', resp: '+CGATT: 1' },
{ cmd: 'AT+CGMI', desc: '查询制造商信息', resp: 'SATELLITE_MODULE_V2' },
{ cmd: 'AT+CGMM', desc: '查询模组型号', resp: 'SM-2300' },
{ cmd: 'AT+CGSN', desc: '查询序列号(IMEI)', resp: '860123456789012' },
{ cmd: 'AT+CIMI', desc: '查询 SIM 卡 IMSI', resp: '460012345678901' },
{ cmd: 'AT+CPIN?', desc: '查询 SIM 卡状态', resp: '+CPIN: READY' },
{ cmd: 'AT+SEND=数据', desc: '发送卫星消息(数据需编码)', resp: '+SEND: OK' },
{ cmd: 'AT+SRECV?', desc: '查询是否有待接收消息', resp: '+SRECV: 0' },
{ cmd: 'AT+IPR?', desc: '查询/设置波特率', resp: '+IPR: 115200' },
{ cmd: 'AT&W', desc: '保存当前配置到模组', resp: 'OK' },
{ cmd: 'AT+RESET', desc: '重启模组', resp: 'OK' },
]
const lengthLimits = [
{ type: '天启卫星(短报文)', maxLen: '140 字节', note: '类似短信,适合传输传感器数据、状态信息' },
{ type: '天启卫星(扩展)', maxLen: '512 字节', note: '支持更长消息,但会增加传输延迟' },
{ type: '铱星(Iridium)SBD', maxLen: '340 字节', note: 'Short Burst Data,上行 340 字节,下行 270 字节' },
{ type: '北斗短报文', maxLen: '78 字节', note: '北斗 RDSS 短报文,区域短报文每次 1000 汉字' },
{ type: 'LoRa 卫星', maxLen: '255 字节', note: 'LoRa 调制通过卫星中继,最大 255 字节' },
]
const serialConfigs = [
{ param: '波特率 (Baud Rate)', value: '115200', desc: '卫星通信模组常用高速波特率,部分设备支持 9600-921600' },
{ param: '数据位 (Data Bits)', value: '8', desc: '固定 8 位数据位' },
{ param: '校验位 (Parity)', value: 'N(无校验)', desc: '通常不使用校验位' },
{ param: '停止位 (Stop Bits)', value: '1', desc: '1 位停止位' },
{ param: '流控制 (Flow Control)', value: 'N(无流控)', desc: '通常不使用硬件流控制' },
{ param: '端口号', value: 'COM3/COM4', desc: '通过设备管理器查看实际端口号' },
]
// ========== 设备状态 ==========
const deviceState = reactive({
connected: false,
signal: '--',
registered: false,
satelliteLocked: false,
module: 'SM-2300',
imei: '860123456789012',
})
const signalType = computed(() => {
if (!deviceState.connected) return 'info'
const s = parseInt(deviceState.signal)
if (s >= 20) return 'success'
if (s >= 10) return 'warning'
return 'danger'
})
// ========== 终端 ==========
const terminalLines = ref([])
const terminalRef = ref(null)
const inputRef = ref(null)
const commandInput = ref('')
const messageToSend = ref('')
const maxMessageLength = 140
const charCountClass = computed(() => {
if (messageToSend.value.length > maxMessageLength) return 'char-over'
if (messageToSend.value.length > maxMessageLength * 0.8) return 'char-warn'
return 'char-ok'
})
const quickCommands = [
{ cmd: 'AT' },
{ cmd: 'AT+CSQ' },
{ cmd: 'AT+CREG?' },
{ cmd: 'AT+SSTATUS?' },
{ cmd: 'AT+CGATT?' },
{ cmd: 'AT+CGMI' },
{ cmd: 'AT+CGSN' },
{ cmd: 'AT+CPIN?' },
]
// 练习
const practice1 = ref('')
const practice2 = ref('')
const practice3 = ref('')
function addTerminalLine(text, type = 'response') {
const now = new Date()
const time = now.toTimeString().substring(0, 8)
terminalLines.value.push({ text, type, time })
nextTick(() => {
if (terminalRef.value) {
terminalRef.value.scrollTop = terminalRef.value.scrollHeight
}
})
}
function connectDevice() {
deviceState.connected = true
deviceState.signal = '25'
deviceState.registered = true
deviceState.satelliteLocked = true
addTerminalLine('=== 串口已连接:COM3,115200-8N1 ===', 'system')
addTerminalLine('AT', 'command')
addTerminalLine('OK', 'response')
addTerminalLine('', 'blank')
addTerminalLine('AT+CSQ', 'command')
addTerminalLine('+CSQ: 25,99', 'response')
addTerminalLine('', 'blank')
addTerminalLine('AT+CREG?', 'command')
addTerminalLine('+CREG: 0,1', 'response')
addTerminalLine('', 'blank')
addTerminalLine('AT+SSTATUS?', 'command')
addTerminalLine('+SSTATUS: LOCKED', 'response')
addTerminalLine('', 'blank')
addTerminalLine('设备连接成功,信号良好,卫星已锁定。', 'success')
}
function disconnectDevice() {
addTerminalLine('=== 串口已断开 ===', 'system')
deviceState.connected = false
deviceState.signal = '--'
deviceState.registered = false
deviceState.satelliteLocked = false
}
function resetDevice() {
terminalLines.value = []
commandInput.value = ''
messageToSend.value = ''
deviceState.connected = false
deviceState.signal = '--'
deviceState.registered = false
deviceState.satelliteLocked = false
addTerminalLine('设备已重置,串口终端已清空。', 'system')
}
function processCommand(cmd) {
const upperCmd = cmd.toUpperCase().trim()
addTerminalLine(cmd, 'command')
// AT 测试
if (upperCmd === 'AT') {
addTerminalLine('OK', 'response')
return
}
// AT+CSQ 信号强度
if (upperCmd === 'AT+CSQ' || upperCmd === 'AT+CSQ?') {
const sig = deviceState.connected ? deviceState.signal : '0'
addTerminalLine(`+CSQ: ${sig},99`, 'response')
return
}
// AT+CREG? 网络注册
if (upperCmd === 'AT+CREG?') {
addTerminalLine(`+CREG: 0,${deviceState.registered ? '1' : '0'}`, 'response')
return
}
// AT+SSTATUS? 卫星状态
if (upperCmd === 'AT+SSTATUS?') {
const status = deviceState.satelliteLocked ? 'LOCKED' : 'SEARCHING'
addTerminalLine(`+SSTATUS: ${status}`, 'response')
return
}
// AT+CGATT? PS附着
if (upperCmd === 'AT+CGATT?') {
addTerminalLine('+CGATT: 1', 'response')
return
}
// AT+CGMI 制造商
if (upperCmd === 'AT+CGMI') {
addTerminalLine('SATELLITE_MODULE_V2', 'response')
return
}
// AT+CGMM 型号
if (upperCmd === 'AT+CGMM') {
addTerminalLine('SM-2300', 'response')
return
}
// AT+CGSN 序列号
if (upperCmd === 'AT+CGSN') {
addTerminalLine(deviceState.imei, 'response')
return
}
// AT+CIMI IMSI
if (upperCmd === 'AT+CIMI') {
addTerminalLine('460012345678901', 'response')
return
}
// AT+CPIN? SIM卡
if (upperCmd === 'AT+CPIN?') {
addTerminalLine('+CPIN: READY', 'response')
return
}
// AT+IPR? 波特率
if (upperCmd === 'AT+IPR?') {
addTerminalLine('+IPR: 115200', 'response')
return
}
// AT&W 保存
if (upperCmd === 'AT&W') {
addTerminalLine('OK', 'response')
return
}
// AT+RESET 重启
if (upperCmd === 'AT+RESET') {
addTerminalLine('OK', 'response')
addTerminalLine('模组重启中...', 'system')
setTimeout(() => {
addTerminalLine('AT', 'command')
addTerminalLine('OK', 'response')
addTerminalLine('模组重启完成。', 'success')
}, 1500)
return
}
// AT+SEND= 发送消息
if (upperCmd.startsWith('AT+SEND=')) {
const data = cmd.substring(8).trim()
if (!data) {
addTerminalLine('ERROR: 消息内容为空', 'error')
return
}
if (data.length > maxMessageLength) {
addTerminalLine(`ERROR: 消息长度超出限制(${data.length} > ${maxMessageLength})`, 'error')
return
}
addTerminalLine('+SEND: OK', 'response')
addTerminalLine(`消息已发送至卫星(${data.length} 字节)`, 'success')
return
}
// AT+SRECV? 接收消息
if (upperCmd === 'AT+SRECV?') {
addTerminalLine('+SRECV: 0', 'response')
return
}
// 未知命令
addTerminalLine('ERROR', 'error')
}
function sendCommand() {
if (!deviceState.connected) return
const cmd = commandInput.value.trim()
if (!cmd) return
processCommand(cmd)
commandInput.value = ''
nextTick(() => {
if (inputRef.value) inputRef.value.focus()
})
}
function sendQuickCommand(cmd) {
if (!deviceState.connected) return
processCommand(cmd)
}
function sendMessage() {
if (!deviceState.connected || !messageToSend.value.trim()) return
const msg = messageToSend.value.trim()
if (msg.length > maxMessageLength) {
addTerminalLine(`ERROR: 消息长度 ${msg.length} 超出限制 ${maxMessageLength}`, 'error')
return
}
processCommand(`AT+SEND=${msg}`)
messageToSend.value = ''
}
</script>
<style scoped>
.at-page {
min-height: 100vh;
background: #f0f2f5;
padding: 20px;
}
.at-header {
text-align: center;
padding: 30px 0 20px;
}
.at-header h1 {
font-size: 28px;
color: #1a1a2e;
margin: 0 0 8px;
}
.subtitle {
font-size: 14px;
color: #909399;
}
.at-body {
display: flex;
gap: 20px;
max-width: 1400px;
margin: 0 auto;
}
.at-knowledge {
flex: 0 0 400px;
max-height: calc(100vh - 160px);
overflow-y: auto;
}
.at-tool {
flex: 1;
min-width: 0;
}
.card-title {
font-weight: 600;
font-size: 16px;
color: #303133;
}
/* 设备状态 */
.device-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.device-info {
margin-bottom: 12px;
}
.connect-row {
display: flex;
gap: 10px;
}
/* 流程图 */
.flow-steps {
padding: 0;
}
.flow-step {
display: flex;
gap: 12px;
padding: 8px 0;
border-bottom: 1px dashed #e4e7ed;
}
.flow-step:last-child {
border-bottom: none;
}
.step-num {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: #409eff;
color: white;
font-weight: bold;
font-size: 14px;
flex-shrink: 0;
}
.flow-step p {
margin: 4px 0 0;
font-size: 13px;
color: #606266;
}
/* 终端 */
.terminal-card {
margin-top: 16px;
}
.terminal-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.serial-info {
font-size: 12px;
color: #909399;
font-family: 'Courier New', monospace;
}
.terminal-output {
background: #1a1a2e;
color: #00ff88;
font-family: 'Courier New', monospace;
font-size: 13px;
padding: 12px;
height: 300px;
overflow-y: auto;
border-radius: 4px;
line-height: 1.6;
}
.terminal-empty {
color: #666;
font-style: italic;
}
.terminal-line {
display: flex;
gap: 8px;
padding: 1px 0;
}
.terminal-line .line-time {
color: #555;
font-size: 11px;
flex-shrink: 0;
}
.terminal-line.command .line-content {
color: #ffd700;
}
.terminal-line.response .line-content {
color: #00ff88;
}
.terminal-line.error .line-content {
color: #ff4444;
}
.terminal-line.system .line-content {
color: #8888ff;
}
.terminal-line.success .line-content {
color: #44ff44;
}
.terminal-line.blank {
height: 4px;
}
.terminal-input-area {
margin-top: 12px;
}
.command-prompt {
display: flex;
align-items: center;
gap: 8px;
}
.prompt {
font-family: 'Courier New', monospace;
font-size: 14px;
color: #409eff;
font-weight: bold;
flex-shrink: 0;
}
.command-input {
flex: 1;
}
.quick-commands {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
align-items: center;
}
.quick-label {
font-size: 12px;
color: #909399;
margin-right: 4px;
}
/* 消息发送 */
.send-card {
margin-top: 16px;
}
.send-area {
display: flex;
flex-direction: column;
gap: 10px;
}
.send-row {
display: flex;
align-items: center;
gap: 12px;
}
.char-count {
font-size: 13px;
}
.char-ok { color: #67c23a; }
.char-warn { color: #e6a23c; }
.char-over { color: #f56c6c; }
/* 练习 */
.practice-card {
margin-top: 16px;
}
.practice-item {
padding: 10px 0;
border-bottom: 1px dashed #ebeef5;
}
.practice-item:last-child {
border-bottom: none;
}
.practice-item p {
font-size: 14px;
color: #303133;
margin: 0 0 8px;
}
.practice-input {
max-width: 500px;
}
/* 响应式 */
@media (max-width: 900px) {
.at-body {
flex-direction: column;
}
.at-knowledge {
flex: none;
max-height: none;
}
}
</style>

1458
src/views/Competition.vue Normal file

File diff suppressed because it is too large Load Diff

490
src/views/Dashboard.vue Normal file
View 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>

View File

@@ -0,0 +1,617 @@
<template>
<div class="forgot-page">
<div class="forgot-container">
<!-- 头部 -->
<div class="forgot-header">
<div class="header-icon">&#128274;</div>
<h1 class="header-title">忘记密码</h1>
<p class="header-subtitle">通过身份验证重置您的登录密码</p>
</div>
<!-- 步骤条 -->
<el-steps :active="currentStep" align-center finish-status="success" class="forgot-steps">
<el-step title="身份验证" description="输入身份证号" />
<el-step title="短信验证" description="输入验证码" />
<el-step title="重置密码" description="设置新密码" />
</el-steps>
<!-- ========== 步骤1:身份验证 ========== -->
<el-card v-if="currentStep === 0" class="step-card" shadow="never">
<template #header>
<span class="step-card-title">第一步:身份验证</span>
</template>
<el-form
ref="step1FormRef"
:model="step1Form"
:rules="step1Rules"
label-position="top"
>
<el-form-item label="身份证号" prop="idNumber">
<el-input
v-model="step1Form.idNumber"
placeholder="请输入您的身份证号"
size="large"
clearable
maxlength="18"
/>
</el-form-item>
<el-form-item label="点击验证码" prop="captchaCode">
<div class="click-captcha-area">
<div class="captcha-hint" v-if="captchaHint">{{ captchaHint }}</div>
<div class="captcha-chars">
<span
v-for="(char, idx) in captchaChars"
:key="idx"
class="captcha-char"
:class="{ clicked: captchaClicked.has(idx) }"
@click="handleCaptchaClick(idx, char)"
>{{ char }}</span>
</div>
<div class="captcha-sequence" v-if="captchaSequence">
已点击:<span class="seq-chars">{{ captchaSequence }}</span>
</div>
<el-link type="primary" v-guard="() => refreshCaptcha()" :underline="false" class="captcha-refresh">
<el-icon><Refresh /></el-icon><span>换一组</span>
</el-link>
</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="step-btn"
:loading="step1Loading"
v-guard="() => handleStep1()"
>
下一步
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- ========== 步骤2:短信验证 ========== -->
<el-card v-if="currentStep === 1" class="step-card" shadow="never">
<template #header>
<span class="step-card-title">第二步:短信验证</span>
</template>
<el-form
ref="step2FormRef"
:model="step2Form"
:rules="step2Rules"
label-position="top"
>
<div class="phone-display">
<span class="phone-label">短信已发送至手机号后四位:</span>
<span class="phone-value">{{ phoneMask }}</span>
</div>
<el-form-item label="短信验证码" prop="smsCode">
<div class="sms-row">
<el-input
v-model="step2Form.smsCode"
placeholder="请输入 6 位短信验证码"
size="large"
maxlength="6"
class="sms-input"
/>
<el-button
size="large"
class="sms-btn"
:disabled="smsCountdown > 0"
v-guard="() => resendSms()"
>
{{ smsCountdown > 0 ? `${smsCountdown}s` : '重新发送' }}
</el-button>
</div>
</el-form-item>
<el-form-item>
<div class="step-btn-group">
<el-button size="large" @click="currentStep = 0">上一步</el-button>
<el-button
type="primary"
size="large"
class="step-btn"
:loading="step2Loading"
@click="handleStep2"
>
下一步
</el-button>
</div>
</el-form-item>
</el-form>
</el-card>
<!-- ========== 步骤3:重置密码 ========== -->
<el-card v-if="currentStep === 2" class="step-card" shadow="never">
<template #header>
<span class="step-card-title">第三步:重置密码</span>
</template>
<el-form
ref="step3FormRef"
:model="step3Form"
:rules="step3Rules"
label-position="top"
>
<el-form-item label="新密码" prop="password">
<el-input
v-model="step3Form.password"
type="password"
placeholder="请设置新密码(至少6位)"
size="large"
show-password
/>
</el-form-item>
<el-form-item label="确认新密码" prop="confirmPassword">
<el-input
v-model="step3Form.confirmPassword"
type="password"
placeholder="请再次输入新密码"
size="large"
show-password
/>
</el-form-item>
<el-form-item>
<div class="step-btn-group">
<el-button size="large" @click="currentStep = 1">上一步</el-button>
<el-button
type="primary"
size="large"
class="step-btn"
:loading="step3Loading"
v-guard="() => handleReset()"
>
重置密码
</el-button>
</div>
</el-form-item>
</el-form>
</el-card>
<!-- 返回链接 -->
<div class="back-link">
<el-link type="info" @click="$router.push('/')">
<el-icon><HomeFilled /></el-icon><span>返回首页</span>
</el-link>
<span class="link-sep">|</span>
<el-link type="info" @click="$router.push('/login')">
<el-icon><ArrowLeft /></el-icon><span>返回登录</span>
</el-link>
</div>
</div>
</div>
</template>
<script setup>
/**
* ForgotPassword.vue - 忘记密码页面(公开访问)
*
* 流程:
* 步骤1:输入身份证号 + 图形验证码
* 步骤2:短信验证码验证
* 步骤3:设置新密码
*/
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, HomeFilled, Refresh } from '@element-plus/icons-vue'
import * as authApi from '@/api/auth'
import { showTestSmsCode } from '@/utils/testSms'
const router = useRouter()
// ==================== 状态 ====================
const currentStep = ref(0)
// 点击式验证码状态
const captchaChars = ref([])
const captchaHint = ref('')
const captchaClicked = ref(new Set())
const captchaSequence = ref('')
const smsCountdown = ref(0)
const userPhone = ref('')
let smsTimer = null
// 步骤1表单
const step1FormRef = ref(null)
const step1Form = reactive({
idNumber: '',
captchaCode: '',
})
const step1Loading = ref(false)
// 步骤2表单
const step2FormRef = ref(null)
const step2Form = reactive({
smsCode: '',
})
const step2Loading = ref(false)
// 步骤3表单
const step3FormRef = ref(null)
const step3Form = reactive({
password: '',
confirmPassword: '',
})
const step3Loading = ref(false)
// ==================== 计算属性 ====================
const phoneMask = computed(() => {
const phone = userPhone.value
if (phone.length >= 4) {
return '****' + phone.slice(-4)
}
return phone || '****'
})
// ==================== 表单验证规则 ====================
const step1Rules = {
idNumber: [
{ required: true, message: '请输入身份证号', trigger: 'blur' },
{
pattern: /^[1-9]\d{5}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/,
message: '请输入正确的身份证号',
trigger: 'blur',
},
],
captchaCode: [
{ required: true, message: '请按顺序点击验证码', trigger: 'change' },
],
}
const step2Rules = {
smsCode: [
{ required: true, message: '请输入短信验证码', trigger: 'blur' },
{ len: 6, message: '请输入 6 位短信验证码', trigger: 'blur' },
],
}
const step3Rules = {
password: [
{ required: true, message: '请设置新密码', trigger: 'blur' },
{ min: 6, message: '密码至少 6 个字符', trigger: 'blur' },
{ max: 20, message: '密码不超过 20 个字符', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请确认新密码', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value !== step3Form.password) {
callback(new Error('两次输入的密码不一致'))
} else {
callback()
}
},
trigger: 'blur',
},
],
}
// ==================== 方法 ====================
/** 获取点击式验证码 */
async function refreshCaptcha() {
try {
captchaChars.value = []
captchaHint.value = ''
captchaClicked.value = new Set()
captchaSequence.value = ''
step1Form.captchaCode = ''
const data = await authApi.getCaptcha()
if (data && data.chars) {
captchaChars.value = data.chars
captchaHint.value = data.hint || ''
}
} catch (e) {
console.warn('获取验证码失败', e)
}
}
/** 点击验证码字符 */
function handleCaptchaClick(idx, char) {
if (captchaClicked.value.has(idx)) return
const newClicked = new Set(captchaClicked.value)
newClicked.add(idx)
captchaClicked.value = newClicked
captchaSequence.value += char
step1Form.captchaCode = captchaSequence.value
}
/** 步骤1:验证身份并发送短信 */
async function handleStep1() {
if (!step1FormRef.value) return
try {
await step1FormRef.value.validate()
step1Loading.value = true
const result = await authApi.forgotPassword(step1Form.idNumber, step1Form.captchaCode)
if (result?.phone) {
userPhone.value = result.phone
}
// 测试环境:后端随响应回传验证码,弹窗展示(生产环境不回传、不弹窗,走真实短信)
if (result?.smsCode) {
showTestSmsCode(result.smsCode, '忘记密码')
}
ElMessage.success('短信验证码已发送')
currentStep.value = 1
startSmsCountdown()
} catch (err) {
if (err?.message) ElMessage.error(err.message)
refreshCaptcha()
} finally {
step1Loading.value = false
}
}
/** 步骤2:验证短信验证码 */
async function handleStep2() {
if (!step2FormRef.value) return
try {
await step2FormRef.value.validate()
step2Loading.value = true
// 短信码由后端在重置密码时验证
currentStep.value = 2
} catch (err) {
if (err?.message) ElMessage.error(err.message)
} finally {
step2Loading.value = false
}
}
/** 步骤3:重置密码 */
async function handleReset() {
if (!step3FormRef.value) return
try {
await step3FormRef.value.validate()
step3Loading.value = true
await authApi.resetPassword({
id_number: step1Form.idNumber,
password: step3Form.password,
smsCode: step2Form.smsCode,
})
ElMessage.success('密码重置成功!请使用新密码登录')
setTimeout(() => {
router.push('/login')
}, 1500)
} catch (err) {
if (err?.message) ElMessage.error(err.message)
} finally {
step3Loading.value = false
}
}
/** 重新发送短信 */
async function resendSms() {
if (smsCountdown.value > 0) return
try {
await refreshCaptcha()
const result = await authApi.forgotPassword(step1Form.idNumber, step1Form.captchaCode)
if (result?.smsCode) {
showTestSmsCode(result.smsCode, '忘记密码(重新发送)')
}
ElMessage.success('短信验证码已重新发送')
startSmsCountdown()
} catch (err) {
if (err?.message) ElMessage.error(err.message)
refreshCaptcha()
}
}
/** 启动倒计时 */
function startSmsCountdown() {
smsCountdown.value = 60
clearInterval(smsTimer)
smsTimer = setInterval(() => {
smsCountdown.value--
if (smsCountdown.value <= 0) {
clearInterval(smsTimer)
smsTimer = null
}
}, 1000)
}
// ==================== 生命周期 ====================
onMounted(() => {
refreshCaptcha()
})
onUnmounted(() => {
if (smsTimer) clearInterval(smsTimer)
})
</script>
<style scoped>
.forgot-page {
min-height: 100vh;
background: linear-gradient(135deg, #f5f6f8 0%, #eef0f5 100%);
padding: 40px 20px;
display: flex;
justify-content: center;
}
.forgot-container {
width: 100%;
max-width: 540px;
}
.forgot-header {
text-align: center;
margin-bottom: 32px;
}
.header-icon {
font-size: 48px;
margin-bottom: 12px;
}
.header-title {
font-size: 28px;
font-weight: 700;
color: #1a3a5c;
margin-bottom: 8px;
}
.header-subtitle {
font-size: 14px;
color: #909399;
}
.forgot-steps {
margin-bottom: 32px;
}
.step-card {
border-radius: 12px;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
.step-card-title {
font-size: 16px;
font-weight: 600;
color: #1a3a5c;
}
/* ==================== 点击验证码 ==================== */
.click-captcha-area {
width: 100%;
}
.captcha-hint {
font-size: 14px;
color: #303133;
margin-bottom: 10px;
font-weight: 500;
}
.captcha-chars {
display: flex;
gap: 12px;
margin-bottom: 8px;
}
.captcha-char {
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #dcdfe6;
border-radius: 8px;
font-size: 22px;
font-weight: bold;
color: #303133;
cursor: pointer;
user-select: none;
background: #fff;
transition: all 0.2s;
}
.captcha-char:hover {
border-color: #409eff;
background: #ecf5ff;
transform: scale(1.05);
}
.captcha-char.clicked {
border-color: #67c23a;
background: #f0f9eb;
color: #67c23a;
cursor: default;
transform: scale(1.05);
}
.captcha-sequence {
font-size: 13px;
color: #606266;
margin-bottom: 6px;
}
.seq-chars {
font-weight: bold;
color: #67c23a;
letter-spacing: 4px;
}
.captcha-refresh {
font-size: 13px;
}
.sms-row {
display: flex;
gap: 12px;
}
.sms-input {
flex: 1;
}
.sms-btn {
flex-shrink: 0;
min-width: 110px;
}
.phone-display {
background: #f5f7fa;
border: 1px solid #e4e7ed;
border-radius: 6px;
padding: 12px 16px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
}
.phone-label {
font-size: 14px;
color: #909399;
}
.phone-value {
font-size: 14px;
color: #303133;
font-weight: 600;
}
.step-btn {
width: 100%;
height: 46px;
font-size: 16px;
}
.step-btn-group {
display: flex;
gap: 12px;
width: 100%;
}
.step-btn-group .el-button {
flex: 1;
}
.back-link {
text-align: center;
margin-top: 24px;
}
@media (max-width: 480px) {
.forgot-page {
padding: 20px 12px;
}
.header-title {
font-size: 22px;
}
.captcha-img {
width: 100px;
}
}
</style>

965
src/views/Home.vue Normal file
View File

@@ -0,0 +1,965 @@
<template>
<div class="home-page">
<!-- ==================== 顶部导航栏 ==================== -->
<header class="home-header">
<div class="header-inner">
<div class="header-logo">
<span class="logo-icon">&#9788;</span>
<span class="logo-text">卫星通信与导航应用技术创新赛</span>
</div>
<nav class="header-nav">
<a href="#intro" class="nav-link">赛事介绍</a>
<a href="#groups" class="nav-link">竞赛组别</a>
<a href="#news" class="nav-link">新闻公告</a>
<a href="#process" class="nav-link">参赛流程</a>
</nav>
<div class="header-actions">
<template v-if="authStore.isLoggedIn">
<el-button v-if="authStore.isAdmin" type="primary" size="default" @click="$router.push('/admin')">
管理后台
</el-button>
<el-button v-else type="primary" size="default" @click="$router.push('/dashboard')">
选手工作台
</el-button>
<el-button type="default" size="default" v-guard="() => handleLogout()">
退出登录
</el-button>
</template>
<template v-else>
<el-button type="primary" size="default" @click="$router.push('/login')">
选手登录
</el-button>
<el-button type="warning" size="default" @click="$router.push('/activate')">
激活账号
</el-button>
</template>
</div>
</div>
</header>
<!-- ==================== 轮播图区域(全部由后台数据库驱动) ==================== -->
<section class="carousel-section" v-if="carouselList.length">
<el-carousel
:interval="5000"
:height="carouselHeight"
indicator-position="outside"
arrow="always"
trigger="click"
>
<el-carousel-item v-for="item in carouselList" :key="'slide-' + item.id">
<div
class="carousel-slide"
:class="{ 'carousel-slide--gradient': !item.imageUrl }"
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : {}"
>
<div class="carousel-overlay"></div>
<div class="carousel-content">
<!-- 标题:用 v-html 以支持 <br> 等基础格式 -->
<h1 class="carousel-title" v-if="item.title" v-html="item.title"></h1>
<!-- 富文本正文(wangEditor 生成,v-html 渲染) -->
<div class="carousel-richtext" v-if="item.content" v-html="item.content"></div>
<!-- 可配置按钮:外链用 a[target=_blank],站内路由用 router-link -->
<template v-if="item.buttonText">
<a
v-if="isExternalLink(item.buttonLink)"
:href="item.buttonLink"
target="_blank"
rel="noopener noreferrer"
class="carousel-cta"
>{{ item.buttonText }}</a>
<router-link
v-else
:to="item.buttonLink || '/activate'"
class="carousel-cta"
>{{ item.buttonText }}</router-link>
</template>
</div>
</div>
</el-carousel-item>
</el-carousel>
</section>
<!-- ==================== 赛事介绍 ==================== -->
<section class="intro-section" id="intro">
<el-row justify="center">
<el-col :xs="24" :md="18" :lg="16">
<div class="intro-card">
<h2 class="section-title">
<span class="title-line"></span>
卫星通信与导航应用技术创新赛
</h2>
<el-divider />
<p class="intro-text">
卫星通信与导航应用技术竞赛为2026"一带一路"暨金砖国家技能发展与技术创新大赛全新增设核心赛项。
赛事聚焦卫星通信、导航定位领域前沿核心技术,紧扣产业发展实际需求与现代职业教育改革导向,
依托多层级、立体化竞赛考核体系,着力展示行业尖端技术成果,引领职业教育教学革新,
全面提升院校实训教学水平,锤炼参赛选手综合实操与创新能力,选拔培育卫星信息领域高素质技术技能人才。
</p>
</div>
</el-col>
</el-row>
</section>
<!-- ==================== 竞赛组别 ==================== -->
<section class="groups-section" id="groups">
<div class="section-header">
<h2 class="section-title"><span class="title-line"></span>竞赛组别</h2>
<p class="section-subtitle">选择适合您的赛道,展现您的技术实力</p>
</div>
<el-row justify="center">
<el-col :xs="24" :md="22" :lg="20">
<el-row :gutter="24" justify="center">
<el-col :xs="24" :sm="12" :md="6" v-for="grp in groups" :key="grp.key">
<el-card class="group-card" :class="`group-card--${grp.key}`" :body-style="{ padding: '0' }" shadow="hover">
<div class="group-card-inner">
<div class="group-card-header" :style="{ background: grp.color }">
<div class="group-icon"><span class="icon-placeholder">{{ grp.icon }}</span></div>
<h3 class="group-name">{{ grp.name }}</h3>
</div>
<div class="group-card-body">
<el-tag :color="grp.tagColor" class="group-tag" effect="dark">{{ grp.type }}</el-tag>
<p class="group-desc">{{ grp.desc }}</p>
<el-divider class="group-divider" />
<el-button class="group-link-btn" :style="{ color: grp.color, borderColor: grp.color }" plain round size="small" @click="$router.push('/login')">
立即报名
</el-button>
</div>
</div>
</el-card>
</el-col>
</el-row>
</el-col>
</el-row>
</section>
<!-- ==================== 新闻公告(带分组 Tab 筛选) ==================== -->
<section class="news-section" id="news">
<div class="section-header">
<h2 class="section-title"><span class="title-line"></span>新闻公告</h2>
<p class="section-subtitle">了解赛事最新动态与通知</p>
</div>
<el-row :gutter="20" justify="center">
<el-col :xs="24" :md="18" :lg="16">
<!-- 分组 Tab -->
<el-tabs v-model="activeNewsTab" @tab-change="onNewsTabChange" class="news-tabs">
<el-tab-pane
v-for="tab in newsTabs"
:key="tab.key"
:label="`${tab.label} (${tab.count})`"
:name="tab.key"
/>
</el-tabs>
<el-card shadow="never" class="news-card" v-loading="newsLoading">
<!-- 置顶新闻 -->
<div v-if="pinnedNews.length > 0" class="pinned-section">
<div class="pinned-header">
<el-icon color="#c9a84c"><Top /></el-icon>
<span class="pinned-title">置顶公告</span>
</div>
<div
v-for="item in pinnedNews"
:key="'pinned-' + item.id"
class="news-item pinned-item"
@click="viewNewsDetail(item.id)"
>
<el-tag type="warning" size="small" effect="dark">置顶</el-tag>
<span class="news-item-title text-ellipsis">{{ item.title }}</span>
<template v-if="item.tags">
<el-tag v-for="tag in (item.tags || '').split(',').filter(Boolean)" :key="tag" size="small" class="news-tag" round>{{ tag.trim() }}</el-tag>
</template>
<span class="news-item-time">{{ formatDate(item.publish_time) }}</span>
</div>
</div>
<!-- 普通新闻列表 -->
<div v-if="normalNews.length > 0" class="normal-section">
<div
v-for="item in normalNews"
:key="item.id"
class="news-item"
@click="viewNewsDetail(item.id)"
>
<span class="news-dot"></span>
<span class="news-item-title text-ellipsis">{{ item.title }}</span>
<template v-if="item.tags">
<el-tag v-for="tag in (item.tags || '').split(',').filter(Boolean)" :key="tag" size="small" class="news-tag" round>{{ tag.trim() }}</el-tag>
</template>
<span class="news-item-time">{{ formatDate(item.publish_time) }}</span>
</div>
</div>
<!-- 空状态 -->
<el-empty v-if="!newsLoading && pinnedNews.length === 0 && normalNews.length === 0" description="暂无新闻公告" />
<!-- 分页 -->
<div class="news-pagination" v-if="newsTotal > newsPagination.pageSize">
<el-pagination
v-model:current-page="newsPagination.page"
:page-size="newsPagination.pageSize"
:total="newsTotal"
layout="prev, pager, next"
size="small"
@current-change="onNewsPageChange"
/>
</div>
</el-card>
</el-col>
</el-row>
</section>
<!-- ==================== 参赛流程 ==================== -->
<section class="timeline-section" id="process">
<div class="section-header">
<h2 class="section-title"><span class="title-line"></span>参赛流程</h2>
<p class="section-subtitle">简单四步,开启您的竞赛之旅</p>
</div>
<el-row justify="center">
<el-col :xs="24" :md="18" :lg="16">
<div class="process-timeline">
<template v-for="(step, index) in processSteps" :key="index">
<div class="process-step">
<div class="process-step-badge">
<span class="step-number">{{ index + 1 }}</span>
</div>
<div class="process-step-content">
<h4 class="step-title">{{ step }}</h4>
</div>
</div>
<div class="process-step-arrow" v-if="index < processSteps.length - 1">
<span class="arrow-icon">&#10132;</span>
</div>
</template>
</div>
</el-col>
</el-row>
</section>
<!-- ==================== 资料下载(已移至工作台) ==================== -->
<!--
<section class="materials-section" id="materials">
<div class="section-header">
<h2 class="section-title"><span class="title-line"></span>资料下载</h2>
<p class="section-subtitle">关于中职组/高职组/本科组/教师组比赛相关的说明资料,点击按钮进入下载界面</p>
</div>
<el-row justify="center">
<el-col :xs="24" :md="18" :lg="16">
<div class="materials-card">
<el-alert
title="赛事资料请通过百度网盘下载"
type="info"
:closable="false"
show-icon
class="materials-alert"
>
<template #default>
<p class="materials-link">百度网盘链接:<el-link type="primary" href="https://pan.baidu.com" target="_blank">https://pan.baidu.com/s/xxxxxx</el-link>(提取码:2026)</p>
<p class="materials-hint">请使用微信扫描下方二维码获取资料,或登录后在工作台查看备赛资料</p>
</template>
</el-alert>
<div class="materials-qr">
<div class="qr-placeholder">
<span class="qr-icon">&#9633;</span>
<span class="qr-text">二维码</span>
</div>
<p class="qr-hint">微信扫码获取资料</p>
</div>
</div>
</el-col>
</el-row>
</section>
-->
<!-- ==================== 页脚 ==================== -->
<footer class="footer-section">
<div class="footer-content">
<h3 class="footer-contact-title">联系我们</h3>
<p class="footer-contact">赛事咨询:吴老师 13810099338</p>
<p class="footer-contact">大赛QQ群:1103585733</p>
<p class="footer-text">&copy; {{ currentYear }} 卫星通信与导航应用技术创新赛组委会 版权所有</p>
<p class="footer-subtext">技术支持 | 竞赛咨询请联系组委会官方渠道</p>
</div>
</footer>
</div>
</template>
<script setup>
/**
* Home.vue - 竞赛网站首页(公开访问)
*
* 功能:
* 1. 顶部导航栏(登录/激活按钮)
* 2. 固定两页轮播 + 管理员自定义轮播
* 3. 赛事介绍
* 4. 竞赛组别卡片(4 个组别)
* 5. 新闻公告(Tab 分组筛选)
* 6. 参赛流程时间线
* 7. 资料下载(百度网盘 + 二维码)
* 8. 页脚
*/
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Top } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import * as carouselApi from '@/api/carousel'
import * as newsApi from '@/api/news'
import { normalizeImageUrl } from '@/utils/upload'
const router = useRouter()
const authStore = useAuthStore()
// ==================== 状态 ====================
// 轮播图(管理员自定义)
const carouselList = ref([])
const carouselHeight = ref('500px')
// 新闻
const newsList = ref([])
const newsLoading = ref(false)
const activeNewsTab = ref('all')
const newsPagination = ref({ page: 1, pageSize: 10 })
const newsTotal = ref(0)
const newsTabs = ref([
{ key: 'all', label: '全部', count: 0 },
{ key: 'zhongzhi', label: '中职组', count: 0 },
{ key: 'gaozhi', label: '高职组', count: 0 },
{ key: 'benke', label: '本科组', count: 0 },
{ key: 'jiaoshi', label: '教师组', count: 0 },
])
// 竞赛组别
const groups = [
{ key: 'zhongzhi', name: '中职组', icon: '中职', color: '#3b82f6', tagColor: '#3b82f6', type: '在线答题', desc: '重点考察卫星通信与导航领域的基础核心知识和高精度接收机的实操能力。' },
{ key: 'gaozhi', name: '高职组', icon: '高职', color: '#10b981', tagColor: '#10b981', type: '在线答题', desc: '重点考察卫星通信与导航领域的基础核心知识和卫星通信设备的实操能力。' },
{ key: 'benke', name: '本科组', icon: '本科', color: '#f59e0b', tagColor: '#f59e0b', type: '提交作品', desc: '重点考察卫星通信与导航领域的基础核心知识和卫星通信与导航的应用创新能力。' },
{ key: 'jiaoshi', name: '教师组', icon: '教师', color: '#ef4444', tagColor: '#ef4444', type: '提交作品', desc: '重点考察卫星通信与导航技术相关课程的教学方案设计能力和现场表达展现能力。' },
]
const processSteps = ['账号激活', '确认考试信息', '参加考试', '成绩获取']
const currentYear = new Date().getFullYear()
// ==================== 计算属性 ====================
const pinnedNews = computed(() => newsList.value.filter(n => n.pinned))
const normalNews = computed(() => newsList.value.filter(n => !n.pinned))
// ==================== 方法 ====================
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}`
}
function viewNewsDetail(id) {
router.push(`/news/${id}`)
}
function onNewsTabChange() {
newsPagination.value.page = 1
fetchNews()
}
function onNewsPageChange() {
fetchNews()
}
/**
* 判断链接是否为外部链接(http/https/协议相对 //)
* @param {string} link
* @returns {boolean}
*/
function isExternalLink(link) {
if (!link) return false
return /^(https?:)?\/\//i.test(link)
}
async function fetchCarousel() {
try {
const data = await carouselApi.getCarouselList()
if (Array.isArray(data)) {
carouselList.value = data
.filter(item => item.enabled !== false)
.map(item => ({
id: item.id,
imageUrl: item.image_url ? normalizeImageUrl(item.image_url) : '',
title: item.title || '',
content: item.content || '',
buttonText: item.button_text || '',
buttonLink: item.button_link || '',
sortOrder: item.sort_order || 0,
}))
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
}
updateCarouselHeight()
window.addEventListener('resize', updateCarouselHeight)
} catch (err) {
console.warn('获取轮播图失败', err)
}
}
function updateCarouselHeight() {
carouselHeight.value = window.innerWidth < 768 ? '280px' : '500px'
}
async function fetchNews() {
newsLoading.value = true
try {
const params = {
page: newsPagination.value.page,
pageSize: newsPagination.value.pageSize,
}
if (activeNewsTab.value !== 'all') {
params.group = activeNewsTab.value
}
const data = await newsApi.getNewsList(params)
if (data && data.list) {
newsList.value = data.list
newsTotal.value = data.total || 0
} else if (Array.isArray(data)) {
newsList.value = data
newsTotal.value = data.length
} else {
newsList.value = []
newsTotal.value = 0
}
const counts = await newsApi.getNewsCounts()
if (counts) {
newsTabs.value.forEach(tab => {
tab.count = counts[tab.key] || 0
})
}
} catch (err) {
console.warn('获取新闻列表失败', err)
newsList.value = []
newsTotal.value = 0
} finally {
newsLoading.value = false
}
}
async function handleLogout() {
await authStore.logout()
}
onMounted(() => {
fetchCarousel()
fetchNews()
})
</script>
<style scoped>
/* ==================== CSS 变量 ==================== */
.home-page {
--primary: #1a3a5c;
--primary-light: #2a5a8c;
--primary-dark: #0f2640;
--gold: #c9a84c;
--gold-light: #e0c97a;
--bg-light: #f5f6f8;
--bg-white: #ffffff;
--text-primary: #1f2937;
--text-secondary: #6b7280;
--border-light: #e5e7eb;
min-height: 100vh;
background: var(--bg-light);
}
/* ==================== 顶部导航栏 ==================== */
.home-header {
position: sticky;
top: 0;
z-index: 1000;
background: rgba(26, 58, 92, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.header-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 60px;
}
.header-logo {
display: flex;
align-items: center;
gap: 10px;
}
.logo-icon {
font-size: 24px;
color: var(--gold);
}
.logo-text {
font-size: 16px;
font-weight: 700;
color: #fff;
letter-spacing: 1px;
}
.header-nav {
display: flex;
gap: 28px;
}
.nav-link {
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
transition: color 0.3s;
cursor: pointer;
}
.nav-link:hover {
color: var(--gold);
}
.header-actions {
display: flex;
gap: 10px;
}
@media (max-width: 768px) {
.header-nav { display: none; }
.logo-text { font-size: 13px; }
.header-inner { padding: 0 12px; }
}
/* ==================== 轮播图 ==================== */
.carousel-section {
position: relative;
background: var(--primary-dark);
}
.carousel-section .el-carousel__arrow {
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.18);
color: #fff;
font-size: 20px;
transition: all 0.3s ease;
border: none;
}
.carousel-section .el-carousel__arrow:hover {
/* 箭头图标为白色字体,hover 不可用浅/白色背景,否则图标隐形 */
background: rgba(26, 58, 92, 0.6);
}
.carousel-section .el-carousel__arrow--left {
left: 24px;
}
.carousel-section .el-carousel__arrow--right {
right: 24px;
}
.carousel-slide {
position: relative;
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
/* 无底图时的默认渐变背景(还原原固定页深蓝渐变外观) */
.carousel-slide--gradient {
background: linear-gradient(135deg, #0f2640 0%, #1a3a5c 30%, #2a5a8c 60%, #0f2640 100%);
}
.carousel-overlay {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
background: radial-gradient(ellipse at 30% 50%, rgba(201, 168, 76, 0.12) 0%, transparent 60%),
radial-gradient(ellipse at 70% 50%, rgba(201, 168, 76, 0.06) 0%, transparent 60%);
pointer-events: none;
}
.carousel-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2;
text-align: center;
color: #fff;
max-width: 800px;
width: 100%;
padding: 0 40px;
}
.carousel-title {
font-size: 40px;
font-weight: 800;
margin-bottom: 18px;
letter-spacing: 2px;
line-height: 1.3;
text-shadow: 0 2px 16px rgba(0, 0, 0, 0.4);
animation: carouselFadeInUp 0.7s ease;
}
.carousel-desc {
font-size: 18px;
color: rgba(255, 255, 255, 0.88);
line-height: 1.8;
margin-bottom: 32px;
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.3);
animation: carouselFadeInUp 0.7s ease 0.15s both;
}
/* 富文本正文容器(wangEditor 生成的 HTML 经 v-html 渲染)
scoped CSS 不作用于 v-html 子节点,故用 :deep() 兜底常见标签的可读性 */
.carousel-richtext {
font-size: 18px;
color: rgba(255, 255, 255, 0.9);
line-height: 1.8;
margin-bottom: 32px;
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.3);
animation: carouselFadeInUp 0.7s ease 0.15s both;
}
.carousel-richtext :deep(p),
.carousel-richtext :deep(span),
.carousel-richtext :deep(li),
.carousel-richtext :deep(div) {
color: inherit;
line-height: 1.8;
}
.carousel-richtext :deep(a) {
color: #e0c97a;
text-decoration: underline;
}
.carousel-richtext :deep(img) {
max-width: 100%;
border-radius: 8px;
}
.carousel-cta {
display: inline-block;
padding: 14px 44px;
font-size: 17px;
font-weight: 600;
letter-spacing: 2px;
color: #1a3a5c;
background: linear-gradient(135deg, #c9a84c, #e0c97a);
border: none;
border-radius: 50px;
text-decoration: none;
cursor: pointer;
box-shadow: 0 4px 20px rgba(201, 168, 76, 0.4);
transition: all 0.3s ease;
animation: carouselFadeInUp 0.7s ease 0.3s both;
}
.carousel-cta:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(201, 168, 76, 0.5);
color: #1a3a5c;
}
/* 轮播页二:赛项内容卡片 */
.carousel-items {
display: flex;
justify-content: center;
gap: 24px;
flex-wrap: wrap;
margin-bottom: 36px;
animation: carouselFadeInUp 0.7s ease 0.15s both;
}
.carousel-item-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 20px 24px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 12px;
min-width: 140px;
transition: all 0.3s ease;
}
.carousel-item-card:hover {
background: rgba(255, 255, 255, 0.18);
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(201, 168, 76, 0.2);
}
.carousel-item-card .item-icon {
font-size: 32px;
line-height: 1;
}
.carousel-item-card span {
font-size: 15px;
font-weight: 600;
letter-spacing: 1px;
}
@keyframes carouselFadeInUp {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.carousel-title {
font-size: 26px;
letter-spacing: 1px;
}
.carousel-desc {
font-size: 15px;
margin-bottom: 24px;
}
.carousel-cta {
font-size: 15px;
padding: 12px 32px;
}
.carousel-section .el-carousel__arrow {
width: 38px;
height: 38px;
}
.carousel-section .el-carousel__arrow--left {
left: 12px;
}
.carousel-section .el-carousel__arrow--right {
right: 12px;
}
.carousel-items {
gap: 12px;
}
.carousel-item-card {
min-width: 120px;
padding: 14px 16px;
}
.carousel-item-card .item-icon {
font-size: 26px;
}
.carousel-item-card span {
font-size: 13px;
}
}
/* ==================== Shared Sections ==================== */
.section-header {
text-align: center;
margin-bottom: 44px;
}
.section-title {
font-size: 30px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.title-line {
display: inline-block;
width: 4px;
height: 28px;
background: var(--gold);
border-radius: 2px;
}
.section-subtitle {
font-size: 15px;
color: var(--text-secondary);
}
/* ==================== Intro ==================== */
.intro-section {
padding: 72px 20px 56px;
background: var(--bg-white);
}
.intro-card { text-align: center; padding: 20px; }
.intro-text { font-size: 15px; color: var(--text-secondary); line-height: 1.9; margin-bottom: 16px; text-align: left; text-indent: 2em; }
/* ==================== Groups ==================== */
.groups-section {
padding: 72px 20px;
background: var(--bg-light);
}
.group-card { border-radius: 12px; overflow: hidden; border: 1px solid var(--border-light); margin-bottom: 24px; transition: transform 0.3s; }
.group-card:hover { transform: translateY(-6px); }
.group-card-inner { display: flex; flex-direction: column; height: 100%; }
.group-card-header { padding: 28px 20px 20px; text-align: center; color: #fff; }
.group-icon { width: 64px; height: 64px; border-radius: 50%; background: rgba(255, 255, 255, 0.2); display: flex; align-items: center; justify-content: center; margin: 0 auto 12px; }
.icon-placeholder { font-size: 15px; font-weight: 700; letter-spacing: 2px; white-space: nowrap; }
.group-name { font-size: 20px; font-weight: 700; margin: 0; letter-spacing: 2px; }
.group-card-body { padding: 20px; flex: 1; display: flex; flex-direction: column; align-items: center; text-align: center; background: var(--bg-white); }
.group-tag { margin-bottom: 12px; border: none; }
.group-desc { font-size: 13px; color: var(--text-secondary); line-height: 1.8; margin: 0; flex: 1; }
.group-divider { margin: 16px 0; }
.group-link-btn { border-width: 1.5px; }
/* ==================== News ==================== */
.news-section {
padding: 72px 20px;
background: var(--bg-white);
}
.news-tabs .el-tabs__nav-wrap::after { height: 1px; }
.news-tabs .el-tabs__item { font-size: 15px; }
.news-card { border-radius: 12px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); }
.pinned-section { margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px dashed var(--border-light); }
.pinned-header { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.pinned-title { font-size: 14px; font-weight: 600; color: var(--gold); }
.news-item { display: flex; align-items: center; gap: 12px; padding: 14px 0; border-bottom: 1px solid var(--border-light); cursor: pointer; transition: all 0.2s; }
.news-item:hover { background: #fafbfc; margin: 0 -12px; padding-left: 12px; padding-right: 12px; border-radius: 8px; }
.news-item:last-child { border-bottom: none; }
.news-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--primary-light); flex-shrink: 0; }
.news-item-title { font-size: 15px; color: var(--text-primary); flex: 1; min-width: 0; }
.text-ellipsis { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.news-tag { margin-left: 6px; font-size: 11px; }
.news-item-time { font-size: 13px; color: var(--text-secondary); white-space: nowrap; flex-shrink: 0; }
.news-pagination { margin-top: 20px; display: flex; justify-content: center; }
/* ==================== Timeline ==================== */
.timeline-section {
padding: 72px 20px;
background: var(--bg-light);
}
.process-timeline {
display: flex;
justify-content: center;
align-items: flex-start;
gap: 0;
flex-wrap: wrap;
}
.process-step {
display: flex;
flex-direction: column;
align-items: center;
min-width: 140px;
max-width: 180px;
position: relative;
}
.process-step-badge {
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary), var(--primary-light));
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
box-shadow: 0 4px 16px rgba(26, 58, 92, 0.25);
}
.step-number {
font-size: 24px;
font-weight: 800;
color: #fff;
}
.process-step-content {
text-align: center;
}
.step-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.process-step-arrow {
display: flex;
align-items: flex-start;
justify-content: center;
flex: 0 0 auto;
/* 垂直对齐流程点徽章中心:徽章高 64,中心在 32;箭头图标约 20 高,半高 10 → 32-10=22 */
margin: 22px 14px 0;
z-index: 1;
}
.arrow-icon {
font-size: 20px;
color: var(--primary-light);
}
/* ==================== Materials ==================== */
.materials-section {
padding: 72px 20px;
background: var(--bg-white);
}
.materials-card {
background: var(--bg-light);
border-radius: 12px;
padding: 32px;
text-align: center;
}
.materials-alert {
margin-bottom: 24px;
text-align: left;
}
.materials-link {
margin: 0 0 8px;
font-size: 14px;
}
.materials-hint {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
}
.materials-qr {
margin-top: 8px;
}
.qr-placeholder {
width: 140px;
height: 140px;
border: 2px dashed var(--border-light);
border-radius: 8px;
margin: 0 auto 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #fff;
}
.qr-icon {
font-size: 36px;
color: var(--text-secondary);
}
.qr-text {
font-size: 13px;
color: var(--text-secondary);
margin-top: 4px;
}
.qr-hint {
font-size: 13px;
color: var(--text-secondary);
}
/* ==================== Footer ==================== */
.footer-section {
background: var(--primary-dark);
color: rgba(255, 255, 255, 0.7);
padding: 48px 20px 32px;
text-align: center;
}
.footer-content {
max-width: 800px;
margin: 0 auto;
}
.footer-contact-title {
font-size: 18px;
font-weight: 600;
color: #fff;
margin-bottom: 12px;
}
.footer-contact {
font-size: 14px;
margin: 6px 0;
}
.footer-text {
font-size: 13px;
margin-top: 24px;
color: rgba(255, 255, 255, 0.5);
}
.footer-subtext {
font-size: 12px;
color: rgba(255, 255, 255, 0.35);
margin-top: 4px;
}
/* ==================== Responsive ==================== */
@media (max-width: 768px) {
.section-title { font-size: 24px; }
.intro-section, .groups-section, .news-section, .timeline-section { padding: 48px 16px; }
.process-step-arrow { display: none; }
.process-timeline { flex-direction: column; gap: 32px; }
.process-step { flex-direction: row; gap: 16px; min-width: auto; width: 100%; max-width: 300px; }
.process-step-badge { margin-bottom: 0; flex-shrink: 0; }
.process-step-content { text-align: left; }
}
</style>

440
src/views/Login.vue Normal file
View File

@@ -0,0 +1,440 @@
<template>
<div class="login-page">
<div class="login-container">
<!-- 左侧品牌区域 -->
<div class="login-brand">
<div class="brand-content">
<div class="brand-icon">&#9788;</div>
<h1 class="brand-title">卫星通信与导航应用技术创新赛</h1>
<p class="brand-subtitle">汇聚创新力量,推动卫星通信与导航技术融合发展</p>
</div>
</div>
<!-- 右侧登录表单 -->
<div class="login-form-wrapper">
<div class="login-form-card">
<!-- 登录过期提示 -->
<el-alert
v-if="expiredNotice"
type="warning"
:closable="false"
show-icon
class="expired-banner"
title="登录状态已过期"
description="您的登录状态已失效,请重新登录以继续访问备赛资料等功能。"
/>
<!-- Tab 切换 -->
<el-tabs v-model="loginType" class="login-tabs" :before-leave="onTabChange">
<el-tab-pane label="选手登录" name="contestant" />
<el-tab-pane label="管理员登录" name="admin" />
</el-tabs>
<!-- ========== 选手登录表单 ========== -->
<el-form
v-if="loginType === 'contestant'"
ref="contestantFormRef"
:model="contestantForm"
:rules="contestantRules"
label-position="top"
@keyup.enter="handleContestantLogin"
>
<el-form-item label="手机号" prop="phone">
<el-input
v-model="contestantForm.phone"
placeholder="请输入手机号"
:prefix-icon="Phone"
size="large"
clearable
maxlength="11"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="contestantForm.password"
type="password"
placeholder="请输入密码"
:prefix-icon="Lock"
size="large"
show-password
@keyup.enter="handleContestantLogin"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="login-btn"
:loading="contestantLoading"
v-guard="() => handleContestantLogin()"
>
登 录
</el-button>
</el-form-item>
<div class="login-links">
<el-link type="primary" @click="$router.push('/activate')">激活账号</el-link>
<el-link type="primary" @click="$router.push('/forgot-password')">忘记密码</el-link>
</div>
</el-form>
<!-- ========== 管理员登录表单 ========== -->
<el-form
v-if="loginType === 'admin'"
ref="adminFormRef"
:model="adminForm"
:rules="adminRules"
label-position="top"
@keyup.enter="handleAdminLogin"
>
<el-form-item label="管理员账号" prop="username">
<el-input
v-model="adminForm.username"
placeholder="请输入管理员账号"
:prefix-icon="UserFilled"
size="large"
clearable
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="adminForm.password"
type="password"
placeholder="请输入密码"
:prefix-icon="Lock"
size="large"
show-password
@keyup.enter="handleAdminLogin"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="login-btn"
:loading="adminLoading"
v-guard="() => handleAdminLogin()"
>
管理员登录
</el-button>
</el-form-item>
</el-form>
<!-- 返回首页 -->
<div class="back-home">
<el-link type="info" @click="$router.push('/')">
<el-icon><ArrowLeft /></el-icon><span>返回首页</span>
</el-link>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
/**
* Login.vue - 登录页面(公开访问)
*
* 功能:
* 1. Tab 切换:选手登录 / 管理员登录
* 2. 选手登录:身份证号 + 密码
* 3. 管理员登录:用户名 + 密码
* 4. 链接:激活账号、忘记密码
* 5. 登录成功后跳转:选手 -> /dashboard,管理员 -> /admin
*/
import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { UserFilled, Lock, ArrowLeft, Phone } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
// ==================== 状态 ====================
const loginType = ref('contestant')
const contestantLoading = ref(false)
const adminLoading = ref(false)
// 登录过期提示(由路由 reason=expired 触发)
const expiredNotice = ref(false)
const contestantFormRef = ref(null)
const adminFormRef = ref(null)
// 选手表单
const contestantForm = reactive({
phone: '',
password: '',
})
// 管理员表单
const adminForm = reactive({
username: '',
password: '',
})
// ==================== 表单验证规则 ====================
const contestantRules = {
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{
pattern: /^1[3-9]\d{9}$/,
message: '请输入正确的手机号',
trigger: 'blur',
},
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少 6 个字符', trigger: 'blur' },
],
}
const adminRules = {
username: [
{ required: true, message: '请输入管理员账号', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
],
}
// ==================== 方法 ====================
/** Tab 切换时清空表单 */
function onTabChange() {
// 清空两个表单
contestantForm.phone = ''
contestantForm.password = ''
adminForm.username = ''
adminForm.password = ''
contestantFormRef.value?.resetFields()
adminFormRef.value?.resetFields()
}
/** 选手登录 */
async function handleContestantLogin() {
if (!contestantFormRef.value) return
try {
await contestantFormRef.value.validate()
contestantLoading.value = true
await authStore.login(contestantForm.phone, contestantForm.password)
ElMessage.success('登录成功')
// 跳转到目标页面
const redirect = route.query.redirect || '/dashboard'
router.push(redirect)
} catch (err) {
if (err?.message) {
ElMessage.error(err.message)
} else if (err !== false) {
// 表单验证失败不提示
}
} finally {
contestantLoading.value = false
}
}
/** 管理员登录 */
async function handleAdminLogin() {
if (!adminFormRef.value) return
try {
await adminFormRef.value.validate()
adminLoading.value = true
await authStore.adminLogin(adminForm.username, adminForm.password)
ElMessage.success('管理员登录成功')
const redirect = route.query.redirect || '/admin'
router.push(redirect)
} catch (err) {
if (err?.message) {
ElMessage.error(err.message)
}
} finally {
adminLoading.value = false
}
}
// ==================== 生命周期 ====================
onMounted(() => {
// 兜底同步:若 localStorage 已无 token(被路由守卫/401 拦截器清理),
// 但以 Pinia store 仍认为已登录,则以 localStorage 为准重新清理,避免登录页自动跳回首页造成死循环。
if (!localStorage.getItem('token') && authStore.isLoggedIn) {
authStore.clearAuth()
}
// 如已登录则自动跳转
if (authStore.isLoggedIn) {
const target = authStore.isAdmin ? '/admin' : '/dashboard'
router.push(target)
}
// 登录过期的友好提示
expiredNotice.value = route.query.reason === 'expired'
})
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0f2640 0%, #1a3a5c 40%, #2a5a8c 100%);
padding: 20px;
}
.login-container {
display: flex;
width: 100%;
max-width: 960px;
min-height: 540px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
/* ==================== 左侧品牌区域 ==================== */
.login-brand {
flex: 1;
background: linear-gradient(135deg, #1a3a5c 0%, #0f2640 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
position: relative;
overflow: hidden;
}
.login-brand::before {
content: '';
position: absolute;
top: -50%;
right: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle at 30% 50%, rgba(201, 168, 76, 0.1) 0%, transparent 50%);
}
.brand-content {
position: relative;
z-index: 1;
text-align: center;
}
.brand-icon {
font-size: 56px;
color: #c9a84c;
margin-bottom: 20px;
}
.brand-title {
font-size: 24px;
font-weight: 700;
color: #fff;
margin-bottom: 16px;
letter-spacing: 2px;
line-height: 1.4;
}
.brand-subtitle {
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
line-height: 1.6;
}
/* ==================== 右侧表单区域 ==================== */
.login-form-wrapper {
flex: 1;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
}
.login-form-card {
width: 100%;
max-width: 360px;
}
.expired-banner {
margin-bottom: 16px;
}
.login-tabs {
margin-bottom: 10px;
}
.login-tabs :deep(.el-tabs__header) {
margin-bottom: 24px;
}
.login-tabs :deep(.el-tabs__item) {
font-size: 16px;
font-weight: 600;
}
.login-btn {
width: 100%;
height: 46px;
font-size: 16px;
letter-spacing: 4px;
font-weight: 600;
}
.login-links {
display: flex;
justify-content: space-between;
margin-top: -8px;
}
.back-home {
text-align: center;
margin-top: 20px;
}
/* ==================== Responsive ==================== */
@media (max-width: 768px) {
.login-container {
flex-direction: column;
max-width: 420px;
min-height: auto;
}
.login-brand {
padding: 32px 20px;
}
.brand-icon {
font-size: 40px;
}
.brand-title {
font-size: 20px;
}
.login-form-wrapper {
padding: 32px 20px;
}
}
@media (max-width: 480px) {
.login-page {
padding: 12px;
}
.login-container {
border-radius: 12px;
}
.brand-title {
font-size: 18px;
}
}
</style>

361
src/views/Materials.vue Normal file
View File

@@ -0,0 +1,361 @@
<template>
<div class="materials-page">
<!-- 顶部导航 -->
<div class="mat-header">
<div class="mat-header-left">
<el-button text @click="$router.push('/')" class="back-btn">
<el-icon><HomeFilled /></el-icon><span>返回首页</span>
</el-button>
<el-button text @click="$router.push('/dashboard')" class="back-btn">
<el-icon><ArrowLeft /></el-icon><span>返回工作台</span>
</el-button>
</div>
<h2 class="mat-title">备赛资料</h2>
<div class="mat-header-right">
<span class="group-tag">{{ groupLabel }}</span>
</div>
</div>
<!-- 内容区域 -->
<div class="mat-content">
<!-- 资料列表 -->
<el-card shadow="never" class="mat-card" v-loading="loading">
<template #header>
<div class="mat-card-header">
<span class="mat-card-title">可用资料列表</span>
<span class="mat-card-count">共 {{ materials.length }} 个文件</span>
</div>
</template>
<div v-if="materials.length > 0">
<div
v-for="item in materials"
:key="item.id"
class="mat-item"
>
<div class="mat-item-info">
<div class="mat-item-icon">
<el-icon :size="32"><Document /></el-icon>
</div>
<div class="mat-item-detail">
<span class="mat-item-name">{{ item.name || item.fileName || '未命名文件' }}</span>
<span class="mat-item-meta">
<template v-if="item.size">{{ formatSize(item.size) }}</template>
<template v-if="item.type"> | {{ item.type.toUpperCase() }}</template>
<template v-if="item.uploadTime"> | {{ formatDate(item.uploadTime) }}</template>
</span>
</div>
</div>
<el-button
type="primary"
:icon="Download"
:loading="downloadingId === item.id"
v-guard="() => downloadMaterial(item)"
>
下载
</el-button>
</div>
</div>
<el-empty v-else description="暂无可用资料" :image-size="120" />
</el-card>
</div>
</div>
</template>
<script setup>
/**
* Materials.vue - 备赛资料页面(需要登录)
*
* 功能:
* 1. 按组别显示可下载的备赛资料列表
* 2. 下载按钮(带 loading 状态)
* 3. 资料说明静态区域
*/
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { ArrowLeft, HomeFilled, Document, Download } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import * as materialsApi from '@/api/materials'
import { safeDownload } from '@/utils/download'
const authStore = useAuthStore()
// ==================== 状态 ====================
const loading = ref(false)
const materials = ref([])
const downloadingId = ref(null)
// ==================== 计算属性 ====================
const groupLabel = computed(() => {
const map = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' }
return map[authStore.userGroup] || authStore.userGroup || '未知组别'
})
// ==================== 方法 ====================
function formatDate(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function formatSize(bytes) {
if (!bytes) return ''
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
}
/** 下载资料 */
async function downloadMaterial(item) {
try {
downloadingId.value = item.id
const fileName = item.fileName || item.name
const url = `/api/materials/${authStore.userGroup}/${encodeURIComponent(fileName)}/download`
// safeDownload 自带「HTTP 状态 + content-type」双重校验,绝不会把后端错误 JSON 存成文件
const ok = await safeDownload(url, { fallbackName: fileName || 'material' })
if (ok) ElMessage.success('下载成功')
} catch (err) {
ElMessage.error('下载失败:' + (err?.message || '未知错误'))
} finally {
downloadingId.value = null
}
}
/** 获取资料列表 */
async function fetchMaterials() {
loading.value = true
try {
const data = await materialsApi.getMaterialsList(authStore.userGroup)
if (Array.isArray(data)) {
materials.value = data
} else if (data?.list) {
materials.value = data.list
}
} catch (err) {
console.warn('获取资料列表失败', err)
materials.value = []
} finally {
loading.value = false
}
}
// ==================== 生命周期 ====================
onMounted(() => {
fetchMaterials()
})
</script>
<style scoped>
.materials-page {
min-height: 100vh;
background: #f5f6f8;
padding-bottom: 40px;
}
/* ==================== 顶部导航 ==================== */
.mat-header {
background: #1a3a5c;
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 60px;
position: sticky;
top: 0;
z-index: 100;
}
.back-btn {
color: rgba(255,255,255,0.85);
white-space: nowrap;
}
.back-btn:hover { color: #c9a84c; }
.mat-title {
font-size: 18px;
font-weight: 600;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.group-tag {
background: rgba(201, 168, 76, 0.2);
color: #c9a84c;
padding: 4px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
}
.mat-header-left {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.mat-header-right {
flex-shrink: 0;
text-align: right;
}
/* ==================== 内容 ==================== */
.mat-content {
max-width: 860px;
margin: 24px auto 0;
padding: 0 20px;
}
.mat-card {
border-radius: 12px;
border: 1px solid #e5e7eb;
margin-bottom: 24px;
}
.mat-card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.mat-card-title {
font-size: 16px;
font-weight: 600;
color: #1f2937;
}
.mat-card-count {
font-size: 13px;
color: #909399;
}
/* ==================== 资料列表项 ==================== */
.mat-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 8px;
border-bottom: 1px solid #f0f0f0;
gap: 16px;
}
.mat-item:last-child { border-bottom: none; }
.mat-item-info {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
min-width: 0;
}
.mat-item-icon {
width: 48px;
height: 48px;
border-radius: 8px;
background: #eef2ff;
display: flex;
align-items: center;
justify-content: center;
color: #1a3a5c;
flex-shrink: 0;
}
.mat-item-detail {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.mat-item-name {
font-size: 15px;
font-weight: 500;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mat-item-meta {
font-size: 12px;
color: #909399;
}
/* ==================== 说明区域 ==================== */
.mat-description {
font-size: 14px;
color: #6b7280;
line-height: 1.8;
}
.syllabus-section {
font-size: 16px;
font-weight: 600;
color: #1a3a5c;
margin: 20px 0 12px;
padding-bottom: 6px;
border-bottom: 2px solid #c9a84c;
}
.syllabus-section:first-child {
margin-top: 0;
}
.syllabus-table {
margin-bottom: 8px;
}
.syllabus-collapse {
margin-bottom: 8px;
}
.syllabus-collapse :deep(.el-collapse-item__header) {
font-weight: 600;
color: #1a3a5c;
}
.syllabus-collapse p {
font-size: 13px;
color: #606266;
margin: 4px 0;
padding-left: 8px;
border-left: 2px solid #e4e7ed;
}
.syllabus-list {
padding-left: 20px;
margin-bottom: 12px;
}
.syllabus-list li {
margin-bottom: 6px;
font-size: 14px;
color: #606266;
}
.syllabus-note {
font-size: 13px;
color: #c9a84c;
font-weight: 500;
margin-top: 12px;
padding: 8px 12px;
background: #fdf6e3;
border-radius: 6px;
border-left: 3px solid #c9a84c;
}
/* ==================== Responsive ==================== */
@media (max-width: 480px) {
.mat-header { padding: 0 12px; }
.mat-item { flex-direction: column; align-items: flex-start; gap: 12px; }
.mat-item .el-button { align-self: flex-end; }
}
</style>

826
src/views/MockExam.vue Normal file
View File

@@ -0,0 +1,826 @@
<template>
<div class="mock-exam-page">
<!-- ==================== 模拟考试提示横幅 ==================== -->
<div class="mock-banner">
<el-icon :size="18"><WarningFilled /></el-icon>
<span>模拟考试系统,模拟选拔赛的完整流程,仅作考试流程演示,提供习题并非考试真题。</span>
</div>
<!-- ==================== 顶部倒计时栏 ==================== -->
<div class="exam-top-bar" :class="{ 'time-critical': remainingSeconds < 300 && remainingSeconds > 0 }">
<div class="exam-top-inner">
<div class="exam-top-left">
<div class="exam-timer">
<el-icon :size="20"><Timer /></el-icon>
<span class="timer-display" :class="{ 'timer-danger': remainingSeconds < 300 }">
{{ countdownDisplay }}
</span>
</div>
<div class="exam-progress">
<span>已答 {{ answeredCount }} / {{ totalCount }} 题</span>
<el-progress :percentage="progressPercent" :stroke-width="8" :color="progressColor" />
</div>
</div>
<span class="exam-title">模拟考试</span>
<div class="exam-top-right">
<span class="group-tag">{{ groupLabel }}</span>
</div>
</div>
</div>
<!-- ==================== 主布局 ==================== -->
<div class="exam-body">
<!-- 左侧题目导航 -->
<div class="exam-sidebar">
<h4 class="sidebar-title">题目导航</h4>
<!-- 选择题 -->
<div class="nav-section">
<div class="nav-section-title">选择题 ({{ mockChoiceQuestions.length }}题)</div>
<div class="question-nav">
<div
v-for="(q, idx) in mockChoiceQuestions"
:key="'choice-' + idx"
class="nav-item"
:class="{
'nav-answered': answers[q.id] !== undefined,
'nav-current': currentIndex === findIndex(q.id),
}"
@click="goToQuestion(findIndex(q.id))"
>
{{ findIndex(q.id) + 1 }}
</div>
</div>
</div>
<!-- 判断题 -->
<div class="nav-section">
<div class="nav-section-title">判断题 ({{ mockJudgeQuestions.length }}题)</div>
<div class="question-nav">
<div
v-for="(q, idx) in mockJudgeQuestions"
:key="'judge-' + idx"
class="nav-item"
:class="{
'nav-answered': answers[q.id] !== undefined,
'nav-current': currentIndex === findIndex(q.id),
}"
@click="goToQuestion(findIndex(q.id))"
>
{{ findIndex(q.id) + 1 }}
</div>
</div>
</div>
<!-- Word 答题 -->
<div class="nav-section">
<div class="nav-section-title">Word 答题</div>
<div class="question-nav">
<div
class="nav-item"
:class="{
'nav-answered': wordCompleted,
'nav-current': currentIndex === wordIndex,
}"
@click="currentIndex = wordIndex"
>
{{ wordIndex + 1 }}
</div>
</div>
</div>
<div class="sidebar-legend">
<span class="legend-item">
<span class="legend-dot answered"></span> 已答
</span>
<span class="legend-item">
<span class="legend-dot current"></span> 当前
</span>
<span class="legend-item">
<span class="legend-dot unanswered"></span> 未答
</span>
</div>
</div>
<!-- 右侧题目区域 -->
<div class="exam-main">
<!-- 选择题 -->
<div v-if="isCurrentQuestion && currentQuestion && currentQuestion.type === 'choice'" class="question-card">
<div class="question-header">
<span class="question-number">第 {{ currentIndex + 1 }} 题</span>
<el-tag type="primary" size="small">选择题</el-tag>
<span class="question-score">({{ currentQuestion.score || 0 }} 分)</span>
</div>
<div class="question-body">
<p class="question-text">{{ currentQuestion.question }}</p>
<el-radio-group
v-model="answers[currentQuestion.id]"
class="options-group"
>
<div
v-for="(opt, oi) in currentQuestion.options"
:key="oi"
class="option-item"
>
<el-radio :value="opt.value">
<span class="option-label">{{ optionLabels[oi] }}.</span>
{{ opt.text }}
</el-radio>
</div>
</el-radio-group>
</div>
<div class="question-footer">
<el-button :disabled="currentIndex === 0" @click="prevQuestion">上一题</el-button>
<el-button type="primary" @click="nextQuestion">
下一题
</el-button>
</div>
</div>
<!-- 判断题 -->
<div v-if="isCurrentQuestion && currentQuestion && currentQuestion.type === 'judge'" class="question-card">
<div class="question-header">
<span class="question-number">第 {{ currentIndex + 1 }} 题</span>
<el-tag type="success" size="small">判断题</el-tag>
<span class="question-score">({{ currentQuestion.score || 0 }} 分)</span>
</div>
<div class="question-body">
<p class="question-text">{{ currentQuestion.question }}</p>
<el-radio-group
v-model="answers[currentQuestion.id]"
class="options-group"
>
<div class="option-item">
<el-radio value="true">正确</el-radio>
</div>
<div class="option-item">
<el-radio value="false">错误</el-radio>
</div>
</el-radio-group>
</div>
<div class="question-footer">
<el-button :disabled="currentIndex === 0" @click="prevQuestion">上一题</el-button>
<el-button type="primary" @click="nextQuestion">
下一题
</el-button>
</div>
</div>
<!-- Word 答题 -->
<div v-if="currentIndex === wordIndex" class="question-card word-card">
<div class="question-header">
<span class="question-number">第 {{ wordIndex + 1 }} 题</span>
<el-tag type="danger" size="small">综合题</el-tag>
</div>
<div class="question-body">
<p class="question-text">请下载 Word 答题模板,在 Word 中完成答题后上传(模拟考试,文件不会真实上传)。</p>
<div class="word-sections">
<div class="word-section">
<h4>第 1 步:下载模板</h4>
<el-button type="primary" :icon="Download" v-guard="() => downloadMockTemplate()">
下载 Word 答题模板
</el-button>
</div>
<el-divider />
<div class="word-section">
<h4>第 2 步:上传答卷</h4>
<el-upload
class="word-upload"
drag
:auto-upload="false"
:limit="1"
:on-change="onMockWordChange"
:file-list="wordFileList"
accept=".doc,.docx"
>
<el-icon class="el-icon--upload" :size="36"><UploadFilled /></el-icon>
<div class="el-upload__text">
拖拽或 <em>点击上传</em> Word 文件
</div>
<template #tip>
<div class="el-upload__tip">模拟考试,文件不会真实上传</div>
</template>
</el-upload>
<div v-if="wordCompleted" class="uploaded-info">
<el-tag type="success" size="small">模拟上传成功</el-tag>
<span class="uploaded-name">{{ wordFileName }}</span>
</div>
</div>
</div>
</div>
<div class="question-footer">
<el-button :disabled="currentIndex === 0" @click="prevQuestion">上一题</el-button>
<el-button type="primary" @click="nextQuestion">
下一题
</el-button>
</div>
</div>
</div>
</div>
<!-- ==================== 底部提交栏 ==================== -->
<div class="exam-bottom-bar">
<div class="exam-bottom-inner">
<span class="submit-hint">
请确保已检查所有题目和 Word 文件后提交
</span>
<el-button
type="danger"
size="large"
v-guard="() => handleSubmitAll()"
:loading="submitting"
>
提交全部
</el-button>
</div>
</div>
</div>
</template>
<script setup>
/**
* MockExam.vue - 模拟考试页面
* 仅中职/高职组可见,模拟选拔赛完整流程,不保存数据
*/
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, onBeforeRouteLeave } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Timer, Download, UploadFilled, WarningFilled } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import * as examApi from '@/api/exam'
const router = useRouter()
const authStore = useAuthStore()
// ==================== 组别标签 ====================
const groupLabel = computed(() => {
const map = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' }
return map[authStore.userGroup] || '未知组别'
})
// ==================== 选项标签 ====================
const optionLabels = ['A', 'B', 'C', 'D']
// ==================== 模拟题目数据 ====================
// 设计意图(与用户明确要求一致):
// 1) 模拟考试的题量必须与在线正式考试一致:选择题 50 道 + 判断题 20 道(+ 1 道 Word 综合题)。
// 2) 为了让学生「明显感觉这只是体验考试形式」,选择题全部使用同一道样题(第一题),
// 判断题也全部使用同一道样题(第一题),后面每一题内容都与第一题相同。
// 3) 关键:即便内容相同,每一题也必须拥有全局唯一的 id(c1..c50 / j1..j20)。
// 否则 answers 以 id 为键会相互覆盖,重演在线考试「序号重复 / 答完仍提示未作答」的碰撞 Bug。
const MOCK_CHOICE_COUNT = 50
const MOCK_JUDGE_COUNT = 20
// 选择题样题(第一题)——所有选择题都复用它
const MOCK_CHOICE_SAMPLE = {
type: 'choice',
question: '卫星通信中,GEO卫星的轨道高度约为多少公里?',
options: [
{ value: 'A', text: '780km' },
{ value: 'B', text: '20200km' },
{ value: 'C', text: '35786km' },
{ value: 'D', text: '1000km' },
],
score: 1,
}
// 判断题样题(第一题)——所有判断题都复用它
const MOCK_JUDGE_SAMPLE = {
type: 'judge',
question: 'GEO卫星相对于地球是静止的,轨道高度约为35786km。',
score: 1,
}
// 生成 50 道内容相同、id 唯一的选择题
const mockChoiceQuestions = Array.from({ length: MOCK_CHOICE_COUNT }, (_, i) => ({
id: 'c' + (i + 1),
type: MOCK_CHOICE_SAMPLE.type,
question: MOCK_CHOICE_SAMPLE.question,
// options 深拷贝,避免多题共享同一引用
options: MOCK_CHOICE_SAMPLE.options.map(o => ({ ...o })),
score: MOCK_CHOICE_SAMPLE.score,
}))
// 生成 20 道内容相同、id 唯一的判断题
const mockJudgeQuestions = Array.from({ length: MOCK_JUDGE_COUNT }, (_, i) => ({
id: 'j' + (i + 1),
type: MOCK_JUDGE_SAMPLE.type,
question: MOCK_JUDGE_SAMPLE.question,
score: MOCK_JUDGE_SAMPLE.score,
}))
// 合并所有题目(选择题在前、判断题在后,与在线考试一致)
const allQuestions = [...mockChoiceQuestions, ...mockJudgeQuestions]
// ==================== 状态 ====================
const currentIndex = ref(0)
const answers = ref({})
const submitting = ref(false)
const remainingSeconds = ref(3600) // 60分钟模拟
const wordCompleted = ref(false)
const wordFileName = ref('')
const wordFileList = ref([])
let timerInterval = null
// ==================== 计算属性 ====================
const totalCount = computed(() => allQuestions.length + 1) // +1 for Word
const wordIndex = computed(() => allQuestions.length)
const isCurrentQuestion = computed(() => currentIndex.value < allQuestions.length)
const currentQuestion = computed(() => {
if (currentIndex.value < allQuestions.length) {
return allQuestions[currentIndex.value]
}
return null
})
const answeredCount = computed(() => {
let count = Object.keys(answers.value).filter(k => answers.value[k] !== undefined && answers.value[k] !== '').length
if (wordCompleted.value) count++
return count
})
const progressPercent = computed(() => {
return totalCount.value > 0 ? Math.round((answeredCount.value / totalCount.value) * 100) : 0
})
const progressColor = computed(() => {
if (progressPercent.value >= 80) return '#67c23a'
if (progressPercent.value >= 50) return '#409eff'
return '#e6a23c'
})
const countdownDisplay = computed(() => {
const h = Math.floor(remainingSeconds.value / 3600)
const m = Math.floor((remainingSeconds.value % 3600) / 60)
const s = remainingSeconds.value % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
})
// ==================== 方法 ====================
function findIndex(id) {
return allQuestions.findIndex(q => q.id === id)
}
function goToQuestion(idx) {
if (idx >= 0 && idx < totalCount.value) {
currentIndex.value = idx
}
}
function prevQuestion() {
if (currentIndex.value > 0) {
currentIndex.value--
}
}
function nextQuestion() {
if (currentIndex.value < totalCount.value - 1) {
currentIndex.value++
}
}
function onMockWordChange(file) {
wordCompleted.value = true
wordFileName.value = file.name
ElMessage.success('模拟上传成功')
}
async function downloadMockTemplate() {
// 模拟考试使用「固定样例模板」,与在线正式考试的真实 Word 模板完全隔离:
// - 不再请求 /api/exam/:group/word-template(那是各组别的真实考试模板);
// - 直接下载随前端一起发布的静态样例文件 public/mock-word-template.docx。
// 这样即便正式模板未上传 / 变更,也不会影响模拟考试的体验,且不会泄露真实模板。
try {
const url = `${import.meta.env.BASE_URL || '/'}mock-word-template.docx`
const response = await fetch(url)
if (!response.ok) {
ElMessage.error('样例模板下载失败')
return
}
const blob = await response.blob()
// 🔴 同源守卫(缺口10 修复):样例模板缺失时 dev/部署可能走 SPA 回退吐 index.html,
// 直接存成 .docx 会损坏。先嗅探是否为 HTML,是则报错不下载。
const tplHead = await blob.slice(0, 256).text()
if (/^\s*(<!doctype\s+html|<html[\s>])/i.test(tplHead)) {
ElMessage.error('样例模板文件缺失或不可用,请联系管理员')
return
}
const downloadUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = downloadUrl
link.download = '模拟考试Word答题样例.docx'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(downloadUrl)
ElMessage.success('样例模板已下载')
} catch (err) {
ElMessage.error('样例模板下载失败')
}
}
async function handleSubmitAll() {
const unanswered = totalCount.value - answeredCount.value
const confirmMsg = unanswered > 0
? `还有 ${unanswered} 题未作答,确定要提交吗?`
: '确定要提交全部答案吗?'
try {
await ElMessageBox.confirm(confirmMsg, '模拟提交确认', {
confirmButtonText: '确认提交',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
submitting.value = true
setTimeout(() => {
submitting.value = false
stopTimer()
ElMessage.success('模拟考试提交成功!感谢您的参与。')
ElMessageBox.alert('您的模拟考试已成功提交,感谢您的参与!', '提交成功', {
confirmButtonText: '返回工作台',
type: 'success',
}).then(() => {
router.push('/dashboard')
})
}, 1500)
}
function startTimer() {
stopTimer()
timerInterval = setInterval(() => {
if (remainingSeconds.value > 0) {
remainingSeconds.value--
} else {
stopTimer()
ElMessage.warning('模拟考试时间已到,系统将自动提交')
handleSubmitAll()
}
}, 1000)
}
function stopTimer() {
if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
}
}
// 路由离开确认
onBeforeRouteLeave((to, from, next) => {
ElMessageBox.confirm(
'确定要离开模拟考试页面吗?当前作答不会保存。',
'提示',
{ confirmButtonText: '确定离开', cancelButtonText: '继续作答', type: 'warning' }
).then(() => {
stopTimer()
next()
}).catch(() => next(false))
})
onMounted(async () => {
// 读取真实考试时长,与在线答题保持一致(取不到时回退默认 60 分钟)
try {
const group = authStore.userGroup
if (group) {
const status = await examApi.getExamStatus(group)
if (status && typeof status.countdown === 'number' && status.countdown > 0) {
remainingSeconds.value = status.countdown
}
}
} catch (e) {
// 忽略异常,保留默认 60 分钟
}
startTimer()
})
onUnmounted(() => {
stopTimer()
})
</script>
<style scoped>
.mock-exam-page {
min-height: 100vh;
background: #f5f6f8;
display: flex;
flex-direction: column;
}
/* ==================== 模拟考试横幅 ==================== */
.mock-banner {
background: linear-gradient(135deg, #fef7e0, #fdf0d5);
border-bottom: 2px solid #e6a23c;
padding: 10px 24px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: #8b6914;
font-size: 14px;
font-weight: 500;
}
/* ==================== 顶部倒计时栏 ==================== */
.exam-top-bar {
background: #1a3a5c;
color: #fff;
padding: 12px 24px;
position: sticky;
top: 0;
z-index: 100;
border-bottom: 2px solid transparent;
}
.exam-top-bar.time-critical {
background: #fef0f0;
border-bottom: 2px solid #f56c6c;
color: #f56c6c;
}
.exam-top-bar.time-critical .exam-progress span { color: #f56c6c; }
.exam-top-bar.time-critical .exam-title { color: #f56c6c; }
.exam-top-bar.time-critical .group-tag {
background: rgba(245, 108, 108, 0.15);
color: #f56c6c;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.85; }
}
.exam-top-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
}
.exam-top-left {
display: flex;
align-items: center;
gap: 24px;
flex-shrink: 0;
}
.exam-top-right {
flex-shrink: 0;
}
.exam-title {
font-size: 18px;
font-weight: 700;
letter-spacing: 2px;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.exam-timer {
display: flex;
align-items: center;
gap: 8px;
}
.timer-display {
font-size: 28px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.timer-danger {
color: #f56c6c;
animation: blink 1s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.exam-progress {
width: 180px;
font-size: 13px;
color: rgba(255,255,255,0.8);
}
.group-tag {
background: rgba(201, 168, 76, 0.2);
color: #c9a84c;
padding: 4px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
/* ==================== 主布局 ==================== */
.exam-body {
display: flex;
flex: 1;
max-width: 1200px;
margin: 20px auto;
width: 100%;
padding: 0 20px;
gap: 20px;
}
/* ==================== 左侧导航 ==================== */
.exam-sidebar {
width: 240px;
flex-shrink: 0;
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
height: fit-content;
position: sticky;
top: 80px;
}
.sidebar-title {
font-size: 16px;
font-weight: 700;
color: #1a3a5c;
margin-bottom: 16px;
}
.nav-section {
margin-bottom: 16px;
}
.nav-section-title {
font-size: 13px;
color: #6b7280;
margin-bottom: 8px;
}
.question-nav {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.nav-item {
width: 36px;
height: 36px;
border-radius: 8px;
background: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
color: #909399;
border: 1px solid #e4e7eb;
}
.nav-item:hover { border-color: #409eff; color: #409eff; }
.nav-answered { background: #ecf5ff; color: #409eff; border-color: #b3d8ff; }
.nav-current { background: #67c23a; color: #fff; border-color: #67c23a; }
.sidebar-legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 16px;
font-size: 12px;
color: #909399;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 2px;
}
.legend-dot.answered { background: #409eff; }
.legend-dot.current { background: #67c23a; }
.legend-dot.unanswered { background: #e4e7eb; border: 1px solid #d0d0d0; }
/* ==================== 右侧题目区 ==================== */
.exam-main {
flex: 1;
min-width: 0;
}
.question-card {
background: #fff;
border-radius: 12px;
padding: 32px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.question-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
}
.question-number {
font-size: 18px;
font-weight: 700;
color: #1a3a5c;
}
.question-score {
font-size: 14px;
color: #909399;
}
.question-body {
min-height: 200px;
}
.question-text {
font-size: 16px;
color: #303133;
line-height: 1.8;
margin-bottom: 24px;
}
.options-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.option-item {
padding: 12px 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
transition: background 0.2s;
}
.option-item:hover { border-color: #409eff; }
.option-label {
font-weight: 600;
color: #409eff;
margin-right: 4px;
}
.question-footer {
display: flex;
justify-content: space-between;
margin-top: 32px;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
}
/* Word 答题 */
.word-sections {
display: flex;
flex-direction: column;
gap: 16px;
}
.word-section h4 {
font-size: 15px;
color: #1a3a5c;
margin-bottom: 12px;
}
.word-upload {
margin-bottom: 12px;
}
.uploaded-info {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background: #f0f9eb;
border-radius: 6px;
margin-top: 8px;
}
.uploaded-name {
font-size: 13px;
color: #303133;
}
/* ==================== 底部提交栏 ==================== */
.exam-bottom-bar {
background: #fff;
border-top: 2px solid #e5e7eb;
padding: 16px 24px;
position: sticky;
bottom: 0;
box-shadow: 0 -2px 8px rgba(0,0,0,0.06);
}
.exam-bottom-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 24px;
}
.submit-hint {
font-size: 13px;
color: #909399;
}
/* ==================== Responsive ==================== */
@media (max-width: 768px) {
.exam-body { flex-direction: column; }
.exam-sidebar {
width: 100%;
position: static;
flex-direction: row;
flex-wrap: wrap;
}
.exam-top-inner { flex-wrap: wrap; gap: 12px; }
.timer-display { font-size: 22px; }
}
</style>

366
src/views/NewsDetail.vue Normal file
View File

@@ -0,0 +1,366 @@
<template>
<div class="news-detail-page">
<!-- 顶部导航栏 -->
<header class="page-header">
<div class="header-inner">
<div class="header-logo">
<span class="logo-icon">&#9788;</span>
<span class="logo-text">卫星通信与导航应用技术创新赛</span>
</div>
<div class="header-actions">
<el-button type="default" size="default" @click="$router.push('/')">
<span>返回首页</span>
</el-button>
</div>
</div>
</header>
<!-- 主体内容 -->
<main class="detail-main" v-loading="loading">
<template v-if="news">
<!-- 面包屑 -->
<div class="breadcrumb">
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>
<a href="/#news" class="breadcrumb-link">新闻公告</a>
</el-breadcrumb-item>
<el-breadcrumb-item>{{ news.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<!-- 文章卡片 -->
<el-card shadow="never" class="article-card">
<!-- 标题区域 -->
<div class="article-header">
<h1 class="article-title">{{ news.title }}</h1>
<div class="article-meta">
<span class="meta-item">
<el-icon><Clock /></el-icon>
{{ formatDate(news.publish_time) }}
</span>
<span class="meta-item" v-if="news.group && news.group !== 'all'">
<el-icon><User /></el-icon>
{{ groupLabel(news.group) }}
</span>
<el-tag v-if="news.pinned" type="warning" size="small" effect="dark">置顶</el-tag>
</div>
</div>
<el-divider />
<!-- 微信链接型新闻 -->
<div v-if="news.news_type === 'wx_link' && news.wx_link" class="wx-link-section">
<el-alert type="info" :closable="false" show-icon>
<template #title>
<span>此新闻来自微信公众号</span>
</template>
<template #default>
<a :href="news.wx_link" target="_blank" rel="noopener" class="wx-link-btn">
<el-button type="success">
<el-icon><Link /></el-icon>
<span>在微信中查看文章</span>
</el-button>
</a>
</template>
</el-alert>
</div>
<!-- 正文内容 -->
<div v-if="news.news_type !== 'wx_link' || !news.wx_link" class="article-content" v-html="news.content"></div>
<!-- 底部信息 -->
<el-divider />
<div class="article-footer">
<span class="footer-item">发布者:赛事组委会</span>
<span class="footer-item">更新时间:{{ formatDate(news.updated_at) }}</span>
</div>
</el-card>
</template>
<!-- 加载失败 -->
<el-empty v-else-if="!loading" description="新闻不存在或已被删除">
<el-button type="primary" @click="$router.push('/')">返回首页</el-button>
</el-empty>
</main>
<!-- 底部 -->
<footer class="page-footer">
<p>卫星通信与导航应用技术创新赛组委会 &copy; {{ new Date().getFullYear() }}</p>
</footer>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { getNewsDetail } from '@/api/news'
const route = useRoute()
const news = ref(null)
const loading = ref(true)
/** 格式化日期 */
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}`
}
/** 组别标签 */
function groupLabel(group) {
const map = {
zhongzhi: '中职组',
gaozhi: '高职组',
benke: '本科组',
jiaoshi: '教师组',
}
return map[group] || group
}
/** 获取新闻详情 */
async function fetchDetail() {
loading.value = true
try {
const id = route.params.id
const data = await getNewsDetail(id)
// 后端返回 { code, data, message },data 是新闻对象
news.value = data
} catch (err) {
console.error('获取新闻详情失败:', err)
news.value = null
} finally {
loading.value = false
}
}
onMounted(() => {
fetchDetail()
})
</script>
<style scoped>
.news-detail-page {
min-height: 100vh;
background: #f5f6f8;
display: flex;
flex-direction: column;
}
/* ==================== 顶部导航 ==================== */
.page-header {
background: rgba(26, 58, 92, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.header-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 60px;
}
.header-logo {
display: flex;
align-items: center;
gap: 10px;
color: #fff;
}
.logo-icon {
font-size: 24px;
color: #c9a84c;
}
.logo-text {
font-size: 18px;
font-weight: 600;
}
/* ==================== 主体 ==================== */
.detail-main {
flex: 1;
max-width: 900px;
width: 100%;
margin: 0 auto;
padding: 24px 20px 60px;
}
/* 面包屑 */
.breadcrumb {
margin-bottom: 20px;
}
.breadcrumb-link {
color: #1a3a5c;
text-decoration: none;
}
.breadcrumb-link:hover {
color: #c9a84c;
text-decoration: underline;
}
/* 文章卡片 */
.article-card {
border-radius: 8px;
overflow: hidden;
}
.article-header {
padding: 8px 0;
}
.article-title {
font-size: 26px;
font-weight: 700;
color: #1f2937;
line-height: 1.4;
margin: 0 0 16px 0;
}
.article-meta {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
color: #6b7280;
font-size: 14px;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
/* 正文内容 */
.article-content {
padding: 16px 0;
font-size: 16px;
line-height: 1.8;
color: #374151;
word-break: break-word;
}
/* 微信链接区 */
.wx-link-section {
padding: 20px 0;
}
.wx-link-btn {
text-decoration: none;
}
/* 富文本样式 */
.article-content :deep(p) {
margin: 0 0 16px 0;
}
.article-content :deep(h1),
.article-content :deep(h2),
.article-content :deep(h3),
.article-content :deep(h4) {
margin: 24px 0 12px 0;
color: #1f2937;
}
.article-content :deep(h2) {
font-size: 20px;
border-left: 4px solid #1a3a5c;
padding-left: 12px;
}
.article-content :deep(h3) {
font-size: 18px;
}
.article-content :deep(img) {
max-width: 100%;
height: auto;
border-radius: 4px;
margin: 12px 0;
}
.article-content :deep(ul),
.article-content :deep(ol) {
padding-left: 24px;
margin: 0 0 16px 0;
}
.article-content :deep(li) {
margin-bottom: 8px;
}
.article-content :deep(blockquote) {
border-left: 4px solid #c9a84c;
padding: 8px 16px;
margin: 16px 0;
background: #fefce8;
border-radius: 0 4px 4px 0;
color: #92400e;
}
.article-content :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
}
.article-content :deep(th),
.article-content :deep(td) {
border: 1px solid #e5e7eb;
padding: 8px 12px;
text-align: left;
}
.article-content :deep(th) {
background: #f3f4f6;
font-weight: 600;
}
.article-content :deep(a) {
color: #1a3a5c;
text-decoration: underline;
}
.article-content :deep(strong) {
font-weight: 600;
color: #1f2937;
}
/* 底部信息 */
.article-footer {
display: flex;
justify-content: space-between;
color: #9ca3af;
font-size: 13px;
padding: 4px 0;
}
/* ==================== 页脚 ==================== */
.page-footer {
text-align: center;
padding: 24px 20px;
color: #9ca3af;
font-size: 13px;
background: #fff;
border-top: 1px solid #e5e7eb;
}
/* ==================== 响应式 ==================== */
@media (max-width: 768px) {
.article-title {
font-size: 22px;
}
.article-content {
font-size: 15px;
}
.article-meta {
gap: 12px;
}
}
</style>

661
src/views/NmeaParser.vue Normal file
View File

@@ -0,0 +1,661 @@
<template>
<div class="nmea-page">
<!-- 顶部导航 -->
<div class="nmea-header">
<div class="nmea-header-left">
<el-button text @click="$router.push('/')" class="back-btn">
<el-icon><HomeFilled /></el-icon><span>返回首页</span>
</el-button>
<el-button text @click="$router.push('/dashboard')" class="back-btn">
<el-icon><ArrowLeft /></el-icon><span>返回工作台</span>
</el-button>
</div>
<h2 class="nmea-title">NMEA-0183 协议解析</h2>
<div class="nmea-header-right">
<span class="group-tag">中职组</span>
</div>
</div>
<div class="nmea-body">
<!-- 左侧:协议知识 -->
<div class="nmea-knowledge">
<el-card shadow="hover">
<template #header>
<span class="card-title">NMEA-0183 协议基础知识</span>
</template>
<el-collapse v-model="activeKnowledge">
<el-collapse-item title="协议概述" name="1">
<p>NMEA-0183 是美国国家海洋电子协会(National Marine Electronics Association)制定的标准通信协议,用于 GPS/GNSS 接收机等海洋电子设备之间的数据交换。</p>
<p><strong>核心特征:</strong>基于 ASCII 文本的串行通信协议,默认波特率 4800bps,每条语句以 <code>$</code> 开头,以 <code>&lt;CR&gt;&lt;LF&gt;</code> 结尾。</p>
</el-collapse-item>
<el-collapse-item title="标准报文结构" name="2">
<p><strong>通用格式:</strong><code>$--SSS,DD1,DD2,...,DDn*hh&lt;CR&gt;&lt;LF&gt;</code></p>
<ul>
<li><code>$</code> — 语句起始符</li>
<li><code>--</code> — 发送者标识(Talker ID),如 <code>GP</code>=GPS, <code>BD</code>=北斗, <code>GN</code>=多系统</li>
<li><code>SSS</code> — 语句标识符(Sentence ID),如 GGA, RMC, GSA, GSV</li>
<li><code>DD1,..</code> — 数据字段,以逗号分隔</li>
<li><code>*</code> — 校验和分隔符</li>
<li><code>hh</code> — 异或校验和(<code>$</code>与<code>*</code>之间所有字符的 XOR 值,十六进制大写)</li>
<li><code>&lt;CR&gt;&lt;LF&gt;</code> — 回车换行结束符</li>
</ul>
</el-collapse-item>
<el-collapse-item title="八大核心语句" name="3">
<el-table :data="coreStatements" border stripe size="small">
<el-table-column prop="id" label="语句" width="70" />
<el-table-column prop="name" label="名称" width="180" />
<el-table-column prop="desc" label="功能说明" />
</el-table>
</el-collapse-item>
<el-collapse-item title="卫星系统前缀" name="4">
<el-table :data="systemPrefixes" border stripe size="small">
<el-table-column prop="prefix" label="前缀" width="80" />
<el-table-column prop="system" label="卫星系统" />
</el-table>
</el-collapse-item>
<el-collapse-item title="GGA 语句详解(重点)" name="5">
<p><strong>$GPGGA</strong> 是 GPS 固定数据输出语句,包含定位信息。</p>
<p><strong>格式:</strong><code>$GPGGA,HHMMSS.SS,DDMM.MMMM,N,DDDMM.MMMM,E,Q,NN,H.H,M,GG.G,M,,*CC</code></p>
<el-table :data="ggaFields" border stripe size="small">
<el-table-column prop="field" label="字段" width="120" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="desc" label="说明" />
</el-table>
</el-collapse-item>
</el-collapse>
</el-card>
</div>
<!-- 右侧:解析工具 -->
<div class="nmea-tool">
<!-- 输入区域 -->
<el-card shadow="hover">
<template #header>
<span class="card-title">NMEA 语句解析器</span>
</template>
<div class="input-section">
<div class="input-header">
<span>请输入 NMEA-0183 语句(支持多条,每行一条)</span>
<el-button type="primary" size="small" @click="loadSample">加载示例</el-button>
</div>
<el-input
v-model="nmeaInput"
type="textarea"
:rows="6"
placeholder='$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47'
class="nmea-textarea"
/>
<div class="action-row">
<el-button type="primary" :icon="Search" @click="parseNmea">解析语句</el-button>
<el-button :icon="Delete" @click="clearAll">清空</el-button>
<span class="validation-info" v-if="validationMsg" :class="validationClass">
{{ validationMsg }}
</span>
</div>
</div>
</el-card>
<!-- 解析结果 -->
<el-card shadow="hover" v-if="parseResults.length > 0" class="result-card">
<template #header>
<span class="card-title">解析结果({{ parseResults.length }} 条)</span>
</template>
<div v-for="(result, idx) in parseResults" :key="idx" class="result-item">
<div class="result-header">
<el-tag :type="result.valid ? 'success' : 'danger'" size="small">
{{ result.valid ? '校验通过' : '校验失败' }}
</el-tag>
<span class="result-statement">{{ result.raw }}</span>
</div>
<div v-if="result.valid && result.parsed" class="result-detail">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="发送者">{{ result.parsed.talkerName }}</el-descriptions-item>
<el-descriptions-item label="语句类型">{{ result.parsed.statementName }}</el-descriptions-item>
<el-descriptions-item label="字段数">{{ result.parsed.fields.length }}</el-descriptions-item>
<el-descriptions-item label="校验和">
<span :class="result.checksumMatch ? 'checksum-ok' : 'checksum-err'">
{{ result.parsed.checksum }}
</span>
</el-descriptions-item>
</el-descriptions>
<!-- GGA 特殊解析 -->
<div v-if="result.parsed.statementType === 'GGA'" class="gga-display">
<h4>GGA 定位信息解析</h4>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="UTC 时间">
{{ result.parsed.gga.utcTime }}
</el-descriptions-item>
<el-descriptions-item label="纬度">
{{ result.parsed.gga.latitude }}
</el-descriptions-item>
<el-descriptions-item label="经度">
{{ result.parsed.gga.longitude }}
</el-descriptions-item>
<el-descriptions-item label="定位质量">
<el-tag :type="result.parsed.gga.quality === 1 ? 'success' : 'warning'" size="small">
{{ result.parsed.gga.qualityLabel }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="卫星数">{{ result.parsed.gga.satellites }}</el-descriptions-item>
<el-descriptions-item label="HDOP">{{ result.parsed.gga.hdop }}</el-descriptions-item>
<el-descriptions-item label="天线高度">{{ result.parsed.gga.altitude }}</el-descriptions-item>
<el-descriptions-item label="大地水准面差距">{{ result.parsed.gga.geoidSeparation }}</el-descriptions-item>
<el-descriptions-item label="差分站ID">{{ result.parsed.gga.dgpsStationId }}</el-descriptions-item>
</el-descriptions>
</div>
<!-- RMC 特殊解析 -->
<div v-if="result.parsed.statementType === 'RMC'" class="rmc-display">
<h4>RMC 推荐最小定位信息解析</h4>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="UTC 时间">{{ result.parsed.rmc.utcTime }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="result.parsed.rmc.status === 'A' ? 'success' : 'danger'" size="small">
{{ result.parsed.rmc.status === 'A' ? '有效' : '无效' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="纬度">{{ result.parsed.rmc.latitude }}</el-descriptions-item>
<el-descriptions-item label="经度">{{ result.parsed.rmc.longitude }}</el-descriptions-item>
<el-descriptions-item label="航速">{{ result.parsed.rmc.speed }} 节</el-descriptions-item>
<el-descriptions-item label="航向">{{ result.parsed.rmc.track }}</el-descriptions-item>
<el-descriptions-item label="日期">{{ result.parsed.rmc.date }}</el-descriptions-item>
<el-descriptions-item label="磁偏角">{{ result.parsed.rmc.magneticVariation }}</el-descriptions-item>
</el-descriptions>
</div>
<!-- 通用字段展示 -->
<div v-if="!['GGA', 'RMC'].includes(result.parsed.statementType)" class="fields-display">
<h4>数据字段</h4>
<el-table :data="result.parsed.fieldTable" border stripe size="small">
<el-table-column prop="index" label="#" width="50" />
<el-table-column prop="value" label="值" />
<el-table-column prop="hint" label="说明" />
</el-table>
</div>
</div>
<div v-if="!result.valid" class="result-error">
<el-alert :title="result.error" type="error" :closable="false" show-icon />
</div>
</div>
</el-card>
<!-- 练习区 -->
<el-card shadow="hover" class="practice-card">
<template #header>
<span class="card-title">练习题目</span>
</template>
<div class="practice-item">
<p><strong>1.</strong> 请写出 GPS 系统的 GGA 语句中,定位质量字段 <code>Q</code> 的取值含义:</p>
<el-input v-model="practiceQ1" placeholder="0=无效, 1=单点定位, 2=差分定位..." class="practice-input" />
</div>
<div class="practice-item">
<p><strong>2.</strong> 在 NMEA-0183 协议中,北斗系统的 Talker ID 前缀是什么?</p>
<el-radio-group v-model="practiceQ2">
<el-radio label="GP">GP</el-radio>
<el-radio label="BD">BD</el-radio>
<el-radio label="GN">GN</el-radio>
<el-radio label="GA">GA</el-radio>
</el-radio-group>
</div>
<div class="practice-item">
<p><strong>3.</strong> NMEA-0183 语句的校验和是如何计算的?</p>
<el-input v-model="practiceQ3" placeholder="异或校验,$和*之间所有字符..." class="practice-input" />
</div>
</el-card>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { Search, Delete, HomeFilled, ArrowLeft } from '@element-plus/icons-vue'
// ============ 知识面板 ============
const activeKnowledge = ref(['1', '5'])
const coreStatements = [
{ id: 'GGA', name: '全球定位系统固定数据', desc: '包含时间、经纬度、定位质量、卫星数、HDOP、海拔高度等完整定位信息。' },
{ id: 'RMC', name: '推荐最小定位信息', desc: '包含时间、日期、经纬度、航速、航向等,是最常用的导航语句。' },
{ id: 'GSA', name: '当前卫星信息', desc: '包含定位模式(2D/3D)、PRN 号、PDOP、HDOP、VDOP 值。' },
{ id: 'GSV', name: '可见卫星信息', desc: '包含可见卫星总数、PRN 号、仰角、方位角、信噪比(SNR)。' },
{ id: 'GLL', name: '地理定位信息', desc: '包含经纬度和 UTC 时间,不含高度信息。' },
{ id: 'VTG', name: '航迹向和航速', desc: '包含航向(真北/磁北)、航速(节/公里/小时)。' },
{ id: 'ZDA', name: '时间和日期', desc: '包含 UTC 时间、日、月、年及本地时区信息。' },
{ id: 'TXT', name: '文本传输', desc: '用于传输文本信息,如错误提示或状态报告。' },
]
const systemPrefixes = [
{ prefix: 'GP', system: 'GPS(美国全球定位系统)' },
{ prefix: 'BD', system: 'BDS(中国北斗卫星导航系统)' },
{ prefix: 'GL', system: 'GLONASS(俄罗斯格洛纳斯)' },
{ prefix: 'GA', system: 'Galileo(欧洲伽利略)' },
{ prefix: 'GN', system: 'GNSS(多系统组合定位)' },
{ prefix: 'GQ', system: 'QZSS(日本准天顶卫星系统)' },
{ prefix: 'GI', system: 'IRNSS/NavIC(印度区域导航系统)' },
]
const ggaFields = [
{ field: 'HHMMSS.SS', name: 'UTC 时间', desc: '协调世界时(小时分钟秒.毫秒)' },
{ field: 'DDMM.MMMM', name: '纬度', desc: '度度分分.分分分分格式' },
{ field: 'N/S', name: '北纬/南纬', desc: 'N=北纬,S=南纬' },
{ field: 'DDDMM.MMMM', name: '经度', desc: '度度度分分.分分分分格式' },
{ field: 'E/W', name: '东经/西经', desc: 'E=东经,W=西经' },
{ field: 'Q', name: '定位质量', desc: '0=无效, 1=单点定位(SPS), 2=差分定位(DGPS), 4=固定解(RTK), 5=浮点解' },
{ field: 'NN', name: '卫星数', desc: '用于定位的卫星数量' },
{ field: 'H.H', name: 'HDOP', desc: '水平精度因子(越小越好,<1 为理想)' },
{ field: 'M', name: '天线高度', desc: '海拔高度(米)' },
{ field: 'GG.G', name: '大地水准面差距', desc: '大地水准面与 WGS-84 椭球面的差距(米)' },
{ field: 'M', name: '大地水准面差距单位', desc: 'M=米' },
{ field: '', name: '差分数据龄期', desc: '距离上次接收差分信号的秒数(RTK 场景为 0)' },
{ field: 'CC', name: '差分站ID', desc: '差分基准站编号(0000-1023)' },
]
// ============ 解析器 ============
const nmeaInput = ref('')
const parseResults = ref([])
const validationMsg = ref('')
const validationClass = ref('')
// 练习
const practiceQ1 = ref('')
const practiceQ2 = ref('')
const practiceQ3 = ref('')
// 语句类型说明
const statementNames = {
GGA: '全球定位系统固定数据',
RMC: '推荐最小定位信息',
GSA: '当前卫星信息',
GSV: '可见卫星信息',
GLL: '地理定位信息',
VTG: '航迹向和航速',
ZDA: '时间和日期',
TXT: '文本传输',
}
const talkerNames = {
GP: 'GPS(美国)',
BD: '北斗(中国)',
GL: 'GLONASS(俄罗斯)',
GA: 'Galileo(欧洲)',
GN: 'GNSS(多系统)',
GQ: 'QZSS(日本)',
GI: 'IRNSS(印度)',
}
function computeChecksum(sentence) {
let checksum = 0
for (let i = 1; i < sentence.length; i++) {
const ch = sentence[i]
if (ch === '*') break
checksum ^= ch.charCodeAt(0)
}
return checksum.toString(16).toUpperCase().padStart(2, '0')
}
function formatLatitude(latStr, ns) {
if (!latStr || latStr.length < 4) return latStr
const deg = parseInt(latStr.substring(0, latStr.indexOf('.') - 2))
const min = parseFloat(latStr.substring(latStr.indexOf('.') - 2))
const decimal = deg + min / 60
return `${decimal.toFixed(6)}°${ns || ''}`
}
function formatLongitude(lonStr, ew) {
if (!lonStr || lonStr.length < 5) return lonStr
const deg = parseInt(lonStr.substring(0, lonStr.indexOf('.') - 2))
const min = parseFloat(lonStr.substring(lonStr.indexOf('.') - 2))
const decimal = deg + min / 60
return `${decimal.toFixed(6)}°${ew || ''}`
}
function formatUtcTime(timeStr) {
if (!timeStr || timeStr.length < 6) return timeStr
const h = timeStr.substring(0, 2)
const m = timeStr.substring(2, 4)
const s = timeStr.substring(4, 6)
const ms = timeStr.length > 6 ? timeStr.substring(7) : ''
return `${h}:${m}:${s}${ms ? '.' + ms : ''} UTC`
}
function parseNmea() {
parseResults.value = []
validationMsg.value = ''
if (!nmeaInput.value.trim()) {
validationMsg.value = '请输入 NMEA 语句'
validationClass.value = 'validation-warn'
return
}
const lines = nmeaInput.value.trim().split('\n').filter(l => l.trim())
lines.forEach((line, lineIdx) => {
const raw = line.trim()
const result = { raw, valid: false, parsed: null, error: '', checksumMatch: false }
if (!raw.startsWith('$')) {
result.error = `第 ${lineIdx + 1} 行:【格式错误】NMEA 语句必须以 "$" 开头`
parseResults.value.push(result)
return
}
const starIdx = raw.indexOf('*')
let sentenceBody, checksumStr = null
if (starIdx >= 0) {
sentenceBody = raw.substring(0, starIdx)
checksumStr = raw.substring(starIdx + 1).trim()
} else {
sentenceBody = raw
}
const talkerMatch = sentenceBody.match(/^\$(\w{2})(\w{3})/)
if (!talkerMatch) {
result.error = `第 ${lineIdx + 1} 行:【格式错误】无法识别 Talker ID 和语句类型,期望格式 $--SSS`
parseResults.value.push(result)
return
}
const talkerId = talkerMatch[1]
const statementId = talkerMatch[2]
if (checksumStr) {
const computed = computeChecksum(raw)
result.checksumMatch = computed.toUpperCase() === checksumStr.toUpperCase()
if (!result.checksumMatch) {
result.error = `第 ${lineIdx + 1} 行:【校验和错误】期望 ${computed},实际 ${checksumStr}`
parseResults.value.push(result)
return
}
}
const fieldsPart = sentenceBody.substring(talkerMatch[0].length + 1)
const fields = fieldsPart.split(',')
result.valid = true
result.parsed = {
talkerId,
talkerName: talkerNames[talkerId] || `未知 (${talkerId})`,
statementType: statementId,
statementName: statementNames[statementId] || `未知 (${statementId})`,
fields,
checksum: checksumStr || '未提供',
fieldTable: [],
}
if (statementId === 'GGA' && fields.length >= 14) {
const qualityLabels = ['无效', '单点定位(SPS)', '差分定位(DGPS)', 'PPS', '固定解(RTK)', '浮点解', '推算', '手动输入', '仿真模式']
result.parsed.gga = {
utcTime: formatUtcTime(fields[0]),
latitude: formatLatitude(fields[1], fields[2]),
longitude: formatLongitude(fields[3], fields[4]),
quality: parseInt(fields[5]) || 0,
qualityLabel: qualityLabels[parseInt(fields[5])] || `未知(${fields[5]})`,
satellites: fields[6],
hdop: fields[7],
altitude: fields[8] + ' ' + (fields[9] || 'M'),
geoidSeparation: fields[10] + ' ' + (fields[11] || 'M'),
dgpsAge: fields[12] || '无',
dgpsStationId: fields[13] || '无',
}
}
if (statementId === 'RMC' && fields.length >= 10) {
result.parsed.rmc = {
utcTime: formatUtcTime(fields[0]),
status: fields[1],
latitude: formatLatitude(fields[2], fields[3]),
longitude: formatLongitude(fields[4], fields[5]),
speed: fields[6],
track: fields[7],
date: fields[8] ? `${fields[8].substring(0, 2)}/${fields[8].substring(2, 4)}/20${fields[8].substring(4)}` : '无',
magneticVariation: fields[9] ? fields[9] + '°' + (fields[10] || '') : '无',
}
}
result.parsed.fieldTable = fields.map((val, i) => {
let hint = ''
if (statementId === 'GGA') {
const ggaHints = [
'UTC时间', '纬度', 'N/S', '经度', 'E/W', '定位质量', '卫星数', 'HDOP', '天线高度',
'高度单位', '大地水准面差距', '差距单位', '差分龄期', '差分站ID'
]
hint = ggaHints[i] || ''
} else if (statementId === 'RMC') {
const rmcHints = [
'UTC时间', '状态(A=有效/V=无效)', '纬度', 'N/S', '经度', 'E/W', '航速(节)', '航向', '日期(DDMMYY)', '磁偏角', '磁偏角方向'
]
hint = rmcHints[i] || ''
}
return { index: i + 1, value: val, hint }
})
parseResults.value.push(result)
})
const validCount = parseResults.value.filter(r => r.valid).length
const totalCount = parseResults.value.length
validationMsg.value = `解析完成:${validCount}/${totalCount} 条语句校验通过`
validationClass.value = validCount === totalCount ? 'validation-ok' : 'validation-warn'
}
function loadSample() {
nmeaInput.value = [
'$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47',
'$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A',
'$GPGSA,A,3,04,05,,09,12,,,24,,,,,2.5,1.3,2.1*39',
'$GPGSV,2,1,08,01,40,083,46,02,17,308,41,12,07,344,39,14,22,228,45*75',
'$BDGGA,063015,3957.263,N,11618.432,E,1,05,1.8,50.2,M,-10.0,M,,*6F',
].join('\n')
}
function clearAll() {
nmeaInput.value = ''
parseResults.value = []
validationMsg.value = ''
}
</script>
<style scoped>
.nmea-page {
min-height: 100vh;
background: #f5f6f8;
}
/* ==================== 顶部导航栏(与 Materials 统一) ==================== */
.nmea-header {
background: #1a3a5c;
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 60px;
position: sticky;
top: 0;
z-index: 100;
}
.back-btn {
color: rgba(255, 255, 255, 0.85);
white-space: nowrap;
}
.back-btn:hover { color: #c9a84c; }
.nmea-title {
font-size: 18px;
font-weight: 600;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.group-tag {
background: rgba(201, 168, 76, 0.2);
color: #c9a84c;
padding: 4px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
}
.nmea-header-left {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.nmea-header-right {
flex-shrink: 0;
text-align: right;
}
/* ==================== 主内容区 ==================== */
.nmea-body {
display: flex;
gap: 20px;
max-width: 1400px;
margin: 20px auto;
padding: 0 20px;
}
.nmea-knowledge {
flex: 0 0 400px;
max-height: calc(100vh - 100px);
overflow-y: auto;
}
.nmea-tool {
flex: 1;
min-width: 0;
}
.card-title {
font-weight: 600;
font-size: 16px;
color: #303133;
}
.input-section {
margin-bottom: 0;
}
.input-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
font-size: 14px;
color: #606266;
}
.nmea-textarea :deep(.el-textarea__inner) {
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
}
.action-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 12px;
}
.validation-info {
font-size: 13px;
margin-left: 10px;
}
.validation-ok { color: #67c23a; }
.validation-warn { color: #e6a23c; }
.validation-err { color: #f56c6c; }
.result-card {
margin-top: 16px;
}
.result-item {
padding: 12px 0;
border-bottom: 1px solid #ebeef5;
}
.result-item:last-child {
border-bottom: none;
}
.result-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.result-statement {
font-family: 'Courier New', monospace;
font-size: 13px;
color: #303133;
word-break: break-all;
}
.result-detail {
margin-top: 8px;
}
.result-detail h4 {
font-size: 14px;
color: #409eff;
margin: 12px 0 8px;
}
.checksum-ok { color: #67c23a; font-weight: bold; }
.checksum-err { color: #f56c6c; font-weight: bold; }
.practice-card {
margin-top: 16px;
}
.practice-item {
padding: 10px 0;
border-bottom: 1px dashed #ebeef5;
}
.practice-item:last-child {
border-bottom: none;
}
.practice-item p {
font-size: 14px;
color: #303133;
margin: 0 0 8px;
}
.practice-input {
max-width: 500px;
}
/* 响应式 */
@media (max-width: 900px) {
.nmea-body {
flex-direction: column;
}
.nmea-knowledge {
flex: none;
max-height: none;
}
}
</style>

50
src/views/NotFound.vue Normal file
View File

@@ -0,0 +1,50 @@
<!-- ============================================================
NotFound.vue — 404 页面未找到
当用户访问未定义的路由时显示此页面
============================================================ -->
<template>
<div class="page-container">
<el-card shadow="hover">
<div class="text-center" style="padding: 60px 20px;">
<h1 style="font-size: 72px; color: #c9a84c; margin: 0;">404</h1>
<p style="font-size: 18px; margin: 20px 0; color: #606266;">
抱歉,您访问的页面不存在
</p>
<el-button type="primary" size="large" @click="goHome">
返回首页
</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
/**
* NotFound.vue — 404 页面未找到视图
* 当用户访问未定义的路由时显示此页面
*/
import { useRouter } from 'vue-router'
const router = useRouter()
/**
* 返回首页
*/
function goHome() {
router.push('/')
}
</script>
<style scoped>
.page-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
padding: 40px 20px;
}
.text-center {
text-align: center;
}
</style>

234
src/views/Score.vue Normal file
View File

@@ -0,0 +1,234 @@
<template>
<div class="score-page">
<!-- 顶部导航栏(与 Materials 统一) -->
<div class="score-header">
<div class="score-header-left">
<el-button text @click="$router.push('/')" class="back-btn">
<el-icon><HomeFilled /></el-icon><span>返回首页</span>
</el-button>
<el-button text @click="$router.push('/dashboard')" class="back-btn">
<el-icon><ArrowLeft /></el-icon><span>返回工作台</span>
</el-button>
</div>
<h2 class="score-title">我的成绩</h2>
<div class="score-header-right">
<span class="group-tag">{{ groupLabel }}</span>
</div>
</div>
<div class="score-container">
<!-- 内容 -->
<div class="score-content" v-loading="loading">
<!-- 已发布 -->
<template v-if="scoreData && scoreData.published">
<div class="score-header-card">
<div class="score-greeting">
<h1 class="greeting-title">{{ authStore.userName }},您的成绩</h1>
</div>
<div class="score-big-display">
<div class="score-number">{{ scoreData.score || '--' }}</div>
<div class="score-unit">分</div>
</div>
</div>
<!-- 分项成绩 -->
<el-card shadow="never" class="detail-card" v-if="breakdown.length > 0 || scoreData.rank || scoreData.formula">
<template #header>
<span class="card-title">成绩详情</span>
</template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item v-for="item in breakdown" :key="item.label" :label="item.label">
<span class="detail-score">{{ item.value }} 分</span>
</el-descriptions-item>
<el-descriptions-item label="总分">
<span class="detail-score total-score">{{ scoreData.score }} 分</span>
</el-descriptions-item>
<el-descriptions-item v-if="scoreData.rank" label="排名">
<span class="detail-score">第 {{ scoreData.rank }} 名</span>
</el-descriptions-item>
</el-descriptions>
<div class="formula-tip" v-if="scoreData.formula">
计分方式:{{ scoreData.formula }}
</div>
</el-card>
</template>
<!-- 未发布 -->
<el-card shadow="never" class="detail-card" v-else>
<el-empty :description="scoreData?.message || '成绩暂未发布,请关注公告'" :image-size="120" />
</el-card>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ArrowLeft, HomeFilled } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { getMyScore } from '@/api/score'
const authStore = useAuthStore()
const loading = ref(false)
const scoreData = ref(null)
const groupLabel = computed(() => {
const map = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' }
return map[authStore.userGroup] || '未知组别'
})
// 所有组别均仅向考生展示「总分」,不展示分数构成(客观题/Word/PPT 分项)。
// 分数分布只在管理后台与数据库可见,避免考生对分项成绩产生异议。
// 需求:本科/教师组"只显示总分"(实施反馈第3项)→ 中职/高职同样适用(分数异议)。
const breakdown = computed(() => {
return []
})
async function fetchScore() {
loading.value = true
try {
const data = await getMyScore()
if (data) {
scoreData.value = data
}
} catch (err) {
scoreData.value = null
} finally {
loading.value = false
}
}
onMounted(() => {
fetchScore()
})
</script>
<style scoped>
.score-page {
min-height: 100vh;
background: #f5f6f8;
}
/* ==================== 顶部导航栏(与 Materials 统一) ==================== */
.score-header {
background: #1a3a5c;
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 60px;
position: sticky;
top: 0;
z-index: 100;
}
.back-btn {
color: rgba(255, 255, 255, 0.85);
white-space: nowrap;
}
.back-btn:hover { color: #c9a84c; }
.score-title {
font-size: 18px;
font-weight: 600;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.group-tag {
background: rgba(201, 168, 76, 0.2);
color: #c9a84c;
padding: 4px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
}
.score-header-left {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.score-header-right {
flex-shrink: 0;
}
/* ==================== 内容区 ==================== */
.score-container {
max-width: 720px;
margin: 24px auto 0;
padding: 0 20px;
}
/* ==================== 头部成绩卡片 ==================== */
.score-header-card {
background: linear-gradient(135deg, #1a3a5c, #2a5a8c);
border-radius: 16px;
padding: 36px;
text-align: center;
color: #fff;
margin-bottom: 24px;
box-shadow: 0 4px 20px rgba(26, 58, 92, 0.3);
}
.greeting-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 12px;
}
.score-big-display {
display: flex;
align-items: baseline;
justify-content: center;
margin-top: 16px;
}
.score-number {
font-size: 72px;
font-weight: 800;
line-height: 1;
}
.score-unit {
font-size: 24px;
color: rgba(255, 255, 255, 0.7);
margin-left: 4px;
}
/* ==================== 详情卡片 ==================== */
.detail-card {
border-radius: 12px;
border: 1px solid #e5e7eb;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #1f2937;
}
.detail-score {
font-size: 15px;
font-weight: 600;
color: #1a3a5c;
}
.total-score {
font-size: 18px;
color: #c9a84c;
}
.formula-tip {
margin-top: 14px;
padding: 10px 14px;
background: #f5f7fa;
border-left: 3px solid #c9a84c;
border-radius: 4px;
font-size: 13px;
color: #606266;
}
</style>

735
src/views/Upload.vue Normal file
View File

@@ -0,0 +1,735 @@
<template>
<div class="upload-page">
<!-- 顶部导航 -->
<div class="upload-header">
<div class="upload-header-left">
<el-button text @click="$router.push('/')" class="back-btn">
<el-icon><HomeFilled /></el-icon><span>返回首页</span>
</el-button>
<el-button text @click="$router.push('/dashboard')" class="back-btn">
<el-icon><ArrowLeft /></el-icon><span>返回工作台</span>
</el-button>
</div>
<h2 class="upload-title">作品提交</h2>
<span class="group-tag">{{ groupLabel }}</span>
</div>
<div class="upload-content">
<!-- 截止时间 -->
<div class="deadline-card" v-if="deadline">
<div class="deadline-info">
<el-icon :size="20"><Clock /></el-icon>
<span>提交截止时间:</span>
<strong :class="{ 'deadline-near': isDeadlineNear }">{{ formatDeadline(deadline) }}</strong>
</div>
<div class="deadline-countdown" v-if="deadlineCountdown > 0">
<span>剩余时间:</span>
<strong>{{ formatCountdown(deadlineCountdown) }}</strong>
</div>
</div>
<!-- 教师组双上传区 -->
<template v-if="isTeacher">
<el-row :gutter="24">
<el-col :xs="24" :md="12">
<div class="upload-card">
<h3 class="upload-card-title">教学方案</h3>
<p class="upload-card-desc">请上传教学方案文件(PDF、Word 格式)</p>
<file-upload-area
group="jiaoshi"
file-type="word"
:max-size="100 * 1024 * 1024"
accept=".pdf,.doc,.docx"
@upload-success="onUploadSuccess('word', $event)"
/>
</div>
</el-col>
<el-col :xs="24" :md="12">
<div class="upload-card">
<h3 class="upload-card-title">说课 PPT</h3>
<p class="upload-card-desc">请上传说课 PPT 文件</p>
<file-upload-area
group="jiaoshi"
file-type="ppt"
:max-size="100 * 1024 * 1024"
accept=".ppt,.pptx"
@upload-success="onUploadSuccess('ppt', $event)"
/>
</div>
</el-col>
</el-row>
</template>
<!-- 本科组双上传区(Word + PPT) -->
<template v-else>
<el-row :gutter="24">
<el-col :xs="24" :md="12">
<div class="upload-card">
<h3 class="upload-card-title">参赛作品(Word)</h3>
<p class="upload-card-desc">请上传参赛作品文档(PDF、Word 格式)</p>
<file-upload-area
:group="userGroup"
file-type="word"
:max-size="50 * 1024 * 1024"
accept=".pdf,.doc,.docx"
@upload-success="onUploadSuccess('word', $event)"
/>
</div>
</el-col>
<el-col :xs="24" :md="12">
<div class="upload-card">
<h3 class="upload-card-title">参赛作品(PPT)</h3>
<p class="upload-card-desc">请上传参赛作品演示文稿(PPT 格式)</p>
<file-upload-area
:group="userGroup"
file-type="ppt"
:max-size="50 * 1024 * 1024"
accept=".ppt,.pptx"
@upload-success="onUploadSuccess('ppt', $event)"
/>
</div>
</el-col>
</el-row>
</template>
<!-- 上传历史 -->
<div class="history-card">
<h3 class="history-title">提交记录</h3>
<div v-loading="historyLoading">
<el-table :data="submissionHistory" stripe empty-text="暂无提交记录">
<el-table-column prop="fileName" label="文件名" min-width="180" show-overflow-tooltip />
<el-table-column prop="fileSize" label="大小" width="100">
<template #default="{ row }">{{ formatSize(row.fileSize) }}</template>
</el-table-column>
<el-table-column prop="uploadTime" label="上传时间" width="170">
<template #default="{ row }">{{ formatDate(row.uploadTime) }}</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center">
<template #default="{ row }">
<el-button
text
type="danger"
size="small"
:loading="deletingId === row.id"
v-guard="() => deleteSubmission(row)"
>删除</el-button>
<el-button text type="primary" size="small" @click="reupload">重新上传</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</div>
</template>
<script setup>
/**
* Upload.vue - 作品上传页面(需要登录,benke/jiaoshi 组)
*
* 功能:
* 1. 截止时间倒计时
* 2. 拖拽上传区域
* 3. 文件类型/大小校验
* 4. 分片上传 + 进度条
* 5. MD5 哈希显示
* 6. 上传历史列表
* 7. 教师组双上传区(教学方案 + 说课PPT)
*/
import { ref, computed, onMounted, onUnmounted, h, resolveComponent } from 'vue'
import { ElMessage, ElMessageBox, ElUpload, ElProgress, ElButton, ElTag, ElIcon } from 'element-plus'
import { ArrowLeft, HomeFilled, Clock, UploadFilled, Delete } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import * as uploadApi from '@/api/upload'
import { chunkFile, calculateMd5, uploadWithRetry, formatFileSize } from '@/utils/upload'
const authStore = useAuthStore()
// ==================== 计算属性 ====================
const userGroup = computed(() => authStore.userGroup)
const groupLabel = computed(() => {
const map = { benke: '本科组', jiaoshi: '教师组' }
return map[userGroup.value] || userGroup.value
})
const isTeacher = computed(() => userGroup.value === 'jiaoshi')
const isDeadlineNear = computed(() => {
if (!deadline.value) return false
const diff = new Date(deadline.value) - new Date()
return diff > 0 && diff < 3 * 24 * 60 * 60 * 1000
})
// ==================== 状态 ====================
const deadline = ref('')
const deadlineCountdown = ref(0)
const submissionHistory = ref([])
const historyLoading = ref(false)
const deletingId = ref(null)
let deadlineTimer = null
// ==================== 文件上传子组件(内联实现) ====================
// 使用简单的内联上传逻辑替代子组件
const uploadStates = reactive({})
// ==================== 方法 ====================
function formatDate(dateStr) {
if (!dateStr) return '--'
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function formatDeadline(dateStr) {
if (!dateStr) return '--'
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function formatSize(bytes) {
return formatFileSize(bytes || 0)
}
function formatCountdown(seconds) {
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
if (d > 0) return `${d}天 ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
/** 启动截止倒计时 */
function startDeadlineCountdown() {
clearInterval(deadlineTimer)
if (!deadline.value) return
deadlineTimer = setInterval(() => {
const diff = Math.floor((new Date(deadline.value) - new Date()) / 1000)
deadlineCountdown.value = Math.max(0, diff)
}, 1000)
}
/** 获取提交历史 */
async function fetchHistory() {
historyLoading.value = true
try {
const data = await uploadApi.getSubmissions(userGroup.value)
if (Array.isArray(data)) {
submissionHistory.value = data
} else if (data?.list) {
submissionHistory.value = data.list
}
} catch (err) {
console.warn('获取提交历史失败', err)
} finally {
historyLoading.value = false
}
}
/** 获取上传状态 */
async function fetchUploadStatus() {
try {
const data = await uploadApi.getUploadStatus(userGroup.value)
if (data?.deadline) {
deadline.value = data.deadline
startDeadlineCountdown()
}
} catch (err) {
console.warn('获取上传状态失败', err)
}
}
function onUploadSuccess(type, result) {
ElMessage.success('文件上传成功')
fetchHistory()
}
/** 删除已上传的作品文件(本科/教师组) */
async function deleteSubmission(row) {
try {
await ElMessageBox.confirm(
`确定要删除「${row.fileName}」吗?删除后可重新上传进行替换。`,
'删除确认',
{ confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
deletingId.value = row.id
try {
await uploadApi.deleteWork(userGroup.value, row.id)
ElMessage.success('文件已删除')
fetchHistory()
} catch (err) {
ElMessage.error('删除失败,请重试')
} finally {
deletingId.value = null
}
}
/** 滚动到上传区以便重新上传 */
function reupload() {
const el = document.querySelector('.upload-card')
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
// ==================== 生命周期 ====================
onMounted(() => {
// 组别检查
if (!['benke', 'jiaoshi'].includes(authStore.userGroup)) {
ElMessage.warning('您所在的组别不需要提交作品')
// 不强制跳转,避免闪烁
}
fetchUploadStatus()
fetchHistory()
})
onUnmounted(() => {
if (deadlineTimer) clearInterval(deadlineTimer)
})
</script>
<!-- ==================== 文件上传子区域组件模板 ==================== -->
<script>
/**
* file-upload-area 是一个内联组件
* 为了保持 Vue SFC 的简洁性,我们用 defineComponent
*/
import { defineComponent, ref, reactive, h, resolveComponent, computed } from 'vue'
import { ElMessage, ElUpload, ElProgress, ElButton, ElTag, ElIcon } from 'element-plus'
import { UploadFilled, Delete } from '@element-plus/icons-vue'
import * as uploadApi from '@/api/upload'
import { chunkFile, calculateMd5, uploadWithRetry, formatFileSize } from '@/utils/upload'
const FileUploadArea = defineComponent({
name: 'FileUploadArea',
props: {
group: { type: String, required: true },
fileType: { type: String, default: 'work' },
maxSize: { type: Number, default: 50 * 1024 * 1024 },
accept: { type: String, default: '.pdf,.doc,.docx,.ppt,.pptx,.zip' },
},
emits: ['upload-success'],
setup(props, { emit }) {
const fileList = ref([])
const uploading = ref(false)
const uploadPercent = ref(0)
const uploadedInfo = ref(null)
function validateFile(file) {
const raw = file.raw || file
if (raw.size > props.maxSize) {
const maxMB = props.maxSize / (1024 * 1024)
ElMessage.error(`文件大小不能超过 ${maxMB}MB`)
return false
}
return true
}
function handleFileChange(file) {
if (!validateFile(file)) {
fileList.value = []
return
}
fileList.value = [file]
uploadedInfo.value = null
// 选择文件后自动开始上传,提供即时反馈
startUpload()
}
function removeFile() {
if (uploading.value) {
ElMessage.warning('文件正在上传中,请等待完成后再操作')
return
}
fileList.value = []
uploadedInfo.value = null
uploadPercent.value = 0
}
async function startUpload() {
// 防重入:选择文件会自动触发一次上传,用户若再点「开始上传」会造成同一文件
// 双份分片流并发,既浪费连接池也可能让后端合并出错。
if (uploading.value) return
if (fileList.value.length === 0) {
ElMessage.warning('请先选择文件')
return
}
const rawFile = fileList.value[0]?.raw || fileList.value[0]
if (!rawFile) return
uploading.value = true
uploadPercent.value = 0
try {
// 计算 MD5 作为 fileId
let fileId = ''
try {
fileId = await calculateMd5(rawFile)
} catch (e) {
console.warn('MD5 计算失败', e)
fileId = rawFile.name + '_' + Date.now()
}
// 分片上传
const chunks = chunkFile(rawFile)
for (let i = 0; i < chunks.length; i++) {
const formData = new FormData()
// ⚠️ Egg stream 模式下 stream.fields 仅解析「出现在文件字段之前」的普通字段。
// 必须把文本字段放在 chunk(文件)之前,否则后端收不到 chunkIndex/fileId/fileName -> 400 缺参。
formData.append('chunkIndex', chunks[i].index)
formData.append('totalChunks', chunks[i].total)
formData.append('fileId', fileId)
formData.append('fileName', rawFile.name)
formData.append('chunk', chunks[i].chunk, rawFile.name)
await uploadWithRetry(
() => uploadApi.uploadChunk(props.group, formData),
3, 1000
)
uploadPercent.value = Math.round(((i + 1) / chunks.length) * 100)
}
// 所有分片上传完成,调用合并接口(透传 file_type:word/ppt)
await uploadApi.mergeChunks(props.group, {
fileId,
fileName: rawFile.name,
totalChunks: chunks.length,
work_name: rawFile.name,
file_type: props.fileType,
})
uploadedInfo.value = {
fileName: rawFile.name,
fileSize: rawFile.size,
fileId,
}
ElMessage.success('上传成功')
emit('upload-success', { type: props.fileType, info: uploadedInfo.value })
} catch (err) {
ElMessage.error('上传失败,请重试')
} finally {
uploading.value = false
}
}
return () => {
return h('div', { class: 'file-upload-wrapper' }, [
// 拖拽上传区
!uploadedInfo.value ? [
h('div', {
class: 'drag-area',
onClick: () => {
// 触发文件选择
},
}, [
h('input', {
type: 'file',
accept: props.accept,
style: { display: 'none' },
ref: 'fileInput',
onChange: (e) => {
const file = e.target.files?.[0]
if (file) {
handleFileChange({ raw: file, name: file.name, size: file.size })
}
e.target.value = ''
},
}),
h('div', {
class: 'drag-content',
onClick: (e) => {
// 点击时触发隐藏的 file input
e.currentTarget.previousElementSibling?.click()
},
}, [
h(resolveComponent('el-icon'), { size: 48 }, () => h(UploadFilled)),
h('div', { class: 'drag-text' }, '拖拽文件到此处或 '),
h('em', null, '点击上传'),
h('div', { class: 'drag-hint' },
`支持 ${props.accept.replace(/\./g, '')} 格式,最大 ${props.maxSize / (1024 * 1024)}MB`
),
]),
]),
// 已选文件
fileList.value.length > 0 ? h('div', { class: 'selected-file' }, [
h('span', { class: 'selected-name' }, fileList.value[0].name || fileList.value[0].fileName),
h('el-button', {
type: 'danger',
size: 'small',
icon: h(Delete),
circle: true,
onClick: removeFile,
}),
]) : null,
// 上传按钮
fileList.value.length > 0 ? h('div', { class: 'upload-actions' }, [
h('el-button', {
type: 'primary',
loading: uploading.value,
onClick: startUpload,
}, { default: () => '开始上传' }),
]) : null,
] : null,
// 上传进度
uploading.value ? h('div', { class: 'upload-progress' }, [
h(resolveComponent('el-progress'), {
percentage: uploadPercent.value,
status: uploadPercent.value >= 100 ? 'success' : undefined,
}),
]) : null,
// 已上传文件信息
uploadedInfo.value ? h('div', { class: 'uploaded-card' }, [
h(resolveComponent('el-tag'), { type: 'success', size: 'small' }, () => '上传成功'),
h('div', { class: 'uploaded-name' }, uploadedInfo.value.fileName),
h('div', { class: 'uploaded-meta' }, [
h('span', null, formatFileSize(uploadedInfo.value.fileSize)),
uploadedInfo.value.md5 ? h('span', null, `MD5: ${uploadedInfo.value.md5.slice(0, 16)}...`) : null,
].filter(Boolean)),
h('el-button', {
text: true,
type: 'danger',
size: 'small',
onClick: removeFile,
}, { default: () => '重新上传' }),
]) : null,
])
}
},
})
export default {
components: { FileUploadArea },
}
</script>
<style scoped>
.upload-page {
min-height: 100vh;
background: #f5f6f8;
padding-bottom: 40px;
}
/* ==================== 顶部导航 ==================== */
.upload-header {
background: #1a3a5c;
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 60px;
position: sticky;
top: 0;
z-index: 100;
}
.back-btn { color: rgba(255,255,255,0.85); white-space: nowrap; }
.back-btn:hover { color: #c9a84c; }
.upload-title {
font-size: 18px;
font-weight: 600;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.upload-header-left {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.group-tag {
background: rgba(201,168,76,0.2);
color: #c9a84c;
padding: 4px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
}
/* ==================== 内容 ==================== */
.upload-content {
max-width: 1000px;
margin: 24px auto 0;
padding: 0 20px;
}
/* ==================== 截止时间 ==================== */
.deadline-card {
background: #fff;
border-radius: 12px;
padding: 20px 24px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.deadline-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
color: #6b7280;
}
.deadline-near { color: #f56c6c; }
.deadline-countdown {
font-size: 18px;
color: #1a3a5c;
}
/* ==================== 上传卡片 ==================== */
.upload-card {
background: #fff;
border-radius: 12px;
padding: 28px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.upload-card-title {
font-size: 18px;
font-weight: 600;
color: #1a3a5c;
margin-bottom: 8px;
}
.upload-card-desc {
font-size: 14px;
color: #6b7280;
margin-bottom: 24px;
}
/* ==================== 拖拽上传区 ==================== */
.drag-area {
border: 2px dashed #d1d5db;
border-radius: 12px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background: #fafafa;
}
.drag-area:hover {
border-color: #1a3a5c;
background: #f0f4f8;
}
.drag-content {
color: #6b7280;
}
.drag-text {
font-size: 14px;
margin-top: 12px;
}
.drag-hint {
font-size: 12px;
color: #909399;
margin-top: 8px;
}
/* ==================== 已选文件 ==================== */
.selected-file {
display: flex;
align-items: center;
justify-content: space-between;
background: #f0f4f8;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px 16px;
margin-top: 12px;
}
.selected-name {
font-size: 14px;
color: #1f2937;
font-weight: 500;
}
.upload-actions {
margin-top: 12px;
text-align: center;
}
.upload-progress {
margin-top: 16px;
}
/* ==================== 上传成功信息 ==================== */
.uploaded-card {
border: 1px solid #bbf7d0;
background: #f0fdf4;
border-radius: 10px;
padding: 20px;
margin-top: 16px;
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.uploaded-name {
font-size: 14px;
font-weight: 600;
color: #1f2937;
flex: 1;
min-width: 200px;
word-break: break-all;
}
.uploaded-meta {
display: flex;
gap: 16px;
font-size: 12px;
color: #6b7280;
width: 100%;
}
/* ==================== 历史记录 ==================== */
.history-card {
background: #fff;
border-radius: 12px;
padding: 28px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.history-title {
font-size: 18px;
font-weight: 600;
color: #1a3a5c;
margin-bottom: 16px;
}
/* ==================== Responsive ==================== */
@media (max-width: 480px) {
.upload-header { padding: 0 12px; }
.deadline-card { flex-direction: column; }
}
</style>