Files
yianxue-assistant/assistant-panel.js
T

977 lines
74 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 独立题库助手面板:页面正文与面板样式互不影响。 */
(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, manualConfirmIncomplete: true, 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 learningSwitch(container, key, label, fallback) {
const control = makeSwitch(label, function () {
state.learning[key] = control.input.checked;
invoke('onLearningSetting', key, control.input.checked);
refreshLearning();
}, container);
control.input.setAttribute('data-testid', 'setting-' + key);
learningControls[key] = { input: control.input, fallback: fallback, switch: true };
return control.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]);
learningSwitch(examIntro, 'manualConfirmIncomplete', '题目不全时手动确认', true);
const examInfo = el(doc, 'dl', 'info-grid');
const examMessage = el(doc, 'p', 'explanation');
const examThreshold = el(doc, 'p', 'threshold');
const incompleteHint = el(doc, 'p', 'hint');
examThreshold.setAttribute('data-testid', 'exam-threshold');
incompleteHint.setAttribute('data-testid', 'incomplete-confirmation-hint');
const examActions = el(doc, 'div', 'task-actions');
const examPause = taskButton(examActions, '暂停考试任务', 'examPause');
const examResume = taskButton(examActions, '继续考试任务', 'examResume');
examIntro.append(examInfo, examThreshold, examMessage, examActions, incompleteHint);
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 (setting.switch) setting.input.checked = learning[key] == null ? setting.fallback : learning[key] !== false;
else if (root.activeElement !== setting.input) setting.input.value = plain(learning[key] == null ? setting.fallback : learning[key]);
});
const manualConfirmIncomplete = learning.manualConfirmIncomplete !== false;
setText(examThreshold, manualConfirmIncomplete
? '必须整卷每题核验且预计得分严格大于 80 分;题目不全时由用户在原网页手动确认交卷。'
: '允许题库部分缺题时自动交卷:已核验预计得分严格大于 80 分,所有可匹配题均已核验,且已满最早交卷时间。');
setText(incompleteHint, (manualConfirmIncomplete
? '本开关已开启:存在未匹配、冲突或未核验题时,即使超过 80 分、已满交卷时长也不自动交卷;网页提示未答完时同样等待人工。'
: '本开关已关闭:仅已精确匹配、点击并核验完整答案文字的题目计分,其余题目不计分;满足得分、计时与当前试卷核验条件后可自动交卷。')
+ '切换会暂停当前计划,请在任务总览点击继续重新核验。无论开关状态,试卷未完整加载均禁止自动交卷;预计得分正好 80 分等待人工。练习与课中题不使用考试交卷门槛。');
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'], '尚未预检'))],
['全文核验', exam.total == null ? '待确认' : plain(exam.verifiedCount || 0) + ' / ' + plain(exam.total) + ' 题'],
['交卷确认', exam.submitted || exam.status === 'submitted' ? '网站已确认交卷' : exam.requiresManualSubmission ? '题目未全部核验或网站提示未答完,请在原网页手动确认' : manualConfirmIncomplete ? '整卷每题核验后才按条件自动交卷' : '已核验得分超过 80 分并满最早交卷时间后才按条件自动交卷'],
['预计得分', 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);