493 lines
35 KiB
JavaScript
493 lines
35 KiB
JavaScript
(function () {
|
||
'use strict';
|
||
if (window.YianQuestionBankExporter) return;
|
||
const core = window.QuestionBankCore;
|
||
const store = window.YianBankStore;
|
||
const matcher = window.YianAnswerMatcher;
|
||
const config = { autoAnswer: false, allowCopy: true, exportEnabled: false, role: '', course: '', panelPosition: null };
|
||
let snapshot = { records: [], counts: {}, total: 0, revision: '', storage: 'origin' };
|
||
let loading = true, disposed = false, scopedOut = false, page = null, previous = null;
|
||
let searchQuery = '', runKey = '', debounceTimer;
|
||
const exported = new Set();
|
||
const settleMs = 1200;
|
||
const copy = window.YianCopyController.create();
|
||
const emptyMetrics = () => ({ selected: 0, pending: 0, missing: 0, conflict: 0, locked: 0, failed: 0, unsupported: 0, total: 0 });
|
||
let metrics = emptyMetrics();
|
||
let positionRevision = 0;
|
||
let learning = null, examController = null, learningState = {}, examState = {}, runnerState = 'idle';
|
||
let actionEpoch = 0;
|
||
let diagnostics = null, lastExamDiagnostic = '', lastAnswerDiagnostic = '', diagnosticRoute = location.href;
|
||
const version = '2.1.4';
|
||
const policyKeys = ['manualConfirmIncomplete', 'autoContinuePlayback', 'resumePlaybackOnReturn', 'skipSingleAttemptQuiz'];
|
||
const pendingLearningSettings = new Map();
|
||
let learningSettings = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, autoContinuePlayback: true, resumePlaybackOnReturn: true, skipSingleAttemptQuiz: true, associations: {} };
|
||
const inScope = () => YianSiteScope.isAllowedURL(location.href);
|
||
function recordDiagnostic(level, event, message, details) {
|
||
return diagnostics?.record(level, event, message, details) || false;
|
||
}
|
||
|
||
function skipSingleAttempt() { return learning ? learning.getState().skipSingleAttemptQuiz !== false : learningSettings.skipSingleAttemptQuiz !== false; }
|
||
function attemptPolicy() {
|
||
return learning?.getAttemptPolicy() || window.YianAttemptPolicy.read(document.querySelector('app-examdetail,app-examtest,.pc_examtest'));
|
||
}
|
||
|
||
function topicPreflight() {
|
||
return window.YianLearningEngine.preflightTopic(document, location.href, snapshot, learningSettings.associations || {});
|
||
}
|
||
|
||
function visible(node) {
|
||
if (!node || node.closest('[hidden],.hidden')) return false;
|
||
const style = getComputedStyle(node);
|
||
return node.getClientRects().length > 0 && style.display !== 'none' && style.visibility !== 'hidden';
|
||
}
|
||
|
||
function routeInfo() {
|
||
const original = location.protocol === 'file:' ? core.savedSourceURL(document) : '';
|
||
const url = new URL(original || location.href);
|
||
let params = url.searchParams;
|
||
if (url.hash.startsWith('#/')) params = new URL(url.hash.slice(1), url.origin).searchParams;
|
||
const root = document.querySelector('app-examtest, .pc_examtest');
|
||
const mode = (params.get('flag') || '').toLowerCase();
|
||
const marker = root && Array.from(root.querySelectorAll('.pc_examtest_commit'))
|
||
.some(node => node.textContent.trim() === '重新练习' && !node.classList.contains('hidden'));
|
||
const desktop = root && root.querySelector('.pc_examtest_left') && root.querySelector('.pc_examtest_card')
|
||
&& (root.matches('.pc_examtest') || root.querySelector('.pc_examtest'));
|
||
const commit = root && root.querySelector('.pc_examtest_commit');
|
||
const countdown = root && root.querySelector('.pc_examtest_countdown .time');
|
||
const expired = countdown && /^\s*0{1,2}:00:00\s*$/.test(countdown.textContent);
|
||
const submitting = Array.from(document.querySelectorAll('[beingsubmitted],.beingsubmitting,.exam_submited_box')).some(visible);
|
||
const submitted = !!(root && root.querySelector('#examtestDisabled,.pc_examtest_commitdefult')) || submitting;
|
||
// app-dialog-box 的 modal 模板会挂到全局;不能只检查组件后代。
|
||
const confirming = Array.from(document.querySelectorAll('.dialog_box,app-dialog-box > *,.examine_box')).some(visible);
|
||
const practice = !!root && (mode ? mode === 'exercise' : !!marker);
|
||
const exam = !!desktop && mode === 'test' && !!commit && visible(commit)
|
||
&& commit.textContent.trim() === '交卷' && !submitted && !expired && !confirming;
|
||
const identity = new URL(location.href);
|
||
identity.searchParams.delete('t');
|
||
return { root, practice, exam, answerSupported: practice || exam, expired: !!expired,
|
||
submitted: !!submitted, confirming,
|
||
local: location.protocol === 'file:', sourceUrl: url.href, key: identity.href,
|
||
expectedTitle: mode === 'exercise' || mode === 'test' ? params.get('title') || '' : '', mode };
|
||
}
|
||
|
||
function structureSignature(bank) {
|
||
return JSON.stringify([bank.title, bank.expectedTotal, bank.questions.map(q =>
|
||
[q.number, q.type, q.question, q.options.map(option => [option.label, option.text, option.images])])]);
|
||
}
|
||
|
||
// 点击前现读上下文,阻断轮询尚未发现的路由、岗位、题目变化。
|
||
function getContext() {
|
||
const route = routeInfo();
|
||
const bank = route.root ? core.extract(document, route.sourceUrl) : { title: '', questions: [] };
|
||
const signature = route.root ? structureSignature(bank) : '';
|
||
const topic = route.mode === 'test' ? topicPreflight() : null;
|
||
const attempt = route.mode === 'test' ? attemptPolicy() : null;
|
||
const attemptBlocked = !!attempt && skipSingleAttempt() && attempt.blocked;
|
||
return { ready: inScope() && !!page && page.ready && !loading && page.key === route.key
|
||
&& page.root === route.root && page.signature === signature && !attemptBlocked,
|
||
practice: route.practice, exam: route.exam, mode: route.mode, local: route.local, role: config.role, course: bank.title,
|
||
sourceUrl: route.sourceUrl, routeKey: route.key, root: route.root,
|
||
pageSignature: signature, revision: snapshot.revision, snapshot, submitted: route.submitted, expired: route.expired,
|
||
courses: topic && topic.status === 'matched' ? topic.courses : undefined,
|
||
topicKey: topic ? JSON.stringify([topic.status, topic.courses, learningSettings.associations, skipSingleAttempt(), attempt.signature]) : '' };
|
||
}
|
||
|
||
const runner = window.YianAnswerRunner.create({ getContext,
|
||
match: (question, context) => matcher.match(question, context, snapshot),
|
||
getInterval: () => learningSettings.answerInterval,
|
||
onProgress: progress => {
|
||
metrics = progress; panel.setState({ metrics });
|
||
const counts = { selected: progress.selected, pending: progress.pending, missing: progress.missing, conflict: progress.conflict, failed: progress.failed, total: progress.total };
|
||
const signature = JSON.stringify(counts);
|
||
if (signature !== lastAnswerDiagnostic) { lastAnswerDiagnostic = signature; recordDiagnostic('info', 'answer.progress', '答案核验进度已更新', counts); }
|
||
},
|
||
onState: (state, reason) => {
|
||
if (disposed) return;
|
||
runnerState = state;
|
||
recordDiagnostic(state === 'paused' ? 'warning' : 'info', 'answer.state', reason || '答题状态更新', { status: state });
|
||
if (state === 'paused') { config.autoAnswer = false; runKey = ''; panel.setState({ autoAnswer: false }); }
|
||
if (state === 'paused') { examController?.disable(reason); learning?.configure({ examPaused: true }); }
|
||
if (reason && (state === 'paused' || state === 'complete')) panel.setNotice(reason, state === 'paused' ? 'warning' : 'success');
|
||
}
|
||
});
|
||
function courses() {
|
||
return Array.from(new Set(snapshot.records.map(record => record.course))).sort();
|
||
}
|
||
function renderLearning() {
|
||
if (!learning) return;
|
||
const video = learningState.video ? Object.assign({}, learningState.video, { learnedTime: learningState.video.learnedSeconds,
|
||
isLearned: learningState.video.platformComplete, refreshCount: learningState.video.retryCount }) : null;
|
||
const route = routeInfo();
|
||
const currentExam = route.mode === 'test' && route.root ? Object.assign({}, learningState.exam || {}, examState) : learningState.exam || {};
|
||
const rendered = Object.assign({}, learningSettings, learningState, {
|
||
video, exam: Object.assign({}, currentExam, { status: currentExam.stage || currentExam.status,
|
||
submitWaitSeconds: currentExam.waitSeconds }), associations: learningSettings.associations || {},
|
||
currentTitle: window.YianLearningEngine.describePage(document, location.href).title });
|
||
for (const [key, pending] of pendingLearningSettings) rendered[key] = pending.value;
|
||
panel.setState({ learning: rendered });
|
||
}
|
||
function refreshPanel(extra) {
|
||
panel.setState(Object.assign({ autoAnswer: config.autoAnswer, allowCopy: config.allowCopy,
|
||
exportEnabled: config.exportEnabled, position: config.panelPosition, role: config.role, course: config.course, courses: courses(),
|
||
bankTotal: snapshot.total, uniqueTotal: snapshot.questionTotal, conflictTotal: snapshot.conflictTotal,
|
||
roleCounts: snapshot.counts, storageMode: snapshot.storage, loading, metrics }, extra || {}));
|
||
}
|
||
function performSearch(query = searchQuery, options = {}) {
|
||
searchQuery = String(query || '');
|
||
const offset = options.offset || 0;
|
||
const result = searchQuery.trim() ? matcher.search(searchQuery,
|
||
{ role: config.role, course: config.course, limit: 20, offset }, snapshot) : { items: [], total: 0 };
|
||
panel.renderSearch(Object.assign({}, result, { offset }));
|
||
return result;
|
||
}
|
||
async function saveSetting(key, value) {
|
||
try { await store.setSetting(key, value); }
|
||
catch (error) { panel.setNotice('设置保存失败:' + error.message, 'error'); }
|
||
}
|
||
async function setRole(role) {
|
||
// Compatibility for old integrations; a role no longer filters any answers.
|
||
config.role = ''; return '';
|
||
}
|
||
function setAutoAnswer(enabled) {
|
||
if (!enabled) actionEpoch++;
|
||
const context = getContext();
|
||
if (enabled && context.mode === 'test' && skipSingleAttempt() && attemptPolicy().blocked) {
|
||
panel.setNotice(attemptPolicy().reason, 'warning'); refreshPanel(); return false;
|
||
}
|
||
if (enabled && (!context.ready || !(context.practice || context.exam) || context.local || !snapshot.total)) {
|
||
panel.setNotice('请先导入统一题库,并进入已加载完成的兼容练习或桌面考试页面。', 'warning'); refreshPanel(); return false;
|
||
}
|
||
if (enabled && context.mode === 'test' && topicPreflight().status !== 'matched') {
|
||
panel.setNotice('请先在题库栏确认当前考试标题与题库课程/主题的关联。', 'warning'); return false;
|
||
}
|
||
config.autoAnswer = !!enabled; runKey = '';
|
||
recordDiagnostic('info', 'answer.toggle', '自动选择' + (enabled ? '开启' : '关闭'), { enabled: !!enabled });
|
||
if (!enabled) { runner.stop('自动选择已关闭,已生效的选择保留。'); examController?.disable('自动选择已关闭'); }
|
||
refreshPanel(); tick(); return config.autoAnswer;
|
||
}
|
||
function setCopy(enabled) {
|
||
config.allowCopy = !!enabled; copy.setEnabled(config.allowCopy && inScope()); refreshPanel(); saveSetting('allowCopy', config.allowCopy);
|
||
}
|
||
async function importFiles(files) {
|
||
examController?.disable('题库正在更新');
|
||
if (learning) await learning.pause();
|
||
runner.stop('正在更新题库'); loading = true; runKey = ''; refreshPanel(); panel.setNotice('正在解析题库,请稍候…');
|
||
try {
|
||
snapshot = await store.importFiles(Array.from(files || []), { role: config.role });
|
||
metrics = emptyMetrics(); config.course = '';
|
||
panel.setNotice('统一题库导入成功:' + snapshot.total + ' 条来源记录,' + snapshot.questionTotal + ' 道题,' + snapshot.conflictTotal + ' 道存在答案冲突。', 'success');
|
||
return snapshot;
|
||
} catch (error) {
|
||
panel.setNotice('导入失败:' + error.message + '。原有题库保留。', 'error'); throw error;
|
||
} finally { loading = false; refreshPanel(); performSearch(searchQuery, { quiet: true }); tick(); }
|
||
}
|
||
function exportCurrent(format = 'csv') {
|
||
if (!inScope()) throw new Error('当前页面不在谊安学脚本生效范围内');
|
||
if (!config.exportEnabled) throw new Error('请先开启“导出题库”开关');
|
||
const route = routeInfo();
|
||
if (!route.practice) throw new Error('请先进入支持的练习页面');
|
||
const bank = core.extract(document, route.sourceUrl);
|
||
if (!bank.total) throw new Error('页面题目尚未加载');
|
||
core.download(bank, format);
|
||
panel.setNotice((bank.complete ? '已发起下载:' : '已发起不完整题库下载:') + bank.title
|
||
+ ',' + bank.total + ' 题、' + bank.answered + ' 个答案。若没有文件,请点击导出按钮。', bank.complete ? 'success' : 'warning');
|
||
return bank;
|
||
}
|
||
function setExportEnabled(enabled) {
|
||
const opening = !!enabled && !config.exportEnabled;
|
||
config.exportEnabled = !!enabled;
|
||
// 用户主动重新开启可再次下载;同一开启期间只自动导出一次相同题库。
|
||
if (opening) exported.clear();
|
||
refreshPanel(); tick(); return config.exportEnabled;
|
||
}
|
||
const panel = window.YianAssistantPanel.create({
|
||
onAutoChange: setAutoAnswer, onCopyChange: setCopy,
|
||
onPositionChange: position => { positionRevision++; config.panelPosition = position; saveSetting('panelPosition', position); },
|
||
onRoleChange: role => { setRole(role).catch(error => panel.setNotice(error.message, 'error')); },
|
||
onCourseChange: course => { config.course = course; performSearch(); },
|
||
onImport: files => { importFiles(files).catch(() => {}); }, onSearch: performSearch,
|
||
onSelectionSearch: text => performSearch(text),
|
||
onExport: format => { try { exportCurrent(format); } catch (error) { panel.setNotice(error.message, 'error'); } },
|
||
onExportEnabledChange: setExportEnabled
|
||
,onTaskAction: action => { taskAction(action).catch(error => panel.setNotice(error.message, 'error')); },
|
||
onLearningSetting: (key, value) => { changeLearningSetting(key, value).catch(error => panel.setNotice(error.message, 'error')); },
|
||
onLogAction: (action, value) => { logAction(action, value).catch(error => panel.setNotice('日志操作失败:' + error.message, 'error')); }
|
||
});
|
||
diagnostics = window.YianDiagnosticLogger.create({
|
||
storage: { name: '本机用户脚本存储', get: (key, fallback) => store.getSetting('diagnostics:' + key, fallback), set: (key, value) => store.setSetting('diagnostics:' + key, value) },
|
||
getContext: () => ({ version, url: location.href, pageKind: window.YianLearningEngine.describePage(document, location.href).kind,
|
||
status: learningState.status || 'initializing', mode: learningSettings.mode, execution: learningSettings.execution,
|
||
bankTotal: snapshot.total, bankRevision: snapshot.revision }),
|
||
onState: info => panel.setState({ diagnostics: info }),
|
||
onError: error => panel.setNotice('诊断日志保存失败,仍保留内存记录:' + error.message, 'warning')
|
||
});
|
||
async function exportDiagnostics(format) {
|
||
if (!inScope() || !['json', 'txt'].includes(format)) throw new Error('请选择 JSON 或 TXT 日志格式');
|
||
const contents = await (format === 'json' ? diagnostics.exportJSON() : diagnostics.exportText());
|
||
const url = URL.createObjectURL(new Blob([format === 'txt' ? '\ufeff' + contents : contents], { type: format === 'json' ? 'application/json;charset=utf-8' : 'text/plain;charset=utf-8' }));
|
||
const link = document.createElement('a'); link.href = url; link.download = '谊安学-诊断日志-' + new Date().toISOString().replace(/[:.]/g, '-') + '.' + format;
|
||
document.body.append(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||
panel.setNotice('诊断日志已导出,可用来核对卡住前后的执行过程。', 'success'); return contents;
|
||
}
|
||
async function logAction(action, value) {
|
||
if (action === 'record') return diagnostics.setEnabled(value === true);
|
||
if (action === 'exportJson' || action === 'exportText') return exportDiagnostics(action === 'exportJson' ? 'json' : 'txt');
|
||
if (action === 'clearSaved') { await diagnostics.clear(); panel.setNotice('已清空保存的诊断日志;题库和任务检查点保留。', 'success'); return; }
|
||
if (action === 'clear') learning?.clearLogs?.();
|
||
if (action === 'copy') {
|
||
const text = (learningState.logs || []).map(item => new Date(item.time).toLocaleTimeString() + ' [' + item.level + '] ' + item.message).join('\n');
|
||
if (typeof GM_setClipboard === 'function') GM_setClipboard(text); else await navigator.clipboard?.writeText(text);
|
||
}
|
||
}
|
||
|
||
async function changeLearningSetting(key, value) {
|
||
const allowed = ['mode', 'execution', 'rate', 'answerInterval', 'minSubmitMinutes', ...policyKeys, 'associations', 'stallSeconds', 'retryDelaySeconds', 'maxRefresh'];
|
||
if (!allowed.includes(key)) return;
|
||
if (key === 'mode' && !['video', 'videoQuiz', 'exam', 'all'].includes(value)) throw new Error('请选择有效任务模式');
|
||
if (key === 'execution' && !['sequential', 'parallel'].includes(value)) throw new Error('请选择顺序或并行');
|
||
if (policyKeys.includes(key) && typeof value !== 'boolean') throw new Error('开关设置须为开或关');
|
||
const ranges = { rate: [1, 2], answerInterval: [0, 120], minSubmitMinutes: [0.5, 180], stallSeconds: [1, 600], retryDelaySeconds: [1, 120], maxRefresh: [1, 3] };
|
||
if (ranges[key]) {
|
||
const range = ranges[key]; value = Number(value);
|
||
if (!Number.isFinite(value) || value < range[0] || value > range[1] || key === 'maxRefresh' && !Number.isInteger(value)) {
|
||
throw new Error('设置数值须在 ' + range[0] + '~' + range[1] + ' 范围内');
|
||
}
|
||
}
|
||
if (key === 'associations' && (!value || typeof value !== 'object' || Array.isArray(value)
|
||
|| Object.values(value).some(courses => !Array.isArray(courses) || courses.some(course => typeof course !== 'string' || !course.trim())))) {
|
||
throw new Error('主题关联须使用完整标题及题库课程名称列表');
|
||
}
|
||
const pending = { value }; pendingLearningSettings.set(key, pending);
|
||
recordDiagnostic('info', 'settings.changed', '学习设置已修改', { name: key, requested: value });
|
||
try {
|
||
if (['mode', 'execution', 'associations', 'manualConfirmIncomplete', 'skipSingleAttemptQuiz'].includes(key)) {
|
||
actionEpoch++;
|
||
config.autoAnswer = false; runner.stop('执行策略或主题关联变更'); examController.disable('设置变更,须重新启动核验');
|
||
await learning.pause();
|
||
}
|
||
learningSettings = Object.assign({}, learningSettings, { [key]: value });
|
||
await learning.configure({ [key]: value });
|
||
await saveSetting('learningSettings', learningSettings);
|
||
} finally {
|
||
if (pendingLearningSettings.get(key) === pending) pendingLearningSettings.delete(key);
|
||
refreshPanel(); renderLearning();
|
||
}
|
||
}
|
||
async function taskAction(action) {
|
||
actionEpoch++;
|
||
recordDiagnostic('info', 'task.action', '任务操作:' + action, { action, status: learningState.status, mode: learningSettings.mode, execution: learningSettings.execution });
|
||
await ready;
|
||
if (!inScope()) return;
|
||
if (action === 'start' || action === 'resume') {
|
||
if (action === 'start') await learning.start(); else await learning.resume();
|
||
} else if (action === 'pause' || action === 'stop') {
|
||
config.autoAnswer = false; runner.stop('用户暂停或停止任务'); examController.disable('用户暂停或停止任务');
|
||
if (action === 'pause') await learning.pause(); else await learning.stop();
|
||
} else if (action === 'videoPause' || action === 'videoResume') await learning.configure({ videoPaused: action === 'videoPause' });
|
||
else if (action === 'examPause') { await learning.configure({ examPaused: true }); setAutoAnswer(false); }
|
||
else if (action === 'examResume') {
|
||
if (learning.getState().status !== 'running') {
|
||
panel.setNotice('当前计划已暂停,请先在任务总览点击“继续上次任务”重新核验。', 'warning'); return;
|
||
}
|
||
await learning.configure({ examPaused: false });
|
||
const epoch = actionEpoch;
|
||
if (await examController.enable() && epoch === actionEpoch) setAutoAnswer(true);
|
||
}
|
||
renderLearning();
|
||
}
|
||
const learningStorage = { get: (key, fallback) => store.getSetting('learning:' + key, fallback), set: (key, value) => store.setSetting('learning:' + key, value) };
|
||
examController = window.YianExamController.create({ getContext, getSnapshot: () => snapshot,
|
||
getTopic: topicPreflight, getSettings: () => Object.assign({}, learningSettings, {
|
||
manualConfirmIncomplete: learning ? learning.getState().manualConfirmIncomplete !== false : learningSettings.manualConfirmIncomplete !== false,
|
||
skipSingleAttemptQuiz: skipSingleAttempt()
|
||
}), getAttemptPolicy: attemptPolicy, getRunnerState: () => runnerState,
|
||
storage: learningStorage, onLog: (level, message) => learning?.log(level, message),
|
||
onState: state => {
|
||
examState = state;
|
||
const signature = JSON.stringify([state.stage, state.enabled, state.verifiedCount, state.predictedScore, state.submitted, String(state.message || '').replace(/\d+(?:\.\d+)?/g, '#')]);
|
||
if (signature !== lastExamDiagnostic) {
|
||
lastExamDiagnostic = signature;
|
||
recordDiagnostic(['unknown', 'failed'].includes(state.stage) ? 'warning' : 'info', 'exam.state', state.message || '考试状态更新',
|
||
{ stage: state.stage, enabled: state.enabled, verifiedCount: state.verifiedCount, total: state.total, predictedScore: state.predictedScore, elapsedSeconds: state.elapsedSeconds, remainingSeconds: state.remainingSeconds, submitted: state.submitted, manualConfirmIncomplete: state.manualConfirmIncomplete, skipSingleAttemptQuiz: state.skipSingleAttemptQuiz });
|
||
}
|
||
renderLearning();
|
||
} });
|
||
learning = window.YianLearningEngine.create({
|
||
getSnapshot: () => ({ document, url: location.href, courses: courses(), bank: snapshot,
|
||
bankVersion: snapshot.revision, accountId: document.querySelector('[data-user-id]')?.getAttribute('data-user-id') || '', planId: '' }),
|
||
getExamState: () => examController.getState(),
|
||
onExamStart: async () => {
|
||
const epoch = actionEpoch;
|
||
if (await examController.enable() && epoch === actionEpoch && learning.getState().status === 'running'
|
||
&& !learning.getState().examPaused) return setAutoAnswer(true);
|
||
return false;
|
||
},
|
||
onExamPause: reason => { setAutoAnswer(false); examController.disable(reason); },
|
||
onLog: record => recordDiagnostic(record.level, record.event, record.message, Object.assign({ id: record.id, taskId: record.taskId, taskTitle: record.task }, record.details)),
|
||
solveQuiz: async (_popup, quizContext) => {
|
||
const revision = snapshot.revision;
|
||
const mode = learningSettings.mode;
|
||
const epoch = actionEpoch;
|
||
const value = await window.YianQuizAdapter.solve({ document, snapshot, matcher,
|
||
context: { course: window.YianLearningEngine.describePage(document, location.href).title,
|
||
sourceUrl: location.href, answerInterval: quizContext.answerInterval, skipSingleAttemptQuiz: quizContext.skipSingleAttemptQuiz },
|
||
getActive: () => epoch === actionEpoch && !disposed && inScope() && learning.getState().status === 'running'
|
||
&& !learning.getState().videoPaused && mode === learningSettings.mode && revision === snapshot.revision });
|
||
return Object.assign({}, value, { status: value.status === 'complete' ? 'completed' : value.status });
|
||
},
|
||
storage: learningStorage,
|
||
navigate: url => { if (YianSiteScope.isAllowedURL(url)) location.assign(url); else throw new Error('任务链接不在脚本范围内'); },
|
||
reload: () => location.reload(),
|
||
openWorker: url => {
|
||
if (!YianSiteScope.isAllowedURL(url)) throw new Error('任务链接不在脚本范围内');
|
||
if (typeof GM_openInTab === 'function') return GM_openInTab(url, { active: false, insert: true, setParent: true });
|
||
const child = window.open(url, '_blank'); if (!child) throw new Error('浏览器阻止并行标签页,请允许打开后重试'); return child;
|
||
},
|
||
onState: state => {
|
||
learningState = state;
|
||
if (!loading) for (const key of policyKeys) if (!pendingLearningSettings.has(key) && typeof state[key] === 'boolean') learningSettings[key] = state[key];
|
||
renderLearning();
|
||
}
|
||
});
|
||
|
||
function tick() {
|
||
if (disposed) return;
|
||
if (diagnosticRoute !== location.href) {
|
||
recordDiagnostic('info', 'page.changed', '页面地址已变化', { source: diagnosticRoute, destination: location.href }); diagnosticRoute = location.href;
|
||
}
|
||
if (!inScope()) {
|
||
if (!scopedOut) {
|
||
scopedOut = true;
|
||
config.autoAnswer = false; config.exportEnabled = false; runKey = '';
|
||
runner.stop('页面已离开脚本生效范围'); copy.setEnabled(false);
|
||
examController.disable('页面已离开脚本范围'); learning.pause();
|
||
panel.host.style.setProperty('display', 'none', 'important');
|
||
previous = page; page = null; metrics = emptyMetrics();
|
||
}
|
||
return;
|
||
}
|
||
if (scopedOut) {
|
||
scopedOut = false;
|
||
panel.host.style.setProperty('display', 'block', 'important');
|
||
copy.setEnabled(config.allowCopy);
|
||
}
|
||
const route = routeInfo();
|
||
if (!loading) {
|
||
if (route.mode === 'test' && skipSingleAttempt() && attemptPolicy().blocked && config.autoAnswer) {
|
||
config.autoAnswer = false; runKey = ''; runner.stop(attemptPolicy().reason); panel.setNotice(attemptPolicy().reason, 'warning');
|
||
}
|
||
examController.tick().catch(error => panel.setNotice(error.message, 'error'));
|
||
learning.tick().catch(error => panel.setNotice(error.message, 'error'));
|
||
}
|
||
if (!route.root) {
|
||
if (runner.running) runner.stop('当前页面未识别到练习题目');
|
||
if (page) previous = page;
|
||
page = null;
|
||
refreshPanel({ canAnswer: false, canExport: false, pageMode: '', pageStatus: '当前网页可搜索题库和复制文字;未识别到支持的题目。' }); return;
|
||
}
|
||
const bank = core.extract(document, route.sourceUrl), signature = structureSignature(bank), now = Date.now();
|
||
if (!page || page.key !== route.key || page.root !== route.root) {
|
||
if (runner.running) runner.stop('页面已切换,旧任务已停止');
|
||
const last = page || previous;
|
||
page = { key: route.key, root: route.root, signature, since: now, ready: false,
|
||
waiting: !!last && last.key !== route.key && last.root === route.root && last.signature === signature };
|
||
if (route.mode === 'test') config.autoAnswer = false;
|
||
runKey = ''; metrics = emptyMetrics(); refreshPanel({ expanded: true });
|
||
}
|
||
if (page.signature !== signature) {
|
||
if (runner.running) runner.stop('题目内容已更新');
|
||
if (route.mode === 'test') config.autoAnswer = false;
|
||
page.signature = signature; page.since = now; page.waiting = false; runKey = ''; metrics = emptyMetrics();
|
||
}
|
||
const countComplete = route.mode === 'test' ? bank.expectedTotal > 0 && bank.expectedTotal === bank.total
|
||
: bank.expectedTotal === null || bank.expectedTotal === bank.total;
|
||
const titleReady = !route.expectedTitle || route.expectedTitle.trim() === bank.title.trim();
|
||
page.ready = !!bank.total && countComplete && titleReady && !page.waiting && now - page.since >= settleMs;
|
||
let status;
|
||
if (route.mode === 'test' && !route.exam) status = route.expired ? '考试计时已结束,自动选择已停止。'
|
||
: route.submitted ? '考试已提交或提交中,自动选择已停止。'
|
||
: route.confirming ? '考试对话框已打开,自动选择已停止。' : '考试页面尚未确认可作答,等待完整桌面试卷加载。';
|
||
else if (!route.answerSupported) status = '已识别题目,但未确认支持的练习或桌面考试模式。';
|
||
else if (page.waiting || !titleReady) status = '等待本次练习的题目加载…';
|
||
else if (!bank.total || now - page.since < settleMs) status = '正在读取题目与隐藏答案…';
|
||
else if (!countComplete) status = bank.expectedTotal === null ? '等待考试答题卡总题数,确认完整试卷后才能自动选择。'
|
||
: '已读取 ' + bank.total + ' / ' + bank.expectedTotal + ' 题,等待全部加载。';
|
||
else if (route.exam) status = bank.title + ':' + bank.total + ' 题。按统一题库严格核对题干、选项和正确答案全文。'
|
||
+ (examController.getState().enabled ? '自动交卷条件已启用,请留意预计得分与倒计时。' : '仅选择答案;通过任务总览启动考试任务后才按条件自动交卷。');
|
||
else status = bank.title + ':' + bank.total + ' 题,' + (bank.missingAnswers.length
|
||
? '缺少 ' + bank.missingAnswers.length + ' 个网页答案;可用导入题库选择。' : '网页答案齐全。')
|
||
+ (route.local ? ' 保存网页支持搜索与导出;自动选择需要在线练习页面。' : '');
|
||
if (route.mode === 'test' && !route.exam) { config.autoAnswer = false; runKey = ''; }
|
||
refreshPanel({ pageStatus: status, pageMode: route.mode === 'test' ? 'test' : route.practice ? 'exercise' : '',
|
||
canExport: route.practice && !!bank.total,
|
||
canAnswer: route.answerSupported && !route.local && page.ready && !loading && !!snapshot.total });
|
||
if (!route.answerSupported || !page.ready || loading) { if (runner.running) runner.stop('等待可作答页面准备完成'); return; }
|
||
if (route.practice && config.exportEnabled && bank.complete) {
|
||
const key = route.key + '|' + signature;
|
||
if (!exported.has(key)) {
|
||
try { exportCurrent('csv'); exported.add(key); }
|
||
catch (error) { config.exportEnabled = false; refreshPanel(); panel.setNotice('自动下载失败:' + error.message, 'error'); }
|
||
}
|
||
}
|
||
const nextRunKey = route.key + '|' + signature + '|' + snapshot.revision;
|
||
if (config.autoAnswer && snapshot.total && runKey !== nextRunKey && !runner.running) {
|
||
runKey = nextRunKey;
|
||
runner.start().catch(error => { config.autoAnswer = false; refreshPanel(); panel.setNotice('自动选择失败:' + error.message, 'error'); });
|
||
}
|
||
}
|
||
const observer = new MutationObserver(records => {
|
||
if (records.every(record => record.target === panel.host || panel.host.contains(record.target))) return;
|
||
clearTimeout(debounceTimer); debounceTimer = setTimeout(tick, 200);
|
||
});
|
||
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'] });
|
||
const timer = setInterval(tick, 600);
|
||
const onNavigation = () => { runner.stop('页面地址已变化'); runKey = ''; tick(); };
|
||
window.addEventListener('popstate', onNavigation); window.addEventListener('hashchange', onNavigation);
|
||
const onError = event => recordDiagnostic('error', 'runtime.error', event.message || '网页脚本发生错误', { errorName: event.error?.name, stack: event.error?.stack });
|
||
const onRejection = event => recordDiagnostic('error', 'runtime.rejection', event.reason?.message || String(event.reason || '异步操作失败'), { errorName: event.reason?.name, stack: event.reason?.stack });
|
||
const onVisibility = () => recordDiagnostic('info', 'page.visibility', document.visibilityState === 'hidden' ? '页面进入后台' : '页面返回前台', { visibility: document.visibilityState });
|
||
const onNetwork = event => recordDiagnostic(event.type === 'offline' ? 'warning' : 'info', 'network.' + event.type, event.type === 'offline' ? '浏览器报告网络已断开' : '浏览器报告网络已恢复');
|
||
const onPageHide = () => { diagnostics.flush().catch(() => {}); };
|
||
window.addEventListener('error', onError); window.addEventListener('unhandledrejection', onRejection);
|
||
window.addEventListener('online', onNetwork); window.addEventListener('offline', onNetwork); window.addEventListener('pagehide', onPageHide);
|
||
document.addEventListener('visibilitychange', onVisibility);
|
||
function dispose() {
|
||
disposed = true; observer.disconnect(); clearInterval(timer); clearTimeout(debounceTimer);
|
||
window.removeEventListener('popstate', onNavigation); window.removeEventListener('hashchange', onNavigation);
|
||
window.removeEventListener('error', onError); window.removeEventListener('unhandledrejection', onRejection);
|
||
window.removeEventListener('online', onNetwork); window.removeEventListener('offline', onNetwork); window.removeEventListener('pagehide', onPageHide);
|
||
document.removeEventListener('visibilitychange', onVisibility);
|
||
runner.destroy(); examController.destroy(); learning.destroy(); copy.destroy(); panel.destroy();
|
||
diagnostics.destroy().catch(() => {});
|
||
}
|
||
const ready = (async () => {
|
||
const positionRevisionAtRead = positionRevision;
|
||
try {
|
||
const values = await Promise.all([store.load(), store.getSetting('learningSettings', {}), store.getSetting('allowCopy', true), store.getSetting('panelPosition', null), diagnostics.ready]);
|
||
snapshot = values[0]; config.role = '';
|
||
if (snapshot.migration?.status === 'pending') panel.setNotice('旧题库仍可使用,但统一存储迁移尚未保存:' + snapshot.migration.error, 'warning');
|
||
learningSettings = Object.assign({}, learningSettings, values[1]); await learning.configure(learningSettings, { restore: true });
|
||
const restoredPolicies = learning.getState();
|
||
for (const key of policyKeys) learningSettings[key] = restoredPolicies[key];
|
||
config.allowCopy = values[2] !== false;
|
||
const position = values[3];
|
||
// A drag during asynchronous storage loading wins over the older saved position.
|
||
if (positionRevision === positionRevisionAtRead) {
|
||
config.panelPosition = position && typeof position.left === 'number' && Number.isFinite(position.left)
|
||
&& typeof position.top === 'number' && Number.isFinite(position.top) ? { left: position.left, top: position.top } : null;
|
||
}
|
||
} catch (error) { panel.setNotice('题库读取失败:' + error.message + '。可重新导入题库。', 'error'); }
|
||
loading = false; copy.setEnabled(config.allowCopy && inScope()); refreshPanel();
|
||
recordDiagnostic('info', 'app.ready', '谊安学助手已加载', { version, mode: learningSettings.mode, execution: learningSettings.execution, total: snapshot.total });
|
||
tick(); return snapshot;
|
||
})();
|
||
const api = Object.freeze({ ready, importFiles, setRole, setAutoAnswer, setCopy, setExportEnabled, search: performSearch, getContext,
|
||
setLearningSetting: changeLearningSetting, taskAction, getLearningState: () => learning.getState(), getExamState: () => examController.getState(),
|
||
getDiagnosticState: () => diagnostics.getState(), flushDiagnostics: () => diagnostics.flush(), setDiagnosticRecording: enabled => diagnostics.setEnabled(enabled === true),
|
||
clearDiagnostics: () => diagnostics.clear(), exportDiagnostics,
|
||
getSnapshot: () => snapshot, getState: () => ({ ...config, metrics, learning: learning.getState(), exam: examController.getState(), pageReady: !!page && page.ready }),
|
||
exportCurrent, collectCurrent: () => core.extract(document, routeInfo().sourceUrl), stop: dispose });
|
||
window.YianAssistant = api; window.YianQuestionBankExporter = api;
|
||
refreshPanel(); tick();
|
||
})();
|