feat: add manual confirmation toggle for incomplete exams

This commit is contained in:
薇星幻海
2026-10-18 01:43:23 +08:00
parent eb9c610b9f
commit 2e044655bb
14 changed files with 758 additions and 201 deletions
+59 -22
View File
@@ -168,8 +168,9 @@
let lastTime = null; let lastAdvancedAt = now(); let attemptedAt = 0; let playStarted = false;
let interactionAt = -Infinity; let lastAutoPlayAt = -Infinity; let manualVideoPause = false; let watchedDocument = null;
let sharedPaused = false;
const config = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, associations: {}, stallSeconds: 60, recoverySeconds: 15, maxRefreshes: 3, minRefreshSeconds: 30, videoPaused: false, examPaused: false };
const state = { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: null, exam: null, logs: [], message: '先识别当前学习计划,再开始本轮任务' };
let manualConfirmRevision = 0; let pendingManualConfirm = null;
const config = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, associations: {}, stallSeconds: 60, recoverySeconds: 15, maxRefreshes: 3, minRefreshSeconds: 30, videoPaused: false, examPaused: false };
const state = { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: null, exam: null, logs: [], message: '先识别当前学习计划,再开始本轮任务' };
function snapshot() {
const given = typeof options.getSnapshot === 'function' ? options.getSnapshot() || {} : {};
@@ -182,7 +183,7 @@
if (event.target && event.target.tagName === 'VIDEO' && interactionAt > lastAutoPlayAt && now() - interactionAt < 2000) manualVideoPause = true;
}
function notify() {
Object.assign(state, { mode: config.mode, execution: config.execution, rate: config.rate, answerInterval: config.answerInterval, minSubmitMinutes: config.minSubmitMinutes, videoPaused: config.videoPaused, examPaused: config.examPaused });
Object.assign(state, { mode: config.mode, execution: config.execution, rate: config.rate, answerInterval: config.answerInterval, minSubmitMinutes: config.minSubmitMinutes, manualConfirmIncomplete: config.manualConfirmIncomplete, videoPaused: config.videoPaused, examPaused: config.examPaused });
const counted = state.tasks.filter(t => t.status !== 'expanded' && allows(t.kind));
state.total = counted.length; state.completed = counted.filter(t => t.status === 'completed').length;
state.skipped = counted.filter(t => t.status === 'skipped').length; state.waiting = counted.filter(t => t.status === 'waiting').length; state.failed = counted.filter(t => t.status === 'failed').length;
@@ -227,6 +228,7 @@
}
const currentControl = await storage.get(CONTROL_KEY);
if (currentControl && currentControl.sessionId !== session.id) return;
syncManualConfirmation(currentControl); session.config = clone(config);
const signature = JSON.stringify([session.tasks, session.config, session.activeTaskId, session.status, session.accountId, session.planId, session.bankVersion]);
if (signature !== saveSignature) { await storage.set(SESSION_KEY, clone(session)); saveSignature = signature; }
}
@@ -325,13 +327,30 @@
} else { state.message = '账号、学习计划或题库版本已变化,未恢复旧任务'; log('warn', state.message); }
}
if (worker && session) { const checkpoint = await storage.get('task:' + session.id + ':' + activeTaskId); const task = state.tasks.find(t => t.id === activeTaskId); if (checkpoint && task) Object.assign(task, checkpoint); }
if (session) syncManualConfirmation(await storage.get(CONTROL_KEY));
mergeTasks(discoverTasks(snap.document, snap.url)); notify();
}
async function control(status, force, explicitWorkerAction) {
function syncManualConfirmation(shared) {
if (!session || !shared || shared.sessionId !== session.id || !shared.config || pendingManualConfirm) return;
const revision = Number(shared.manualConfirmRevision) || 0;
if (revision < manualConfirmRevision || typeof shared.config.manualConfirmIncomplete !== 'boolean') return;
config.manualConfirmIncomplete = shared.config.manualConfirmIncomplete; state.manualConfirmIncomplete = config.manualConfirmIncomplete; manualConfirmRevision = revision;
session.config = Object.assign({}, session.config, { manualConfirmIncomplete: config.manualConfirmIncomplete });
}
async function control(status, force, explicitWorkerAction, manualPatch) {
if (!session || worker && !explicitWorkerAction) return;
const current = await storage.get(CONTROL_KEY);
if (!force && current && current.sessionId !== session.id) return;
await storage.set(CONTROL_KEY, { sessionId: session.id, status, config: clone(worker && current ? current.config : config), updatedAt: now(), owner: worker && current ? current.owner : owner });
const sameSession = current && current.sessionId === session.id;
if (manualPatch) {
config.manualConfirmIncomplete = manualPatch.value;
manualConfirmRevision = Math.max(manualConfirmRevision, sameSession ? Number(current.manualConfirmRevision) || 0 : 0) + 1;
} else syncManualConfirmation(current);
const sharedConfig = clone(worker && sameSession ? current.config : config);
sharedConfig.manualConfirmIncomplete = config.manualConfirmIncomplete;
// 设置修改只发布策略,不得把另一页已经暂停的任务重新改成 running。
await storage.set(CONTROL_KEY, { sessionId: session.id, status: manualPatch && sameSession ? current.status : status, config: sharedConfig,
manualConfirmRevision, updatedAt: now(), owner: worker && sameSession ? current.owner : owner });
}
async function configure(patch) {
await initialize(); patch = patch || {}; const wasRunning = state.status === 'running';
@@ -347,11 +366,15 @@
if (patch.associations && typeof patch.associations === 'object') config.associations = clone(patch.associations);
if (typeof patch.videoPaused === 'boolean') config.videoPaused = patch.videoPaused;
if (typeof patch.examPaused === 'boolean') config.examPaused = patch.examPaused;
const manualPatch = typeof patch.manualConfirmIncomplete === 'boolean' ? { value: patch.manualConfirmIncomplete } : null;
if (manualPatch) { pendingManualConfirm = manualPatch; config.manualConfirmIncomplete = manualPatch.value; }
if (patch.clearLogs) state.logs = [];
if (modeChanged && wasRunning) await pause('执行模式已改变,请核对任务后继续');
if (config.videoPaused) { const snap = snapshot(); const v = snap.document && describePage(snap.document, snap.url).video; if (v) v.pause(); }
if (config.examPaused && typeof options.onExamPause === 'function') options.onExamPause('考试任务已局部暂停');
await control(state.status); await persist(); notify(); return getState();
try { await control(state.status, false, Boolean(manualPatch), manualPatch); }
finally { if (pendingManualConfirm === manualPatch) pendingManualConfirm = null; }
await persist(); notify(); return getState();
}
async function start() {
await initialize(); if (destroyed) return getState();
@@ -479,8 +502,26 @@
attemptedAt = 0; lastAdvancedAt = now();
}
}
async function observeExamState(task, readOnly) {
// 仅观察已经交给本场控制器的考试,避免将旧场次结果附到尚未开考的任务。
if (!task.examRequested) return;
const exam = typeof options.getExamState === 'function' ? options.getExamState() : null;
if (!exam) return;
state.exam = Object.assign({ taskId: task.id, title: task.title }, clone(exam));
state.exam.total = exam.total == null && exam.details ? exam.details.length : exam.total;
state.exam.verified = exam.verifiedCount == null ? exam.verified : exam.verifiedCount;
const stage = exam.stage || exam.status;
if (stage === 'completed' || stage === 'submitted' || exam.submitted === true && exam.confirmed === true) { task.progress = 1; taskState(task, 'completed', '网站已确认本场考试提交完成'); activeTaskId = ''; await release(); }
else if (!readOnly && ['waiting', 'paused', 'failed', 'unknown', 'awaitingAttempt'].includes(stage)) taskState(task, 'waiting', exam.reason || exam.message || '考试等待人工处理');
else if (!readOnly && stage === 'waitingSubmit') taskState(task, 'waitingSubmit', exam.reason || '已核验,等待最早交卷时间');
}
async function examTick(task, snap, page) {
if (config.examPaused) { taskState(task, 'waiting', '考试任务已局部暂停'); return; }
// 人工交卷后仍需接收网站确认;等待/局部暂停期间不得重新开考或启动答题。
if (task.status === 'waiting' || config.examPaused) {
await observeExamState(task, true);
if (task.status !== 'completed' && config.examPaused) taskState(task, 'waiting', '考试任务已局部暂停');
return;
}
if (!['exam', 'paper'].includes(page.kind)) {
const node = resolveNode(task, snap);
if (node && !task.examLinkClickedAt) {
@@ -511,16 +552,7 @@
}
else taskState(task, 'waiting', '考试控制器尚未接入,请人工处理');
}
const exam = typeof options.getExamState === 'function' ? options.getExamState() : null;
if (exam) {
state.exam = Object.assign({ taskId: task.id, title: task.title }, clone(exam));
state.exam.total = exam.total == null && exam.details ? exam.details.length : exam.total;
state.exam.verified = exam.verifiedCount == null ? exam.verified : exam.verifiedCount;
const stage = exam.stage || exam.status;
if (stage === 'completed' || stage === 'submitted' || exam.submitted === true && exam.confirmed === true) { task.progress = 1; taskState(task, 'completed', '网站已确认本场考试提交完成'); activeTaskId = ''; await release(); }
else if (['waiting', 'paused', 'failed', 'unknown', 'awaitingAttempt'].includes(stage)) taskState(task, 'waiting', exam.reason || exam.message || '考试等待人工处理');
else if (stage === 'waitingSubmit') taskState(task, 'waitingSubmit', exam.reason || '已核验,等待最早交卷时间');
}
await observeExamState(task, false);
}
async function parallelTick(snap, page) {
if (config.execution !== 'parallel' || worker || !session) return;
@@ -586,10 +618,13 @@
await initialize(); const snap = snapshot(); if (!snap.document) return getState();
const page = describePage(snap.document, snap.url);
if (!safeURL(snap.url, snap.url)) { if (state.status === 'running') await pause('离开已授权站点,任务已暂停'); return getState(); }
if (session && (state.status === 'running' || sharedPaused)) {
if (session) {
const controlValue = await storage.get(CONTROL_KEY);
if (await observeControl(controlValue)) return getState();
if (worker && controlValue && controlValue.config) Object.assign(config, controlValue.config);
if (worker && controlValue && controlValue.sessionId === session.id && controlValue.config) {
const sharedConfig = Object.assign({}, controlValue.config); delete sharedConfig.manualConfirmIncomplete;
Object.assign(config, sharedConfig);
}
if (session.accountId && session.accountId !== account(snap, page) || session.bankVersion && snap.bankVersion && session.bankVersion !== snap.bankVersion) { await pause('账号或题库版本改变,已暂停旧任务'); return getState(); }
}
if (pageKey !== page.url) { pageKey = page.url; playStarted = false; lastTime = null; attemptedAt = 0; lastAdvancedAt = now(); }
@@ -598,7 +633,7 @@
if (task.title !== page.title && canonical(task.url || task.pageUrl) !== page.url) taskState(task, 'waiting', '当前课程与已领取任务不同,请核对后继续');
else { mergeTasks(discoverTasks(snap.document, snap.url), task); taskState(task, 'expanded', '已读取课程目录;课件须逐一等待平台确认完成'); activeTaskId = ''; await release(); task = null; }
} else mergeTasks(discoverTasks(snap.document, snap.url));
if (state.status !== 'running') { notify(); return getState(); }
if (state.status !== 'running') { await persist(); notify(); return getState(); }
if (task && TERMINAL.includes(task.status)) { activeTaskId = ''; await release(); task = null; }
if (!task) {
const candidates = state.tasks.filter(t => t.status === 'pending' && allows(t.kind) && (!worker || t.id === worker.taskId));
@@ -611,7 +646,7 @@
const held = await heartbeat(task) || await claim(task);
if (!held) state.message = '等待同类任务租约释放,暂不操作当前页面';
else if (task.status !== 'waiting' && task.kind === 'video') await videoTick(task, snap, page, epoch);
else if (task.status !== 'waiting' && task.kind === 'exam') await examTick(task, snap, page);
else if (task.kind === 'exam') await examTick(task, snap, page);
}
if (epoch === generation) await parallelTick(snap, page);
if (worker && session) { const request = await storage.get('worker:' + worker.nonce); if (request) { const own = task || state.tasks.find(t => t.id === worker.taskId); request.heartbeatAt = now(); request.status = own && own.status || 'idle'; request.video = state.video; request.exam = state.exam; await storage.set('worker:' + worker.nonce, request); } }
@@ -623,8 +658,10 @@
finally { busy = false; }
}
async function observeControl(value) {
if (!session || state.status !== 'running' && !sharedPaused) return false;
if (!session) return false;
const shared = value === undefined ? await storage.get(CONTROL_KEY) : value;
syncManualConfirmation(shared);
if (state.status !== 'running' && !sharedPaused) return false;
if (sharedPaused && shared && shared.sessionId === session.id && shared.status === 'running') {
sharedPaused = false; generation++; state.status = 'running'; session.status = 'running'; state.message = '已收到继续全部任务,重新核验当前页面'; playStarted = false; manualVideoPause = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now();
const active = state.tasks.find(t => t.id === activeTaskId); if (active) { active.examRequested = false; if (active.status === 'waiting') active.status = 'pending'; }