feat: add Huayi question bank assistant v1.1.0

This commit is contained in:
suanyuit-cyber
2026-10-29 18:08:28 +08:00
commit 6b04243428
13 changed files with 4348 additions and 0 deletions
+504
View File
@@ -0,0 +1,504 @@
// 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 DEFAULTS = { mode: 'open', keepLauncher: true, 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', 'settings'].includes(config.tab)) config.tab = 'search';
['keepLauncher', 'copyEnabled', 'collecting'].forEach(key => { if (typeof config[key] !== 'boolean') config[key] = DEFAULTS[key]; });
let records = [];
let loadError = '';
try {
const saved = storage.get(RECORDS_KEY, []);
if (!Array.isArray(saved)) throw new Error('本地题库格式不正确');
records = QBCore.upsert([], saved);
} catch (error) { loadError = `本地题库读取失败:${error.message}。请先保留已有备份。`; }
let root, shadow, panel, launcher, resultBox, statusBox;
let saveTimer, searchTimer, query = '', scope = '__current__', searchScope = '__current__', searchField = 'all';
let activeBank = null, importing = false;
let status = loadError || '准备就绪,可输入文字搜索,也可选中题干检索。';
let storageError = false;
let mounted = false;
const adapter = QBAdapter.create({ window: pageWindow, document, onQuestions: receive, onStatus: adapterStatus, onBankChange: bankChanged });
function bankChanged(bank) {
if (!bank || (!bank.bankName && !bank.bankId)) 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) 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('现有题库结构异常,已停止覆盖');
records = QBCore.upsert(stored, records);
storage.set(RECORDS_KEY, records);
storageError = false;
} catch (error) {
storageError = true;
tell(`保存失败:${error.message}。当前内存数据仍可导出,请及时备份。`, true);
}
}
function receive(incoming) {
if (!Array.isArray(incoming) || !incoming.length || loadError) return;
try {
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;
});
records = QBCore.upsert(records, incoming);
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 = config.mode !== 'hidden' || !config.keepLauncher;
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 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 = `${bank?.loading ? '切换中 · ' : ''}本库已采集 ${pool.length} 题 · 已确认 ${pool.filter(record => record.answerStatus === 'confirmed').length} 题`;
shadow.getElementById('qb-current-title').textContent = bank?.loading ? '题库切换中,等待当前题目加载。' : current?.stem || '打开练习题后,这里显示当前题目。';
shadow.getElementById('qb-current-bank').textContent = name;
}
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} 道有已确认答案。`);
}
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 merged = QBCore.upsert(records, incoming);
records = merged;
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:18px;bottom:18px;z-index:2147483646;color:#17283d;font:14px/1.5 -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:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:3px solid #63d3c7;outline-offset:2px}
#qb-panel{pointer-events:auto;width:390px;max-width:calc(100vw - 24px);max-height:calc(100dvh - 36px);display:flex;flex-direction:column;border:1px solid #dbe4ed;border-radius:18px;background:#f7f9fc;box-shadow:0 16px 60px #0d24362c;overflow:hidden}
.header{background:#152a40;color:#fff;padding:15px 16px;display:flex;align-items:center;justify-content:space-between;gap:8px;flex-shrink:0}.brand{font-size:16px;font-weight:700;letter-spacing:.5px}.subtitle{font-size:11px;color:#bed0df;margin-top:2px}.tools{display:flex;gap:5px}.tools button{border:1px solid #526578;color:#e8f4ff;background:transparent;padding:5px 8px;border-radius:7px;font-size:12px;white-space:nowrap}.tools button:hover{background:#294359}
.body{overflow:auto;overscroll-behavior:contain;min-height:0}.collapsed .body{display:none}.tabs{display:flex;gap:4px;padding:10px 12px 0;background:#fff;border-bottom:1px solid #e7edf3}.tabs button{flex:1;padding:9px 4px;background:none;border:0;border-bottom:3px solid transparent;color:#6b7b8f;font-weight:600}.tabs button.active{border-bottom-color:#008d82;color:#00786e}
.view{padding:14px}.search-wrap{display:flex;gap:7px}.search-wrap input{width:100%;min-width:0;border:1px solid #cdd9e4;border-radius:9px;padding:10px 11px;background:#fff;color:#17283d}.btn{border:1px solid #ccd8e4;border-radius:8px;padding:8px 11px;background:#fff;color:#284059;white-space:nowrap}.btn:hover{background:#edf5f8}.btn.primary{background:#00877d;border-color:#00877d;color:white}.btn.primary:hover{background:#006f66}.btn.small{padding:5px 9px;font-size:12px}
.context{padding:10px 15px;background:#eaf2f6;border-bottom:1px solid #dce6ee}.context-label{color:#718599;font-size:10px}.context strong{display:block;color:#25445b;font-size:12px;line-height:1.6;overflow-wrap:anywhere}.context-count{font-size:10px;color:#587487;margin-top:3px}.search-label{display:flex;justify-content:space-between;align-items:center;font-size:12px;color:#3e5d74;margin-bottom:8px}.text-button{border:0;background:none;color:#6a7f92;font-size:11px;padding:3px}.text-button:hover{color:#007b70}.search-filters{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(0,1fr);gap:8px;margin-top:10px}.search-filters label{min-width:0;color:#72869a;font-size:10px}.search-filters select{display:block;width:100%;min-width:0;border:1px solid #d1dce6;background:#fff;padding:7px 4px;margin-top:3px;border-radius:7px;color:#37556c;font-size:11px}.export-name{font-size:13px!important;color:#244e65!important;font-weight:600;overflow-wrap:anywhere;margin:4px 0 10px!important}.filename{font-size:10px!important;word-break:break-all;color:#758c9d!important}.bank-list{max-height:130px;overflow:auto;margin-top:8px}.bank-row{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;border-top:1px solid #edf1f5;padding:7px 0;font-size:11px;color:#657f94}.bank-row span{overflow-wrap:anywhere}.bank-row strong{flex-shrink:0;color:#486b80;font-weight:500}
.hint{font-size:11px;color:#738298;margin:8px 0 12px}.empty{padding:24px 14px;text-align:center;color:#597087;border:1px dashed #cedce5;border-radius:12px;background:#fff}.empty strong{color:#29475f;font-size:15px}.empty p{font-size:12px;line-height:1.8;margin:8px 0 0}.result{border:1px solid #e0e7ee;border-radius:12px;background:#fff;padding:13px;margin-bottom:10px}.meta{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11px;color:#8090a2}.badge{padding:2px 7px;border-radius:5px;background:#edf1f6;color:#536b82}.badge.confirmed{background:#e3f6ef;color:#137e55}.badge.missing{background:#fff4d8;color:#966819}.badge.conflict{background:#ffe9e7;color:#b74740}.stem{font-size:13px;line-height:1.75;margin:10px 0;white-space:pre-wrap;overflow-wrap:anywhere}.answer{border-radius:9px;background:#eaf7f3;padding:10px 11px;display:flex;flex-direction:column;gap:4px;overflow-wrap:anywhere}.answer.pending{background:#fff6e7}.answer-label{font-size:10px;letter-spacing:.5px;color:#517467}.answer strong{color:#086550;font-size:16px;line-height:1.6;white-space:pre-wrap}.answer.pending strong{color:#936426;font-size:14px}.mapping{font-size:11px;color:#567367}.source{font-size:10px;color:#8291a1;margin-top:10px;overflow-wrap:anywhere}.explanation{margin-top:9px;color:#617387;font-size:12px}.explanation summary{cursor:pointer}.explanation p{white-space:pre-wrap;overflow-wrap:anywhere}
.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin-bottom:12px}.stat{background:#fff;border:1px solid #e3eaf1;border-radius:9px;padding:9px 3px;text-align:center}.stat strong{display:block;font-size:21px;color:#234257}.stat span{font-size:10px;color:#7a8ca1}.section{padding:13px;border:1px solid #e0e7ee;background:#fff;border-radius:12px;margin-bottom:12px}.section h3{font-size:13px;margin:0 0 9px}.section p{font-size:12px;color:#718297;margin:5px 0;line-height:1.8;overflow-wrap:anywhere}.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.row.spread{justify-content:space-between}.state{font-size:11px;color:#088574;background:#e7f6ee;border-radius:9px;padding:2px 8px}.state.paused{color:#8c672e;background:#fff3dd}#qb-current-title{font-size:12px;color:#344f66;margin:8px 0;max-height:80px;overflow:auto;white-space:pre-wrap}.scope{display:block;font-size:12px;color:#62778b;margin-bottom:10px}.scope select{margin-top:5px;width:100%;padding:8px;border:1px solid #cfdae5;border-radius:8px;background:#fff;color:#263f57}.check{display:flex;gap:9px;align-items:flex-start;margin:12px 0;color:#304e67;font-size:13px}.check input{accent-color:#00877d;margin-top:4px}.check small{display:block;font-size:11px;color:#7b8da0;margin-top:3px}.kbd{background:#edf1f5;border:1px solid #dce3eb;border-radius:4px;padding:2px 6px;font-family:monospace;font-size:12px;color:#374e62}.footer{padding:9px 14px 12px;font-size:11px;color:#718297;background:#eff4f8;overflow-wrap:anywhere;line-height:1.6;border-top:1px solid #e1e8ef}#qb-status[data-error="true"]{color:#b0473c}.footline{display:flex;gap:6px;align-items:center;margin-bottom:5px}.dot{width:5px;height:5px;background:#079a87;border-radius:50%}#qb-launcher{pointer-events:auto;border:1px solid #1b776f;background:#153f42;color:#fff;box-shadow:0 6px 22px #173b3c33;border-radius:50%;width:54px;height:54px;font-size:13px;font-weight:600}#qb-launcher:hover{background:#086f65}
@media(max-width:480px){:host{right:12px;bottom:12px}#qb-panel{max-height:calc(100dvh - 24px)}.view{padding:12px}.header{padding:12px}.brand{font-size:15px}}
`;
function mount() {
if (mounted || document.getElementById('huayi-qb-root')) return;
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"><span class="context-label">当前题库</span><strong id="qb-context-name">正在识别题库…</strong><div class="context-count" id="qb-context-count"></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="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"></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><p>记录正常加载的练习题与标准答案。答案尚未返回的题目会标为待补,可在后续练习中继续补齐。</p></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>可一次选择多个 CSV / JSON,分别保留题库名称。无名称的文件使用文件名作为题库名。</p><p id="qb-library-summary"></p><div class="bank-list" id="qb-bank-list"></div></div>
</div>
<div class="view" id="qb-view-settings" data-view="settings" role="tabpanel" hidden>
<div class="section"><h3>面板显示</h3><p>右上角可收起或隐藏,刷新后保持原状态。</p><label class="check"><input id="qb-keep-launcher" type="checkbox"><span>隐藏时保留悬浮按钮<small>取消后可完全隐藏,用快捷键或油猴菜单恢复。</small></span></label><p>显示 / 隐藏快捷键:<kbd class="kbd">Alt + Shift + 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>
<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-keep-launcher').checked = Boolean(config.keepLauncher);
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 '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 '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-keep-launcher').addEventListener('change', event => { config.keepLauncher = event.target.checked; persistConfig(); renderMode(); });
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; }
});
refreshScope(); renderStats(); renderCurrent(); renderResults(); 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', persistRecords);
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('显示题库助手(Alt+Shift+Q)', () => setMode('open'));
GM_registerMenuCommand('隐藏题库助手', () => setMode('hidden'));
GM_registerMenuCommand('完全隐藏题库助手(快捷键或菜单恢复)', () => { config.keepLauncher = false; if (shadow) shadow.getElementById('qb-keep-launcher').checked = false; setMode('hidden'); });
}
if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => {
if (!remote || !Array.isArray(value)) return;
try { records = QBCore.upsert(records, value); 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 (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount, { once: true });
else mount();
})();