diff --git a/README.md b/README.md index 0543b00..c292bda 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,16 @@ 1. 在 Chrome / Edge 中安装并启用油猴(Tampermonkey)。 2. 新建脚本,将 `谊安学助手 2.0.user.js` 的全部内容粘贴进去,保存。脚本显示名称为“谊安学助手 2.0”。升级时替换原脚本全文,不要同时启用新旧两份脚本。 -3. 刷新网页,点击右下角“题库助手”入口展开面板。 +3. 刷新谊安学网页,点击右下角“题库助手”入口展开面板。拖动顶部标题栏可移动面板,位置会保存。 4. 选择“主操 / 班长 / 副操”,点击“导入题库”,选择 ZIP、CSV 或 JSON 文件。 -脚本使用 `@match *://*/*` 和 `@match file:///*`,不固定域名、IP、端口或网址路径,换地址后无需修改脚本。普通网页均显示可折叠入口;`chrome://` 等浏览器限制页面无法注入。访问本地 HTML 时,需在扩展设置中开启“允许访问文件网址”。 +脚本仅在 `http://10.119.67.7/clientcn/*` 生效,覆盖 `/clientcn/main`、它的下级路径,以及同目录的 `/clientcn/exam/` 练习和考试页面。其他域名、HTTPS、其他端口、目录外页面及本地文件不显示面板,也不解除复制限制。除油猴 `@match` 外,脚本启动时再次核对网址。 -**网址通用不等于所有平台都能自动答题。** 自动选择适配提供的谊安学桌面练习页,以及明确为 `flag=test`、完整加载并且仍可作答的桌面考试页。未知模式、移动端虚拟翻页试卷、已提交或计时结束的考试不会自动选择。`file:` 保存网页只用于搜索、复制和核对;原练习页可导出,不执行自动选择。 +同一网页内切换路由离开 `/clientcn/` 时,脚本停止自动选择和导出、隐藏面板并恢复复制限制;返回范围内后重新显示面板,自动选择与导出保持关闭。 + +标题栏支持鼠标和触屏拖动,展开或收起都可移动;拖拽不会触发展开/收起。按钮、搜索输入和正文滚动保持原操作。刷新或切换页面会恢复保存位置;窗口缩小、面板展开或内容变高时自动调整到可见区域。 + +自动选择适配提供的谊安学桌面练习页,以及明确为 `flag=test`、完整加载并且仍可作答的桌面考试页。未知模式、移动端虚拟翻页试卷、已提交或计时结束的考试不会自动选择。下载的 HTML 可使用独立的“离线导出题库”工具解析。 ## 导入题库与搜索答案 @@ -28,7 +32,7 @@ - ZIP 内按“主操 / 班长 / 副操”目录自动归类;成功导入后全量替换已有题库。提供的 `题库.zip` 已核对为 28 个 CSV、5,520 条记录:主操 1,620、班长 2,340、副操 1,560。 - 单个或多个 CSV / JSON 建议先选择岗位;文件缺少岗位信息时,使用当前选择的岗位。导入会更新相同岗位、课程和来源的数据,保留其他岗位及其他来源。 - 所有文件先解析和验证,再保存。题干、选项或参考答案无效时,会提示文件及题目位置,原题库保留。 -- 油猴存储在同一浏览器中跨网址共享题库及岗位设置,刷新后无需重新导入。无油猴存储 API 时,回退为当前网站的本地存储,面板会明确提示;此模式不能跨网址共享。 +- 油猴存储在同一浏览器中共享题库、岗位、复制设置及面板位置,站点内切换页面或刷新后无需重新导入。无油猴存储 API 时,回退为当前网站的本地存储,面板会明确提示。 搜索时先选择岗位,可再按课程筛选。输入题干、选项文字或关键词后点击“搜索”;多个关键词用空格分开,结果须同时包含所有关键词。也可选中网页题目文字后点击“用选中文字搜索”。切换岗位会保留当前搜索词并重新查询。 @@ -75,7 +79,7 @@ ## 开发与验证 -`question-bank-core.js` 负责网页提取和导出;`bank-store.js`、`answer-matcher.js` 负责本地题库和匹配搜索;`assistant-panel.js`、`copy-controller.js`、`answer-runner.js` 分别负责面板、复制开关和真实选项点击;`practice-exporter.js` 负责页面识别及功能协调。发布脚本已构建,安装时只需一个 `.user.js` 文件。 +`site-scope.js` 负责站点范围;`question-bank-core.js` 负责网页提取和导出;`bank-store.js`、`answer-matcher.js` 负责本地题库和匹配搜索;`assistant-panel.js`、`copy-controller.js`、`answer-runner.js` 分别负责面板拖拽、复制开关和真实选项点击;`practice-exporter.js` 负责页面识别及功能协调。发布脚本已构建,安装时只需一个 `.user.js` 文件。 修改源码后重新构建: diff --git a/assistant-panel.js b/assistant-panel.js index 034c83a..4e52265 100644 --- a/assistant-panel.js +++ b/assistant-panel.js @@ -21,12 +21,20 @@ 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 + canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false, position: null }; let destroyed = false; let selectionText = ''; let currentOffset = 0; let resultTotal = 0; + let drag = null; + let suppressHeaderClick = false; + let lastPositionInput = 'unset'; + let appliedPosition = 'default'; + let clampFrame = null; + let notifyClamp = false; + let resizeObserver = null; + let previousExpanded = false; const host = doc.createElement('div'); host.id = 'yian-question-assistant'; host.setAttribute('data-yian-assistant', ''); @@ -39,6 +47,7 @@ .panel{width:354px;max-width:calc(100vw - 24px);background:white;border:1px solid #dce3ee;border-radius:13px;box-shadow:0 8px 36px #14264528;overflow:hidden}.header{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;background:#172a49;color:white}.brand{font-size:15px;font-weight:700;letter-spacing:.2px}.toggle{border:0;background:#ffffff17;color:white;padding:3px 9px;border-radius:5px;min-width:30px}.toggle:hover:enabled{background:#ffffff2c;color:white;border-color:transparent}.body{padding:13px;max-height:calc(100vh - 102px);max-height:calc(100dvh - 102px);overflow:auto;overscroll-behavior:contain}.body[hidden]{display:none}.panel.collapsed{width:auto;min-width:140px}.collapsed .header{padding:10px 12px;gap:16px}.switches{display:grid;gap:9px;padding-bottom:12px;border-bottom:1px solid var(--line)}.switch-row{display:flex;align-items:center;justify-content:space-between;position:relative;gap:12px;cursor:pointer;font-weight:600}.switch{appearance:none;-webkit-appearance:none;width:35px;height:21px;margin:0;border:0!important;border-radius:20px;background:#b7c0ce;position:relative;transition:background .15s}.switch::after{content:"";position:absolute;width:15px;height:15px;top:3px;left:3px;border-radius:50%;background:white;box-shadow:0 1px 3px #0002;transition:transform .15s}.switch:checked{background:var(--accent)}.switch:checked::after{transform:translateX(14px)}.hint{font-size:12px;color:var(--muted);margin:6px 0 0;overflow-wrap:anywhere}.section{padding-top:12px}.section-title{font-size:13px;font-weight:700;margin:0 0 7px}.import-row{display:flex;align-items:center;gap:10px}.import-row button{white-space:nowrap}.bank-info{font-size:12px;color:var(--muted);overflow-wrap:anywhere}.storage{color:#97702e;margin:4px 0 0;font-size:11px}.roles{display:flex;gap:6px;flex-wrap:wrap}.role{display:flex;align-items:center;gap:4px;padding:6px 8px;border:1px solid var(--line);border-radius:7px;cursor:pointer;background:white;flex:1;justify-content:center;white-space:nowrap}.role.active{background:#edf3ff;border-color:#abc5fa;color:#194dab}.role input{margin:0}.role-count{font-size:10px;color:var(--muted)}.course-row{display:grid;grid-template-columns:36px 1fr;gap:7px;align-items:center;margin-top:8px}.search-row{display:flex;gap:6px;margin-top:9px}.search-row input{flex:1}.primary{background:var(--accent);border-color:var(--accent);color:white;white-space:nowrap}.primary:hover:enabled{background:#184baf;color:white;border-color:#184baf}.selection-search{margin-top:7px;font-size:12px;width:100%}.notice{padding:8px 9px;border-radius:7px;margin:10px 0 0;font-size:12px;overflow-wrap:anywhere;background:#edf3ff;color:#254e8b}.notice.warning{background:#fff5df;color:#8a5d11}.notice.error{background:#fff0ed;color:#a23a2c}.notice.success{background:#eaf7f0;color:#1e754c}.notice[hidden]{display:none}.results-title{font-size:12px;color:var(--muted);margin:11px 0 7px}.empty{background:var(--surface);border-radius:8px;padding:13px 10px;color:var(--muted);font-size:12px}.result{border:1px solid var(--line);border-radius:9px;padding:10px;margin-bottom:9px;-webkit-user-select:text;user-select:text}.question{font-weight:600;line-height:1.6;overflow-wrap:anywhere;margin:0 0 7px;white-space:pre-wrap}.type{display:inline-block;font-size:10px;font-weight:500;color:#556984;background:#eff3f8;padding:1px 5px;border-radius:4px;margin-right:5px;vertical-align:1px}.answer{font-weight:600;color:#1b714a;background:#edf8f1;border-radius:5px;padding:6px 7px;overflow-wrap:anywhere}.options{padding:0;margin:7px 0;list-style:none;font-size:12px;display:grid;gap:4px}.option{padding:3px 6px;border-radius:4px;overflow-wrap:anywhere;white-space:pre-wrap}.option.correct{color:#196442;background:#edf8f1;font-weight:600}.sources{font-size:11px;color:var(--muted);white-space:pre-wrap;overflow-wrap:anywhere;margin-top:5px}.conflict{color:#9d6012;background:#fff5df;border-radius:5px;font-size:11px;padding:6px;margin:6px 0}.copy-result{margin-top:8px;font-size:11px}.pagination{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11px;color:var(--muted);margin:5px 0 0}.pagination button{font-size:11px;padding:4px 8px}.page-section{border-top:1px solid var(--line);margin-top:13px;padding-top:11px}.page-status{font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted)}.metrics{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:11px;color:var(--muted);margin-top:6px}.unprocessed{font-size:11px;color:#8b621c;margin-top:7px}.unprocessed summary{cursor:pointer}.unprocessed-list{max-height:155px;overflow:auto;padding:7px;border-radius:6px;background:#fff7e8;margin-top:4px;display:grid;gap:5px;overflow-wrap:anywhere}.exports{display:flex;gap:6px;margin-top:9px}.exports button{flex:1;font-size:12px}.auto-export{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--muted);margin-top:8px}.auto-export input{margin:0}.busy{color:#83692c}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:420px){.body{padding:11px}.panel{width:350px}.role{padding:6px 5px}} `; style.textContent += '.answer-check{font-size:11px;color:#235c43;margin-top:7px}.answer-check summary{cursor:pointer}.answer-check-list{max-height:210px;overflow:auto;overscroll-behavior:contain;margin-top:5px;display:grid;gap:7px}.answer-check-item{border:1px solid #d9eade;border-radius:6px;background:#f0f8f3;padding:8px;overflow-wrap:anywhere;-webkit-user-select:text;user-select:text}.answer-check-question{font-weight:600;margin:0 0 4px;white-space:pre-wrap}.answer-check-course{color:var(--muted);margin:0 0 5px}.answer-check-label{font-weight:600;margin:0 0 3px}.answer-check-options{margin:0;padding:0;list-style:none;display:grid;gap:4px;white-space:pre-wrap}.answer-check-note{font-size:10px;color:var(--muted);margin:5px 0 0}'; + style.textContent += '.header{cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none}.panel.dragging .header{cursor:grabbing}.panel.dragging,.panel.dragging *{-webkit-user-select:none!important;user-select:none!important}'; root.appendChild(style); const visibilityStyle = doc.createElement('style'); visibilityStyle.textContent = '[hidden]{display:none!important}'; @@ -46,6 +55,7 @@ const panel = el(doc, 'section', 'panel collapsed'); panel.setAttribute('aria-label', '谊安学助手 2.0'); const header = el(doc, 'div', 'header'); + header.title = '拖动标题栏移动面板'; const brand = el(doc, 'span', 'brand', '题库助手'); const expand = el(doc, 'button', 'toggle', '展开'); expand.type = 'button'; @@ -73,9 +83,119 @@ } function togglePanel() { state.expanded = !state.expanded; refresh(); } expand.addEventListener('click', togglePanel); - brand.style.cursor = 'pointer'; 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'); @@ -359,6 +479,10 @@ 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)); @@ -485,16 +609,65 @@ function setState(partial) { if (destroyed) return; - Object.assign(state, partial || {}); + const update = Object.assign({}, partial || {}); + let incoming = null, acceptPosition = false; + if (Object.prototype.hasOwnProperty.call(update, 'position')) { + incoming = positionValue(update.position); + const signature = positionSignature(incoming); + acceptPosition = signature !== lastPositionInput && !drag; + lastPositionInput = signature; + delete update.position; + } + Object.assign(state, update); refresh(); + if (acceptPosition) { + applyPosition(incoming); + if (incoming && positionSignature(incoming) !== positionSignature(state.position)) savePosition(); + } } function destroy() { if (destroyed) return; destroyed = true; + const oldDrag = drag; + drag = null; + removeDragListeners(); + if (oldDrag && oldDrag.captured) { + try { if (header.hasPointerCapture(oldDrag.id)) header.releasePointerCapture(oldDrag.id); } catch (_) { /* 节点卸载时浏览器可能已释放。 */ } + } + header.removeEventListener('pointerdown', startDrag); + header.removeEventListener('lostpointercapture', finishDrag); + header.removeEventListener('click', suppressDragClick, true); + expand.removeEventListener('click', togglePanel); + brand.removeEventListener('click', togglePanel); + global.removeEventListener('resize', viewportChanged); + if (global.visualViewport) { + global.visualViewport.removeEventListener('resize', viewportChanged); + global.visualViewport.removeEventListener('scroll', viewportChanged); + } + if (resizeObserver) resizeObserver.disconnect(); + if (clampFrame !== null) global.cancelAnimationFrame(clampFrame); + clampFrame = null; doc.removeEventListener('selectionchange', rememberSelection); host.remove(); } (doc.body || doc.documentElement).appendChild(host); + global.addEventListener('resize', viewportChanged); + if (global.visualViewport) { + global.visualViewport.addEventListener('resize', viewportChanged); + global.visualViewport.addEventListener('scroll', viewportChanged); + } + if (typeof global.ResizeObserver === 'function') { + let sizeSignature = ''; + resizeObserver = new global.ResizeObserver(function (entries) { + const box = entries[0] && entries[0].contentRect; + if (!box) return; + const signature = box.width + ',' + box.height; + if (signature === sizeSignature) return; + sizeSignature = signature; + scheduleClamp(true); + }); + resizeObserver.observe(panel); + } renderSearch({ items: [], total: 0 }); refresh(); return { setState: setState, renderSearch: renderSearch, setNotice: setNotice, destroy: destroy, host: host, root: root }; diff --git a/build-userscript.mjs b/build-userscript.mjs index a2884e2..cf905b0 100644 --- a/build-userscript.mjs +++ b/build-userscript.mjs @@ -3,10 +3,9 @@ import { readFileSync, writeFileSync } from 'node:fs'; const metadata = `// ==UserScript== // @name 谊安学助手 2.0 // @namespace yianxue-question-bank -// @version 2.0.2 -// @description 不绑定网址的题库助手:岗位搜索、答案全文核对、练习及桌面考试自动选择。 -// @match *://*/* -// @match file:///* +// @version 2.0.3 +// @description 谊安学站点助手:可拖动面板、岗位搜索、答案全文核对及自动选择。 +// @match http://10.119.67.7/clientcn/* // @run-at document-idle // @grant GM_getValue // @grant GM_setValue @@ -25,5 +24,9 @@ const zipWrapper = '(function () { const module = { exports: {} }; const exports const modules = ['question-bank-core.js', 'bank-store.js', 'answer-matcher.js', 'copy-controller.js', 'assistant-panel.js', 'answer-runner.js', 'practice-exporter.js']; const source = modules.map(name => readFileSync(new URL('./' + name, import.meta.url), 'utf8')).join('\n'); -writeFileSync(new URL('./谊安学助手 2.0.user.js', import.meta.url), metadata + '\n' + notices + zipWrapper + source, 'utf8'); +const scope = readFileSync(new URL('./site-scope.js', import.meta.url), 'utf8'); +// Also enforce the boundary when a manager injects manually or ignores metadata. +const bundle = '(function () {\n' + scope + '\nif (!YianSiteScope.isAllowedURL(location.href)) return;\n' + + zipWrapper + source + '\n})();\n'; +writeFileSync(new URL('./谊安学助手 2.0.user.js', import.meta.url), metadata + '\n' + notices + bundle, 'utf8'); console.log('已生成 谊安学助手 2.0.user.js'); diff --git a/package.json b/package.json index b5ca8da..a8de4b5 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,16 @@ { "name": "yianxue-question-bank-exporter", - "version": "2.0.2", + "version": "2.0.3", "private": true, - "description": "不绑定网址的谊安学题库助手:岗位搜索、答案全文核对、练习及桌面考试自动选择", + "description": "谊安学站点助手:可拖动面板、岗位搜索、答案全文核对及自动选择", "scripts": { "build": "node build-userscript.mjs", "merge:banks": "node scripts/merge-question-banks.cjs", "pretest": "node build-userscript.mjs", "test": "node tests/run.cjs", "test:assistant": "node tests/assistant.cjs", - "test:exam": "node tests/exam-assistant.cjs" + "test:exam": "node tests/exam-assistant.cjs", + "test:drag": "node tests/panel-drag.cjs" }, "devDependencies": { "playwright": "1.62.1" diff --git a/practice-exporter.js b/practice-exporter.js index dd83701..e90fb97 100644 --- a/practice-exporter.js +++ b/practice-exporter.js @@ -4,15 +4,17 @@ const core = window.QuestionBankCore; const store = window.YianBankStore; const matcher = window.YianAnswerMatcher; - const config = { autoAnswer: false, allowCopy: true, exportEnabled: false, role: '', course: '' }; + const config = { autoAnswer: false, allowCopy: true, exportEnabled: false, role: '', course: '', panelPosition: null }; let snapshot = { records: [], counts: {}, total: 0, revision: '', storage: 'origin' }; - let loading = true, disposed = false, page = null, previous = null; + let loading = true, disposed = false, scopedOut = false, page = null, previous = null; let searchQuery = '', runKey = '', debounceTimer; const exported = new Set(); const settleMs = 1200; const copy = window.YianCopyController.create(); const emptyMetrics = () => ({ selected: 0, pending: 0, missing: 0, conflict: 0, locked: 0, failed: 0, unsupported: 0, total: 0 }); let metrics = emptyMetrics(); + let positionRevision = 0; + const inScope = () => YianSiteScope.isAllowedURL(location.href); function visible(node) { if (!node || node.closest('[hidden],.hidden')) return false; @@ -59,7 +61,7 @@ const route = routeInfo(); const bank = route.root ? core.extract(document, route.sourceUrl) : { title: '', questions: [] }; const signature = route.root ? structureSignature(bank) : ''; - return { ready: !!page && page.ready && !loading && page.key === route.key + return { ready: inScope() && !!page && page.ready && !loading && page.key === route.key && page.root === route.root && page.signature === signature, practice: route.practice, exam: route.exam, mode: route.mode, local: route.local, role: config.role, course: bank.title, sourceUrl: route.sourceUrl, routeKey: route.key, root: route.root, @@ -80,7 +82,7 @@ } function refreshPanel(extra) { panel.setState(Object.assign({ autoAnswer: config.autoAnswer, allowCopy: config.allowCopy, - exportEnabled: config.exportEnabled, role: config.role, course: config.course, courses: courses(), + exportEnabled: config.exportEnabled, position: config.panelPosition, role: config.role, course: config.course, courses: courses(), bankTotal: snapshot.total, roleCounts: snapshot.counts, storageMode: snapshot.storage, loading, metrics }, extra || {})); } function performSearch(query = searchQuery, options = {}) { @@ -111,7 +113,7 @@ refreshPanel(); tick(); return config.autoAnswer; } function setCopy(enabled) { - config.allowCopy = !!enabled; copy.setEnabled(config.allowCopy); refreshPanel(); saveSetting('allowCopy', config.allowCopy); + config.allowCopy = !!enabled; copy.setEnabled(config.allowCopy && inScope()); refreshPanel(); saveSetting('allowCopy', config.allowCopy); } async function importFiles(files) { runner.stop('正在更新题库'); loading = true; runKey = ''; refreshPanel(); panel.setNotice('正在解析题库,请稍候…'); @@ -125,6 +127,7 @@ } finally { loading = false; refreshPanel(); performSearch(searchQuery, { quiet: true }); tick(); } } function exportCurrent(format = 'csv') { + if (!inScope()) throw new Error('当前页面不在谊安学脚本生效范围内'); if (!config.exportEnabled) throw new Error('请先开启“导出题库”开关'); const route = routeInfo(); if (!route.practice) throw new Error('请先进入支持的练习页面'); @@ -144,6 +147,7 @@ } const panel = window.YianAssistantPanel.create({ onAutoChange: setAutoAnswer, onCopyChange: setCopy, + onPositionChange: position => { positionRevision++; config.panelPosition = position; saveSetting('panelPosition', position); }, onRoleChange: role => { setRole(role).catch(error => panel.setNotice(error.message, 'error')); }, onCourseChange: course => { config.course = course; performSearch(); }, onImport: files => { importFiles(files).catch(() => {}); }, onSearch: performSearch, @@ -154,6 +158,21 @@ function tick() { if (disposed) return; + if (!inScope()) { + if (!scopedOut) { + scopedOut = true; + config.autoAnswer = false; config.exportEnabled = false; runKey = ''; + runner.stop('页面已离开脚本生效范围'); copy.setEnabled(false); + panel.host.style.setProperty('display', 'none', 'important'); + previous = page; page = null; metrics = emptyMetrics(); + } + return; + } + if (scopedOut) { + scopedOut = false; + panel.host.style.setProperty('display', 'block', 'important'); + copy.setEnabled(config.allowCopy); + } const route = routeInfo(); if (!route.root) { if (runner.running) runner.stop('当前页面未识别到练习题目'); @@ -225,12 +244,19 @@ runner.destroy(); copy.destroy(); panel.destroy(); } const ready = (async () => { + const positionRevisionAtRead = positionRevision; try { - const values = await Promise.all([store.load(), store.getSetting('role', ''), store.getSetting('allowCopy', true)]); + const values = await Promise.all([store.load(), store.getSetting('role', ''), store.getSetting('allowCopy', true), store.getSetting('panelPosition', null)]); snapshot = values[0]; config.role = store.roles.includes(values[1]) ? values[1] : ''; config.allowCopy = values[2] !== false; + const position = values[3]; + // A drag during asynchronous storage loading wins over the older saved position. + if (positionRevision === positionRevisionAtRead) { + config.panelPosition = position && typeof position.left === 'number' && Number.isFinite(position.left) + && typeof position.top === 'number' && Number.isFinite(position.top) ? { left: position.left, top: position.top } : null; + } } catch (error) { panel.setNotice('题库读取失败:' + error.message + '。可重新导入题库。', 'error'); } - loading = false; copy.setEnabled(config.allowCopy); refreshPanel(); tick(); return snapshot; + loading = false; copy.setEnabled(config.allowCopy && inScope()); refreshPanel(); tick(); return snapshot; })(); const api = Object.freeze({ ready, importFiles, setRole, setAutoAnswer, setCopy, setExportEnabled, search: performSearch, getContext, getSnapshot: () => snapshot, getState: () => ({ ...config, metrics, pageReady: !!page && page.ready }), diff --git a/site-scope.js b/site-scope.js new file mode 100644 index 0000000..33d1afb --- /dev/null +++ b/site-scope.js @@ -0,0 +1,14 @@ +/* Shared URL boundary for userscript injection and same-page navigation. */ +const YianSiteScope = (function () { + 'use strict'; + const origin = 'http://10.119.67.7'; + const basePath = '/clientcn/'; + function isAllowedURL(value) { + try { + const url = new URL(String(value)); + return url.origin === origin && url.pathname.startsWith(basePath); + } catch (_) { return false; } + } + return Object.freeze({ origin, basePath, isAllowedURL }); +})(); +if (typeof window === 'undefined' && typeof module !== 'undefined' && module.exports) module.exports = YianSiteScope; diff --git a/tests/assistant.cjs b/tests/assistant.cjs index c2d174f..a86addc 100644 --- a/tests/assistant.cjs +++ b/tests/assistant.cjs @@ -1,14 +1,14 @@ -/* 完整面板与跨网址存储验证;GM 桥接仅用于本机模拟,不请求业务服务器。 */ +/* 指定主站范围、完整面板与同站跨路径存储验证;所有主站请求均由本机模拟。 */ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const http = require('node:http'); +const { pathToFileURL } = require('node:url'); const { chromium } = require('playwright'); const workspace = path.resolve(__dirname, '..'); const script = fs.readFileSync(path.join(workspace, '谊安学助手 2.0.user.js'), 'utf8'); -assert(script.includes('// @match *://*/*')); -assert(!/\/\/ @match\s+https?:\/\/10\./.test(script)); +assert.deepEqual(Array.from(script.matchAll(/^\/\/ @match\s+(.+)$/gm), match => match[1].trim()), ['http://10.119.67.7/clientcn/*']); assert(script.includes('// @name 谊安学助手 2.0')); const qaDir = process.env.YIAN_QA_DIR || path.join(os.tmpdir(), 'yianxue-assistant-qa'); fs.mkdirSync(qaDir, { recursive: true }); @@ -42,7 +42,7 @@ function practiceHTML() { `

