Files
huayi-question-bank/src/app.js
T

703 lines
53 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.
// UI and persistence stay inside the userscript sandbox. Page data is rendered as text.
(() => {
const CONFIG_KEY = 'huayi-qb.config.v1';
const RECORDS_KEY = 'huayi-qb.records.v1';
const BACKUP_KEY = 'huayi-qb.records.before-dedup.v1';
const OPTION_REPAIR_BACKUP_KEY = 'huayi-qb.records.before-duplicate-option-repair.v1';
const IMAGE_UPGRADE_BACKUP_KEY = 'huayi-qb.records.before-image-upgrade.v1';
const DEFAULTS = { mode: 'hidden', keepLauncher: false, copyEnabled: true, collecting: true, tab: 'search' };
const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const storage = {
get(key, fallback) {
if (typeof GM_getValue === 'function') return GM_getValue(key, fallback);
const raw = localStorage.getItem(key);
return raw === null ? fallback : JSON.parse(raw);
},
set(key, value) {
if (typeof GM_setValue === 'function') return GM_setValue(key, value);
localStorage.setItem(key, JSON.stringify(value));
}
};
let rawConfig;
try { rawConfig = storage.get(CONFIG_KEY, {}); } catch (_) { rawConfig = {}; }
const config = { ...DEFAULTS, ...(rawConfig && typeof rawConfig === 'object' ? rawConfig : {}) };
if (!['open', 'collapsed', 'hidden'].includes(config.mode)) config.mode = 'open';
if (!['search', 'library', 'auto', 'settings'].includes(config.tab)) config.tab = 'search';
['keepLauncher', 'copyEnabled', 'collecting'].forEach(key => { if (typeof config[key] !== 'boolean') config[key] = DEFAULTS[key]; });
// Every new page starts completely hidden, including upgrades from older settings.
config.mode = 'hidden';
config.keepLauncher = false;
let records = [];
let recordsDirty = false;
let loadError = '';
let cleanupNotice = '';
function ensureCleanupBackup(original) {
const existing = storage.get(BACKUP_KEY, null);
if (existing === null) storage.set(BACKUP_KEY, { schemaVersion: 1, savedAt: new Date().toISOString(), records: original });
else if (!existing || !Array.isArray(existing.records)) throw new Error('去重备份结构异常,已保留原题库并停止覆盖');
}
function repairAnswerConflicts(original) {
const result = QBCore.repairDuplicateOptionConflicts(original);
if (!result.repaired.length) return result;
const existing = storage.get(OPTION_REPAIR_BACKUP_KEY, null);
if (existing !== null && (!existing || existing.schemaVersion !== 1 || !Array.isArray(existing.records))) {
throw new Error('答案误报修复备份结构异常,已停止修复');
}
// Preserve every original version, including later imports and other tabs.
// Back up before returning repaired data to any caller that may persist it.
const originals = existing ? [...existing.records] : [];
const signatures = new Set(originals.map(record => JSON.stringify(record)));
for (const item of result.repaired) {
const signature = JSON.stringify(item.original);
if (!signatures.has(signature)) { signatures.add(signature); originals.push(item.original); }
}
storage.set(OPTION_REPAIR_BACKUP_KEY, { schemaVersion: 1, savedAt: existing?.savedAt || new Date().toISOString(), records: originals });
return result;
}
function imageReplacementSignature(value) {
const record = QBCore.normalizeRecord(value);
return JSON.stringify([record.key, record.type, record.stem,
record.options.map(option => JSON.stringify([option.id, option.text])).sort(),
[...record.correctOptionIds].sort(), [...record.answerTexts].sort(), record.answerStatus]);
}
function imageUpgradeBackup() {
const backup = storage.get(IMAGE_UPGRADE_BACKUP_KEY, null);
if (backup !== null && (!backup || backup.schemaVersion !== 1 || !Array.isArray(backup.records) || !Array.isArray(backup.migrations))) {
throw new Error('图片补全备份结构异常,已停止覆盖');
}
return backup;
}
function ensureImageUpgradeBackup(upgraded) {
if (!upgraded.length) return;
const previous = imageUpgradeBackup();
const stored = storage.get(RECORDS_KEY, []);
if (!Array.isArray(stored)) throw new Error('现有题库结构异常,已停止图片补全');
const originals = previous ? [...previous.records] : [], migrations = previous ? [...previous.migrations] : [];
const signatures = new Set(originals.map(record => JSON.stringify(QBCore.normalizeRecord(record))));
for (const item of upgraded) {
const canonical = QBCore.normalizeRecord(item.original);
// Compaction may just have assigned occurrence=0 in memory, without a
// previous storage write. Capture that exact stored predecessor as well;
// only this generated metadata may be absent, never content or answers.
const versions = [item.original, ...stored.filter(value => {
const record = QBCore.normalizeRecord(value);
if (record.key !== canonical.key) return false;
if (record.contentOccurrence == null && canonical.contentOccurrence != null) record.contentOccurrence = canonical.contentOccurrence;
return JSON.stringify(QBCore.normalizeRecord(record)) === JSON.stringify(canonical);
})];
for (const value of versions) {
const original = QBCore.normalizeRecord(value), originalSignature = JSON.stringify(original);
if (!signatures.has(originalSignature)) { signatures.add(originalSignature); originals.push(value); }
const migration = { originalKey: original.key, originalSignature, replacementKey: item.replacement.key,
replacementSignature: imageReplacementSignature(item.replacement), bankId: item.bankId,
batchId: item.batchId, expectedCount: item.expectedCount, questionIndex: item.questionIndex };
if (!migrations.some(value => JSON.stringify(value) === JSON.stringify(migration))) migrations.push(migration);
}
}
storage.set(IMAGE_UPGRADE_BACKUP_KEY, { schemaVersion: 1, savedAt: previous?.savedAt || new Date().toISOString(), records: originals, migrations });
}
function removeBackedUpImagePredecessors(original, replacements) {
const backup = imageUpgradeBackup();
if (!backup) return original;
// A stale tab can reintroduce the exact pre-upgrade record while saving.
// Remove only a durably backed-up version with its unchanged, confirmed
// replacement present. Different answers or newly changed old data survive.
const backups = new Set(backup.records.map(record => JSON.stringify(QBCore.normalizeRecord(record))));
return original.filter(value => {
const record = QBCore.normalizeRecord(value), signature = JSON.stringify(record);
return !backup.migrations.some(item => item.originalKey === record.key && item.originalSignature === signature && backups.has(signature) &&
replacements.some(replacement => replacement.answerStatus === 'confirmed' && replacement.key === item.replacementKey &&
replacement.questionIdAliases?.includes(record.questionId) && imageReplacementSignature(replacement) === item.replacementSignature));
});
}
try {
const saved = storage.get(RECORDS_KEY, []);
if (!Array.isArray(saved)) throw new Error('本地题库格式不正确');
const retained = removeBackedUpImagePredecessors(saved, saved);
const repaired = repairAnswerConflicts(retained);
const compacted = QBCore.compactRecords(QBCore.upsert([], repaired.records));
records = compacted.records;
const removed = retained.length - records.length;
if (retained.length < saved.length) recordsDirty = true;
if (removed > 0) {
ensureCleanupBackup(saved);
recordsDirty = true;
cleanupNotice = `已合并 ${removed} 条重复加载记录,原始数据已备份。`;
}
if (repaired.repaired.length) {
recordsDirty = true;
cleanupNotice += `${cleanupNotice ? ' ' : ''}已恢复 ${repaired.repaired.length} 道重复选项文字造成的答案误报,修复前记录已备份。`;
}
} catch (error) { loadError = `本地题库读取失败:${error.message}。请先保留已有备份。`; }
let root, shadow, panel, launcher, resultBox, statusBox;
let networkEnabled = ['www.shhuayi-elearning.cn', 'shhuayi-elearning.cn'].includes(window.location.hostname);
let saveTimer, searchTimer, query = '', scope = networkEnabled ? '__current__' : '', searchScope = scope, searchField = 'all';
let activeBank = null, importing = false;
let status = loadError || cleanupNotice || '可输入文字或划选题干检索。';
let storageError = false;
let mounted = false;
const createAdapter = () => QBAdapter.create({ window: pageWindow, document, onQuestions: receive, onStatus: adapterStatus, onBankChange: bankChanged, observeNetwork: networkEnabled });
let adapter = createAdapter();
const autoAnswer = QBAuto.create({ window: pageWindow, document, core: QBCore, getRecords: () => records, getQuestion: currentQuestion, getBank: currentBank,
onStatus: message => { tell(message); }, onChange: () => { renderAuto(); } });
function activateKnownPage() {
if (!networkEnabled && document.querySelector('.yxtulcdsdk-pc-marking-main')) {
adapter.stop();
adapter.setCopyEnabled(false);
networkEnabled = true;
scope = '__current__'; searchScope = '__current__';
adapter = createAdapter();
if (config.collecting) adapter.start();
adapter.setCopyEnabled(Boolean(config.copyEnabled));
}
}
function bankChanged(bank) {
if (!bank || (!bank.bankName && !bank.bankId)) {
if (activeBank) {
activeBank = null; scope = ''; searchScope = '';
if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); }
}
return;
}
const previous = activeBank;
const sameName = previous && bankNameKey(previous.bankName) === bankNameKey(bank.bankName);
const identityChanged = previous && (!sameName || (previous.taskId && bank.taskId && previous.taskId !== bank.taskId) || (previous.bankId && bank.bankId && previous.bankId !== bank.bankId));
const changed = JSON.stringify(previous) !== JSON.stringify(bank);
activeBank = { ...bank };
if (identityChanged) { scope = '__current__'; searchScope = '__current__'; }
if (mounted && changed) { refreshScope(); renderStats(); renderCurrent(); renderResults(); }
}
function adapterStatus(value) {
const message = typeof value === 'string' ? value : value && value.message;
if (message && !storageError) { status = message; renderStatus(); }
}
function persistConfig() {
try { storage.set(CONFIG_KEY, { ...config }); }
catch (error) { tell(`设置保存失败:${error.message}`, true); }
}
function persistRecords() {
clearTimeout(saveTimer);
if (loadError || !recordsDirty) return;
try {
// Merge other tabs before writing; GM change events also refresh an open panel.
const stored = storage.get(RECORDS_KEY, []);
if (!Array.isArray(stored)) throw new Error('现有题库结构异常,已停止覆盖');
const retained = removeBackedUpImagePredecessors(stored, records);
const compacted = QBCore.compactRecords(QBCore.upsert(repairAnswerConflicts(retained).records, repairAnswerConflicts(records).records));
if (compacted.removed > 0) ensureCleanupBackup(stored);
records = repairAnswerConflicts(compacted.records).records;
storage.set(RECORDS_KEY, records);
recordsDirty = false;
storageError = false;
} catch (error) {
storageError = true;
tell(`保存失败:${error.message}。当前内存数据仍可导出,请及时备份。`, true);
}
}
function receive(incoming, context = {}) {
if (!Array.isArray(incoming) || !incoming.length || loadError) return;
try {
incoming = repairAnswerConflicts(incoming).records;
const bank = currentBank();
const imageUpgrade = QBCore.upgradeLegacyImageRecords(records, incoming, {
bankId: !bank?.loading && (context.currentBankId || context.bankId) === bank?.bankId ? bank.bankId : '',
expectedCount: context.pageQuestionCount, fullSnapshot: context.fullSnapshot === true
});
ensureImageUpgradeBackup(imageUpgrade.upgraded);
records = imageUpgrade.existing;
incoming = imageUpgrade.incoming;
const namesById = new Map();
incoming.forEach(record => {
if (!record.bankId || record.source?.kind !== 'network' || !record.bankName || record.bankName === '未命名题库') return;
if (!namesById.has(record.bankId)) namesById.set(record.bankId, new Set());
namesById.get(record.bankId).add(record.bankName);
});
// A newly observed, explicit bank ID/name pair can repair an old stale
// title for that same ID. Never infer a rename from question type or text.
records = records.map(record => {
const names = record.bankId && namesById.get(record.bankId);
return names?.size === 1 ? QBCore.normalizeRecord({ ...record, bankName: [...names][0] }) : record;
});
const expanded = QBCore.upsert(records, incoming);
const compacted = QBCore.compactRecords(expanded);
if (compacted.removed > 0) ensureCleanupBackup(expanded);
records = repairAnswerConflicts(compacted.records).records;
recordsDirty = true;
clearTimeout(saveTimer);
saveTimer = setTimeout(persistRecords, 250);
if (mounted) { refreshScope(); renderStats(); renderResults(); renderCurrent(); }
} catch (error) { tell(`这批题目未保存:${error.message}`, true); }
}
function tell(message, error = false) {
status = message;
if (statusBox) statusBox.dataset.error = String(error);
renderStatus();
}
function renderStatus() {
if (statusBox) statusBox.textContent = status;
}
function node(tag, className, text) {
const element = document.createElement(tag);
if (className) element.className = className;
if (text !== undefined) element.textContent = text;
return element;
}
function setMode(mode) {
config.mode = mode;
persistConfig();
renderMode();
if (mode === 'open' && config.tab === 'search') shadow?.getElementById('qb-search').focus();
}
function renderMode() {
if (!panel) return;
panel.hidden = config.mode === 'hidden';
panel.classList.toggle('collapsed', config.mode === 'collapsed');
launcher.hidden = true;
const collapse = shadow.querySelector('[data-action="collapse"]');
collapse.textContent = config.mode === 'collapsed' ? '展开' : '收起';
collapse.setAttribute('aria-expanded', String(config.mode !== 'collapsed'));
}
function selectTab(tab) {
config.tab = tab;
persistConfig();
shadow.querySelectorAll('[data-tab]').forEach(button => {
const selected = button.dataset.tab === tab;
button.setAttribute('aria-selected', String(selected));
button.classList.toggle('active', selected);
});
shadow.querySelectorAll('[data-view]').forEach(view => { view.hidden = view.dataset.view !== tab; });
}
function bankKey(record) { return String(record.bankId || record.bankName || ''); }
function bankNameKey(name) { return String(name || '').replace(/\s+/g, ''); }
function bankCatalog() {
const namedIds = new Map();
records.forEach(record => {
if (!record.bankId) return;
const name = bankNameKey(record.bankName);
if (!namedIds.has(name)) namedIds.set(name, new Set());
namedIds.get(name).add(record.bankId);
});
const catalog = new Map();
records.forEach(record => {
const ids = namedIds.get(bankNameKey(record.bankName));
const key = !record.bankId && ids?.size === 1 ? [...ids][0] : bankKey(record);
if (!catalog.has(key)) catalog.set(key, { key, name: record.bankName || record.bankId || '未命名题库', count: 0 });
catalog.get(key).count++;
});
return [...catalog.values()];
}
function currentQuestion() {
try { return adapter.getCurrentQuestion(); } catch (_) { return null; }
}
function currentBank() {
try { return adapter.getCurrentBank?.() || activeBank || currentQuestion(); }
catch (_) { return activeBank; }
}
function sameBankScope(record, anchor, knownIds) {
if (record.bankId && anchor.bankId) return record.bankId === anchor.bankId;
const name = String(anchor.bankName || '').replace(/\s+/g, '');
if (!name || name === '未命名题库' || String(record.bankName || '').replace(/\s+/g, '') !== name) return false;
const namedIds = knownIds || new Set(records.filter(item => item.bankId && bankNameKey(item.bankName) === name).map(item => item.bankId));
if (anchor.bankId) namedIds.add(anchor.bankId);
// A human CSV often has a bank name only. Include it only when that name
// identifies one known bank; identical names across bank IDs stay separate.
return namedIds.size <= 1;
}
function scopedRecords(selectedScope = scope) {
if (!selectedScope) return records;
const isCurrent = selectedScope === '__current__';
const anchor = isCurrent ? currentBank() : records.find(record => bankKey(record) === selectedScope);
if (!anchor) return [];
const namedIds = new Set(records.filter(item => item.bankId && bankNameKey(item.bankName) === bankNameKey(anchor.bankName)).map(item => item.bankId));
if (anchor.bankId) namedIds.add(anchor.bankId);
return records.filter(record => (!isCurrent && bankKey(record) === selectedScope) || sameBankScope(record, anchor, namedIds));
}
function scopeName(selectedScope = scope) {
if (selectedScope === '') return '全部题库';
if (selectedScope === '__current__') return currentBank()?.bankName || '当前网页未识别题库';
const anchor = records.find(record => bankKey(record) === selectedScope);
return anchor?.bankName || '未命名题库';
}
function refreshScope() {
if (!shadow) return;
const banks = new Map(bankCatalog().map(bank => [bank.key, bank.name]));
if (scope && scope !== '__current__' && !banks.has(scope)) scope = '__current__';
if (searchScope && searchScope !== '__current__' && !banks.has(searchScope)) searchScope = '__current__';
const names = [...banks.values()];
const options = [['__current__', `当前题库:${scopeName('__current__')}`], ['', `全部题库(${records.length}题)`], ...[...banks].map(([key, name]) => [key, names.filter(value => value === name).length > 1 ? `${name}(${key === name ? '无ID' : key})` : name])];
['qb-scope', 'qb-search-scope'].forEach(id => {
const select = shadow.getElementById(id);
const signature = JSON.stringify(options);
if (select.dataset.signature !== signature) {
select.dataset.signature = signature;
select.replaceChildren();
options.forEach(([value, label]) => {
const option = node('option', '', label); option.value = value; option.title = label; select.append(option);
});
}
select.value = id === 'qb-scope' ? scope : searchScope;
});
}
function renderStats() {
if (!shadow) return;
const selected = scopedRecords();
const confirmed = selected.filter(record => record.answerStatus === 'confirmed').length;
const conflicts = selected.filter(record => record.answerStatus === 'conflict').length;
[['total', selected.length], ['confirmed', confirmed], ['missing', selected.length - confirmed - conflicts], ['conflicts', conflicts]].forEach(([name, count]) => {
shadow.getElementById(`qb-stat-${name}`).textContent = count.toLocaleString('zh-CN');
});
const exportName = scopeName();
shadow.getElementById('qb-export-name').textContent = exportName;
shadow.getElementById('qb-export-name').title = exportName;
const catalog = bankCatalog();
shadow.getElementById('qb-library-summary').textContent = `本地共保存 ${catalog.length} 个题库、${records.length} 道题。上方统计与导出均按所选范围。`;
const bankList = shadow.getElementById('qb-bank-list');
bankList.replaceChildren();
catalog.forEach(bank => {
const item = node('div', 'bank-row');
item.append(node('span', '', bank.name), node('strong', '', `${bank.count} 题`));
bankList.append(item);
});
shadow.getElementById('qb-export-preview').textContent = selected.length ? `文件名:${exportFilename(exportName, selected.length, 'csv')}` : '该范围暂无题目;不会导出其他题库。';
const button = shadow.querySelector('[data-action="toggle-capture"]');
button.textContent = config.collecting ? '暂停采集' : '继续采集';
const indicator = shadow.getElementById('qb-capture-state');
indicator.textContent = config.collecting ? '采集中' : '已暂停';
indicator.classList.toggle('paused', !config.collecting);
}
function renderCurrent() {
if (!shadow) return;
const bank = currentBank();
const current = currentQuestion();
const known = Boolean(bank?.bankName || bank?.bankId);
const name = bank?.bankName || '通用网页 · 本地题库';
shadow.getElementById('qb-context-name').textContent = name;
shadow.getElementById('qb-context-name').title = name;
const pool = scopedRecords('__current__');
shadow.getElementById('qb-context-count').textContent = known ? `${bank?.loading ? '切换中 · ' : ''}本库已采集 ${pool.length} 题 · 已确认 ${pool.filter(record => record.answerStatus === 'confirmed').length} 题` : `本地共 ${records.length} 题 · 可划词或输入文字搜索`;
shadow.getElementById('qb-page-total').textContent = known && Number.isSafeInteger(bank?.questionCount) && bank.questionCount > 0 ? ` · 页面 ${bank.questionCount} 题` : '';
shadow.getElementById('qb-current-title').textContent = bank?.loading ? '题库切换中,等待当前题目加载。' : current?.stem || '打开练习题后,这里显示当前题目。';
shadow.getElementById('qb-current-bank').textContent = name;
}
function renderAuto() {
if (!shadow) return;
const state = autoAnswer.getState();
shadow.getElementById('qb-auto-enabled').checked = state.enabled;
shadow.getElementById('qb-auto-status').textContent = state.message || '自动答题尚未开启。可先点击“答当前题”。';
shadow.getElementById('qb-auto-count').textContent = `本页已选择 ${state.answeredCount || 0} 题 · 已跳过 ${state.skippedCount || 0} 题`;
shadow.querySelector('[data-action="auto-answer-current"]').disabled = Boolean(state.busy);
}
function renderResults() {
if (!resultBox) return;
resultBox.replaceChildren();
const pool = scopedRecords(searchScope);
const matches = query.trim() ? QBCore.search(pool, query, { limit: 50, field: searchField }) : [];
const searchName = scopeName(searchScope);
shadow.getElementById('qb-query-state').textContent = query.trim() ? `${searchName} · ${pool.length} 题 · 显示 ${matches.length} 个结果${matches.length === 50 ? '(最多50条,请细化关键词)' : ''}` : `${searchName} · 可输入题干、选项或标准答案关键词`;
if (!matches.length) {
const empty = node('div', 'empty');
empty.append(node('strong', '', query.trim() ? '没有找到匹配题目' : '输入文字,查找正确答案'), node('p', '', query.trim() ? '可切换到全部题库、更换关键词,或导入其他题库。' : '支持输入、粘贴或划选文字;按回车或点搜索查询。多个关键词可用空格分隔。'));
resultBox.append(empty);
return;
}
const current = currentQuestion();
matches.forEach(({ record, match, matchedField }) => {
const card = node('article', 'result');
const meta = node('div', 'meta');
const statusText = record.answerStatus === 'confirmed' ? '已确认' : record.answerStatus === 'conflict' ? '答案冲突' : '待补答案';
const matchLabel = matchedField === 'options' ? '选项匹配' : matchedField === 'answer' ? '答案匹配' : ({ exact: '题干一致', partial: '文字匹配', similar: '相似候选' })[match] || '候选';
meta.append(node('span', `badge ${record.answerStatus}`, statusText), node('span', '', `${({ single: '单选', multiple: '多选', judge: '判断' })[record.type] || '题目'} · ${matchLabel}`));
card.append(meta, node('p', 'stem', record.stem));
const answer = node('div', `answer ${record.answerStatus !== 'confirmed' ? 'pending' : ''}`);
answer.append(node('span', 'answer-label', '正确答案'));
if (record.answerStatus === 'confirmed') {
const texts = record.answerTexts || [];
answer.append(node('strong', '', texts.length ? texts.join(';') : '标准答案缺少文字,需核验选项'));
const mapped = current ? QBCore.mapAnswers(record, current) : null;
if (mapped?.status === 'matched' && mapped.labels?.length) answer.append(node('span', 'mapping', `当前页面对应:${mapped.labels.join('、')}`));
else answer.append(node('span', 'mapping', '当前页面选项未确认,请按答案内容核对。'));
} else {
answer.append(node('strong', '', record.answerStatus === 'conflict' ? '存在不同标准答案,需要核验' : '尚未取得标准答案'));
if (record.answerStatus === 'conflict' && record.conflicts?.length) answer.append(node('span', 'mapping', '冲突记录已保留在 JSON / CSV 备份中。'));
}
card.append(answer);
if (matchedField === 'options') {
const details = node('details', 'explanation');
details.open = true;
details.append(node('summary', '', '题目选项'));
record.options.forEach(option => details.append(node('p', '', `${option.label}. ${option.text}`)));
card.append(details);
}
if (record.explanation) {
const details = node('details', 'explanation');
details.append(node('summary', '', '查看解析'), node('p', '', record.explanation));
card.append(details);
}
card.append(node('div', 'source', `${record.bankName || '未命名题库'}${record.questionId ? ` · ID ${record.questionId}` : ''}`));
resultBox.append(card);
});
}
function runSearch(text) {
query = String(text || '').trim().slice(0, 4000);
if (shadow) shadow.getElementById('qb-search').value = query;
renderResults();
}
function download(contents, filename, mime) {
const blob = new Blob([contents], { type: mime });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url; anchor.download = filename;
(document.body || document.documentElement).append(anchor);
anchor.click(); anchor.remove();
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
function safeFilename(value) {
let name = String(value || '未命名题库').replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_').replace(/\s+/g, ' ').trim().replace(/[. ]+$/, '');
name = Array.from(name).slice(0, 110).join('');
if (!name || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name)) name = `题库_${name || '未命名'}`;
return name;
}
function exportFilename(name, count, format, date = new Date()) {
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(date).map(part => [part.type, part.value]));
return `${safeFilename(name)}_${count}题_${parts.year}${parts.month}${parts.day}.${format}`;
}
function exportData(format) {
const selected = scopedRecords();
if (!selected.length) return tell('所选范围还没有题目,请先采集或导入。');
const date = new Date();
const bankName = scopeName();
const filename = exportFilename(bankName, selected.length, format, date);
if (format === 'csv') download(QBCore.toCSV(selected), filename, 'text/csv;charset=utf-8');
else download(JSON.stringify({ schemaVersion: 1, exportedAt: date.toISOString(), bankName, records: selected }, null, 2), filename, 'application/json;charset=utf-8');
tell(`已导出「${bankName}」${selected.length} 道题,其中 ${selected.filter(record => record.answerStatus === 'confirmed').length} 道有已确认答案。`);
}
function exportCleanupBackup() {
const backup = storage.get(BACKUP_KEY, null);
if (!backup || !Array.isArray(backup.records)) return tell('没有需要恢复的整理前备份。');
download(JSON.stringify(backup, null, 2), exportFilename('题库整理前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8');
tell(`已导出整理前的 ${backup.records.length} 条原始记录。`);
}
function exportOptionRepairBackup() {
const backup = storage.get(OPTION_REPAIR_BACKUP_KEY, null);
if (!backup || !Array.isArray(backup.records)) return tell('没有答案误报修复前的备份。');
download(JSON.stringify(backup, null, 2), exportFilename('答案误报修复前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8');
tell(`已导出答案误报修复前的 ${backup.records.length} 条原始记录。`);
}
function exportImageUpgradeBackup() {
const backup = imageUpgradeBackup();
if (!backup) return tell('没有图片补全前的备份。');
download(JSON.stringify(backup, null, 2), exportFilename('图片补全前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8');
tell(`已导出图片补全前的 ${backup.records.length} 条原始记录。`);
}
async function parseImportFile(file) {
if (file.size > 20 * 1024 * 1024) throw new Error('文件超过 20 MB,请拆分后导入');
const text = (await file.text()).replace(/^\uFEFF/, '');
let incoming;
if (/\.json$/i.test(file.name) || /^[\s]*[\[{]/.test(text)) {
const payload = JSON.parse(text);
if (!Array.isArray(payload) && payload?.schemaVersion !== 1) throw new Error('不支持这个 JSON 备份版本');
const source = Array.isArray(payload) ? payload : payload.records;
if (!Array.isArray(source) || source.length > 50000) throw new Error('JSON 应包含不超过 50000 道题的 records 数组');
incoming = source.map(record => {
if (!record || typeof record !== 'object' || typeof record.stem !== 'string' || !record.stem.trim()) throw new Error('JSON 题目必须包含非空 stem 题干');
if (!['single', 'multiple', 'judge', 'unknown'].includes(record.type)) throw new Error('JSON 中存在不支持的题型');
if (record.answerStatus && !['confirmed', 'missing', 'conflict'].includes(record.answerStatus)) throw new Error('JSON 中存在不支持的答案状态');
return QBCore.normalizeRecord(record);
});
} else incoming = QBCore.fromCSV(text);
if (!incoming.length) throw new Error('文件中没有可导入的题目');
const fallbackName = String(file.name).replace(/\.(csv|json)$/i, '').trim() || '导入题库';
return incoming.map(record => QBCore.normalizeRecord({ ...record, bankName: !record.bankName || record.bankName === '未命名题库' ? fallbackName : record.bankName }));
}
async function importFiles(files) {
if (!files.length) return;
if (loadError) throw new Error('现有本地题库异常,为保留原数据已停止写入。请先从油猴存储备份原数据');
if (files.reduce((sum, file) => sum + file.size, 0) > 50 * 1024 * 1024) throw new Error('本次文件合计超过 50 MB,请分批导入');
const incoming = [];
// Parse every file before changing the library. A bad file cancels this batch.
for (const file of files) {
try { incoming.push(...await parseImportFile(file)); }
catch (error) { throw new Error(`「${file.name}」:${error.message}`); }
if (incoming.length > 50000) throw new Error('本次导入超过 50000 条记录,请分批导入');
}
const before = records.length;
const expanded = QBCore.upsert(records, repairAnswerConflicts(removeBackedUpImagePredecessors(incoming, records)).records);
const compacted = QBCore.compactRecords(expanded);
if (compacted.removed > 0) ensureCleanupBackup(expanded);
const merged = repairAnswerConflicts(compacted.records).records;
records = merged;
recordsDirty = true;
persistRecords();
refreshScope(); renderStats(); renderCurrent(); renderResults();
const bankCount = new Set(incoming.map(bankKey)).size;
if (!storageError) tell(`已导入 ${files.length} 个文件、${bankCount} 个题库,共 ${incoming.length} 条记录,新增 ${Math.max(0, records.length - before)} 道题。各题库名称已分别保留。`);
}
const css = `
:host{all:initial;position:fixed;right:12px;bottom:12px;z-index:2147483646;color:#555;font:12px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;pointer-events:none;color-scheme:light}
*,*:before,*:after{box-sizing:border-box}[hidden]{display:none!important}button,input,select{font:inherit}button{cursor:pointer}button:disabled{cursor:default;color:#aaa}button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:1px solid #999;outline-offset:1px}
#qb-panel{pointer-events:auto;width:300px;max-width:calc(100vw - 24px);max-height:min(440px,calc(100dvh - 24px));display:flex;flex-direction:column;border:1px solid #e7e7e7;border-radius:5px;background:#fff;box-shadow:0 1px 5px #00000009;overflow:hidden}
.header{background:#fff;color:#555;padding:6px 9px;display:flex;align-items:center;justify-content:space-between;gap:6px;flex-shrink:0;border-bottom:1px solid #eee}.brand{font-size:12px;font-weight:500}.subtitle{display:none}.tools{display:flex;gap:5px}.tools button{border:0;color:#888;background:#fff;padding:2px 4px;font-size:10px;white-space:nowrap}.tools button:hover{color:#444;background:#f7f7f7}
.body{overflow:auto;overscroll-behavior:contain;min-height:0;scrollbar-width:thin;scrollbar-color:#ddd #fff}.collapsed .body{display:none}.context{padding:6px 9px;background:#fff;border-bottom:1px solid #eee}.context strong{display:block;color:#555;font-size:11px;font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.context-count{font-size:10px;color:#999;margin-top:2px;line-height:1.35}
.tabs{display:flex;padding:0 6px;background:#fff;border-bottom:1px solid #eee}.tabs button{flex:1;padding:6px 2px;background:#fff;border:0;border-bottom:1px solid transparent;color:#999;font-size:11px;white-space:nowrap}.tabs button.active{border-bottom-color:#888;color:#555}.view{padding:8px}
.search-label{display:flex;justify-content:space-between;align-items:center;font-size:10px;color:#888;margin-bottom:5px}.text-button{border:0;background:#fff;color:#999;font-size:10px;padding:1px 3px}.text-button:hover{color:#555}.search-wrap{display:flex;gap:5px}.search-wrap input{width:100%;min-width:0;border:1px solid #ddd;border-radius:3px;padding:5px 6px;background:#fff;color:#555;font-size:11px}.btn{border:1px solid #ddd;border-radius:3px;padding:5px 8px;background:#fff;color:#666;font-size:11px;white-space:nowrap}.btn.primary{background:#fff;border-color:#ccc;color:#555}.btn:hover,.btn.primary:hover{background:#f7f7f7}.btn.small{padding:3px 6px;font-size:10px}
.search-filters{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(0,1fr);gap:5px;margin-top:6px}.search-filters label{min-width:0;color:#999;font-size:10px}.search-filters select{display:block;width:100%;min-width:0;border:1px solid #e2e2e2;background:#fff;padding:3px 2px;margin-top:2px;border-radius:3px;color:#777;font-size:10px}.hint{font-size:10px;color:#999;margin:5px 0 8px;line-height:1.4}
.empty{padding:14px 8px;text-align:center;color:#999;border:1px dashed #e8e8e8;border-radius:3px;background:#fff}.empty strong{color:#888;font-size:12px;font-weight:400}.empty p{font-size:10px;line-height:1.6;margin:5px 0 0}.result{border:1px solid #eee;border-radius:3px;background:#fff;padding:8px;margin-bottom:6px}.meta{display:flex;align-items:center;justify-content:space-between;gap:5px;font-size:10px;color:#999}.badge{padding:0 3px;border:1px solid #e4e4e4;border-radius:2px;background:#fff;color:#888}.badge.confirmed,.badge.missing,.badge.conflict{background:#fff;color:#777}.badge.conflict{font-weight:600}.stem{font-size:11px;line-height:1.65;margin:6px 0;white-space:pre-wrap;overflow-wrap:anywhere}.answer,.answer.pending{border-radius:2px;background:#fff;border-left:2px solid #ddd;padding:3px 7px;display:flex;flex-direction:column;gap:2px;overflow-wrap:anywhere}.answer-label{font-size:10px;color:#999}.answer strong{color:#555;font-size:13px;line-height:1.5;white-space:pre-wrap}.answer.pending strong{color:#777;font-size:11px}.mapping{font-size:10px;color:#999}.source{font-size:9px;color:#aaa;margin-top:6px;overflow-wrap:anywhere}.explanation{margin-top:5px;color:#888;font-size:10px}.explanation summary{cursor:pointer}.explanation p{white-space:pre-wrap;overflow-wrap:anywhere}
.section{padding:8px;border:1px solid #eee;background:#fff;border-radius:3px;margin-bottom:7px}.section h3{font-size:11px;font-weight:500;margin:0 0 5px;color:#555}.section p{font-size:10px;color:#999;margin:4px 0;line-height:1.55;overflow-wrap:anywhere}.row{display:flex;align-items:center;gap:5px;flex-wrap:wrap}.row.spread{justify-content:space-between}.scope{display:block;font-size:10px;color:#999;margin-bottom:5px}.scope select{margin-top:3px;width:100%;padding:4px 3px;border:1px solid #ddd;border-radius:3px;background:#fff;color:#666;font-size:11px}.export-name{font-size:10px!important;color:#777!important;font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:3px 0 5px!important}.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:3px;margin-bottom:6px}.stat{background:#fff;border:0;border-radius:0;padding:3px 0;text-align:center}.stat strong{display:block;font-size:16px;color:#666;font-weight:400}.stat span{font-size:9px;color:#aaa}.filename{font-size:9px!important;word-break:break-all;color:#aaa!important;line-height:1.4!important}.state,.state.paused{font-size:9px;color:#999;background:#fff;border-radius:2px;border:1px solid #eee;padding:0 3px}#qb-current-title{font-size:10px;color:#888;margin:5px 0;max-height:31px;overflow:auto;white-space:pre-wrap}.muted-bank{display:none}.bank-list{max-height:75px;overflow:auto;margin-top:5px}.bank-row{display:flex;align-items:flex-start;justify-content:space-between;gap:6px;border-top:1px solid #f1f1f1;padding:4px 0;font-size:10px;color:#999}.bank-row span{overflow-wrap:anywhere}.bank-row strong{flex-shrink:0;color:#888;font-weight:400}
.check{display:flex;gap:6px;align-items:flex-start;margin:7px 0;color:#666;font-size:11px}.check input{accent-color:#888;margin-top:2px}.check small{display:block;font-size:10px;color:#aaa;margin-top:2px}.kbd{background:#fff;border:1px solid #eee;border-radius:2px;padding:1px 4px;font-family:monospace;font-size:10px;color:#777}.footer{padding:5px 9px;font-size:9px;color:#aaa;background:#fff;overflow-wrap:anywhere;line-height:1.4;border-top:1px solid #eee;max-height:38px;overflow:auto}#qb-status[data-error="true"]{color:#777;font-weight:500}.footline{display:none}#qb-launcher{display:none!important}
@media(max-width:340px){:host{right:8px;bottom:8px}#qb-panel{max-width:calc(100vw - 16px);max-height:min(440px,calc(100dvh - 16px))}}
`;
function mount() {
if (mounted || document.getElementById('huayi-qb-root')) return;
activateKnownPage();
root = node('div'); root.id = 'huayi-qb-root';
shadow = root.attachShadow({ mode: 'open' });
const style = node('style'); style.textContent = css;
shadow.append(style);
const ui = node('div');
ui.innerHTML = `
<section id="qb-panel" role="region" aria-label="华谊题库助手">
<header class="header"><div><div class="brand">题库助手</div><div class="subtitle">HUAYI · 本地练习资料</div></div><div class="tools"><button type="button" data-action="collapse" title="收起或展开内容" aria-expanded="true">收起</button><button type="button" data-action="hide" title="隐藏面板,Alt + Shift + Q 恢复">隐藏</button></div></header>
<div class="body">
<div class="context"><strong id="qb-context-name">正在识别题库…</strong><div class="context-count"><span id="qb-context-count"></span><span id="qb-page-total"></span></div></div>
<nav class="tabs" role="tablist" aria-label="助手功能"><button type="button" data-tab="search" role="tab" aria-controls="qb-view-search">文字搜索</button><button type="button" data-tab="library" role="tab" aria-controls="qb-view-library">题库 / 导出</button><button type="button" data-tab="auto" role="tab" aria-controls="qb-view-auto">自动答题</button><button type="button" data-tab="settings" role="tab" aria-controls="qb-view-settings">设置</button></nav>
<div class="view" id="qb-view-search" data-view="search" role="tabpanel"><div class="search-label"><label for="qb-search">输入或粘贴文字查答案</label><button type="button" class="text-button" data-action="clear-search">清空</button></div><div class="search-wrap"><input id="qb-search" type="search" maxlength="4000" placeholder="题干、选项或答案关键词…" aria-label="文字搜索"><button type="button" class="btn primary" data-action="search">搜索</button></div><div class="search-filters"><label>搜索题库<select id="qb-search-scope" aria-label="搜索题库"><option value="__current__">当前题库</option><option value="">全部题库</option></select></label><label>搜索内容<select id="qb-search-field" aria-label="搜索内容"><option value="all">全部文字</option><option value="stem">仅题干</option><option value="options">仅选项</option><option value="answer">仅标准答案</option></select></label></div><p class="hint" id="qb-query-state"></p><div id="qb-results" aria-live="polite"></div></div>
<div class="view" id="qb-view-library" data-view="library" role="tabpanel" hidden>
<div class="section"><h3>统计与导出范围</h3><label class="scope">选择题库<select id="qb-scope" aria-label="题库范围"><option value="__current__">当前题库</option><option value="">全部题库</option></select></label><p class="export-name" id="qb-export-name"></p><div class="stats"><div class="stat"><strong id="qb-stat-total">0</strong><span>范围内题数</span></div><div class="stat"><strong id="qb-stat-confirmed">0</strong><span>答案确认</span></div><div class="stat"><strong id="qb-stat-missing">0</strong><span>待补答案</span></div><div class="stat"><strong id="qb-stat-conflicts">0</strong><span>答案冲突</span></div></div><div class="row"><button type="button" class="btn primary" data-action="export-csv">导出 CSV</button><button type="button" class="btn" data-action="export-json">备份 JSON</button></div><p class="filename" id="qb-export-preview"></p></div>
<div class="section"><div class="row spread"><h3>当前练习</h3><span class="state" id="qb-capture-state">采集中</span></div><p id="qb-current-bank" class="muted-bank"></p><div id="qb-current-title"></div><div class="row"><button type="button" class="btn small primary" data-action="capture-current">采集当前题</button><button type="button" class="btn small" data-action="toggle-capture">暂停采集</button></div></div>
<div class="section"><div class="row spread"><h3>已保存题库</h3><button type="button" class="btn small" data-action="import">导入多个题库</button></div><input id="qb-import-file" type="file" accept=".csv,.json,text/csv,application/json" multiple hidden><p id="qb-library-summary"></p><div class="bank-list" id="qb-bank-list"></div></div>
</div>
<div class="view" id="qb-view-auto" data-view="auto" role="tabpanel" hidden>
<div class="section"><h3>自动选择正确答案</h3><p>根据本地已确认答案核对题干、题型和完整选项,按当前顺序选择。没有答案、答案冲突或无法唯一匹配的题目会跳过。</p><button type="button" class="btn primary" data-action="auto-answer-current">答当前题</button><label class="check"><input id="qb-auto-enabled" type="checkbox"><span>连续自动选择<small>开启后随题目变化自动选择,仅本页有效;刷新或打开新页面后关闭。</small></span></label><p id="qb-auto-status" role="status"></p><p id="qb-auto-count"></p><p>选择后按页面流程检查、提交或换题;部分网站会自动保存作答。</p></div>
<div class="section"><h3>适配范围</h3><p>支持华谊练习页面,以及使用标准单选框/复选框和明确题干容器的网页。网页未识别时可继续使用文字搜索。</p></div>
</div>
<div class="view" id="qb-view-settings" data-view="settings" role="tabpanel" hidden>
<div class="section"><h3>面板显示</h3><p>每次打开或刷新网页都默认完全隐藏,不显示悬浮按钮。</p><p>显示 / 隐藏快捷键:<kbd class="kbd">Shift + Alt + Q</kbd></p><p>面板隐藏时,划选文字仍可更新检索内容,面板保持隐藏。</p><button type="button" class="btn small" data-action="hide">立即隐藏</button></div>
<div class="section"><h3>选择与复制</h3><label class="check"><input id="qb-copy-enabled" type="checkbox"><span>恢复题目区域的选择复制<small>换题后自动适配,关闭后恢复原有处理。</small></span></label></div>
<div class="section"><h3>答案规则</h3><p>按标准答案内容保存,再对应当前页面的选项位置。没有标准答案就显示待补;不同答案会保留冲突。所有资料保存在当前浏览器,建议定期导出备份。</p></div>
<div class="section"><h3>重复记录整理</h3><p>重复加载的题目按批次对应合并,保留原题库中的同文题和全部来源 ID。首次整理前会保留原始备份。</p><button type="button" class="btn small" data-action="export-dedup-backup">导出整理前备份</button></div>
<div class="section"><h3>答案误报恢复</h3><p>只恢复旧版因多个正确选项文字相同产生、且全部冲突证据一致的误报。修复前记录独立备份,真实答案冲突继续保留。</p><button type="button" class="btn small" data-action="export-option-repair-backup">导出误报修复前备份</button></div>
<div class="section"><h3>题目图片补全</h3><p>新完整题库批次与页面题数一致、位置和文字答案均能唯一核对时,补全旧版遗漏的图片,并保留原始题目 ID 与独立备份。</p><button type="button" class="btn small" data-action="export-image-upgrade-backup">导出图片补全前备份</button></div>
</div>
<footer class="footer"><div class="footline"><span class="dot"></span><span>本地题库 · 标准答案优先</span></div><div id="qb-status" role="status"></div></footer>
</div>
</section><button type="button" id="qb-launcher" data-action="restore" title="显示题库助手 · Alt + Shift + Q" aria-label="显示题库助手" hidden>题库</button>`;
shadow.append(...ui.childNodes);
(document.body || document.documentElement).append(root);
panel = shadow.getElementById('qb-panel'); launcher = shadow.getElementById('qb-launcher');
resultBox = shadow.getElementById('qb-results'); statusBox = shadow.getElementById('qb-status');
mounted = true;
shadow.getElementById('qb-copy-enabled').checked = Boolean(config.copyEnabled);
shadow.addEventListener('click', event => {
const button = event.target.closest('button');
if (!button) return;
if (button.dataset.tab) return selectTab(button.dataset.tab);
try {
switch (button.dataset.action) {
case 'hide': setMode('hidden'); break;
case 'restore': setMode('open'); break;
case 'collapse': setMode(config.mode === 'collapsed' ? 'open' : 'collapsed'); break;
case 'search': runSearch(shadow.getElementById('qb-search').value); break;
case 'clear-search': clearTimeout(searchTimer); runSearch(''); shadow.getElementById('qb-search').focus(); break;
case 'auto-answer-current': Promise.resolve(autoAnswer.answerCurrent()).catch(error => tell(`自动选择失败:${error.message}`, true)); break;
case 'capture-current': {
const captured = adapter.captureCurrent();
renderCurrent();
if (captured) tell(`已检查「${captured.bankName || '当前题库'}」的当前题目;尚未取得的标准答案会标为待补。`);
break;
}
case 'toggle-capture':
config.collecting = !config.collecting;
config.collecting ? adapter.start() : adapter.stop();
adapter.setCopyEnabled(Boolean(config.copyEnabled));
persistConfig(); renderStats();
tell(config.collecting ? '已继续采集。此前未观察到的响应,可在重新进入练习后采集。' : '已暂停自动采集,已保存的题库仍可检索和导出。');
break;
case 'export-csv': exportData('csv'); break;
case 'export-json': exportData('json'); break;
case 'export-dedup-backup': exportCleanupBackup(); break;
case 'export-option-repair-backup': exportOptionRepairBackup(); break;
case 'export-image-upgrade-backup': exportImageUpgradeBackup(); break;
case 'import': shadow.getElementById('qb-import-file').click(); break;
}
} catch (error) { tell(`操作失败:${error.message}`, true); }
});
shadow.getElementById('qb-search').addEventListener('input', event => {
clearTimeout(searchTimer); const value = event.target.value;
searchTimer = setTimeout(() => runSearch(value), 180);
});
shadow.getElementById('qb-search').addEventListener('keydown', event => {
if (event.key === 'Enter') { clearTimeout(searchTimer); runSearch(event.target.value); }
});
shadow.getElementById('qb-scope').addEventListener('change', event => { scope = event.target.value; renderStats(); });
shadow.getElementById('qb-search-scope').addEventListener('change', event => { searchScope = event.target.value; renderResults(); });
shadow.getElementById('qb-search-field').addEventListener('change', event => { searchField = event.target.value; renderResults(); });
shadow.getElementById('qb-auto-enabled').addEventListener('change', event => { autoAnswer.setEnabled(event.target.checked); renderAuto(); });
shadow.getElementById('qb-copy-enabled').addEventListener('change', event => { config.copyEnabled = event.target.checked; adapter.setCopyEnabled(config.copyEnabled); persistConfig(); });
shadow.getElementById('qb-import-file').addEventListener('change', async event => {
const input = event.currentTarget;
if (importing) return;
const files = Array.from(input.files || []);
importing = true;
const button = shadow.querySelector('[data-action="import"]');
button.disabled = true;
try { await importFiles(files); }
catch (error) { tell(`导入失败,题库未替换:${error.message}`, true); }
finally { input.value = ''; importing = false; button.disabled = false; }
});
if (cleanupNotice) status = cleanupNotice;
refreshScope(); renderStats(); renderCurrent(); renderResults(); renderAuto(); renderStatus(); selectTab(config.tab); renderMode();
setInterval(() => { bankChanged(currentBank()); if (config.mode === 'open') renderCurrent(); }, 1000);
}
document.addEventListener('mouseup', event => {
if (event.composedPath().includes(root)) return;
const active = document.activeElement;
if (active && (active.matches('input,textarea,[contenteditable="true"]'))) return;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
const selected = window.getSelection()?.toString().trim();
if (selected && selected.length >= 2 && selected.length <= 4000) {
runSearch(selected);
if (shadow && config.tab !== 'search') selectTab('search');
}
}, 120);
});
document.addEventListener('keydown', event => {
if (event.altKey && event.shiftKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyQ' && !event.repeat) {
event.preventDefault(); setMode(config.mode === 'hidden' ? 'open' : 'hidden');
}
}, true);
window.addEventListener('pagehide', () => { autoAnswer.setEnabled(false); persistRecords(); });
window.addEventListener('pageshow', event => {
if (event.persisted) { autoAnswer.setEnabled(false); config.mode = 'hidden'; renderMode(); }
});
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('显示题库助手(Alt+Shift+Q)', () => setMode('open'));
GM_registerMenuCommand('隐藏题库助手', () => setMode('hidden'));
GM_registerMenuCommand('导出整理前原始题库备份', exportCleanupBackup);
GM_registerMenuCommand('导出答案误报修复前原始备份', exportOptionRepairBackup);
GM_registerMenuCommand('导出图片补全前原始备份', exportImageUpgradeBackup);
}
if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => {
if (!remote || !Array.isArray(value)) return;
try {
const retained = removeBackedUpImagePredecessors(value, records);
const incoming = repairAnswerConflicts(retained);
records = repairAnswerConflicts(QBCore.compactRecords(QBCore.upsert(records, incoming.records)).records).records;
if (incoming.repaired.length || retained.length < value.length) { recordsDirty = true; clearTimeout(saveTimer); saveTimer = setTimeout(persistRecords, 250); }
if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); }
}
catch (_) { /* Keep the current usable in-memory data if another tab has bad data. */ }
});
if (config.collecting) adapter.start();
adapter.setCopyEnabled(Boolean(config.copyEnabled));
if (recordsDirty && !loadError) saveTimer = setTimeout(persistRecords, 250);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount, { once: true });
else mount();
})();