chore: 前端首次基线提交
运行必须源码:src/、public/、index.html、package.json 及构建配置。 已忽略运行非必须:node_modules/、dist/、.env*。
This commit is contained in:
350
src/api/admin.js
Normal file
350
src/api/admin.js
Normal 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
94
src/api/auth.js
Normal 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
11
src/api/carousel.js
Normal 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
92
src/api/exam.js
Normal 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
13
src/api/materials.js
Normal 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
29
src/api/news.js
Normal 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
269
src/api/request.js
Normal 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
11
src/api/score.js
Normal 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
95
src/api/upload.js
Normal 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 })
|
||||
}
|
||||
Reference in New Issue
Block a user