fix: recover expired task leases and persist diagnostic logs

This commit is contained in:
薇星幻海
2026-10-18 02:30:56 +08:00
parent 1b497286dc
commit ef35b461f7
15 changed files with 1368 additions and 85 deletions
+27 -4
View File
@@ -22,6 +22,7 @@
bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别题目页面', pageMode: '',
canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false, position: null,
activeTab: 'overview', uniqueTotal: 0, conflictTotal: 0,
diagnostics: { enabled: true, total: 0, dropped: 0, limit: 2000, storage: '', pending: 0, storageError: '' },
learning: { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, autoContinuePlayback: true, skipSingleAttemptQuiz: true, resumePlaybackOnReturn: true, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: {}, exam: {}, logs: [], message: '' }
};
let destroyed = false;
@@ -555,7 +556,21 @@
invoke('onLearningSetting', 'associations', associations); refreshLearning(); setNotice('主题关联已删除。', 'success');
});
views.console.appendChild(el(doc, 'h2', 'section-title', '运行控制台'));
views.console.appendChild(el(doc, 'h2', 'section-title', '故障诊断记录'));
const diagnosticSwitch = makeSwitch('持续记录诊断日志', function () {
state.diagnostics.enabled = diagnosticSwitch.input.checked;
invoke('onLogAction', 'record', diagnosticSwitch.input.checked); refreshLearning();
}, views.console);
diagnosticSwitch.input.setAttribute('data-testid', 'diagnostic-record');
const diagnosticStatus = el(doc, 'p', 'hint'); diagnosticStatus.setAttribute('data-testid', 'diagnostic-status');
const diagnosticActions = el(doc, 'div', 'console-actions');
[['exportJson', '导出诊断 JSON'], ['exportText', '导出诊断 TXT'], ['clearSaved', '清空已保存诊断']].forEach(function (entry) {
const button = el(doc, 'button', '', entry[1]); button.type = 'button';
button.setAttribute('data-testid', 'diagnostic-' + entry[0]);
button.addEventListener('click', function () { invoke('onLogAction', entry[0]); });
diagnosticActions.appendChild(button);
});
views.console.append(diagnosticStatus, el(doc, 'p', 'hint', '记录任务切换、播放、执行权和交卷等待原因,刷新后可导出核对;不保存题库、题目或答案正文。关闭只停止新增记录。'), diagnosticActions, el(doc, 'h2', 'section-title', '本轮运行显示'));
const logActions = el(doc, 'div', 'console-actions');
const logFilter = el(doc, 'select', 'console-filter'); logFilter.setAttribute('aria-label', '日志级别');
[['all', '全部日志'], ['info', '运行状态'], ['warning', '警告'], ['error', '错误']].forEach(function (entry) { const option = el(doc, 'option', '', entry[1]); option.value = entry[0]; logFilter.appendChild(option); });
@@ -660,7 +675,7 @@
});
}
const learningStatusNames = { idle: '尚未开始', ready: '等待开始', running: '运行中', loading: '加载中', scanning: '识别任务中', video: '视频学习中', playing: '播放中', backgroundPaused: '后台暂停,等待返回', quiz: '课中答题', exam: '考试答题中', answering: '答题中', preflight: '题库预检', waiting: '等待人工', waitingHuman: '等待人工', waitingSubmit: '等待交卷', waitingPlatform: '等待平台确认', paused: '已暂停', stopped: '已停止', completed: '已完成', done: '已完成', skipped: '已跳过', failed: '失败', error: '异常', retrying: '恢复中', pending: '待处理', submitted: '已交卷', finished: '本轮结束', unknown: '结果待确认', submitting: '提交中', confirming: '确认交卷中', expanded: '已读取目录' };
const learningStatusNames = { idle: '尚未开始', ready: '等待开始', running: '运行中', loading: '加载中', scanning: '识别任务中', video: '视频学习中', playing: '播放中', backgroundPaused: '后台暂停,等待返回', quiz: '课中答题', exam: '考试答题中', answering: '答题中', preflight: '题库预检', waiting: '等待人工', waitingHuman: '等待人工', waitingLease: '等待执行权(自动重试)', waitingSubmit: '等待交卷', waitingPlatform: '等待平台确认', paused: '已暂停', stopped: '已停止', completed: '已完成', done: '已完成', skipped: '已跳过', failed: '失败', error: '异常', retrying: '恢复中', pending: '待处理', submitted: '已交卷', finished: '本轮结束', unknown: '结果待确认', submitting: '提交中', confirming: '确认交卷中', expanded: '已读取目录' };
function statusName(value) { return value === 'awaitingAttempt' ? '等待确认新场次' : learningStatusNames[plain(value)] || plain(value) || '待处理'; }
function firstValue(object, keys, fallback) {
object = object || {};
@@ -683,7 +698,7 @@
const total = Math.max(0, Number(learning.total) || 0), completed = Math.max(0, Number(learning.completed) || 0);
const paused = ['paused', 'stopped'].includes(learning.status);
const active = !['idle', 'ready', 'stopped', 'completed', 'done', 'finished'].includes(learning.status || 'idle');
setText(globalStatus, statusName(learning.status) + (Number(learning.waiting) > 0 ? ' · ' + Number(learning.waiting) + ' 项等待人工' : ''));
setText(globalStatus, statusName(learning.status) + (Number(learning.waiting) > 0 ? ' · ' + Number(learning.waiting) + ' 项等待人工' : '') + (Number(learning.leaseWaiting) > 0 ? ' · ' + Number(learning.leaseWaiting) + ' 项等待执行权' : ''));
setText(globalPause, paused ? '继续全部' : '暂停全部');
globalPause.disabled = !active && !paused;
globalProgress.max = Math.max(total, 1); globalProgress.value = Math.min(completed, total);
@@ -692,7 +707,7 @@
const examTitle = plain(firstValue(exam, ['title', 'theme', 'name'], '暂无考试任务'));
const videoState = statusName(video.status || 'idle'), examState = statusName(exam.status || 'idle');
setText(workerStatus, '视频:' + videoState + ' 考试:' + examState + (learning.message ? '\n' + plain(learning.message) : ''));
setText(collapseSummary, completed + '/' + total + (Number(learning.waiting) > 0 ? ' · 待人工' : paused ? ' · 已暂停' : ''));
setText(collapseSummary, completed + '/' + total + (Number(learning.waiting) > 0 ? ' · 待人工' : paused ? ' · 已暂停' : Number(learning.leaseWaiting) > 0 ? ' · 待执行权' : ''));
collapseSummary.hidden = Boolean(state.expanded);
setText(learningMessage, learning.message || '在学习计划、课程或考试页面开始本轮,先读取实际任务,再按网页状态执行。人工刷新后需继续上次任务。');
Object.keys(summaryNodes).forEach(function (key) { setText(summaryNodes[key], key === 'etaSeconds' ? duration(learning.etaSeconds) : Math.max(0, Number(learning[key]) || 0)); });
@@ -764,6 +779,13 @@
setText(associationList, Object.keys(associations).map(function (title) { return title + ' → ' + (Array.isArray(associations[title]) ? associations[title] : []).join('、'); }).join('\n') || '暂无手工主题关联。');
if (root.activeElement !== associationCourses) associationCourses.value = (associations[normalized(associationTitle.value)] || []).join('\n');
}
const diagnostics = state.diagnostics || {};
diagnosticSwitch.input.checked = diagnostics.enabled !== false;
setText(diagnosticStatus, (diagnostics.enabled === false ? '已停止新增记录' : '记录已开启') + ' · 已记录 ' + Math.max(0, Number(diagnostics.total) || 0) + ' 条 / 最多 ' + (Number(diagnostics.limit) || 2000) + ' 条'
+ (Number(diagnostics.pending) > 0 ? ' · 待保存 ' + Number(diagnostics.pending) + ' 条' : '')
+ (Number(diagnostics.dropped) > 0 ? ' · 已移除最早 ' + Number(diagnostics.dropped) + ' 条' : '')
+ (diagnostics.storage === 'memory' ? ' · 当前仅内存保存' : diagnostics.storage === 'origin' ? ' · 当前浏览器本地保存' : '')
+ (diagnostics.storageError ? '\n保存异常:' + plain(diagnostics.storageError) + ';可先导出当前记录。' : ''));
const logs = (Array.isArray(learning.logs) ? learning.logs : []).slice(-500);
const filtered = logs.filter(function (entry) { return logFilter.value === 'all' || (entry.level === 'warn' ? 'warning' : entry.level || 'info') === logFilter.value; });
const nextLogSignature = JSON.stringify([logFilter.value, logs]);
@@ -930,6 +952,7 @@
if (Object.prototype.hasOwnProperty.call(update, 'learning')) {
update.learning = Object.assign({}, state.learning || {}, update.learning || {});
}
if (Object.prototype.hasOwnProperty.call(update, 'diagnostics')) update.diagnostics = Object.assign({}, state.diagnostics || {}, update.diagnostics || {});
Object.assign(state, update);
refresh();
if (acceptPosition) {