Files
yianxue-assistant/practice-exporter.js
T

211 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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: '' };
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,
exportEnabled: config.exportEnabled, 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') {
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,
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
});
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.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 + '|' + 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)]);
snapshot = values[0]; config.role = store.roles.includes(values[1]) ? values[1] : '';
config.allowCopy = values[2] !== 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, setExportEnabled, 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();
})();