[${q.type}] ${index + 1}. ${q.stem}查看答案

${q.options.map((text, i) => `
${String.fromCharCode(65 + i)}${text}
`).join('')}
`).join('')}
${questions.map((_, i) => `
${i + 1}
`).join('')}
重新练习
`; } function documentHTML(practice) { - return `${practice ? '助手练习验证' : '普通网页验证'}

${practice ? '练习页面' : '任意网址的题库搜索'}

广西华谊新材料有限公司涉及几种高危工艺

${practice ? practiceHTML() : '

网页没有练习题目,仍可使用岗位题库搜索与复制开关。

'}`; @@ -55,13 +55,55 @@ function documentHTML(practice) { res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' }); res.end(documentHTML(req.url.includes('exercise') || req.url.includes('flag=test'))); }); - await new Promise(resolve => server.listen(0, resolve)); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const port = server.address().port; + const localOrigin = 'http://127.0.0.1:' + port; + const origin = 'http://10.119.67.7'; let browser; try { browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true }); const context = await browser.newContext({ viewport: { width: 1366, height: 900 }, acceptDownloads: true }); - await context.exposeBinding('__gmRead', (source, key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback); + const externalRequests = []; + const rejectedURLs = [ + 'https://10.119.67.7/clientcn/main', + 'http://10.119.67.8/clientcn/main', + 'http://localhost/clientcn/main', + 'http://10.119.67.7:8080/clientcn/main', + origin + '/outside/main', + origin + '/clientcn-other/main', + origin + '/clientcn' + ]; + const rejectedNavigation = new Set(rejectedURLs); + await context.route('**/*', async route => { + const request = route.request(), url = new URL(request.url()); + // Even the exact business origin is never continued onto the network. + if (url.origin === origin) { + const response = await route.fetch({ url: localOrigin + url.pathname + url.search }); + return route.fulfill({ response }); + } + // Deliberate negative navigation fixtures are fulfilled without reaching those hosts. + if (request.isNavigationRequest() && rejectedNavigation.has(url.href)) { + return route.fulfill({ status: 200, contentType: 'text/html;charset=utf-8', body: documentHTML(false) }); + } + if (url.origin === localOrigin || url.protocol === 'file:' && url.href.startsWith(pathToFileURL(qaDir).href + '/')) return route.continue(); + externalRequests.push(url.href); return route.abort(); + }); + let delayedPositionPage = null; + let delayedPositionRead = null; + let signalPositionRead = null; + const positionKey = 'yianxue.assistant.setting.v2.panelPosition'; + await context.exposeBinding('__gmRead', (source, key, fallback) => { + const captured = memory.has(key) ? structuredClone(memory.get(key)) : fallback; + // Hold only the first position read from one dedicated page; subsequent reads + // and all other pages/keys remain immediate, including verification of new writes. + if (source.page === delayedPositionPage && key === positionKey && !delayedPositionRead) { + return new Promise(resolve => { + delayedPositionRead = { captured, release: () => resolve(captured) }; + if (signalPositionRead) signalPositionRead(); + }); + } + return captured; + }); await context.exposeBinding('__gmWrite', (source, key, value) => { memory.set(key, structuredClone(value)); }); await context.exposeBinding('__gmCopy', (source, text) => { clipboard = text; }); // API 仅作为沙箱全局变量存在,刻意不放到 window 上。 @@ -71,17 +113,17 @@ function documentHTML(practice) { const errors = []; function capture(page) { page.on('pageerror', e => errors.push(e.message)); page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); }); } const page = await context.newPage(); capture(page); - const startURL = `http://127.0.0.1:${port}/completely-unrelated-path`; + const startURL = origin + '/clientcn/main?from=assistant-test'; await page.goto(startURL); await page.addScriptTag({ content: sandboxScript }); await page.evaluate(() => YianAssistant.ready); - assert.equal(page.url(), startURL); assert.equal(await page.title(), '普通网页验证'); - assert(await page.getByRole('heading', { name: '任意网址的题库搜索' }).isVisible()); + assert.equal(page.url(), startURL); assert.equal(await page.title(), '主站主页验证'); + assert(await page.getByRole('heading', { name: '主站主页的题库搜索' }).isVisible()); await page.getByRole('button', { name: '展开题库助手' }).click(); assert(await page.locator('#yian-question-assistant .brand').textContent() === '谊安学助手 2.0'); assert.equal(await page.getByRole('switch', { name: '导出题库', exact: true }).isChecked(), false); assert(await page.getByRole('switch', { name: '自动选择答案' }).isDisabled()); assert.equal(await page.evaluate(() => typeof window.GM_getValue), 'undefined'); - console.log('PASS 任意路径显示搜索面板,非练习页禁止自动选择,沙箱API兼容'); + console.log('PASS 主站 /clientcn/main 查询参数下显示搜索面板,非练习页禁止自动选择,沙箱 API 兼容'); const input = await bankInput(); await page.locator('#yian-question-assistant input[type=file]').setInputFiles(input.file); @@ -134,7 +176,7 @@ function documentHTML(practice) { console.log('PASS 无效导入提示与原题库保留'); const second = await context.newPage(); capture(second); - const secondURL = `http://localhost:${port}/different/application/route?flag=exercise`; + const secondURL = origin + '/clientcn/exam/nested/fixture/questions?flag=exercise'; await second.goto(secondURL); await second.addScriptTag({ content: sandboxScript }); await second.evaluate(() => YianAssistant.ready); await second.waitForFunction(() => YianAssistant.getState().pageReady); assert.equal(await second.getByRole('switch', { name: '导出题库', exact: true }).isChecked(), false, '旧版导出偏好不能开启新版开关'); @@ -151,9 +193,9 @@ function documentHTML(practice) { await second.getByRole('switch', { name: '自动选择答案' }).check(); await second.waitForTimeout(500); assert.equal(await second.evaluate(() => window.optionClicks.length), 5); await second.getByRole('switch', { name: '自动选择答案' }).uncheck(); - console.log('PASS 更换域名/路径后题库岗位共用,实际选项点击、换序、多选差集与无重复'); + console.log('PASS 同站不同 /clientcn 路径题库岗位共用,实际选项点击、换序、多选差集与无重复'); - await second.evaluate(html => { history.pushState({}, '', '/another/random-route?flag=exercise'); const doc = new DOMParser().parseFromString(html, 'text/html'); document.querySelector('app-examtest').replaceWith(doc.querySelector('app-examtest')); window.optionClicks = []; }, practiceHTML()); + await second.evaluate(html => { history.pushState({}, '', '/clientcn/exam/another/random-route?flag=exercise'); const doc = new DOMParser().parseFromString(html, 'text/html'); document.querySelector('app-examtest').replaceWith(doc.querySelector('app-examtest')); window.optionClicks = []; }, practiceHTML()); await second.waitForFunction(() => YianAssistant.getContext().ready); await second.getByRole('switch', { name: '自动选择答案' }).check(); await second.waitForFunction(() => window.optionClicks.length > 0); @@ -177,7 +219,132 @@ function documentHTML(practice) { await page.setViewportSize({ width: 1366, height: 900 }); await page.locator('#yian-question-assistant .body').evaluate(node => { node.scrollTop = 0; }); await page.locator('#yian-question-assistant').screenshot({ path: path.join(qaDir, 'assistant-panel.png') }); + + // Drag the real header with browser input; persist the released coordinates through GM. + const beforeDrag = await page.locator('#yian-question-assistant').boundingBox(); + const brand = await page.locator('#yian-question-assistant .brand').boundingBox(); + assert(beforeDrag && brand); + await page.mouse.move(brand.x + brand.width / 2, brand.y + brand.height / 2); + await page.mouse.down(); + await page.mouse.move(brand.x + brand.width / 2 - 260, brand.y + brand.height / 2 + 35, { steps: 12 }); + await page.mouse.up(); + const draggedPosition = await page.evaluate(() => YianAssistant.getState().panelPosition); + assert(draggedPosition && Number.isFinite(draggedPosition.left) && Number.isFinite(draggedPosition.top)); + assert(draggedPosition.left < beforeDrag.x - 100, '真实拖拽应改变面板位置'); + assert(await page.locator('#yian-question-assistant .body').isVisible(), '拖动标题不能误触收起'); + await page.waitForFunction(async position => JSON.stringify(await YianBankStore.getSetting('panelPosition', null)) === JSON.stringify(position), draggedPosition); + await page.reload(); await page.addScriptTag({ content: sandboxScript }); await page.evaluate(() => YianAssistant.ready); + assert.deepEqual(await page.evaluate(() => YianAssistant.getState().panelPosition), draggedPosition, '刷新主站后应恢复 GM 保存位置'); + const restoredPosition = await page.locator('#yian-question-assistant').boundingBox(); + assert(Math.abs(restoredPosition.x - draggedPosition.left) < 2 && Math.abs(restoredPosition.y - draggedPosition.top) < 2); + await page.getByRole('button', { name: '展开题库助手' }).click(); + assert(await page.locator('#yian-question-assistant .body').isVisible(), '恢复位置后仍可展开面板'); + console.log('PASS 标题真实鼠标拖拽、不误折叠、GM 保存坐标与刷新主站后的面板位置恢复'); + + // A late initial settings response cannot overwrite a new position chosen by the user. + const positionReadStarted = new Promise(resolve => { signalPositionRead = resolve; }); + memory.set(positionKey, { left: 100, top: 100 }); + delayedPositionPage = await context.newPage(); capture(delayedPositionPage); + await delayedPositionPage.goto(origin + '/clientcn/main?scope=position-read-race'); + await delayedPositionPage.addScriptTag({ content: sandboxScript }); + await positionReadStarted; + assert.deepEqual(delayedPositionRead.captured, { left: 100, top: 100 }, '迟到读取必须返回读取时捕获的旧坐标'); + await delayedPositionPage.evaluate(() => { window.__qaReadyComplete = false; YianAssistant.ready.then(() => { window.__qaReadyComplete = true; }); }); + assert.equal(await delayedPositionPage.evaluate(() => window.__qaReadyComplete), false); + const raceHost = await delayedPositionPage.locator('#yian-question-assistant').boundingBox(); + const raceBrand = await delayedPositionPage.locator('#yian-question-assistant .brand').boundingBox(); + assert(raceHost && raceBrand); + assert(await delayedPositionPage.getByRole('button', { name: '展开题库助手' }).isVisible(), '加载期间仍为可拖动的收起标题'); + const grabOffset = { x: raceBrand.x + raceBrand.width / 2 - raceHost.x, y: raceBrand.y + raceBrand.height / 2 - raceHost.y }; + await delayedPositionPage.mouse.move(raceHost.x + grabOffset.x, raceHost.y + grabOffset.y); + await delayedPositionPage.mouse.down(); + await delayedPositionPage.mouse.move(220 + grabOffset.x, 60 + grabOffset.y, { steps: 14 }); + await delayedPositionPage.mouse.up(); + const newPosition = await delayedPositionPage.evaluate(() => YianAssistant.getState().panelPosition); + assert.deepEqual(newPosition, { left: 220, top: 60 }, '加载完成前的真实拖动应记录新位置'); + await delayedPositionPage.waitForFunction(async position => JSON.stringify(await YianBankStore.getSetting('panelPosition', null)) === JSON.stringify(position), newPosition); + assert.deepEqual(memory.get(positionKey), newPosition, '释放旧读取前,新位置已写入 GM'); + assert.equal(await delayedPositionPage.evaluate(() => window.__qaReadyComplete), false, '拖动和写入必须发生在 ready 等待期间'); + delayedPositionRead.release(); + await delayedPositionPage.evaluate(() => YianAssistant.ready); + assert.equal(await delayedPositionPage.evaluate(() => window.__qaReadyComplete), true); + assert.deepEqual(await delayedPositionPage.evaluate(() => YianAssistant.getState().panelPosition), newPosition, '迟到旧设置不得覆盖当前用户坐标'); + assert.deepEqual(memory.get(positionKey), newPosition, 'GM 中仍应保留新坐标'); + const settledRacePosition = await delayedPositionPage.locator('#yian-question-assistant').boundingBox(); + assert(Math.abs(settledRacePosition.x - newPosition.left) < 2 && Math.abs(settledRacePosition.y - newPosition.top) < 2, 'ready 后的实际面板应停留在用户新位置'); + await delayedPositionPage.close(); delayedPositionPage = null; signalPositionRead = null; + console.log('PASS 初始 GM 位置读取延迟期间真实拖动,迟到旧坐标不覆盖新配置、GM 写值或实际面板位置'); + + // A running SPA session must suspend its UI and copy controller immediately on leaving scope. + await second.evaluate(html => { + history.replaceState({}, '', '/clientcn/exam/scope-start/questions?flag=exercise'); + const doc = new DOMParser().parseFromString(html, 'text/html'); + document.querySelector('app-examtest').replaceWith(doc.querySelector('app-examtest')); + window.optionClicks = []; + dispatchEvent(new PopStateEvent('popstate')); + }, practiceHTML()); + await second.waitForFunction(() => YianAssistant.getContext().ready); + const leftScope = await second.evaluate(() => { + YianAssistant.setCopy(true); + const enabled = YianAssistant.setAutoAnswer(true); + YianAssistant.setExportEnabled(true); + const before = YianAssistant.getState(); + history.pushState({}, '', '/outside/spa-scope?flag=exercise'); + dispatchEvent(new PopStateEvent('popstate')); + return { enabled, before, after: YianAssistant.getState() }; + }); + assert.equal(leftScope.enabled, true); + assert.equal(leftScope.before.autoAnswer, true); assert.equal(leftScope.before.exportEnabled, true); + await second.waitForFunction(() => getComputedStyle(document.querySelector('#yian-question-assistant')).display === 'none'); + assert.equal(leftScope.after.autoAnswer, false); assert.equal(leftScope.after.exportEnabled, false); + assert.equal(await second.evaluate(() => YianAssistant.getContext().ready), false); + assert.deepEqual(await second.evaluate(() => ({ select: document.body.getAttribute('onselectstart'), context: document.body.getAttribute('oncontextmenu'), style: getComputedStyle(document.querySelector('#copy-target')).userSelect })), + { select: 'return false', context: 'return false', style: 'none' }, '离开范围应恢复网页复制限制'); + assert.equal(await second.evaluate(() => YianAssistant.setAutoAnswer(true)), false); + assert((await second.evaluate(() => { try { YianAssistant.exportCurrent('csv'); return ''; } catch (error) { return error.message; } })).includes('范围')); + await second.waitForTimeout(400); + assert.equal(await second.evaluate(() => window.optionClicks.length), 0, '离开范围后不能产生后续选项点击'); + await second.evaluate(() => { history.pushState({}, '', '/clientcn/exam/scope-start/questions?flag=exercise'); dispatchEvent(new PopStateEvent('popstate')); }); + await second.waitForFunction(() => YianAssistant.getContext().ready); + assert(await second.locator('#yian-question-assistant').isVisible()); + assert.equal(await second.evaluate(() => YianAssistant.getState().autoAnswer), false); + assert.equal(await second.evaluate(() => YianAssistant.getState().exportEnabled), false); + assert.deepEqual(await second.evaluate(() => ({ select: document.body.getAttribute('onselectstart'), context: document.body.getAttribute('oncontextmenu'), style: getComputedStyle(document.querySelector('#copy-target')).userSelect })), + { select: null, context: null, style: 'text' }, '返回主站范围应恢复当前复制开关'); + assert.equal(await second.evaluate(() => window.optionClicks.length), 0); + console.log('PASS SPA 离开 /clientcn/ 立即隐藏面板、停止答题/导出并恢复复制限制;返回后恢复面板与复制、自动开关保持关闭'); + + // The outer bundle guard is tested even when code is injected manually, outside metadata scope. + for (const allowedURL of [origin + '/clientcn/main', origin + '/clientcn/main/deep/path?scope=query', 'http://10.119.67.7:80/clientcn/main?scope=default-port']) { + const allowed = await context.newPage(); capture(allowed); + await allowed.goto(allowedURL); await allowed.addScriptTag({ content: sandboxScript }); + await allowed.evaluate(() => YianAssistant.ready); + assert.equal(await allowed.locator('#yian-question-assistant').count(), 1); + assert.equal(await allowed.evaluate(() => typeof JSZip), 'function'); + await allowed.close(); + } + const localPath = path.join(qaDir, 'scope-rejected.html'); + fs.writeFileSync(localPath, documentHTML(false)); + for (const rejectedURL of rejectedURLs.concat(pathToFileURL(localPath).href)) { + const rejected = await context.newPage(); capture(rejected); + await rejected.goto(rejectedURL); + const inspect = () => rejected.evaluate(() => ({ + select: document.body.getAttribute('onselectstart'), context: document.body.getAttribute('oncontextmenu'), + style: document.body.getAttribute('style'), userSelect: getComputedStyle(document.querySelector('#copy-target')).userSelect, + blocked: !document.body.dispatchEvent(new Event('selectstart', { bubbles: true, cancelable: true })), + body: document.body.innerHTML + })); + const before = await inspect(); + await rejected.addScriptTag({ content: sandboxScript }); + assert.equal(await rejected.locator('#yian-question-assistant').count(), 0, rejectedURL); + assert.deepEqual(await rejected.evaluate(() => ['JSZip', 'QuestionBankCore', 'YianBankStore', 'YianAnswerMatcher', 'YianCopyController', 'YianAssistantPanel', 'YianAnswerRunner', 'YianAssistant', 'YianQuestionBankExporter'].map(key => [key, typeof window[key]])), + ['JSZip', 'QuestionBankCore', 'YianBankStore', 'YianAnswerMatcher', 'YianCopyController', 'YianAssistantPanel', 'YianAnswerRunner', 'YianAssistant', 'YianQuestionBankExporter'].map(key => [key, 'undefined']), rejectedURL + ' 整个 bundle 必须不注入'); + assert.deepEqual(await inspect(), before, rejectedURL + ' 不得改变复制限制或正文'); + await rejected.close(); + } + assert.deepEqual(externalRequests, [], '所有非测试目标请求应被阻断;业务 origin 只允许模拟响应'); assert.deepEqual(errors, []); + console.log('PASS HTTP 主站 main / 子目录 / 查询参数有效;HTTPS、其他 host、非80端口、范围外路径、相似目录和 file 均无面板/API/JSZip且不改变复制限制'); console.log('PASS 桌面/手机面板、页面身份与内容、无错误覆盖层/控制台错误'); console.log('截图:' + qaDir); } finally { if (browser) await browser.close(); await new Promise(resolve => server.close(resolve)); } diff --git a/tests/exam-assistant.cjs b/tests/exam-assistant.cjs index 7b2c059..250e768 100644 --- a/tests/exam-assistant.cjs +++ b/tests/exam-assistant.cjs @@ -12,7 +12,7 @@ const scriptPath = path.join(workspace, '谊安学助手 2.0.user.js'); const script = fs.readFileSync(scriptPath, 'utf8'); const coreScript = fs.readFileSync(path.join(workspace, 'question-bank-core.js'), 'utf8'); assert(script.includes('// @name 谊安学助手 2.0')); -assert(script.includes('// @match *://*/*')); +assert.deepEqual(Array.from(script.matchAll(/^\/\/ @match\s+(.+)$/gm), match => match[1].trim()), ['http://10.119.67.7/clientcn/*']); const qaDir = process.env.YIAN_EXAM_QA_DIR || path.join(os.tmpdir(), 'yianxue-exam-qa'); fs.mkdirSync(qaDir, { recursive: true }); const roles = ['主操', '班长', '副操']; @@ -110,11 +110,16 @@ function installExamModel() { await context.exposeBinding('__gmRead', (_, key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback); await context.exposeBinding('__gmWrite', (_, key, value) => { memory.set(key, structuredClone(value)); }); const sandboxScript = '(function(){const GM_getValue=(key,fallback)=>window.__gmRead(key,fallback);const GM_setValue=(key,value)=>window.__gmWrite(key,value);\n' + script + '\n})();'; - let origin = ''; - await context.route('**/*', route => { - const url = route.request().url(); - if (url.startsWith(origin + '/') && origin || url.startsWith(pathToFileURL(qaDir).href + '/')) return route.continue(); - externalRequests.push(url); return route.abort(); + const origin = 'http://10.119.67.7'; + let localOrigin = ''; + await context.route('**/*', async route => { + const url = new URL(route.request().url()); + if (url.origin === origin && localOrigin) { + const response = await route.fetch({ url: localOrigin + url.pathname + url.search }); + return route.fulfill({ response }); + } + if (localOrigin && url.origin === localOrigin || url.protocol === 'file:' && url.href.startsWith(pathToFileURL(qaDir).href + '/')) return route.continue(); + externalRequests.push(url.href); return route.abort(); }); const capture = page => { page.on('pageerror', error => errors.push(error.message)); @@ -139,8 +144,8 @@ function installExamModel() { const pageHTML = documentHTML(fixture); server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' }); res.end(pageHTML); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); - origin = 'http://127.0.0.1:' + server.address().port; - const examURL = origin + '/unfixed/exam-path?flag=test&title=' + encodeURIComponent(fixture.title); + localOrigin = 'http://127.0.0.1:' + server.address().port; + const examURL = origin + '/clientcn/exam/nested/exam-path?flag=test&title=' + encodeURIComponent(fixture.title); const page = await context.newPage(); capture(page); async function open(page, url) { await page.goto(url); @@ -231,7 +236,7 @@ function installExamModel() { YianAssistant.setAutoAnswer(false); const root = document.createElement('app-examtest'); root.innerHTML = html; document.querySelector('app-examtest').replaceWith(root); - history.replaceState({}, '', '/unfixed/case-' + sequence + '?flag=' + mode + '&title=' + encodeURIComponent(title)); + history.replaceState({}, '', '/clientcn/exam/case-' + sequence + '?flag=' + mode + '&title=' + encodeURIComponent(title)); if (mode === 'exercise') { root.querySelector('.pc_examtest_commit').textContent = '重新练习'; root.querySelectorAll('.pc_examtest_content_question p').forEach(p => { const button = document.createElement('span'); button.className = 'pc_examtest_content_question_showanswer'; button.textContent = '查看答案'; p.appendChild(button); }); @@ -305,17 +310,25 @@ function installExamModel() { const filePath = path.join(qaDir, 'saved-exam.html'); fs.writeFileSync(filePath, '\n' + pageHTML); const saved = await context.newPage(); capture(saved); - await open(saved, pathToFileURL(filePath).href); - await saved.waitForFunction(() => YianAssistant.getState().pageReady); - assert.equal(await saved.evaluate(() => YianAssistant.setAutoAnswer(true)), false); - assert.equal(await saved.evaluate(() => examModel.clicks.length), 0); + await saved.goto(pathToFileURL(filePath).href); + const inspectSaved = () => saved.evaluate(() => ({ + body: document.body.innerHTML, select: document.body.getAttribute('onselectstart'), + context: document.body.getAttribute('oncontextmenu'), style: document.body.getAttribute('style'), + userSelect: getComputedStyle(document.body).userSelect, + selected: document.querySelectorAll('.examtest_content_list_des_checked').length + })); + const beforeSaved = await inspectSaved(); + await saved.addScriptTag({ content: sandboxScript }); + assert.equal(await saved.locator('#yian-question-assistant').count(), 0); + assert.deepEqual(await saved.evaluate(() => [typeof window.JSZip, typeof window.QuestionBankCore, typeof window.YianAssistant]), ['undefined', 'undefined', 'undefined']); + assert.deepEqual(await inspectSaved(), beforeSaved, 'file 保存考试不能注入或改变复制限制和选项'); await saved.close(); await reset('exercise'); const navigation = await page.evaluate(({ html, title }) => { const enabled = YianAssistant.setAutoAnswer(true); const root = document.createElement('app-examtest'); root.innerHTML = html; document.querySelector('app-examtest').replaceWith(root); - history.pushState({}, '', '/unfixed/final-exam?flag=test&title=' + encodeURIComponent(title)); + history.pushState({}, '', '/clientcn/exam/final-exam?flag=test&title=' + encodeURIComponent(title)); dispatchEvent(new PopStateEvent('popstate')); return { enabled, afterNavigation: YianAssistant.getState().autoAnswer }; }, { html: fixture.html, title: fixture.title }); @@ -323,7 +336,7 @@ function installExamModel() { assert.equal(navigation.afterNavigation, false); await page.waitForTimeout(250); assert.equal(await page.getByRole('switch', { name: '自动选择答案' }).isChecked(), false); - console.log('PASS 未知 flag、移动页面、file 保存考试拒绝自动选择;练习转考试恢复关闭'); + console.log('PASS 未知 flag、移动页面拒绝自动选择;file 保存考试完全不注入;练习转考试恢复关闭'); assert.deepEqual(externalRequests, [], '测试不允许任何外部业务请求'); assert.deepEqual(downloads, [], '考试不得自动或手动发起题库下载'); diff --git a/tests/panel-drag.cjs b/tests/panel-drag.cjs new file mode 100644 index 0000000..3e6f6d4 --- /dev/null +++ b/tests/panel-drag.cjs @@ -0,0 +1,253 @@ +/* Browser plugin not available. 独立面板拖动回归:本机 HTTP、真实 Chrome 鼠标及 CDP 触屏输入。 */ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const http = require('node:http'); +const { chromium } = require('playwright'); + +const workspace = path.resolve(__dirname, '..'); +const panelSource = fs.readFileSync(path.join(workspace, 'assistant-panel.js'), 'utf8'); +const qaDir = process.env.YIAN_DRAG_QA_DIR || path.join(os.tmpdir(), 'yianxue-drag-qa'); +fs.mkdirSync(qaDir, { recursive: true }); + +function near(actual, expected, message) { assert(Math.abs(actual - expected) <= 2, `${message}: ${actual} != ${expected}`); } + +async function main() { + // URL 无练习路径,证明拖动功能由独立面板模块负责。 + const server = http.createServer((request, response) => { + response.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' }); + response.end('助手拖动验证

题库助手拖动验证

拖动标题栏调整位置。面板内的岗位、搜索和开关保持正常操作。

'); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + let browser; + try { + browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true }); + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { if (message.type() === 'error' || message.type() === 'warning') errors.push(message.text()); }); + const origin = `http://127.0.0.1:${server.address().port}`; + await page.goto(origin + '/panel-only'); + assert.equal(await page.title(), '助手拖动验证'); + assert.equal(page.url(), origin + '/panel-only'); + await page.addScriptTag({ content: panelSource }); + await page.evaluate(() => { + window.qaCalls = { positions: [], auto: [], copy: [], searches: [] }; + window.qaBase = { role: '主操', bankTotal: 60, roleCounts: { 主操: 20, 班长: 20, 副操: 20 }, courses: ['安全课程'], canAnswer: true, canExport: true, pageMode: 'exercise', pageStatus: '已识别练习,可以选择答案。', storageMode: 'userscript' }; + window.qaMakePanel = partial => { + window.qaPanel?.destroy(); + window.qaPanel = YianAssistantPanel.create({ + onPositionChange: value => window.qaCalls.positions.push({ left: value.left, top: value.top }), + onAutoChange: value => window.qaCalls.auto.push(value), + onCopyChange: value => window.qaCalls.copy.push(value), + onSearch: (query, settings) => window.qaCalls.searches.push({ query, settings }) + }); + window.qaPanel.setState(Object.assign({}, window.qaBase, partial || {})); + }; + window.qaMakePanel({ expanded: false }); + }); + + const host = page.locator('#yian-question-assistant'); + const brand = host.locator('.brand'); + const toggle = host.locator('.toggle'); + async function rect() { + return page.evaluate(() => { + const box = window.qaPanel.host.getBoundingClientRect(); + return { left: box.left, top: box.top, width: box.width, height: box.height, right: box.right, bottom: box.bottom, viewportWidth: innerWidth, viewportHeight: innerHeight }; + }); + } + async function visible() { + await page.waitForFunction(() => { + const panel = window.qaPanel.host; + const box = panel.getBoundingClientRect(); + const header = window.qaPanel.root.querySelector('.header').getBoundingClientRect(); + return box.width > 0 && box.height > 0 && box.left >= -1 && box.top >= -1 && box.right <= innerWidth + 1 && box.bottom <= innerHeight + 1 && header.height > 0; + }); + return rect(); + } + async function positionSaves() { return page.evaluate(() => window.qaCalls.positions.length); } + async function isExpanded() { return toggle.getAttribute('aria-expanded'); } + async function startMouse(locator) { + const box = await locator.boundingBox(); + assert(box, '拖动目标必须可见'); + const point = { x: box.x + Math.min(box.width / 2, 50), y: box.y + box.height / 2 }; + await page.mouse.move(point.x, point.y); + await page.mouse.down(); + return point; + } + async function dragTo(left, top) { + const before = await rect(); + const point = await startMouse(brand); + await page.mouse.move(point.x + left - before.left, point.y + top - before.top, { steps: 8 }); + await page.mouse.up(); + return visible(); + } + async function screenshot(filename) { await page.screenshot({ path: path.join(qaDir, filename), fullPage: false }); } + + const initial = await visible(); + near(initial.viewportWidth - initial.right, 16, '默认右侧间距'); + near(initial.viewportHeight - initial.bottom, 16, '默认底部间距'); + assert.equal(await isExpanded(), 'false'); + assert((await host.textContent()).trim().length > 0 || await brand.textContent(), '面板必须呈现内容'); + await screenshot('01-desktop-default.png'); + console.log('PASS independent module loads collapsed at bottom right'); + + const oldPosition = { left: Math.round(initial.left), top: Math.round(initial.top) }; + const beforeDragSaves = await positionSaves(); + const point = await startMouse(brand); + await page.mouse.move(point.x + 220 - initial.left, point.y + 60 - initial.top, { steps: 10 }); + const duringDrag = await rect(); + near(duringDrag.left, 220, '折叠面板水平拖动'); + near(duringDrag.top, 60, '折叠面板垂直拖动'); + assert.equal(await positionSaves(), beforeDragSaves, 'pointermove 不应持续保存位置'); + await page.evaluate(position => window.qaPanel.setState({ position, bankTotal: 61 }), oldPosition); + const afterRefresh = await rect(); + near(afterRefresh.left, duringDrag.left, '拖中旧 state 不回跳'); + near(afterRefresh.top, duringDrag.top, '拖中旧 state 不回跳'); + assert.equal(await positionSaves(), beforeDragSaves); + await page.mouse.up(); + assert.equal(await positionSaves(), beforeDragSaves + 1, 'mouseup 应保存一次'); + const dragged = await visible(); + const saved = await page.evaluate(() => window.qaCalls.positions.at(-1)); + near(saved.left, dragged.left, '保存的 left'); + near(saved.top, dragged.top, '保存的 top'); + assert.equal(await isExpanded(), 'false', '真实拖动产生的 click 不能展开面板'); + await page.evaluate(position => window.qaPanel.setState({ position }), oldPosition); + near((await rect()).left, dragged.left, '拖后相同旧 state 不回跳'); + assert.deepEqual(await page.evaluate(() => [qaCalls.auto, qaCalls.copy]), [[], []]); + await screenshot('02-desktop-collapsed-dragged.png'); + console.log('PASS real collapsed drag saves only on end and ignores stale position refresh'); + + await brand.click(); + assert.equal(await isExpanded(), 'true', '普通品牌点击应展开'); + await visible(); + await toggle.click(); + assert.equal(await isExpanded(), 'false', '普通 toggle 点击应折叠'); + const tinySaves = await positionSaves(); + const tinyPoint = await startMouse(brand); + await page.mouse.move(tinyPoint.x + 2, tinyPoint.y + 1); + await page.mouse.up(); + assert.equal(await isExpanded(), 'true', '小于 4px 移动仍是普通点击'); + assert.equal(await positionSaves(), tinySaves, '微小移动不能被保存为拖动'); + await visible(); + console.log('PASS brand/toggle clicks work and movement below threshold remains a click'); + + const beforeExpandedDrag = await positionSaves(); + const expanded = await dragTo(80, 20); + near(expanded.left, 80, '展开面板 left'); + near(expanded.top, 20, '展开面板 top'); + assert.equal(await positionSaves(), beforeExpandedDrag + 1); + assert.equal(await isExpanded(), 'true', '展开面板拖动不能误折叠'); + const beforeToggleDrag = await rect(); + const toggleSaves = await positionSaves(); + const togglePoint = await startMouse(toggle); + await page.mouse.move(togglePoint.x + 120, togglePoint.y + 30, { steps: 5 }); + await page.mouse.up(); + near((await rect()).left, beforeToggleDrag.left, 'toggle 控件不能拖动面板'); + near((await rect()).top, beforeToggleDrag.top, 'toggle 控件不能拖动面板'); + assert.equal(await positionSaves(), toggleSaves); + assert.equal(await isExpanded(), 'true'); + assert.deepEqual(await page.evaluate(() => [qaCalls.auto, qaCalls.copy]), [[], []]); + await screenshot('03-desktop-expanded-dragged.png'); + console.log('PASS real expanded drag suppresses brand click; header controls do not drag'); + + const beforeControls = await rect(); + const controlsSaves = await positionSaves(); + await host.getByRole('switch', { name: '自动选择答案', exact: true }).click(); + await host.getByRole('switch', { name: '允许复制文字', exact: true }).click(); + await host.getByRole('textbox', { name: '搜索题目' }).fill('安全控制'); + await host.getByRole('textbox', { name: '搜索题目' }).press('Enter'); + assert.deepEqual(await page.evaluate(() => [qaCalls.auto, qaCalls.copy]), [[true], [false]]); + assert.equal(await page.evaluate(() => qaCalls.searches.at(-1).query), '安全控制'); + near((await rect()).left, beforeControls.left, '开关、搜索不移动面板'); + near((await rect()).top, beforeControls.top, '开关、搜索不移动面板'); + assert.equal(await positionSaves(), controlsSaves); + const restoredPosition = await page.evaluate(() => window.qaCalls.positions.at(-1)); + const restoreSaves = await positionSaves(); + await page.evaluate(position => window.qaMakePanel({ expanded: true, position }), restoredPosition); + const restored = await visible(); + near(restored.left, restoredPosition.left, '恢复 left'); + near(restored.top, restoredPosition.top, '恢复 top'); + assert.equal(await positionSaves(), restoreSaves, '合法位置恢复不应额外保存'); + console.log('PASS controls/search remain usable and saved position restores in a new panel'); + + await page.evaluate(() => window.qaPanel.setState({ position: { left: 10000, top: -10000 } })); + const clamped = await visible(); + assert(clamped.top >= 0 && clamped.right <= clamped.viewportWidth + 1, '超出视口的恢复位置应钳制'); + await page.setViewportSize({ width: 390, height: 844 }); + await visible(); + await page.evaluate(() => window.qaPanel.renderSearch({ total: 20, items: Array.from({ length: 20 }, (_, index) => ({ number: index + 1, type: '单选', question: '检索结果内容变高后仍应在视口内。'.repeat(4), options: [{ label: 'A', text: '安全控制' }, { label: 'B', text: '其他选项' }], answer: 'A', role: '主操', course: '安全课程' })) })); + const grown = await visible(); + assert(grown.height <= grown.viewportHeight + 1); + await screenshot('04-mobile-expanded-results.png'); + await toggle.click(); + assert.equal(await isExpanded(), 'false'); + await dragTo(280, 800); + await toggle.click(); + assert.equal(await isExpanded(), 'true'); + await visible(); + await page.setViewportSize({ width: 320, height: 480 }); + const small = await visible(); + assert(small.width <= 320 && small.height <= 480); + await screenshot('05-small-viewport.png'); + console.log('PASS offscreen restore, content growth, fold/unfold and viewport resize remain visible'); + + await page.setViewportSize({ width: 1280, height: 900 }); + await page.evaluate(() => window.qaPanel.setState({ expanded: true, position: { left: 120, top: 15 } })); + await visible(); + const cancelPoint = await startMouse(brand); + await page.mouse.move(cancelPoint.x + 35, cancelPoint.y + 25, { steps: 4 }); + const canceledAt = await rect(); + await page.evaluate(() => window.qaPanel.root.querySelector('.header').dispatchEvent(new PointerEvent('pointercancel', { pointerId: 1, pointerType: 'mouse', button: 0, bubbles: true, composed: true, cancelable: true }))); + const cancelSaves = await positionSaves(); + await page.mouse.move(cancelPoint.x + 160, cancelPoint.y + 100, { steps: 4 }); + await page.mouse.up(); + near((await rect()).left, canceledAt.left, 'pointercancel 后不能继续拖'); + near((await rect()).top, canceledAt.top, 'pointercancel 后不能继续拖'); + assert.equal(await positionSaves(), cancelSaves, '取消后旧 pointerup 不能再次保存'); + assert.equal(await page.evaluate(() => qaPanel.root.querySelector('.panel').classList.contains('dragging')), false); + assert.equal(await page.evaluate(() => { const event = new Event('selectstart', { bubbles: true, cancelable: true }); document.body.dispatchEvent(event); return event.defaultPrevented; }), false); + console.log('PASS pointercancel removes capture/global drag state and selection blocking'); + + assert.equal(await page.evaluate(() => getComputedStyle(qaPanel.root.querySelector('.header')).touchAction), 'none'); + const cdp = await page.context().newCDPSession(page); + await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 }); + const touchBefore = await rect(); + const touchBox = await brand.boundingBox(); + const touchPoint = { x: Math.round(touchBox.x + 35), y: Math.round(touchBox.y + touchBox.height / 2) }; + const touchSaves = await positionSaves(); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [touchPoint] }); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: touchPoint.x + 45, y: touchPoint.y + 35 }] }); + assert.equal(await positionSaves(), touchSaves, '触屏移动不能持续保存'); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + const touchAfter = await visible(); + near(touchAfter.left, Math.max(0, Math.min(touchBefore.left + 45, touchBefore.viewportWidth - touchBefore.width)), 'CDP 真实触屏水平拖动'); + near(touchAfter.top, Math.max(0, Math.min(touchBefore.top + 35, touchBefore.viewportHeight - touchBefore.height)), 'CDP 真实触屏垂直拖动及边界钳制'); + assert.equal(await positionSaves(), touchSaves + 1); + assert.equal(await isExpanded(), 'true'); + await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: false }); + await cdp.detach(); + console.log('PASS touch-action is none and CDP touch drag saves once without folding'); + + const destroyPoint = await startMouse(brand); + await page.mouse.move(destroyPoint.x + 20, destroyPoint.y + 15); + assert.equal(await page.evaluate(() => { const event = new Event('selectstart', { bubbles: true, cancelable: true }); document.body.dispatchEvent(event); return event.defaultPrevented; }), true); + const destroySaves = await positionSaves(); + await page.evaluate(() => window.qaPanel.destroy()); + await page.mouse.move(destroyPoint.x + 80, destroyPoint.y + 60); + await page.mouse.up(); + await page.setViewportSize({ width: 900, height: 650 }); + assert.equal(await page.locator('#yian-question-assistant').count(), 0); + assert.equal(await positionSaves(), destroySaves, 'destroy 后不能保留回调或 resize 保存'); + assert.equal(await page.evaluate(() => { const event = new Event('selectstart', { bubbles: true, cancelable: true }); document.body.dispatchEvent(event); return event.defaultPrevented; }), false); + assert.deepEqual(errors, []); + console.log('PASS destroy during dragging cleans listeners and observer; console remains clean'); + console.log('Screenshots: ' + qaDir); + } finally { + if (browser) await browser.close(); + await new Promise(resolve => server.close(resolve)); + } +} + +main().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/run.cjs b/tests/run.cjs index bab6bf2..9652f0d 100644 --- a/tests/run.cjs +++ b/tests/run.cjs @@ -1,6 +1,6 @@ const { spawnSync } = require('node:child_process'); const path = require('node:path'); -for (const file of ['bank-engine.cjs', 'runner-browser.cjs', 'verify.cjs', 'assistant.cjs', 'exam-assistant.cjs']) { +for (const file of ['bank-engine.cjs', 'runner-browser.cjs', 'verify.cjs', 'assistant.cjs', 'exam-assistant.cjs', 'panel-drag.cjs']) { const result = spawnSync(process.execPath, [path.join(__dirname, file)], { stdio: 'inherit' }); if (result.status !== 0) process.exit(result.status || 1); } diff --git a/tests/verify.cjs b/tests/verify.cjs index 2d8f596..e9c9da0 100644 --- a/tests/verify.cjs +++ b/tests/verify.cjs @@ -1,14 +1,16 @@ -/* 真实浏览器验证:只启动本机测试服务,不请求原网站。 */ +/* 真实浏览器验证:主站 URL 仅由本机服务模拟,不请求业务服务器。 */ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const http = require('node:http'); const { chromium } = require('playwright'); +const { pathToFileURL } = require('node:url'); const root = path.resolve(__dirname, '..'); const coreSource = fs.readFileSync(path.join(root, 'question-bank-core.js'), 'utf8'); const scriptSource = fs.readFileSync(path.join(root, '谊安学助手 2.0.user.js'), 'utf8'); +assert.deepEqual(Array.from(scriptSource.matchAll(/^\/\/ @match\s+(.+)$/gm), match => match[1].trim()), ['http://10.119.67.7/clientcn/*']); const qaDir = process.env.YIAN_QA_DIR || path.join(os.tmpdir(), 'yianxue-export-qa'); fs.mkdirSync(qaDir, { recursive: true }); const samplePath = path.join(root, '网页样本', '考试答案', '谊安学.html'); @@ -56,11 +58,24 @@ function parseCSV(text) { } }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); - const origin = `http://127.0.0.1:${server.address().port}`; + const localOrigin = `http://127.0.0.1:${server.address().port}`; + const origin = 'http://10.119.67.7'; let browser; try { browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true }); const context = await browser.newContext({ acceptDownloads: true, viewport: { width: 1366, height: 900 } }); + const externalRequests = []; + const toolURL = pathToFileURL(path.join(root, '离线导出题库.html')).href; + const coreURL = pathToFileURL(path.join(root, 'question-bank-core.js')).href; + await context.route('**/*', async route => { + const url = new URL(route.request().url()); + if (url.origin === origin) { + const response = await route.fetch({ url: localOrigin + url.pathname + url.search }); + return route.fulfill({ response }); + } + if (url.origin === localOrigin || url.href === toolURL || url.href === coreURL || url.protocol === 'file:' && url.href.startsWith(pathToFileURL(qaDir).href + '/')) return route.continue(); + externalRequests.push(url.href); return route.abort(); + }); const page = await context.newPage(); const errors = []; page.on('pageerror', error => errors.push(error.message)); page.on('console', message => { if (message.type() === 'error' || message.type() === 'warning') errors.push(message.text()); }); @@ -176,7 +191,6 @@ function parseCSV(text) { const tool = await context.newPage(); tool.on('pageerror', error => errors.push(error.message)); tool.on('console', message => { if (message.type() === 'error' || message.type() === 'warning') errors.push(message.text()); }); - const toolURL = require('node:url').pathToFileURL(path.join(root, '离线导出题库.html')).href; await tool.goto(toolURL); assert.equal(tool.url(), toolURL); assert.equal(await tool.title(), '离线导出题库'); @@ -210,18 +224,24 @@ function parseCSV(text) { await tool.screenshot({ path: path.join(qaDir, 'offline-mobile.png'), fullPage: true }); console.log('PASS 离线工具文件导入、三格式下载、无联网、桌面与手机布局'); - // 验证原网页的文档级保存注释可以激活 file: 模式。 + // 文档级保存注释不能绕过 file 范围限制,独立离线工具仍可用。 const local = await context.newPage(); - const localHTML = '' + fixture('本地练习'); + local.on('pageerror', error => errors.push(error.message)); + local.on('download', download => downloads.push(download)); + const localHTML = '' + fixture('本地练习').replace('', ''); const localPath = path.join(qaDir, 'local-practice.html'); fs.writeFileSync(localPath, localHTML); - await local.goto(require('node:url').pathToFileURL(localPath).href); + await local.goto(pathToFileURL(localPath).href); + const beforeBody = await local.evaluate(() => ({ html: document.body.innerHTML, select: document.body.getAttribute('onselectstart'), context: document.body.getAttribute('oncontextmenu'), style: document.body.getAttribute('style') })); + const beforeLocal = downloads.length; await local.addScriptTag({ content: scriptSource }); - await local.evaluate(() => YianAssistant.ready); - const localDownload = local.waitForEvent('download'); - await local.getByRole('switch', { name: '导出题库', exact: true }).check(); - assert.equal((await localDownload).suggestedFilename(), '本地练习_题库_3题.csv'); + await local.waitForTimeout(150); + assert.equal(await local.locator('#yian-question-assistant').count(), 0); + assert.deepEqual(await local.evaluate(() => [typeof window.JSZip, typeof window.QuestionBankCore, typeof window.YianAssistant]), ['undefined', 'undefined', 'undefined']); + assert.deepEqual(await local.evaluate(() => ({ html: document.body.innerHTML, select: document.body.getAttribute('onselectstart'), context: document.body.getAttribute('oncontextmenu'), style: document.body.getAttribute('style') })), beforeBody); + assert.equal(downloads.length, beforeLocal, 'file 保存网页不能自动下载'); + assert.deepEqual(externalRequests, [], '业务 URL 均通过本机模拟;不得请求其他网站'); assert.equal(errors.length, 0, '浏览器不应有控制台/运行时错误:' + errors.join('\n')); - console.log('PASS 本地保存网页自动识别与浏览器错误检查'); + console.log('PASS file 保存网页不注入/下载或改变复制限制;独立离线导出工具保持可用,无外部请求与浏览器错误'); console.log('验证截图和下载文件:' + qaDir); } finally { if (browser) await browser.close(); diff --git a/谊安学助手 2.0.user.js b/谊安学助手 2.0.user.js index 02caa41..5469096 100644 --- a/谊安学助手 2.0.user.js +++ b/谊安学助手 2.0.user.js @@ -1,10 +1,9 @@ // ==UserScript== // @name 谊安学助手 2.0 // @namespace yianxue-question-bank -// @version 2.0.2 -// @description 不绑定网址的题库助手:岗位搜索、答案全文核对、练习及桌面考试自动选择。 -// @match *://*/* -// @match file:///* +// @version 2.0.3 +// @description 谊安学站点助手:可拖动面板、岗位搜索、答案全文核对及自动选择。 +// @match http://10.119.67.7/clientcn/* // @run-at document-idle // @grant GM_getValue // @grant GM_setValue @@ -63,6 +62,23 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +(function () { +/* Shared URL boundary for userscript injection and same-page navigation. */ +const YianSiteScope = (function () { + 'use strict'; + const origin = 'http://10.119.67.7'; + const basePath = '/clientcn/'; + function isAllowedURL(value) { + try { + const url = new URL(String(value)); + return url.origin === origin && url.pathname.startsWith(basePath); + } catch (_) { return false; } + } + return Object.freeze({ origin, basePath, isAllowedURL }); +})(); +if (typeof window === 'undefined' && typeof module !== 'undefined' && module.exports) module.exports = YianSiteScope; + +if (!YianSiteScope.isAllowedURL(location.href)) return; (function () { const module = { exports: {} }; const exports = module.exports; /*! @@ -1083,12 +1099,20 @@ window.JSZip = module.exports; 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 + canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false, position: null }; let destroyed = false; let selectionText = ''; let currentOffset = 0; let resultTotal = 0; + let drag = null; + let suppressHeaderClick = false; + let lastPositionInput = 'unset'; + let appliedPosition = 'default'; + let clampFrame = null; + let notifyClamp = false; + let resizeObserver = null; + let previousExpanded = false; const host = doc.createElement('div'); host.id = 'yian-question-assistant'; host.setAttribute('data-yian-assistant', ''); @@ -1101,6 +1125,7 @@ window.JSZip = module.exports; .panel{width:354px;max-width:calc(100vw - 24px);background:white;border:1px solid #dce3ee;border-radius:13px;box-shadow:0 8px 36px #14264528;overflow:hidden}.header{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;background:#172a49;color:white}.brand{font-size:15px;font-weight:700;letter-spacing:.2px}.toggle{border:0;background:#ffffff17;color:white;padding:3px 9px;border-radius:5px;min-width:30px}.toggle:hover:enabled{background:#ffffff2c;color:white;border-color:transparent}.body{padding:13px;max-height:calc(100vh - 102px);max-height:calc(100dvh - 102px);overflow:auto;overscroll-behavior:contain}.body[hidden]{display:none}.panel.collapsed{width:auto;min-width:140px}.collapsed .header{padding:10px 12px;gap:16px}.switches{display:grid;gap:9px;padding-bottom:12px;border-bottom:1px solid var(--line)}.switch-row{display:flex;align-items:center;justify-content:space-between;position:relative;gap:12px;cursor:pointer;font-weight:600}.switch{appearance:none;-webkit-appearance:none;width:35px;height:21px;margin:0;border:0!important;border-radius:20px;background:#b7c0ce;position:relative;transition:background .15s}.switch::after{content:"";position:absolute;width:15px;height:15px;top:3px;left:3px;border-radius:50%;background:white;box-shadow:0 1px 3px #0002;transition:transform .15s}.switch:checked{background:var(--accent)}.switch:checked::after{transform:translateX(14px)}.hint{font-size:12px;color:var(--muted);margin:6px 0 0;overflow-wrap:anywhere}.section{padding-top:12px}.section-title{font-size:13px;font-weight:700;margin:0 0 7px}.import-row{display:flex;align-items:center;gap:10px}.import-row button{white-space:nowrap}.bank-info{font-size:12px;color:var(--muted);overflow-wrap:anywhere}.storage{color:#97702e;margin:4px 0 0;font-size:11px}.roles{display:flex;gap:6px;flex-wrap:wrap}.role{display:flex;align-items:center;gap:4px;padding:6px 8px;border:1px solid var(--line);border-radius:7px;cursor:pointer;background:white;flex:1;justify-content:center;white-space:nowrap}.role.active{background:#edf3ff;border-color:#abc5fa;color:#194dab}.role input{margin:0}.role-count{font-size:10px;color:var(--muted)}.course-row{display:grid;grid-template-columns:36px 1fr;gap:7px;align-items:center;margin-top:8px}.search-row{display:flex;gap:6px;margin-top:9px}.search-row input{flex:1}.primary{background:var(--accent);border-color:var(--accent);color:white;white-space:nowrap}.primary:hover:enabled{background:#184baf;color:white;border-color:#184baf}.selection-search{margin-top:7px;font-size:12px;width:100%}.notice{padding:8px 9px;border-radius:7px;margin:10px 0 0;font-size:12px;overflow-wrap:anywhere;background:#edf3ff;color:#254e8b}.notice.warning{background:#fff5df;color:#8a5d11}.notice.error{background:#fff0ed;color:#a23a2c}.notice.success{background:#eaf7f0;color:#1e754c}.notice[hidden]{display:none}.results-title{font-size:12px;color:var(--muted);margin:11px 0 7px}.empty{background:var(--surface);border-radius:8px;padding:13px 10px;color:var(--muted);font-size:12px}.result{border:1px solid var(--line);border-radius:9px;padding:10px;margin-bottom:9px;-webkit-user-select:text;user-select:text}.question{font-weight:600;line-height:1.6;overflow-wrap:anywhere;margin:0 0 7px;white-space:pre-wrap}.type{display:inline-block;font-size:10px;font-weight:500;color:#556984;background:#eff3f8;padding:1px 5px;border-radius:4px;margin-right:5px;vertical-align:1px}.answer{font-weight:600;color:#1b714a;background:#edf8f1;border-radius:5px;padding:6px 7px;overflow-wrap:anywhere}.options{padding:0;margin:7px 0;list-style:none;font-size:12px;display:grid;gap:4px}.option{padding:3px 6px;border-radius:4px;overflow-wrap:anywhere;white-space:pre-wrap}.option.correct{color:#196442;background:#edf8f1;font-weight:600}.sources{font-size:11px;color:var(--muted);white-space:pre-wrap;overflow-wrap:anywhere;margin-top:5px}.conflict{color:#9d6012;background:#fff5df;border-radius:5px;font-size:11px;padding:6px;margin:6px 0}.copy-result{margin-top:8px;font-size:11px}.pagination{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11px;color:var(--muted);margin:5px 0 0}.pagination button{font-size:11px;padding:4px 8px}.page-section{border-top:1px solid var(--line);margin-top:13px;padding-top:11px}.page-status{font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted)}.metrics{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:11px;color:var(--muted);margin-top:6px}.unprocessed{font-size:11px;color:#8b621c;margin-top:7px}.unprocessed summary{cursor:pointer}.unprocessed-list{max-height:155px;overflow:auto;padding:7px;border-radius:6px;background:#fff7e8;margin-top:4px;display:grid;gap:5px;overflow-wrap:anywhere}.exports{display:flex;gap:6px;margin-top:9px}.exports button{flex:1;font-size:12px}.auto-export{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--muted);margin-top:8px}.auto-export input{margin:0}.busy{color:#83692c}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:420px){.body{padding:11px}.panel{width:350px}.role{padding:6px 5px}} `; style.textContent += '.answer-check{font-size:11px;color:#235c43;margin-top:7px}.answer-check summary{cursor:pointer}.answer-check-list{max-height:210px;overflow:auto;overscroll-behavior:contain;margin-top:5px;display:grid;gap:7px}.answer-check-item{border:1px solid #d9eade;border-radius:6px;background:#f0f8f3;padding:8px;overflow-wrap:anywhere;-webkit-user-select:text;user-select:text}.answer-check-question{font-weight:600;margin:0 0 4px;white-space:pre-wrap}.answer-check-course{color:var(--muted);margin:0 0 5px}.answer-check-label{font-weight:600;margin:0 0 3px}.answer-check-options{margin:0;padding:0;list-style:none;display:grid;gap:4px;white-space:pre-wrap}.answer-check-note{font-size:10px;color:var(--muted);margin:5px 0 0}'; + style.textContent += '.header{cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none}.panel.dragging .header{cursor:grabbing}.panel.dragging,.panel.dragging *{-webkit-user-select:none!important;user-select:none!important}'; root.appendChild(style); const visibilityStyle = doc.createElement('style'); visibilityStyle.textContent = '[hidden]{display:none!important}'; @@ -1108,6 +1133,7 @@ window.JSZip = module.exports; const panel = el(doc, 'section', 'panel collapsed'); panel.setAttribute('aria-label', '谊安学助手 2.0'); const header = el(doc, 'div', 'header'); + header.title = '拖动标题栏移动面板'; const brand = el(doc, 'span', 'brand', '题库助手'); const expand = el(doc, 'button', 'toggle', '展开'); expand.type = 'button'; @@ -1135,9 +1161,119 @@ window.JSZip = module.exports; } function togglePanel() { state.expanded = !state.expanded; refresh(); } expand.addEventListener('click', togglePanel); - brand.style.cursor = 'pointer'; 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'); @@ -1421,6 +1557,10 @@ window.JSZip = module.exports; 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)); @@ -1547,16 +1687,65 @@ window.JSZip = module.exports; function setState(partial) { if (destroyed) return; - Object.assign(state, partial || {}); + const update = Object.assign({}, partial || {}); + let incoming = null, acceptPosition = false; + if (Object.prototype.hasOwnProperty.call(update, 'position')) { + incoming = positionValue(update.position); + const signature = positionSignature(incoming); + acceptPosition = signature !== lastPositionInput && !drag; + lastPositionInput = signature; + delete update.position; + } + Object.assign(state, update); refresh(); + if (acceptPosition) { + applyPosition(incoming); + if (incoming && positionSignature(incoming) !== positionSignature(state.position)) savePosition(); + } } function destroy() { if (destroyed) return; destroyed = true; + const oldDrag = drag; + drag = null; + removeDragListeners(); + if (oldDrag && oldDrag.captured) { + try { if (header.hasPointerCapture(oldDrag.id)) header.releasePointerCapture(oldDrag.id); } catch (_) { /* 节点卸载时浏览器可能已释放。 */ } + } + header.removeEventListener('pointerdown', startDrag); + header.removeEventListener('lostpointercapture', finishDrag); + header.removeEventListener('click', suppressDragClick, true); + expand.removeEventListener('click', togglePanel); + brand.removeEventListener('click', togglePanel); + global.removeEventListener('resize', viewportChanged); + if (global.visualViewport) { + global.visualViewport.removeEventListener('resize', viewportChanged); + global.visualViewport.removeEventListener('scroll', viewportChanged); + } + if (resizeObserver) resizeObserver.disconnect(); + if (clampFrame !== null) global.cancelAnimationFrame(clampFrame); + clampFrame = null; doc.removeEventListener('selectionchange', rememberSelection); host.remove(); } (doc.body || doc.documentElement).appendChild(host); + global.addEventListener('resize', viewportChanged); + if (global.visualViewport) { + global.visualViewport.addEventListener('resize', viewportChanged); + global.visualViewport.addEventListener('scroll', viewportChanged); + } + if (typeof global.ResizeObserver === 'function') { + let sizeSignature = ''; + resizeObserver = new global.ResizeObserver(function (entries) { + const box = entries[0] && entries[0].contentRect; + if (!box) return; + const signature = box.width + ',' + box.height; + if (signature === sizeSignature) return; + sizeSignature = signature; + scheduleClamp(true); + }); + resizeObserver.observe(panel); + } renderSearch({ items: [], total: 0 }); refresh(); return { setState: setState, renderSearch: renderSearch, setNotice: setNotice, destroy: destroy, host: host, root: root }; @@ -2025,15 +2214,17 @@ window.JSZip = module.exports; const core = window.QuestionBankCore; const store = window.YianBankStore; const matcher = window.YianAnswerMatcher; - const config = { autoAnswer: false, allowCopy: true, exportEnabled: false, role: '', course: '' }; + const config = { autoAnswer: false, allowCopy: true, exportEnabled: false, role: '', course: '', panelPosition: null }; let snapshot = { records: [], counts: {}, total: 0, revision: '', storage: 'origin' }; - let loading = true, disposed = false, page = null, previous = null; + let loading = true, disposed = false, scopedOut = false, page = null, previous = null; let searchQuery = '', runKey = '', debounceTimer; const exported = new Set(); const settleMs = 1200; const copy = window.YianCopyController.create(); const emptyMetrics = () => ({ selected: 0, pending: 0, missing: 0, conflict: 0, locked: 0, failed: 0, unsupported: 0, total: 0 }); let metrics = emptyMetrics(); + let positionRevision = 0; + const inScope = () => YianSiteScope.isAllowedURL(location.href); function visible(node) { if (!node || node.closest('[hidden],.hidden')) return false; @@ -2080,7 +2271,7 @@ window.JSZip = module.exports; const route = routeInfo(); const bank = route.root ? core.extract(document, route.sourceUrl) : { title: '', questions: [] }; const signature = route.root ? structureSignature(bank) : ''; - return { ready: !!page && page.ready && !loading && page.key === route.key + return { ready: inScope() && !!page && page.ready && !loading && page.key === route.key && page.root === route.root && page.signature === signature, practice: route.practice, exam: route.exam, mode: route.mode, local: route.local, role: config.role, course: bank.title, sourceUrl: route.sourceUrl, routeKey: route.key, root: route.root, @@ -2101,7 +2292,7 @@ window.JSZip = module.exports; } function refreshPanel(extra) { panel.setState(Object.assign({ autoAnswer: config.autoAnswer, allowCopy: config.allowCopy, - exportEnabled: config.exportEnabled, role: config.role, course: config.course, courses: courses(), + exportEnabled: config.exportEnabled, position: config.panelPosition, role: config.role, course: config.course, courses: courses(), bankTotal: snapshot.total, roleCounts: snapshot.counts, storageMode: snapshot.storage, loading, metrics }, extra || {})); } function performSearch(query = searchQuery, options = {}) { @@ -2132,7 +2323,7 @@ window.JSZip = module.exports; refreshPanel(); tick(); return config.autoAnswer; } function setCopy(enabled) { - config.allowCopy = !!enabled; copy.setEnabled(config.allowCopy); refreshPanel(); saveSetting('allowCopy', config.allowCopy); + config.allowCopy = !!enabled; copy.setEnabled(config.allowCopy && inScope()); refreshPanel(); saveSetting('allowCopy', config.allowCopy); } async function importFiles(files) { runner.stop('正在更新题库'); loading = true; runKey = ''; refreshPanel(); panel.setNotice('正在解析题库,请稍候…'); @@ -2146,6 +2337,7 @@ window.JSZip = module.exports; } finally { loading = false; refreshPanel(); performSearch(searchQuery, { quiet: true }); tick(); } } function exportCurrent(format = 'csv') { + if (!inScope()) throw new Error('当前页面不在谊安学脚本生效范围内'); if (!config.exportEnabled) throw new Error('请先开启“导出题库”开关'); const route = routeInfo(); if (!route.practice) throw new Error('请先进入支持的练习页面'); @@ -2165,6 +2357,7 @@ window.JSZip = module.exports; } const panel = window.YianAssistantPanel.create({ onAutoChange: setAutoAnswer, onCopyChange: setCopy, + onPositionChange: position => { positionRevision++; config.panelPosition = position; saveSetting('panelPosition', position); }, onRoleChange: role => { setRole(role).catch(error => panel.setNotice(error.message, 'error')); }, onCourseChange: course => { config.course = course; performSearch(); }, onImport: files => { importFiles(files).catch(() => {}); }, onSearch: performSearch, @@ -2175,6 +2368,21 @@ window.JSZip = module.exports; function tick() { if (disposed) return; + if (!inScope()) { + if (!scopedOut) { + scopedOut = true; + config.autoAnswer = false; config.exportEnabled = false; runKey = ''; + runner.stop('页面已离开脚本生效范围'); copy.setEnabled(false); + panel.host.style.setProperty('display', 'none', 'important'); + previous = page; page = null; metrics = emptyMetrics(); + } + return; + } + if (scopedOut) { + scopedOut = false; + panel.host.style.setProperty('display', 'block', 'important'); + copy.setEnabled(config.allowCopy); + } const route = routeInfo(); if (!route.root) { if (runner.running) runner.stop('当前页面未识别到练习题目'); @@ -2246,12 +2454,19 @@ window.JSZip = module.exports; runner.destroy(); copy.destroy(); panel.destroy(); } const ready = (async () => { + const positionRevisionAtRead = positionRevision; try { - const values = await Promise.all([store.load(), store.getSetting('role', ''), store.getSetting('allowCopy', true)]); + const values = await Promise.all([store.load(), store.getSetting('role', ''), store.getSetting('allowCopy', true), store.getSetting('panelPosition', null)]); snapshot = values[0]; config.role = store.roles.includes(values[1]) ? values[1] : ''; config.allowCopy = values[2] !== false; + const position = values[3]; + // A drag during asynchronous storage loading wins over the older saved position. + if (positionRevision === positionRevisionAtRead) { + config.panelPosition = position && typeof position.left === 'number' && Number.isFinite(position.left) + && typeof position.top === 'number' && Number.isFinite(position.top) ? { left: position.left, top: position.top } : null; + } } catch (error) { panel.setNotice('题库读取失败:' + error.message + '。可重新导入题库。', 'error'); } - loading = false; copy.setEnabled(config.allowCopy); refreshPanel(); tick(); return snapshot; + loading = false; copy.setEnabled(config.allowCopy && inScope()); refreshPanel(); tick(); return snapshot; })(); const api = Object.freeze({ ready, importFiles, setRole, setAutoAnswer, setCopy, setExportEnabled, search: performSearch, getContext, getSnapshot: () => snapshot, getState: () => ({ ...config, metrics, pageReady: !!page && page.ready }), @@ -2259,3 +2474,5 @@ window.JSZip = module.exports; window.YianAssistant = api; window.YianQuestionBankExporter = api; refreshPanel(); tick(); })(); + +})();