- src/views/Admin.vue
· 作品管理一行 = 一个小组(小组编号/作品目录 + 提交状态 + 该组文件与评分 + 人数 + 提交人),
替代原「按人平铺」(同组 6 人 6 行、5 行未提交)
· 搜索口径改「小组编号」(姓名已不在列表展示);计数单位 人→组
· PDF 预览常驻说明 + 「在新标签打开」出口(iframe 白屏时不误导「系统没加载」)
- src/api/admin.js:getSubmissions/getScores 增加 params,供 includeIdle 口径透传
771 lines
28 KiB
JavaScript
771 lines
28 KiB
JavaScript
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 })
|
||
}
|
||
|
||
/**
|
||
* 获取小组(团队)激活状态清单(R84 续六)
|
||
* @param {object} params - { group, activated, includeTest }
|
||
* @returns {Promise} { list, summary, filteredTotal, filters }
|
||
*/
|
||
export function getTeams(params) {
|
||
return request.get('/admin/teams', { 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, params) {
|
||
return request.get(`/admin/submissions/${group}`, { params })
|
||
}
|
||
|
||
/**
|
||
* 获取比赛时间配置
|
||
* @returns {Promise}
|
||
*/
|
||
export function getTimes() {
|
||
return request.get('/admin/times')
|
||
}
|
||
|
||
/**
|
||
* 设置比赛时间
|
||
* @param {Object} data - { zhongzhi: {start, end}, gaozhi: ... }
|
||
* @param {Object} [expected] - B② 乐观锁:打开表单时读到的旧值
|
||
* { zhongzhi: { start, end, deadline }, ... };不传 ⇒ 后端不校验(旧行为)
|
||
* @returns {Promise}
|
||
*/
|
||
export function setTimes(data, expected) {
|
||
// 后端要求 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,
|
||
}))
|
||
const payload = { times }
|
||
// ⛔ 只在真的有基线时才带:空对象会让后端误以为「所有组都要求校验」而恒冲突
|
||
if (expected && Object.keys(expected).length > 0) payload.expectedTimes = expected
|
||
return request.put('/admin/times', payload)
|
||
}
|
||
|
||
/**
|
||
* 节点备份调度器状态(P1「生产备份静默失败」可见性)
|
||
*
|
||
* silent: true —— 本接口是「状态展示」而非「用户动作」,失败由页面内提示承载,
|
||
* 不希望拦截器再弹一次全局错误(同族双弹纪律)。
|
||
* @returns {Promise} { available, enabled, isProduction, buffer, keepDays, plans, history, artifacts, health, paths }
|
||
*/
|
||
export function getNodeBackupStatus() {
|
||
return request.get('/admin/node-backup/status', { silent: true })
|
||
}
|
||
|
||
/**
|
||
* 设置评分
|
||
* @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, params) {
|
||
return request.get(`/admin/scores/${group}`, { params })
|
||
}
|
||
|
||
/**
|
||
* 发布/撤回指定组别的成绩
|
||
*
|
||
* 发布状态按组独立存储:发布本科组不会让中职组考生看到成绩。
|
||
* 不传 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)
|
||
}
|
||
|
||
// ==================== 成绩公示表(R176) ====================
|
||
|
||
/**
|
||
* 公示表导出前的统计预检(⛔ 不下载文件,只回统计)
|
||
*
|
||
* 为什么要先预检:成绩公示表是**对外的正式文书**,缺数据不能静默带出。
|
||
* 本接口回传「无小组归属人数 / 测试数据人数 / 赛项名称是否已配置」,
|
||
* 前端据此在执行下载前让管理员知情并确认,而不是下载完才发现表格有空白列。
|
||
*
|
||
* ⚠️ 为什么不用响应头传这些字段:生产经 nginx `X-Accel-Redirect` 时
|
||
* 上游自定义响应头会被丢弃(本项目已实测),故改用 `?meta=1` 走 JSON。
|
||
*
|
||
* silent: true —— 属「预检」而非用户动作,失败由调用方按业务语境提示。
|
||
* @param {string} group - 组别标识
|
||
* @returns {Promise<{fileName:string,rowCount:number,testCount:number,
|
||
* noTeamCount:number,totalContestants:number,itemName:string,itemCode:string}>}
|
||
*/
|
||
export function getPublicityMeta(group) {
|
||
return request.get(`/admin/export-publicity/${group}`, {
|
||
params: { meta: 1 },
|
||
silent: true,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 下载成绩公示表半成品(按模板填充,获奖等级列留空由人工填写)
|
||
*
|
||
* 下载动作本身由 `utils/download.js#safeDownload` 发起(含 HTTP 状态 +
|
||
* content-type + 文件头三重校验),故这里不提供 axios 封装,
|
||
* 调用方直接用 `/api/admin/export-publicity/${group}` 走 safeDownload。
|
||
*
|
||
* 留此常量而非散落字符串:URL 前缀被前端(safeDownload)与后端路由共同约束,
|
||
* 改成单点引用可避免两处漂移。
|
||
* @param {string} group
|
||
* @returns {string} 下载 URL
|
||
*/
|
||
export function publicityExportUrl(group) {
|
||
return `/api/admin/export-publicity/${group}`
|
||
}
|
||
|
||
/**
|
||
* 读取赛项信息(赛项名称 / 赛项编号)
|
||
*
|
||
* 这两项用于公示表标题(模板第 1~3 行)。用户裁定:不做硬编码,
|
||
* 改由管理人员在后台填写 —— 因为赛项名称/编号每届都会变。
|
||
* @returns {Promise<{itemName:string,itemCode:string}>}
|
||
*/
|
||
export function getCompetitionItem() {
|
||
return request.get('/admin/competition-item', { silent: true })
|
||
}
|
||
|
||
/**
|
||
* 保存赛项信息
|
||
* @param {{itemName?: string, itemCode?: string}} data
|
||
* @returns {Promise<{itemName:string,itemCode:string}>}
|
||
*/
|
||
export function updateCompetitionItem(data) {
|
||
// silent: true —— 越界(名称 >120 字 / 编号 >60 字)是业务性 400,
|
||
// 后端 message 已可读,由调用方在弹窗内就地提示,避免拦截器再弹一次(同族双弹)
|
||
return request.put('/admin/competition-item', data, { silent: true })
|
||
}
|
||
|
||
// ==================== 指导教师回填(R176) ====================
|
||
|
||
/**
|
||
* 从报名表回填「指导教师」
|
||
*
|
||
* 背景:报名表导入时按用户裁定只录入参赛选手,指导教师的姓名没有落库。
|
||
* 公示表需要「指导教师」列,故提供一次性回填:重新上传同一份报名表,
|
||
* 服务端只读其中的「指导教师」行,按「组别 + 小组编号」定位到该组全体组员,
|
||
* 写入 `contestants.teacher_name`(组级属性,冗余到每位组员)。
|
||
*
|
||
* ⛔ 不新建账号、不改组别/成绩,只更新这一个列。
|
||
* silent: true —— 失败提示由调用方统一给出,避免同族双弹。
|
||
* @param {FormData} formData - 含报名表(字段名 file)
|
||
* @returns {Promise<{ok:boolean,code:string,message:string,stats:object,
|
||
* teams:object,updated:number,unchanged:number,unmatched:Array,warnings:string[]}>}
|
||
*/
|
||
export function backfillTeachers(formData) {
|
||
return request.post('/admin/contestants/backfill-teacher', formData, {
|
||
timeout: 120000,
|
||
silent: true,
|
||
})
|
||
}
|
||
|
||
// ==================== 新闻管理 ====================
|
||
|
||
/**
|
||
* 获取新闻列表(管理员)
|
||
* @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 })
|
||
}
|
||
|
||
/**
|
||
* 上传新闻附件(R176「缺口2:新闻管理新增文件附件能力」)
|
||
*
|
||
* 场景:成绩公示表这类**文件类公示**要在新闻里对外提供下载。
|
||
* 附件与正文分开存:`attachment_url`(服务端返回的访问路径)+ `attachment_name`
|
||
* (展示用的原始文件名,中文原样保留)。
|
||
*
|
||
* ⚠️ 不要手动设置 Content-Type:axios 会在 FormData 上自动补 boundary,
|
||
* 写死 'multipart/form-data' 会让后端 getFileStream 报 Missing boundary → 500。
|
||
*
|
||
* silent: true —— 后端对「格式不支持 / 超 20MB」已给出可读文案,
|
||
* 由调用方就地提示,避免拦截器再弹一次(同族双弹)。
|
||
* @param {FormData} formData - 含文件(字段名 file)
|
||
* @returns {Promise<{url:string,name:string,size:number}>}
|
||
*/
|
||
export function uploadNewsFile(formData) {
|
||
return request.post('/admin/upload/news-file', formData, {
|
||
timeout: 120000,
|
||
silent: true,
|
||
})
|
||
}
|
||
|
||
// ==================== 轮播图管理 ====================
|
||
|
||
/**
|
||
* 获取轮播图列表(管理员)
|
||
* @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)
|
||
}
|
||
|
||
/**
|
||
* 下载「综合大题成绩导入」模板(R172)
|
||
*
|
||
* 模板两页:`成绩导入`(小组编号 / 分数)+ `填写说明`。
|
||
* @param {string} group - 组别标识(zhongzhi / gaozhi)
|
||
* @returns {Promise}
|
||
*/
|
||
export function getBigScoreImportTemplate(group) {
|
||
return request.get(`/admin/big-answers/${group}/import-template`, {
|
||
responseType: 'blob',
|
||
timeout: 60000,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 预览成绩导入(R172 · ⛔ 零写入;R174 改三小问四列制)
|
||
*
|
||
* 服务端逐行判定:可导入 / 将覆盖 / 被拒绝(附原因),前端据此让管理员确认后再导入。
|
||
* @param {string} group
|
||
* @param {FormData} formData - 含 Excel 文件(字段名 file)
|
||
* @returns {Promise} {
|
||
* total, importable, overwritable, blocked, skippedBlank,
|
||
* rows: [{ row, teamNumber, subRaws, subs, total, status, message, blocking,
|
||
* contestantId, contestantName, contestantIdNumber, teamUid,
|
||
* memberCount, operatorCount, currentScores }]
|
||
* }
|
||
* · subRaws = 三小问原始填报值(报错行也有,用于定位)
|
||
* · subs = 校验通过后的三小问得分;total = 三者之和(判定通过时才有)
|
||
*/
|
||
export function previewBigScoreImport(group, formData) {
|
||
// silent: true —— 错误提示由调用方统一给出,避免拦截器 + 调用方双弹
|
||
return request.post(`/admin/big-answers/${group}/import-preview`, formData, {
|
||
timeout: 120000,
|
||
silent: true,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 执行成绩导入(R172)
|
||
*
|
||
* 写入仍走唯一评分路径(setSubScores → service.admin.updateScores),
|
||
* 不新增第二条写成绩通路。
|
||
* @param {string} group
|
||
* @param {FormData} formData - 含 Excel 文件(字段名 file)
|
||
* @param {boolean} [overwrite=false] - 是否覆盖已评分
|
||
* @returns {Promise} { imported, skipped, overwritten, errors: [{ row, teamNumber, message }] }
|
||
*/
|
||
export function importBigScores(group, formData, overwrite = false) {
|
||
return request.post(`/admin/big-answers/${group}/import-scores`, formData, {
|
||
params: { overwrite: overwrite ? 1 : 0 },
|
||
timeout: 300000,
|
||
silent: true,
|
||
})
|
||
}
|
||
|
||
// ==================== 成绩复核 ====================
|
||
// 场景:考生对成绩有异议 → 按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
|
||
|
||
/**
|
||
* 检索考生(姓名 / 身份证号 / 电话 任一,模糊匹配)
|
||
*
|
||
* 不传 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}`)
|
||
}
|
||
|
||
// ==================== 选手信息编辑(R108 新增) ====================
|
||
|
||
/**
|
||
* 编辑已入库选手的身份字段(姓名 / 手机号 / 身份证号)
|
||
*
|
||
* 背景:此前选手信息只有「Excel 导入」一个写入口,录错了既改不了也删不掉。
|
||
*
|
||
* silent: true —— 抑制响应拦截器的通用错误提示,由调用方按业务码分支提示。
|
||
* 本接口的失败形态是**业务性**的(400 入参非法 / 404 选手不存在 / 409 证号被占用),
|
||
* 后端 message 已含可读原因(如「身份证号 X 已被占用:张三 / id=123 / 组别=zhongzhi」),
|
||
* 拦截器再弹一次「请求失败」即为同族双弹(见 frontend-error-notify.md 口径)。
|
||
*
|
||
* @param {number|string} id - contestants.id
|
||
* @param {{name?: string, phone?: string, id_number?: string}} payload
|
||
* 仅这三项可改;传其他字段后端会显式 400 拒绝(调用方切勿透传整行对象,
|
||
* 否则 group / score 等只读字段会把请求打成 400)。
|
||
* @returns {Promise<{code:number,message:string,data:{
|
||
* ok:boolean, changed:string[], warnings:string[],
|
||
* contestant:{id:number,name:string,phone:string,id_number:string,group:string,
|
||
* team_id:number|null,activated:boolean,test_flag:number}
|
||
* }}>}
|
||
* `changed` 为空数组 = 提交值与库内一致,未落库(后端 message 为「未检测到需要修改的内容」)。
|
||
* `warnings` 非空须展示(典型:改号后口令不重置,需走「忘记密码」)。
|
||
*/
|
||
export function updateContestant(id, payload) {
|
||
return request.put(`/admin/contestants/${id}`, payload, { silent: true })
|
||
}
|
||
|
||
// ==================== R178 多管理员 + 权限 ====================
|
||
|
||
/**
|
||
* 当前登录管理员的自助信息(权限点 + 权限目录)
|
||
*
|
||
* 为什么需要它:权限被最高管理员改动后,前端手里那份(登录时落盘的)会过期。
|
||
* 进页面时拉一次,能让「权限已被收回」被立刻感知,而不是点每个按钮都撞 403。
|
||
*
|
||
* silent: true —— 该接口在 401(会话失效)时由拦截器统一跳登录,业务上不产生
|
||
* 需要额外提示的失败形态,避免与拦截器双弹。
|
||
*/
|
||
export function getAdminMe() {
|
||
return request.get('/admin/me', { silent: true })
|
||
}
|
||
|
||
/** 管理员列表(不含口令) */
|
||
export function listAdminUsers() {
|
||
return request.get('/admin/admin-users')
|
||
}
|
||
|
||
/**
|
||
* 新增管理员
|
||
* @param {{username:string,password:string,permissions:string[],remark?:string}} data
|
||
*/
|
||
export function createAdminUser(data) {
|
||
return request.post('/admin/admin-users', data)
|
||
}
|
||
|
||
/** 编辑权限与备注(对目标账号立即生效,无需其重新登录) */
|
||
export function updateAdminUserPermissions(id, data) {
|
||
return request.put(`/admin/admin-users/${id}/permissions`, data)
|
||
}
|
||
|
||
/** 重置口令(不要求旧口令;成功后目标账号所有在线会话立即失效) */
|
||
export function resetAdminUserPassword(id, data) {
|
||
return request.put(`/admin/admin-users/${id}/password`, data)
|
||
}
|
||
|
||
/** 启用 / 禁用(status: 1 启用 / 0 禁用;禁用会立即踢下线) */
|
||
export function setAdminUserStatus(id, data) {
|
||
return request.put(`/admin/admin-users/${id}/status`, data)
|
||
}
|
||
|
||
/** 删除管理员(最高管理员与本人账号不可删,后端会拒绝) */
|
||
export function deleteAdminUser(id) {
|
||
return request.delete(`/admin/admin-users/${id}`)
|
||
}
|
||
|
||
|
||
|