/* 独立题库助手面板:页面正文与面板样式互不影响。 */ (function (global) { 'use strict'; 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, activeTab: 'overview', uniqueTotal: 0, conflictTotal: 0, learning: { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: {}, exam: {}, logs: [], message: '' } }; 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}'; style.textContent += ` .panel{width:640px;max-width:calc(100vw - 24px)}.panel.collapsed{width:auto;max-width:calc(100vw - 24px);min-width:210px}.brand{font-size:14px}.body{padding:0;overflow:hidden;max-height:calc(100vh - 88px);max-height:calc(100dvh - 88px);display:flex;flex-direction:column}.global-bar{flex:none;padding:11px 13px;border-bottom:1px solid var(--line);background:#f8faff}.global-top{display:flex;align-items:center;gap:8px}.global-status{font-weight:650;flex:1;min-width:0;overflow-wrap:anywhere}.global-action{font-size:12px;white-space:nowrap}.global-progress-row{display:flex;align-items:center;gap:9px;margin-top:7px}.global-progress{min-width:30px;flex:1;height:7px;border:0;border-radius:5px;overflow:hidden;accent-color:var(--accent)}.global-progress::-webkit-progress-bar{background:#e1e8f3;border-radius:5px}.global-progress::-webkit-progress-value{background:var(--accent);border-radius:5px}.global-progress-label{font-size:11px;color:var(--muted);white-space:nowrap}.worker-status{font-size:11px;color:var(--muted);margin-top:5px;white-space:pre-wrap;overflow-wrap:anywhere}.workspace{display:flex;min-height:0;height:500px;max-height:calc(100vh - 230px);max-height:calc(100dvh - 230px)}.navigation{display:flex;flex-direction:column;gap:4px;flex:none;width:123px;padding:12px 8px;border-right:1px solid var(--line);background:#f8faff;overflow:auto}.nav-tab{border:0;background:transparent;text-align:left;padding:9px 10px;border-radius:7px;font-size:12px;white-space:nowrap}.nav-tab[aria-selected=true]{background:#e6efff;color:#174fae;font-weight:700}.nav-tab[data-alert=true]::after{content:' ·';color:#ad6921;font-weight:800}.panes{flex:1;min-width:0;min-height:0}.tab-pane{height:100%;padding:13px;overflow:auto;overscroll-behavior:contain}.tab-pane>.section:first-child{padding-top:0}.tab-pane .page-section:first-child{margin-top:0;padding-top:0;border-top:0}.notice{flex:none;margin:0;padding:8px 13px;border-radius:0;max-height:65px;overflow:auto}.setting-row{display:grid;grid-template-columns:88px minmax(0,1fr);align-items:center;gap:9px;margin:10px 0}.setting-row>span{font-size:12px;color:#43546d}.setting-row input[type=number]{min-width:0;width:100%;border:1px solid #cfd8e7;border-radius:7px;padding:6px 8px;color:#17253d;background:white}.task-actions,.console-actions{display:flex;flex-wrap:wrap;gap:7px;margin:12px 0}.summary-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:10px 0}.summary-card{padding:9px 10px;background:var(--surface);border:1px solid var(--line);border-radius:8px;overflow-wrap:anywhere}.summary-card strong{display:block;font-size:20px;font-weight:650;color:#214e91}.summary-card span{font-size:11px;color:var(--muted)}.worker-card{display:block;text-align:left;width:100%;margin-top:8px;padding:10px;white-space:pre-wrap;overflow-wrap:anywhere}.task-list{display:grid;gap:7px}.task-row{padding:9px 10px;border:1px solid var(--line);border-radius:7px;overflow-wrap:anywhere}.task-name{font-weight:600;margin:0 0 3px;font-size:12px}.task-state{font-size:11px;color:var(--muted)}.info-grid{display:grid;grid-template-columns:80px minmax(0,1fr);gap:7px;font-size:12px;margin:10px 0}.info-grid dt{color:var(--muted)}.info-grid dd{margin:0;overflow-wrap:anywhere;white-space:pre-wrap}.explanation{padding:9px 10px;background:var(--surface);border-radius:7px;font-size:12px;color:var(--muted);white-space:pre-wrap;overflow-wrap:anywhere}.mode-note{font-size:11px;color:#926214;margin:7px 0}.console-filter{max-width:125px}.log-list{display:grid;gap:6px;font-size:11px;-webkit-user-select:text;user-select:text}.log-entry{padding:7px 8px;border-radius:5px;background:#f4f7fb;white-space:pre-wrap;overflow-wrap:anywhere;border-left:3px solid #b9c9de}.log-entry.warning{background:#fff6e6;border-left-color:#d0a354}.log-entry.error{background:#fff0ed;border-left-color:#d68370}.log-time{color:var(--muted);margin-right:7px}.threshold{font-size:12px;font-weight:600;color:#825b20;margin:9px 0}.export-section{padding-top:13px!important}.collapse-summary{font-size:10px;color:#bbd3fa;margin-left:7px;white-space:nowrap}.bank-counts{font-size:11px;color:var(--muted);margin:8px 0}.topic-row{display:flex;gap:6px;margin-top:9px}.topic-row input{flex:1;min-width:0}.theme-associations{font-size:11px;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted);margin:8px 0}.tab-pane .switches{margin-bottom:12px} @media(max-width:600px){.panel{width:calc(100vw - 24px)}.workspace{flex-direction:column;height:480px;max-height:calc(100vh - 244px);max-height:calc(100dvh - 244px)}.navigation{flex-direction:row;width:100%;border-right:0;border-bottom:1px solid var(--line);padding:7px;overflow-x:auto;flex-shrink:0}.nav-tab{padding:8px 9px}.panes{min-height:0}.tab-pane{padding:11px}.global-bar{padding:9px 11px}.brand{font-size:13px}.collapsed .header{gap:8px}.global-top{gap:5px}.global-action{padding:5px 7px}.setting-row{grid-template-columns:80px minmax(0,1fr)}} `; 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', '谊安学助手 2.0'); const collapseSummary = el(doc, 'span', 'collapse-summary'); const expand = el(doc, 'button', 'toggle', '展开'); expand.type = 'button'; expand.setAttribute('aria-label', '展开题库助手'); expand.setAttribute('aria-expanded', 'false'); header.append(brand, collapseSummary, expand); const body = el(doc, 'div', 'body'); body.hidden = true; panel.append(header, body); root.appendChild(panel); const globalBar = el(doc, 'div', 'global-bar'); const globalTop = el(doc, 'div', 'global-top'); const globalStatus = el(doc, 'span', 'global-status', '尚未开始'); globalStatus.setAttribute('data-testid', 'learning-status'); globalStatus.setAttribute('aria-live', 'polite'); const globalPause = el(doc, 'button', 'global-action', '暂停全部'); globalPause.type = 'button'; globalPause.setAttribute('data-testid', 'global-pause'); globalPause.addEventListener('click', function () { invoke('onTaskAction', ['paused', 'stopped'].includes(state.learning.status) ? 'resume' : 'pause'); }); globalTop.append(globalStatus, globalPause); const globalProgressRow = el(doc, 'div', 'global-progress-row'); const globalProgress = el(doc, 'progress', 'global-progress'); globalProgress.max = 1; globalProgress.value = 0; globalProgress.setAttribute('aria-label', '已完成任务进度'); const globalProgressLabel = el(doc, 'span', 'global-progress-label', '0 / 0'); const workerStatus = el(doc, 'div', 'worker-status'); globalProgressRow.append(globalProgress, globalProgressLabel); globalBar.append(globalTop, globalProgressRow, workerStatus); const workspace = el(doc, 'div', 'workspace'); const navigation = el(doc, 'nav', 'navigation'); navigation.setAttribute('role', 'tablist'); navigation.setAttribute('aria-label', '助手功能'); const panes = el(doc, 'div', 'panes'); const tabs = {}, views = {}; const tabOrder = ['overview', 'video', 'exam', 'bank', 'console', 'settings']; const tabNames = { overview: '任务总览', video: '视频学习', exam: '考试答题', bank: '题库搜索', console: '控制台', settings: '其他设置' }; function selectTab(key, focus) { if (!views[key]) return; state.activeTab = key; tabOrder.forEach(function (id) { const active = id === key; tabs[id].setAttribute('aria-selected', String(active)); tabs[id].tabIndex = active ? 0 : -1; views[id].hidden = !active; }); if (focus) tabs[key].focus(); scheduleClamp(true); } tabOrder.forEach(function (key, index) { const tab = el(doc, 'button', 'nav-tab', tabNames[key]); tab.type = 'button'; tab.id = 'yian-tab-' + key; tab.setAttribute('role', 'tab'); tab.setAttribute('aria-controls', 'yian-pane-' + key); tab.setAttribute('data-testid', 'tab-' + key); tab.addEventListener('click', function () { selectTab(key, false); }); tab.addEventListener('keydown', function (event) { let target; if (event.key === 'Home') target = tabOrder[0]; if (event.key === 'End') target = tabOrder[tabOrder.length - 1]; if (event.key === 'ArrowRight' || event.key === 'ArrowDown') target = tabOrder[(index + 1) % tabOrder.length]; if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') target = tabOrder[(index + tabOrder.length - 1) % tabOrder.length]; if (target) { event.preventDefault(); selectTab(target, true); } }); const view = el(doc, 'section', 'tab-pane'); view.id = 'yian-pane-' + key; view.setAttribute('role', 'tabpanel'); view.setAttribute('aria-labelledby', tab.id); view.setAttribute('data-testid', 'pane-' + key); tabs[key] = tab; views[key] = view; navigation.appendChild(tab); panes.appendChild(view); }); workspace.append(navigation, panes); body.append(globalBar, workspace); 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); views.exam.appendChild(switches); views.settings.appendChild(copySwitch.label); 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); views.bank.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 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'); body.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); views.bank.appendChild(searchSection); function search(offset) { 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 (!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); views.exam.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); views.settings.appendChild(exportSection); const learningControls = {}; function learningSetting(container, key, label, values, fallback, range) { const row = el(doc, 'label', 'setting-row'); const input = el(doc, values ? 'select' : 'input'); input.setAttribute('aria-label', label); input.setAttribute('data-testid', 'setting-' + key); if (values) values.forEach(function (entry) { const option = el(doc, 'option', '', entry[1]); option.value = plain(entry[0]); input.appendChild(option); }); else { input.type = 'number'; input.min = plain(range && range[0] || 0); input.max = plain(range && range[1] || 3600); input.step = plain(range && range[2] || 1); } row.append(el(doc, 'span', '', label), input); container.appendChild(row); learningControls[key] = { input: input, fallback: fallback, numeric: !values || typeof fallback === 'number' }; input.addEventListener('change', function () { if (!input.checkValidity()) { setNotice('请输入有效的' + label + '。', 'warning'); return; } const value = learningControls[key].numeric ? Number(input.value) : input.value; state.learning[key] = value; invoke('onLearningSetting', key, value); refreshLearning(); }); return input; } function taskButton(container, label, action, className) { const button = el(doc, 'button', className || '', label); button.type = 'button'; button.setAttribute('data-testid', 'task-' + action); button.addEventListener('click', function () { invoke('onTaskAction', action); }); container.appendChild(button); return button; } views.overview.appendChild(el(doc, 'h2', 'section-title', '本轮学习任务')); learningSetting(views.overview, 'mode', '任务模式', [['video', '只刷视频(课中题人工处理)'], ['videoQuiz', '视频与课中答题'], ['exam', '只做考试'], ['all', '视频、课中题与考试']], 'all'); learningSetting(views.overview, 'execution', '执行方式', [['sequential', '顺序执行'], ['parallel', '并行:一个视频 + 一场考试']], 'sequential'); const taskActions = el(doc, 'div', 'task-actions'); const startTask = taskButton(taskActions, '开始本轮', 'start', 'primary'); const resumeTask = taskButton(taskActions, '继续上次任务', 'resume'); const stopTask = taskButton(taskActions, '停止本轮', 'stop'); views.overview.appendChild(taskActions); const learningMessage = el(doc, 'p', 'explanation'); const summaryGrid = el(doc, 'div', 'summary-grid'); const summaryNodes = {}; [['total', '任务总数'], ['completed', '已完成'], ['skipped', '已跳过'], ['waiting', '等待人工'], ['failed', '失败'], ['etaSeconds', '预计剩余']].forEach(function (entry) { const card = el(doc, 'div', 'summary-card'); const value = el(doc, 'strong', '', '0'); card.append(value, el(doc, 'span', '', entry[1])); summaryNodes[entry[0]] = value; summaryGrid.appendChild(card); }); const videoSummary = el(doc, 'button', 'worker-card'); videoSummary.type = 'button'; const examSummary = el(doc, 'button', 'worker-card'); examSummary.type = 'button'; videoSummary.addEventListener('click', function () { selectTab('video', true); }); examSummary.addEventListener('click', function () { selectTab('exam', true); }); const taskList = el(doc, 'div', 'task-list'); views.overview.append(learningMessage, summaryGrid, videoSummary, examSummary, el(doc, 'h3', 'section-title', '任务清单'), taskList); views.video.appendChild(el(doc, 'h2', 'section-title', '视频学习')); const videoModeNote = el(doc, 'p', 'mode-note'); views.video.appendChild(videoModeNote); learningSetting(views.video, 'rate', '播放倍速', [[1, '1X'], [1.25, '1.25X'], [1.5, '1.5X'], [2, '2X(最高)']], 1); const videoInfo = el(doc, 'dl', 'info-grid'); const videoActions = el(doc, 'div', 'task-actions'); const videoPause = taskButton(videoActions, '暂停视频任务', 'videoPause'); const videoResume = taskButton(videoActions, '继续视频任务', 'videoResume'); const videoMessage = el(doc, 'p', 'explanation'); views.video.append(videoInfo, videoActions, videoMessage, el(doc, 'p', 'hint', '播放结束后等待平台确认完成;倍速不会保证平台要求的学习时长按比例缩短。课中题出现时暂停播放,精确匹配后处理。')); const examIntro = el(doc, 'div', 'section'); examIntro.appendChild(el(doc, 'h2', 'section-title', '考试设置与进度')); const examModeNote = el(doc, 'p', 'mode-note'); examIntro.appendChild(examModeNote); learningSetting(examIntro, 'answerInterval', '答题间隔(秒)', null, 4, [0, 120, 0.5]); learningSetting(examIntro, 'minSubmitMinutes', '最早交卷(分)', null, 6, [0.5, 180, 0.5]); const examInfo = el(doc, 'dl', 'info-grid'); const examMessage = el(doc, 'p', 'explanation'); const examActions = el(doc, 'div', 'task-actions'); const examPause = taskButton(examActions, '暂停考试任务', 'examPause'); const examResume = taskButton(examActions, '继续考试任务', 'examResume'); examIntro.append(examInfo, el(doc, 'p', 'threshold', '预计得分必须严格大于 80 分;正好 80 分等待人工。'), examMessage, examActions, el(doc, 'p', 'hint', '仅已精确匹配、点击并核验完整答案文字的题目计分。进入原试卷满设定时长、核验完成且满足条件后才自动交卷;预计分数以本地题库为依据。练习与课中题不使用考试交卷门槛。')); views.exam.insertBefore(examIntro, switches); const bankCounts = el(doc, 'p', 'bank-counts'); bankSection.appendChild(bankCounts); const associationSection = el(doc, 'details', 'page-section'); associationSection.appendChild(el(doc, 'summary', 'section-title', '课程与考试主题关联')); const associationTitle = el(doc, 'input'); associationTitle.type = 'text'; associationTitle.placeholder = '课程或考试的完整标题'; associationTitle.setAttribute('aria-label', '关联的完整标题'); const associationCourses = el(doc, 'textarea'); associationCourses.placeholder = '填写题库中的完整课程名,多个课程用换行或逗号分隔'; associationCourses.setAttribute('aria-label', '关联题库课程'); associationCourses.style.cssText = 'font:inherit;width:100%;min-height:70px;margin-top:7px;padding:8px;border:1px solid #cfd8e7;border-radius:7px;resize:vertical;color:#17253d;background:white;'; const associationActions = el(doc, 'div', 'task-actions'); const saveAssociation = el(doc, 'button', '', '保存关联'); saveAssociation.type = 'button'; const deleteAssociation = el(doc, 'button', '', '删除关联'); deleteAssociation.type = 'button'; associationActions.append(saveAssociation, deleteAssociation); const associationList = el(doc, 'p', 'theme-associations'); associationSection.append(associationTitle, associationCourses, associationActions, el(doc, 'p', 'hint', '填写精确标题和题库课程名称。相似关键词只能帮助查找,不能授权自动答题。'), associationList); views.bank.appendChild(associationSection); let associationSignature = ''; associationTitle.addEventListener('change', function () { associationCourses.value = ((state.learning.associations || {})[normalized(associationTitle.value)] || []).join('\n'); }); saveAssociation.addEventListener('click', function () { const title = normalized(associationTitle.value); const courses = Array.from(new Set(associationCourses.value.split(/[\n,,]+/).map(normalized).filter(Boolean))); if (!title || !courses.length) { setNotice('请填写完整标题及至少一个完整题库课程名。', 'warning'); return; } const knownCourses = (state.courses || []).map(function (course) { return typeof course === 'string' ? course : plain(course.value == null ? course.course || course.name : course.value); }); const missing = courses.filter(function (course) { return !knownCourses.includes(course); }); if (missing.length) { setNotice('题库中没有找到这些完整课程名:' + missing.join('、'), 'warning'); return; } const associations = Object.assign({}, state.learning.associations || {}); associations[title] = courses; state.learning.associations = associations; invoke('onLearningSetting', 'associations', associations); refreshLearning(); setNotice('主题关联已保存,将重新检查受影响的答题任务。', 'success'); }); deleteAssociation.addEventListener('click', function () { const title = normalized(associationTitle.value); if (!title || !Object.prototype.hasOwnProperty.call(state.learning.associations || {}, title)) { setNotice('该完整标题尚未保存关联。', 'warning'); return; } const associations = Object.assign({}, state.learning.associations || {}); delete associations[title]; state.learning.associations = associations; associationCourses.value = ''; invoke('onLearningSetting', 'associations', associations); refreshLearning(); setNotice('主题关联已删除。', 'success'); }); views.console.appendChild(el(doc, 'h2', 'section-title', '运行控制台')); const logActions = el(doc, 'div', 'console-actions'); const logFilter = el(doc, 'select', 'console-filter'); logFilter.setAttribute('aria-label', '日志级别'); [['all', '全部日志'], ['info', '运行状态'], ['warning', '警告'], ['error', '错误']].forEach(function (entry) { const option = el(doc, 'option', '', entry[1]); option.value = entry[0]; logFilter.appendChild(option); }); const copyLog = el(doc, 'button', '', '复制日志'); copyLog.type = 'button'; copyLog.addEventListener('click', function () { invoke('onLogAction', 'copy'); }); const clearLog = el(doc, 'button', '', '清空显示'); clearLog.type = 'button'; clearLog.addEventListener('click', function () { invoke('onLogAction', 'clear'); }); logActions.append(logFilter, copyLog, clearLog); const logCount = el(doc, 'p', 'hint'); const logList = el(doc, 'div', 'log-list'); logList.setAttribute('data-testid', 'learning-logs'); views.console.append(logActions, logCount, logList); logFilter.addEventListener('change', function () { refreshLearning(); }); const recoverySection = el(doc, 'div', 'page-section'); recoverySection.appendChild(el(doc, 'h2', 'section-title', '异常恢复')); learningSetting(recoverySection, 'stallSeconds', '停滞检查(秒)', null, 60, [5, 600, 1]); learningSetting(recoverySection, 'retryDelaySeconds', '续播等待(秒)', null, 15, [1, 120, 1]); learningSetting(recoverySection, 'maxRefresh', '最多自动刷新', null, 3, [0, 10, 1]); recoverySection.appendChild(el(doc, 'p', 'hint', '主动暂停、课中题、等待平台确认、等待交卷和等待人工不会触发故障刷新。两次刷新至少间隔 30 秒。')); views.settings.append(recoverySection, el(doc, 'p', 'explanation', '切换标签页或最小化后尽量继续运行,回到前台会按网页实际状态核对。浏览器关闭、设备休眠或标签冻结期间无法保证继续;不会虚增平台学习时长。')); function autoUnavailableReason() { if (!(Number(state.bankTotal) > 0)) return '请先在题库搜索栏导入统一题库。'; 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 || '请人工核对。')); }); } const learningStatusNames = { idle: '尚未开始', ready: '等待开始', running: '运行中', loading: '加载中', scanning: '识别任务中', video: '视频学习中', playing: '播放中', quiz: '课中答题', exam: '考试答题中', answering: '答题中', preflight: '题库预检', waiting: '等待人工', waitingHuman: '等待人工', waitingSubmit: '等待交卷', waitingPlatform: '等待平台确认', paused: '已暂停', stopped: '已停止', completed: '已完成', done: '已完成', skipped: '已跳过', failed: '失败', error: '异常', retrying: '恢复中', pending: '待处理', submitted: '已交卷', finished: '本轮结束', unknown: '结果待确认', submitting: '提交中', confirming: '确认交卷中', expanded: '已读取目录' }; function statusName(value) { return value === 'awaitingAttempt' ? '等待确认新场次' : learningStatusNames[plain(value)] || plain(value) || '待处理'; } function firstValue(object, keys, fallback) { object = object || {}; for (let i = 0; i < keys.length; i += 1) if (object[keys[i]] != null && object[keys[i]] !== '') return object[keys[i]]; return fallback; } function duration(value) { if (value == null || value === '' || !Number.isFinite(Number(value))) return '待确认'; const seconds = Math.max(0, Math.ceil(Number(value))); return (seconds >= 3600 ? Math.floor(seconds / 3600) + ':' : '') + String(Math.floor(seconds / 60) % 60).padStart(2, '0') + ':' + String(seconds % 60).padStart(2, '0'); } function infoRows(container, entries) { container.replaceChildren(); entries.forEach(function (entry) { container.append(el(doc, 'dt', '', entry[0]), el(doc, 'dd', '', entry[1])); }); } let taskSignature = '', logSignature = ''; function refreshLearning() { const learning = state.learning || {}; const video = learning.video || {}, exam = learning.exam || {}; const total = Math.max(0, Number(learning.total) || 0), completed = Math.max(0, Number(learning.completed) || 0); const paused = ['paused', 'stopped'].includes(learning.status); const active = !['idle', 'ready', 'stopped', 'completed', 'done', 'finished'].includes(learning.status || 'idle'); setText(globalStatus, statusName(learning.status) + (Number(learning.waiting) > 0 ? ' · ' + Number(learning.waiting) + ' 项等待人工' : '')); setText(globalPause, paused ? '继续全部' : '暂停全部'); globalPause.disabled = !active && !paused; globalProgress.max = Math.max(total, 1); globalProgress.value = Math.min(completed, total); setText(globalProgressLabel, completed + ' / ' + total + ' 已完成'); const videoTitle = plain(firstValue(video, ['title', 'course', 'name'], '暂无视频任务')); const examTitle = plain(firstValue(exam, ['title', 'theme', 'name'], '暂无考试任务')); const videoState = statusName(video.status || 'idle'), examState = statusName(exam.status || 'idle'); setText(workerStatus, '视频:' + videoState + ' 考试:' + examState + (learning.message ? '\n' + plain(learning.message) : '')); setText(collapseSummary, completed + '/' + total + (Number(learning.waiting) > 0 ? ' · 待人工' : paused ? ' · 已暂停' : '')); collapseSummary.hidden = Boolean(state.expanded); setText(learningMessage, learning.message || '在学习计划、课程或考试页面开始本轮,先读取实际任务,再按网页状态执行。人工刷新后需继续上次任务。'); Object.keys(summaryNodes).forEach(function (key) { setText(summaryNodes[key], key === 'etaSeconds' ? duration(learning.etaSeconds) : Math.max(0, Number(learning[key]) || 0)); }); startTask.disabled = active; resumeTask.disabled = !paused && !['waiting', 'waitingHuman'].includes(learning.status); stopTask.disabled = !active && !paused; setText(videoSummary, '视频 · ' + videoState + '\n' + videoTitle); setText(examSummary, '考试 · ' + examState + '\n' + examTitle); const tasks = Array.isArray(learning.tasks) ? learning.tasks : []; const taskNextSignature = JSON.stringify(tasks); if (taskNextSignature !== taskSignature || !taskList.childElementCount) { taskSignature = taskNextSignature; taskList.replaceChildren(); if (!tasks.length) taskList.appendChild(el(doc, 'p', 'empty', '尚未读取到任务。完成状态以平台记录为准,跳过项目不会计为已完成。')); tasks.forEach(function (task) { const row = el(doc, 'article', 'task-row'); row.append(el(doc, 'p', 'task-name', firstValue(task, ['title', 'name', 'course'], '未命名任务')), el(doc, 'div', 'task-state', ([(task.kind || task.type) === 'video' ? '视频' : (task.kind || task.type) === 'exam' ? '考试' : (task.kind || task.type) === 'course' ? '课程' : task.kind || task.type, statusName(task.status), task.message || task.reason].filter(Boolean)).join(' · '))); taskList.appendChild(row); }); } Object.keys(learningControls).forEach(function (key) { const setting = learningControls[key]; if (root.activeElement !== setting.input) setting.input.value = plain(learning[key] == null ? setting.fallback : learning[key]); }); const videoEnabled = learning.mode !== 'exam'; setText(videoModeNote, videoEnabled ? '' : '本模式未启用视频;可查看设置与记录。'); videoModeNote.hidden = videoEnabled; const examEnabled = learning.mode === 'exam' || learning.mode === 'all' || !learning.mode; setText(examModeNote, examEnabled ? '' : '本模式未启用独立考试;仍可操作当前练习页。'); examModeNote.hidden = examEnabled; const videoDuration = firstValue(video, ['duration', 'durationSeconds', 'totalSeconds'], null); const learnedTime = firstValue(video, ['learnedTime', 'learnedSeconds', 'platformSeconds'], null); infoRows(videoInfo, [['当前课程', videoTitle], ['播放进度', duration(firstValue(video, ['currentTime', 'position', 'positionSeconds'], null)) + ' / ' + duration(videoDuration)], ['平台记录', duration(learnedTime)], ['平台完成', video.isLearned === true || video.completed === true ? '已确认完成' : '尚未确认'], ['实际倍速', firstValue(video, ['actualRate', 'playbackRate', 'rate'], null) == null ? '等待播放器' : plain(firstValue(video, ['actualRate', 'playbackRate', 'rate'], 1)) + 'X'], ['课中题', firstValue(video, ['quizStatus', 'quizMessage'], '未发现弹题')], ['恢复次数', plain(firstValue(video, ['refreshCount', 'retries', 'retryCount'], 0))]]); setText(videoMessage, video.message || video.reason || '等待视频任务。播放结束与平台确认完成分别记录。'); const videoHasTask = Boolean(video.title || video.name || video.course || video.taskId || video.id); videoPause.disabled = !videoHasTask || ['paused', 'completed', 'done', 'idle'].includes(video.status || 'idle'); videoResume.disabled = !videoHasTask || !['paused', 'waiting', 'waitingHuman', 'failed', 'error'].includes(video.status); infoRows(examInfo, [['考试主题', examTitle], ['题库预检', plain(firstValue(exam, ['preflightMessage', 'themeStatus', 'preflight'], '尚未预检'))], ['预计得分', firstValue(exam, ['predictedScore', 'score'], null) == null ? '待核算' : plain(firstValue(exam, ['predictedScore', 'score'], 0)) + ' 分(题库估计)'], ['网页剩余', duration(firstValue(exam, ['remainingSeconds', 'remainingTime'], null))], ['进入试卷', duration(firstValue(exam, ['elapsedSeconds', 'elapsedTime'], null))], ['最早交卷', duration(firstValue(exam, ['submitWaitSeconds', 'waitSeconds'], null))], ['交卷状态', statusName(exam.status || 'idle')]]); setText(examMessage, exam.message || exam.reason || '先确认主题与题库对应。没有对应题库则跳过;不明确的答案等待人工。'); const examHasTask = Boolean(exam.title || exam.name || exam.theme || exam.taskId || exam.id); examPause.disabled = !examHasTask || ['paused', 'submitted', 'completed', 'done', 'idle'].includes(exam.status || 'idle'); examResume.disabled = !examHasTask || !['paused', 'waiting', 'waitingHuman', 'failed', 'error'].includes(exam.status); tabs.video.setAttribute('data-alert', String(['waiting', 'waitingHuman', 'failed', 'error'].includes(video.status))); tabs.exam.setAttribute('data-alert', String(['waiting', 'waitingHuman', 'failed', 'error'].includes(exam.status))); const associations = learning.associations && typeof learning.associations === 'object' ? learning.associations : {}; const nextAssociationSignature = JSON.stringify(associations); if (!associationTitle.value && learning.currentTitle && root.activeElement !== associationTitle) associationTitle.value = plain(learning.currentTitle); if (associationSignature !== nextAssociationSignature) { associationSignature = nextAssociationSignature; setText(associationList, Object.keys(associations).map(function (title) { return title + ' → ' + (Array.isArray(associations[title]) ? associations[title] : []).join('、'); }).join('\n') || '暂无手工主题关联。'); if (root.activeElement !== associationCourses) associationCourses.value = (associations[normalized(associationTitle.value)] || []).join('\n'); } const logs = (Array.isArray(learning.logs) ? learning.logs : []).slice(-500); const filtered = logs.filter(function (entry) { return logFilter.value === 'all' || (entry.level === 'warn' ? 'warning' : entry.level || 'info') === logFilter.value; }); const nextLogSignature = JSON.stringify([logFilter.value, logs]); if (nextLogSignature !== logSignature || !logList.childElementCount) { logSignature = nextLogSignature; logList.replaceChildren(); if (!filtered.length) logList.appendChild(el(doc, 'p', 'empty', logs.length ? '该级别暂无日志。' : '暂无日志。清空显示不会删除任务检查点。')); filtered.forEach(function (entry) { const level = entry.level === 'warn' ? 'warning' : entry.level || 'info'; const row = el(doc, 'div', 'log-entry ' + level); let time = plain(entry.time || ''); if (typeof entry.time === 'number' || /^\d{4}-\d{2}-\d{2}T/.test(time)) { const date = new Date(entry.time); if (!Number.isNaN(date.getTime())) time = date.toLocaleTimeString('zh-CN', { hour12: false }); } row.append(el(doc, 'span', 'log-time', time), doc.createTextNode((entry.task ? '[' + plain(entry.task) + '] ' : '') + plain(entry.message))); logList.appendChild(row); }); } setText(logCount, '显示 ' + filtered.length + ' 条 / 最近 ' + logs.length + ' 条;最多保留 500 条。'); } let courseSignature = ''; function refresh() { if (destroyed) return; panel.classList.toggle('collapsed', !state.expanded); body.hidden = !state.expanded; brand.textContent = '谊安学助手 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') + ' 条来源记录'; bankCounts.textContent = '题目 ' + Number(state.uniqueTotal || 0).toLocaleString('zh-CN') + ' 道 · 答案冲突 ' + Number(state.conflictTotal || 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; 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 = 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); refreshLearning(); selectTab(views[state.activeTab] ? state.activeTab : 'overview', false); } 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 = '统一题库 · 找到 ' + resultTotal.toLocaleString('zh-CN') + ' 道题'; results.replaceChildren(); if (!items.length) results.appendChild(el(doc, 'div', 'empty', Number(state.bankTotal) > 0 ? '未找到题目,可调整关键词或课程筛选。' : '导入统一题库后,输入题干、关键词或选项文字查找答案。')); 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; } if (Object.prototype.hasOwnProperty.call(update, 'learning')) { update.learning = Object.assign({}, state.learning || {}, update.learning || {}); } 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);