fix: deduplicate practice snapshots and compact white panel
This commit is contained in:
Vendored
+334
-41
@@ -1,7 +1,7 @@
|
||||
// ==UserScript==
|
||||
// @name 华谊题库助手 · CSV导出与划词检索
|
||||
// @namespace local.huayi.question-bank
|
||||
// @version 1.2.0
|
||||
// @version 1.2.1
|
||||
// @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。
|
||||
// @match *://*/*
|
||||
// @run-at document-start
|
||||
@@ -64,14 +64,18 @@ const QBCore = (() => {
|
||||
|
||||
function sourceInfo(value) {
|
||||
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
return { kind: clean(source.kind) || 'unknown', url: clean(source.url), observedAt: clean(source.observedAt) || new Date().toISOString() };
|
||||
const result = { kind: clean(source.kind) || 'unknown', url: clean(source.url), observedAt: clean(source.observedAt) || new Date().toISOString() };
|
||||
if (clean(source.batchId)) result.batchId = idText(source.batchId, '采集批次ID');
|
||||
return result;
|
||||
}
|
||||
|
||||
function recordKey(record) {
|
||||
const bank = record.bankId || record.bankName;
|
||||
if (record.questionId) return `id:${JSON.stringify([bank, record.questionId])}`;
|
||||
// Keep the complete signature, rather than a short hash whose collisions could merge answers.
|
||||
return `fp:${JSON.stringify([bank, record.type, normalizedStem(record.stem), sorted(optionContents(record))])}`;
|
||||
const fingerprint = [bank, record.type, normalizedStem(record.stem), sorted(optionContents(record))];
|
||||
if (record.contentOccurrence != null) fingerprint.push(record.contentOccurrence);
|
||||
return `fp:${JSON.stringify(fingerprint)}`;
|
||||
}
|
||||
|
||||
function normalizeRecord(input) {
|
||||
@@ -122,6 +126,13 @@ const QBCore = (() => {
|
||||
correctOptionIds: answerStatus === 'missing' ? [] : correctOptionIds, answerTexts, answerStatus,
|
||||
explanation: clean(input.explanation), source, updatedAt: clean(input.updatedAt) || source.observedAt, conflicts,
|
||||
};
|
||||
for (const field of ['questionIndex', 'contentOccurrence']) {
|
||||
if (input[field] != null) {
|
||||
if (!Number.isSafeInteger(input[field]) || input[field] < 0) throw new Error(`${field} 必须为非负整数`);
|
||||
record[field] = input[field];
|
||||
}
|
||||
}
|
||||
if (own(input, 'questionIdAliases')) record.questionIdAliases = unique([...stringArray(input.questionIdAliases, '题目ID别名'), record.questionId].filter(Boolean));
|
||||
if (input.answerSource || answerStatus === 'confirmed') record.answerSource = sourceInfo(input.answerSource || source);
|
||||
record.key = recordKey(record);
|
||||
return record;
|
||||
@@ -151,7 +162,14 @@ const QBCore = (() => {
|
||||
if (oldRecord.key !== newRecord.key) throw new Error('不能合并不同题目的记录');
|
||||
const contentMatches = sameContent(oldRecord, newRecord);
|
||||
const explanation = newRecord.explanation || oldRecord.explanation;
|
||||
const base = { ...newRecord, explanation };
|
||||
const metadata = {};
|
||||
for (const field of ['questionIndex', 'contentOccurrence']) {
|
||||
if (newRecord[field] != null || oldRecord[field] != null) metadata[field] = newRecord[field] ?? oldRecord[field];
|
||||
}
|
||||
if (own(oldRecord, 'questionIdAliases') || own(newRecord, 'questionIdAliases')) metadata.questionIdAliases = unique([
|
||||
...(oldRecord.questionIdAliases || []), ...(newRecord.questionIdAliases || []), oldRecord.questionId, newRecord.questionId
|
||||
].filter(Boolean));
|
||||
const base = { ...newRecord, ...metadata, explanation };
|
||||
const conflicts = [...oldRecord.conflicts, ...newRecord.conflicts];
|
||||
const conflictAlready = oldRecord.answerStatus === 'conflict' || newRecord.answerStatus === 'conflict';
|
||||
const answersDiffer = oldRecord.answerStatus === 'confirmed' && newRecord.answerStatus === 'confirmed' && answerSignature(oldRecord) !== answerSignature(newRecord);
|
||||
@@ -167,7 +185,7 @@ const QBCore = (() => {
|
||||
}
|
||||
if (newRecord.answerStatus === 'missing' && oldRecord.answerStatus === 'confirmed') {
|
||||
// The incoming DOM may use different IDs/order. Keep the original answer's own options and IDs together.
|
||||
return normalizeRecord({ ...oldRecord, bankName: newRecord.bankName || oldRecord.bankName, explanation,
|
||||
return normalizeRecord({ ...oldRecord, ...metadata, bankName: newRecord.bankName || oldRecord.bankName, explanation,
|
||||
updatedAt: newRecord.updatedAt, source: newRecord.source, answerSource: oldRecord.answerSource || oldRecord.source });
|
||||
}
|
||||
return normalizeRecord({ ...base, conflicts });
|
||||
@@ -182,6 +200,25 @@ const QBCore = (() => {
|
||||
records.set(record.key, records.has(record.key) ? mergeRecord(records.get(record.key), record) : record);
|
||||
lastSeen.set(record.key, index);
|
||||
}
|
||||
// Explicit aliases are produced only by content/batch repair. Ordinary
|
||||
// records with different IDs are still kept separate until that repair.
|
||||
const bankIdentity = bankResolver([...records.values()]);
|
||||
for (const target of [...records.values()].filter(record => record.questionIdAliases?.length > 1)) {
|
||||
if (!records.has(target.key)) continue;
|
||||
for (const other of [...records.values()]) {
|
||||
if (other.key === target.key || !records.has(other.key) || !other.questionId) continue;
|
||||
const current = records.get(target.key);
|
||||
const aliases = new Set(current.questionIdAliases || []);
|
||||
if (!aliases.has(other.questionId) || bankIdentity(current) !== bankIdentity(other) || !sameContent(current, other) || current.type !== other.type) continue;
|
||||
if (current.contentOccurrence != null && other.contentOccurrence != null && current.contentOccurrence !== other.contentOccurrence) continue;
|
||||
records.set(target.key, foldEquivalent([current, other], { questionId: current.questionId, bankId: current.bankId, bankName: current.bankName, contentOccurrence: current.contentOccurrence }));
|
||||
records.delete(other.key);
|
||||
}
|
||||
}
|
||||
for (const record of [...records.values()]) {
|
||||
if (record.questionId || record.contentOccurrence != null || record.answerStatus !== 'missing') continue;
|
||||
if ([...records.values()].some(other => other.questionIdAliases?.length > 1 && bankIdentity(other) === bankIdentity(record) && other.type === record.type && sameContent(other, record))) records.delete(record.key);
|
||||
}
|
||||
// The saved DOM has no stable question ID. Upgrade it only when the complete
|
||||
// content and bank identify one unique server record in both directions.
|
||||
const anonymous = [...records.values()].filter((record) => !record.questionId);
|
||||
@@ -191,7 +228,8 @@ const QBCore = (() => {
|
||||
if (!a.bankName || !b.bankName || a.bankName === '未命名题库' || b.bankName === '未命名题库') return false;
|
||||
return normalized(a.bankName) === normalized(b.bankName);
|
||||
};
|
||||
const possiblePair = (a, b) => a.options.length > 0 && b.options.length > 0 && a.type === b.type && sameBank(a, b) && sameContent(a, b);
|
||||
const possiblePair = (a, b) => a.options.length > 0 && b.options.length > 0 && a.type === b.type && sameBank(a, b) && sameContent(a, b) &&
|
||||
(a.contentOccurrence == null || b.contentOccurrence == null || a.contentOccurrence === b.contentOccurrence);
|
||||
const candidates = new Map(anonymous.map((record) => [record.key, identified.filter((other) => possiblePair(record, other))]));
|
||||
for (const record of anonymous) {
|
||||
const matches = candidates.get(record.key);
|
||||
@@ -207,6 +245,125 @@ const QBCore = (() => {
|
||||
return [...records.values()];
|
||||
}
|
||||
|
||||
function contentSignature(record) {
|
||||
return JSON.stringify([record.type, normalizedStem(record.stem), sorted(optionContents(record))]);
|
||||
}
|
||||
|
||||
function bankResolver(records) {
|
||||
const namedIds = new Map();
|
||||
for (const record of records) if (record.bankId && record.bankName !== '未命名题库') {
|
||||
const name = normalized(record.bankName);
|
||||
if (!namedIds.has(name)) namedIds.set(name, new Set());
|
||||
namedIds.get(name).add(record.bankId);
|
||||
}
|
||||
return record => {
|
||||
if (record.bankId) return `id:${record.bankId}`;
|
||||
const name = normalized(record.bankName);
|
||||
if (!name || record.bankName === '未命名题库') return `unresolved:${record.key}`;
|
||||
const ids = namedIds.get(name);
|
||||
return ids?.size === 1 ? `id:${[...ids][0]}` : `name:${name}`;
|
||||
};
|
||||
}
|
||||
|
||||
function foldEquivalent(group, identity = {}) {
|
||||
const existing = group.find(record => record.questionIdAliases?.length > 1);
|
||||
const first = existing || group.find(record => record.questionId) || group[0];
|
||||
const questionId = identity.questionId ?? first.questionId;
|
||||
const bankId = identity.bankId ?? (group.find(record => record.bankId)?.bankId || first.bankId);
|
||||
const bankName = identity.bankName || first.bankName;
|
||||
const ids = unique(group.flatMap(record => [record.questionId, ...(record.questionIdAliases || [])]).filter(Boolean));
|
||||
const meta = { bankId, bankName, questionId };
|
||||
if (ids.length > 1 || group.some(record => own(record, 'questionIdAliases'))) meta.questionIdAliases = ids;
|
||||
const occurrence = identity.contentOccurrence ?? first.contentOccurrence ?? group.find(record => record.contentOccurrence != null)?.contentOccurrence;
|
||||
if (occurrence != null) meta.contentOccurrence = occurrence;
|
||||
// Process missing observations first so the selected options and the answer
|
||||
// IDs always come together from an answer-bearing record.
|
||||
const ordered = group.filter(record => record.answerStatus === 'missing').concat(group.filter(record => record.answerStatus !== 'missing'));
|
||||
let result;
|
||||
for (const record of ordered) {
|
||||
const rebased = normalizeRecord({ ...record, ...meta });
|
||||
result = result ? mergeRecord(result, rebased) : rebased;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function compactRecords(input) {
|
||||
if (!Array.isArray(input)) throw new Error('题库必须是数组');
|
||||
const entries = input.map((value, index) => ({ record: normalizeRecord(value), index, batch: '', occurrence: null }));
|
||||
const bankIdentity = bankResolver(entries.map(entry => entry.record));
|
||||
const listSource = record => [record.answerSource, record.source].find(source => source?.kind === 'network' &&
|
||||
/\/(?:upm\/(?:wrong\/)?start|pra\/[^/]+\/questions)\/?$/u.test(source.url || '') && Number.isFinite(Date.parse(source.observedAt)));
|
||||
const legacySources = new Map();
|
||||
for (const entry of entries) {
|
||||
const record = entry.record;
|
||||
if (record.contentOccurrence != null) entry.occurrence = record.contentOccurrence;
|
||||
const explicit = record.source.batchId || record.answerSource?.batchId;
|
||||
if (explicit) entry.batch = `${bankIdentity(record)}:${explicit}`;
|
||||
else if (entry.occurrence == null) {
|
||||
const source = listSource(record);
|
||||
if (!source) continue;
|
||||
const key = JSON.stringify([bankIdentity(record), source.url]);
|
||||
if (!legacySources.has(key)) legacySources.set(key, []);
|
||||
legacySources.get(key).push({ entry, time: Date.parse(source.observedAt) });
|
||||
}
|
||||
}
|
||||
// Older versions timestamped individual rows inside each list response.
|
||||
// Infer only network list-response clusters; DOM/answer observations are
|
||||
// never assumed to be full snapshots. Their original order gives duplicate
|
||||
// occurrence numbers and keeps legitimate repeated questions in each list.
|
||||
for (const [key, items] of legacySources) {
|
||||
items.sort((a, b) => a.time - b.time || a.entry.index - b.entry.index);
|
||||
let previous = -Infinity, batch = '';
|
||||
for (const item of items) {
|
||||
if (item.time - previous > 1000) batch = `legacy:${key}:${item.time}`;
|
||||
item.entry.batch = batch; previous = item.time;
|
||||
}
|
||||
}
|
||||
const counters = new Map();
|
||||
for (const entry of entries) {
|
||||
if (!entry.batch || entry.occurrence != null) continue;
|
||||
const key = JSON.stringify([entry.batch, contentSignature(entry.record)]);
|
||||
entry.occurrence = counters.get(key) || 0;
|
||||
counters.set(key, entry.occurrence + 1);
|
||||
}
|
||||
const groups = new Map();
|
||||
const byContent = new Map();
|
||||
const add = (key, entry) => {
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(entry);
|
||||
};
|
||||
for (const entry of entries) {
|
||||
const content = JSON.stringify([bankIdentity(entry.record), contentSignature(entry.record)]);
|
||||
entry.content = content;
|
||||
if (entry.occurrence == null || entry.record.options.length < 2 || entry.record.type === 'unknown') continue;
|
||||
const key = JSON.stringify([content, entry.occurrence]);
|
||||
add(key, entry);
|
||||
if (!byContent.has(content)) byContent.set(content, new Set());
|
||||
byContent.get(content).add(key);
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.occurrence != null && entry.record.options.length >= 2 && entry.record.type !== 'unknown') continue;
|
||||
const matches = [...(byContent.get(entry.content) || [])];
|
||||
let key;
|
||||
const matchingAlias = matches.filter(candidate => groups.get(candidate).some(item => item.record.questionId === entry.record.questionId || item.record.questionIdAliases?.includes(entry.record.questionId)));
|
||||
if (entry.record.questionId && matchingAlias.length === 1) key = matchingAlias[0];
|
||||
else if (matches.length === 1) key = matches[0];
|
||||
else if (matches.length > 1 && !entry.record.questionId && entry.record.answerStatus === 'missing') continue;
|
||||
else key = `unresolved:${entry.record.key}`;
|
||||
add(key, entry);
|
||||
}
|
||||
const records = [];
|
||||
let duplicateGroups = 0;
|
||||
for (const group of groups.values()) {
|
||||
if (group.length > 1) duplicateGroups++;
|
||||
const identity = {};
|
||||
const occurrence = group.find(entry => entry.occurrence != null)?.occurrence;
|
||||
if (occurrence != null) identity.contentOccurrence = occurrence;
|
||||
records.push(foldEquivalent(group.map(entry => entry.record), identity));
|
||||
}
|
||||
return { records, removed: input.length - records.length, groups: duplicateGroups, conflictCount: records.filter(record => record.answerStatus === 'conflict').length };
|
||||
}
|
||||
|
||||
function bigrams(value) {
|
||||
const chars = Array.from(value);
|
||||
if (chars.length < 2) return new Set(chars);
|
||||
@@ -412,7 +569,7 @@ const QBCore = (() => {
|
||||
return records;
|
||||
}
|
||||
|
||||
return { normalizeRecord, mergeRecord, upsert, search, mapAnswers, toCSV, fromCSV };
|
||||
return { normalizeRecord, mergeRecord, upsert, compactRecords, search, mapAnswers, toCSV, fromCSV };
|
||||
})();
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = QBCore;
|
||||
@@ -478,13 +635,17 @@ const QBAdapter = (() => {
|
||||
function recordFromNode(node, context) {
|
||||
const rawType = has(node, 'quesType') ? node.quesType : node.type;
|
||||
let type = TYPES[rawType];
|
||||
const id = idString(node.quesId || node.questionId || node.id || context.questionId);
|
||||
// The practice client submits listQues[i].id as answers[].quesId. A
|
||||
// single-answer response may have its own receipt ID, so its request wins.
|
||||
const id = idString((context.answerResponse && context.questionId) ||
|
||||
(context.collection === 'listQues' && node.id) || node.quesId || node.questionId || node.id || context.questionId);
|
||||
const previous = knownById(context, id);
|
||||
if (!type && previous) type = previous.type;
|
||||
if (!type) return null;
|
||||
let stem = text(node.content);
|
||||
if (!stem && previous) stem = previous.stem;
|
||||
if (!stem || stem.length > 100000) return null;
|
||||
if (context.answerResponse && previous && (type !== previous.type || flat(stem) !== flat(previous.stem))) return null;
|
||||
// Do not treat an arbitrary content/type business object as a question.
|
||||
if (!previous && !has(node, 'quesType') && !Array.isArray(node.choiceItems) && !has(node, 'judgeAnswer')) return null;
|
||||
let options;
|
||||
@@ -499,16 +660,24 @@ const QBAdapter = (() => {
|
||||
} else if (previous) options = previous.options.map(o => ({ ...o }));
|
||||
else return null;
|
||||
const answers = standardAnswer(node, type, options, context);
|
||||
return {
|
||||
const record = {
|
||||
bankId: idString(node.praId || context.bankId || (previous && previous.bankId)),
|
||||
bankName: text(context.bankName || (previous && previous.bankName)), questionId: id,
|
||||
type, stem, options, ...answers,
|
||||
explanation: text((node.resultInfo && node.resultInfo.explainText) || node.explainText),
|
||||
source: { kind: context.kind || 'network', url: String(context.url || ''), observedAt: context.observedAt || new Date().toISOString() }
|
||||
source: { kind: context.kind || 'network', url: String(context.url || ''), observedAt: context.observedAt || new Date().toISOString(),
|
||||
...(context.batchId ? { batchId: context.batchId } : {}) }
|
||||
};
|
||||
if (context.answerResponse && previous) {
|
||||
if (Number.isSafeInteger(previous.questionIndex)) record.questionIndex = previous.questionIndex;
|
||||
if (Number.isSafeInteger(previous.contentOccurrence)) record.contentOccurrence = previous.contentOccurrence;
|
||||
if (previous.source?.batchId) record.source.batchId = previous.source.batchId;
|
||||
} else if (context.fullList && has(node, 'content')) record._fullList = true;
|
||||
return record;
|
||||
}
|
||||
|
||||
function extractQuestions(payload, context = {}) {
|
||||
context = { ...context, observedAt: context.observedAt || new Date().toISOString() };
|
||||
const output = [];
|
||||
const visited = new WeakSet();
|
||||
let count = 0;
|
||||
@@ -522,9 +691,13 @@ const QBAdapter = (() => {
|
||||
return;
|
||||
}
|
||||
const next = { ...inherited, bankId: idString(node.praId || inherited.bankId), bankName: text(node.praName || inherited.bankName) };
|
||||
const candidate = recordFromNode(node, next);
|
||||
const isListContainer = ['listQues', 'questionList', 'questions'].some(key => Array.isArray(node[key]));
|
||||
const candidate = isListContainer ? null : recordFromNode(node, next);
|
||||
if (candidate) output.push(candidate);
|
||||
for (const key of childKeys) if (has(node, key)) visit(node[key], next, depth + 1);
|
||||
for (const key of childKeys) if (has(node, key)) {
|
||||
const list = ['listQues', 'questionList', 'questions'].includes(key) && Array.isArray(node[key]) && !next.answerResponse;
|
||||
visit(node[key], list ? { ...next, fullList: true, collection: key } : next, depth + 1);
|
||||
}
|
||||
}
|
||||
visit(payload, context, 0);
|
||||
const unique = new Map();
|
||||
@@ -533,7 +706,18 @@ const QBAdapter = (() => {
|
||||
const old = unique.get(key);
|
||||
if (!old || (old.answerStatus === 'missing' && q.answerStatus === 'confirmed')) unique.set(key, q);
|
||||
}
|
||||
return [...unique.values()];
|
||||
const records = [...unique.values()];
|
||||
let questionIndex = 0;
|
||||
const occurrences = new Map();
|
||||
for (const record of records) {
|
||||
if (!record._fullList) continue;
|
||||
delete record._fullList;
|
||||
const contentKey = `${record.bankId}\u001f${signature(record)}`;
|
||||
record.questionIndex = questionIndex++;
|
||||
record.contentOccurrence = occurrences.get(contentKey) || 0;
|
||||
occurrences.set(contentKey, record.contentOccurrence + 1);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
function classifyURL(value, base) {
|
||||
@@ -565,6 +749,35 @@ const QBAdapter = (() => {
|
||||
} catch (_) { return ''; }
|
||||
}
|
||||
|
||||
function responseQuestionCount(payload) {
|
||||
const visited = new WeakSet();
|
||||
function countItems(items) {
|
||||
let total = 0;
|
||||
for (const node of items) {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
const type = Number(has(node, 'quesType') ? node.quesType : node.type);
|
||||
if ([0, 1, 2, 3, 4].includes(type) && has(node, 'content')) total++;
|
||||
else if (type === 5 && Array.isArray(node.subQuesList)) {
|
||||
const children = countItems(node.subQuesList);
|
||||
if (children === null) return null;
|
||||
total += children;
|
||||
} else return null;
|
||||
}
|
||||
return total <= 100000 ? total : null;
|
||||
}
|
||||
function find(node, depth) {
|
||||
if (!node || typeof node !== 'object' || depth > 8 || visited.has(node)) return null;
|
||||
visited.add(node);
|
||||
for (const key of ['listQues', 'questionList']) if (Array.isArray(node[key])) return countItems(node[key]);
|
||||
for (const key of ['data', 'result']) if (node[key]) {
|
||||
const count = find(node[key], depth + 1);
|
||||
if (count !== null) return count;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return find(payload, 0);
|
||||
}
|
||||
|
||||
function create({ window: win, document: doc, onQuestions = () => {}, onStatus = () => {}, onBankChange = () => {}, observeNetwork = true }) {
|
||||
let active = false;
|
||||
let copyEnabled = false;
|
||||
@@ -579,11 +792,14 @@ const QBAdapter = (() => {
|
||||
let xhrSendWrapped = null;
|
||||
let lastDOMSignature = '';
|
||||
let networkGeneration = 0;
|
||||
let requestSequence = 0;
|
||||
let batchSequence = 0;
|
||||
const known = new Map();
|
||||
const xhrContext = new WeakMap();
|
||||
const pendingXHR = new Set();
|
||||
const banksById = new Map();
|
||||
const banksByTask = new Map();
|
||||
const knownSnapshotOrder = new Map();
|
||||
let currentBank = null;
|
||||
let pendingSelection = '';
|
||||
let blockedForm = null;
|
||||
@@ -613,6 +829,14 @@ const QBAdapter = (() => {
|
||||
} catch (_) { return ''; }
|
||||
}
|
||||
|
||||
function displayedQuestionCount() {
|
||||
const progress = doc.querySelector('.yxtulcdsdk-answer-card__top');
|
||||
const match = text(progress?.textContent).match(/(\d+)\s*\/\s*(\d+)(?!\d)/);
|
||||
if (!match) return null;
|
||||
const done = Number(match[1]), total = Number(match[2]);
|
||||
return total >= 0 && total <= 100000 && done <= total ? total : null;
|
||||
}
|
||||
|
||||
function bankDOM() {
|
||||
const selected = doc.querySelector('.task-content-active .group-title, .task-content-active .task-content-color-active');
|
||||
const selectedName = text(selected?.textContent);
|
||||
@@ -636,7 +860,7 @@ const QBAdapter = (() => {
|
||||
|
||||
function syncBank() {
|
||||
if (!isSupportedPage()) {
|
||||
const empty = { bankId: '', bankName: '', taskId: '', loading: false };
|
||||
const empty = { bankId: '', bankName: '', taskId: '', loading: false, questionCount: null };
|
||||
const changed = !currentBank || JSON.stringify(currentBank) !== JSON.stringify(empty);
|
||||
currentBank = empty;
|
||||
pendingSelection = '';
|
||||
@@ -669,7 +893,11 @@ const QBAdapter = (() => {
|
||||
const named = [...banksById.values()].filter(bank => flat(bank.bankName) === flat(state.bankName));
|
||||
if (named.length === 1) bankId = named[0].bankId;
|
||||
}
|
||||
const next = { bankId, bankName: state.bankName, taskId: state.taskId, loading: state.mismatch || Boolean(blockedForm) };
|
||||
const loading = state.mismatch || Boolean(blockedForm);
|
||||
const displayed = loading ? null : displayedQuestionCount();
|
||||
const remembered = bankId && banksById.get(bankId)?.questionCount;
|
||||
const questionCount = displayed !== null ? displayed : Number.isSafeInteger(remembered) ? remembered : null;
|
||||
const next = { bankId, bankName: state.bankName, taskId: state.taskId, loading, questionCount };
|
||||
const changed = !currentBank || JSON.stringify(next) !== JSON.stringify(currentBank);
|
||||
currentBank = next;
|
||||
lastFormSnapshot = snapshot;
|
||||
@@ -700,6 +928,7 @@ const QBAdapter = (() => {
|
||||
const useCurrent = !saved || sameTask || (!context.answerResponse && !current.loading);
|
||||
context.bankName = useCurrent ? current.bankName : saved.bankName;
|
||||
context.taskId = useCurrent ? current.taskId : saved.taskId;
|
||||
context.requestSequence = ++requestSequence;
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -707,18 +936,30 @@ const QBAdapter = (() => {
|
||||
const distinct = new Map();
|
||||
for (const record of records) if (record.bankId) distinct.set(record.bankId, record);
|
||||
for (const record of distinct.values()) {
|
||||
const previous = banksById.get(record.bankId);
|
||||
if (!context.answerResponse && previous?.requestSequence > context.requestSequence) continue;
|
||||
const name = record.bankName || context.bankName;
|
||||
const taskId = flat(name) === flat(context.bankName) ? context.taskId || '' : '';
|
||||
const value = { bankId: record.bankId, bankName: name, taskId };
|
||||
const value = { bankId: record.bankId, bankName: name, taskId,
|
||||
questionCount: Number.isSafeInteger(context.questionCount) ? context.questionCount : previous?.questionCount ?? null,
|
||||
requestSequence: Math.max(previous?.requestSequence || 0, context.requestSequence || 0) };
|
||||
banksById.set(record.bankId, value);
|
||||
if (value.taskId && distinct.size === 1) banksByTask.set(value.taskId, value);
|
||||
}
|
||||
}
|
||||
|
||||
function emit(records) {
|
||||
function emit(records, context = {}) {
|
||||
if (!records.length) return;
|
||||
const staleBanks = new Set();
|
||||
if (context.fullSnapshot) {
|
||||
for (const bankId of new Set(records.map(record => record.bankId))) {
|
||||
if ((knownSnapshotOrder.get(bankId) || 0) > (context.requestSequence || 0)) { staleBanks.add(bankId); continue; }
|
||||
for (const [key, old] of known) if (old.bankId === bankId) known.delete(key);
|
||||
knownSnapshotOrder.set(bankId, context.requestSequence || 0);
|
||||
}
|
||||
}
|
||||
for (const q of records) {
|
||||
if (q.questionId) known.set(`${q.bankId}\u001f${q.questionId}`, q);
|
||||
if (q.questionId && !staleBanks.has(q.bankId)) known.set(`${q.bankId}\u001f${q.questionId}`, q);
|
||||
}
|
||||
try { onQuestions(records); } catch (error) { status(`保存题目失败:${error.message || error}`); }
|
||||
}
|
||||
@@ -761,12 +1002,23 @@ const QBAdapter = (() => {
|
||||
correctOptionIds: [], answerTexts: [], answerStatus: 'missing', explanation: '',
|
||||
source: { kind: 'dom', url: locationURL().split('?')[0].split('#')[0], observedAt: new Date().toISOString() }
|
||||
};
|
||||
const matches = [...known.values()].filter(item => signature(item) === signature(q) &&
|
||||
const number = text(form.querySelector('.yxtulcdsdk-form__sub')?.textContent).match(/^\s*(\d+)/);
|
||||
const questionIndex = number && Number(number[1]) >= 1 && Number(number[1]) <= 100000 ? Number(number[1]) - 1 : null;
|
||||
if (questionIndex !== null) q.questionIndex = questionIndex;
|
||||
let matches = [...known.values()].filter(item => signature(item) === signature(q) &&
|
||||
(q.bankId ? item.bankId === q.bankId : flat(item.bankName) === flat(q.bankName)));
|
||||
if (questionIndex !== null) {
|
||||
const indexed = matches.filter(item => item.questionIndex === questionIndex);
|
||||
if (indexed.length) matches = indexed;
|
||||
else if (matches.length && matches.every(item => Number.isSafeInteger(item.questionIndex))) return null;
|
||||
}
|
||||
if (blockedForm && (!bank.bankId || matches.length !== 1)) return null;
|
||||
if (matches.length === 1) {
|
||||
q.questionId = matches[0].questionId;
|
||||
q.bankId = matches[0].bankId;
|
||||
if (Number.isSafeInteger(matches[0].questionIndex)) q.questionIndex = matches[0].questionIndex;
|
||||
if (Number.isSafeInteger(matches[0].contentOccurrence)) q.contentOccurrence = matches[0].contentOccurrence;
|
||||
if (matches[0].source?.batchId) q.source.batchId = matches[0].source.batchId;
|
||||
if (blockedForm) { blockedForm = null; syncBank(); }
|
||||
}
|
||||
// Only a row explicitly labelled standard answer can establish correctness.
|
||||
@@ -807,9 +1059,14 @@ const QBAdapter = (() => {
|
||||
function observePayload(payload, context) {
|
||||
if (!active) return;
|
||||
try {
|
||||
const records = extractQuestions(payload, { ...context, knownQuestions: known });
|
||||
rememberBanks(context, records);
|
||||
emit(records);
|
||||
const observedAt = new Date().toISOString();
|
||||
const batchId = `${observedAt}:${++batchSequence}`;
|
||||
const snapshotContext = { ...context, observedAt, batchId,
|
||||
questionCount: context.answerResponse ? null : responseQuestionCount(payload) };
|
||||
const records = extractQuestions(payload, { ...snapshotContext, knownQuestions: known });
|
||||
snapshotContext.fullSnapshot = !context.answerResponse && records.some(record => Number.isSafeInteger(record.questionIndex));
|
||||
rememberBanks(snapshotContext, records);
|
||||
emit(records, snapshotContext);
|
||||
syncBank();
|
||||
if (records.length) {
|
||||
const names = [...new Set(records.map(record => record.bankName).filter(Boolean))];
|
||||
@@ -1236,6 +1493,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
(() => {
|
||||
const CONFIG_KEY = 'huayi-qb.config.v1';
|
||||
const RECORDS_KEY = 'huayi-qb.records.v1';
|
||||
const BACKUP_KEY = 'huayi-qb.records.before-dedup.v1';
|
||||
const DEFAULTS = { mode: 'hidden', keepLauncher: false, copyEnabled: true, collecting: true, tab: 'search' };
|
||||
const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
|
||||
const storage = {
|
||||
@@ -1261,16 +1519,29 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
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('去重备份结构异常,已保留原题库并停止覆盖');
|
||||
}
|
||||
try {
|
||||
const saved = storage.get(RECORDS_KEY, []);
|
||||
if (!Array.isArray(saved)) throw new Error('本地题库格式不正确');
|
||||
records = QBCore.upsert([], saved);
|
||||
const compacted = QBCore.compactRecords(QBCore.upsert([], saved));
|
||||
records = compacted.records;
|
||||
const removed = saved.length - records.length;
|
||||
if (removed > 0) {
|
||||
ensureCleanupBackup(saved);
|
||||
recordsDirty = true;
|
||||
cleanupNotice = `已合并 ${removed} 条重复加载记录,原始数据已备份。`;
|
||||
}
|
||||
} 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 || '准备就绪,可输入文字搜索,也可选中题干检索。';
|
||||
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 });
|
||||
@@ -1322,7 +1593,9 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
// 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);
|
||||
const compacted = QBCore.compactRecords(QBCore.upsert(stored, records));
|
||||
if (compacted.removed > 0) ensureCleanupBackup(stored);
|
||||
records = compacted.records;
|
||||
storage.set(RECORDS_KEY, records);
|
||||
recordsDirty = false;
|
||||
storageError = false;
|
||||
@@ -1346,7 +1619,10 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
const names = record.bankId && namesById.get(record.bankId);
|
||||
return names?.size === 1 ? QBCore.normalizeRecord({ ...record, bankName: [...names][0] }) : record;
|
||||
});
|
||||
records = QBCore.upsert(records, incoming);
|
||||
const expanded = QBCore.upsert(records, incoming);
|
||||
const compacted = QBCore.compactRecords(expanded);
|
||||
if (compacted.removed > 0) ensureCleanupBackup(expanded);
|
||||
records = compacted.records;
|
||||
recordsDirty = true;
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(persistRecords, 250);
|
||||
@@ -1500,6 +1776,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
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;
|
||||
}
|
||||
@@ -1595,6 +1872,12 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
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} 条原始记录。`);
|
||||
}
|
||||
async function parseImportFile(file) {
|
||||
if (file.size > 20 * 1024 * 1024) throw new Error('文件超过 20 MB,请拆分后导入');
|
||||
const text = (await file.text()).replace(/^\uFEFF/, '');
|
||||
@@ -1627,7 +1910,10 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
if (incoming.length > 50000) throw new Error('本次导入超过 50000 条记录,请分批导入');
|
||||
}
|
||||
const before = records.length;
|
||||
const merged = QBCore.upsert(records, incoming);
|
||||
const expanded = QBCore.upsert(records, incoming);
|
||||
const compacted = QBCore.compactRecords(expanded);
|
||||
if (compacted.removed > 0) ensureCleanupBackup(expanded);
|
||||
const merged = compacted.records;
|
||||
records = merged;
|
||||
recordsDirty = true;
|
||||
persistRecords();
|
||||
@@ -1637,16 +1923,18 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
}
|
||||
|
||||
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}}
|
||||
: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() {
|
||||
@@ -1661,13 +1949,13 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
<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>
|
||||
<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"></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 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>
|
||||
@@ -1677,6 +1965,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
<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>
|
||||
<footer class="footer"><div class="footline"><span class="dot"></span><span>本地题库 · 标准答案优先</span></div><div id="qb-status" role="status"></div></footer>
|
||||
</div>
|
||||
@@ -1714,6 +2003,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
break;
|
||||
case 'export-csv': exportData('csv'); break;
|
||||
case 'export-json': exportData('json'); break;
|
||||
case 'export-dedup-backup': exportCleanupBackup(); break;
|
||||
case 'import': shadow.getElementById('qb-import-file').click(); break;
|
||||
}
|
||||
} catch (error) { tell(`操作失败:${error.message}`, true); }
|
||||
@@ -1741,6 +2031,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
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);
|
||||
}
|
||||
@@ -1770,14 +2061,16 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
if (typeof GM_registerMenuCommand === 'function') {
|
||||
GM_registerMenuCommand('显示题库助手(Alt+Shift+Q)', () => setMode('open'));
|
||||
GM_registerMenuCommand('隐藏题库助手', () => setMode('hidden'));
|
||||
GM_registerMenuCommand('导出整理前原始题库备份', exportCleanupBackup);
|
||||
}
|
||||
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(); } }
|
||||
try { records = QBCore.compactRecords(QBCore.upsert(records, value)).records; 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();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user