Files
yianxue-assistant/practice-exporter.js
T

267 lines
17 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: '', 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;
const inScope = () => YianSiteScope.isAllowedURL(location.href);
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) : '';
return { ready: inScope() && !!page && page.ready && !loading && page.key === route.key
&& page.root === route.root && page.signature === signature,
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 };
}
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, position: config.panelPosition, 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.exam) || 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 && inScope()); 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 (!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
});
function tick() {
if (disposed) return;
if (!inScope()) {
if (!scopedOut) {
scopedOut = true;
config.autoAnswer = false; config.exportEnabled = false; runKey = '';
runner.stop('页面已离开脚本生效范围'); copy.setEnabled(false);
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 (!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 + ' 题。按所选岗位题库严格核对题干、选项和正确答案全文。'
+ (route.local ? ' 保存网页只用于核对,自动选择需要在线考试页面。' : '完成后请检查结果并自行交卷。');
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 && !!config.role && !!snapshot.counts[config.role] });
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 + '|' + 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 () => {
const positionRevisionAtRead = positionRevision;
try {
const values = await Promise.all([store.load(), store.getSetting('role', ''), store.getSetting('allowCopy', true), store.getSetting('panelPosition', null)]);
snapshot = values[0]; config.role = store.roles.includes(values[1]) ? values[1] : '';
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(); 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();
})();