(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, autoExport: true, role: '', course: '' }; let snapshot = { records: [], counts: {}, total: 0, revision: '', storage: 'origin' }; let loading = true, disposed = 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(); 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 identity = new URL(location.href); identity.searchParams.delete('t'); return { root, practice: !!root && (mode ? mode === 'exercise' : !!marker), local: location.protocol === 'file:', sourceUrl: url.href, key: identity.href, expectedTitle: mode === 'exercise' ? 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) : ''; return { ready: !!page && page.ready && !loading && page.key === route.key && page.root === route.root && page.signature === signature, practice: route.practice, local: route.local, role: config.role, course: bank.title, sourceUrl: route.sourceUrl, routeKey: route.key, root: route.root, pageSignature: signature, revision: snapshot.revision, snapshot }; } const runner = window.YianAnswerRunner.create({ getContext, match: (question, context) => matcher.match(question, context, snapshot), onProgress: progress => { metrics = progress; panel.setState({ metrics }); }, onState: (state, reason) => { if (disposed) return; if (state === 'paused') { config.autoAnswer = false; runKey = ''; panel.setState({ autoAnswer: false }); } if (reason && (state === 'paused' || state === 'complete')) panel.setNotice(reason, state === 'paused' ? 'warning' : 'success'); } }); function courses() { return Array.from(new Set(snapshot.records.filter(record => record.role === config.role).map(record => record.course))).sort(); } function refreshPanel(extra) { panel.setState(Object.assign({ autoAnswer: config.autoAnswer, allowCopy: config.allowCopy, autoExport: config.autoExport, role: config.role, course: config.course, courses: courses(), bankTotal: snapshot.total, 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 })); if (!config.role && !options.quiet) panel.setNotice('请先选择主操、班长或副操。', 'warning'); return result; } async function saveSetting(key, value) { try { await store.setSetting(key, value); } catch (error) { panel.setNotice('设置保存失败:' + error.message, 'error'); } } async function setRole(role) { if (!store.roles.includes(role)) throw new Error('请选择主操、班长或副操'); runner.stop('岗位已切换'); config.role = role; config.course = ''; runKey = ''; metrics = emptyMetrics(); refreshPanel(); performSearch(); tick(); await saveSetting('role', role); return role; } function setAutoAnswer(enabled) { const context = getContext(); if (enabled && (!context.ready || !context.practice || context.local || !config.role || !snapshot.counts[config.role])) { panel.setNotice('请先导入所选岗位题库,并进入已加载完成的兼容练习页面。', 'warning'); refreshPanel(); return false; } config.autoAnswer = !!enabled; runKey = ''; if (!enabled) runner.stop('自动选择已关闭,已生效的选择保留。'); refreshPanel(); tick(); return config.autoAnswer; } function setCopy(enabled) { config.allowCopy = !!enabled; copy.setEnabled(config.allowCopy); refreshPanel(); saveSetting('allowCopy', config.allowCopy); } async function importFiles(files) { 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 + ' 条记录。请选择岗位后搜索或自动选择。', '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') { 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; } const panel = window.YianAssistantPanel.create({ onAutoChange: setAutoAnswer, onCopyChange: setCopy, 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'); } }, onAutoExportChange: enabled => { config.autoExport = !!enabled; saveSetting('autoExport', config.autoExport); tick(); } }); function tick() { if (disposed) return; const route = routeInfo(); if (!route.root) { if (runner.running) runner.stop('当前页面未识别到练习题目'); if (page) previous = page; page = null; refreshPanel({ canAnswer: false, canExport: false, 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 }; runKey = ''; metrics = emptyMetrics(); refreshPanel({ expanded: true }); } if (page.signature !== signature) { if (runner.running) runner.stop('题目内容已更新'); page.signature = signature; page.since = now; page.waiting = false; runKey = ''; metrics = emptyMetrics(); } const countComplete = 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.practice) status = '已识别题目,但未确认练习模式;自动选择和导出暂停。'; else if (page.waiting || !titleReady) status = '等待本次练习的题目加载…'; else if (!bank.total || now - page.since < settleMs) status = '正在读取题目与隐藏答案…'; else if (!countComplete) status = '已读取 ' + bank.total + ' / ' + bank.expectedTotal + ' 题,等待全部加载。'; else status = bank.title + ':' + bank.total + ' 题,' + (bank.missingAnswers.length ? '缺少 ' + bank.missingAnswers.length + ' 个网页答案;可用导入题库选择。' : '网页答案齐全。') + (route.local ? ' 保存网页支持搜索与导出;自动选择需要在线练习页面。' : ''); refreshPanel({ pageStatus: status, canExport: route.practice && !!bank.total, canAnswer: route.practice && !route.local && page.ready && !loading && !!config.role && !!snapshot.counts[config.role] }); if (!route.practice || !page.ready || loading) { if (runner.running) runner.stop('等待兼容练习页面准备完成'); return; } if (config.autoExport && bank.complete) { const key = route.key + '|' + signature; if (!exported.has(key)) { try { exportCurrent('csv'); exported.add(key); } catch (error) { config.autoExport = false; refreshPanel(); panel.setNotice('自动下载失败:' + error.message, 'error'); } } } const nextRunKey = route.key + '|' + signature + '|' + config.role + '|' + snapshot.revision; if (config.autoAnswer && config.role && snapshot.counts[config.role] && 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); function dispose() { disposed = true; observer.disconnect(); clearInterval(timer); clearTimeout(debounceTimer); window.removeEventListener('popstate', onNavigation); window.removeEventListener('hashchange', onNavigation); runner.destroy(); copy.destroy(); panel.destroy(); } const ready = (async () => { try { const values = await Promise.all([store.load(), store.getSetting('role', ''), store.getSetting('allowCopy', true), store.getSetting('autoExport', true)]); snapshot = values[0]; config.role = store.roles.includes(values[1]) ? values[1] : ''; config.allowCopy = values[2] !== false; config.autoExport = values[3] !== false; } catch (error) { panel.setNotice('题库读取失败:' + error.message + '。可重新导入题库。', 'error'); } loading = false; copy.setEnabled(config.allowCopy); refreshPanel(); tick(); return snapshot; })(); const api = Object.freeze({ ready, importFiles, setRole, setAutoAnswer, setCopy, search: performSearch, getContext, getSnapshot: () => snapshot, getState: () => ({ ...config, metrics, pageReady: !!page && page.ready }), exportCurrent, collectCurrent: () => core.extract(document, routeInfo().sourceUrl), stop: dispose }); window.YianAssistant = api; window.YianQuestionBankExporter = api; refreshPanel(); tick(); })();