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