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

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 })
}