Files
competition-website/src/api/admin.js
qubiaobiao ac7637f3e1 feat(admin): 综合大题页签增加 Excel 模板下载与批量导入(R73)
api/admin.js 新增 importBigQuestionsExcel(silent:true,错误提示由调用方统一给出,避免同族双弹)。
Admin.vue 大题工具栏新增「下载Excel模板」「Excel批量导入」两按钮 + 隐藏 file input,
复用既有 safeDownload 与 ref 模式;按钮 title 明确提示「先下载模板、按模板列填写、
组别以当前选中组别为准」,解决测试人员找不到模板入口的问题。
2026-10-08 10:08:44 +08:00

507 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,且永不落库。
//
// silent: true —— 抑制响应拦截器的通用错误提示。本接口的**失败提示由调用方负责**:
// 后端刻意只回传纯错误(admin.js importExcel catch 注释:「前端已统一加
// 「Excel 导入失败:」前缀,这里只回传纯错误,避免重复前缀」),故此处必须静默,
// 否则会与 Admin.vue 的「Excel 导入失败:<原因>」构成同族双弹。
return request.post('/admin/import-excel', formData, {
timeout: 60000,
silent: true,
})
}
/**
* 获取参赛作品列表(按组别)
* @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。
// silent: true —— 同 importExcel:失败提示由调用方(Admin.vue onExcelFileChange)
// 以「Excel 导入失败:<原因>」形式统一给出,此处静默以避免同族双弹。
return request.post(`/admin/exam/${group}/questions/import-excel`, formData, {
timeout: 120000,
silent: true,
})
}
/**
* 综合大题 Excel 批量导入(R73)
* 首 sheet 为数据表(表头在第 1 行),可携带「填写说明」sheet
* @param {string} group - 组别标识(zhongzhi / gaozhi)
* @param {FormData} formData - 包含 Excel 文件的 FormData(字段名 file)
* @returns {Promise} { imported, skipped, errors: [{ row, message }] }
*/
export function importBigQuestionsExcel(group, formData) {
// silent: true —— 与 importQuestionsExcel 同口径:错误提示由调用方统一给出,避免同族双弹
return request.post(`/admin/big-questions/${group}/import-excel`, formData, {
timeout: 300000,
silent: true,
})
}
/**
* 上传 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 的选手及其提交记录)
*
* returnMessage: true —— 保留后端 message(不再被响应拦截器丢弃)。
* 后端提示含「已恢复为『未激活 + 默认口令 X』,需先用默认口令完成短信激活即可登录重测」
* 这一关键指引;前端自拼文案曾写成「可直接用默认口令登录重测」,
* 与后端 activated:false 自相矛盾(用户反馈 Req2)。以后端文案为唯一真相源。
* @returns {Promise<{code:number,message:string,data:object}>}
*/
export function resetTestData() {
// silent: true —— 抑制响应拦截器(request.js)的通用错误提示。
// 本调用方在 Admin.vue 的 catch 里已按上下文弹一次「重置失败:<后端原文>」,
// 若不再静默,同一 403(生产环境禁止重置测试数据)会被拦截器与 catch 各弹一次,
// 用户会看到两条完全相同的报错(R58 用户反馈)。
return request.post('/admin/reset-test-data', null, { returnMessage: true, silent: true })
}
// ==================== 综合大题管理(中职/高职) ====================
/**
* 获取综合大题题库(按组别)
*
* 仅中职/高职启用;本科/教师组调用返回 400(未启用综合大题)。
* @param {string} group - 组别标识(zhongzhi / gaozhi)
* @returns {Promise} {
* list: [{ id, questionUid, group, title, description,
* subPrompt1, subPrompt2, subPrompt3,
* scorePerSub, totalScore, sortOrder, status, createdAt, updatedAt }],
* total, scorePerSub, subCount, maxChars
* }
*/
export function getBigQuestions(group) {
return request.get(`/admin/big-questions/${group}`)
}
/**
* 新增大题
*
* 题干 description 为富文本 HTML(支持表格);三小问提示 subPrompt1~3 与题干同存一行。
* @param {string} group - 组别标识
* @param {object} data - { title, description, subPrompt1, subPrompt2, subPrompt3, scorePerSub, sortOrder, status }
* @returns {Promise} { id, questionUid }
*/
export function createBigQuestion(group, data) {
return request.post(`/admin/big-questions/${group}`, data)
}
/**
* 更新大题
*
* 注意:已被考生抽取的题目仍可修改,但考生端展示的是抽题时刻的题干快照,
* 不会受此修改影响(防纠纷设计)。
* @param {string} group - 组别标识
* @param {number|string} id - 大题 ID
* @param {object} data - 同 createBigQuestion
* @returns {Promise} { id }
*/
export function updateBigQuestion(group, id, data) {
return request.put(`/admin/big-questions/${group}/${id}`, data)
}
/**
* 删除大题
*
* 已被考生抽取的题目服务端拒绝删除(返回 409),避免作答记录悬空。
* @param {string} group - 组别标识
* @param {number|string} id - 大题 ID
* @returns {Promise} { deleted }
*/
export function deleteBigQuestion(group, id) {
return request.delete(`/admin/big-questions/${group}/${id}`)
}
/**
* 获取大题作答与查证列表(覆盖该组全部考生,含未抽题者)
*
* 每行含考生信息、抽题快照、三小问答案、已答数量、三小问得分、大题总分与提交时间,
* 供管理员核对考生实际抽到的题目与作答内容,避免考生纠纷。
* @param {string} group - 组别标识
* @returns {Promise} {
* list: [{ contestantId, aggregationUid, name, idNumber, phone, teamId,
* drawn, bigQuestionUid, questionSnapshot,
* answer1, answer2, answer3, answeredCount,
* score1, score2, score3, bigScore, wordScore, submitTime }],
* total, drawnCount
* }
*/
export function getBigAnswers(group) {
return request.get(`/admin/big-answers/${group}`)
}
/**
* 录入三小问得分(小题分)
*
* 服务端逐问校验 0 ~ scorePerSub 区间,累加后经唯一评分写路径
* service.admin.updateScores 落 contestants.word_score(中职/高职总分 = exam_score + word_score)。
* @param {string} group - 组别标识
* @param {number|string} uid - 考生聚合 ID(aggregation_uid)
* @param {{score_1: number, score_2: number, score_3: number}} scores
* @returns {Promise} { total, perSubScore }
*/
export function setBigAnswerScores(group, uid, scores) {
return request.put(`/admin/big-answers/${group}/${uid}/scores`, scores)
}
// ==================== 成绩复核 ====================
// 场景:考生对成绩有异议 → 按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
/**
* 检索考生(姓名 / 身份证号 / 电话 任一,模糊匹配)
*
* 不传 group 则全组别检索 —— 复核时通常只拿到一个身份证号,不应强迫先猜组别。
* @param {string} keyword - 检索词
* @param {string} [group] - 可选:限定组别
* @returns {Promise} { list: [{ contestantId, name, idNumber, phone, group, objectiveApplicable }], total, truncated }
*/
export function reviewSearch(keyword, group) {
const params = { keyword }
if (group) params.group = group
return request.get('/admin/review/search', { params })
}
/**
* 考生全卷明细(复核)
*
* 返回客观题逐题(题干 / 选项 / 考生答案 / **正确答案** / 对错 / 得分 / 满分)
* + 综合大题(题干快照 + 三小问作答与得分)+ 本科/教师组作品文件清单。
* ⚠️ 含正确答案,仅管理员可用。
* @param {string} group - 组别标识(须与考生实际组别一致)
* @param {number|string} contestantId - 考生 ID(contestants.id)
* @returns {Promise} { name, idNumber, phone, group, objectiveApplicable, scorePublished,
* objective: [{ seq, questionType, questionUid, question, options, correctAnswer, maxScore,
* answer, answered, isCorrect, score, answerMatches, questionMissing }],
* summary: { answered, total, correctCount, autoScore, wordScore, totalScore },
* bigQuestion, works, warnings, matchedKeys, questionOrderFixed }
*/
export function getContestantScore(group, contestantId) {
return request.get(`/admin/review/score/${group}/${contestantId}`)
}