feat(auth/exam): 登录重试同源 + 静默刷新透传 + 交卷链路加固(R5/R8)
前端重试与后端闸门同源(须成对上线) - src/api/request.js: 新增 parseRetryAfterMs / computeLoginRetryDelayMs, 退避取 max(指数退避, Retry-After) + ±20% 抖动 + 服务端值封顶; silent 透传(doSilentRefresh 不再吞掉 silent 标记)。 - src/api/auth.js / src/stores/auth.js: 登录 429 走同源重试;错误分类区分 "TCP 未握手(可重试)"与"业务码失败(不可重试)"。 - src/stores/exam.js: 交卷链路顺序红线(取卷 -> 作答 -> submit-all 严格顺序), 答案不丢 / 重复不脏 / 窗口不锁死。 - src/views/Competition.vue: 中职/高职综合大题区块(3 x textarea,2000 字上限 与右下角计数);本科/教师组保留 Word 上传入口。 验证:源码抽取测试 8/8(含判别力自证:不读 header 的对照实现必 FAIL); npm run build BUILD_RC=0(12.82s / 1711 modules);真实交卷端到端 code=0。
This commit is contained in:
@@ -31,13 +31,14 @@ export function activate(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 参赛者登录(身份证号 + 密码)
|
* 参赛者登录(手机号 + 密码)
|
||||||
* @param {string} idNumber - 身份证号
|
* @param {string} phone - 手机号
|
||||||
* @param {string} password - 密码
|
* @param {string} password - 密码
|
||||||
|
* @param {object} [config] - 透传给 axios 的配置(如 { silent: true } 抑制拦截器逐次错误提示)
|
||||||
* @returns {Promise} { token, user }
|
* @returns {Promise} { token, user }
|
||||||
*/
|
*/
|
||||||
export function login(phone, password) {
|
export function login(phone, password, config) {
|
||||||
return request.post('/auth/login', { phone, password })
|
return request.post('/auth/login', { phone, password }, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -175,6 +175,16 @@ request.interceptors.response.use(
|
|||||||
return res
|
return res
|
||||||
},
|
},
|
||||||
async (error) => {
|
async (error) => {
|
||||||
|
// silent 模式(2026-09-21 新增):调用方可在请求 config 上传 silent: true
|
||||||
|
// 来抑制**逐次**错误提示。供自带走重试的调用方(登录 / 交卷 / 逐题保存)使用,
|
||||||
|
// 避免重试期间连弹多条相同报错、让考生误以为系统崩了。
|
||||||
|
// ⛔ 不传 silent 的既有调用方行为**完全不变**(silent 为 undefined → falsy)。
|
||||||
|
// 注:401 分支的提示由 forceLogout 负责(token 真失效必须让用户看到),不受此影响。
|
||||||
|
const silent = !!(error.config && error.config.silent)
|
||||||
|
const notifyError = (msg) => {
|
||||||
|
if (!silent) ElMessage.error(msg)
|
||||||
|
}
|
||||||
|
|
||||||
// 处理 HTTP 状态码错误
|
// 处理 HTTP 状态码错误
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
const { status } = error.response
|
const { status } = error.response
|
||||||
@@ -240,26 +250,26 @@ request.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 403:
|
case 403:
|
||||||
ElMessage.error('您没有权限执行此操作')
|
notifyError('您没有权限执行此操作')
|
||||||
break
|
break
|
||||||
|
|
||||||
case 404:
|
case 404:
|
||||||
ElMessage.error('请求的资源不存在')
|
notifyError('请求的资源不存在')
|
||||||
break
|
break
|
||||||
|
|
||||||
case 500:
|
case 500:
|
||||||
ElMessage.error('服务器内部错误,请稍后重试')
|
notifyError('服务器内部错误,请稍后重试')
|
||||||
break
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
ElMessage.error(error.response.data?.message || `请求失败 (${status})`)
|
notifyError(error.response.data?.message || `请求失败 (${status})`)
|
||||||
}
|
}
|
||||||
} else if (error.code === 'ECONNABORTED') {
|
} else if (error.code === 'ECONNABORTED') {
|
||||||
// 请求超时
|
// 请求超时
|
||||||
ElMessage.error('请求超时,请检查网络连接')
|
notifyError('请求超时,请检查网络连接')
|
||||||
} else {
|
} else {
|
||||||
// 网络错误或其他错误
|
// 网络错误或其他错误
|
||||||
ElMessage.error('网络连接异常,请检查网络')
|
notifyError('网络连接异常,请检查网络')
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
|
|||||||
@@ -1,8 +1,153 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
import * as authApi from '@/api/auth'
|
import * as authApi from '@/api/auth'
|
||||||
|
|
||||||
|
// ==================== 登录自动重试策略(2026-09-21 并发压测第二轮结论) ====================
|
||||||
|
//
|
||||||
|
// 背景:后端把 /api/auth/login 纳入并发闸门(middleware/concurrency_guard.js 的
|
||||||
|
// LOGIN_PATTERNS),过载时由「TCP 层静默 RST —— 客户端只拿到 WinError 10061,
|
||||||
|
// 考生完全不知道自己是"登不上"还是"要多点几次"」变为
|
||||||
|
// 「HTTP 429 + Retry-After —— 明确、可识别、可定时重试」。
|
||||||
|
// 客户端**必须**配套重试才能收敛,否则只是把「静默失败」换成了「明确失败」。
|
||||||
|
//
|
||||||
|
// 与交卷重试(stores/exam.js:66-68、504-529)**同源同策略**:
|
||||||
|
// 6 次尝试,退避 1s→2s→4s→8s→15s→15s(封顶),总计约 45s。
|
||||||
|
// 此处刻意**不**抽公共工具:exam.js 的交卷链路刚经 46 项专项验证 + 两轮压测复测
|
||||||
|
// (已稳定),为「不影响其他正常功能」而不触碰它。⚠️ 两处策略常量若有变动须同步。
|
||||||
|
//
|
||||||
|
// ==================== R8(2026-09-21):让 Retry-After 真正可达 ====================
|
||||||
|
//
|
||||||
|
// 问题:后端在 L118/L137/L157 一直发 `Retry-After: 2`,但**此前前端从不读它**
|
||||||
|
// ⇒ 该响应头在生产上是**纯装饰**(只在压测探针里生效,因为探针读它)。
|
||||||
|
// 结果:被拒后仍按 1s 的首次退避立刻回来 → 撞墙(实测登录侧请求 +14%、重试 +28%)。
|
||||||
|
//
|
||||||
|
// 修法:**服务端建议**与**指数退避**取较大者,再叠加抖动。三条决策依据:
|
||||||
|
// ① 为何取 max 而非直接用 Retry-After:
|
||||||
|
// 服务端只知道"当前多忙",不知道客户端已重试几次。若纯用它,重试后期
|
||||||
|
// (退避已到 8s/15s)反而会被**压回** 2s ⇒ 比改动前更频繁地撞墙;
|
||||||
|
// 纯用退避则忽略服务端的明确信号。取 max 兼顾两者。
|
||||||
|
// ② 为何要抖动(jitter):800 人几乎同刻被拒 ⇒ 若都等同一时长,会形成
|
||||||
|
// 「同步重试潮」在同一时刻再次撞墙(thundering herd)。±20% 抖动把重试打散。
|
||||||
|
// ③ 为何要给服务端值封顶:后端异常(如误配一个很大的值)不得把考生**锁死**在
|
||||||
|
// 等待里 —— 超过 LOGIN_RETRY_SERVER_CAP_MS 一律按上限处理。
|
||||||
|
//
|
||||||
|
// ⚠️ 已知的**非同源**(有意为之,非遗漏):`stores/exam.js` 的交卷重试与
|
||||||
|
// `file_io_guard` 的上传重试**仍不读** `Retry-After`。理由:交卷链路实测在
|
||||||
|
// 5 档 429 下均 100% 收敛(最终 HTTP 200),而它是考试关键路径、回归风险高于收益。
|
||||||
|
// ⛔ 若将来要统一,必须走独立排期 + 专项回归,不要"顺手"改。
|
||||||
|
//
|
||||||
|
// 重试安全性(为何不会把考生锁死):
|
||||||
|
// ① 登录请求无副作用(仅校验口令 + 签发 JWT),被拒/超时的请求从未到达业务逻辑;
|
||||||
|
// ② 后端闸门在 **loginRateLimit 之前**返回 429(全局中间件先于 router 级),
|
||||||
|
// 故 429 不会被计入「登录失败次数」→ 重试**不会**把考生自己打到 15 分钟账号锁;
|
||||||
|
// ③ 密码错误(3005)/未激活(3004)/手机号不在名单(3003) 等**业务判定**不在重试范围,
|
||||||
|
// 立即抛出(经拦截器转为无 response 字段的裸 Error,据此与网络层错误区分)。
|
||||||
|
const LOGIN_MAX_ATTEMPTS = 6
|
||||||
|
const LOGIN_RETRY_BASE_MS = 1000
|
||||||
|
const LOGIN_RETRY_CAP_MS = 15000
|
||||||
|
// 服务端 Retry-After 的采纳上限(防误配把考生锁死);见文件头 R8 第 ③ 条
|
||||||
|
const LOGIN_RETRY_SERVER_CAP_MS = 15000
|
||||||
|
// 抖动幅度(±20%):把"同时被拒"的考生重试时刻打散,避免同步重试潮
|
||||||
|
const LOGIN_RETRY_JITTER = 0.2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断登录失败是否可安全重试
|
||||||
|
* - 有 HTTP 响应:429(闸门背压,带 Retry-After)或 5xx(服务端瞬时故障)→ 可重试;
|
||||||
|
* 其余 4xx(语义已明确)→ 不重试。
|
||||||
|
* - 无 HTTP 响应:axios 网络/超时错误(连接被 RST、超时)→ 请求从未到达后端,可重试。
|
||||||
|
* - 拦截器为「业务码非 0」生成的裸 Error(无 response/code/request/config)→ 不重试。
|
||||||
|
*/
|
||||||
|
function isRetriableLoginError(err) {
|
||||||
|
if (!err) return false
|
||||||
|
const status = err.response && err.response.status
|
||||||
|
if (status) return status === 429 || (status >= 500 && status < 600)
|
||||||
|
return !!(err.isAxiosError || err.code || err.request || err.config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析响应头 `Retry-After` 为毫秒(无效/缺失返回 0)
|
||||||
|
*
|
||||||
|
* 只接受 **delay-seconds**(整数秒)形式 —— 后端 concurrency_guard.js 发的即此形式。
|
||||||
|
* HTTP 规范另允许 HTTP-date 形式,本系统不使用;无法解析的值一律返回 0
|
||||||
|
* (安全回退到纯指数退避:绝不因一个坏 header 让重试变快或卡死)。
|
||||||
|
*
|
||||||
|
* ⚠️ axios 的 headers 可能是普通对象或 AxiosHeaders 实例,两种形态都要兼容。
|
||||||
|
*/
|
||||||
|
function parseRetryAfterMs(err) {
|
||||||
|
const headers = err && err.response && err.response.headers
|
||||||
|
if (!headers) return 0
|
||||||
|
let raw
|
||||||
|
if (typeof headers.get === 'function') {
|
||||||
|
raw = headers.get('retry-after')
|
||||||
|
}
|
||||||
|
if (raw === undefined || raw === null || raw === '') {
|
||||||
|
raw = headers['retry-after']
|
||||||
|
}
|
||||||
|
if (raw === undefined || raw === null || raw === '') return 0
|
||||||
|
const sec = Number(raw)
|
||||||
|
if (!Number.isFinite(sec) || sec <= 0) return 0
|
||||||
|
return Math.min(sec * 1000, LOGIN_RETRY_SERVER_CAP_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算第 attempt 次失败后的重试等待(毫秒)
|
||||||
|
*
|
||||||
|
* = max(指数退避, 服务端 Retry-After) × (1 ± LOGIN_RETRY_JITTER)
|
||||||
|
* 设计依据见文件头「R8」三条(为何取 max / 为何抖动 / 为何给服务端值封顶)。
|
||||||
|
*/
|
||||||
|
function computeLoginRetryDelayMs(err, attempt) {
|
||||||
|
const backoff = Math.min(
|
||||||
|
LOGIN_RETRY_CAP_MS,
|
||||||
|
LOGIN_RETRY_BASE_MS * Math.pow(2, attempt - 1)
|
||||||
|
)
|
||||||
|
const base = Math.max(backoff, parseRetryAfterMs(err))
|
||||||
|
const jittered =
|
||||||
|
base * (1 - LOGIN_RETRY_JITTER + Math.random() * LOGIN_RETRY_JITTER * 2)
|
||||||
|
return Math.round(jittered)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把最终失败的错误包装成考生能看懂的文案(由 Login.vue 的统一 catch 弹出)。
|
||||||
|
* 业务错误(如「密码错误」「账号未激活」)原样返回,保留后端文案。
|
||||||
|
*/
|
||||||
|
function friendlyLoginError(err) {
|
||||||
|
const status = (err && err.response && err.response.status) || 0
|
||||||
|
if (status === 429) {
|
||||||
|
return new Error('当前登录人数过多,服务器繁忙,请稍后重试(若持续失败请举手联系监考老师)')
|
||||||
|
}
|
||||||
|
if (status >= 500 && status < 600) {
|
||||||
|
return new Error('服务器暂时不可用,请稍后重试')
|
||||||
|
}
|
||||||
|
if (!status && err && (err.isAxiosError || err.code || err.request)) {
|
||||||
|
return new Error('网络连接异常,请检查网络后重试')
|
||||||
|
}
|
||||||
|
return err // 业务错误:保留后端文案(密码错误 / 账号未激活 / 登录过于频繁…)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重试期间的常驻提示(持续显示,成功后关闭;避免连弹多条相同报错)
|
||||||
|
let _loginRetryTip = null
|
||||||
|
function showLoginRetryTip() {
|
||||||
|
if (_loginRetryTip) return
|
||||||
|
_loginRetryTip = ElMessage({
|
||||||
|
message: '服务器繁忙,正在自动重试登录,请勿刷新页面(最多约 45 秒)',
|
||||||
|
type: 'warning',
|
||||||
|
duration: 0,
|
||||||
|
showClose: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function closeLoginRetryTip() {
|
||||||
|
if (_loginRetryTip) {
|
||||||
|
try {
|
||||||
|
_loginRetryTip.close()
|
||||||
|
} catch (e) {
|
||||||
|
/* 忽略:提示已被用户关闭 */
|
||||||
|
}
|
||||||
|
_loginRetryTip = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 认证状态管理 ====================
|
// ==================== 认证状态管理 ====================
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
@@ -66,16 +211,41 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
// ==================== 操作方法(Actions) ====================
|
// ==================== 操作方法(Actions) ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 参赛者登录(身份证号 + 密码)
|
* 参赛者登录(手机号 + 密码)—— 带自动重试(见文件头「登录自动重试策略」)
|
||||||
* @param {string} idNumber - 身份证号
|
* @param {string} phone - 手机号
|
||||||
* @param {string} password - 密码
|
* @param {string} password - 密码
|
||||||
* @returns {Promise<object>} 用户信息
|
* @returns {Promise<object>} 用户信息
|
||||||
*/
|
*/
|
||||||
async function login(phone, password) {
|
async function login(phone, password) {
|
||||||
const result = await authApi.login(phone, password)
|
let lastErr = null
|
||||||
// 后端应返回 { token, user }
|
for (let attempt = 1; attempt <= LOGIN_MAX_ATTEMPTS; attempt++) {
|
||||||
|
try {
|
||||||
|
// silent:抑制拦截器的逐次错误提示,改由本函数统一提示(避免连弹多条)
|
||||||
|
const result = await authApi.login(phone, password, { silent: true })
|
||||||
|
closeLoginRetryTip()
|
||||||
|
// 后端应返回 { token, user } —— 仅在真正拿到结果后才落盘
|
||||||
persistAuth(result.token, result.user)
|
persistAuth(result.token, result.user)
|
||||||
return result.user
|
return result.user
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err
|
||||||
|
if (!isRetriableLoginError(err) || attempt >= LOGIN_MAX_ATTEMPTS) {
|
||||||
|
closeLoginRetryTip()
|
||||||
|
throw friendlyLoginError(err)
|
||||||
|
}
|
||||||
|
showLoginRetryTip()
|
||||||
|
// max(指数退避, 服务端 Retry-After) + 抖动 —— 见文件头 R8
|
||||||
|
const delay = computeLoginRetryDelayMs(err, attempt)
|
||||||
|
const serverHint = parseRetryAfterMs(err)
|
||||||
|
console.warn(
|
||||||
|
`[auth] 登录第 ${attempt} 次失败(可重试),${delay}ms 后重试` +
|
||||||
|
`(服务端 Retry-After=${serverHint ? `${serverHint}ms` : '未提供'}):`,
|
||||||
|
err && err.message
|
||||||
|
)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closeLoginRetryTip()
|
||||||
|
throw friendlyLoginError(lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -38,6 +38,151 @@ const bigError = ref('')
|
|||||||
const bigSaving = ref(false)
|
const bigSaving = ref(false)
|
||||||
let bigSaveTimer = null
|
let bigSaveTimer = null
|
||||||
|
|
||||||
|
// ==================== 答案本地草稿与落库重试(防丢答案) ====================
|
||||||
|
//
|
||||||
|
// 背景(2026-09-21 并发压测结论):
|
||||||
|
// 原 submitAnswer 在保存失败时只 console.warn(静默),答案仅存在于内存 answers 中。
|
||||||
|
// 一旦刷新页面 / 断电 / 浏览器崩溃,整场作答永久丢失;若交卷同时失败
|
||||||
|
// (400 并发下约 20% 考生的连接在 TCP accept 层即被拒,请求从未到达后端),
|
||||||
|
// 考生将无法恢复任何内容。这对竞赛是不可接受的。
|
||||||
|
//
|
||||||
|
// 本段提供两层兜底:
|
||||||
|
// ① 待重试队列(pendingSaves):逐题保存失败的答案记入队列,指数退避重试直到成功;
|
||||||
|
// ② 本地草稿:把作答写入 localStorage(按「组别 + 账号」隔离),重进考试时与
|
||||||
|
// 服务端答案合并,补齐「服务端缺失」的作答。
|
||||||
|
//
|
||||||
|
// 设计原则(重要):
|
||||||
|
// - 草稿**只补空位、绝不覆盖服务端已有值** —— 服务端是权威。最坏情况是给考生补回
|
||||||
|
// 一道他已答过的题(他可见并可修改),而不会用旧内容改写新作答;
|
||||||
|
// - 场次隔离用 status.startTime(管理员中途延长 endTime 属同一场次,不应使草稿作废);
|
||||||
|
// - 草稿仅在**交卷成功**后清除 —— 任何失败路径都必须保留,否则兜底形同虚设。
|
||||||
|
|
||||||
|
const DRAFT_VERSION = 1
|
||||||
|
const DRAFT_KEY_PREFIX = 'exam_draft::'
|
||||||
|
const DRAFT_WRITE_DEBOUNCE_MS = 200
|
||||||
|
const SAVE_RETRY_MAX = 6 // 逐题保存重试次数上限(退避 2s→4s→…→30s 封顶)
|
||||||
|
const SAVE_RETRY_BASE_MS = 2000
|
||||||
|
const SAVE_RETRY_CAP_MS = 30000
|
||||||
|
const SUBMIT_MAX_ATTEMPTS = 6 // 交卷自动重试次数上限(退避 1s→2s→…→15s 封顶)
|
||||||
|
const SUBMIT_RETRY_BASE_MS = 1000
|
||||||
|
const SUBMIT_RETRY_CAP_MS = 15000
|
||||||
|
|
||||||
|
/** 草稿键:按「组别 + 账号」隔离(机房共用电脑时不同考生互不覆盖) */
|
||||||
|
function draftKeyOf(group) {
|
||||||
|
let uid = 'anon'
|
||||||
|
try {
|
||||||
|
const u = JSON.parse(localStorage.getItem('user') || 'null')
|
||||||
|
uid = String((u && (u.id || u.uid || u.phone || u.username)) || 'anon')
|
||||||
|
} catch (e) { /* 解析失败退化为 anon,仍受组别隔离 */ }
|
||||||
|
return `${DRAFT_KEY_PREFIX}${group}::${uid}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 逐题保存失败、待重试的作答 { questionUid: answer } */
|
||||||
|
const pendingSaves = ref({})
|
||||||
|
let draftTimer = null
|
||||||
|
let saveRetryTimer = null
|
||||||
|
let saveRetryAttempt = 0
|
||||||
|
/** 考试场次标识:status.startTime(延长 endTime 属同一场次,不应使草稿作废) */
|
||||||
|
let draftWindowStart = null
|
||||||
|
|
||||||
|
/** 立即落盘草稿(同步写 localStorage) */
|
||||||
|
function writeDraftNow() {
|
||||||
|
if (draftTimer) { clearTimeout(draftTimer); draftTimer = null }
|
||||||
|
const g = currentGroup.value
|
||||||
|
if (!g) return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(draftKeyOf(g), JSON.stringify({
|
||||||
|
v: DRAFT_VERSION,
|
||||||
|
group: g,
|
||||||
|
windowStart: draftWindowStart,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
answers: { ...answers.value },
|
||||||
|
big: BIG_QUESTION_GROUPS.includes(g) ? { ...bigAnswers.value } : null,
|
||||||
|
pending: Object.keys(pendingSaves.value),
|
||||||
|
}))
|
||||||
|
} catch (e) {
|
||||||
|
// 隐私模式 / 配额写满:草稿只是兜底能力,写入失败绝不能打断考生答题
|
||||||
|
console.warn('[exam] 本地草稿写入失败(不影响答题与交卷)', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 防抖落盘(逐字符输入时避免写放大) */
|
||||||
|
function scheduleDraftWrite() {
|
||||||
|
if (draftTimer) clearTimeout(draftTimer)
|
||||||
|
draftTimer = setTimeout(() => { draftTimer = null; writeDraftNow() }, DRAFT_WRITE_DEBOUNCE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取草稿(不校验场次,由调用方决定是否采用) */
|
||||||
|
function readDraft(group) {
|
||||||
|
const g = group || currentGroup.value
|
||||||
|
if (!g) return null
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(draftKeyOf(g))
|
||||||
|
if (!raw) return null
|
||||||
|
const d = JSON.parse(raw)
|
||||||
|
if (!d || d.v !== DRAFT_VERSION) return null
|
||||||
|
return d
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除草稿(仅在交卷成功后调用) */
|
||||||
|
function clearDraft(group) {
|
||||||
|
try { localStorage.removeItem(draftKeyOf(group || currentGroup.value)) } catch (e) { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否有尚未同步到服务端的作答(供 UI 提示使用) */
|
||||||
|
function hasUnsynced() {
|
||||||
|
return Object.keys(pendingSaves.value).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把待重试队列里的答案一次性推给服务端
|
||||||
|
* @returns {Promise<boolean>} 是否全部成功(队列为空视为成功)
|
||||||
|
*/
|
||||||
|
async function flushPendingSaves() {
|
||||||
|
const entries = Object.entries(pendingSaves.value)
|
||||||
|
if (!entries.length) return true
|
||||||
|
const batch = entries.map(([uid, ans]) => ({
|
||||||
|
question_uid: uid,
|
||||||
|
question_type: typeOfUid(uid),
|
||||||
|
answer_data: ans,
|
||||||
|
}))
|
||||||
|
try {
|
||||||
|
await examApi.submitAnswers(currentGroup.value, batch)
|
||||||
|
pendingSaves.value = {}
|
||||||
|
writeDraftNow()
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 指数退避调度待重试队列(已有在途定时器时不重复排程) */
|
||||||
|
function scheduleSaveRetry() {
|
||||||
|
if (saveRetryTimer) return
|
||||||
|
if (saveRetryAttempt >= SAVE_RETRY_MAX) return
|
||||||
|
const delay = Math.min(SAVE_RETRY_CAP_MS, SAVE_RETRY_BASE_MS * Math.pow(2, saveRetryAttempt))
|
||||||
|
saveRetryAttempt++
|
||||||
|
saveRetryTimer = setTimeout(async () => {
|
||||||
|
saveRetryTimer = null
|
||||||
|
const ok = await flushPendingSaves()
|
||||||
|
if (ok) {
|
||||||
|
saveRetryAttempt = 0
|
||||||
|
} else {
|
||||||
|
scheduleSaveRetry()
|
||||||
|
}
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停止草稿与重试相关定时器(退出考试页时调用;不清草稿,草稿只在交卷成功后清) */
|
||||||
|
function stopDraftTimers() {
|
||||||
|
if (draftTimer) { clearTimeout(draftTimer); draftTimer = null }
|
||||||
|
if (saveRetryTimer) { clearTimeout(saveRetryTimer); saveRetryTimer = null }
|
||||||
|
saveRetryAttempt = 0
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 计算属性 ====================
|
// ==================== 计算属性 ====================
|
||||||
|
|
||||||
const countdownDisplay = computed(() => {
|
const countdownDisplay = computed(() => {
|
||||||
@@ -200,6 +345,7 @@ function stopTimer() {
|
|||||||
|
|
||||||
async function startExam(group) {
|
async function startExam(group) {
|
||||||
stopExam()
|
stopExam()
|
||||||
|
draftWindowStart = null // 重置场次标识,避免把上一场的草稿归到本场
|
||||||
|
|
||||||
// 生成唯一的考试 session ID
|
// 生成唯一的考试 session ID
|
||||||
examSessionId = group + '_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8)
|
examSessionId = group + '_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8)
|
||||||
@@ -217,6 +363,8 @@ async function startExam(group) {
|
|||||||
if (stateData.countdown !== undefined) {
|
if (stateData.countdown !== undefined) {
|
||||||
countdown.value = stateData.countdown
|
countdown.value = stateData.countdown
|
||||||
}
|
}
|
||||||
|
// 记录本场考试标识(隔离不同场次的本地草稿;见「答案本地草稿」段)
|
||||||
|
draftWindowStart = stateData.startTime || null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (questionList.status === 'fulfilled' && questionList.value) {
|
if (questionList.status === 'fulfilled' && questionList.value) {
|
||||||
@@ -264,6 +412,7 @@ async function startUploadCountdown(group) {
|
|||||||
|
|
||||||
function stopExam() {
|
function stopExam() {
|
||||||
stopTimer()
|
stopTimer()
|
||||||
|
stopDraftTimers()
|
||||||
examStarted.value = false
|
examStarted.value = false
|
||||||
sessionStorage.removeItem('exam_session')
|
sessionStorage.removeItem('exam_session')
|
||||||
}
|
}
|
||||||
@@ -282,8 +431,9 @@ function typeOfUid(questionUid) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitAnswer(questionId, answer) {
|
async function submitAnswer(questionId, answer) {
|
||||||
// 本地先保存答案
|
// 本地先保存答案,并立即写入本地草稿(先落本地、再谈服务端)
|
||||||
answers.value = { ...answers.value, [questionId]: answer }
|
answers.value = { ...answers.value, [questionId]: answer }
|
||||||
|
scheduleDraftWrite()
|
||||||
|
|
||||||
// 异步提交到服务器(必须带 question_type,否则后端校验直接拒绝:
|
// 异步提交到服务器(必须带 question_type,否则后端校验直接拒绝:
|
||||||
// 「答案格式错误,需要合法的 question_type(choice/judge)」)
|
// 「答案格式错误,需要合法的 question_type(choice/judge)」)
|
||||||
@@ -291,13 +441,47 @@ async function submitAnswer(questionId, answer) {
|
|||||||
await examApi.submitAnswers(currentGroup.value, [
|
await examApi.submitAnswers(currentGroup.value, [
|
||||||
{ question_uid: questionId, question_type: typeOfUid(questionId), answer_data: answer },
|
{ question_uid: questionId, question_type: typeOfUid(questionId), answer_data: answer },
|
||||||
])
|
])
|
||||||
|
// 落库成功:从待重试队列中移除该题
|
||||||
|
if (pendingSaves.value[questionId] !== undefined) {
|
||||||
|
const next = { ...pendingSaves.value }
|
||||||
|
delete next[questionId]
|
||||||
|
pendingSaves.value = next
|
||||||
|
writeDraftNow()
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 静默失败,不弹窗打扰用户答题
|
// 🔴 不再静默丢弃(原为仅 console.warn):记入待重试队列并落本地草稿,
|
||||||
console.warn('提交答案失败', e)
|
// 由指数退避重试直到成功。仍不弹窗打扰答题(失败多为瞬时网络抖动),
|
||||||
|
// 但交卷前会强制补推一次,且答题页可据 hasUnsynced 提示「未同步 N 题」。
|
||||||
|
pendingSaves.value = { ...pendingSaves.value, [questionId]: answer }
|
||||||
|
writeDraftNow()
|
||||||
|
scheduleSaveRetry()
|
||||||
|
console.warn('提交答案失败,已记入重试队列并保留本地草稿', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断交卷失败是否可安全重试
|
||||||
|
*
|
||||||
|
* 可重试(请求**未到达业务逻辑**,或服务端瞬时过载):
|
||||||
|
* - 无 HTTP 响应但属 axios 网络/超时错误(连接被 RST、超时)→ 请求从未到达后端;
|
||||||
|
* - HTTP 429(并发闸门主动背压,响应带 Retry-After);HTTP 5xx(服务端瞬时故障)。
|
||||||
|
* 不可重试(业务已作出明确判定,重试无意义):
|
||||||
|
* - 拦截器为「业务码非 0」生成的裸 Error(4001 考试已结束 / 4004 未答满 /
|
||||||
|
* 4005 团队锁等)。它没有 axios 的 response、code、config 字段,
|
||||||
|
* 据此与网络层错误区分 —— 否则会把「未答满」误判为可重试并空转 6 次。
|
||||||
|
*/
|
||||||
|
function isRetriableSubmitError(err) {
|
||||||
|
if (!err) return false
|
||||||
|
const status = err.response && err.response.status
|
||||||
|
if (status) return status === 429 || (status >= 500 && status < 600)
|
||||||
|
return !!(err.isAxiosError || err.code || err.request || err.config)
|
||||||
|
}
|
||||||
|
|
||||||
async function submitAll(additionalData = {}) {
|
async function submitAll(additionalData = {}) {
|
||||||
|
// 交卷前先把未确认落库的答案补推一次(不因失败而阻塞:submit-all 本身会带上
|
||||||
|
// 内存中的全部答案做一次全量落库,是更强的兜底)
|
||||||
|
try { await flushPendingSaves() } catch (e) { /* 忽略,交由后续全量提交 */ }
|
||||||
|
|
||||||
// 综合大题(中职/高职)先落库再交卷:submit-all 只负责客观题与提交时间,
|
// 综合大题(中职/高职)先落库再交卷:submit-all 只负责客观题与提交时间,
|
||||||
// 大题答案必须在此之前单独保存,否则最后一刻的作答会随刷新丢失。
|
// 大题答案必须在此之前单独保存,否则最后一刻的作答会随刷新丢失。
|
||||||
if (bigQuestionEnabled.value) {
|
if (bigQuestionEnabled.value) {
|
||||||
@@ -316,9 +500,33 @@ async function submitAll(additionalData = {}) {
|
|||||||
totalQuestions: totalQuestions.value,
|
totalQuestions: totalQuestions.value,
|
||||||
...additionalData,
|
...additionalData,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔴 交卷自动重试(2026-09-21 并发压测结论)
|
||||||
|
//
|
||||||
|
// 缺陷:400 并发同刻交卷时约 20% 考生的交卷请求在 TCP accept 层就被拒
|
||||||
|
// (Windows 在 accept 队列满时回 RST,客户端拿到 10061),请求**从未到达后端**;
|
||||||
|
// 原实现仅提示「请手动点击提交全部」,而手动提交不带 forceSubmit、
|
||||||
|
// 窗口关闭后必被 4001 拒 —— 这批考生会彻底交不上卷(答案在库里却 submit_time 为空)。
|
||||||
|
//
|
||||||
|
// 重试安全性论证:被拒请求从未到达业务逻辑(无副作用);后端落库为 upsert(幂等),
|
||||||
|
// submit_time 由 ensureExamSubmission 保证只写一次(findOrCreate + `!submit_time` 保护),
|
||||||
|
// 因此重试不会产生重复行、不会覆盖首次提交时间。
|
||||||
|
let lastErr = null
|
||||||
|
for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) {
|
||||||
|
try {
|
||||||
const result = await examApi.submitAll(currentGroup.value, data)
|
const result = await examApi.submitAll(currentGroup.value, data)
|
||||||
stopExam()
|
stopExam()
|
||||||
|
clearDraft() // 仅在交卷成功后清除草稿;任何失败路径都必须保留兜底数据
|
||||||
return result
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err
|
||||||
|
if (!isRetriableSubmitError(err) || attempt >= SUBMIT_MAX_ATTEMPTS) throw err
|
||||||
|
const delay = Math.min(SUBMIT_RETRY_CAP_MS, SUBMIT_RETRY_BASE_MS * Math.pow(2, attempt - 1))
|
||||||
|
console.warn(`[exam] 交卷第 ${attempt} 次失败(可重试),${delay}ms 后重试:`, err && err.message)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 综合大题方法 ====================
|
// ==================== 综合大题方法 ====================
|
||||||
@@ -347,6 +555,21 @@ async function loadBigQuestion(group) {
|
|||||||
if (Number(data.subCount) > 0) bigSubCount.value = Number(data.subCount)
|
if (Number(data.subCount) > 0) bigSubCount.value = Number(data.subCount)
|
||||||
bigReused.value = data.reused === true
|
bigReused.value = data.reused === true
|
||||||
bigLoaded.value = !!bigQuestion.value
|
bigLoaded.value = !!bigQuestion.value
|
||||||
|
|
||||||
|
// 用本地草稿补齐服务端缺失的小问(只补空位,绝不覆盖服务端已有内容)
|
||||||
|
const d = readDraft(g)
|
||||||
|
if (bigLoaded.value && d && d.big) {
|
||||||
|
const mergedBig = { ...bigAnswers.value }
|
||||||
|
let filled = 0
|
||||||
|
for (const k of ['answer_1', 'answer_2', 'answer_3']) {
|
||||||
|
const local = d.big[k]
|
||||||
|
if ((!mergedBig[k] || !String(mergedBig[k]).trim()) && local && String(local).trim()) {
|
||||||
|
mergedBig[k] = local
|
||||||
|
filled++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filled > 0) bigAnswers.value = mergedBig
|
||||||
|
}
|
||||||
return bigLoaded.value
|
return bigLoaded.value
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 典型失败:4004 该组暂无大题 / 400 未启用 / 403 非本组考生
|
// 典型失败:4004 该组暂无大题 / 400 未启用 / 403 非本组考生
|
||||||
@@ -367,6 +590,7 @@ async function loadBigQuestion(group) {
|
|||||||
function setBigAnswer(index, value) {
|
function setBigAnswer(index, value) {
|
||||||
const key = `answer_${index}`
|
const key = `answer_${index}`
|
||||||
bigAnswers.value = { ...bigAnswers.value, [key]: value == null ? '' : String(value) }
|
bigAnswers.value = { ...bigAnswers.value, [key]: value == null ? '' : String(value) }
|
||||||
|
scheduleDraftWrite() // 先落本地草稿:即便随后的防抖保存失败也不会丢
|
||||||
scheduleBigSave()
|
scheduleBigSave()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +664,7 @@ function resetBigQuestion() {
|
|||||||
|
|
||||||
function resetExam() {
|
function resetExam() {
|
||||||
stopTimer()
|
stopTimer()
|
||||||
|
stopDraftTimers()
|
||||||
resetBigQuestion()
|
resetBigQuestion()
|
||||||
questions.value = []
|
questions.value = []
|
||||||
answers.value = {}
|
answers.value = {}
|
||||||
@@ -457,9 +682,54 @@ function isSessionValid() {
|
|||||||
return saved === examSessionId && examSessionId !== ''
|
return saved === examSessionId && examSessionId !== ''
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 恢复已有答案(考试重新进入时使用) */
|
/** 恢复已有答案(服务端为基线;考试重新进入时使用) */
|
||||||
function restoreAnswers(restoredAnswers) {
|
function restoreAnswers(restoredAnswers) {
|
||||||
answers.value = { ...restoredAnswers }
|
answers.value = { ...(restoredAnswers || {}) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用本地草稿补齐服务端缺失的作答(考试重新进入时调用,且应在 restoreAnswers 之后)
|
||||||
|
*
|
||||||
|
* 合并规则(重要):**只补空位,绝不覆盖服务端已有值**。服务端是权威,
|
||||||
|
* 草稿仅承载「逐题保存失败因而未能上报」的作答。
|
||||||
|
* 场次隔离:草稿的 windowStart 与当前场次(startTime)不一致时判为上一场考试并丢弃。
|
||||||
|
*
|
||||||
|
* @returns {{merged:number, stale:boolean}} merged = 从草稿补回的题数
|
||||||
|
*/
|
||||||
|
function restoreDraft() {
|
||||||
|
const g = currentGroup.value
|
||||||
|
if (!g) return { merged: 0, stale: false }
|
||||||
|
const d = readDraft(g)
|
||||||
|
if (!d) return { merged: 0, stale: false }
|
||||||
|
|
||||||
|
// 不同场次(startTime 不同)→ 草稿属上一场考试,清除以免污染本场
|
||||||
|
if (draftWindowStart && d.windowStart && d.windowStart !== draftWindowStart) {
|
||||||
|
clearDraft(g)
|
||||||
|
return { merged: 0, stale: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = { ...answers.value }
|
||||||
|
let merged = 0
|
||||||
|
for (const [uid, ans] of Object.entries(d.answers || {})) {
|
||||||
|
if (ans === undefined || ans === null || String(ans).length === 0) continue
|
||||||
|
const cur = next[uid]
|
||||||
|
// 服务端已有作答 → 不覆盖(草稿可能是更早的内容)
|
||||||
|
if (cur !== undefined && cur !== null && String(cur).length > 0) continue
|
||||||
|
next[uid] = ans
|
||||||
|
merged++
|
||||||
|
}
|
||||||
|
answers.value = next
|
||||||
|
|
||||||
|
// 草稿里标记为「未成功落库」的题重新排入重试队列
|
||||||
|
const pending = { ...pendingSaves.value }
|
||||||
|
for (const uid of (d.pending || [])) {
|
||||||
|
if (next[uid] !== undefined) pending[uid] = next[uid]
|
||||||
|
}
|
||||||
|
if (Object.keys(pending).length) {
|
||||||
|
pendingSaves.value = pending
|
||||||
|
scheduleSaveRetry()
|
||||||
|
}
|
||||||
|
return { merged, stale: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useExamStore() {
|
export function useExamStore() {
|
||||||
@@ -506,6 +776,11 @@ export function useExamStore() {
|
|||||||
resetExam,
|
resetExam,
|
||||||
isSessionValid,
|
isSessionValid,
|
||||||
restoreAnswers,
|
restoreAnswers,
|
||||||
|
// 本地草稿与落库重试(防丢答案)
|
||||||
|
restoreDraft,
|
||||||
|
flushPendingSaves,
|
||||||
|
hasUnsynced,
|
||||||
|
pendingSaves,
|
||||||
// 综合大题方法
|
// 综合大题方法
|
||||||
loadBigQuestion,
|
loadBigQuestion,
|
||||||
setBigAnswer,
|
setBigAnswer,
|
||||||
|
|||||||
@@ -723,7 +723,14 @@ async function handleSubmitAll() {
|
|||||||
await examStore.submitAll({})
|
await examStore.submitAll({})
|
||||||
ElMessage.success('提交成功!')
|
ElMessage.success('提交成功!')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.message) ElMessage.error(err.message)
|
// 交卷失败:答案已保留在本地草稿中,须明确告知可重试,
|
||||||
|
// 避免考生误以为「已经交上了」而离开页面。
|
||||||
|
const msg = (err && err.message) || ''
|
||||||
|
if (msg) {
|
||||||
|
ElMessage.error(msg)
|
||||||
|
} else {
|
||||||
|
ElMessage.error('提交未完成,请检查网络后重试(您的答案已在本机保留)')
|
||||||
|
}
|
||||||
return
|
return
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
@@ -752,25 +759,35 @@ async function autoSubmit() {
|
|||||||
router.push('/dashboard')
|
router.push('/dashboard')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ElMessage.warning('考试时间已到,系统将自动保存并提交您的答案')
|
ElMessage.warning('考试时间已到,系统将自动保存并提交您的答案,请勿关闭页面')
|
||||||
try {
|
try {
|
||||||
// forceSubmit:倒计时归零即合法交卷,绕过「未答满全部题目 / 窗口已关闭」闸门,
|
// forceSubmit:倒计时归零即合法交卷,豁免「未答满全部题目」闸门,
|
||||||
// 确保最后输入的大题答案与 submit_time 都能落库(管理后台显示真实交卷时间)。
|
// 确保最后输入的大题答案与 submit_time 都能落库(管理后台显示真实交卷时间)。
|
||||||
|
// ⚠️ 注意 forceSubmit **不再**能够绕过「考试窗口已关闭」——窗口关闭后由服务端
|
||||||
|
// 走结算通道(以库内已落库作答为准补写 submit_time),这正是本流程的兜底。
|
||||||
const saved = await ensureBigAnswersSaved({ force: true })
|
const saved = await ensureBigAnswersSaved({ force: true })
|
||||||
if (!saved) {
|
if (!saved) {
|
||||||
// 落库失败不退化为静默丢答案:提示考生手动重试
|
// 大题落库失败不退化为静默丢答案:提示考生手动重试
|
||||||
ElMessage.error('综合大题答案未能保存,请点击「提交全部」重试')
|
ElMessage.error('综合大题答案未能保存,请点击「提交全部」重试')
|
||||||
autoSubmitted.value = false
|
autoSubmitted.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// submitAll 内部会逐题保存客观题答案并写入提交时间(大题已在上面落库)
|
// submitAll 内含自动重试(网络层失败 / 429 / 5xx 会指数退避重试 6 次),
|
||||||
|
// 每次重试都带上内存中的全部客观题答案做一次全量落库。
|
||||||
await examStore.submitAll({ forceSubmit: true })
|
await examStore.submitAll({ forceSubmit: true })
|
||||||
ElMessage.success('已自动保存并提交')
|
ElMessage.success('已自动保存并提交')
|
||||||
router.push('/dashboard')
|
router.push('/dashboard')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
autoSubmitted.value = false
|
autoSubmitted.value = false
|
||||||
console.error('自动提交失败', err)
|
console.error('自动提交失败', err)
|
||||||
ElMessage.error('自动提交失败,请手动点击「提交全部」')
|
// 🔴 不再只提示「请手动点击提交全部」:重试若干次仍失败说明网络或服务端持续不可用,
|
||||||
|
// 此时答案已保留在本地草稿中(见 stores/exam.js),须明确告知考生可重试,
|
||||||
|
// 否则考生会误以为已经交上卷。窗口关闭后服务端仍接受结算补交,故重试始终有效。
|
||||||
|
ElMessageBox.alert(
|
||||||
|
'自动提交未能完成(网络异常或服务器繁忙)。您的答案已在本机保留,请点击「提交全部」重试;若仍失败,请立即联系监考老师。',
|
||||||
|
'提交未完成',
|
||||||
|
{ confirmButtonText: '我知道了', type: 'error' }
|
||||||
|
).catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1012,21 +1029,36 @@ onMounted(async () => {
|
|||||||
|
|
||||||
// 恢复已有答案:考试组在「已提交 / 意外退出后重进」时始终尝试恢复,
|
// 恢复已有答案:考试组在「已提交 / 意外退出后重进」时始终尝试恢复,
|
||||||
// 避免断网、断电或误操作退出导致答题进度丢失(仅重载答案,不返回评分与对错)。
|
// 避免断网、断电或误操作退出导致答题进度丢失(仅重载答案,不返回评分与对错)。
|
||||||
|
// 🔴 2026-09-21 加固:服务端答案恢复后**必须**再用本地草稿补齐 ——
|
||||||
|
// 逐题保存失败的作答只存在于本机 localStorage,若不合并草稿,
|
||||||
|
// 「断网期间作答后刷新」会静默丢失这批内容。两条路径都要尝试草稿恢复,
|
||||||
|
// 且草稿恢复失败绝不能中断进入考试。
|
||||||
try {
|
try {
|
||||||
const answersData = await examApi.getAnswers(group)
|
const answersData = await examApi.getAnswers(group)
|
||||||
if (answersData && answersData.answers && answersData.answers.length > 0) {
|
|
||||||
const restored = {}
|
const restored = {}
|
||||||
|
if (answersData && answersData.answers && answersData.answers.length > 0) {
|
||||||
answersData.answers.forEach(a => {
|
answersData.answers.forEach(a => {
|
||||||
if (a.answer_data) {
|
if (a.answer_data) {
|
||||||
restored[a.question_uid] = a.answer_data
|
restored[a.question_uid] = a.answer_data
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (Object.keys(restored).length > 0) {
|
|
||||||
examStore.restoreAnswers(restored)
|
|
||||||
}
|
}
|
||||||
|
examStore.restoreAnswers(restored)
|
||||||
|
// 草稿只补「服务端没有」的题,绝不覆盖服务端已有作答
|
||||||
|
const merged = examStore.restoreDraft()
|
||||||
|
if (merged && merged.merged > 0) {
|
||||||
|
ElMessage.warning(`已从本机恢复 ${merged.merged} 道未同步到服务器的作答,请核对后再交卷`)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('恢复已有答案失败', e)
|
console.warn('恢复已有答案失败,尝试仅用本地草稿恢复', e)
|
||||||
|
try {
|
||||||
|
const merged = examStore.restoreDraft()
|
||||||
|
if (merged && merged.merged > 0) {
|
||||||
|
ElMessage.warning(`服务器暂时不可用,已从本机恢复 ${merged.merged} 道作答`)
|
||||||
|
}
|
||||||
|
} catch (e2) {
|
||||||
|
console.warn('本地草稿恢复失败', e2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user