feat: add local question bank reset with stale-tab isolation
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
# 华谊题库助手
|
||||
|
||||
支持在普通 HTTP / HTTPS 网页中通过快捷键打开的本地题库油猴脚本:文字搜索、划词检索、自动选择已确认答案,并保留华谊练习平台的按库采集、CSV 导出与多题库导入。当前版本 **1.2.7**。
|
||||
支持在普通 HTTP / HTTPS 网页中通过快捷键打开的本地题库油猴脚本:文字搜索、划词检索、自动选择已确认答案,并保留华谊练习平台的按库采集、CSV 导出与多题库导入。当前版本 **1.2.8**。
|
||||
|
||||
1.2.8 新增“重置全部题库”,用于清除旧版或错误导入的数据。确认后清空本地全部题库、答案、冲突和四类历史修复备份,取消正在进行的导入及待保存任务,停止自动选择和判断题补齐,并暂停采集。新的题库存储与重置前隔离,旧标签页或旧版脚本再次写入的数据不会混回。升级本身保留已有资料,需要清空时再手动重置。
|
||||
|
||||
1.2.7 新增默认关闭的“补齐当前判断题库”。手动启动后会通过正常练习控件逐题作答,等待网站标准反馈与同题可信入库记录一致后再进入下一题;这会改变练习记录。只支持本站500题或232题的正常判断练习,不支持普通网页、正式考试或错题重做。该功能与普通自动选择互斥,详见下方使用说明。仓库答案在实际采集及逐条严格核验后更新,不能从脚本版本或练习进度推定完整。
|
||||
|
||||
@@ -104,6 +106,18 @@
|
||||
|
||||
导入成功后可在“已保存题库”查看各库名称和题数,再在搜索 / 导出下拉框选择相应题库。导入不会改变页面当前正在练习的题库,也不会自动将导出范围切为全部。
|
||||
|
||||
## 清除旧数据并重新导入
|
||||
|
||||
1. 将油猴中的原脚本完整更新为 [`dist/huayi-question-bank.user.js`](dist/huayi-question-bank.user.js)(1.2.8),保存并刷新网页。
|
||||
2. 按 **Shift + Alt + Q** 显示面板。在“题库 / 导出”的“已保存题库”下方,或“设置”顶部,点击 **重置全部题库**;油猴菜单也有同名入口。
|
||||
3. 确认提示会说明清除范围。如需保留旧资料,先取消并点击“备份 JSON”。确认重置后,本地题数归零,四类历史修复备份一起清除,正在读取的导入文件也不会在稍后写回。
|
||||
4. 点击“导入多个题库”,选择仓库中已核验的 [`合并题库JSON`](题库/华谊集团化工总控工(高级)-合并题库.json) 或 [`合并CSV`](题库/华谊集团化工总控工(高级)-合并题库.csv),导入后应为 **1884题**。只需选择其中一种;也可导入所需的分库文件。
|
||||
5. 重置后采集保持暂停,刷新后也不会自动恢复。需要重新采集时,再点击“继续采集”;连续自动选择和判断题补齐仍须手动开启。
|
||||
|
||||
重置作用于当前浏览器中同一油猴脚本保存的全部题库,不受搜索或导出范围限制。已打开的新版本标签页会同步清空内存,并关闭自动操作;漏收通知的页面在下次读取、保存或恢复前检查重置状态。旧版页面继续写入旧存储时,新版也不会再读取那一份数据。复制开关和面板偏好保留,网页账号的练习记录以及已下载的文件不受影响。
|
||||
|
||||
重置需要先验证空题库和重置标记写入成功,再清理原数据。准备或标记保存失败时保留原题库并显示错误;如果仅旧存储清理失败,会明确提示旧数据已隔离。原题库或修复备份损坏时,仍可使用重置功能重新初始化。
|
||||
|
||||
## 隐藏、收起与恢复
|
||||
|
||||
| 操作 | 效果 | 恢复方式 |
|
||||
|
||||
@@ -4,7 +4,7 @@ const root = __dirname;
|
||||
const header = `// ==UserScript==
|
||||
// @name 华谊题库助手 · CSV导出与划词检索
|
||||
// @namespace local.huayi.question-bank
|
||||
// @version 1.2.7
|
||||
// @version 1.2.8
|
||||
// @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。
|
||||
// @match *://*/*
|
||||
// @run-at document-start
|
||||
@@ -12,8 +12,10 @@ const header = `// ==UserScript==
|
||||
// @grant unsafeWindow
|
||||
// @grant GM_getValue
|
||||
// @grant GM_setValue
|
||||
// @grant GM_deleteValue
|
||||
// @grant GM_registerMenuCommand
|
||||
// @grant GM_addValueChangeListener
|
||||
// @grant GM_removeValueChangeListener
|
||||
// ==/UserScript==
|
||||
`;
|
||||
const sources = ['src/core.js', 'src/adapter.js', 'src/autofill.js', 'src/collector.js', 'src/app.js'];
|
||||
|
||||
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));
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "huayi-question-bank",
|
||||
"version": "1.2.7",
|
||||
"version": "1.2.8",
|
||||
"private": true,
|
||||
"description": "华谊练习题库本地采集、CSV 导出与划词检索油猴脚本",
|
||||
"scripts": {
|
||||
@@ -8,7 +8,7 @@
|
||||
"merge:banks": "node scripts/merge-question-banks.cjs",
|
||||
"select:snapshots": "node scripts/select-bank-snapshots.cjs",
|
||||
"test": "node --test tests/core.test.cjs tests/adapter.test.cjs tests/autofill.test.cjs tests/collector.test.cjs tests/select-bank-snapshots.test.cjs",
|
||||
"test:browser": "node --test tests/integration.test.cjs",
|
||||
"test:browser": "node --test tests/integration.test.cjs tests/reset-browser.test.cjs",
|
||||
"test:performance": "node --test tests/performance-browser.test.cjs",
|
||||
"check": "node build.cjs && node --check dist/huayi-question-bank.user.js"
|
||||
}
|
||||
|
||||
+9
-1
@@ -817,7 +817,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 };
|
||||
|
||||
+189
-38
@@ -6,9 +6,11 @@
|
||||
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);
|
||||
@@ -17,8 +19,30 @@
|
||||
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 : {}) };
|
||||
@@ -28,9 +52,10 @@
|
||||
// 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;
|
||||
@@ -141,46 +166,52 @@
|
||||
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}`; }
|
||||
@@ -249,14 +280,96 @@
|
||||
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.
|
||||
@@ -311,6 +424,7 @@
|
||||
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));
|
||||
@@ -606,6 +720,7 @@
|
||||
});
|
||||
}
|
||||
function runSearch(text) {
|
||||
if (!synchronizeReset()) return;
|
||||
query = String(text || '').trim().slice(0, 4000);
|
||||
if (shadow) shadow.getElementById('qb-search').value = query;
|
||||
renderResults();
|
||||
@@ -630,6 +745,7 @@
|
||||
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();
|
||||
@@ -640,24 +756,28 @@
|
||||
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');
|
||||
@@ -685,6 +805,8 @@
|
||||
}
|
||||
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 = [];
|
||||
@@ -692,6 +814,7 @@
|
||||
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;
|
||||
@@ -740,7 +863,7 @@
|
||||
<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>
|
||||
@@ -748,6 +871,7 @@
|
||||
<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>
|
||||
@@ -799,6 +923,7 @@
|
||||
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); }
|
||||
@@ -823,13 +948,14 @@
|
||||
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();
|
||||
@@ -858,18 +984,22 @@
|
||||
}, 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;
|
||||
@@ -882,6 +1012,27 @@
|
||||
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));
|
||||
|
||||
@@ -833,6 +833,26 @@ test('DOM standard feedback carries bound or known trusted answer evidence', asy
|
||||
page.adapter.stop();
|
||||
});
|
||||
|
||||
test('library reset clears cached standards and rejects pre-reset answer receipts after capture resumes', async () => {
|
||||
const page = bankPage(async () => new Response(JSON.stringify({ listQues: [single()] }), { headers: { 'content-type': 'application/json' } }));
|
||||
page.adapter.start();
|
||||
await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush();
|
||||
page.state.feedback = [{ heading: '正确答案', answer: 'B' }];
|
||||
assert.equal(page.adapter.getCurrentQuestion().answerStatus, 'confirmed');
|
||||
const pending = new page.win.XMLHttpRequest();
|
||||
pending.open('PUT', '/upm/bank-a/users/u1/answer');
|
||||
pending.send(JSON.stringify({ answers: [{ quesId: 'q1', answer: ['a1'] }] }));
|
||||
page.adapter.clearCache();
|
||||
page.received.length = 0;
|
||||
assert.equal(page.adapter.getCurrentQuestion().answerStatus, 'missing', '重置后裸DOM反馈不能继承旧缓存标准');
|
||||
page.adapter.start();
|
||||
const count = page.received.length;
|
||||
pending.respond([{ answers: ['a2'] }]);
|
||||
assert.equal(page.received.length, count, '重置前未完成的回执不得在重新采集后写回');
|
||||
assert.equal(page.adapter.getCurrentQuestion().answerStatus, 'missing');
|
||||
page.adapter.stop();
|
||||
});
|
||||
|
||||
test('an older answer receipt cannot attach its answer to a newer same-id full snapshot', () => {
|
||||
const page = fakePage();
|
||||
page.adapter.start();
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
'use strict';
|
||||
|
||||
// Exercise the built userscript with real Chromium and normal DOM controls.
|
||||
// All requests are fulfilled locally; no account or production API is used.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const RECORDS = 'huayi-qb.records.v1', CONFIG = 'huayi-qb.config.v1', RESET = 'huayi-qb.library-reset.v1';
|
||||
const BACKUPS = ['before-dedup', 'before-duplicate-option-repair', 'before-image-upgrade', 'before-judge-evidence-repair']
|
||||
.map(name => `huayi-qb.records.${name}.v1`);
|
||||
const seed = [{ bankId: 'reset-bank', bankName: '旧导入题库', questionId: 'old-question', type: 'single', stem: '旧题库中的错误答案记录。',
|
||||
options: [{ id: 'a', label: 'A', text: '旧选项一' }, { id: 'b', label: 'B', text: '旧选项二' }],
|
||||
correctOptionIds: ['a'], answerTexts: ['旧选项一'], answerStatus: 'confirmed' }];
|
||||
|
||||
function installStorage(seed, fallback) {
|
||||
window.unsafeWindow = window;
|
||||
window.__menus = [];
|
||||
window.GM_registerMenuCommand = (name, action) => window.__menus.push({ name, action });
|
||||
if (!fallback) {
|
||||
window.GM_getValue = (key, defaultValue) => {
|
||||
const value = localStorage.getItem(key);
|
||||
return value === null ? defaultValue : JSON.parse(value);
|
||||
};
|
||||
window.GM_setValue = (key, value) => {
|
||||
const blocked = window.__blockWrite;
|
||||
if (blocked && (blocked === key || (blocked === 'staging' && key.includes('.reset.')))) {
|
||||
if (window.__silentFailure) return;
|
||||
throw new Error('fixture storage denied');
|
||||
}
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
};
|
||||
window.GM_deleteValue = key => {
|
||||
if (window.__blockCleanup) throw new Error('fixture cleanup denied');
|
||||
localStorage.removeItem(key);
|
||||
};
|
||||
const listeners = new Map(); let nextId = 0;
|
||||
window.GM_addValueChangeListener = (key, action) => { const id = ++nextId; listeners.set(id, { key, action }); return id; };
|
||||
window.GM_removeValueChangeListener = id => listeners.delete(id);
|
||||
window.addEventListener('storage', event => {
|
||||
if (window.__muteNotifications) return;
|
||||
for (const entry of [...listeners.values()]) if (entry.key === event.key) {
|
||||
entry.action(event.key, event.oldValue === null ? null : JSON.parse(event.oldValue), event.newValue === null ? null : JSON.parse(event.newValue), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Simulate a suspended tab that misses both GM and native storage events.
|
||||
window.addEventListener('storage', event => { if (window.__muteNotifications) event.stopImmediatePropagation(); }, true);
|
||||
if (!localStorage.getItem('__resetFixtureSeeded')) {
|
||||
localStorage.setItem('__resetFixtureSeeded', '1');
|
||||
localStorage.setItem('huayi-qb.records.v1', JSON.stringify(seed));
|
||||
localStorage.setItem('huayi-qb.config.v1', JSON.stringify({ collecting: false, copyEnabled: false }));
|
||||
for (const suffix of ['before-dedup', 'before-duplicate-option-repair', 'before-image-upgrade', 'before-judge-evidence-repair']) {
|
||||
localStorage.setItem(`huayi-qb.records.${suffix}.v1`, JSON.stringify({ schemaVersion: 1, records: seed, migrations: [] }));
|
||||
}
|
||||
localStorage.setItem('unrelated-site-data', 'preserve me');
|
||||
}
|
||||
}
|
||||
function activeRecords(page) {
|
||||
return page.evaluate(({ RECORDS, RESET }) => {
|
||||
const marker = JSON.parse(localStorage.getItem(RESET) || 'null');
|
||||
return JSON.parse(localStorage.getItem(marker ? `${RECORDS}.reset.${marker.generation}` : RECORDS) || '[]');
|
||||
}, { RECORDS, RESET });
|
||||
}
|
||||
async function show(page, tab = 'library') {
|
||||
if (!await page.locator('#qb-panel').isVisible()) await page.keyboard.press('Alt+Shift+Q');
|
||||
await page.locator(`[data-tab="${tab}"]`).click();
|
||||
}
|
||||
async function reset(page, accept = true, tab = 'library') {
|
||||
await show(page, tab);
|
||||
const dialog = page.waitForEvent('dialog');
|
||||
const clicking = page.locator(`#qb-view-${tab} [data-action="reset-library"]`).click();
|
||||
const prompt = await dialog;
|
||||
assert.match(prompt.message(), /全部.*道题/);
|
||||
await (accept ? prompt.accept() : prompt.dismiss());
|
||||
await clicking;
|
||||
}
|
||||
async function importJSON(page, records, name = 'verified.json') {
|
||||
await show(page);
|
||||
await page.locator('#qb-import-file').setInputFiles({ name, mimeType: 'application/json', buffer: Buffer.from(JSON.stringify({ schemaVersion: 1, records })) });
|
||||
await page.waitForFunction(() => document.querySelector('#huayi-qb-root').shadowRoot.querySelector('#qb-import-file').files.length === 0);
|
||||
}
|
||||
|
||||
test('重置题库:清空、旧标签隔离、异步取消和写入失败保护', { timeout: 90000 }, async t => {
|
||||
let playwright;
|
||||
try { playwright = require(process.env.QB_PLAYWRIGHT_MODULE || 'playwright'); }
|
||||
catch { playwright = require(path.join(os.homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')); }
|
||||
const cached = path.join(os.homedir(), 'AppData/Local/ms-playwright/chromium_headless_shell-1228/chrome-headless-shell-win64/chrome-headless-shell.exe');
|
||||
const requested = process.env.QB_CHROMIUM_EXECUTABLE || process.env.QB_CHROMIUM_PATH;
|
||||
const browser = await playwright.chromium.launch({ headless: true, ...(requested || fs.existsSync(cached) ? { executablePath: requested || cached } : {}) });
|
||||
const script = fs.readFileSync(path.join(ROOT, 'dist/huayi-question-bank.user.js'), 'utf8');
|
||||
const errors = [];
|
||||
async function fixture(fallback = false) {
|
||||
const context = await browser.newContext();
|
||||
await context.route('**/*', route => route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><html><body><main>本地题库重置验收</main></body></html>' }));
|
||||
await context.addInitScript({ content: `(${installStorage.toString()})(${JSON.stringify(seed)},${fallback});\n${script}` });
|
||||
async function open() {
|
||||
const page = await context.newPage(); page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto('https://reset.example.test/');
|
||||
await page.locator('#huayi-qb-root').waitFor({ state: 'attached' });
|
||||
return page;
|
||||
}
|
||||
return { context, open, page: await open() };
|
||||
}
|
||||
try {
|
||||
await t.test('取消保持原库;确认清空题目、冲突和四类备份,保留其他网站数据与复制设置', async () => {
|
||||
const { context, page } = await fixture();
|
||||
try {
|
||||
const before = await activeRecords(page);
|
||||
await reset(page, false);
|
||||
assert.deepEqual(await activeRecords(page), before);
|
||||
assert.equal(await page.evaluate(key => localStorage.getItem(key), RESET), null);
|
||||
await show(page, 'auto');
|
||||
await page.locator('#qb-auto-enabled').check();
|
||||
await reset(page);
|
||||
assert.deepEqual(await activeRecords(page), []);
|
||||
assert.equal(await page.locator('#qb-stat-total').innerText(), '0');
|
||||
for (const key of [RECORDS, ...BACKUPS]) assert.equal(await page.evaluate(key => localStorage.getItem(key), key), null);
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem('unrelated-site-data')), 'preserve me');
|
||||
await show(page, 'auto');
|
||||
assert.equal(await page.locator('#qb-auto-enabled').isChecked(), false);
|
||||
await show(page, 'settings');
|
||||
assert.equal(await page.locator('#qb-copy-enabled').isChecked(), false);
|
||||
await page.reload(); await show(page);
|
||||
assert.deepEqual(await activeRecords(page), []);
|
||||
assert.equal(await page.locator('[data-action="toggle-capture"]').innerText(), '继续采集');
|
||||
assert.ok(await page.evaluate(() => window.__menus.some(entry => entry.name === '重置全部本地题库')));
|
||||
} finally { await context.close(); }
|
||||
});
|
||||
|
||||
await t.test('跨标签同步;漏通知的旧页在保存前清空,旧版写回与刷新均不能复活数据', async () => {
|
||||
const { context, page: a, open } = await fixture();
|
||||
try {
|
||||
const b = await open(), suspended = await open();
|
||||
await show(b); await show(suspended);
|
||||
await suspended.evaluate(() => { window.__muteNotifications = true; });
|
||||
await reset(a);
|
||||
await b.waitForFunction(() => document.querySelector('#huayi-qb-root').shadowRoot.querySelector('#qb-stat-total').textContent === '0');
|
||||
assert.equal(await suspended.locator('#qb-stat-total').innerText(), '1');
|
||||
await suspended.evaluate(() => window.dispatchEvent(new Event('pagehide')));
|
||||
assert.equal(await suspended.locator('#qb-stat-total').innerText(), '0');
|
||||
await a.evaluate(({ RECORDS, CONFIG, BACKUPS, seed }) => {
|
||||
localStorage.setItem(RECORDS, JSON.stringify(seed));
|
||||
localStorage.setItem(CONFIG, JSON.stringify({ collecting: true }));
|
||||
for (const key of BACKUPS) localStorage.setItem(key, JSON.stringify({ schemaVersion: 1, records: seed, migrations: [] }));
|
||||
}, { RECORDS, CONFIG, BACKUPS, seed });
|
||||
await a.reload(); await show(a);
|
||||
assert.deepEqual(await activeRecords(a), []);
|
||||
assert.equal(await a.locator('#qb-stat-total').innerText(), '0');
|
||||
assert.equal(await a.locator('[data-action="toggle-capture"]').innerText(), '继续采集');
|
||||
await show(a, 'settings');
|
||||
await a.locator('[data-action="export-judge-evidence-backup"]').click();
|
||||
assert.match(await a.locator('#qb-status').innerText(), /没有判断题/);
|
||||
} finally { await context.close(); }
|
||||
});
|
||||
|
||||
await t.test('重置取消正在读取的旧文件,新导入不受旧异步完成影响;1884题重新导入导出一致', async () => {
|
||||
const { context, page: a, open } = await fixture();
|
||||
try {
|
||||
const b = await open(); await show(b);
|
||||
await b.evaluate(() => {
|
||||
const original = File.prototype.text;
|
||||
File.prototype.text = function () {
|
||||
if (this.name !== 'delayed.json') return original.call(this);
|
||||
return new Promise(resolve => { window.__releaseImport = async () => resolve(await original.call(this)); });
|
||||
};
|
||||
});
|
||||
await b.locator('#qb-import-file').setInputFiles({ name: 'delayed.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify({ schemaVersion: 1, records: seed })) });
|
||||
await b.waitForFunction(() => typeof window.__releaseImport === 'function');
|
||||
await reset(a, true, 'settings');
|
||||
await b.waitForFunction(() => !document.querySelector('#huayi-qb-root').shadowRoot.querySelector('[data-action="import"]').disabled);
|
||||
const verified = JSON.parse(fs.readFileSync(path.join(ROOT, '题库/华谊集团化工总控工(高级)-合并题库.json'), 'utf8')).records;
|
||||
await importJSON(b, verified);
|
||||
await b.evaluate(() => window.__releaseImport());
|
||||
assert.equal((await activeRecords(b)).length, 1884);
|
||||
assert.equal((await activeRecords(b)).some(record => record.questionId === seed[0].questionId), false);
|
||||
const waiting = b.waitForEvent('download');
|
||||
await b.locator('[data-action="export-json"]').click();
|
||||
const download = await waiting;
|
||||
const exported = JSON.parse(fs.readFileSync(await download.path(), 'utf8'));
|
||||
assert.equal(exported.records.length, 1884);
|
||||
assert.ok(exported.records.every(record => record.answerStatus === 'confirmed'));
|
||||
const oldMarker = await b.evaluate(key => JSON.parse(localStorage.getItem(key)), RESET);
|
||||
await reset(b);
|
||||
assert.equal(await b.evaluate(({ RECORDS, generation }) => localStorage.getItem(`${RECORDS}.reset.${generation}`), { RECORDS, generation: oldMarker.generation }), null);
|
||||
assert.deepEqual(await activeRecords(b), []);
|
||||
} finally { await context.close(); }
|
||||
});
|
||||
|
||||
await t.test('空库写入失败、标记写入失败及静默失败都保留原题库和备份', async () => {
|
||||
for (const failure of [{ key: 'staging', silent: false }, { key: RESET, silent: false }, { key: RESET, silent: true }]) {
|
||||
const { context, page } = await fixture();
|
||||
try {
|
||||
const before = await activeRecords(page);
|
||||
await page.evaluate(failure => { window.__blockWrite = failure.key; window.__silentFailure = failure.silent; }, failure);
|
||||
await reset(page);
|
||||
assert.match(await page.locator('#qb-status').innerText(), /重置未完成/);
|
||||
assert.deepEqual(await activeRecords(page), before);
|
||||
assert.equal(await page.evaluate(key => localStorage.getItem(key), RESET), null);
|
||||
for (const key of BACKUPS) assert.ok(await page.evaluate(key => localStorage.getItem(key), key));
|
||||
} finally { await context.close(); }
|
||||
}
|
||||
});
|
||||
|
||||
await t.test('原存储损坏仍可重置;清理失败明确提示并隔离,localStorage回退同样有效', async () => {
|
||||
for (const fallback of [false, true]) {
|
||||
const { context, page } = await fixture(fallback);
|
||||
try {
|
||||
await page.evaluate(({ RECORDS, BACKUPS }) => {
|
||||
localStorage.setItem(RECORDS, '{broken');
|
||||
localStorage.setItem(BACKUPS[2], '{broken');
|
||||
}, { RECORDS, BACKUPS });
|
||||
await page.reload(); await show(page);
|
||||
if (!fallback) await page.evaluate(() => { window.__blockCleanup = true; });
|
||||
await reset(page);
|
||||
assert.deepEqual(await activeRecords(page), []);
|
||||
if (!fallback) assert.match(await page.locator('#qb-status').innerText(), /部分旧存储清理失败.*隔离/);
|
||||
await page.reload(); await show(page);
|
||||
assert.equal(await page.locator('#qb-stat-total').innerText(), '0');
|
||||
} finally { await context.close(); }
|
||||
}
|
||||
});
|
||||
assert.deepEqual(errors, []);
|
||||
} finally { await browser.close(); }
|
||||
});
|
||||
Reference in New Issue
Block a user