Files
competition-website/src/api/admin.js
qubiaobiao ab1c67dbc5 feat(admin-ui): 时间并发冲突自动回读 + 轮播排序脏标记 + 权限热校准
- api/admin.js:setTimes(data, expected);⚠️ 只在基线**非空**时才带 expectedTimes
  (空对象会让后端误判"所有组都要求校验"而恒冲突)。
- Admin.vue(时间设置):timesBaseline 记录 fetchTimes 载入时的服务端旧值,
  ⛔ 不用表单当前值(那样永远等于提交值 ⇒ 校验恒真 = 假保护)。冲突时自动
  fetchTimes 重载(表单已过期,拿它重试只会再冲突);其它失败保留用户输入。
- Admin.vue(轮播):carouselOrderDirty —— 拖拽只改前端顺序、不落库,
  此前卡片"排序: N"用的是渲染下标,拖完立刻变新序号,看起来已生效、刷新却回退。
  现加"排序未保存"标签 + 按钮文案变化,未保存状态可见。
- Admin.vue(权限):切菜单时 8s 节流重拉 /api/admin/me,权限变化即提示。
  原先前端只在 onMounted 拉一次 ⇒ 被改权限后必须手动刷新页面,
  否则停在旧菜单上每个请求都撞 403 且无任何提示。⛔ 不用 location.reload()
  (会丢未提交的表单输入)。
2026-10-21 13:44:38 +08:00

771 lines
28 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 })
}
/**
* 获取小组(团队)激活状态清单(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) {
return request.get(`/admin/submissions/${group}`)
}
/**
* 获取比赛时间配置
* @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) {
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)
}
// ==================== 成绩公示表(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}`)
}