feat: add local question bank reset with stale-tab isolation
This commit is contained in:
Vendored
+201
-40
@@ -1,7 +1,7 @@
|
||||
// ==UserScript==
|
||||
// @name 华谊题库助手 · CSV导出与划词检索
|
||||
// @namespace local.huayi.question-bank
|
||||
// @version 1.2.7
|
||||
// @version 1.2.8
|
||||
// @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。
|
||||
// @match *://*/*
|
||||
// @run-at document-start
|
||||
@@ -9,8 +9,10 @@
|
||||
// @grant unsafeWindow
|
||||
// @grant GM_getValue
|
||||
// @grant GM_setValue
|
||||
// @grant GM_deleteValue
|
||||
// @grant GM_registerMenuCommand
|
||||
// @grant GM_addValueChangeListener
|
||||
// @grant GM_removeValueChangeListener
|
||||
// ==/UserScript==
|
||||
|
||||
(function () {
|
||||
@@ -1777,7 +1779,15 @@ const QBAdapter = (() => {
|
||||
status('采集已暂停,已保存题库和选择复制功能仍可使用。');
|
||||
}
|
||||
|
||||
return { start, stop, captureCurrent, getCurrentQuestion, getCurrentBank, setCopyEnabled, isSupportedPage };
|
||||
function clearCache() {
|
||||
stop();
|
||||
known.clear(); knownContentIndex = null;
|
||||
banksById.clear(); banksByTask.clear(); knownSnapshotOrder.clear();
|
||||
currentBank = null; pendingSelection = ''; blockedForm = null; lastFormSnapshot = null;
|
||||
lastDOMSignature = '';
|
||||
}
|
||||
|
||||
return { start, stop, clearCache, captureCurrent, getCurrentQuestion, getCurrentBank, setCopyEnabled, isSupportedPage };
|
||||
}
|
||||
|
||||
return { create, extractQuestions, richText };
|
||||
@@ -2371,9 +2381,11 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
|
||||
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 JUDGE_EVIDENCE_BACKUP_KEY = 'huayi-qb.records.before-judge-evidence-repair.v1';
|
||||
const RESET_KEY = 'huayi-qb.library-reset.v1';
|
||||
const LIBRARY_KEYS = [RECORDS_KEY, BACKUP_KEY, OPTION_REPAIR_BACKUP_KEY, IMAGE_UPGRADE_BACKUP_KEY, JUDGE_EVIDENCE_BACKUP_KEY];
|
||||
const DEFAULTS = { mode: 'hidden', keepLauncher: false, copyEnabled: true, collecting: true, tab: 'search' };
|
||||
const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
|
||||
const storage = {
|
||||
const rawStorage = {
|
||||
get(key, fallback) {
|
||||
if (typeof GM_getValue === 'function') return GM_getValue(key, fallback);
|
||||
const raw = localStorage.getItem(key);
|
||||
@@ -2382,8 +2394,30 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
|
||||
set(key, value) {
|
||||
if (typeof GM_setValue === 'function') return GM_setValue(key, value);
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
},
|
||||
remove(key) {
|
||||
if (typeof GM_deleteValue === 'function') return GM_deleteValue(key);
|
||||
if (typeof GM_setValue === 'function') return GM_setValue(key, null);
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
};
|
||||
function readGeneration() {
|
||||
const marker = rawStorage.get(RESET_KEY, null);
|
||||
if (marker === null) return '';
|
||||
if (marker?.schemaVersion !== 1 || typeof marker.generation !== 'string' || !/^[a-zA-Z0-9-]{1,100}$/.test(marker.generation)) {
|
||||
throw new Error('题库重置标记异常,请使用“重置全部题库”重新初始化');
|
||||
}
|
||||
return marker.generation;
|
||||
}
|
||||
const scopedKey = (key, generation) => generation ? `${key}.reset.${generation}` : key;
|
||||
let libraryGeneration = '', generationError = '';
|
||||
try { libraryGeneration = readGeneration(); } catch (error) { generationError = error.message; }
|
||||
// After a reset, even a still-open pre-upgrade tab can only write to an
|
||||
// obsolete namespace. Never migrate those legacy values into the new library.
|
||||
const storage = {
|
||||
get: (key, fallback) => rawStorage.get(scopedKey(key, libraryGeneration), fallback),
|
||||
set: (key, value) => rawStorage.set(scopedKey(key, libraryGeneration), value)
|
||||
};
|
||||
let rawConfig;
|
||||
try { rawConfig = storage.get(CONFIG_KEY, {}); } catch (_) { rawConfig = {}; }
|
||||
const config = { ...DEFAULTS, ...(rawConfig && typeof rawConfig === 'object' ? rawConfig : {}) };
|
||||
@@ -2393,9 +2427,10 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
|
||||
// Every new page starts completely hidden, including upgrades from older settings.
|
||||
config.mode = 'hidden';
|
||||
config.keepLauncher = false;
|
||||
if (generationError) config.collecting = false;
|
||||
let records = [];
|
||||
let recordsDirty = false;
|
||||
let loadError = '';
|
||||
let loadError = generationError;
|
||||
let cleanupNotice = '';
|
||||
let storedRecordsText = '';
|
||||
let saveIdle = null;
|
||||
@@ -2506,46 +2541,52 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
|
||||
imageReplacementSignature(replacement, false) === item.replacementContentSignature))));
|
||||
});
|
||||
}
|
||||
try {
|
||||
const saved = storage.get(RECORDS_KEY, []);
|
||||
if (!Array.isArray(saved)) throw new Error('本地题库格式不正确');
|
||||
storedRecordsText = JSON.stringify(saved);
|
||||
const retained = removeBackedUpImagePredecessors(saved, saved);
|
||||
const judgeRepair = repairJudgeEvidence(retained);
|
||||
const repaired = repairAnswerConflicts(judgeRepair.records);
|
||||
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} 道重复选项文字造成的答案误报,修复前记录已备份。`;
|
||||
}
|
||||
if (judgeRepair.changed.length) {
|
||||
recordsDirty = true;
|
||||
cleanupNotice += `${cleanupNotice ? ' ' : ''}已核验 ${judgeRepair.changed.length} 条判断题答案,缺少标准答案依据的记录已改为待补,原值已独立备份。`;
|
||||
}
|
||||
} catch (error) { loadError = `本地题库读取失败:${error.message}。请先保留已有备份。`; }
|
||||
function loadRecords() {
|
||||
try {
|
||||
if (generationError) throw new Error(generationError);
|
||||
const saved = storage.get(RECORDS_KEY, []);
|
||||
if (!Array.isArray(saved)) throw new Error('本地题库格式不正确');
|
||||
storedRecordsText = JSON.stringify(saved);
|
||||
const retained = removeBackedUpImagePredecessors(saved, saved);
|
||||
const judgeRepair = repairJudgeEvidence(retained);
|
||||
const repaired = repairAnswerConflicts(judgeRepair.records);
|
||||
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} 道重复选项文字造成的答案误报,修复前记录已备份。`;
|
||||
}
|
||||
if (judgeRepair.changed.length) {
|
||||
recordsDirty = true;
|
||||
cleanupNotice += `${cleanupNotice ? ' ' : ''}已核验 ${judgeRepair.changed.length} 条判断题答案,缺少标准答案依据的记录已改为待补,原值已独立备份。`;
|
||||
}
|
||||
} catch (error) { loadError = `本地题库读取失败:${error.message}。请先保留已有备份。`; }
|
||||
}
|
||||
loadRecords();
|
||||
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 activeBank = null, importing = false, importAttempt = 0;
|
||||
let status = loadError || cleanupNotice || '可输入文字或划选题干检索。';
|
||||
let storageError = false;
|
||||
let mounted = false;
|
||||
let recordsListener = null;
|
||||
let collector = null, collectorInitError = '';
|
||||
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,
|
||||
const getLiveRecords = () => { synchronizeReset(); return records; };
|
||||
const autoAnswer = QBAuto.create({ window: pageWindow, document, core: QBCore, getRecords: getLiveRecords, getQuestion: currentQuestion, getBank: currentBank,
|
||||
onStatus: message => { tell(message); }, onChange: () => { renderAuto(); } });
|
||||
try {
|
||||
if (typeof QBCollector !== 'undefined') collector = QBCollector.create({ window: pageWindow, document, core: QBCore,
|
||||
getRecords: () => records, getQuestion: currentQuestion, getBank: currentBank,
|
||||
getRecords: getLiveRecords, getQuestion: currentQuestion, getBank: currentBank,
|
||||
isCollecting: () => networkEnabled && config.collecting && !loadError && !storageError && !importing,
|
||||
onChange: () => { if (mounted && config.mode === 'open' && config.tab === 'auto') renderAuto(); } });
|
||||
} catch (error) { collectorInitError = `练习补齐模块加载失败:${error.message}`; }
|
||||
@@ -2614,14 +2655,96 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
|
||||
const message = typeof value === 'string' ? value : value && value.message;
|
||||
if (message && !storageError) { status = message; renderStatus(); }
|
||||
}
|
||||
function cancelPendingSave() {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
if (saveIdle !== null) { window.cancelIdleCallback?.(saveIdle); saveIdle = null; }
|
||||
}
|
||||
function adoptGeneration(generation) {
|
||||
cancelPendingSave();
|
||||
stopCollector('题库已重置,练习补齐已停止。');
|
||||
autoAnswer.setEnabled(false);
|
||||
config.collecting = false;
|
||||
if (adapter.clearCache) adapter.clearCache();
|
||||
else adapter.stop();
|
||||
libraryGeneration = generation;
|
||||
generationError = ''; loadError = ''; storageError = false; cleanupNotice = '';
|
||||
records = []; recordsDirty = false; storedRecordsText = '';
|
||||
importAttempt++; importing = false;
|
||||
clearTimeout(searchTimer);
|
||||
query = ''; scope = networkEnabled ? '__current__' : ''; searchScope = scope; searchField = 'all';
|
||||
if (shadow) {
|
||||
shadow.getElementById('qb-search').value = '';
|
||||
shadow.getElementById('qb-search-field').value = 'all';
|
||||
shadow.getElementById('qb-import-file').value = '';
|
||||
shadow.querySelector('[data-action="import"]').disabled = false;
|
||||
}
|
||||
loadRecords();
|
||||
watchRecords();
|
||||
if (recordsDirty && !loadError) schedulePersist();
|
||||
tell(loadError || '题库已重置并同步,采集与自动答题已关闭。请重新导入可信题库。', Boolean(loadError));
|
||||
renderVisible(); renderAuto();
|
||||
}
|
||||
function synchronizeReset() {
|
||||
try {
|
||||
const generation = readGeneration();
|
||||
if (generation === libraryGeneration && !generationError) return true;
|
||||
adoptGeneration(generation);
|
||||
} catch (error) {
|
||||
generationError = error.message;
|
||||
loadError = `题库重置状态读取失败:${error.message}`;
|
||||
cancelPendingSave();
|
||||
config.collecting = false;
|
||||
stopCollector('题库重置状态异常,练习补齐已停止。');
|
||||
autoAnswer.setEnabled(false); adapter.stop();
|
||||
tell(loadError, true);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function resetLibrary() {
|
||||
synchronizeReset();
|
||||
if (!window.confirm(`确定重置全部本地题库吗?\n\n将清空全部 ${records.length} 道题、答案、冲突及历史修复备份,并暂停采集和自动答题。\n如需保留,请先取消并导出 JSON 备份。重置后可重新导入可信题库。`)) return;
|
||||
const previousGeneration = libraryGeneration;
|
||||
const generation = window.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
const nextConfig = { ...config, collecting: false };
|
||||
const staged = [scopedKey(RECORDS_KEY, generation), scopedKey(CONFIG_KEY, generation)];
|
||||
try {
|
||||
// Prepare and verify an empty library before publishing the reset marker.
|
||||
// Failure here leaves the existing library and all its backups untouched.
|
||||
rawStorage.set(staged[0], []);
|
||||
rawStorage.set(staged[1], nextConfig);
|
||||
if (JSON.stringify(rawStorage.get(staged[0], null)) !== '[]' ||
|
||||
JSON.stringify(rawStorage.get(staged[1], null)) !== JSON.stringify(nextConfig)) throw new Error('空题库未能完整保存');
|
||||
rawStorage.set(RESET_KEY, { schemaVersion: 1, generation, resetAt: new Date().toISOString() });
|
||||
if (readGeneration() !== generation) throw new Error('重置状态未能保存或已被另一标签页更新');
|
||||
} catch (error) {
|
||||
// A storage provider may throw after writing; follow the published marker
|
||||
// in that case, rather than letting this tab keep saving pre-reset data.
|
||||
const unchanged = synchronizeReset();
|
||||
if (unchanged) for (const key of staged) { try { rawStorage.remove(key); } catch (_) {} }
|
||||
tell(`重置未完成:${error.message}。请检查油猴存储后重试。`, true);
|
||||
return;
|
||||
}
|
||||
adoptGeneration(generation);
|
||||
const obsolete = new Set([...LIBRARY_KEYS, CONFIG_KEY].flatMap(key => [key, scopedKey(key, previousGeneration)]));
|
||||
let cleanupFailed = false;
|
||||
for (const key of obsolete) {
|
||||
try {
|
||||
rawStorage.remove(key);
|
||||
if (rawStorage.get(key, null) !== null) cleanupFailed = true;
|
||||
} catch (_) { cleanupFailed = true; }
|
||||
}
|
||||
tell(cleanupFailed ? '题库已重置;部分旧存储清理失败,已隔离且不会再读取。请检查油猴存储。' :
|
||||
'已清空全部题库及历史修复备份,采集与自动答题已关闭。请重新导入可信题库。', cleanupFailed);
|
||||
}
|
||||
function persistConfig() {
|
||||
if (!synchronizeReset()) return;
|
||||
try { storage.set(CONFIG_KEY, { ...config }); }
|
||||
catch (error) { tell(`设置保存失败:${error.message}`, true); }
|
||||
}
|
||||
function persistRecords() {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
if (saveIdle !== null) { window.cancelIdleCallback?.(saveIdle); saveIdle = null; }
|
||||
if (!synchronizeReset()) return;
|
||||
cancelPendingSave();
|
||||
if (loadError || !recordsDirty) return;
|
||||
try {
|
||||
// Merge other tabs before writing; GM change events also refresh an open panel.
|
||||
@@ -2676,6 +2799,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
|
||||
JSON.stringify(record.options.map(option => flat(option.text)).sort());
|
||||
}
|
||||
function receive(incoming, context = {}) {
|
||||
if (!synchronizeReset()) return;
|
||||
if (!Array.isArray(incoming) || !incoming.length || loadError) return;
|
||||
try {
|
||||
incoming = incoming.filter(record => !redundantDOMObservation(record));
|
||||
@@ -2971,6 +3095,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
|
||||
});
|
||||
}
|
||||
function runSearch(text) {
|
||||
if (!synchronizeReset()) return;
|
||||
query = String(text || '').trim().slice(0, 4000);
|
||||
if (shadow) shadow.getElementById('qb-search').value = query;
|
||||
renderResults();
|
||||
@@ -2995,6 +3120,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
|
||||
return `${safeFilename(name)}_${count}题_${parts.year}${parts.month}${parts.day}.${format}`;
|
||||
}
|
||||
function exportData(format) {
|
||||
if (!synchronizeReset()) return;
|
||||
const selected = scopedRecords();
|
||||
if (!selected.length) return tell('所选范围还没有题目,请先采集或导入。');
|
||||
const date = new Date();
|
||||
@@ -3005,24 +3131,28 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
|
||||
tell(`已导出「${bankName}」${selected.length} 道题,其中 ${selected.filter(record => record.answerStatus === 'confirmed').length} 道有已确认答案。`);
|
||||
}
|
||||
function exportCleanupBackup() {
|
||||
if (!synchronizeReset()) return;
|
||||
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() {
|
||||
if (!synchronizeReset()) return;
|
||||
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() {
|
||||
if (!synchronizeReset()) return;
|
||||
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} 条原始记录。`);
|
||||
}
|
||||
function exportJudgeEvidenceBackup() {
|
||||
if (!synchronizeReset()) return;
|
||||
const backup = storage.get(JUDGE_EVIDENCE_BACKUP_KEY, null);
|
||||
if (!backup || backup.schemaVersion !== 1 || !Array.isArray(backup.records)) return tell('没有判断题答案核验前的备份。');
|
||||
download(JSON.stringify(backup, null, 2), exportFilename('判断题答案核验前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8');
|
||||
@@ -3050,6 +3180,8 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
|
||||
}
|
||||
async function importFiles(files) {
|
||||
if (!files.length) return;
|
||||
if (!synchronizeReset()) throw new Error('题库已重置,请重新选择导入文件');
|
||||
const importGeneration = libraryGeneration;
|
||||
if (loadError) throw new Error('现有本地题库异常,为保留原数据已停止写入。请先从油猴存储备份原数据');
|
||||
if (files.reduce((sum, file) => sum + file.size, 0) > 50 * 1024 * 1024) throw new Error('本次文件合计超过 50 MB,请分批导入');
|
||||
const incoming = [];
|
||||
@@ -3057,6 +3189,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
|
||||
for (const file of files) {
|
||||
try { incoming.push(...await parseImportFile(file)); }
|
||||
catch (error) { throw new Error(`「${file.name}」:${error.message}`); }
|
||||
if (!synchronizeReset() || importGeneration !== libraryGeneration) throw new Error('题库已重置,本次导入已取消,请重新选择文件');
|
||||
if (incoming.length > 50000) throw new Error('本次导入超过 50000 条记录,请分批导入');
|
||||
}
|
||||
const before = records.length;
|
||||
@@ -3105,7 +3238,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
|
||||
<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 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><button type="button" class="btn small" data-action="reset-library">重置全部题库</button></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>
|
||||
@@ -3113,6 +3246,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
|
||||
<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>清空全部题库、答案、冲突及历史修复备份,同时暂停采集并关闭自动答题。重置前如需保留,请先导出 JSON;重置后重新导入可信题库。</p><button type="button" class="btn small" data-action="reset-library">重置全部题库</button></div>
|
||||
<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>
|
||||
@@ -3164,6 +3298,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
|
||||
case 'export-option-repair-backup': exportOptionRepairBackup(); break;
|
||||
case 'export-image-upgrade-backup': exportImageUpgradeBackup(); break;
|
||||
case 'export-judge-evidence-backup': exportJudgeEvidenceBackup(); break;
|
||||
case 'reset-library': resetLibrary(); break;
|
||||
case 'import': shadow.getElementById('qb-import-file').click(); break;
|
||||
}
|
||||
} catch (error) { tell(`操作失败:${error.message}`, true); }
|
||||
@@ -3188,13 +3323,14 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
|
||||
const input = event.currentTarget;
|
||||
if (importing) return;
|
||||
const files = Array.from(input.files || []);
|
||||
const attempt = ++importAttempt;
|
||||
importing = true;
|
||||
stopCollector('开始导入题库,练习补齐已停止。');
|
||||
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; }
|
||||
catch (error) { if (attempt === importAttempt) tell(`导入失败,题库未替换:${error.message}`, true); }
|
||||
finally { if (attempt === importAttempt) { input.value = ''; importing = false; button.disabled = false; } }
|
||||
});
|
||||
if (cleanupNotice) status = cleanupNotice;
|
||||
refreshScope(); renderStats(); renderCurrent(); renderResults(); renderAuto(); renderStatus(); selectTab(config.tab); renderMode();
|
||||
@@ -3223,18 +3359,22 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
|
||||
}, true);
|
||||
window.addEventListener('pagehide', () => { stopCollector('页面已离开,练习补齐已停止。'); autoAnswer.setEnabled(false); persistRecords(); });
|
||||
window.addEventListener('pageshow', event => {
|
||||
synchronizeReset();
|
||||
if (event.persisted) { stopCollector('页面已恢复,练习补齐保持关闭。'); autoAnswer.setEnabled(false); config.mode = 'hidden'; renderMode(); }
|
||||
});
|
||||
window.addEventListener('focus', synchronizeReset);
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) synchronizeReset(); });
|
||||
if (typeof GM_registerMenuCommand === 'function') {
|
||||
GM_registerMenuCommand('显示题库助手(Alt+Shift+Q)', () => setMode('open'));
|
||||
GM_registerMenuCommand('隐藏题库助手', () => setMode('hidden'));
|
||||
GM_registerMenuCommand('重置全部本地题库', resetLibrary);
|
||||
GM_registerMenuCommand('导出整理前原始题库备份', exportCleanupBackup);
|
||||
GM_registerMenuCommand('导出答案误报修复前原始备份', exportOptionRepairBackup);
|
||||
GM_registerMenuCommand('导出内容补全前原始备份', exportImageUpgradeBackup);
|
||||
GM_registerMenuCommand('导出判断题答案核验前原始备份', exportJudgeEvidenceBackup);
|
||||
}
|
||||
if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => {
|
||||
if (!remote || !Array.isArray(value)) return;
|
||||
function mergeRemoteRecords(value) {
|
||||
if (!Array.isArray(value)) return;
|
||||
try {
|
||||
const valueText = JSON.stringify(value);
|
||||
if (valueText === storedRecordsText) return;
|
||||
@@ -3247,6 +3387,27 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
|
||||
renderVisible();
|
||||
}
|
||||
catch (error) { tell(`跨标签题库未合并:${error.message}。当前内存记录仍保留。`, true); }
|
||||
}
|
||||
function watchRecords() {
|
||||
if (recordsListener !== null && typeof GM_removeValueChangeListener === 'function') GM_removeValueChangeListener(recordsListener);
|
||||
if (typeof GM_addValueChangeListener !== 'function') return;
|
||||
const generation = libraryGeneration;
|
||||
recordsListener = GM_addValueChangeListener(scopedKey(RECORDS_KEY, generation), (_key, _old, value, remote) => {
|
||||
if (remote && synchronizeReset() && generation === libraryGeneration) mergeRemoteRecords(value);
|
||||
});
|
||||
}
|
||||
if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RESET_KEY, (_key, _old, _value, remote) => {
|
||||
if (remote) synchronizeReset();
|
||||
});
|
||||
watchRecords();
|
||||
// The fallback uses origin-local storage; GM storage remains shared across
|
||||
// supported sites. No polling is needed for either reset notification path.
|
||||
window.addEventListener('storage', event => {
|
||||
if (event.key === RESET_KEY) synchronizeReset();
|
||||
else if (event.key === scopedKey(RECORDS_KEY, libraryGeneration) && synchronizeReset()) {
|
||||
try { mergeRemoteRecords(event.newValue === null ? [] : JSON.parse(event.newValue)); }
|
||||
catch (error) { tell(`跨标签题库读取失败:${error.message}`, true); }
|
||||
}
|
||||
});
|
||||
if (config.collecting) adapter.start();
|
||||
adapter.setCopyEnabled(Boolean(config.copyEnabled));
|
||||
|
||||
Reference in New Issue
Block a user