feat: add portable role-based question bank assistant
This commit is contained in:
+170
-141
@@ -1,173 +1,202 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// 源代码:构建后连同 question-bank-core.js 一起装入单个油猴脚本。
|
||||
if (window.YianQuestionBankExporter) return;
|
||||
const core = window.QuestionBankCore;
|
||||
if (!core) throw new Error('题库提取核心未加载');
|
||||
const config = { autoExport: true, format: 'csv', settleMs: 1800 };
|
||||
const completed = new Set();
|
||||
let state = null;
|
||||
let panel = null;
|
||||
let statusNode = null;
|
||||
let debounceTimer = null;
|
||||
let previousPractice = false;
|
||||
let previousRoot = null;
|
||||
let previousSignature = '';
|
||||
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 url = new URL(location.href);
|
||||
const original = location.protocol === 'file:' ? core.savedSourceURL(document) : '';
|
||||
const url = new URL(original || location.href);
|
||||
let params = url.searchParams;
|
||||
let path = url.pathname;
|
||||
if (url.hash.startsWith('#/')) {
|
||||
const hashUrl = new URL(url.hash.slice(1), url.origin);
|
||||
params = hashUrl.searchParams;
|
||||
path = hashUrl.pathname;
|
||||
}
|
||||
if (url.hash.startsWith('#/')) params = new URL(url.hash.slice(1), url.origin).searchParams;
|
||||
const root = document.querySelector('app-examtest, .pc_examtest');
|
||||
const local = url.protocol === 'file:';
|
||||
const practice = !!root && (local
|
||||
? /[?&]flag=exercise(?:&|$)/.test(core.savedSourceURL(document))
|
||||
: params.get('flag') === 'exercise' && /\/exam\/[^/]+\/questions\/?$/.test(path));
|
||||
return { practice, root, title: params.get('title') || '', key: path + '|' + (params.get('title') || ''), local };
|
||||
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 fingerprint(bank) {
|
||||
function structureSignature(bank) {
|
||||
return JSON.stringify([bank.title, bank.expectedTotal, bank.questions.map(q =>
|
||||
[q.number, q.type, q.question, q.options, q.answer, q.explanation, q.images])]);
|
||||
[q.number, q.type, q.question, q.options.map(option => [option.label, option.text, option.images])])]);
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
if (statusNode && statusNode.textContent !== message) statusNode.textContent = message;
|
||||
// 点击前现读上下文,阻断轮询尚未发现的路由、岗位、题目变化。
|
||||
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 };
|
||||
}
|
||||
|
||||
function createPanel() {
|
||||
if (panel) return;
|
||||
panel = document.createElement('div');
|
||||
panel.id = 'yian-question-bank-exporter';
|
||||
panel.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:2147483600;max-width:min(330px,calc(100vw - 32px));padding:14px;border:1px solid #d5dde5;border-radius:12px;background:#fff;color:#233447;box-shadow:0 4px 20px #0002;font:13px/1.6 system-ui,sans-serif;text-align:left';
|
||||
const heading = document.createElement('strong');
|
||||
heading.textContent = '练习题库导出';
|
||||
statusNode = document.createElement('div');
|
||||
statusNode.setAttribute('role', 'status');
|
||||
statusNode.style.cssText = 'margin:6px 0;white-space:pre-line';
|
||||
const actions = document.createElement('div');
|
||||
actions.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap';
|
||||
for (const format of ['csv', 'json', 'txt']) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = '导出 ' + format.toUpperCase();
|
||||
button.style.cssText = 'cursor:pointer;border:1px solid #d5dde5;border-radius:6px;background:#f5f8fa;padding:5px 9px;color:#233447;font:inherit';
|
||||
button.addEventListener('click', () => {
|
||||
try { exportCurrent(format); }
|
||||
catch (error) { setStatus('导出失败:' + error.message); }
|
||||
});
|
||||
actions.append(button);
|
||||
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');
|
||||
}
|
||||
const label = document.createElement('label');
|
||||
label.style.cssText = 'display:flex;gap:6px;align-items:center;margin-top:8px;cursor:pointer';
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.checked = config.autoExport;
|
||||
checkbox.addEventListener('change', () => {
|
||||
config.autoExport = checkbox.checked;
|
||||
tick();
|
||||
});
|
||||
label.append(checkbox, document.createTextNode('进入练习后自动导出 CSV'));
|
||||
panel.append(heading, statusNode, actions, label);
|
||||
document.body.append(panel);
|
||||
});
|
||||
function courses() {
|
||||
return Array.from(new Set(snapshot.records.filter(record => record.role === config.role).map(record => record.course))).sort();
|
||||
}
|
||||
|
||||
function exportCurrent(format = config.format) {
|
||||
if (!routeInfo().practice) throw new Error('请先进入练习页面');
|
||||
const bank = core.extract(document, location.href);
|
||||
if (!bank.total) throw new Error('页面还没有题目,请稍后重试');
|
||||
// 手动导出可保存不完整内容,文件及提示都会标记缺失。
|
||||
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);
|
||||
setStatus((bank.complete ? '已发起下载:' : '已发起不完整题库下载:') + bank.title + '\n'
|
||||
+ bank.total + ' 题,' + bank.answered + ' 个答案'
|
||||
+ (bank.complete ? '。' : ',请检查缺失内容。')
|
||||
+ '\n若未出现文件,请点击上方导出按钮。');
|
||||
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.practice) {
|
||||
if (panel) panel.remove();
|
||||
panel = statusNode = null;
|
||||
state = null;
|
||||
previousPractice = false;
|
||||
previousRoot = route.root;
|
||||
return;
|
||||
if (!route.root) {
|
||||
if (runner.running) runner.stop('当前页面未识别到练习题目');
|
||||
if (page) previous = page;
|
||||
page = null;
|
||||
refreshPanel({ canAnswer: false, canExport: false, pageStatus: '当前网页可搜索题库和复制文字;未识别到支持的练习题目。' }); return;
|
||||
}
|
||||
createPanel();
|
||||
const bank = core.extract(document, location.href);
|
||||
const signature = fingerprint(bank);
|
||||
const now = Date.now();
|
||||
if (!state || state.key !== route.key) {
|
||||
// SPA 改 URL 后旧题目可能仍留在 DOM,等待题目或组件更新。
|
||||
const transitioning = !!previousRoot && !!(previousPractice || previousSignature)
|
||||
&& previousRoot === route.root && previousSignature === signature;
|
||||
state = { key: route.key, signature, since: now, initialRoot: route.root,
|
||||
initialSignature: signature, waitingForReplacement: transitioning };
|
||||
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 (state.waitingForReplacement && (route.root !== state.initialRoot || signature !== state.initialSignature)) {
|
||||
state.waitingForReplacement = false;
|
||||
state.since = now;
|
||||
if (page.signature !== signature) {
|
||||
if (runner.running) runner.stop('题目内容已更新');
|
||||
page.signature = signature; page.since = now; page.waiting = false; runKey = ''; metrics = emptyMetrics();
|
||||
}
|
||||
previousPractice = true;
|
||||
previousRoot = route.root;
|
||||
previousSignature = signature;
|
||||
if (state.signature !== signature) {
|
||||
state.signature = signature;
|
||||
state.since = now;
|
||||
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'); }
|
||||
}
|
||||
}
|
||||
if (state.waitingForReplacement || (route.title && route.title.trim() !== bank.title.trim())) {
|
||||
setStatus('等待本次练习的题目加载…');
|
||||
return;
|
||||
}
|
||||
if (!bank.total || now - state.since < config.settleMs) {
|
||||
setStatus('正在读取题目与隐藏答案…');
|
||||
return;
|
||||
}
|
||||
if (bank.expectedTotal !== null && bank.expectedTotal !== bank.total) {
|
||||
setStatus('已读取 ' + bank.total + ' / ' + bank.expectedTotal + ' 题,等待全部加载。\n若页面分页,请逐页导出;手动导出会标记不完整。');
|
||||
return;
|
||||
}
|
||||
if (!bank.complete) {
|
||||
setStatus('已读取 ' + bank.total + ' 题,缺少 ' + bank.missingAnswers.length + ' 个答案。\n自动导出等待答案加载;可手动保存现有内容。');
|
||||
return;
|
||||
}
|
||||
const exportKey = state.key + '|' + signature;
|
||||
if (completed.has(exportKey)) return;
|
||||
if (!config.autoExport) {
|
||||
setStatus(bank.title + '\n' + bank.total + ' 题,答案齐全,可点击导出。');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
exportCurrent(config.format);
|
||||
completed.add(exportKey);
|
||||
} catch (error) {
|
||||
config.autoExport = false;
|
||||
const checkbox = panel.querySelector('input');
|
||||
if (checkbox) checkbox.checked = false;
|
||||
setStatus('自动下载失败:' + error.message + '\n请点击导出按钮重试。');
|
||||
console.error('[谊安学题库导出]', 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 => panel && (record.target === panel || panel.contains(record.target)))) return;
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(tick, 300);
|
||||
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 });
|
||||
const timer = setInterval(tick, 750); // 同时覆盖 Angular 路由和返回/前进。
|
||||
window.YianQuestionBankExporter = Object.freeze({
|
||||
exportCurrent,
|
||||
collectCurrent: () => core.extract(document, location.href),
|
||||
stop: () => { observer.disconnect(); clearInterval(timer); clearTimeout(debounceTimer); if (panel) panel.remove(); }
|
||||
});
|
||||
tick();
|
||||
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();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user