Compare commits
13 Commits
680faacd1d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f4cfc280e | |||
| 4fd04f106a | |||
| 96d16129f7 | |||
| ab1c67dbc5 | |||
| 45e77d9f69 | |||
| c8097fab00 | |||
| b7ec541223 | |||
| 4e7dfe09c8 | |||
| 1b569a38e6 | |||
| 3085616605 | |||
| 092910404a | |||
| 27665c2186 | |||
| e7179bfe54 |
1474
package-lock.json
generated
1474
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,8 +22,8 @@
|
|||||||
"vue-router": "^4.3.0"
|
"vue-router": "^4.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.0",
|
"@vitejs/plugin-vue": "^6.0.10",
|
||||||
"unplugin-vue-components": "^32.1.0",
|
"unplugin-vue-components": "^32.1.0",
|
||||||
"vite": "^5.0.0"
|
"vite": "^8.3.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
276
src/api/admin.js
276
src/api/admin.js
@@ -19,6 +19,15 @@ export function getAccounts(params) {
|
|||||||
return request.get('/admin/accounts', { 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 批量创建账号
|
* 导入 Excel 批量创建账号
|
||||||
* @param {FormData} formData - 包含 Excel 文件的 FormData
|
* @param {FormData} formData - 包含 Excel 文件的 FormData
|
||||||
@@ -44,8 +53,8 @@ export function importExcel(formData) {
|
|||||||
* @param {string} group - 组别标识
|
* @param {string} group - 组别标识
|
||||||
* @returns {Promise} 作品列表
|
* @returns {Promise} 作品列表
|
||||||
*/
|
*/
|
||||||
export function getSubmissions(group) {
|
export function getSubmissions(group, params) {
|
||||||
return request.get(`/admin/submissions/${group}`)
|
return request.get(`/admin/submissions/${group}`, { params })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,9 +68,11 @@ export function getTimes() {
|
|||||||
/**
|
/**
|
||||||
* 设置比赛时间
|
* 设置比赛时间
|
||||||
* @param {Object} data - { zhongzhi: {start, end}, gaozhi: ... }
|
* @param {Object} data - { zhongzhi: {start, end}, gaozhi: ... }
|
||||||
|
* @param {Object} [expected] - B② 乐观锁:打开表单时读到的旧值
|
||||||
|
* { zhongzhi: { start, end, deadline }, ... };不传 ⇒ 后端不校验(旧行为)
|
||||||
* @returns {Promise}
|
* @returns {Promise}
|
||||||
*/
|
*/
|
||||||
export function setTimes(data) {
|
export function setTimes(data, expected) {
|
||||||
// 后端要求 times 数组,格式:[{ group, startTime, endTime, deadline, choiceCount, judgeCount }]
|
// 后端要求 times 数组,格式:[{ group, startTime, endTime, deadline, choiceCount, judgeCount }]
|
||||||
// 注意:必须携带 choiceCount / judgeCount,否则后端按缺省 50/20 处理,后台「抽取数量」配置无法落库
|
// 注意:必须携带 choiceCount / judgeCount,否则后端按缺省 50/20 处理,后台「抽取数量」配置无法落库
|
||||||
const times = Object.entries(data).map(([group, item]) => ({
|
const times = Object.entries(data).map(([group, item]) => ({
|
||||||
@@ -72,7 +83,21 @@ export function setTimes(data) {
|
|||||||
choiceCount: item.choiceCount != null ? parseInt(item.choiceCount, 10) : 50,
|
choiceCount: item.choiceCount != null ? parseInt(item.choiceCount, 10) : 50,
|
||||||
judgeCount: item.judgeCount != null ? parseInt(item.judgeCount, 10) : 20,
|
judgeCount: item.judgeCount != null ? parseInt(item.judgeCount, 10) : 20,
|
||||||
}))
|
}))
|
||||||
return request.put('/admin/times', { times })
|
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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -231,8 +256,8 @@ export function deleteMaterial(group, fileName) {
|
|||||||
* @param {string} group - 组别标识
|
* @param {string} group - 组别标识
|
||||||
* @returns {Promise} 成绩列表
|
* @returns {Promise} 成绩列表
|
||||||
*/
|
*/
|
||||||
export function getScores(group) {
|
export function getScores(group, params) {
|
||||||
return request.get(`/admin/scores/${group}`)
|
return request.get(`/admin/scores/${group}`, { params })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -257,6 +282,91 @@ export function getPublishStatus(group) {
|
|||||||
return request.get('/admin/publish-scores', group ? { params: { group } } : undefined)
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 新闻管理 ====================
|
// ==================== 新闻管理 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -304,6 +414,28 @@ export function updateNewsOrder(orders) {
|
|||||||
return request.put('/admin/news/order', { 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 轮播图管理 ====================
|
// ==================== 轮播图管理 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -468,6 +600,61 @@ export function setBigAnswerScores(group, uid, scores) {
|
|||||||
return request.put(`/admin/big-answers/${group}/${uid}/scores`, 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 成绩复核 ====================
|
// ==================== 成绩复核 ====================
|
||||||
// 场景:考生对成绩有异议 → 按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
|
// 场景:考生对成绩有异议 → 按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
|
||||||
|
|
||||||
@@ -503,4 +690,81 @@ export function getContestantScore(group, contestantId) {
|
|||||||
return request.get(`/admin/review/score/${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}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ import request from './request'
|
|||||||
/**
|
/**
|
||||||
* 获取上传状态
|
* 获取上传状态
|
||||||
* @param {string} group - 组别标识
|
* @param {string} group - 组别标识
|
||||||
* @returns {Promise} { submitted, deadline, canSubmit }
|
* @param {Object} [config] - 透传给 axios 的配置(如 { silent: true } 抑制拦截器报错)
|
||||||
|
* @returns {Promise} { submitted, deadline, canSubmit, canUpload, teamLocked, teamLockMessage, teamFiles, ... }
|
||||||
*/
|
*/
|
||||||
export function getUploadStatus(group) {
|
export function getUploadStatus(group, config = {}) {
|
||||||
return request.get(`/upload/${group}/status`)
|
return request.get(`/upload/${group}/status`, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
1
src/components.d.ts
vendored
1
src/components.d.ts
vendored
@@ -19,6 +19,7 @@ declare module 'vue' {
|
|||||||
ElCarousel: typeof import('element-plus/es')['ElCarousel']
|
ElCarousel: typeof import('element-plus/es')['ElCarousel']
|
||||||
ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem']
|
ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem']
|
||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
|
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||||
ElCol: typeof import('element-plus/es')['ElCol']
|
ElCol: typeof import('element-plus/es')['ElCol']
|
||||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||||
|
|||||||
@@ -5,7 +5,18 @@
|
|||||||
=========================================================== */
|
=========================================================== */
|
||||||
|
|
||||||
/* ============ CSS 变量定义 ============ */
|
/* ============ CSS 变量定义 ============ */
|
||||||
:root {
|
/* 🔴 选择器必须是 html:root 而不是 :root(2026-10-21 R188 实测):
|
||||||
|
Element Plus 默认主题在同一 `:root` 上定义 `--el-color-primary:#409eff`,
|
||||||
|
两者特异性相同 ⇒ **谁后加载谁赢**。
|
||||||
|
· vite 5(rollup):EP 基础样式与 global.css 合并在同一个入口 CSS 内,
|
||||||
|
EP 在前、本文件在后 ⇒ 覆盖生效(生产旧包 index-CVAb-2kd.css 实测:
|
||||||
|
EP 规则 @27768 < 本文件覆盖 @57506)。
|
||||||
|
· vite 8(rolldown):EP 基础样式被拆成独立的 css-*.css 块,且
|
||||||
|
index.html 里排在入口 CSS **之后** ⇒ EP 默认值反过来赢,
|
||||||
|
主色退回 #409eff(登录按钮由深藏青变亮蓝)。
|
||||||
|
html:root = (0,0,1,1) 高于 :root = (0,0,1,0),与加载顺序无关。
|
||||||
|
⛔ 不要改回 :root —— 除非能证明 EP 样式一定排在前面。 */
|
||||||
|
html:root {
|
||||||
/* 主色调:深蓝色,用于导航栏、按钮等关键元素 */
|
/* 主色调:深蓝色,用于导航栏、按钮等关键元素 */
|
||||||
--primary-color: #1a3a5c;
|
--primary-color: #1a3a5c;
|
||||||
/* 辅助色:金色,用于强调、高亮、获奖标识等 */
|
/* 辅助色:金色,用于强调、高亮、获奖标识等 */
|
||||||
@@ -371,7 +382,14 @@ input:-webkit-autofill {
|
|||||||
本规则只改背景、不动文字颜色,故不会影响任何页面的文字配色设计。
|
本规则只改背景、不动文字颜色,故不会影响任何页面的文字配色设计。
|
||||||
新增页面自动继承此保障,无需逐页排查。
|
新增页面自动继承此保障,无需逐页排查。
|
||||||
=========================================================== */
|
=========================================================== */
|
||||||
.el-button.is-text:not(.is-disabled):hover,
|
/* 🔴 选择器前缀 :root 是**必需**的(2026-10-21 R188 实测),不能省:
|
||||||
.el-button.is-text:not(.is-disabled):focus {
|
本规则与 EP 的 .el-button.is-text:not(.is-disabled):hover 同为 4 个类级,
|
||||||
|
旧构建靠「本文件在后」取胜;vite 8 把 EP 样式拆块后排在入口 CSS 之后,
|
||||||
|
同特异性下 EP 反过来赢 ⇒ hover 底变回不透明近白 #f5f7fa ⇒
|
||||||
|
「白底白字」缺陷复现(该缺陷此前已修复,见上方说明)。
|
||||||
|
前缀 :root 使特异性升到 5 个类级 ⇒ 与顺序无关,重新压过 EP。 */
|
||||||
|
:root .el-button.is-text:not(.is-disabled):hover,
|
||||||
|
:root .el-button.is-text:not(.is-disabled):focus {
|
||||||
background-color: rgba(120, 130, 145, 0.16);
|
background-color: rgba(120, 130, 145, 0.16);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -95,6 +95,32 @@ function getHeader(headers, name) {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取服务端声明的「真实字节数」哨兵(下载完整性校验用)。
|
||||||
|
*
|
||||||
|
* 🔴 R103(2026-10-12 生产实测):X-Accel-Redirect 分支下 nginx 会**丢弃上游自定义响应头**。
|
||||||
|
* 双通道实测同一文件:直连后端 ⇒ 带 `x-file-size: 19009`;经 nginx ⇒ 返回 19009 字节
|
||||||
|
* 但该头缺失(而 `Content-Disposition` 能穿透)。
|
||||||
|
* ⇒ 后端把同一字节数同时写入 RFC 6266 §4.1 的 disposition 扩展参数 `size=`,
|
||||||
|
* 这里按「x-file-size → CD 的 size=」顺序回落;两通道同源同值,任一缺失只降级不误伤。
|
||||||
|
*
|
||||||
|
* 判据可靠性:`filename*=` 的值是百分号编码的,其中不可能出现字面量 `size=`,
|
||||||
|
* 且 `size=` 必须落在参数边界(行首或 `;` 之后),故不会与文件名误匹配。
|
||||||
|
*
|
||||||
|
* @returns {number} 声明字节数;两通道都取不到时返回 NaN(调用方跳过校验,优雅降级)
|
||||||
|
*/
|
||||||
|
function readDeclaredSize(headers) {
|
||||||
|
const direct = parseInt(getHeader(headers, 'x-file-size'), 10)
|
||||||
|
if (Number.isFinite(direct) && direct > 0) return direct
|
||||||
|
const cd = getHeader(headers, 'content-disposition')
|
||||||
|
const m = /(?:^|;)\s*size\s*=\s*(\d+)\s*(?:;|$)/i.exec(cd || '')
|
||||||
|
if (m) {
|
||||||
|
const n = parseInt(m[1], 10)
|
||||||
|
if (Number.isFinite(n) && n > 0) return n
|
||||||
|
}
|
||||||
|
return NaN
|
||||||
|
}
|
||||||
|
|
||||||
// 归一化路径:dl 实例已带 baseURL '/api',若调用方传入 '/api/xxx' 会重复前缀。
|
// 归一化路径:dl 实例已带 baseURL '/api',若调用方传入 '/api/xxx' 会重复前缀。
|
||||||
function normalizeUrl(url) {
|
function normalizeUrl(url) {
|
||||||
if (url && url.startsWith('/api')) return url.slice(4)
|
if (url && url.startsWith('/api')) return url.slice(4)
|
||||||
@@ -155,8 +181,10 @@ function triggerDownload(blob, filename, originalUrl) {
|
|||||||
// 原有「HTTP 状态 + JSON 信封」两道校验对 HTML 完全无感,于是损坏文件被静默存盘。
|
// 原有「HTTP 状态 + JSON 信封」两道校验对 HTML 完全无感,于是损坏文件被静默存盘。
|
||||||
// 对策(两道,任一命中即拒绝落盘并弹出可行动的错误):
|
// 对策(两道,任一命中即拒绝落盘并弹出可行动的错误):
|
||||||
// ① HTML 嗅探:Content-Type 为 text/html,或正文以 <!DOCTYPE html / <html 开头;
|
// ① HTML 嗅探:Content-Type 为 text/html,或正文以 <!DOCTYPE html / <html 开头;
|
||||||
// ② 大小哨兵:后端透出 X-File-Size(服务端 stat 的真实字节数),与 blob.size 比对,
|
// ② 大小哨兵:后端透出「服务端 stat 的真实字节数」,与 blob.size 比对,
|
||||||
// 不一致即说明内容被替换或传输被截断。该头缺失时跳过此校验(优雅降级,不误伤)。
|
// 不一致即说明内容被替换或传输被截断。取头顺序(见 readDeclaredSize):
|
||||||
|
// X-File-Size → Content-Disposition 的 `size=` 扩展参数 —— 两通道同源同值,
|
||||||
|
// 都取不到时跳过此校验(优雅降级,不误伤)。
|
||||||
async function assertRealFile(res) {
|
async function assertRealFile(res) {
|
||||||
const blob = res.data
|
const blob = res.data
|
||||||
if (!blob) return '下载失败:响应内容为空'
|
if (!blob) return '下载失败:响应内容为空'
|
||||||
@@ -176,7 +204,9 @@ async function assertRealFile(res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ② 大小哨兵:与服务端 stat 的真实字节数比对
|
// ② 大小哨兵:与服务端 stat 的真实字节数比对
|
||||||
const declared = parseInt(getHeader(res.headers, 'x-file-size'), 10)
|
// 🔴 R103:头来源改为 readDeclaredSize —— 优先 x-file-size,缺失时回落 Content-Disposition
|
||||||
|
// 的 `size=` 扩展参数(X-Accel 分支下 nginx 会丢掉前者,见 readDeclaredSize 注释)。
|
||||||
|
const declared = readDeclaredSize(res.headers)
|
||||||
if (Number.isFinite(declared) && declared > 0 && blob.size !== declared) {
|
if (Number.isFinite(declared) && declared > 0 && blob.size !== declared) {
|
||||||
return `文件传输不完整:应为 ${declared} 字节,实际收到 ${blob.size} 字节,请重试`
|
return `文件传输不完整:应为 ${declared} 字节,实际收到 ${blob.size} 字节,请重试`
|
||||||
}
|
}
|
||||||
|
|||||||
2264
src/views/Admin.vue
2264
src/views/Admin.vue
File diff suppressed because it is too large
Load Diff
@@ -21,12 +21,46 @@
|
|||||||
<div class="exam-upload-mode" v-if="isUploadMode" v-loading="uploadPanelLoading">
|
<div class="exam-upload-mode" v-if="isUploadMode" v-loading="uploadPanelLoading">
|
||||||
<el-card class="upload-card" shadow="never">
|
<el-card class="upload-card" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
|
<div class="upload-header">
|
||||||
<span class="upload-title">答案文件上传({{ groupLabel }})</span>
|
<span class="upload-title">答案文件上传({{ groupLabel }})</span>
|
||||||
|
<!-- R192:页面级常驻出口。本页无全局导航(App.vue 只有 router-view),
|
||||||
|
组锁 / 倒计时归零 / 只是想放弃提交 的用户若无此按钮就只能在浏览器后退,
|
||||||
|
移动端甚至退不出去(体验反馈:成员进入后被锁在提交页)。 -->
|
||||||
|
<el-button text type="primary" :icon="Back" @click="leaveCompetition">返回工作台</el-button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-alert type="info" :closable="false" show-icon style="margin-bottom:16px">
|
<el-alert type="info" :closable="false" show-icon style="margin-bottom:16px">
|
||||||
<template #title>本组别无客观题:先选择文件并确认无误,再点击「上传所选文件」提交</template>
|
<template #title>本组别无客观题:先选择文件并确认无误,再点击「上传所选文件」提交</template>
|
||||||
支持 docx / pdf / ppt / zip。规则:① zip 须单独上传(与任何其他文件互斥,自动覆盖);② pdf 与 docx 相互覆盖,只能保留其一;③ 若需同时提交文档与演示稿,请选「pdf + ppt」或「docx + ppt」组合。zip 上限 200MB,其余格式上限 100MB。
|
支持 docx / pdf / ppt / zip。规则:① zip 须单独上传(与任何其他文件互斥,自动覆盖);② pdf 与 docx 相互覆盖,只能保留其一;③ 若需同时提交文档与演示稿,请选「pdf + ppt」或「docx + ppt」组合。zip 上限 200MB,其余格式上限 100MB。
|
||||||
</el-alert>
|
</el-alert>
|
||||||
|
|
||||||
|
<!-- R190:小组作品状态(组内互斥)—— 组内已有人上传后,其他成员不能再传。
|
||||||
|
文案直接取后端 teamLockMessage,⛔ 前端不另拼一套口径(避免与后端判定漂移)。 -->
|
||||||
|
<div
|
||||||
|
v-if="teamPanelVisible"
|
||||||
|
class="team-status-panel"
|
||||||
|
:class="{ 'is-locked': teamLocked }"
|
||||||
|
>
|
||||||
|
<div class="team-status-head">
|
||||||
|
<span class="team-status-title">
|
||||||
|
<template v-if="teamLocked">本组作品已由其他成员上传,您不能再上传</template>
|
||||||
|
<template v-else-if="uploadStatus.teamSubmitted">本组作品已由您上传,截止前可随时替换</template>
|
||||||
|
<template v-else>本组尚无人上传作品</template>
|
||||||
|
</span>
|
||||||
|
<el-tag v-if="uploadStatus.teamNumber" size="small" type="info">小组编号 {{ uploadStatus.teamNumber }}</el-tag>
|
||||||
|
<el-tag v-if="uploadStatus.memberCount > 1" size="small" type="info">组内 {{ uploadStatus.memberCount }} 人</el-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="uploadStatus.teamLockMessage" class="team-status-msg">{{ uploadStatus.teamLockMessage }}</div>
|
||||||
|
<div v-if="teamSharedFiles.length" class="team-status-files">
|
||||||
|
<div class="pending-list-title">组内已上传文件:</div>
|
||||||
|
<div v-for="(f, i) in teamSharedFiles" :key="i" class="team-status-file">
|
||||||
|
<span class="uploaded-name">{{ f.fileName }}</span>
|
||||||
|
<el-tag size="small" :type="categoryTagType(categoryOf(f.fileName))">{{ categoryLabel(categoryOf(f.fileName)) }}</el-tag>
|
||||||
|
<span class="team-status-uploader">上传者:{{ f.uploaderName || '组内成员' }}{{ f.isMine ? '(我)' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-upload
|
<el-upload
|
||||||
ref="uploadRef"
|
ref="uploadRef"
|
||||||
:auto-upload="false"
|
:auto-upload="false"
|
||||||
@@ -34,6 +68,7 @@
|
|||||||
:accept="'.doc,.docx,.pdf,.ppt,.pptx,.zip'"
|
:accept="'.doc,.docx,.pdf,.ppt,.pptx,.zip'"
|
||||||
:file-list="uploadFileList"
|
:file-list="uploadFileList"
|
||||||
:on-change="onUploadFileChange"
|
:on-change="onUploadFileChange"
|
||||||
|
:disabled="teamLocked"
|
||||||
multiple
|
multiple
|
||||||
drag
|
drag
|
||||||
class="upload-drag"
|
class="upload-drag"
|
||||||
@@ -67,9 +102,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="upload-actions">
|
<div class="upload-actions">
|
||||||
<el-button type="primary" :loading="uploading" :disabled="pendingUploadFiles.length === 0 || !uploadComboValid || hasOverLimitFile" v-guard="() => submitUploadFiles()">上传所选文件</el-button>
|
<el-button type="primary" :loading="uploading" :disabled="teamLocked || pendingUploadFiles.length === 0 || !uploadComboValid || hasOverLimitFile" v-guard="() => submitUploadFiles()">上传所选文件</el-button>
|
||||||
<el-button @click="clearUploadFiles" :disabled="uploadFileList.length === 0">清空待上传</el-button>
|
<el-button @click="clearUploadFiles" :disabled="uploadFileList.length === 0">清空待上传</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 被组锁时,明确告知「为什么按钮点不动」,避免用户以为是页面故障 -->
|
||||||
|
<el-alert
|
||||||
|
v-if="teamLocked"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
style="margin-top:12px"
|
||||||
|
title="组内作品唯一:本组已有成员上传,您无法再上传"
|
||||||
|
description="如需替换作品,请联系上传者本人操作(仅首个上传者可在截止前替换)。您本人无需提交,可直接返回工作台。"
|
||||||
|
/>
|
||||||
|
<!-- 组锁的出口按钮必须**紧贴提示**:视线焦点在这里,且此处的「返回」不弹
|
||||||
|
「丢失提交进度」确认(团队作品已由队友提交,本机没有可丢的内容)。 -->
|
||||||
|
<div v-if="teamLocked" class="team-locked-actions">
|
||||||
|
<el-button type="primary" @click="leaveCompetition">返回工作台</el-button>
|
||||||
|
<span class="team-locked-hint">本组作品已由队友提交,您无需任何操作。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-divider v-if="uploadedWorks.length" />
|
<el-divider v-if="uploadedWorks.length" />
|
||||||
<div v-if="uploadedWorks.length" class="uploaded-list">
|
<div v-if="uploadedWorks.length" class="uploaded-list">
|
||||||
@@ -366,7 +417,7 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useRouter, onBeforeRouteLeave } from 'vue-router'
|
import { useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
|
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
|
||||||
import { Timer, UploadFilled } from '@element-plus/icons-vue'
|
import { Timer, UploadFilled, Back } from '@element-plus/icons-vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useExamStore } from '@/stores/exam'
|
import { useExamStore } from '@/stores/exam'
|
||||||
import * as examApi from '@/api/exam'
|
import * as examApi from '@/api/exam'
|
||||||
@@ -396,6 +447,31 @@ const uploadFileList = ref([]) // el-upload 绑定列表
|
|||||||
const uploadedWorks = ref([])
|
const uploadedWorks = ref([])
|
||||||
const uploadRef = ref(null)
|
const uploadRef = ref(null)
|
||||||
|
|
||||||
|
// ---------- R190:小组作品状态(组内互斥)----------
|
||||||
|
// 后端 `GET /api/upload/:group/status` 是组锁判定的**唯一权威**,前端只负责展示:
|
||||||
|
// teamLocked / teamLockMessage / teamFiles 等一律原样取用,⛔ 不在前端重算一套规则
|
||||||
|
//(重算必然与后端漂移,且学生端与下载端的「谁是归属人」会打架)。
|
||||||
|
const uploadStatus = ref({
|
||||||
|
submitted: false,
|
||||||
|
canUpload: true,
|
||||||
|
deadlineValid: true,
|
||||||
|
teamNumber: null,
|
||||||
|
folderName: '',
|
||||||
|
memberCount: 1,
|
||||||
|
teamSubmitted: false,
|
||||||
|
teamOwnerName: null,
|
||||||
|
isTeamOwner: false,
|
||||||
|
teamLocked: false,
|
||||||
|
teamLockMessage: null,
|
||||||
|
teamFiles: [],
|
||||||
|
})
|
||||||
|
const teamLocked = computed(() => !!uploadStatus.value.teamLocked)
|
||||||
|
// 有小组编号 or 组内多于一人时展示小组状态面板;独立参赛者保持原界面不变
|
||||||
|
const teamPanelVisible = computed(() => !!uploadStatus.value.teamNumber || uploadStatus.value.memberCount > 1)
|
||||||
|
// 被锁时才展示「组内已上传文件」——归属人(本人)在下方「已上传文件」里已有可删除列表,
|
||||||
|
// 两处同列会重复且按钮语义不同,容易误删。
|
||||||
|
const teamSharedFiles = computed(() => (teamLocked.value ? (uploadStatus.value.teamFiles || []) : []))
|
||||||
|
|
||||||
const ZIP_MAX = 200 * 1024 * 1024
|
const ZIP_MAX = 200 * 1024 * 1024
|
||||||
const OTHER_MAX = 100 * 1024 * 1024
|
const OTHER_MAX = 100 * 1024 * 1024
|
||||||
|
|
||||||
@@ -452,6 +528,36 @@ function clearUploadFiles() {
|
|||||||
uploadRef.value?.clearFiles?.()
|
uploadRef.value?.clearFiles?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回工作台(R192)—— 上传模式的统一出口。
|
||||||
|
*
|
||||||
|
* 🔴 必须先 resetExam() 再 push,理由有三条:
|
||||||
|
* ① 不清 `examStarted` ⇒ `onBeforeRouteLeave` 会再弹一次「离开页面将丢失当前提交进度」。
|
||||||
|
* 上传模式下用户常常只是放弃提交(甚至被组锁、本机根本没有可提交内容),
|
||||||
|
* 这句文案既失真又构成二次打扰;
|
||||||
|
* ② 不清 timer ⇒ store 是全局单例,回到工作台后倒计时仍在跑,下次进入会看到陈旧剩余时间;
|
||||||
|
* ③ 用 resetExam(而非只 stopExam)与 `onBeforeRouteLeave` 里确认离开后的清理**同一口径**,
|
||||||
|
* ⛔ 不留「按钮退出」与「路由守卫退出」两套清理逻辑(漏项会以「下次进入状态诡异」的形式出现)。
|
||||||
|
*
|
||||||
|
* 只有「待上传列表里有已选文件」才算真有内容会丢,此时才确认一次。
|
||||||
|
* 组锁用户的上传控件是 disabled 的,选不出文件 ⇒ 不弹确认,直接离开。
|
||||||
|
*/
|
||||||
|
async function leaveCompetition() {
|
||||||
|
if (!teamLocked.value && uploadFileList.value.length > 0) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'已选择的文件尚未上传,离开后需要重新选择。确定返回工作台吗?',
|
||||||
|
'提示',
|
||||||
|
{ confirmButtonText: '返回工作台', cancelButtonText: '继续上传', type: 'warning' }
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
examStore.resetExam()
|
||||||
|
router.push('/dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
async function submitUploadFiles() {
|
async function submitUploadFiles() {
|
||||||
const files = pendingUploadFiles.value
|
const files = pendingUploadFiles.value
|
||||||
if (!files.length) return
|
if (!files.length) return
|
||||||
@@ -483,7 +589,8 @@ async function submitUploadFiles() {
|
|||||||
ElMessage.success('上传成功,作品已提交')
|
ElMessage.success('上传成功,作品已提交')
|
||||||
uploadFileList.value = []
|
uploadFileList.value = []
|
||||||
uploadRef.value?.clearFiles?.()
|
uploadRef.value?.clearFiles?.()
|
||||||
await fetchUploadedWorks()
|
// 上传成功后必须同步组锁状态:归属人变成本人、teamSubmitted 变 true(R190)
|
||||||
|
await Promise.all([fetchUploadedWorks(), fetchUploadStatus()])
|
||||||
examStore.stopExam()
|
examStore.stopExam()
|
||||||
} catch {
|
} catch {
|
||||||
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
||||||
@@ -510,7 +617,12 @@ async function deleteUploadedWork(w, silent = false) {
|
|||||||
try {
|
try {
|
||||||
await uploadApi.deleteWork(authStore.userGroup, w.id)
|
await uploadApi.deleteWork(authStore.userGroup, w.id)
|
||||||
uploadedWorks.value = uploadedWorks.value.filter(x => x.id !== w.id)
|
uploadedWorks.value = uploadedWorks.value.filter(x => x.id !== w.id)
|
||||||
if (!silent) ElMessage.success('已删除')
|
// 🔴 只在「用户手动删除」时刷新组锁状态:silent=true 的批量删除是 submitUploadFiles
|
||||||
|
// 的中间步骤,此时并发刷新可能在上传完成后才返回旧快照,把面板刷回陈旧结论。
|
||||||
|
if (!silent) {
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
fetchUploadStatus()
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -526,6 +638,31 @@ async function fetchUploadedWorks() {
|
|||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取小组提交状态(R190)。
|
||||||
|
* 🔴 失败**不阻断**上传面板:状态查询只是辅助展示,真正的上传校验在后端(6006)。
|
||||||
|
* 用 silent 抑制拦截器提示,避免辅助查询失败时给考生弹一条看不懂的报错。
|
||||||
|
*/
|
||||||
|
async function fetchUploadStatus() {
|
||||||
|
try {
|
||||||
|
const d = (await uploadApi.getUploadStatus(authStore.userGroup, { silent: true })) || {}
|
||||||
|
uploadStatus.value = {
|
||||||
|
submitted: !!d.submitted,
|
||||||
|
canUpload: d.canUpload !== false,
|
||||||
|
deadlineValid: d.deadlineValid !== false,
|
||||||
|
teamNumber: d.teamNumber || null,
|
||||||
|
folderName: d.folderName || '',
|
||||||
|
memberCount: Number(d.memberCount) || 1,
|
||||||
|
teamSubmitted: !!d.teamSubmitted,
|
||||||
|
teamOwnerName: d.teamOwnerName || null,
|
||||||
|
isTeamOwner: !!d.isTeamOwner,
|
||||||
|
teamLocked: !!d.teamLocked,
|
||||||
|
teamLockMessage: d.teamLockMessage || null,
|
||||||
|
teamFiles: Array.isArray(d.teamFiles) ? d.teamFiles : [],
|
||||||
|
}
|
||||||
|
} catch (e) { /* 辅助状态查询失败保持默认(不锁),真正拦截在后端 */ }
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 状态 ====================
|
// ==================== 状态 ====================
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -936,7 +1073,13 @@ function preventKeyboard(e) {
|
|||||||
|
|
||||||
// 路由离开警告
|
// 路由离开警告
|
||||||
onBeforeRouteLeave((to, from, next) => {
|
onBeforeRouteLeave((to, from, next) => {
|
||||||
if (examStore.examStarted && !examStore.isTimeUp) {
|
// 🔴 组锁用户不弹:上传控件 disabled、无交卷动作 ⇒ 本机没有任何可丢的内容。
|
||||||
|
// 对这类用户弹「离开将丢失提交进度」是假警告(体验反馈:被锁成员被困在提交页)。
|
||||||
|
// 判定写成「上传模式 && teamLocked」而不是只看 teamLocked:后者当前虽等价
|
||||||
|
// (客观题模式不拉 uploadStatus),但语义上把考试组也纳入了豁免面,将来一旦
|
||||||
|
// 考试组也拉 status,就会静默取消「交卷前离开」的确认 —— 这是不可接受的放松。
|
||||||
|
const lockedNoContent = isUploadMode.value && teamLocked.value
|
||||||
|
if (examStore.examStarted && !examStore.isTimeUp && !lockedNoContent) {
|
||||||
const verb = isUploadMode.value ? '提交' : '答题'
|
const verb = isUploadMode.value ? '提交' : '答题'
|
||||||
ElMessageBox.confirm(
|
ElMessageBox.confirm(
|
||||||
`离开页面将丢失当前${verb}进度,确定要离开吗?`,
|
`离开页面将丢失当前${verb}进度,确定要离开吗?`,
|
||||||
@@ -1078,7 +1221,8 @@ onMounted(async () => {
|
|||||||
// 提交组(本科/教师):无客观题,进入「答案文件上传」模式并启动提交倒计时
|
// 提交组(本科/教师):无客观题,进入「答案文件上传」模式并启动提交倒计时
|
||||||
uploadPanelLoading.value = true
|
uploadPanelLoading.value = true
|
||||||
try {
|
try {
|
||||||
await fetchUploadedWorks()
|
// 两个查询互不依赖,并行发起:作品列表 + 组锁状态(R190)
|
||||||
|
await Promise.all([fetchUploadedWorks(), fetchUploadStatus()])
|
||||||
} finally {
|
} finally {
|
||||||
uploadPanelLoading.value = false
|
uploadPanelLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -1483,6 +1627,81 @@ onUnmounted(() => {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================== R192:上传页出口(组锁 / 主动放弃提交) ==================== */
|
||||||
|
/* 本页无全局导航(App.vue 只有 router-view)⇒ 出口按钮必须常驻,否则被组锁的
|
||||||
|
成员只能靠浏览器后退离开(移动端/内嵌浏览器常常退不出去)。 */
|
||||||
|
.upload-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.team-locked-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.team-locked-hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== R190:小组作品状态面板 ==================== */
|
||||||
|
.team-status-panel {
|
||||||
|
border: 1px solid #c6e2ff;
|
||||||
|
background: #f0f7ff;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.team-status-panel.is-locked {
|
||||||
|
border-color: #f5dab1;
|
||||||
|
background: #fdf6ec;
|
||||||
|
}
|
||||||
|
.team-status-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.team-status-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
.team-status-panel.is-locked .team-status-title {
|
||||||
|
color: #b88230;
|
||||||
|
}
|
||||||
|
.team-status-msg {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
.team-status-files {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.team-status-file {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.team-status-file + .team-status-file {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
.team-status-uploader {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* 超出上传大小限制:明显的高亮警告样式(Issue ②) */
|
/* 超出上传大小限制:明显的高亮警告样式(Issue ②) */
|
||||||
.file-over-limit {
|
.file-over-limit {
|
||||||
background: #fef0f0;
|
background: #fef0f0;
|
||||||
|
|||||||
@@ -49,6 +49,22 @@
|
|||||||
|
|
||||||
<el-divider />
|
<el-divider />
|
||||||
|
|
||||||
|
<!-- R176:附件下载(成绩公示表等「文件类公示」)
|
||||||
|
放在正文之前:附件是这类新闻的**主内容**,正文只是说明。
|
||||||
|
⛔ 链接直接用后端返回的 /public/... 相对路径(dev 由 vite 代理、
|
||||||
|
生产由 nginx 直出),不要拼 /api 前缀。 -->
|
||||||
|
<div v-if="news.attachment_url" class="article-attachment">
|
||||||
|
<el-icon class="article-attachment__icon"><Paperclip /></el-icon>
|
||||||
|
<span class="article-attachment__label">附件下载:</span>
|
||||||
|
<a
|
||||||
|
:href="news.attachment_url"
|
||||||
|
class="article-attachment__link"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
:download="news.attachment_name || ''"
|
||||||
|
>{{ news.attachment_name || '点击下载附件' }}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 微信链接型新闻 -->
|
<!-- 微信链接型新闻 -->
|
||||||
<div v-if="news.news_type === 'wx_link' && news.wx_link" class="wx-link-section">
|
<div v-if="news.news_type === 'wx_link' && news.wx_link" class="wx-link-section">
|
||||||
<el-alert type="info" :closable="false" show-icon>
|
<el-alert type="info" :closable="false" show-icon>
|
||||||
@@ -251,6 +267,36 @@ onMounted(() => {
|
|||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* R176:附件下载块(成绩公示表等文件类公示) */
|
||||||
|
.article-attachment {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 8px 0 16px;
|
||||||
|
background: #f0f6ff;
|
||||||
|
border: 1px solid #d6e4f5;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.article-attachment__icon {
|
||||||
|
color: #1a3a5c;
|
||||||
|
}
|
||||||
|
.article-attachment__label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
.article-attachment__link {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a3a5c;
|
||||||
|
text-decoration: underline;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.article-attachment__link:hover {
|
||||||
|
color: #c9a84c;
|
||||||
|
}
|
||||||
|
|
||||||
.wx-link-btn {
|
.wx-link-btn {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user