678 lines
43 KiB
JavaScript
678 lines
43 KiB
JavaScript
/* 独立题库助手面板:页面正文与面板样式互不影响。 */
|
||
(function (global) {
|
||
'use strict';
|
||
|
||
const ROLES = ['主操', '班长', '副操'];
|
||
const PAGE_SIZE = 20;
|
||
const TYPE_NAMES = { single: '单选', multiple: '多选', judgment: '判断', unknown: '未知' };
|
||
|
||
function plain(value) { return String(value == null ? '' : value); }
|
||
function normalized(value) { return plain(value).replace(/\s+/g, ' ').trim(); }
|
||
function el(doc, tag, className, value) {
|
||
const node = doc.createElement(tag);
|
||
if (className) node.className = className;
|
||
if (value != null) node.textContent = value;
|
||
return node;
|
||
}
|
||
|
||
function create(callbacks) {
|
||
callbacks = callbacks || {};
|
||
const doc = global.document;
|
||
const state = {
|
||
autoAnswer: false, allowCopy: true, role: '', course: '', courses: [],
|
||
bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别题目页面', pageMode: '',
|
||
canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false, position: null
|
||
};
|
||
let destroyed = false;
|
||
let selectionText = '';
|
||
let currentOffset = 0;
|
||
let resultTotal = 0;
|
||
let drag = null;
|
||
let suppressHeaderClick = false;
|
||
let lastPositionInput = 'unset';
|
||
let appliedPosition = 'default';
|
||
let clampFrame = null;
|
||
let notifyClamp = false;
|
||
let resizeObserver = null;
|
||
let previousExpanded = false;
|
||
const host = doc.createElement('div');
|
||
host.id = 'yian-question-assistant';
|
||
host.setAttribute('data-yian-assistant', '');
|
||
host.style.cssText = 'all:initial!important;position:fixed!important;right:16px!important;bottom:16px!important;z-index:2147483646!important;display:block!important;max-width:calc(100vw - 24px)!important;font:13px/1.5 system-ui,"Microsoft YaHei",sans-serif!important;color:#17253d!important;';
|
||
const root = host.attachShadow({ mode: 'open' });
|
||
const style = doc.createElement('style');
|
||
style.textContent = `
|
||
:host{font-family:system-ui,-apple-system,"Microsoft YaHei",sans-serif;color:#17253d;font-size:13px;line-height:1.5;color-scheme:light;--accent:#225aca;--muted:#65728a;--line:#e4e9f1;--surface:#f5f7fb}
|
||
*,*::before,*::after{box-sizing:border-box}button,input,select{font:inherit}button,input,select,label{-webkit-user-select:none;user-select:none}button{cursor:pointer}button:disabled,input:disabled,select:disabled{cursor:not-allowed;opacity:.5}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #b8d1ff;outline-offset:2px}button{border:1px solid var(--line);background:white;color:#273953;border-radius:7px;padding:6px 10px}button:hover:enabled{background:#f2f6ff;border-color:#b6caf1}input[type=text],select{width:100%;min-width:0;border:1px solid #cfd8e7;border-radius:7px;background:#fff;color:#17253d;padding:8px}select{padding:6px 8px}input[type=checkbox],input[type=radio]{accent-color:var(--accent)}
|
||
.panel{width:354px;max-width:calc(100vw - 24px);background:white;border:1px solid #dce3ee;border-radius:13px;box-shadow:0 8px 36px #14264528;overflow:hidden}.header{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;background:#172a49;color:white}.brand{font-size:15px;font-weight:700;letter-spacing:.2px}.toggle{border:0;background:#ffffff17;color:white;padding:3px 9px;border-radius:5px;min-width:30px}.toggle:hover:enabled{background:#ffffff2c;color:white;border-color:transparent}.body{padding:13px;max-height:calc(100vh - 102px);max-height:calc(100dvh - 102px);overflow:auto;overscroll-behavior:contain}.body[hidden]{display:none}.panel.collapsed{width:auto;min-width:140px}.collapsed .header{padding:10px 12px;gap:16px}.switches{display:grid;gap:9px;padding-bottom:12px;border-bottom:1px solid var(--line)}.switch-row{display:flex;align-items:center;justify-content:space-between;position:relative;gap:12px;cursor:pointer;font-weight:600}.switch{appearance:none;-webkit-appearance:none;width:35px;height:21px;margin:0;border:0!important;border-radius:20px;background:#b7c0ce;position:relative;transition:background .15s}.switch::after{content:"";position:absolute;width:15px;height:15px;top:3px;left:3px;border-radius:50%;background:white;box-shadow:0 1px 3px #0002;transition:transform .15s}.switch:checked{background:var(--accent)}.switch:checked::after{transform:translateX(14px)}.hint{font-size:12px;color:var(--muted);margin:6px 0 0;overflow-wrap:anywhere}.section{padding-top:12px}.section-title{font-size:13px;font-weight:700;margin:0 0 7px}.import-row{display:flex;align-items:center;gap:10px}.import-row button{white-space:nowrap}.bank-info{font-size:12px;color:var(--muted);overflow-wrap:anywhere}.storage{color:#97702e;margin:4px 0 0;font-size:11px}.roles{display:flex;gap:6px;flex-wrap:wrap}.role{display:flex;align-items:center;gap:4px;padding:6px 8px;border:1px solid var(--line);border-radius:7px;cursor:pointer;background:white;flex:1;justify-content:center;white-space:nowrap}.role.active{background:#edf3ff;border-color:#abc5fa;color:#194dab}.role input{margin:0}.role-count{font-size:10px;color:var(--muted)}.course-row{display:grid;grid-template-columns:36px 1fr;gap:7px;align-items:center;margin-top:8px}.search-row{display:flex;gap:6px;margin-top:9px}.search-row input{flex:1}.primary{background:var(--accent);border-color:var(--accent);color:white;white-space:nowrap}.primary:hover:enabled{background:#184baf;color:white;border-color:#184baf}.selection-search{margin-top:7px;font-size:12px;width:100%}.notice{padding:8px 9px;border-radius:7px;margin:10px 0 0;font-size:12px;overflow-wrap:anywhere;background:#edf3ff;color:#254e8b}.notice.warning{background:#fff5df;color:#8a5d11}.notice.error{background:#fff0ed;color:#a23a2c}.notice.success{background:#eaf7f0;color:#1e754c}.notice[hidden]{display:none}.results-title{font-size:12px;color:var(--muted);margin:11px 0 7px}.empty{background:var(--surface);border-radius:8px;padding:13px 10px;color:var(--muted);font-size:12px}.result{border:1px solid var(--line);border-radius:9px;padding:10px;margin-bottom:9px;-webkit-user-select:text;user-select:text}.question{font-weight:600;line-height:1.6;overflow-wrap:anywhere;margin:0 0 7px;white-space:pre-wrap}.type{display:inline-block;font-size:10px;font-weight:500;color:#556984;background:#eff3f8;padding:1px 5px;border-radius:4px;margin-right:5px;vertical-align:1px}.answer{font-weight:600;color:#1b714a;background:#edf8f1;border-radius:5px;padding:6px 7px;overflow-wrap:anywhere}.options{padding:0;margin:7px 0;list-style:none;font-size:12px;display:grid;gap:4px}.option{padding:3px 6px;border-radius:4px;overflow-wrap:anywhere;white-space:pre-wrap}.option.correct{color:#196442;background:#edf8f1;font-weight:600}.sources{font-size:11px;color:var(--muted);white-space:pre-wrap;overflow-wrap:anywhere;margin-top:5px}.conflict{color:#9d6012;background:#fff5df;border-radius:5px;font-size:11px;padding:6px;margin:6px 0}.copy-result{margin-top:8px;font-size:11px}.pagination{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11px;color:var(--muted);margin:5px 0 0}.pagination button{font-size:11px;padding:4px 8px}.page-section{border-top:1px solid var(--line);margin-top:13px;padding-top:11px}.page-status{font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted)}.metrics{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:11px;color:var(--muted);margin-top:6px}.unprocessed{font-size:11px;color:#8b621c;margin-top:7px}.unprocessed summary{cursor:pointer}.unprocessed-list{max-height:155px;overflow:auto;padding:7px;border-radius:6px;background:#fff7e8;margin-top:4px;display:grid;gap:5px;overflow-wrap:anywhere}.exports{display:flex;gap:6px;margin-top:9px}.exports button{flex:1;font-size:12px}.auto-export{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--muted);margin-top:8px}.auto-export input{margin:0}.busy{color:#83692c}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:420px){.body{padding:11px}.panel{width:350px}.role{padding:6px 5px}}
|
||
`;
|
||
style.textContent += '.answer-check{font-size:11px;color:#235c43;margin-top:7px}.answer-check summary{cursor:pointer}.answer-check-list{max-height:210px;overflow:auto;overscroll-behavior:contain;margin-top:5px;display:grid;gap:7px}.answer-check-item{border:1px solid #d9eade;border-radius:6px;background:#f0f8f3;padding:8px;overflow-wrap:anywhere;-webkit-user-select:text;user-select:text}.answer-check-question{font-weight:600;margin:0 0 4px;white-space:pre-wrap}.answer-check-course{color:var(--muted);margin:0 0 5px}.answer-check-label{font-weight:600;margin:0 0 3px}.answer-check-options{margin:0;padding:0;list-style:none;display:grid;gap:4px;white-space:pre-wrap}.answer-check-note{font-size:10px;color:var(--muted);margin:5px 0 0}';
|
||
style.textContent += '.header{cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none}.panel.dragging .header{cursor:grabbing}.panel.dragging,.panel.dragging *{-webkit-user-select:none!important;user-select:none!important}';
|
||
root.appendChild(style);
|
||
const visibilityStyle = doc.createElement('style');
|
||
visibilityStyle.textContent = '[hidden]{display:none!important}';
|
||
root.appendChild(visibilityStyle);
|
||
const panel = el(doc, 'section', 'panel collapsed');
|
||
panel.setAttribute('aria-label', '谊安学助手 2.0');
|
||
const header = el(doc, 'div', 'header');
|
||
header.title = '拖动标题栏移动面板';
|
||
const brand = el(doc, 'span', 'brand', '题库助手');
|
||
const expand = el(doc, 'button', 'toggle', '展开');
|
||
expand.type = 'button';
|
||
expand.setAttribute('aria-label', '展开题库助手');
|
||
expand.setAttribute('aria-expanded', 'false');
|
||
header.append(brand, expand);
|
||
const body = el(doc, 'div', 'body');
|
||
body.hidden = true;
|
||
panel.append(header, body);
|
||
root.appendChild(panel);
|
||
|
||
function invoke(name) {
|
||
if (destroyed || typeof callbacks[name] !== 'function') return;
|
||
const args = Array.prototype.slice.call(arguments, 1);
|
||
try {
|
||
const result = callbacks[name].apply(null, args);
|
||
if (result && typeof result.catch === 'function') result.catch(function (error) { setNotice(error && error.message || '操作失败,请重试。', 'error'); });
|
||
return result;
|
||
} catch (error) { setNotice(error && error.message || '操作失败,请重试。', 'error'); }
|
||
}
|
||
function setNotice(message, level) {
|
||
notice.textContent = plain(message);
|
||
notice.className = 'notice ' + (['warning', 'error', 'success'].includes(level) ? level : 'info');
|
||
notice.hidden = !message;
|
||
}
|
||
function togglePanel() { state.expanded = !state.expanded; refresh(); }
|
||
expand.addEventListener('click', togglePanel);
|
||
brand.addEventListener('click', togglePanel);
|
||
|
||
function positionValue(value) {
|
||
if (!value || !Number.isFinite(value.left) || !Number.isFinite(value.top)) return null;
|
||
return { left: Math.round(value.left), top: Math.round(value.top) };
|
||
}
|
||
function positionSignature(value) { return value ? value.left + ',' + value.top : 'default'; }
|
||
function clampPosition(value) {
|
||
const viewport = global.visualViewport;
|
||
const left = viewport ? viewport.offsetLeft : 0;
|
||
const top = viewport ? viewport.offsetTop : 0;
|
||
const width = viewport ? viewport.width : global.innerWidth;
|
||
const height = viewport ? viewport.height : global.innerHeight;
|
||
const rect = host.getBoundingClientRect();
|
||
// SPA 暂时隐藏面板时不依据 0×0 尺寸移动或覆盖已保存的位置。
|
||
if (rect.width <= 0 || rect.height <= 0) return { left: value.left, top: value.top };
|
||
return {
|
||
left: Math.round(Math.max(left, Math.min(value.left, left + Math.max(0, width - rect.width)))),
|
||
top: Math.round(Math.max(top, Math.min(value.top, top + Math.max(0, height - rect.height))))
|
||
};
|
||
}
|
||
function applyPosition(value) {
|
||
const next = value ? clampPosition(value) : null;
|
||
const signature = positionSignature(next);
|
||
const changed = positionSignature(state.position) !== signature;
|
||
state.position = next;
|
||
if (signature === appliedPosition) return changed;
|
||
appliedPosition = signature;
|
||
if (next) {
|
||
host.style.setProperty('left', next.left + 'px', 'important');
|
||
host.style.setProperty('top', next.top + 'px', 'important');
|
||
host.style.setProperty('right', 'auto', 'important');
|
||
host.style.setProperty('bottom', 'auto', 'important');
|
||
} else {
|
||
host.style.removeProperty('left'); host.style.removeProperty('top');
|
||
host.style.setProperty('right', '16px', 'important');
|
||
host.style.setProperty('bottom', '16px', 'important');
|
||
}
|
||
return changed;
|
||
}
|
||
function savePosition() {
|
||
if (state.position) invoke('onPositionChange', { left: state.position.left, top: state.position.top });
|
||
}
|
||
function scheduleClamp(notify) {
|
||
if (destroyed) return;
|
||
notifyClamp = notifyClamp || Boolean(notify);
|
||
if (clampFrame !== null) return;
|
||
clampFrame = global.requestAnimationFrame(function () {
|
||
clampFrame = null;
|
||
const shouldNotify = notifyClamp;
|
||
notifyClamp = false;
|
||
if (destroyed || !state.position) return;
|
||
const changed = applyPosition(state.position);
|
||
if (changed && shouldNotify && !drag) savePosition();
|
||
});
|
||
}
|
||
function removeDragListeners() {
|
||
global.removeEventListener('pointermove', moveDrag, true);
|
||
global.removeEventListener('pointerup', finishDrag, true);
|
||
global.removeEventListener('pointercancel', finishDrag, true);
|
||
doc.removeEventListener('selectstart', preventDragSelection, true);
|
||
}
|
||
function preventDragSelection(event) { if (drag && drag.moved && event.cancelable) event.preventDefault(); }
|
||
function startDrag(event) {
|
||
suppressHeaderClick = false;
|
||
if (destroyed || drag || event.isPrimary === false || event.button !== 0) return;
|
||
const target = event.target && event.target.nodeType === 1 ? event.target : null;
|
||
if (!target || target.closest('button,input,select,textarea,a,[contenteditable="true"],[role="button"]')) return;
|
||
const rect = host.getBoundingClientRect();
|
||
drag = { id: event.pointerId, x: event.clientX, y: event.clientY, left: rect.left, top: rect.top, moved: false, captured: false };
|
||
global.addEventListener('pointermove', moveDrag, { capture: true, passive: false });
|
||
global.addEventListener('pointerup', finishDrag, true);
|
||
global.addEventListener('pointercancel', finishDrag, true);
|
||
doc.addEventListener('selectstart', preventDragSelection, true);
|
||
}
|
||
function moveDrag(event) {
|
||
if (!drag || event.pointerId !== drag.id) return;
|
||
const deltaX = event.clientX - drag.x, deltaY = event.clientY - drag.y;
|
||
if (!drag.moved && Math.hypot(deltaX, deltaY) <= 4) return;
|
||
if (!drag.moved) {
|
||
drag.moved = true;
|
||
panel.classList.add('dragging');
|
||
try { header.setPointerCapture(drag.id); drag.captured = true; } catch (_) { /* 全局监听仍可完成拖动。 */ }
|
||
}
|
||
if (event.cancelable) event.preventDefault();
|
||
applyPosition({ left: drag.left + deltaX, top: drag.top + deltaY });
|
||
}
|
||
function finishDrag(event) {
|
||
if (!drag || event.pointerId !== drag.id) return;
|
||
const ended = drag;
|
||
drag = null;
|
||
removeDragListeners();
|
||
panel.classList.remove('dragging');
|
||
if (ended.captured) {
|
||
try { if (header.hasPointerCapture(ended.id)) header.releasePointerCapture(ended.id); } catch (_) { /* 浏览器取消时可能已自动释放。 */ }
|
||
}
|
||
if (ended.moved) {
|
||
suppressHeaderClick = true;
|
||
if (event.cancelable) event.preventDefault();
|
||
applyPosition(state.position);
|
||
savePosition();
|
||
}
|
||
}
|
||
function suppressDragClick(event) {
|
||
if (!suppressHeaderClick || event.detail === 0) return;
|
||
suppressHeaderClick = false;
|
||
event.preventDefault(); event.stopImmediatePropagation();
|
||
}
|
||
function viewportChanged() { scheduleClamp(true); }
|
||
header.addEventListener('pointerdown', startDrag);
|
||
header.addEventListener('lostpointercapture', finishDrag);
|
||
header.addEventListener('click', suppressDragClick, true);
|
||
|
||
const switches = el(doc, 'div', 'switches');
|
||
function makeSwitch(labelText, callback, container) {
|
||
const label = el(doc, 'label', 'switch-row');
|
||
const text = el(doc, 'span', '', labelText);
|
||
const input = el(doc, 'input', 'switch');
|
||
input.type = 'checkbox';
|
||
input.setAttribute('role', 'switch');
|
||
input.setAttribute('aria-label', labelText);
|
||
label.append(text, input);
|
||
(container || switches).appendChild(label);
|
||
input.addEventListener('change', callback);
|
||
return { input: input, label: label };
|
||
}
|
||
const autoSwitch = makeSwitch('自动选择答案', function () { state.autoAnswer = autoSwitch.input.checked; invoke('onAutoChange', state.autoAnswer); refresh(); });
|
||
const copySwitch = makeSwitch('允许复制文字', function () { state.allowCopy = copySwitch.input.checked; invoke('onCopyChange', state.allowCopy); refresh(); });
|
||
const answerHint = el(doc, 'p', 'hint');
|
||
switches.appendChild(answerHint);
|
||
body.appendChild(switches);
|
||
|
||
const bankSection = el(doc, 'div', 'section');
|
||
const importRow = el(doc, 'div', 'import-row');
|
||
const importButton = el(doc, 'button', '', '导入题库');
|
||
importButton.type = 'button';
|
||
const fileInput = el(doc, 'input');
|
||
fileInput.type = 'file';
|
||
fileInput.accept = '.zip,.csv,.json,application/zip,text/csv,application/json';
|
||
fileInput.multiple = true;
|
||
fileInput.hidden = true;
|
||
const bankInfo = el(doc, 'span', 'bank-info');
|
||
importRow.append(importButton, bankInfo, fileInput);
|
||
const storageInfo = el(doc, 'p', 'storage');
|
||
bankSection.append(importRow, storageInfo);
|
||
body.appendChild(bankSection);
|
||
importButton.addEventListener('click', function () { fileInput.click(); });
|
||
fileInput.addEventListener('change', function () {
|
||
const files = Array.from(fileInput.files || []);
|
||
if (files.length) invoke('onImport', files);
|
||
fileInput.value = '';
|
||
});
|
||
|
||
const searchSection = el(doc, 'div', 'section');
|
||
searchSection.appendChild(el(doc, 'h2', 'section-title', '搜索题库'));
|
||
const roles = el(doc, 'div', 'roles');
|
||
roles.setAttribute('role', 'radiogroup');
|
||
roles.setAttribute('aria-label', '选择岗位');
|
||
const roleInputs = {};
|
||
const roleLabels = {};
|
||
const roleCounters = {};
|
||
ROLES.forEach(function (role) {
|
||
const label = el(doc, 'label', 'role');
|
||
const input = el(doc, 'input');
|
||
input.type = 'radio';
|
||
input.name = 'yian-search-role';
|
||
input.value = role;
|
||
input.setAttribute('aria-label', role);
|
||
const counter = el(doc, 'span', 'role-count');
|
||
label.append(input, el(doc, 'span', '', role), counter);
|
||
roles.appendChild(label);
|
||
roleInputs[role] = input;
|
||
roleLabels[role] = label;
|
||
roleCounters[role] = counter;
|
||
input.addEventListener('change', function () {
|
||
if (!input.checked) return;
|
||
const query = searchInput.value;
|
||
state.role = role;
|
||
state.course = '';
|
||
currentOffset = 0;
|
||
refresh();
|
||
invoke('onRoleChange', role);
|
||
if (normalized(query)) invoke('onSearch', query, { offset: 0 });
|
||
});
|
||
});
|
||
searchSection.appendChild(roles);
|
||
const courseRow = el(doc, 'label', 'course-row');
|
||
const courseSelect = el(doc, 'select');
|
||
courseSelect.setAttribute('aria-label', '选择课程');
|
||
courseRow.append(el(doc, 'span', '', '课程'), courseSelect);
|
||
courseSelect.addEventListener('change', function () {
|
||
const query = searchInput.value;
|
||
state.course = courseSelect.value;
|
||
currentOffset = 0;
|
||
invoke('onCourseChange', state.course);
|
||
if (normalized(query)) invoke('onSearch', query, { offset: 0 });
|
||
});
|
||
searchSection.appendChild(courseRow);
|
||
const searchRow = el(doc, 'div', 'search-row');
|
||
const searchInput = el(doc, 'input');
|
||
searchInput.type = 'text';
|
||
searchInput.placeholder = '输入题干、关键词或选项文字…';
|
||
searchInput.setAttribute('aria-label', '搜索题目');
|
||
const searchButton = el(doc, 'button', 'primary', '搜索');
|
||
searchButton.type = 'button';
|
||
searchRow.append(searchInput, searchButton);
|
||
searchSection.appendChild(searchRow);
|
||
const selectionButton = el(doc, 'button', 'selection-search', '用选中文字搜索');
|
||
selectionButton.type = 'button';
|
||
searchSection.appendChild(selectionButton);
|
||
const notice = el(doc, 'div', 'notice');
|
||
notice.hidden = true;
|
||
notice.setAttribute('role', 'status');
|
||
notice.setAttribute('aria-live', 'polite');
|
||
searchSection.appendChild(notice);
|
||
const resultsTitle = el(doc, 'p', 'results-title');
|
||
const results = el(doc, 'div', 'results');
|
||
const pagination = el(doc, 'div', 'pagination');
|
||
const previous = el(doc, 'button', '', '上一页');
|
||
const pageLabel = el(doc, 'span');
|
||
const next = el(doc, 'button', '', '下一页');
|
||
previous.type = next.type = 'button';
|
||
pagination.append(previous, pageLabel, next);
|
||
pagination.hidden = true;
|
||
searchSection.append(resultsTitle, results, pagination);
|
||
body.appendChild(searchSection);
|
||
|
||
function search(offset) {
|
||
if (!state.role) { setNotice('请先选择岗位:主操、班长或副操。', 'warning'); return; }
|
||
if (!normalized(searchInput.value)) { setNotice('请输入题干、关键词或选项文字。', 'warning'); searchInput.focus(); return; }
|
||
currentOffset = Math.max(0, Number(offset) || 0);
|
||
invoke('onSearch', searchInput.value, { offset: currentOffset });
|
||
}
|
||
searchButton.addEventListener('click', function () { search(0); });
|
||
searchInput.addEventListener('keydown', function (event) { if (event.key === 'Enter' && !event.isComposing) { event.preventDefault(); search(0); } });
|
||
previous.addEventListener('click', function () { search(Math.max(0, currentOffset - PAGE_SIZE)); });
|
||
next.addEventListener('click', function () { search(currentOffset + PAGE_SIZE); });
|
||
function rememberSelection() {
|
||
const selected = global.getSelection && global.getSelection();
|
||
if (!selected || !selected.anchorNode || !selected.focusNode) return;
|
||
if (host.contains(selected.anchorNode) || host.contains(selected.focusNode) || root.contains(selected.anchorNode) || root.contains(selected.focusNode)) return;
|
||
const text = selected.toString().trim();
|
||
if (text) selectionText = text;
|
||
}
|
||
doc.addEventListener('selectionchange', rememberSelection);
|
||
selectionButton.addEventListener('pointerdown', rememberSelection);
|
||
selectionButton.addEventListener('click', function () {
|
||
rememberSelection();
|
||
if (!state.role) { setNotice('请先选择岗位:主操、班长或副操。', 'warning'); return; }
|
||
if (!selectionText) { setNotice('请先在网页正文选中题目文字。', 'warning'); return; }
|
||
searchInput.value = selectionText;
|
||
currentOffset = 0;
|
||
if (typeof callbacks.onSelectionSearch === 'function') invoke('onSelectionSearch', selectionText);
|
||
else search(0);
|
||
});
|
||
|
||
const pageSection = el(doc, 'div', 'page-section');
|
||
const pageTitle = el(doc, 'h2', 'section-title', '当前页面');
|
||
pageSection.appendChild(pageTitle);
|
||
const pageStatus = el(doc, 'div', 'page-status');
|
||
const metrics = el(doc, 'div', 'metrics');
|
||
const answerCheck = el(doc, 'details', 'answer-check');
|
||
const answerCheckSummary = el(doc, 'summary', '', '查看答案核对');
|
||
const answerCheckList = el(doc, 'div', 'answer-check-list');
|
||
answerCheck.append(answerCheckSummary, answerCheckList);
|
||
answerCheck.hidden = true;
|
||
const unprocessed = el(doc, 'details', 'unprocessed');
|
||
const unprocessedSummary = el(doc, 'summary', '', '查看未处理题目');
|
||
const unprocessedList = el(doc, 'div', 'unprocessed-list');
|
||
unprocessed.append(unprocessedSummary, unprocessedList);
|
||
unprocessed.hidden = true;
|
||
pageSection.append(pageStatus, metrics, answerCheck, unprocessed);
|
||
body.appendChild(pageSection);
|
||
|
||
const exportSection = el(doc, 'section', 'page-section export-section');
|
||
exportSection.setAttribute('aria-label', '题库导出设置');
|
||
const exportSwitch = makeSwitch('导出题库', function () {
|
||
state.exportEnabled = exportSwitch.input.checked;
|
||
refresh();
|
||
invoke('onExportEnabledChange', state.exportEnabled);
|
||
}, exportSection);
|
||
const exportContent = el(doc, 'div', 'export-content');
|
||
exportContent.id = 'yian-export-content';
|
||
exportContent.hidden = true;
|
||
exportSwitch.input.setAttribute('aria-controls', exportContent.id);
|
||
exportContent.appendChild(el(doc, 'p', 'hint', '仅在练习页面导出。开启后,练习题目和答案完整时自动导出 CSV;也可点击下方按钮重新导出。关闭后停止后续导出。'));
|
||
const exportRow = el(doc, 'div', 'exports');
|
||
const exportButtons = [];
|
||
['csv', 'json', 'txt'].forEach(function (format) {
|
||
const button = el(doc, 'button', '', '导出 ' + format.toUpperCase());
|
||
button.type = 'button';
|
||
button.addEventListener('click', function () {
|
||
if (state.exportEnabled && state.canExport) invoke('onExport', format);
|
||
});
|
||
exportRow.appendChild(button);
|
||
exportButtons.push(button);
|
||
});
|
||
exportContent.appendChild(exportRow);
|
||
exportSection.appendChild(exportContent);
|
||
body.appendChild(exportSection);
|
||
|
||
function autoUnavailableReason() {
|
||
if (!state.role) return '请选择岗位后再开启自动选择。';
|
||
if (!(Number(state.roleCounts && state.roleCounts[state.role]) > 0)) return '请导入包含' + state.role + '题目的题库。';
|
||
if (!state.canAnswer) return state.pageStatus || '当前页面未识别到支持的练习或考试题目。';
|
||
return '';
|
||
}
|
||
autoSwitch.label.addEventListener('pointerdown', function () {
|
||
const reason = autoUnavailableReason();
|
||
if (!state.autoAnswer && reason) setNotice(reason, 'warning');
|
||
});
|
||
|
||
const metricLabels = { selected: '已选择', pending: '待确认', missing: '未匹配', conflict: '冲突', locked: '已锁定', unsupported: '需人工核对', failed: '失败', skipped: '已跳过' };
|
||
const metricNodes = new Map();
|
||
const answerCheckNodes = new Map();
|
||
const unprocessedNodes = new Map();
|
||
|
||
function setText(node, value) {
|
||
value = plain(value);
|
||
if (node.textContent !== value) node.textContent = value;
|
||
}
|
||
// 每次进度更新复用相同题目的节点,保留列表滚动与 details 的展开状态。
|
||
function syncRows(container, entries, nodeCache, makeRow, updateRow) {
|
||
const present = new Set();
|
||
let previousNode = null;
|
||
entries.forEach(function (entry, index) {
|
||
const key = index + '\u0000' + plain(entry.number) + '\u0000' + plain(entry.question);
|
||
present.add(key);
|
||
let saved = nodeCache.get(key);
|
||
if (!saved) { saved = { node: makeRow(), signature: '' }; nodeCache.set(key, saved); }
|
||
const signature = JSON.stringify([entry.number, entry.status, entry.reason, entry.question, entry.course, entry.correctTexts, entry.currentLabels]);
|
||
if (signature !== saved.signature) { updateRow(saved.node, entry); saved.signature = signature; }
|
||
const expectedNode = previousNode ? previousNode.nextSibling : container.firstChild;
|
||
if (expectedNode !== saved.node) container.insertBefore(saved.node, expectedNode);
|
||
previousNode = saved.node;
|
||
});
|
||
nodeCache.forEach(function (saved, key) {
|
||
if (!present.has(key)) { saved.node.remove(); nodeCache.delete(key); }
|
||
});
|
||
}
|
||
function refreshMetrics() {
|
||
const progress = state.metrics || {};
|
||
Object.keys(metricLabels).forEach(function (key) {
|
||
let node = metricNodes.get(key);
|
||
if (progress[key] != null && !node) { node = el(doc, 'span'); metricNodes.set(key, node); metrics.appendChild(node); }
|
||
if (!node) return;
|
||
node.hidden = progress[key] == null;
|
||
if (!node.hidden) setText(node, metricLabels[key] + ' ' + Number(progress[key] || 0));
|
||
});
|
||
const details = Array.isArray(progress.details) ? progress.details : [];
|
||
const selected = details.filter(function (item) { return item.status === 'selected'; });
|
||
answerCheck.hidden = !selected.length;
|
||
setText(answerCheckSummary, '查看答案核对(' + selected.length + ')');
|
||
syncRows(answerCheckList, selected, answerCheckNodes, function () {
|
||
const row = el(doc, 'article', 'answer-check-item');
|
||
row.append(el(doc, 'p', 'answer-check-question'), el(doc, 'p', 'answer-check-course'), el(doc, 'p', 'answer-check-label', '题库正确答案全文 → 当前选项'), el(doc, 'ul', 'answer-check-options'), el(doc, 'p', 'answer-check-note'));
|
||
return row;
|
||
}, function (row, item) {
|
||
setText(row.children[0], '第 ' + plain(item.number) + ' 题' + (item.question ? ' · ' + plain(item.question) : ''));
|
||
const course = plain(item.course);
|
||
setText(row.children[1], course ? '课程:' + course : '课程来源未记录');
|
||
const texts = Array.isArray(item.correctTexts) ? item.correctTexts : [];
|
||
const labels = Array.isArray(item.currentLabels) ? item.currentLabels : [];
|
||
const list = row.children[3];
|
||
texts.forEach(function (text, index) {
|
||
let option = list.children[index];
|
||
if (!option) { option = el(doc, 'li'); list.appendChild(option); }
|
||
setText(option, (labels[index] ? plain(labels[index]) + '. ' : '当前字母未记录 · ') + plain(text));
|
||
});
|
||
if (!texts.length) {
|
||
let message = list.firstElementChild;
|
||
if (!message) { message = el(doc, 'li'); list.appendChild(message); }
|
||
setText(message, '题库答案文字未记录,请核对网页。');
|
||
}
|
||
const count = Math.max(1, texts.length);
|
||
while (list.children.length > count) list.lastElementChild.remove();
|
||
setText(row.children[4], item.reason || '');
|
||
row.children[4].hidden = !item.reason;
|
||
});
|
||
const notProcessed = details.filter(function (item) { return item.status !== 'selected'; });
|
||
unprocessed.hidden = !notProcessed.length;
|
||
setText(unprocessedSummary, '查看未处理题目(' + notProcessed.length + ')');
|
||
syncRows(unprocessedList, notProcessed, unprocessedNodes, function () { return el(doc, 'div'); }, function (row, item) {
|
||
setText(row, '第 ' + plain(item.number) + ' 题 · ' + (metricLabels[item.status] || '已跳过') + ':' + (item.reason || '请人工核对。'));
|
||
});
|
||
}
|
||
|
||
let courseSignature = '';
|
||
function refresh() {
|
||
if (destroyed) return;
|
||
panel.classList.toggle('collapsed', !state.expanded);
|
||
body.hidden = !state.expanded;
|
||
brand.textContent = state.expanded ? '谊安学助手 2.0' : '题库助手';
|
||
expand.textContent = state.expanded ? '收起' : '展开';
|
||
expand.setAttribute('aria-label', state.expanded ? '收起题库助手' : '展开题库助手');
|
||
expand.setAttribute('aria-expanded', String(Boolean(state.expanded)));
|
||
if (previousExpanded !== Boolean(state.expanded)) {
|
||
previousExpanded = Boolean(state.expanded);
|
||
scheduleClamp(true);
|
||
}
|
||
const unavailable = autoUnavailableReason();
|
||
autoSwitch.input.checked = Boolean(state.autoAnswer);
|
||
autoSwitch.input.disabled = !state.autoAnswer && (Boolean(unavailable) || Boolean(state.loading));
|
||
autoSwitch.label.title = unavailable;
|
||
copySwitch.input.checked = Boolean(state.allowCopy);
|
||
answerHint.textContent = unavailable || (state.autoAnswer ? '正在按题干和选项全文匹配答案;关闭即可停止后续操作。' : '支持练习和考试,按题干和选项全文匹配答案;交卷由你操作。');
|
||
bankInfo.textContent = state.loading ? '正在读取题库…' : '已加载 ' + Number(state.bankTotal || 0).toLocaleString('zh-CN') + ' 条' + (state.role ? ' · ' + state.role + ' ' + Number(state.roleCounts && state.roleCounts[state.role] || 0).toLocaleString('zh-CN') + ' 条' : '');
|
||
bankInfo.classList.toggle('busy', Boolean(state.loading));
|
||
importButton.disabled = Boolean(state.loading);
|
||
storageInfo.textContent = state.storageMode === 'origin' ? '此模式仅在当前网站保存题库和设置。' : '';
|
||
storageInfo.hidden = !storageInfo.textContent;
|
||
ROLES.forEach(function (role) {
|
||
roleInputs[role].checked = state.role === role;
|
||
roleLabels[role].classList.toggle('active', state.role === role);
|
||
const count = Number(state.roleCounts && state.roleCounts[role] || 0);
|
||
roleCounters[role].textContent = count ? '(' + count.toLocaleString('zh-CN') + ')' : '';
|
||
});
|
||
const availableCourses = (state.courses || []).map(function (course) {
|
||
return typeof course === 'string' ? { value: course, label: course } : { value: plain(course.value == null ? (course.course || course.name) : course.value), label: plain(course.label || course.name || course.course || course.value) };
|
||
});
|
||
const signature = JSON.stringify(availableCourses);
|
||
if (signature !== courseSignature) {
|
||
courseSignature = signature;
|
||
courseSelect.replaceChildren();
|
||
const all = el(doc, 'option', '', '全部课程');
|
||
all.value = '';
|
||
courseSelect.appendChild(all);
|
||
availableCourses.forEach(function (course) { const option = el(doc, 'option', '', course.label); option.value = course.value; courseSelect.appendChild(option); });
|
||
}
|
||
courseSelect.value = state.course || '';
|
||
courseSelect.disabled = !state.role || Boolean(state.loading);
|
||
searchButton.disabled = Boolean(state.loading);
|
||
selectionButton.disabled = Boolean(state.loading);
|
||
setText(pageTitle, state.pageMode === 'test' ? '当前考试' : state.pageMode === 'exercise' ? '当前练习' : '当前页面');
|
||
setText(pageStatus, state.pageStatus || '等待识别题目页面');
|
||
refreshMetrics();
|
||
exportSwitch.input.checked = Boolean(state.exportEnabled);
|
||
exportContent.hidden = !state.exportEnabled;
|
||
exportButtons.forEach(function (button) { button.disabled = !state.exportEnabled || !state.canExport; });
|
||
previous.disabled = currentOffset === 0 || Boolean(state.loading);
|
||
next.disabled = currentOffset + PAGE_SIZE >= resultTotal || Boolean(state.loading);
|
||
}
|
||
|
||
function optionsOf(item) {
|
||
if (Array.isArray(item.options)) return item.options.map(function (option, index) {
|
||
return typeof option === 'string' ? { label: String.fromCharCode(65 + index), text: option } : { label: plain(option.label || option.key || String.fromCharCode(65 + index)), text: plain(option.text == null ? option.value : option.text), correct: option.correct };
|
||
});
|
||
return Object.keys(item.options || {}).map(function (label) { return { label: label, text: plain(item.options[label]) }; });
|
||
}
|
||
function sourcesOf(item) {
|
||
const entries = Array.isArray(item.sources) && item.sources.length ? item.sources : [{ role: item.role || state.role, course: item.course || item.title || item.bankTitle || '', file: item.filename || '' }];
|
||
return Array.from(new Set(entries.map(function (source) {
|
||
if (typeof source === 'string') return source;
|
||
return [source.role || item.role, source.course || source.title || source.bankTitle || source.name, source.filename || source.file].filter(Boolean).join(' / ');
|
||
}).filter(Boolean)));
|
||
}
|
||
function answerInfo(item, options) {
|
||
const answer = plain(item.answer == null ? item.correctAnswer : item.answer);
|
||
const letters = plain(item.answerLabels || item.correctLabels || answer).replace(/[\s,,、;;.。]/g, '').toUpperCase();
|
||
const texts = Array.isArray(item.answerTexts) ? item.answerTexts.map(normalized) : [];
|
||
const correctOptions = options.filter(function (option) { return option.correct || /^[A-Z]+$/.test(letters) && letters.includes(option.label.toUpperCase()) || texts.includes(normalized(option.text)); });
|
||
const description = item.answerText || correctOptions.map(function (option) { return option.label + ' — ' + (option.text || '[图片选项]'); }).join(';') || texts.join(';');
|
||
return { answer: answer, description: plain(description), correctOptions: correctOptions };
|
||
}
|
||
async function copyText(value) {
|
||
const legacyClipboard = typeof GM_setClipboard === 'function' ? GM_setClipboard : global.GM_setClipboard;
|
||
const modernGM = typeof GM !== 'undefined' ? GM : global.GM;
|
||
if (typeof legacyClipboard === 'function') { await legacyClipboard(value, 'text'); return; }
|
||
if (modernGM && typeof modernGM.setClipboard === 'function') { await modernGM.setClipboard(value, 'text'); return; }
|
||
if (global.navigator.clipboard && typeof global.navigator.clipboard.writeText === 'function') {
|
||
try { await global.navigator.clipboard.writeText(value); return; } catch (_) { /* 再尝试浏览器复制命令。 */ }
|
||
}
|
||
const textarea = el(doc, 'textarea');
|
||
textarea.value = value;
|
||
textarea.style.cssText = 'position:fixed;left:0;top:0;opacity:0;width:1px;height:1px;';
|
||
root.appendChild(textarea);
|
||
textarea.focus();
|
||
textarea.select();
|
||
let copied = false;
|
||
try { copied = doc.execCommand('copy'); } finally { textarea.remove(); }
|
||
if (!copied) throw new Error('浏览器未允许复制,请选中结果文字手动复制。');
|
||
}
|
||
function renderSearch(result) {
|
||
if (destroyed) return;
|
||
result = result || {};
|
||
const items = (result.items || []).slice(0, PAGE_SIZE);
|
||
resultTotal = Number(result.total == null ? items.length : result.total) || 0;
|
||
currentOffset = Math.max(0, Number(result.offset == null ? currentOffset : result.offset) || 0);
|
||
resultsTitle.textContent = state.role ? '找到 ' + resultTotal.toLocaleString('zh-CN') + ' 道题 · 当前岗位:' + state.role : '先选择岗位,再搜索题目';
|
||
results.replaceChildren();
|
||
if (!items.length) results.appendChild(el(doc, 'div', 'empty', state.role ? '当前岗位未找到题目,可调整关键词、课程或切换岗位。' : '选择主操、班长或副操,导入题库后即可查找答案。'));
|
||
items.forEach(function (item) {
|
||
const card = el(doc, 'article', 'result');
|
||
const title = el(doc, 'p', 'question');
|
||
const type = TYPE_NAMES[item.type] || item.type || '题目';
|
||
title.append(el(doc, 'span', 'type', type), doc.createTextNode(plain(item.question == null ? item.stem : item.question)));
|
||
card.appendChild(title);
|
||
const options = optionsOf(item);
|
||
const info = answerInfo(item, options);
|
||
card.appendChild(el(doc, 'div', 'answer', '正确答案:' + (info.answer || '见下方选项') + (info.description ? ' · ' + info.description : '')));
|
||
const list = el(doc, 'ul', 'options');
|
||
options.forEach(function (option) {
|
||
const correct = info.correctOptions.includes(option);
|
||
const row = el(doc, 'li', 'option' + (correct ? ' correct' : ''), option.label + '. ' + (option.text || '[图片选项]') + (correct ? ' ✓' : ''));
|
||
list.appendChild(row);
|
||
});
|
||
card.appendChild(list);
|
||
if (item.conflict || item.conflicts && item.conflicts.length || item.ambiguous) card.appendChild(el(doc, 'div', 'conflict', '此题存在不同答案,请核对课程来源;自动选择会跳过不明确的匹配。'));
|
||
const sources = sourcesOf(item);
|
||
card.appendChild(el(doc, 'div', 'sources', '来源:' + (sources.join('\n') || '未知')));
|
||
const copyButton = el(doc, 'button', 'copy-result', '复制题目与答案');
|
||
copyButton.type = 'button';
|
||
copyButton.addEventListener('click', async function () {
|
||
const content = ['[' + type + '] ' + plain(item.question == null ? item.stem : item.question)].concat(options.map(function (option) { return option.label + '. ' + (option.text || '[图片选项]'); }), ['正确答案:' + info.answer, info.description ? '对应选项:' + info.description : '', '来源:' + sources.join(';')]).filter(Boolean).join('\n');
|
||
try { await copyText(content); setNotice('题目与答案已复制。', 'success'); } catch (error) { setNotice(error.message, 'error'); }
|
||
});
|
||
card.appendChild(copyButton);
|
||
results.appendChild(card);
|
||
});
|
||
pagination.hidden = resultTotal <= PAGE_SIZE;
|
||
pageLabel.textContent = resultTotal ? (currentOffset + 1) + '–' + Math.min(currentOffset + items.length, resultTotal) + ' / ' + resultTotal : '0 / 0';
|
||
refresh();
|
||
}
|
||
|
||
function setState(partial) {
|
||
if (destroyed) return;
|
||
const update = Object.assign({}, partial || {});
|
||
let incoming = null, acceptPosition = false;
|
||
if (Object.prototype.hasOwnProperty.call(update, 'position')) {
|
||
incoming = positionValue(update.position);
|
||
const signature = positionSignature(incoming);
|
||
acceptPosition = signature !== lastPositionInput && !drag;
|
||
lastPositionInput = signature;
|
||
delete update.position;
|
||
}
|
||
Object.assign(state, update);
|
||
refresh();
|
||
if (acceptPosition) {
|
||
applyPosition(incoming);
|
||
if (incoming && positionSignature(incoming) !== positionSignature(state.position)) savePosition();
|
||
}
|
||
}
|
||
function destroy() {
|
||
if (destroyed) return;
|
||
destroyed = true;
|
||
const oldDrag = drag;
|
||
drag = null;
|
||
removeDragListeners();
|
||
if (oldDrag && oldDrag.captured) {
|
||
try { if (header.hasPointerCapture(oldDrag.id)) header.releasePointerCapture(oldDrag.id); } catch (_) { /* 节点卸载时浏览器可能已释放。 */ }
|
||
}
|
||
header.removeEventListener('pointerdown', startDrag);
|
||
header.removeEventListener('lostpointercapture', finishDrag);
|
||
header.removeEventListener('click', suppressDragClick, true);
|
||
expand.removeEventListener('click', togglePanel);
|
||
brand.removeEventListener('click', togglePanel);
|
||
global.removeEventListener('resize', viewportChanged);
|
||
if (global.visualViewport) {
|
||
global.visualViewport.removeEventListener('resize', viewportChanged);
|
||
global.visualViewport.removeEventListener('scroll', viewportChanged);
|
||
}
|
||
if (resizeObserver) resizeObserver.disconnect();
|
||
if (clampFrame !== null) global.cancelAnimationFrame(clampFrame);
|
||
clampFrame = null;
|
||
doc.removeEventListener('selectionchange', rememberSelection);
|
||
host.remove();
|
||
}
|
||
(doc.body || doc.documentElement).appendChild(host);
|
||
global.addEventListener('resize', viewportChanged);
|
||
if (global.visualViewport) {
|
||
global.visualViewport.addEventListener('resize', viewportChanged);
|
||
global.visualViewport.addEventListener('scroll', viewportChanged);
|
||
}
|
||
if (typeof global.ResizeObserver === 'function') {
|
||
let sizeSignature = '';
|
||
resizeObserver = new global.ResizeObserver(function (entries) {
|
||
const box = entries[0] && entries[0].contentRect;
|
||
if (!box) return;
|
||
const signature = box.width + ',' + box.height;
|
||
if (signature === sizeSignature) return;
|
||
sizeSignature = signature;
|
||
scheduleClamp(true);
|
||
});
|
||
resizeObserver.observe(panel);
|
||
}
|
||
renderSearch({ items: [], total: 0 });
|
||
refresh();
|
||
return { setState: setState, renderSearch: renderSearch, setNotice: setNotice, destroy: destroy, host: host, root: root };
|
||
}
|
||
|
||
global.YianAssistantPanel = { create: create };
|
||
})(typeof window !== 'undefined' ? window : globalThis);
|