feat: add manual confirmation toggle for incomplete exams
This commit is contained in:
+59
-22
@@ -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'; }
|
||||
|
||||
Reference in New Issue
Block a user