977 lines
74 KiB
JavaScript
977 lines
74 KiB
JavaScript
/* 独立题库助手面板:页面正文与面板样式互不影响。 */
|
||
(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);
|