// ==UserScript== // @name 盐城工学院 实验室安全ai全自动答题助学 // @namespace https://github.com/spottedcat2/lab-safety-t300 // @version 2.8.1 // @description 实验室安全课程学习辅助:播放管理、章节衔接、随堂回顾;考试面板支持一键自动作答并自动交卷(需卡密的 AI 解析会直接给出答案并填入) // @author 智能学习助手 // @license proprietary // @supportURL https://wpa.qq.com/msgrd?v=3&uin=746957502&site=qq&menu=yes // @match *://sa.ycit.edu.cn/* // @grant GM_setValue // @grant GM_getValue // @grant GM_xmlhttpRequest // @grant GM_addStyle // @connect api.labsafetyt300.ltd // @antifeature payment // @tag 盐城工学院 // @tag 实验室安全 // @tag 学习辅助 // @tag 课程助手 // @run-at document-end // ==/UserScript== /* * ============================================================ * v2.8.1(2026-11-04) * · 上架分发适配:头部加 @antifeature payment 与四条 @tag, * 支持渠道改为 QQ 临时会话(原来指私有仓库,对外是死链)。 * · @description 如实写明考试面板会一键自动作答并自动交卷。 * ============================================================ */ /* * ============================================================ * v2.8.0(2026-11-04) * · 课程树导航重写:由「只点下一节」的扁平写法换成「章 / 节 / 知识点」 * 三层识别 + 范围感知的 getNextCourse,翻章不再点错层级。 * · 常规模式改为三段式、不设跳过判据:章内切下一节 → 本章末且未展开 * 课程树则提示「当前章节已刷完」并停 → 本章末且已展开则直接接下一章; * 到树末提示「全部课程已刷完」。不再因为「没学完」中断脚本。 * · 复习模式语义不变,判据仍只在 reviewCheck;顺带修掉树选中节点误命中 * 根节点、导致当前章节状态判错的问题。 * · 新增整屏提示组件 showBigNotice:只保留完成提示,去掉黄色警告。 * · 商品由 5 档精简为 2 档:尝鲜 3.90(80 算力)与账号型 10.00 * (1420 算力 / 约 747 道 / 10 天)。购买链接继续由服务端热加载。 * · 链接配置缓存加结构版本号:档位键集一变即作废旧缓存,避免老缓存 * 里缺 account 键而被误降级成「QQ 私聊购买」。 * · 头壳与本体版本号统一(此前分别是 2.6.4 与 2.7.0)。 * ============================================================ */ /* * ============================================================ * v2.7.0(2026-10-24) * · 发卡平台由卡掌柜(ekaboss,域名已失效)切换到酷发卡(kufaka)。 * · 购买链接改为「热加载」:面板创建时向授权服务 GET /api/shop-config 拉一份 * 商品链接配置(12h 本地缓存、2s 超时),换发卡站只改服务器上的 JSON, * 不必重新混淆发版;拉不到自动回落脚本内置链接。 * · 保底:服务端标 manual 或某档链接为空时,按钮降级为「QQ 私聊购买」, * 跳 QQ 临时会话做人工发卡。 * ============================================================ */ /* * ============================================================ * v2.6.1(2026-10-12) * · 修复面板玻璃描边与圆角错位。 * · 考试相关开关每次加载默认关闭。 * ============================================================ */ /* * ============================================================ * v2.5.22(2026-10-08) * · 修复高级UI(玻璃态)四角「双圆角」瑕疵:面板 ::before 描边环原写死 * border-radius:20px,而玻璃态面板本体被改成 16px,两者错位 ⇒ 四角露出 * 两层弧线。现将圆角收敛为单一 CSS 变量 --panel-radius,描边环跟随面板, * 四角弧线完全重合。 * ============================================================ */ /* * ============================================================ * v2.5.21(2026-10-08) * · 「开始考试」与「自动提交」改为会话态:默认均关闭,且不再写盘 * ⇒ 刷新页面后一律回到初始态,不继承上一轮的开启状态。 * · 新增 SESSION_CONFIG 收敛这两个键的初始值(单一来源)。 * · 其余行为不变。 * ============================================================ */ (function() { 'use strict'; // ============================ 核心配置 ============================ const SCRIPT_INFO = { name: '实验室安全课程AI答题助手(优化合并版)', version: '2.8.1', author: '智能学习助手' }; // ============================ 售卖链接:热加载 + 兜底 ============================ // 【2.7.0】购买链接不再写死在源码里。脚本是混淆后发到 GitHub 的静态文件,换发卡平台 / // 改商品链接若写死在源码,每次都得重新混淆发版;改成「面板创建时向授权服务拉一份配置」, // 服务器上那个 JSON 改完即生效,不用重新发版。 // // 硬约束(沿用 2026-10-13 定下的低侵入原则): // · 只在面板创建时拉一次,12h 内复用本地缓存 —— 不参与答题主链路,不影响计费与授权; // · 2s 超时,失败静默回落内置兜底链接,绝不阻塞 UI、绝不弹错误; // · 拿不到链接 / 服务端标 manual => 按钮自动降级为「QQ 私聊购买」(人肉发卡兜底)。 const SHOP_CONFIG_PATH = '/api/shop-config'; const SHOP_CONFIG_CACHE_KEY = 'shopConfigCache'; // GM 存储键:{ v: 结构版本, at: 时间戳, data: 配置 } const SHOP_CONFIG_TTL = 12 * 60 * 60 * 1000; // 本地缓存有效期(12 小时) // 【缓存结构版本】档位键集(SHOP_FALLBACK.links 的 key)一旦增删就必须 +1: // 旧缓存在 12h 内不会重拉,若键集已变(如新增 account),旧缓存会让新档位被判"无链接" // 而错误降级成「QQ 私聊购买」。版本不符 ⇒ 直接丢弃缓存重新拉。 const SHOP_SCHEMA = 2; const SHOP_CONFIG_TIMEOUT = 2000; // 拉取超时(毫秒):宁可回落兜底也不卡面板 // 内置兜底:服务端拉不到时用(= 发版当时的酷发卡链接,改动见线上 shop_config.json) const SHOP_FALLBACK = { manual: false, fallbackQQ: '746957502', links: { shop: 'https://www.kufaka.com/item/y4agq1', // 「购买卡密」按钮:跳最低档商品页 trial: 'https://www.kufaka.com/item/y4agq1', // 尝鲜 80 算力 account: 'https://www.kufaka.com/item/ad6woe' // 账号型 1420 算力 } }; // 人肉发卡兜底:跳 QQ 临时会话(不装 QQ 也可以走网页版) function qqBuyUrl(qq) { const uin = encodeURIComponent(String(qq || SHOP_FALLBACK.fallbackQQ)); return 'https://wpa.qq.com/msgrd?v=3&uin=' + uin + '&site=qq&menu=yes'; } const ShopConfig = { data: null, // 当前生效配置:{ manual, fallbackQQ, links: {...} } inflight: null, // 进行中的请求 Promise(防重复拉) // 取某一档的购买链接;空值统一返回 QQ 临时会话,保证按钮永远可点、不会死链 linkFor: function(key) { const d = this.data || SHOP_FALLBACK; if (d.manual) return qqBuyUrl(d.fallbackQQ); const url = ((d.links || {})[key] || '').trim(); return url || qqBuyUrl(d.fallbackQQ); }, // 该档是否走了「人工发卡」兜底 isManual: function(key) { const d = this.data || SHOP_FALLBACK; if (d.manual) return true; return !(((d.links || {})[key] || '').trim()); }, // 购买按钮文案:正常档「点击购买 ¥x」;降级后「QQ 私聊购买 ¥x」 buttonText: function(key, price) { return (this.isManual(key) ? 'QQ 私聊购买 ' : '点击购买 ') + (price || ''); }, // 应用一份配置。字段做白名单校验:只认 http(s) 链接、只认 5~12 位数字 QQ, // 防止服务端配置被污染后把按钮改成 javascript: 之类。 // 另有「脏响应护栏」:既没明确 manual=true、又一个有效链接都没有 ⇒ 拒绝采纳。 // (典型场景:接口还没部署时服务端回 {"detail":"Not Found"},不能让它把兜底链接冲掉) apply: function(raw) { if (!raw || typeof raw !== 'object') return false; const safeUrl = (u) => (typeof u === 'string' && /^https?:\/\//i.test(u.trim())) ? u.trim() : ''; const src = (raw.links && typeof raw.links === 'object') ? raw.links : {}; const links = {}; Object.keys(SHOP_FALLBACK.links).forEach((k) => { links[k] = safeUrl(src[k]); }); const manual = raw.manual === true; if (!manual && !Object.keys(links).some((k) => links[k])) return false; const qq = String(raw.fallbackQQ || ''); this.data = { manual: manual, fallbackQQ: /^\d{5,12}$/.test(qq) ? qq : SHOP_FALLBACK.fallbackQQ, links: links }; return true; }, // 生效后原地刷新已渲染的 DOM(面板不重建,只改 href 与文案) refreshUI: function() { document.querySelectorAll('#ai-helper-panel .intro-price-btn').forEach((a) => { const key = a.dataset.goods; if (!key) return; const g = (typeof GOODS_INTRO !== 'undefined' ? GOODS_INTRO : []).find((x) => x.key === key); a.href = this.linkFor(key); a.textContent = this.buttonText(key, g ? g.price : ''); }); }, // 主流程:缓存命中就用缓存;否则先上兜底再后台拉,拉到后写缓存并刷新 load: function() { if (this.inflight) return this.inflight; // ① 本地缓存(12h 内不再发请求) try { const raw = GM_getValue(SHOP_CONFIG_CACHE_KEY, ''); if (raw) { const c = JSON.parse(raw); if (c && c.v === SHOP_SCHEMA && c.data && (Date.now() - (c.at || 0)) < SHOP_CONFIG_TTL && this.apply(c.data)) { this.refreshUI(); return Promise.resolve(this.data); } } } catch (e) { /* 缓存损坏:当作没有,继续走远程 */ } // ② 内置兜底先顶上,UI 立刻可用(不会出现空 href 的死按钮) this.apply(SHOP_FALLBACK); this.refreshUI(); // ③ 后台拉远程配置,失败/超时一律静默保持兜底 const base = String(currentConfig.authServerUrl || '').replace(/\/+$/, ''); if (!base) return Promise.resolve(this.data); this.inflight = new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: base + SHOP_CONFIG_PATH, timeout: SHOP_CONFIG_TIMEOUT, onload: (res) => { try { const j = JSON.parse(res.responseText); // 兼容两种返回:裸对象 或 { success, data:{...} } const payload = (j && j.data && typeof j.data === 'object') ? j.data : j; if (this.apply(payload)) { try { GM_setValue(SHOP_CONFIG_CACHE_KEY, JSON.stringify({ v: SHOP_SCHEMA, at: Date.now(), data: payload })); } catch (e) {} this.refreshUI(); } } catch (e) { /* 脏响应:保持兜底 */ } resolve(this.data); }, onerror: () => resolve(this.data), ontimeout: () => resolve(this.data) }); }).then((d) => { this.inflight = null; return d; }); return this.inflight; } }; // 自定义提示词长度上限(防单次 AI 消耗暴涨;错题数据注入不在此限) const PROMPT_MAX_LEN = 2000; // 考试批量答题:滑块边界(单轮题数 / 单次提交轮数) // 上界的工程依据:单轮题数过大 -> 单次响应变长、JSON 截断风险上升; // 单次提交轮数过大 -> 同瞬间并发请求数上升,服务端限流/超时概率上升。 const EXAM_BATCH_SIZE_MIN = 1; const EXAM_BATCH_SIZE_MAX = 20; const EXAM_CONCURRENCY_MIN = 1; const EXAM_CONCURRENCY_MAX = 8; // 【2.5.14】这三个默认值同时也是「一键完成」的预设:预设值 / 输入框留空时的回落值 / 界面上写的 // 预设文案,全部由它们派生,不再在别处手写数字 —— 改这一处,三处同步。 const EXAM_BATCH_SIZE_DEFAULT = 10; const EXAM_CONCURRENCY_DEFAULT = 2; // 单批请求失败的重试:网络类错误(连不上/超时/响应解析失败/限流)重发整批 // 3 次尝试 = 首次 + 2 次重试,退避 2s / 4s;不可重试的错误(余额/卡密/拦截)不浪费额度 const EXAM_RETRY_ATTEMPTS = 3; const EXAM_RETRY_BASE_DELAY = 2000; // 【2.5.9】「等待超时」滑块边界(秒):单批请求最多等多久,超时即判定该批失败、不重发 // 下界依据:服务端构建 10 题批量的响应一般要十几秒,30 秒留足余量; // 上界依据:考试限时 60 分钟,单批等 180 秒已属极端,再长会侵蚀后面的答题与补救时间。 // 默认 70 = 2.5.9 之前的硬编码值,直接可用。 const EXAM_TIMEOUT_MIN = 30; const EXAM_TIMEOUT_MAX = 180; const EXAM_TIMEOUT_STEP = 10; const EXAM_TIMEOUT_DEFAULT = 70; // 非考试路径(视频答题等)的答题请求超时,维持原值不动 const AUTH_ANSWER_TIMEOUT_MS = 70000; // 【2.5.7】调度三件套(全自动、无 UI) // 1) 令牌桶:全池任意两次请求至少间隔 250ms,避免并发 N 时"同一瞬间打出 N 个请求"被限流 const EXAM_MIN_REQUEST_INTERVAL = 250; // 2) 退避抖动:等待 = BASE × 2^(k-1) × (1 - JITTER + rand()×2×JITTER),默认 0.5 ⇒ 0.5~1.5 倍 // 目的:多个 worker 同时失败时错开重发,避免又在同一毫秒一起撞上去 const EXAM_JITTER = 0.5; // 3) 自适应降级:滑块给的是上限,有效并发随窗口失败率浮动 const EXAM_ADAPT_WINDOW = 4; // 滑动观察窗口(批) const EXAM_ADAPT_FAIL_RATIO = 0.5; // 窗口失败率超过它 ⇒ 有效并发降到 1 const EXAM_ADAPT_RECOVER_STREAK = 3; // 连续成功这么多批 ⇒ 有效并发 +1(可回升到滑块上限) // 轻量熔断:连续这么多批"网络类"失败 ⇒ 停手报错(不做冷却/探测/半开) // 只对网络类生效:余额不足/上游欠费类本就不重试,一批失败即返回,谈不上"连环白等" const EXAM_BREAKER_FAILS = 3; const EXAM_NET_CODES = ['NETWORK_ERROR', 'TIMEOUT']; // 取值端唯一夹紧入口:非数字/越界/小数 一律拉回合法整数区间 // 【2.5.12】输入框被清空时 value 是空串,Number('') === 0 会绕过"非数字回落默认值"的本意 // ⇒ 空值单独判一次,回落默认值(而非下界 1) function isExamBlank(value) { return value === '' || value === null || value === undefined; } function clampExamBatchSize(value) { if (isExamBlank(value)) return EXAM_BATCH_SIZE_DEFAULT; const n = Math.round(Number(value)); if (!Number.isFinite(n)) return EXAM_BATCH_SIZE_DEFAULT; return Math.min(EXAM_BATCH_SIZE_MAX, Math.max(EXAM_BATCH_SIZE_MIN, n)); } function clampExamConcurrency(value) { if (isExamBlank(value)) return EXAM_CONCURRENCY_DEFAULT; const n = Math.round(Number(value)); if (!Number.isFinite(n)) return EXAM_CONCURRENCY_DEFAULT; return Math.min(EXAM_CONCURRENCY_MAX, Math.max(EXAM_CONCURRENCY_MIN, n)); } // 等待超时(秒):越界按边界夹紧,非数字回落默认值;再按步长对齐(10 秒一档) function clampExamRequestTimeout(value) { if (isExamBlank(value)) return EXAM_TIMEOUT_DEFAULT; const n = Math.round(Number(value)); if (!Number.isFinite(n)) return EXAM_TIMEOUT_DEFAULT; const clamped = Math.min(EXAM_TIMEOUT_MAX, Math.max(EXAM_TIMEOUT_MIN, n)); return EXAM_TIMEOUT_MIN + Math.round((clamped - EXAM_TIMEOUT_MIN) / EXAM_TIMEOUT_STEP) * EXAM_TIMEOUT_STEP; } // 【2.5.21】会话态配置:不进 GM 存储、不跨刷新存活。 // 需求:刷新后「开始考试」「自动提交」一律回到初始态(关),上一轮的开启状态不残留。 // 实现:这两个键不再走 GM_getValue/GM_setValue,只改内存 currentConfig —— 与 // 「一键完成」的一次性语义也更贴:那是本场动作,不该变成常驻设置。 const SESSION_CONFIG = { examEnabled: false, // 开始考试:默认关闭(真值每轮重置) examAutoSubmit: false // 自动提交:默认关闭(真值每轮重置) }; const DEFAULT_CONFIG = { aiAnswerEnabled: GM_getValue('aiAnswerEnabled', true), autoAnswer: GM_getValue('autoAnswer', true), autoSubmit: GM_getValue('autoSubmit', true), randomAnswer: GM_getValue('randomAnswer', true), matchRate: GM_getValue('matchRate', 0.6), deepseekModel: GM_getValue('deepseekModel', 'deepseek-chat'), deepseekTemperature: GM_getValue('deepseekTemperature', 0.1), // 授权系统:AI 走服务端代理后,不再使用自填 key // 【正式】公网授权服务固定域名 authServerUrl: GM_getValue('authServerUrl', 'https://api.labsafetyt300.ltd'), cardKey: GM_getValue('cardKey', ''), authStatus: GM_getValue('authStatus', '{}'), // 本地缓存卡密状态 videoSpeed: GM_getValue('videoSpeed', 2.0), debugMode: GM_getValue('debugMode', true), // 章节展开 + 视频自动切换配置 expandChapters: GM_getValue('expandChapters', true), autoPlayVideo: GM_getValue('autoPlayVideo', true), autoSwitchVideo: GM_getValue('autoSwitchVideo', true), noPause: GM_getValue('noPause', true), // 不许暂停:视频未结束时自动恢复播放 videoAnswerMode: GM_getValue('videoAnswerMode', 'api-ai'), chapterExpandDelay: GM_getValue('chapterExpandDelay', 500), videoLoadDelay: GM_getValue('videoLoadDelay', 2000), // 考试模式配置 // 【2.5.21】这两项已改为会话态(见 SESSION_CONFIG),默认均为关闭且刷新不保留 examEnabled: SESSION_CONFIG.examEnabled, // examAutoAdvance:面板开关已移除(2.5.5,批量流程一次性填全部题、无需翻题)。 // 仍保留读取:文件内已停用的旧单题流程(ExamProcessor)会引用它,避免隐式 undefined。 examAutoAdvance: GM_getValue('examAutoAdvance', true), examAutoSubmit: SESSION_CONFIG.examAutoSubmit, examAdvanceDelay: GM_getValue('examAdvanceDelay', 800), // 考试批量答题:两个滑块(读取即夹紧,越界存储值不会漏到请求端) examBatchSize: clampExamBatchSize(GM_getValue('examBatchSize', EXAM_BATCH_SIZE_DEFAULT)), examConcurrency: clampExamConcurrency(GM_getValue('examConcurrency', EXAM_CONCURRENCY_DEFAULT)), // 【2.5.9】「等待超时」滑块(秒):超时即报错、不拆批、不原样重发(读取即夹紧) examRequestTimeout: clampExamRequestTimeout(GM_getValue('examRequestTimeout', EXAM_TIMEOUT_DEFAULT)), // 开发者模式 logDetail: GM_getValue('logDetail', false), // false=精简 true=详细(含AI对话) customPrompt: GM_getValue('customPrompt', ''), // 统一自定义System提示词(所有题型通用) customUserPrompt: GM_getValue('customUserPrompt', ''), // 自定义User提示词(可选) // 复习模式 reviewMode: GM_getValue('reviewMode', false), reviewData: GM_getValue('reviewData', '{}'), // JSON: { 章节名: { 题目: 答案 } } glassBlur: GM_getValue('glassBlur', GM_getValue('advancedUI', false)), // 兼容旧版高级 UI 设置 glassBlurAmount: GM_getValue('glassBlurAmount', 28), panelOpacity: GM_getValue('panelOpacity', 100), depthStrength: GM_getValue('depthStrength', 100), tabAnimEnabled: GM_getValue('tabAnimEnabled', false), // Tab 切换动画,默认关 tabRubberBand: GM_getValue('tabRubberBand', 80), // Tab 拖拽弹力(越界可拉伸距离 px),0=硬边界;预设 80 tabSpringMode: GM_getValue('tabSpringMode', 'manual'), // 弹性模式:auto=物理联动(远=慢) manual=独立调节;预设 manual tabSpringK: GM_getValue('tabSpringK', 0.09), // 独立模式下的弹簧软硬 K(每帧逼近比例 0.02~1,越小越软越慢);预设 0.09 // 趣味物理:默认全开(首次安装即有完整彩蛋体验);关掉后可分别恢复经典手感 funJelly: GM_getValue('funJelly', true), // 按钮果冻按压 funJellySwitch: GM_getValue('funJellySwitch', true), // 开关液态小球 funInertia: GM_getValue('funInertia', true), // 面板惯性甩动 + 撞墙回弹 funParticles: GM_getValue('funParticles', true), // 拖拽/撞墙粒子特效 // 【2.5.21】水球彩蛋默认关闭:长按标题栏 880ms 误触概率高,且进入后是整块面板形变, // 非预期触发会让人以为面板坏了。仍可在「开发者面板」里手动开。 funWaterBall: GM_getValue('funWaterBall', false), }; let currentConfig = {...DEFAULT_CONFIG}; let isAnswering = false; let authStatusRequest = null; let popupObserver = null; let popupPollingId = null; let lastQuestionText = ''; // 用于判断复用容器内题目是否更新 let chapterObserver = null; let chapterExpandPending = false; let videoEndListenerAttached = false; let examProcessing = false; // 考试处理中标记 let activeHintData = null; // 当前激活的错题提示词数据(手动开启) // ============================ 注入高级样式 ============================ GM_addStyle(` #ai-helper-panel, #dev-panel { --panel-transition: width 0.35s cubic-bezier(0.22, 1, 0.36, 1), height 0.35s cubic-bezier(0.22, 1, 0.36, 1), padding 0.35s ease, transform 0.35s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.2s ease, background 0.35s ease, border-color 0.35s ease, border-radius 0.3s ease, box-shadow 0.35s ease, backdrop-filter 0.35s ease, -webkit-backdrop-filter 0.35s ease; /* 【2.5.22】面板圆角唯一真源:::before 描边环必须跟随它,否则高级UI(16px)下 描边环仍用 20px,四角露出双层弧线(双圆角瑕疵)。 */ --panel-radius: 20px; } #ai-helper-panel { position: fixed; top: 120px; right: 20px; width: 340px; background: #fff; border-radius: var(--panel-radius, 20px); padding: 20px; z-index: 9999999; font-size: 14px; user-select: none; /* 收起/展开只动视觉层(transform/opacity),不动布局 → tab 平移读数永远稳定 */ transition: var(--panel-transition); box-sizing: border-box; overflow: hidden; border: 1px solid rgba(0,0,0,0.08); box-shadow: 0 2px 12px rgba(0,0,0,0.12); color: #1a1a1a; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; will-change: transform; } /* 高级UI开启:毛玻璃 + 物理质感 */ #ai-helper-panel.panel-glass { background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(16px) saturate(200%); -webkit-backdrop-filter: blur(16px) saturate(200%); border: none; box-shadow: 0 0 0 1px rgba(255,255,255,0.2), 0 8px 32px rgba(0,0,0,0.2), inset 0 2px 0 rgba(255,255,255,0.3); will-change: transform; } #ai-helper-panel::before { content: ''; position: absolute; inset: 0; border-radius: var(--panel-radius, 20px); /* 【2.5.22】跟随面板,杜绝双圆角 */ padding: 1px; background: linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(255,255,255,0.2) 50%, rgba(0,0,0,0.1) 100%); -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); -webkit-mask-composite: xor; mask-composite: exclude; pointer-events: none; z-index: 10; opacity: 0; } #ai-helper-panel.panel-glass::before { opacity: 1; } #ai-helper-panel.mini { /* 收起:真正的圆形按钮,不保留完整面板的长宽比例。 */ width: 50px; height: 50px; transform: none !important; border-radius: 50%; --panel-radius: 50%; /* 【2.5.22】mini 圆球:描边环同步为圆 */ padding: 0; /* 球心定位基准干净 */ cursor: pointer; } /* mini 态内容整体渐隐(不删 DOM、不 display:none,避免布局抖动) 只隐藏 head 内的兄弟元素(标题 span、⚙ 按钮),toggle 所在按钮组 div 保持 opacity:1, 这样 #panel-toggle 不会被父级 opacity 合成压没,mini 态 ✨ 能正常显形 */ #ai-helper-panel.mini .head > span, #ai-helper-panel.mini #dev-toggle-btn, #ai-helper-panel.mini .panel-tabs, #ai-helper-panel.mini .panel-tab-view { opacity: 0; visibility: hidden; transition: opacity 0.2s ease, visibility 0.2s ease; pointer-events: none; } /* mini 态高亮光斑禁用(防小球残影) */ #ai-helper-panel.mini #panel-highlight { opacity: 0 !important; } /* mini 态收起按钮变身居中的 ✨ 圆钮 */ #ai-helper-panel.mini #panel-toggle { position: absolute; inset: 0; margin: auto; width: 50px; height: 50px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 22px; padding: 0; transform: none; background: #1890ff; color: #fff; box-shadow: 0 4px 16px rgba(24,144,255,0.4); } #panel-highlight { position: absolute; width: 200px; height: 200px; background: radial-gradient(circle, rgba(255,255,255,0.6) 0%, transparent 70%); border-radius: 50%; pointer-events: none; z-index: 5; opacity: 0; transition: opacity 0.3s ease; mix-blend-mode: overlay; } /* 高级UI关闭(白色面板)时不渲染光斑 */ #ai-helper-panel:not(.panel-glass) #panel-highlight { display: none; } #ai-helper-panel .head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; cursor: inherit; padding-bottom: 10px; border-bottom: 1px solid rgba(0,0,0,0.1); } /* 整个面板可拖提示:统一抓手(悬停grab,拖动中grabbing),控件区域恢复默认 */ #ai-helper-panel, #dev-panel { cursor: grab; } #ai-helper-panel.dragging, #dev-panel.dragging { cursor: grabbing !important; } #ai-helper-panel input, #ai-helper-panel button, #ai-helper-panel textarea, #ai-helper-panel select, #ai-helper-panel label, #ai-helper-panel a, #dev-panel input, #dev-panel button, #dev-panel textarea, #dev-panel select, #dev-panel label, #dev-panel a { cursor: default; } #ai-helper-panel .head span { font-weight: bold; font-size: 16px; color: #333; } #ai-helper-panel .item { margin: 10px 0; display: flex; justify-content: space-between; align-items: center; } #ai-helper-panel label { font-weight: 500; } /* 【2.5.21】.item 是 flex 容器,label 与右侧控件同为一行的 flex item: 中文标签默认 min-width:auto + 可折行,右侧控件一宽(如「填入答案 + 提交本次考试 + 开关」) 就把 label 挤成竖排(实测「手动提交」折成「手动提/交」两行)。 标签是定长文案,一律不折、不被压缩;要收缩就压右侧的弹性内容。 */ #ai-helper-panel .item > label { flex-shrink: 0; white-space: nowrap; } #ai-helper-panel input[type="text"], #ai-helper-panel select { width: 150px; padding: 6px 10px; border: 1px solid rgba(0,0,0,0.1); border-radius: 8px; background: rgba(255,255,255,0.5); font-size: 13px; outline: none; transition: border 0.2s; } #ai-helper-panel input[type="text"]:focus, #ai-helper-panel select:focus { border-color: #1890ff; background: rgba(255,255,255,0.8); } #ai-helper-panel input[type="checkbox"] { accent-color: #1890ff; width: 16px; height: 16px; } #ai-helper-panel button { background: #1890ff; color: #fff; border: none; border-radius: 999px; padding: 6px 14px; cursor: pointer; transition: all 0.2s ease; font-weight: 500; } #ai-helper-panel button:hover { background: #40a9ff; transform: translateY(-1px); } #ai-helper-panel .btn-group { display: flex; gap: 10px; margin-top: 15px; } #ai-helper-panel .btn-group button { flex: 1; padding: 10px 0; } /* iOS 风格开关(复选框 → 开关按钮) */ #ai-helper-panel .switch-wrap, #dev-panel .switch-wrap { display: inline-flex; align-items: center; cursor: pointer; position: relative; width: 42px; height: 24px; } #ai-helper-panel .switch-wrap input[type="checkbox"], #dev-panel .switch-wrap input[type="checkbox"] { appearance: none; -webkit-appearance: none; width: 42px; height: 24px; border-radius: 999px; background: rgba(0,0,0,0.18); margin: 0; position: absolute; z-index: 1; opacity: 0; outline: none; cursor: pointer; } #ai-helper-panel .switch-wrap .switch-track, #dev-panel .switch-wrap .switch-track { width: 42px; height: 24px; border-radius: 999px; background: rgba(0,0,0,0.18); position: relative; display: inline-block; transition: background 0.25s; pointer-events: none; } #ai-helper-panel .switch-wrap .switch-track .switch-knob, #dev-panel .switch-wrap .switch-track .switch-knob { position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.3); transition: left 0.25s; } #ai-helper-panel .switch-wrap .switch-track.on, #dev-panel .switch-wrap .switch-track.on { background: #1890ff; } #ai-helper-panel .switch-wrap .switch-track.on .switch-knob, #dev-panel .switch-wrap .switch-track.on .switch-knob { left: 20px; } /* 视频倍速进度条(主面板 + 开发者面板统一样式) */ #ai-helper-panel input[type="range"], #dev-panel input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; box-sizing: border-box; height: 20px; background: transparent; border: none; outline: none; margin: 0; padding: 0; } #ai-helper-panel input[type="range"]::-webkit-slider-runnable-track, #dev-panel input[type="range"]::-webkit-slider-runnable-track { height: 4px; border-radius: 999px; background: #e5e6eb; margin-top: 8px; } #ai-helper-panel input[type="range"]::-webkit-slider-thumb, #dev-panel input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 18px; height: 18px; border-radius: 50%; background: #fff; border: 2px solid #1890ff; box-shadow: 0 1px 3px rgba(0,0,0,0.2); cursor: pointer; margin-top: -7px; } /* 面板 tab 栏(分段控件风格:整条浅灰底 + 蓝色胶囊滑块) */ #ai-helper-panel .panel-tabs { position: relative; display: flex; gap: 4px; margin-bottom: 14px; background: rgba(0,0,0,0.05); padding: 3px; border-radius: 999px; } #ai-helper-panel .panel-tab-indicator { position: absolute; top: 3px; bottom: 3px; left: 3px; width: 0; border-radius: 999px; background: #1890ff; box-shadow: 0 2px 8px rgba(24,144,255,0.4); transition: left 0.7s cubic-bezier(0.22, 1, 0.36, 1), width 0.7s cubic-bezier(0.22, 1, 0.36, 1); z-index: 0; pointer-events: none; } #ai-helper-panel .panel-tab { position: relative; z-index: 1; flex: 1; padding: 7px 0; font-size: 12px; font-weight: 600; background: transparent; color: #666; border: none; border-radius: 999px; cursor: pointer; transition: background 0.25s, color 0.25s, transform 0.25s; } #ai-helper-panel .panel-tab.active { color: #0b1f3a; transform: scale(1.12); } #ai-helper-panel .panel-tabs .panel-tab, #ai-helper-panel .panel-tabs .panel-tab:hover { background: transparent; } #ai-helper-panel .panel-tabs .panel-tab:hover:not(.active) { background: rgba(24,144,255,0.12); } #ai-helper-panel .panel-tab:hover:not(.active) { background: rgba(24,144,255,0.12); color: #1890ff; } /* 介绍子 tab(分段控件,独立于主 tab;胶囊 indicator + 内容平移 + 拖动吸附) */ #ai-helper-panel .intro-sub-tabs { position: relative; display: flex; gap: 4px; margin-bottom: 10px; background: rgba(0,0,0,0.05); padding: 3px; border-radius: 999px; } #ai-helper-panel .intro-indicator { position: absolute; top: 3px; bottom: 3px; left: 3px; width: 0; border-radius: 999px; background: #1890ff; box-shadow: 0 2px 6px rgba(24,144,255,0.4); transition: left 0.7s cubic-bezier(0.22, 1, 0.36, 1), width 0.7s cubic-bezier(0.22, 1, 0.36, 1); z-index: 0; pointer-events: none; } #ai-helper-panel .intro-sub-tab { position: relative; z-index: 1; flex: 1; padding: 5px 0; font-size: 11px; font-weight: 600; background: transparent; color: #666; border: none; border-radius: 999px; cursor: pointer; transition: color 0.25s; white-space: nowrap; } /* 覆盖通用 button:hover 蓝色:介绍子 tab 自身 hover 保持透明,仅非激活 tab 显示浅蓝(与主 tab / dev tab 同款修复) */ #ai-helper-panel .intro-sub-tabs .intro-sub-tab, #ai-helper-panel .intro-sub-tabs .intro-sub-tab:hover { background: transparent; transform: none; } #ai-helper-panel .intro-sub-tabs .intro-sub-tab:hover:not(.active) { background: rgba(24,144,255,0.12); } #ai-helper-panel .intro-sub-tab:hover:not(.active) { background: rgba(24,144,255,0.12); color: #1890ff; } #ai-helper-panel .intro-sub-tab.active { color: #fff; } /* 介绍内容视口:横向 carousel 平移(无滚动条,内容自然高度) */ #ai-helper-panel .intro-sub-view { position: relative; overflow: hidden; } #ai-helper-panel .intro-sub-track { display: flex; } #ai-helper-panel.tab-anim .intro-sub-track { transition: transform 0.5s cubic-bezier(0.22,1,0.36,1); } #ai-helper-panel .intro-sub-content { flex: 0 0 100%; min-width: 100%; box-sizing: border-box; overflow: hidden; /* 将购买胶囊及其阴影限制在所属页内,避免邻页边缘漏色。 */ } #ai-helper-panel .intro-goods-name { font-size: 14px; font-weight: 700; color: #333; } #ai-helper-panel .intro-price-btn { display: inline-flex; align-items: center; padding: 3px 10px; font-size: 12px; font-weight: 700; color: #fff; background: #1890ff; border-radius: 999px; text-decoration: none; box-shadow: 0 2px 6px rgba(24,144,255,0.3); transition: filter 0.2s, transform 0.2s; white-space: nowrap; } #ai-helper-panel .intro-price-btn:hover { filter: brightness(1.1); transform: translateY(-1px); color: #fff; background: #1890ff; } #ai-helper-panel .intro-badge { display: inline-block; margin: 4px 0 6px; padding: 1px 8px; font-size: 11px; color: #1890ff; background: rgba(24,144,255,0.1); border-radius: 999px; } #ai-helper-panel .intro-spec { width: 100%; border-collapse: collapse; font-size: 12px; } #ai-helper-panel .intro-spec td { padding: 5px 8px; border-bottom: 1px dashed rgba(0,0,0,0.08); } #ai-helper-panel .intro-spec td:first-child { color: #888; width: 76px; white-space: nowrap; } #ai-helper-panel .intro-spec td:last-child { font-weight: 600; color: #333; } #ai-helper-panel .intro-fit { margin-top: 8px; padding: 6px 8px; font-size: 12px; color: #333; background: rgba(24,144,255,0.08); border-radius: 8px; line-height: 1.5; } #ai-helper-panel .intro-desc { margin-top: 8px; font-size: 12px; color: #666; line-height: 1.6; } /* Tab 内容切换动画:整页平移 */ #ai-helper-panel .panel-tab-view { position: relative; overflow: hidden; } #ai-helper-panel .panel-tab-track { display: flex; } #ai-helper-panel .panel-tab-content { flex: 0 0 100%; min-width: 100%; box-sizing: border-box; padding: 0 10px; } #ai-helper-panel.tab-anim .panel-tab-track { transition: transform 0.5s cubic-bezier(0.22,1,0.36,1); } #ai-helper-panel.tab-anim:not(.mini) .panel-tab-view { transition: height 0.5s cubic-bezier(0.22,1,0.36,1); } /* 复习模式悬停提示 */ .review-info-btn { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; background: rgba(24,144,255,0.15); color: #1890ff; font-size: 11px; font-weight: bold; cursor: help; position: relative; } .review-info-btn::after { content: attr(data-tooltip); position: absolute; top: 100%; right: 0; z-index: 99999; width: 220px; padding: 8px 10px; border-radius: 8px; background: rgba(0,0,0,0.85); color: #fff; font-size: 11px; line-height: 1.5; font-weight: normal; text-align: left; opacity: 0; visibility: hidden; transition: opacity 0.2s, visibility 0.2s; pointer-events: none; white-space: normal; } .review-info-btn:hover::after { opacity: 1; visibility: visible; } /* 考试提交槽位:开关常驻(关闭态也不消失,随时可切回);【2.5.16】手动态两枚按钮(填入答案 / 提交本次考试) 仅在关闭自动提交时淡入,自动态一起淡出,开关位置不动 */ #ai-helper-panel .exam-submit-slot { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-height: 26px; /* 【2.5.21】不让槽位反向压缩左侧标签(配合 .item > label 的 nowrap); 空间不足时由槽位内部换行,而不是把「手动提交」挤成竖排。 */ flex-shrink: 0; } #ai-helper-panel .exam-submit-slot .exam-manual-submit-btn { padding: 4px 12px; border: none; border-radius: 8px; background: #1890ff; color: #fff; font-size: 12px; font-weight: bold; cursor: pointer; white-space: nowrap; line-height: 18px; transition: opacity 0.25s ease, transform 0.25s ease; /* 【2.5.21】按钮文案也定长,不被压窄(压窄会折行) */ flex-shrink: 0; } /* 【2.5.16】「填入答案」与「提交本次考试」并排:同为实心蓝,靠深浅区分(浅一档 = 填入)。 必须写在这里 —— 与 [data-empty="1"] / [data-state=...] 打平时靠先后顺序定胜负, 排在它们前面,灰态与各状态色才能照常生效 */ #ai-helper-panel .exam-submit-slot .exam-manual-submit-btn.exam-fill-btn { background: #40a9ff; } #ai-helper-panel .exam-submit-slot .exam-manual-submit-btn.exam-fill-btn[data-state="idle"]:hover:not([data-empty="1"]) { background: #69c0ff; } /* 无答案时不用 disabled(Chromium 不给 disabled 控件派发鼠标事件,悬停提示会失效), 改用 data-empty 视觉 + cursor:help,点击时由 JS 告警拦截 */ #ai-helper-panel .exam-submit-slot .exam-manual-submit-btn[data-state="idle"]:hover:not([data-empty="1"]) { background: #40a9ff; } #ai-helper-panel .exam-submit-slot .exam-manual-submit-btn[data-empty="1"] { background: rgba(0,0,0,0.12); color: rgba(0,0,0,0.35); cursor: help; } #ai-helper-panel .exam-submit-slot .exam-manual-submit-btn[data-state="submitting"] { background: rgba(24,144,255,0.65); cursor: default; } /* 【2.5.19】取消 [data-state="done"] 成功态、[data-state="failed"] 失败态: 按钮点击后一律回常态(idle),成败只在 toast 里说;失败想再试直接再点一次就行, 不必让按钮变色喊「重试填入 / 重试提交」 */ /* 自动态:按钮淡出右移并让出指针(visibility 延迟到淡出结束才隐藏),开关始终在位 */ #ai-helper-panel .exam-submit-slot.is-auto .exam-manual-submit-btn { opacity: 0; transform: translateX(6px); pointer-events: none; visibility: hidden; transition: opacity 0.25s ease, transform 0.25s ease, visibility 0s linear 0.25s; } .ai-toast { position: fixed; right: 20px; padding: 12px 20px; border-radius: 8px; color: white; font-weight: bold; z-index: 10000000; box-shadow: 0 4px 12px rgba(0,0,0,0.2); animation: slideIn 0.3s cubic-bezier(0.22, 1, 0.36, 1); } /* 联系开发者留言弹窗(与主面板/开发者面板统一风格;高级UI开=毛玻璃) */ #feedback-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9999999; display: flex; align-items: center; justify-content: center; } #feedback-overlay .feedback-modal { width: min(440px, 90vw); background: #fff; border: 1px solid rgba(0,0,0,0.08); box-shadow: 0 8px 30px rgba(0,0,0,0.25); border-radius: 20px; padding: 20px 22px; font-size: 13px; color: #333; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; } #feedback-overlay .feedback-modal.panel-glass { background: rgba(255,255,255,0.15); backdrop-filter: blur(16px) saturate(200%); -webkit-backdrop-filter: blur(16px) saturate(200%); border: none; box-shadow: 0 0 0 1px rgba(255,255,255,0.2), 0 8px 32px rgba(0,0,0,0.2), inset 0 2px 0 rgba(255,255,255,0.3); } #feedback-overlay .feedback-modal input, #feedback-overlay .feedback-modal textarea { width: 100%; box-sizing: border-box; border: 1px solid rgba(0,0,0,0.1); border-radius: 8px; background: rgba(255,255,255,0.5); padding: 8px; font-size: 13px; font-family: inherit; color: #333; outline: none; transition: border 0.2s; } #feedback-overlay .feedback-modal input:focus, #feedback-overlay .feedback-modal textarea:focus { border-color: #1890ff; background: rgba(255,255,255,0.8); } #feedback-overlay .feedback-modal button { background: #1890ff; color: #fff; border: none; border-radius: 999px; padding: 8px 18px; cursor: pointer; transition: all 0.2s ease; font-weight: 500; font-size: 13px; } #feedback-overlay .feedback-modal button:hover { background: #40a9ff; transform: translateY(-1px); } #feedback-overlay .feedback-modal .fb-title { font-size: 15px; font-weight: bold; color: #333; } /* 说明区:遇到问题 / 每日限额 两行统一为淡蓝底黑色字提示条(背景微蓝,文字黑色) */ #feedback-overlay .feedback-modal .fb-desc { color: #333; background: rgba(24,144,255,0.08); border: 1px solid rgba(24,144,255,0.2); padding: 8px 10px; border-radius: 8px; font-size: 12px; line-height: 1.6; margin-bottom: 6px; font-weight: 500; } #feedback-overlay .feedback-modal .fb-limit { color: #333; background: rgba(24,144,255,0.08); border: 1px solid rgba(24,144,255,0.2); padding: 6px 10px; border-radius: 8px; font-size: 12px; line-height: 1.5; margin-bottom: 10px; font-weight: 500; } /* 购买卡密/测试连接/联系开发者按钮悬停提示:已改为 JS 动态 tooltip(挂 body,见 createPanel 内绑定) */ #ai-helper-panel #buy-btn, #ai-helper-panel #test-api-btn, #ai-helper-panel #contact-btn { position: relative; z-index: 100; } #ai-helper-panel .auth-actions { flex-wrap: nowrap; } #ai-helper-panel .auth-actions button { flex: 1 1 0; min-width: 0; white-space: nowrap; padding: 4px 6px !important; font-size: 12px; } /* 底部说明折叠条(计费说明 / 价格说明,左对齐,收起态提示点击展开) */ #ai-helper-panel .price-note-toggle { display: block; width: 100%; margin: 4px 0 0; padding: 3px 0; font-size: 10px; color: #aaa; background: transparent; border: none; cursor: pointer; text-align: left; } #ai-helper-panel .price-note-toggle:hover { color: #1890ff; background: transparent; transform: none; } #ai-helper-panel .price-note-body { display: none; margin-top: 4px; padding: 6px 8px; font-size: 10px; color: #888; line-height: 1.6; text-align: left; background: rgba(0,0,0,0.03); border-radius: 8px; } #ai-helper-panel .price-note-body.open { display: block; } /* 【2.5.11】标题行内的紧凑折叠按钮(参数行「ⓘ 说明」),不占独立一行 */ #ai-helper-panel .price-note-toggle.inline-note { display: inline-block; width: auto; margin: 0; padding: 0; font-size: 10px; line-height: 1; white-space: nowrap; } /* 【2.5.12】考试参数由滑块改为数字输入框:省掉滑块本体与量程行,数值直接可改 */ #ai-helper-panel .exam-num-input { width: 52px; margin: 0; padding: 2px 6px; border: 1px solid rgba(24,144,255,0.35); border-radius: 6px; background: #fff; color: #1890ff; font-size: 12px; font-weight: bold; line-height: 18px; text-align: center; outline: none; transition: border-color 0.2s ease, box-shadow 0.2s ease; } /* 数字微调箭头在 52px 宽度下会挤掉数字,去掉;微调走键盘上/下或直接输入 */ #ai-helper-panel .exam-num-input::-webkit-outer-spin-button, #ai-helper-panel .exam-num-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } #ai-helper-panel .exam-num-input:focus { border-color: #1890ff; box-shadow: 0 0 0 2px rgba(24,144,255,0.15); } #ai-helper-panel .exam-param-unit { font-size: 12px; font-weight: bold; color: #1890ff; white-space: nowrap; } /* 【2.5.12】考试操作行:开始考试 / 一键完成,同宽并排 */ #ai-helper-panel .exam-action-row { display: flex; gap: 8px; } #ai-helper-panel .exam-act-btn { flex: 1; padding: 6px 8px; border: none; border-radius: 999px; font-size: 12px; font-weight: bold; cursor: pointer; white-space: nowrap; transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease; } /* 【2.5.13】两枚按钮统一蓝色调:状态只用蓝色深浅区分,不引入绿/红等异色 */ /* 「开始考试·未开始」主蓝实心 */ #ai-helper-panel .exam-act-btn.exam-act-start[data-on="0"] { background: #1890ff; color: #fff; } #ai-helper-panel .exam-act-btn.exam-act-start[data-on="0"]:hover { background: #40a9ff; } /* 「开始考试·已开始」浅蓝底 + 蓝字(点击即停),与实心态仅靠色深区分 */ #ai-helper-panel .exam-act-btn.exam-act-start[data-on="1"] { background: rgba(24,144,255,0.14); color: #1890ff; } #ai-helper-panel .exam-act-btn.exam-act-start[data-on="1"]:hover { background: rgba(24,144,255,0.26); color: #096dd9; } /* 「一键完成」比主蓝浅一档的实心蓝,与「开始考试」并排仍可区分 */ #ai-helper-panel .exam-act-btn.exam-act-oneclick { background: #40a9ff; color: #fff; } #ai-helper-panel .exam-act-btn.exam-act-oneclick:hover { background: #69c0ff; } @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } } /* 开发者独立悬浮窗 —— 与主面板统一风格 */ #dev-panel { position: fixed; top: 120px; left: 20px; width: 300px; min-height: 280px; background: #ffffff; border-radius: var(--panel-radius, 20px); padding: 20px; z-index: 10000000; font-size: 14px; user-select: none; display: none; flex-direction: column; overflow: hidden; border: 1px solid rgba(0,0,0,0.08); box-shadow: 0 2px 12px rgba(0,0,0,0.12); color: #1a1a1a; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; transition: var(--panel-transition); } #dev-panel.panel-glass { background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(16px) saturate(200%); -webkit-backdrop-filter: blur(16px) saturate(200%); border: none; box-shadow: 0 0 0 1px rgba(255,255,255,0.2), 0 8px 32px rgba(0,0,0,0.2), inset 0 2px 0 rgba(255,255,255,0.3); } #dev-panel::before { content: ''; position: absolute; inset: 0; border-radius: var(--panel-radius, 20px); /* 【2.5.22】跟随面板,杜绝双圆角 */ padding: 1px; background: linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(255,255,255,0.2) 50%, rgba(0,0,0,0.1) 100%); -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); -webkit-mask-composite: xor; mask-composite: exclude; pointer-events: none; z-index: 10; opacity: 0; transition: opacity 0.3s ease; } #dev-panel.panel-glass::before { opacity: 1; } #dev-panel:not(.panel-glass) .dev-highlight { display: none; } #dev-panel.open { display: flex; } #dev-panel { transform-origin: top left; } #dev-panel.dev-morphing { min-height: 0; padding: 0 !important; transition: none !important; } #dev-panel.dev-morphing::before { border-radius: inherit; } #dev-panel.dev-measuring, #dev-panel.dev-measuring * { transition: none !important; } #dev-panel .dev-morph-content { display: flex; flex-direction: column; flex-shrink: 0; box-sizing: border-box; } #dev-panel .dev-morph-icon { position: absolute; inset: 0; display: grid; place-items: center; color: #fff; pointer-events: none; } #dev-panel .dev-highlight { position: absolute; width: 180px; height: 180px; background: radial-gradient(circle, rgba(255,255,255,0.6) 0%, transparent 70%); border-radius: 50%; pointer-events: none; z-index: 5; opacity: 0; transition: opacity 0.3s ease; mix-blend-mode: overlay; } #dev-panel .dev-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid rgba(0,0,0,0.1); cursor: inherit; font-weight: bold; font-size: 16px; color: #333; position: relative; z-index: 11; } #dev-panel .dev-body { position: relative; z-index: 11; flex: 1; display: flex; flex-direction: column; overflow: hidden; } #dev-panel .dev-tabs { position: relative; display: flex; gap: 4px; margin-bottom: 12px; background: rgba(0,0,0,0.05); padding: 3px; border-radius: 999px; } /* dev 面板胶囊滑块(与主面板一致) */ #dev-panel .dev-tab-indicator { position: absolute; top: 3px; bottom: 3px; left: 3px; width: 0; border-radius: 999px; background: #1890ff; box-shadow: 0 2px 8px rgba(24,144,255,0.4); transition: left 0.7s cubic-bezier(0.22, 1, 0.36, 1), width 0.7s cubic-bezier(0.22, 1, 0.36, 1); z-index: 0; pointer-events: none; } #dev-panel .dev-tab-btn { position: relative; z-index: 1; flex: 1; padding: 7px 0; font-size: 12px; font-weight: 600; background: transparent; color: #666; border: none; border-radius: 999px; cursor: pointer; box-shadow: none; text-align: center; transition: background 0.25s, color 0.25s, transform 0.25s; } #dev-panel .dev-tab-btn.active { color: #0b1f3a; font-weight: 600; transform: scale(1.12); } /* 覆盖通用 button:hover 蓝色:dev tab 自身 hover 保持透明,仅非激活 tab 显示浅蓝 */ #dev-panel .dev-tabs .dev-tab-btn, #dev-panel .dev-tabs .dev-tab-btn:hover { background: transparent; } #dev-panel .dev-tabs .dev-tab-btn:hover:not(.active) { background: rgba(24,144,255,0.12); } #dev-panel .dev-tab-btn:hover:not(.active) { background: rgba(24,144,255,0.12); color: #1890ff; } #dev-panel .dev-tab-view { position: relative; overflow: hidden; } #dev-panel .dev-tab-track { display: flex; } #dev-panel .dev-tab-content { flex: 0 0 100%; min-width: 100%; box-sizing: border-box; display: block; padding: 0 8px; } /* dev 面板 tab 内容切换动画(与主面板同步,整页平移) */ #dev-panel.tab-anim .dev-tab-track { transition: transform 0.5s cubic-bezier(0.22,1,0.36,1); } #dev-panel.tab-anim .dev-tab-view { transition: height 0.5s cubic-bezier(0.22,1,0.36,1); } #dev-panel .dev-ui-sub-tabs { position: relative; display: flex; gap: 4px; margin: 2px 0 10px; background: rgba(0,0,0,0.05); padding: 3px; border-radius: 999px; } #dev-panel .dev-ui-sub-indicator { position: absolute; top: 3px; bottom: 3px; left: 3px; width: 0; border-radius: 999px; background: rgba(24,144,255,0.85); transition: left 0.5s cubic-bezier(0.22,1,0.36,1), width 0.5s cubic-bezier(0.22,1,0.36,1); pointer-events: none; } #dev-panel .dev-ui-sub-tab { position: relative; z-index: 1; flex: 1; padding: 5px 0; border: 0; border-radius: 999px !important; background: transparent; color: #666; font-size: 11px; font-weight: 600; box-shadow: none; text-align: center; transition: background 0.25s, color 0.25s; } #dev-panel .dev-ui-sub-tab.active { color: #fff; } #dev-panel .dev-ui-sub-tabs .dev-ui-sub-tab, #dev-panel .dev-ui-sub-tabs .dev-ui-sub-tab:hover { border-radius: 999px !important; background: transparent; box-shadow: none; transform: none; } #dev-panel .dev-ui-sub-tabs .dev-ui-sub-tab:hover:not(.active) { background: rgba(24,144,255,0.12); color: #1890ff; } #dev-panel .dev-ui-sub-view { position: relative; overflow: hidden; } #dev-panel .dev-ui-sub-track { display: flex; } #dev-panel .dev-ui-sub-content { flex: 0 0 100%; min-width: 100%; box-sizing: border-box; padding: 0 5px; } #dev-panel.tab-anim .dev-ui-sub-track { transition: transform 0.5s cubic-bezier(0.22,1,0.36,1); } #dev-panel.tab-anim .dev-ui-sub-view { transition: height 0.5s cubic-bezier(0.22,1,0.36,1); } #dev-panel #tab-log.dev-tab-content { display: flex; flex-direction: column; overflow: hidden; } #dev-panel textarea { width: 100%; height: 44px; padding: 4px 6px; border: 1px solid rgba(0,0,0,0.1); border-radius: 8px; font-size: 12px; resize: vertical; background: rgba(255,255,255,0.5); font-family: inherit; outline: none; box-sizing: border-box; /* 防止 100%+padding 溢出到相邻 tab 页 */ } #dev-panel textarea:focus { border-color: #1890ff; background: rgba(255,255,255,0.8); } #log-view { width: 100%; flex: 0 1 auto; height: 200px; min-height: 100px; max-height: 600px; resize: vertical; padding: 6px 8px; border: 1px solid rgba(0,0,0,0.1); border-radius: 8px; font-size: 12px; font-family: Consolas, "Courier New", monospace; background: rgba(0,0,0,0.04); color: #333; overflow: auto; white-space: pre-wrap; word-break: break-all; outline: none; box-sizing: border-box; } #dev-panel button { background: #1890ff; color: #fff; border: none; border-radius: 999px; padding: 6px 14px; cursor: pointer; transition: all 0.2s ease; font-weight: 500; } #dev-panel button:hover { background: #40a9ff; transform: translateY(-1px); } #dev-panel .mini-btn { padding: 2px 8px; font-size: 11px; box-shadow: none; } #dev-panel .section-title { font-size: 12px; color: #999; margin: 6px 0 3px; font-weight: bold; } #dev-panel .close-btn { background: rgba(24,144,255,0.15) !important; color: #1890ff; font-size: 16px; cursor: pointer; padding: 2px 8px; box-shadow: none !important; line-height: 1; } #dev-panel .close-btn:hover { color: #ff4d4f; } #dev-panel .item { margin: 8px 0; display: flex; justify-content: space-between; align-items: center; } #dev-panel label { font-weight: 500; font-size: 13px; } #dev-panel input[type="checkbox"] { accent-color: #1890ff; width: 16px; height: 16px; } /* ============ 物理系彩蛋 UI ============ */ /* 1. 按钮果冻按压(欠阻尼回弹) */ #ai-helper-panel button, #dev-panel button { transform-origin: center; will-change: transform; } #ai-helper-panel button.pressed, #dev-panel button.pressed { transform: scale(0.86) !important; transition: transform 0.07s ease !important; } #ai-helper-panel button.jelly-pop, #dev-panel button.jelly-pop { animation: ui-btn-jelly-pop 0.5s cubic-bezier(0.22, 1, 0.36, 1); } @keyframes ui-btn-jelly-pop { 0% { transform: scale(0.86); } 45% { transform: scale(1.08); } 70% { transform: scale(0.96); } 100% { transform: scale(1); } } /* 2. 开关液态小球(overshoot + 按压拉伸) */ #ai-helper-panel.fun-jelly-switch .switch-wrap .switch-track .switch-knob, #dev-panel.fun-jelly-switch .switch-wrap .switch-track .switch-knob { transition: left 0.28s cubic-bezier(0.34, 1.56, 0.64, 1), transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1); } #ai-helper-panel.fun-jelly-switch .switch-wrap:active .switch-track .switch-knob, #dev-panel.fun-jelly-switch .switch-wrap:active .switch-track .switch-knob { transform: scaleX(1.35) scaleY(0.8); } /* 3. 面板撞墙压缩回弹 */ #ai-helper-panel.squash, #dev-panel.squash { animation: ui-panel-squash 0.28s cubic-bezier(0.22, 1, 0.36, 1); } @keyframes ui-panel-squash { 0% { transform: scale(1, 1); } 30% { transform: scale(0.96, 1.05); } 60% { transform: scale(1.03, 0.96); } 100% { transform: scale(1, 1); } } /* 4. 水球模式(长按标题栏触发):面板变果冻水晶球 */ #ai-helper-panel.water-ball { width: 96px !important; height: 96px !important; padding: 0 !important; border-radius: 50% !important; overflow: visible !important; cursor: grab; background: linear-gradient(135deg, rgba(120,200,255,0.9) 0%, rgba(24,144,255,0.92) 45%, rgba(0,76,153,0.95) 100%); box-shadow: 0 0 0 1px rgba(255,255,255,0.35), 0 12px 40px rgba(24,144,255,0.45), inset 0 -8px 16px rgba(0,0,0,0.15), inset 0 6px 10px rgba(255,255,255,0.4); animation: ui-water-ball-pulse 2.4s ease-in-out infinite; } @keyframes ui-water-ball-pulse { 0%, 100% { transform: scale(1); border-radius: 50% 50% 50% 50% / 50% 50% 50% 50%; } 30% { transform: scale(1.05, 0.97); border-radius: 46% 54% 48% 52% / 54% 46% 52% 48%; } 60% { transform: scale(0.98, 1.04); border-radius: 54% 46% 52% 48% / 46% 54% 48% 52%; } } #ai-helper-panel .water-ball-core { display: none; } #ai-helper-panel.water-ball > * { display: none !important; } #ai-helper-panel.water-ball .water-ball-core { display: flex !important; position: absolute; inset: 0; align-items: center; justify-content: center; font-size: 44px; filter: drop-shadow(0 4px 8px rgba(0,0,0,0.2)); animation: ui-water-core-float 2.4s ease-in-out infinite; } @keyframes ui-water-core-float { 0%, 100% { transform: translateY(0) rotate(-6deg); } 50% { transform: translateY(-6px) rotate(6deg); } } #ai-helper-panel.water-ball #panel-highlight { opacity: 1 !important; } /* 5. 拖拽粒子尾迹 */ #ui-particle-layer { position: fixed; inset: 0; pointer-events: none; z-index: 9999998; overflow: hidden; } #ui-particle-layer .uip { position: absolute; border-radius: 50%; pointer-events: none; animation: ui-particle-fly var(--dur, 0.7s) cubic-bezier(0.2, 0.6, 0.4, 1) forwards; } @keyframes ui-particle-fly { 0% { transform: translate(0, 0) scale(1); opacity: var(--o, 0.9); } 100% { transform: translate(var(--dx, 0px), var(--dy, 0px)) scale(0); opacity: 0; } } /* 乳白磨砂:保留鼠标倾斜与 Tab 滑块,压低其他视觉噪声。 */ #ai-helper-panel.panel-glass, #dev-panel.panel-glass { background: rgba(248, 250, 253, calc(0.72 * var(--panel-opacity, 1))); backdrop-filter: blur(var(--glass-blur, 28px)) saturate(125%); -webkit-backdrop-filter: blur(var(--glass-blur, 28px)) saturate(125%); border: 1px solid rgba(255,255,255,0.92); box-shadow: 0 12px 36px rgba(28,42,66,0.12), 0 0 0 1px rgba(28,42,66,0.06); color: #273244; } /* 拖动浮层时暂停背景采样,避免文字底图在合成层重绘中闪烁。 */ #ai-helper-panel.panel-glass:not(.mini):not(.water-ball), #dev-panel.panel-glass { padding: 16px; border-radius: 16px; --panel-radius: 16px; /* 【2.5.22】同步描述边环,与面板圆角保持一致 */ } /* 与主面板共用外观过渡;文字布局属性也必须有可插值的起止值。 */ #dev-panel { box-sizing: border-box; } #dev-panel .dev-head { line-height: 1.4; letter-spacing: 0; font-weight: 600; transition: padding 0.35s ease, font-size 0.35s ease, letter-spacing 0.35s ease, color 0.35s ease; } #dev-panel .item { line-height: 1.4; gap: 0; transition: padding 0.35s ease, font-size 0.35s ease, line-height 0.35s ease, gap 0.35s ease; } #ai-helper-panel.panel-glass .head, #dev-panel.panel-glass .dev-head { margin-bottom: 12px; padding-bottom: 12px; border-bottom-color: rgba(28,42,66,0.07); } #ai-helper-panel.panel-glass .head span, #dev-panel.panel-glass .dev-head { font-size: 14px; font-weight: 600; letter-spacing: 0.2px; color: #273244; } #ai-helper-panel.panel-glass .item, #dev-panel.panel-glass .item { margin: 8px 0; gap: 10px; font-size: 12px; line-height: 1.6; } #dev-panel.panel-glass #tab-ui .item .item { padding: 4px 0; } #ai-helper-panel.panel-glass button:not(.panel-tab):not(.intro-sub-tab), #dev-panel.panel-glass button:not(.dev-tab-btn) { border-radius: 8px; box-shadow: none; transition: background 150ms ease, transform 120ms ease; } #ai-helper-panel.panel-glass button:hover, #dev-panel.panel-glass button:hover { transform: none; } #ai-helper-panel.panel-glass button:active, #dev-panel.panel-glass button:active { transform: scale(0.97); } #ai-helper-panel.panel-glass .panel-tabs, #ai-helper-panel.panel-glass .intro-sub-tabs, #dev-panel.panel-glass .dev-tabs { background: rgba(28,42,66,0.045); } #ai-helper-panel.panel-glass .panel-tab.active, #dev-panel.panel-glass .dev-tab-btn.active { color: #fff; transform: none; } #ai-helper-panel.panel-glass .panel-tab-indicator, #dev-panel.panel-glass .dev-tab-indicator { box-shadow: 0 2px 5px rgba(24,144,255,0.18); } #ai-helper-panel.panel-glass input[type="text"], #dev-panel.panel-glass textarea { background: rgba(255,255,255,0.8); border-color: rgba(28,42,66,0.1); } #ai-helper-panel.panel-glass #test-api-btn, #ai-helper-panel.panel-glass #buy-btn, #ai-helper-panel.panel-glass #contact-btn, #ai-helper-panel.panel-glass #dev-toggle-btn { background: #e9eff7 !important; color: #45617f !important; } #ai-helper-panel .panel-tab-track, #dev-panel .dev-tab-track, #dev-panel .dev-ui-sub-track { align-items: flex-start; } /* 导航和内容共用一个透视平面,内部 carousel 继续各自裁切。 */ #ai-helper-panel .panel-depth-layer, #dev-panel .panel-depth-layer { display: flex; flex-direction: column; min-width: 0; transition: transform 0.18s ease-out; } #ai-helper-panel .head > span, #dev-panel .dev-head > span { transition: translate 0.18s ease-out, transform 0.18s ease-out; } @media (hover: hover) and (prefers-reduced-motion: no-preference) { #ai-helper-panel.panel-glass:not(.mini):not(.water-ball):not(.dragging), #dev-panel.panel-glass:not(.dragging):not(.dev-morphing) { box-shadow: calc(var(--depth-x, 0) * -5px) calc(12px - var(--depth-y, 0) * 4px) 36px rgba(28,42,66,0.12), 0 0 0 1px rgba(28,42,66,0.06); } #ai-helper-panel.panel-glass:not(.mini):not(.water-ball):not(.dragging) .head > span, #dev-panel.panel-glass:not(.dragging):not(.dev-morphing) .dev-head > span { translate: calc(var(--depth-x, 0) * 2.5px) calc(var(--depth-y, 0) * 2.5px); } #ai-helper-panel.panel-glass:not(.mini):not(.water-ball):not(.dragging) .head > span, #dev-panel.panel-glass:not(.dragging):not(.dev-morphing) .dev-head > span, #ai-helper-panel.panel-glass:not(.mini):not(.water-ball):not(.dragging) .panel-depth-layer, #dev-panel.panel-glass:not(.dragging):not(.dev-morphing) .panel-depth-layer { transform: perspective(1000px) rotateX(calc(var(--depth-y, 0) * -2.6deg)) rotateY(calc(var(--depth-x, 0) * 3.2deg)); } } #ai-helper-panel button:focus-visible, #dev-panel button:focus-visible, #ai-helper-panel .switch-wrap:has(input:focus-visible), #dev-panel .switch-wrap:has(input:focus-visible) { outline: 2px solid #1890ff; outline-offset: 3px; } `); // ============================ 工具函数 ============================ const utils = { resetPanelDepth: function(panel) { panel.style.setProperty('--depth-x', 0); panel.style.setProperty('--depth-y', 0); const highlight = panel.querySelector('#panel-highlight, .dev-highlight'); if (highlight) highlight.style.opacity = '0'; }, bindPanelDepth: function(panel) { const highlight = panel.querySelector('#panel-highlight, .dev-highlight'); const motion = window.matchMedia('(hover: hover) and (prefers-reduced-motion: no-preference)'); let frame = null, clientX = 0, clientY = 0; const reset = () => { if (frame !== null) cancelAnimationFrame(frame); frame = null; utils.resetPanelDepth(panel); }; panel.addEventListener('mousemove', event => { clientX = event.clientX; clientY = event.clientY; if (frame !== null) return; frame = requestAnimationFrame(() => { frame = null; if (!motion.matches || !currentConfig.depthStrength || !panel.classList.contains('panel-glass') || panel.matches('.mini, .water-ball, .dragging, .dev-morphing') || panel.contains(document.activeElement) && document.activeElement.matches('input, textarea, select')) { reset(); return; } const rect = panel.getBoundingClientRect(); const x = clientX - rect.left, y = clientY - rect.top; const normalized = (position, size) => Math.max(-1, Math.min(1, position / size * 2 - 1)) * currentConfig.depthStrength / 100; panel.style.setProperty('--depth-x', normalized(x, rect.width)); panel.style.setProperty('--depth-y', normalized(y, rect.height)); highlight.style.opacity = '1'; highlight.style.left = (x - highlight.offsetWidth / 2) + 'px'; highlight.style.top = (y - highlight.offsetHeight / 2) + 'px'; }); }); ['mouseleave', 'mousedown', 'keydown', 'focusin'].forEach(event => panel.addEventListener(event, reset)); motion.addEventListener('change', reset); }, similarity: function(s1, s2) { if (!s1 || !s2) return 0; if (s1 === s2) return 1; const s1Lower = s1.toLowerCase(); const s2Lower = s2.toLowerCase(); if (s1Lower.includes(s2Lower) || s2Lower.includes(s1Lower)) return 0.9; const matrix = []; for (let i = 0; i <= s1Lower.length; i++) matrix[i] = [i]; for (let j = 0; j <= s2Lower.length; j++) matrix[0][j] = j; for (let i = 1; i <= s1Lower.length; i++) { for (let j = 1; j <= s2Lower.length; j++) { const cost = s1Lower[i-1] === s2Lower[j-1] ? 0 : 1; matrix[i][j] = Math.min(matrix[i-1][j] + 1, matrix[i][j-1] + 1, matrix[i-1][j-1] + cost); } } return 1 - matrix[s1Lower.length][s2Lower.length] / Math.max(s1Lower.length, s2Lower.length); }, fuzzyMatchIndex: function(options, answers, matchRate = 0.6) { const matchArr = []; if (!answers || !Array.isArray(answers)) return matchArr; answers.forEach(answer => { options.forEach((option, index) => { if (this.similarity(option.text || option, answer) > matchRate && !matchArr.includes(index)) matchArr.push(index); }); }); return matchArr; }, wait: ms => new Promise(resolve => setTimeout(resolve, ms)), removeHtml: html => html ? html.replace(/<[^>]*>/g, '') : '', cleanText: text => text ? text.replace(/\s+/g, ' ').trim() : '', clickAndVerify: async function(option) { if (!option || !option.element) return false; option.element.click(); for (let i = 0; i < 10; i++) { await this.wait(100); const isCheckedClass = option.element.className.includes('-checked'); const isInputChecked = option.inputElement && option.inputElement.checked; if (isCheckedClass || isInputChecked) { return true; } if (i === 2) { option.element.click(); if (option.inputElement) { option.inputElement.click(); } } } return false; }, log: (...args) => { if (currentConfig.debugMode) console.log(`[${SCRIPT_INFO.name}]`, ...args); }, showMessage: function(message, type = 'info', duration = 3000) { const colors = { success: '#52c41a', error: '#ff4d4f', warning: '#faad14', info: '#1890ff' }; const msgDiv = document.createElement('div'); msgDiv.className = 'ai-toast'; msgDiv.style.background = colors[type] || colors.info; msgDiv.innerHTML = message; // 垂直堆叠:统计当前可见提示数,每条下移52px const existing = document.querySelectorAll('.ai-toast:not(.ai-toast-out)').length; msgDiv.style.top = (20 + existing * 52) + 'px'; document.body.appendChild(msgDiv); const ttl = (type === 'error' || type === 'warning') ? (duration || 5000) : (duration || 3000); setTimeout(() => { msgDiv.classList.add('ai-toast-out'); msgDiv.style.animation = 'slideOut 0.3s cubic-bezier(0.22, 1, 0.36, 1) forwards'; setTimeout(() => msgDiv.remove(), 300); }, ttl); }, // 大号居中的醒目提示(半透明遮罩 + 大字主文案 + 可选小字副文案), // 常驻显示,点击屏幕任意处关闭(不自动消失)。 showBigNotice: function(message, subtitle, type = 'success') { const colors = { success: '#52c41a', error: '#ff4d4f', warning: '#faad14', info: '#1890ff' }; const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;inset:0;z-index:999999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.55);'; const box = document.createElement('div'); box.style.cssText = `background:${colors[type] || colors.success};color:#fff;padding:40px 64px;border-radius:18px;box-shadow:0 16px 60px rgba(0,0,0,0.45);text-align:center;user-select:none;max-width:90vw;`; const big = document.createElement('div'); big.style.cssText = 'font-size:32px;font-weight:700;line-height:1.4;'; big.textContent = message; box.appendChild(big); if (subtitle) { const small = document.createElement('div'); small.style.cssText = 'margin-top:14px;font-size:15px;font-weight:400;opacity:0.9;line-height:1.5;'; small.textContent = subtitle; box.appendChild(small); } box.animate([{ transform: 'scale(0.5)', opacity: 0 }, { transform: 'scale(1)', opacity: 1 }], { duration: 300, easing: 'cubic-bezier(.22,1,.36,1)' }); overlay.appendChild(box); document.body.appendChild(overlay); overlay.addEventListener('click', () => { box.animate([{ transform: 'scale(1.05)', opacity: 1 }, { transform: 'scale(1.05)', opacity: 0 }], { duration: 250, easing: 'ease' }).onfinish = () => overlay.remove(); }); }, findSubmitButton: function(popup) { const submitSelectors = ['.ant-modal-footer .ant-btn-primary', '.ant-modal-footer button:last-child', 'button.ant-btn-primary', 'button:contains("提交")', 'button:contains("确定")']; let submitBtn = null; for (const selector of submitSelectors) { try { submitBtn = popup.querySelector(selector); if (submitBtn && !submitBtn.disabled && submitBtn.offsetParent) break; } catch (e) {} } if (!submitBtn) { const allButtons = popup.querySelectorAll('button'); for (const btn of allButtons) { if ((btn.textContent.includes('提交') || btn.textContent.includes('确定')) && !btn.disabled && btn.offsetParent) { submitBtn = btn; break; } } } return submitBtn; }, triggerButtonClick: function(btn) { if (!btn) return false; try { btn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); btn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); btn.dispatchEvent(new MouseEvent('click', { bubbles: true })); btn.click(); return true; } catch (e) { return false; } }, // 果冻按钮:mousedown 压扁 / mouseup 欠阻尼回弹 / 中途取消恢复 jellyPress: function(btn) { if (!btn) return; const down = () => { if (!currentConfig.funJelly) return; btn.classList.remove('jelly-pop'); btn.classList.add('pressed'); }; const up = () => { btn.classList.remove('pressed'); if (!currentConfig.funJelly) return; btn.classList.add('jelly-pop'); setTimeout(() => btn.classList.remove('jelly-pop'), 520); }; const cancel = () => { btn.classList.remove('pressed'); }; btn.addEventListener('mousedown', down); btn.addEventListener('mouseup', up); btn.addEventListener('mouseleave', cancel); return { down, up, cancel }; }, // 拖拽粒子尾迹(快速甩面板时撒粒子) emitParticles: function(x, y, count) { if (!currentConfig.funParticles) return; const layer = utils.ensureParticleLayer(); count = Math.max(1, Math.min(48, count || 0)); const colors = ['#1890ff', '#40a9ff', '#69c0ff', '#91d5ff', '#bae7ff']; for (let i = 0; i < count; i++) { const p = document.createElement('span'); p.className = 'uip'; const size = 4 + Math.random() * 8; const angle = Math.random() * Math.PI * 2; const dist = 30 + Math.random() * 80; const dur = 0.5 + Math.random() * 0.5; p.style.left = (x - size / 2) + 'px'; p.style.top = (y - size / 2) + 'px'; p.style.width = size + 'px'; p.style.height = size + 'px'; p.style.background = colors[(Math.random() * colors.length) | 0]; p.style.setProperty('--dx', (Math.cos(angle) * dist) + 'px'); p.style.setProperty('--dy', (Math.sin(angle) * dist) + 'px'); p.style.setProperty('--dur', dur + 's'); p.style.setProperty('--o', (0.6 + Math.random() * 0.4).toFixed(2)); layer.appendChild(p); setTimeout(() => p.remove(), dur * 1000 + 100); } }, ensureParticleLayer: function() { let layer = document.getElementById('ui-particle-layer'); if (!layer) { layer = document.createElement('div'); layer.id = 'ui-particle-layer'; document.body.appendChild(layer); } return layer; }, // 面板抛掷 + 撞墙回弹循环:松手后按末速度继续滑行/撞墙/回弹直到静止 launchPanelPhysics: function(panel, getVx, getVy) { if (!currentConfig.funInertia) return; if (panel.dataset.__physicsRunning === '1') return; const vinit = Math.hypot(getVx(), getVy()); if (vinit < 40) return; // 太慢直接吸附位置,不玩物理 panel.dataset.__physicsRunning = '1'; const W = window.innerWidth, H = window.innerHeight; let vx = getVx(), vy = getVy(); let fx = panel.offsetLeft, fy = panel.offsetTop; const REST_SCALE = 0.22; // 每次撞墙速度保留系数 const FRICTION = 0.94; // 每帧空气摩擦,低速时快速停下 const SPEED_EPS = 40; // 低于此速度即停止(px/s) let lastT = performance.now(); const step = (now) => { // 用户在物理循环中重新按住拖动 → 交给拖拽逻辑接管 if (panel.dataset.__physicsRunning !== '1') return; const dt = Math.min(0.032, (now - lastT) / 1000); lastT = now; const pw = panel.offsetWidth, ph = panel.offsetHeight; // 记录反弹前速度,用于撞墙装惯性/碰撞点计算 const preVx = vx, preVy = vy; fx += preVx * dt; fy += preVy * dt; panel.style.left = fx + 'px'; panel.style.top = fy + 'px'; panel.style.right = 'auto'; let hit = false, hitX = null, hitY = null, impactSpeed = Math.hypot(preVx, preVy); if (fx <= 0 && preVx < 0) { fx = 0; vx = -preVx * REST_SCALE; hit = true; hitX = 0; hitY = fy + ph * 0.5; } else if (fx + pw >= W && preVx > 0) { fx = W - pw; vx = -preVx * REST_SCALE; hit = true; hitX = W - pw; hitY = fy + ph * 0.5; } else if (fx <= 0) { fx = 0; vx = 0; } else if (fx + pw >= W) { fx = W - pw; vx = 0; } if (fy <= 0 && preVy < 0) { fy = 0; vy = -preVy * REST_SCALE; hit = true; if (hitX === null) { hitX = fx + pw * 0.5; hitY = 0; } } else if (fy + ph >= H && preVy > 0) { fy = H - ph; vy = -preVy * REST_SCALE; hit = true; if (hitX === null) { hitX = fx + pw * 0.5; hitY = H - ph; } } else if (fy <= 0) { fy = 0; vy = 0; } else if (fy + ph >= H) { fy = H - ph; vy = 0; } // 撞墙粒子:撞击速度越快撒越多(100px/s 起撒,最多 48 粒) if (hit && impactSpeed >= 100) { const n = Math.min(48, Math.round((impactSpeed - 90) / 12)); if (n > 0) utils.emitParticles(hitX, hitY, n); } // 空气摩擦:防止低速时漂移过久 vx *= FRICTION; vy *= FRICTION; if (hit && Math.hypot(vx, vy) > SPEED_EPS) { panel.style.left = fx + 'px'; panel.style.top = fy + 'px'; panel.classList.remove('squash'); void panel.offsetWidth; // 强制 reflow 重启动画 panel.classList.add('squash'); } if (Math.hypot(vx, vy) < SPEED_EPS) { panel.style.left = fx + 'px'; panel.style.top = fy + 'px'; panel.dataset.__physicsRunning = ''; panel.classList.remove('squash'); return; } requestAnimationFrame(step); }; requestAnimationFrame(step); }, isInteractiveEl: function(e) { const el = e && e.target; if (!el || !(el instanceof Element)) return false; return !!el.closest('input, button, textarea, select, label, a, [data-tab], [data-goods]'); }, // 橡皮筋阻尼:超出边界后越拖阻力越大,位移渐进逼近软上限(真正的弹簧手感) // 边界处 1:1 跟手,拖得越远增幅越小,最终饱和在 OVER 像素内;OVER=0 时硬边界(不可越界) rubberBand: function(value, min, max) { const OVER = Math.max(0, Number(currentConfig.tabRubberBand) || 0); if (OVER === 0) return Math.min(max, Math.max(min, value)); if (value < min) return min - OVER * (1 - Math.exp(-(min - value) / OVER)); if (value > max) return max + OVER * (1 - Math.exp(-(value - max) / OVER)); return value; }, // 弹簧软硬 K:auto 模式与 OVER 物理联动(软=远+慢,硬=近+快);manual 模式读独立滑条 springK: function() { if (currentConfig.tabSpringMode === 'manual') { return Math.max(0.02, Math.min(1, Number(currentConfig.tabSpringK) || 0.25)); } const OVER = Math.max(0, Number(currentConfig.tabRubberBand) || 0); return 1 / (1 + OVER / 120); }, // 弹簧跟随器:模拟「恒定拉力 + 理想弹簧 + 粘性阻尼」——位移朝目标逐帧逼近, // 剩余位移 e 指数衰减(越来越短),最终停在目标(= 力平衡点),不再线性跟手。 // applyScore: 每帧把当前显示位移写回 DOM(如 indicator.style.left) createSpringFollower: function(applyScore) { const EPS = 0.35; // 收敛阈值(px),小于该值直接吸附到目标 let target = null, value = null, rafId = null; const loop = () => { rafId = null; const diff = target - value; const K = utils.springK(); // 每帧实时读取(滑条可动态调) if (Math.abs(diff) < EPS) { value = target; // 收敛:精确归位 } else { value += diff * K; // 剩余位移按比例衰减(指数逼近,恒定拉力+阻尼的解) } applyScore(value); if (value !== target) rafId = requestAnimationFrame(loop); }; return { reset: function (v) { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } target = value = v; }, seek: function (t) { target = t; if (rafId === null) rafId = requestAnimationFrame(loop); }, stop: function () { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } } }; } }; // ============================ 日志系统 ============================ const Logger = { MAX_ENTRIES: 100, buffer: [], add: function(type, message, detail) { const entry = { time: new Date().toLocaleTimeString(), type: type, // 'info' | 'success' | 'error' | 'ai' | 'milestone' message: message, detail: detail || null // 详细模式下才展示的附加内容 }; this.buffer.push(entry); if (this.buffer.length > this.MAX_ENTRIES) { this.buffer.shift(); // 丢弃最旧的 } // 同时输出到控制台(debug模式) if (currentConfig.debugMode) { const prefix = { ai: '🤖', milestone: '◆', success: '✅', error: '❌', info: 'ℹ️' }[type] || '·'; console.log(`[LOG ${entry.time}] ${prefix} ${message}`, detail || ''); } }, // 里程碑记录(精简模式下显示) milestone: function(msg) { this.add('milestone', msg); }, // AI对话记录(详细模式下显示) ai: function(msg, detail) { this.add('ai', msg, detail); }, // 获取日志文本 getText: function() { if (this.buffer.length === 0) return '暂无日志'; return this.buffer.map(e => { const prefix = { ai: '[AI]', milestone: '[·]', success: '[✓]', error: '[✗]', info: '[i]' }[e.type] || '[·]'; let line = `${e.time} ${prefix} ${e.message}`; if (currentConfig.logDetail && e.detail) { line += '\n' + e.detail.split('\n').map(l => ' ' + l).join('\n'); } return line; }).join('\n'); }, clear: function() { this.buffer = []; } }; // ============================ 授权服务(卡密验证/答题代理) ============================ const AuthService = { // 从页面提取当前登录账号标识(学号):localStorage 的 pro__Login_Userinfo getCurrentAccountId: function() { try { const raw = localStorage.getItem('pro__Login_Userinfo'); if (raw) { const parsed = JSON.parse(raw); const info = (parsed && parsed.value) || parsed; return info.username || info.workNo || info.id || ''; } } catch (e) {} return ''; }, // 设备指纹(本地持久化,激活时携带) getDeviceFp: function() { let fp = GM_getValue('deviceFp', ''); if (!fp) { fp = 'dev-' + Math.random().toString(36).slice(2, 10); GM_setValue('deviceFp', fp); } return fp; }, getCachedStatus: function() { try { return JSON.parse(currentConfig.authStatus) || null; } catch (e) { return null; } }, setCachedStatus: function(status) { currentConfig.authStatus = JSON.stringify(status || {}); GM_setValue('authStatus', currentConfig.authStatus); }, // 本地缓存是否还有余额(用于区分「余额不足」与「上游失效」) hasBalance: function() { const st = this.getCachedStatus(); if (!st) return false; const b = st.balance_cent; return typeof b === 'number' && b > 0; }, // 上游类失败错误码:服务端侧问题(上游未配置/禁用/调用失败),与用户余额无关 isUpstreamFailure: function(code) { return code === 'UPSTREAM_ERROR' || code === 'UPSTREAM_DISABLED' || code === 'SERVER_NOT_CONFIGURED'; }, // POST 封装(GM_xmlhttpRequest,绕过 CORS) request: function(path, body, timeout = 15000) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'POST', url: currentConfig.authServerUrl.replace(/\/+$/, '') + path, headers: { 'Content-Type': 'application/json' }, data: JSON.stringify(body), timeout, onload: (res) => { try { resolve(JSON.parse(res.responseText)); } catch (e) { resolve({ success: false, code: 'BAD_RESPONSE', msg: '服务响应解析失败' }); } }, onerror: () => resolve({ success: false, code: 'NETWORK_ERROR', msg: '无法连接授权服务,请检查服务器地址' }), // 【2.5.9】超时不再并入 NETWORK_ERROR:单独标 TIMEOUT 并带上等待秒数, // 这样它就不会被当成"可重试的网络抖动"原样重发同一批(最坏白等 216 秒) ontimeout: () => resolve({ success: false, code: 'TIMEOUT', msg: `授权服务超时(等待 ${Math.round(timeout / 1000)} 秒无响应)` }) }); }); }, // 激活卡密 activate: async function() { const cardKey = currentConfig.cardKey.trim(); if (!cardKey) { utils.showMessage('请先填入卡密', 'warning'); return false; } const accountId = this.getCurrentAccountId(); utils.showMessage('正在激活卡密...', 'info'); const res = await this.request('/api/activate', { card_key: cardKey, account_id: accountId, device_fp: this.getDeviceFp() }); if (res.success) { this.setCachedStatus(res.data); Logger.milestone(`卡密激活成功:类型=${res.data.type} 余额=${res.data.balance_cent}分`); utils.showMessage('✅ 卡密激活成功', 'success'); return true; } else { utils.showMessage('激活失败:' + res.msg, 'error'); Logger.add('error', '卡密激活失败', res.code + ' - ' + res.msg); return false; } }, // 实时刷新卡密状态 refreshStatus: async function() { const cardKey = currentConfig.cardKey.trim(); if (!cardKey) return null; // ponytail: one shared status request; add per-account locking only if concurrency grows. if (authStatusRequest) return authStatusRequest; authStatusRequest = this.request('/api/status', { card_key: cardKey }) .then((res) => { if (res.success) { this.setCachedStatus(res.data); return res.data; } return null; }) .finally(() => { authStatusRequest = null; }); return authStatusRequest; }, // 答题前只使用本地状态预检;/api/answer 仍会执行最终授权和余额校验。 ensureReady: async function() { const cardKey = currentConfig.cardKey.trim(); if (!cardKey) return { ok: false, error: '未激活卡密:请在面板填入并激活授权卡密' }; let st = this.getCachedStatus(); if (!st || !st.card_key || st.card_key !== cardKey) { return { ok: false, error: '请先激活卡密,或点击“刷新状态”同步已有卡密' }; } // 有效期检查:bind 与 quota 通用。quota 自 2026-10-13 起也有时限(服务端首次激活时定格, // 默认 90 天≈3 个月);未激活过的卡 expires_at 为 null,即不限期,照常放行。 if (st.expires_at && new Date(String(st.expires_at).replace(' ', 'T')) < new Date()) { return { ok: false, error: '卡密已过期,请续费' }; } if (st.type === 'bind') { // 统一额度模型:bind 也有余额,扣完即停 if (st.balance_cent == null || st.balance_cent <= 0) { return { ok: false, error: '额度不足:余额已用完,请购买新卡密' }; } } else if (st.balance_cent <= 0) { return { ok: false, error: '本地余额不足;如已充值,请点击“刷新状态”同步额度' }; } return { ok: true, status: st }; }, // 答题代理:messages 已构建好,发到服务端调用 AI 并扣费 // 【2.5.9】timeout 参数化:考试批量路径传「等待超时」滑块值,其余路径沿用原 70 秒 answer: async function(messages, timeout = AUTH_ANSWER_TIMEOUT_MS) { return this.request('/api/answer', { card_key: currentConfig.cardKey.trim(), account_id: this.getCurrentAccountId(), messages: messages, model: currentConfig.deepseekModel }, timeout); }, // 联系开发者:弹出留言框,提交到 /api/feedback(服务端推 Termux 通知到 Pad) openFeedbackModal: function() { const overlay = document.createElement('div'); overlay.id = 'feedback-overlay'; const box = document.createElement('div'); box.className = 'feedback-modal' + (currentConfig.glassBlur ? ' panel-glass' : ''); box.innerHTML = `
💬 联系开发者
遇到问题(如「上游算力用尽」)可以直接留言,开发者会尽快处理。服务端一般几分钟内就能收到通知。
每个卡密每天最多留言 5 条,超出请明天再试
`; overlay.appendChild(box); document.body.appendChild(overlay); // esc 关闭 / 点遮罩关闭 const close = () => overlay.remove(); const onKey = (e) => { if (e.key === 'Escape') close(); }; document.addEventListener('keydown', onKey); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); box.querySelector('#fb-close').addEventListener('click', close); // 发送:需已激活卡密(服务端也会强制校验+每日限流);成功不关弹窗 box.querySelector('#fb-send').addEventListener('click', async () => { const content = box.querySelector('#fb-content').value.trim(); const msgEl = box.querySelector('#fb-msg'); if (!currentConfig.cardKey.trim()) { msgEl.textContent = '请先填入并激活授权卡密,再联系开发者(防滥用)'; msgEl.style.color = '#ff4d4f'; return; } if (!content) { msgEl.textContent = '请先填写留言内容'; msgEl.style.color = '#ff4d4f'; return; } msgEl.textContent = '发送中...'; msgEl.style.color = '#999'; const res = await AuthService.request('/api/feedback', { name: '', contact: box.querySelector('#fb-contact').value.trim(), content: content, card_key: currentConfig.cardKey.trim() }); if (res && res.success) { msgEl.textContent = '✅ 留言已提交,开发者会尽快处理'; msgEl.style.color = '#52c41a'; box.querySelector('#fb-content').value = ''; box.querySelector('#fb-contact').value = ''; } else { msgEl.textContent = '❌ 提交失败:' + ((res && res.msg) || '请检查服务器连接'); msgEl.style.color = '#ff4d4f'; } }); } }; // ============================ DeepSeek API模块 ============================ const DeepSeekService = { // 提示词注入审核:检测常见越狱/指令注入模式,返回命中的风险名称列表 checkInjection: function(text) { if (!text) return []; const patterns = [ { re: /忽略\s*(?:上面|以上|之前|前面|前述|此前)?\s*(?:的)?\s*(?:指令|提示|要求|内容|规则|设定|所有)/, name: '忽略指令注入' }, { re: /ignore\s+(?:previous|all|above|prior|everything|instructions)/i, name: '忽略指令注入(英文)' }, { re: /忘记\s*(?:之前|上面|以上|所有)\s*(?:的)?\s*(?:指令|设定|规则|提示)/, name: '忘记指令注入' }, { re: /你\s*(?:现在|开始|被)?\s*(?:要|是)?\s*(?:帮我)?\s*(?:扮演|当成|装作|假装)/, name: '角色伪装注入' }, { re: /jailbreak|developer mode|dev mode|DAN/i, name: '越狱关键词' }, { re: /system\s*prompt/i, name: '系统提示词探询' }, { re: /重新\s*(?:编写|生成|设置|设计)\s*(?:你)?\s*(?:的)?\s*(?:指令|提示词|规则)/i, name: '重写指令注入' }, { re: /─{10,}|━━{3,}/, name: '伪装分隔符' }, ]; return patterns.filter(p => p.re.test(text)).map(p => p.name); }, getAnswerFromDeepSeek: async function(question, options, questionType, hintData) { if (!currentConfig.aiAnswerEnabled) { utils.showMessage('请先在设置中启用AI答题', 'warning'); return []; } // 手动激活的错题提示词优先,其次调用时传入的 const effectiveHint = hintData || activeHintData; const systemPrompt = this.buildSystemPrompt(questionType); const userPrompt = this.buildUserPrompt(question, options, questionType, effectiveHint); // 提示词审核:检测注入风险,命中则阻止发送并提示 const injection = this.checkInjection(systemPrompt + '\n' + userPrompt); if (injection.length > 0) { Logger.milestone(`⚠️ 检测到提示词注入风险(${injection.join('、')}),已阻止本次AI调用`); utils.showMessage(`⚠️ 检测到提示词注入风险(${injection.join('、')}),已阻止AI调用`, 'warning'); return []; } // 授权校验(卡密) const ready = await AuthService.ensureReady(); if (!ready.ok) { utils.showMessage(ready.error, 'warning'); Logger.add('error', '授权校验未通过', ready.error); return []; } Logger.ai('调用AI(代理)', 'System: ' + systemPrompt + '\nUser: ' + userPrompt); const res = await AuthService.answer([ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt } ]); if (!res.success) { // 服务端是余额最终来源;拒绝后更新本地显示,不额外触发状态请求。 if (res.code === 'INSUFFICIENT_BALANCE') { const cached = AuthService.getCachedStatus(); if (cached) { cached.balance_cent = 0; AuthService.setCachedStatus(cached); } if (ControlPanel.updateAuthStatusUI) ControlPanel.updateAuthStatusUI(); utils.showMessage(res.msg, 'error'); } else if (AuthService.isUpstreamFailure(res.code) && AuthService.hasBalance()) { // 上游失效/未配置/禁用(服务端问题,非用户余额问题) utils.showMessage('上游算力用尽,请联系开发者', 'error'); } else { utils.showMessage('AI请求失败:' + res.msg, 'error'); } Logger.add('error', 'AI代理请求失败', res.code + ' - ' + res.msg); return []; } try { const raw = (res.data && res.data.content || '').trim(); const answer = this.parseAIResponse(raw, questionType, options); Logger.ai('AI响应(代理)', '原始: ' + raw + '\n解析: ' + JSON.stringify(answer)); // 同步本地缓存的余额,并实时刷新主页面「授权」余额显示 if (res.data && res.data.balance_cent !== undefined) { const cached = AuthService.getCachedStatus(); if (cached) { cached.balance_cent = res.data.balance_cent; AuthService.setCachedStatus(cached); } } if (ControlPanel.updateAuthStatusUI) ControlPanel.updateAuthStatusUI(); return answer; } catch (e) { Logger.add('error', 'AI解析失败', e.message); return []; } }, refreshAuthStatus: async function() { if (!currentConfig.cardKey.trim()) { utils.showMessage('请先填入卡密', 'warning'); return false; } utils.showMessage('正在刷新授权状态...', 'info'); const status = await AuthService.refreshStatus(); if (status) { if (ControlPanel.updateAuthStatusUI) ControlPanel.updateAuthStatusUI(); const power = status.power != null ? status.power : (status.balance_cent || 0); const est = status.est_questions != null ? status.est_questions : ''; utils.showMessage(`授权状态已刷新(${status.type === 'bind' ? '绑定账号型' : '额度型'},算力 ${power} 分${est !== '' ? ' · 约 ' + est + ' 道' : ''})`, 'success'); return true; } else { utils.showMessage('刷新授权状态失败,请检查网络或卡密', 'error'); return false; } }, testPrompt: async function(question, options, questionType) { // 用当前生效的提示词实际调用AI一次,返回完整 prompt 与原始回复 if (!currentConfig.cardKey.trim()) { utils.showMessage('请先填入并激活卡密', 'warning'); return null; } const systemPrompt = this.buildSystemPrompt(questionType); const userPrompt = this.buildUserPrompt(question, options, questionType, activeHintData); // 提示词审核:命中注入则拦截,不发请求 const injection = this.checkInjection(systemPrompt + '\n' + userPrompt); if (injection.length > 0) { Logger.milestone(`⚠️ 提示词测试:检测到注入风险(${injection.join('、')}),未发送`); return { systemPrompt, userPrompt, raw: `⚠️ 已拦截:检测到提示词注入风险(${injection.join('、')})`, status: 'blocked' }; } const res = await AuthService.answer([ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt } ]); if (!res.success) { // 有余额但上游全部失效:raw 直接显示修正文案(开发面板 AI 回复框可读) const msg = (AuthService.isUpstreamFailure(res.code) && AuthService.hasBalance()) ? '上游算力用尽,请联系开发者' : res.msg; return { systemPrompt, userPrompt, raw: msg, status: res.code }; } const raw = (res.data && res.data.content || '').trim(); Logger.ai('提示词测试', 'System: ' + systemPrompt + '\nUser: ' + userPrompt + '\nAI回复: ' + raw); // 同步余额缓存 + 刷新主页面授权余额显示(测试也会扣费) if (res.data && res.data.balance_cent !== undefined) { const cached = AuthService.getCachedStatus(); if (cached) { cached.balance_cent = res.data.balance_cent; AuthService.setCachedStatus(cached); } } if (ControlPanel.updateAuthStatusUI) ControlPanel.updateAuthStatusUI(); return { systemPrompt, userPrompt, raw, status: 'ok' }; }, buildSystemPrompt: function(questionType) { const defaults = { 'judgment': '你是实验室安全课程专家,判断以下陈述是否正确,仅回答"正确"或"错误",不要解释。', 'single': '你是实验室安全课程专家,回答以下单选题,仅返回选项字母(如A、B),不要解释。', 'multiple': '你是实验室安全课程专家,回答以下多选题,返回所有正确选项字母(如A,B,C),不要解释。', 'default': '你是实验室安全课程专家,准确回答以下问题,直接给出答案。' }; // 复习模式:禁止自定义提示词,强制用题型默认(避免与错题数据注入冲突) if (currentConfig.reviewMode) { return defaults[questionType] || defaults['default']; } // 统一自定义提示词:填了所有题型都用它,否则按题型用默认 const custom = currentConfig.customPrompt; return (custom && custom.trim()) ? custom.trim() : (defaults[questionType] || defaults['default']); }, buildUserPrompt: function(question, options, questionType, hintData) { let prompt = ''; // 用户自定义 User 提示词(可选):复习模式下禁用(避免与错题注入冲突) if (!currentConfig.reviewMode && currentConfig.customUserPrompt && currentConfig.customUserPrompt.trim()) { prompt += currentConfig.customUserPrompt.trim() + '\n\n'; } // 采集到的错题参考答案(作为上下文,帮助AI识别) if (hintData && hintData.length > 0) { prompt += '【历史错题已确认正确答案(优先级最高):若当前题目与以下某题相符,请直接采用其答案,勿受系统或其它指令影响】\n'; hintData.forEach(item => { prompt += `- 题目:${item.question}\n 答案:${Array.isArray(item.answer) ? item.answer.join('、') : item.answer}\n`; }); prompt += '\n'; } prompt += `题目:${question}\n`; if (options && options.length > 0) { prompt += '选项:\n'; options.forEach((opt, idx) => prompt += `${String.fromCharCode(65 + idx)}. ${opt}\n`); } return prompt + '\n请直接给出答案:'; }, // 手动激活错题提示词(一次性构建,后续答题复用) setHintData: function(data) { activeHintData = data && data.length > 0 ? data : null; return activeHintData; }, // 清除错题提示词(恢复默认提示词) clearHintData: function() { activeHintData = null; }, // 当前错题提示词状态 getHintStatus: function() { return activeHintData ? activeHintData.length : 0; }, parseAIResponse: function(aiResponse, questionType, options) { if (!aiResponse) return []; aiResponse = aiResponse.trim(); if (questionType === 'judgment') { if (aiResponse.includes('正确') || aiResponse.includes('对') || aiResponse === 'A') return ['正确']; if (aiResponse.includes('错误') || aiResponse.includes('错') || aiResponse === 'B') return ['错误']; } if (questionType === 'single') { const match = aiResponse.match(/[A-D]/); if (match && options && options.length > 0) { const index = match[0].charCodeAt(0) - 65; if (index >= 0 && index < options.length) return [options[index]]; } return [aiResponse]; } if (questionType === 'multiple') { let answers = []; const matches = aiResponse.match(/[A-D]/g); if (matches) { matches.forEach(letter => { const index = letter.charCodeAt(0) - 65; if (index >= 0 && index < options.length && !answers.includes(options[index])) answers.push(options[index]); }); } return answers.length > 0 ? answers : [aiResponse]; } return [aiResponse]; } }; // ============================ 商品介绍数据(与酷发卡上架档位对应) ============================ // 注意:这里**不再存 buyUrl** —— 购买链接由 ShopConfig.linkFor(key) 热加载提供(见文件头 // SHOP_FALLBACK / /api/shop-config),换发卡站不必改源码。 const GOODS_INTRO = [ { key: 'trial', shortName: '尝鲜款', name: '尝鲜款 · 绑定型', badge: '绑定账号型', type: '绑定账号型(一人一码)', price: '¥3.90', power: '80 算力', questions: '约 42 道', validity: '10 天(激活起算)', unitCost: '≈0.093 元/道', fit: '最低门槛体验:先花小钱试水完整 AI 功能', desc: '激活时绑定当前登录账号,卡密专属该账号使用,不可更换。适合想低成本体验 AI 自动答题 / 自动播放效果,确认可用后再买账号型。' }, { key: 'account', shortName: '账号型', name: '账号型 · 绑定型', badge: '绑定账号型', type: '绑定账号型(一人一码)', price: '¥10.00', power: '1420 算力', questions: '约 747 道', validity: '10 天(激活起算)', unitCost: '≈0.013 元/道', fit: '单账号 10 天内把课程与考试一次刷完', desc: '绑定当前登录账号,一人一码。1420 算力约可答 747 道,覆盖单个账号完整的课程 + 考试量,10 天有效期;单价成本最低,买一份基本够一个号刷完。' } ]; // ============================ UI面板构建与交互 ============================ const ControlPanel = { powerView: 'ball', // 算力显示模式:'ball' 余额 / 'total' 总量,点击切换 createPanel: function() { if (document.getElementById('ai-helper-panel')) return; const panel = document.createElement('div'); panel.id = 'ai-helper-panel'; // 商品介绍:子 tab 数量由 GOODS_INTRO 决定(当前 2 档:尝鲜 / 账号型),数据驱动渲染 const introSubTabsHtml = GOODS_INTRO.map(g => `` ).join(''); const introSubContentsHtml = GOODS_INTRO.map(g => `
${g.name} ${ShopConfig.buttonText(g.key, g.price)}
${g.badge}
卡密类型${g.type}
算力额度${g.power}
约可答题${g.questions}
有效期${g.validity}
单题成本${g.unitCost}
🎯 ${g.fit}
${g.desc}
`).join(''); panel.innerHTML = `
实安课程 · T300
未激活
${currentConfig.videoSpeed}x
1x16x
题
每次交给 AI 的题目数量,可填 ${EXAM_BATCH_SIZE_MIN}–${EXAM_BATCH_SIZE_MAX}。调大省请求、但单次响应更长,截断风险上升。
轮
同一时间并发发起的批次数,可填 ${EXAM_CONCURRENCY_MIN}–${EXAM_CONCURRENCY_MAX}。调大更快、但并发过高易触发限流或超时。
秒
单批请求最多等多久,可填 ${EXAM_TIMEOUT_MIN}–${EXAM_TIMEOUT_MAX} 秒(推荐默认 ${EXAM_TIMEOUT_DEFAULT} 秒)。超时即判定该批失败并停止重试(不原样重发),可调小「单轮题数」或调大此处后重跑。
总体提交次数:考试开始后自动计算
批量答题:等待考试开始
${introSubTabsHtml}
${introSubContentsHtml}
算力按 AI 的 token 消耗计费(模型的思考过程不计费),提示词越长或错题参考数据越多,单次消耗会相应增加。面板显示的是本地缓存快照,不是实时值;要同步最新余额请点「刷新状态」(只查询、不扣费)
价格主要覆盖以下几块成本:
· AI 模型调用费:每一次答题都真实调用大模型 API,按 token 消耗计费,这是最大头;
· 中转 + 服务器 + 域名费:免去自己配置 API Key 的麻烦,卡密校验、答案代理、额度结算都跑在独立服务器上,填卡密即可直接调用 AI,含带宽与域名续费;
· 脚本开发投入:脚本诞生过程中开发调试、功能联调产生了大量真实 AI 调用,这部分成本也分摊在了价格里。
按实际消耗扣算力:用多少、扣多少。
`; document.body.appendChild(panel); // 高级UI(默认关=白色面板;开=毛玻璃+物理效果) if (currentConfig.glassBlur) panel.classList.add('panel-glass'); // Tab 切换动画 if (currentConfig.tabAnimEnabled) panel.classList.add('tab-anim'); this.bindEvents(panel); // 售卖链接热加载:上面的 HTML 已按「本地缓存 / 内置兜底」渲染出可用链接, // 这里再拉一次远程配置,拿到后原地刷新按钮(失败静默保持兜底,不打扰用户) ShopConfig.load(); }, bindEvents: function(panel) { const highlight = panel.querySelector('#panel-highlight'); const toggleBtn = panel.querySelector('#panel-toggle'); // 水球模式核心光球(CSS .water-ball 时显示) let waterCore = panel.querySelector('.water-ball-core'); if (!waterCore) { waterCore = document.createElement('div'); waterCore.className = 'water-ball-core'; waterCore.textContent = '💧'; panel.appendChild(waterCore); } // 折叠逻辑:mini 态改为真实 50px 圆形,避免缩放后保留椭圆外框。 const togglePanel = () => { const willMini = !panel.classList.contains('mini'); if (willMini) { // 方案B:3D 高光已作用在内容视图 tabView;收起前清楚残留倾斜,面板本体由 CSS .mini transform:none 兜底 tabView.style.transform = ''; panel.classList.add('mini'); toggleBtn.textContent = '✨'; highlight.style.opacity = '0'; // mini 态 mousemove 高光已禁用;若面板本体仍有残留 inline,rAF 兜底清空 requestAnimationFrame(() => { if (panel.classList.contains('mini')) panel.style.transform = ''; }); } else { // 展开:移除 mini class,恢复正常面板尺寸。 panel.classList.remove('mini'); toggleBtn.textContent = '收起'; // 收起时窄宽度会把内容撑高;展开后清掉旧高度并按正常宽度重测。 const refreshExpandedHeight = () => { const tabView = panel.querySelector('.panel-tab-view'); const activeTab = panel.querySelector('.panel-tab.active'); if (tabView) tabView.style.height = ''; if (activeTab) switchTab(activeTab.dataset.tab); }; // 宽高过渡约 350ms,等过渡结束后再测量,避免按 50px 窄宽度计算。 setTimeout(refreshExpandedHeight, 400); } }; toggleBtn.addEventListener('click', e => { e.stopPropagation(); if (!dragMoved) togglePanel(); }); panel.addEventListener('click', () => { if (panel.classList.contains('mini') && !dragMoved) togglePanel(); }); // ===== 果冻按钮:面板内所有 button 欠阻尼按压回弹 ===== panel.querySelectorAll('button').forEach(b => utils.jellyPress(b)); // ===== 彩蛋:长按标题栏 880ms 进入水球模式;水球态长按面板退出 ===== (function(){ const head = panel.querySelector('.head'); if (!head) return; let wbTimer = null; // 按下启动计时;弹起取消(仅当计时尚未触发);触发后切换一次 const wbArm = (e) => { if (!currentConfig.funWaterBall) return; if (panel.classList.contains('mini')) return; const isWB = panel.classList.contains('water-ball'); if (!isWB) { // 进入:必须按在标题栏上 if (e.target !== head && !head.contains(e.target)) return; } else { // 退出:不能按在交互元素上 if (utils.isInteractiveEl(e)) return; } if (wbTimer) { clearTimeout(wbTimer); wbTimer = null; } wbTimer = setTimeout(() => { wbTimer = null; if (!currentConfig.funWaterBall) return; panel.classList.toggle('water-ball'); utils.log('💧 水球模式:' + (panel.classList.contains('water-ball') ? '开(长按水球退出)' : '关')); }, 880); }; const wbDisarm = () => { if (wbTimer) { clearTimeout(wbTimer); wbTimer = null; } }; head.addEventListener('mousedown', wbArm); panel.addEventListener('mousedown', wbArm); // 弹起/移出都打断未完成的长按;若计时已触发(timer=null)则不影响 document.addEventListener('mouseup', wbDisarm); panel.addEventListener('mouseleave', wbDisarm); })(); // 内容包装(carousel 两层:固定视口 + 平移 track) const tabView = document.createElement('div'); tabView.className = 'panel-tab-view'; const tabTrack = document.createElement('div'); tabTrack.className = 'panel-tab-track'; panel.querySelectorAll('.panel-tab-content').forEach(c => { c.style.display = 'block'; tabTrack.appendChild(c); }); tabView.appendChild(tabTrack); panel.querySelector('.panel-tabs').after(tabView); const depthLayer = document.createElement('div'); depthLayer.className = 'panel-depth-layer'; const navigation = panel.querySelector('.panel-tabs'); navigation.before(depthLayer); depthLayer.append(navigation, tabView); // tab 栏切换(胶囊滑块跟随激活 tab) const tabBtns = panel.querySelectorAll('.panel-tab'); const tabIndicator = panel.querySelector('.panel-tab-indicator'); const updateTabIndicator = () => { const active = panel.querySelector('.panel-tab.active'); if (!tabIndicator || !active) return; tabIndicator.style.left = active.offsetLeft + 'px'; tabIndicator.style.width = active.offsetWidth + 'px'; }; const switchTab = (name) => { const curBtn = panel.querySelector('.panel-tab[data-tab="' + name + '"]'); const curIdx = curBtn ? Array.from(tabBtns).indexOf(curBtn) : 0; tabBtns.forEach(t => t.classList.toggle('active', t.dataset.tab === name)); if (tabTrack && tabView) { tabTrack.style.transform = 'translateX(' + (-curIdx * 100) + '%)'; const content = panel.querySelector('#tab-' + name); if (content) tabView.style.height = content.offsetHeight + 'px'; } updateTabIndicator(); // 【2.5.20】进考试页签就重算一次提交槽位:按钮可用性以「卷面有没有答案」为准, // 而卷面可能在面板开着期间变化(自己在页面上作答 / 清过数据),不能只在初始化时算一次 if (name === 'exam') { try { ExamBatchProcessor.syncSubmitUI(); } catch (error) { /* 面板初始化不该被它拖垮 */ } } }; let justDragged = false; tabBtns.forEach(t => t.addEventListener('click', () => { if (justDragged) { justDragged = false; return; } switchTab(t.dataset.tab); })); switchTab('auth'); // 按当前页内容定高,避免被相邻长页撑出空白;异步状态更新后同步高度。 const contentResize = new ResizeObserver(() => { if (panel.classList.contains('mini')) return; const active = panel.querySelector('.panel-tab.active'); if (active) switchTab(active.dataset.tab); updateIntroIndicator(); }); panel.querySelectorAll('.panel-tab-content').forEach(content => contentResize.observe(content)); // 介绍子 tab:胶囊 indicator + 内容 carousel 平移 + 拖动吸附(对齐主 tab 交互) const introBtns = panel.querySelectorAll('.intro-sub-tab'); const introIndicator = panel.querySelector('.intro-indicator'); const introTrack = panel.querySelector('.intro-sub-track'); const introView = panel.querySelector('.intro-sub-view'); const updateIntroIndicator = () => { const active = panel.querySelector('.intro-sub-tab.active'); if (!introIndicator || !active) return; introIndicator.style.left = active.offsetLeft + 'px'; introIndicator.style.width = active.offsetWidth + 'px'; }; const switchIntroTab = (key) => { const curBtn = panel.querySelector('.intro-sub-tab[data-goods="' + key + '"]'); const curIdx = curBtn ? Array.from(introBtns).indexOf(curBtn) : 0; introBtns.forEach(b => b.classList.toggle('active', b.dataset.goods === key)); if (introTrack && introView) { introTrack.style.transform = 'translateX(' + (-curIdx * 100) + '%)'; } updateIntroIndicator(); }; let justDraggedIntro = false; introBtns.forEach(btn => { btn.addEventListener('click', () => { if (justDraggedIntro) { justDraggedIntro = false; return; } switchIntroTab(btn.dataset.goods); }); }); // 子 tab 胶囊拖动(弹簧跟随器,与主 tab 同款手感) const introTabsEl = panel.querySelector('.intro-sub-tabs'); const introFollow = utils.createSpringFollower(v => { introIndicator.style.left = v + 'px'; }); let dragIntro = false, introDragMoved = false, introDragStartX = 0, introDragStartLeft = 0; introTabsEl.addEventListener('mousedown', (e) => { dragIntro = true; introDragMoved = false; introDragStartX = e.clientX; introDragStartLeft = parseFloat(introIndicator.style.left) || introIndicator.offsetLeft || 0; introFollow.reset(introDragStartLeft); }); document.addEventListener('mousemove', (e) => { if (!dragIntro) return; const dx = e.clientX - introDragStartX; if (!introDragMoved && Math.abs(dx) > 5) { introDragMoved = true; introIndicator.style.transition = 'none'; } if (introDragMoved) { const target = introDragStartLeft + dx; const minL = 0; const maxL = introTabsEl.clientWidth - introIndicator.offsetWidth; introFollow.seek(utils.rubberBand(target, minL, maxL)); } }); document.addEventListener('mouseup', () => { if (!dragIntro) return; dragIntro = false; if (!introDragMoved) return; introDragMoved = false; introFollow.stop(); introIndicator.style.transition = ''; const indCenter = introIndicator.offsetLeft + introIndicator.offsetWidth / 2; let nearest = 0, best = Infinity; introBtns.forEach((b, i) => { const cx = b.offsetLeft + b.offsetWidth / 2; const d = Math.abs(cx - indCenter); if (d < best) { best = d; nearest = i; } }); justDraggedIntro = true; setTimeout(() => { justDraggedIntro = false; }, 200); switchIntroTab(introBtns[nearest].dataset.goods); }); switchIntroTab(GOODS_INTRO[0].key); // ===== 滑块拖拽:按住可拖动,越界用弹簧跟随器逼近,松手落在最近 tab ===== const tabsEl = panel.querySelector('.panel-tabs'); const tabFollow = utils.createSpringFollower(v => { tabIndicator.style.left = v + 'px'; }); let draggingTab = false, tabDragMoved = false, dragStartX = 0, dragStartLeft = 0; tabsEl.addEventListener('mousedown', (e) => { draggingTab = true; tabDragMoved = false; dragStartX = e.clientX; dragStartLeft = parseFloat(tabIndicator.style.left) || tabIndicator.offsetLeft || 0; tabFollow.reset(dragStartLeft); }); document.addEventListener('mousemove', (e) => { if (!draggingTab) return; const dx = e.clientX - dragStartX; if (!tabDragMoved && Math.abs(dx) > 5) { tabDragMoved = true; tabIndicator.style.transition = 'none'; } if (tabDragMoved) { const target = dragStartLeft + dx; const minL = 0; const maxL = tabsEl.clientWidth - tabIndicator.offsetWidth; tabFollow.seek(utils.rubberBand(target, minL, maxL)); } }); document.addEventListener('mouseup', () => { if (!draggingTab) return; draggingTab = false; if (!tabDragMoved) return; tabDragMoved = false; tabFollow.stop(); tabIndicator.style.transition = ''; const indCenter = tabIndicator.offsetLeft + tabIndicator.offsetWidth / 2; let nearest = 0, best = Infinity; tabBtns.forEach((t, i) => { const cx = t.offsetLeft + t.offsetWidth / 2; const d = Math.abs(cx - indCenter); if (d < best) { best = d; nearest = i; } }); justDragged = true; setTimeout(() => { justDragged = false; }, 200); switchTab(tabBtns[nearest].dataset.tab); }); // 拖拽逻辑(性能优化:rAF 节流 + 拖拽中禁 transition,解决拖动卡顿) let dragging = false, offsetX = 0, offsetY = 0, dragRafId = null; let dragMoved = false, startClientX = 0, startClientY = 0; let lastPX = 0, lastPY = 0, lastPT = 0, vX = 0, vY = 0; // 整个面板可拖,排除输入框/按钮/复选框/下拉框/textarea/标签等交互元素 const isInteractiveEl = (el) => !!el.closest('input, button, textarea, select, label, a, [data-tab]'); const endDrag = () => { dragging = false; if (dragRafId) { cancelAnimationFrame(dragRafId); dragRafId = null; } panel.classList.remove('dragging'); panel.style.transition = ''; document.removeEventListener('mousemove', onDragMove); document.removeEventListener('mouseup', endDrag); // 松手抛掷:速度够快才玩物理,否则吸附当前位置 const speed = Math.hypot(vX, vY); if (dragMoved && speed >= 60) { if (speed >= 200) utils.emitParticles(lastPX, lastPY, Math.round((speed - 170) / 12)); utils.launchPanelPhysics(panel, () => vX, () => vY); } vX = vY = 0; setTimeout(() => { dragMoved = false; }, 0); }; const onDragMove = (e) => { if (!dragging) return; e.preventDefault(); const now = performance.now(); if (!dragMoved && (Math.abs(e.clientX - startClientX) > 5 || Math.abs(e.clientY - startClientY) > 5)) { dragMoved = true; lastPX = e.clientX; lastPY = e.clientY; lastPT = now; } if (!dragMoved) return; // 末速度估计:最近两帧位置差分,指数平滑消除抖动 if (now - lastPT > 16) { const dt = Math.max(1, now - lastPT); vX = vX * 0.7 + ((e.clientX - lastPX) / dt * 1000) * 0.3; vY = vY * 0.7 + ((e.clientY - lastPY) / dt * 1000) * 0.3; lastPX = e.clientX; lastPY = e.clientY; lastPT = now; } if (dragRafId) return; dragRafId = requestAnimationFrame(() => { dragRafId = null; panel.style.left = (e.clientX - offsetX) + 'px'; panel.style.top = (e.clientY - offsetY) + 'px'; panel.style.right = 'auto'; }); }; panel.addEventListener('mousedown', e => { if (!panel.classList.contains('mini') && isInteractiveEl(e.target)) return; panel.dataset.__physicsRunning = ''; // 打断惯性循环,交给拖拽接管 panel.classList.remove('squash'); dragging = true; panel.classList.add('dragging'); // 用布局偏移(offsetLeft/offsetTop 不受 transform 缩放/3D 旋转影响, // 与拖拽写入的 style.left/top 同坐标系)→ mini 态缩成球或高光 transform 残留时拖动不再跳变 offsetX = e.clientX - panel.offsetLeft; offsetY = e.clientY - panel.offsetTop; startClientX = e.clientX; startClientY = e.clientY; dragMoved = false; vX = vY = 0; lastPX = e.clientX; lastPY = e.clientY; lastPT = performance.now(); panel.style.transition = 'none'; document.addEventListener('mousemove', onDragMove); document.addEventListener('mouseup', endDrag); }); utils.bindPanelDepth(panel); // 算力显示切换:点击「余额/总量」在余额↔总量间切换 const authStatusEl = panel.querySelector('#auth-status-text'); if (authStatusEl) { authStatusEl.addEventListener('click', (e) => { if (e.target.closest('[data-power-toggle]')) { this.powerView = this.powerView === 'total' ? 'ball' : 'total'; this.updateAuthStatusUI(); } }); } // 卡密输入框实时同步:输入框为唯一真源,清空时清除旧卡缓存,避免「空输入框但旧卡状态残留」 const cardKeyInput = panel.querySelector('#card-key'); if (cardKeyInput) { cardKeyInput.addEventListener('input', () => { currentConfig.cardKey = cardKeyInput.value.trim(); GM_setValue('cardKey', currentConfig.cardKey); if (!currentConfig.cardKey) { AuthService.setCachedStatus({}); Logger.add('info', '卡密输入框已清空,清除本地状态缓存'); } if (ControlPanel.updateAuthStatusUI) ControlPanel.updateAuthStatusUI(); }); } // 业务按键绑定:激活卡密 + 刷新状态 panel.querySelector('#activate-btn').addEventListener('click', async () => { const input = panel.querySelector('#card-key'); currentConfig.cardKey = input.value.trim(); GM_setValue('cardKey', currentConfig.cardKey); await AuthService.activate(); this.updateAuthStatusUI(); }); // 手动刷新授权状态;不调用 AI、不扣费。 panel.querySelector('#test-api-btn').addEventListener('click', () => { // 始终以输入框当前值为准,避免残留内存旧卡 const input = panel.querySelector('#card-key'); if (input) { currentConfig.cardKey = input.value.trim(); GM_setValue('cardKey', currentConfig.cardKey); } DeepSeekService.refreshAuthStatus(); }); // 购买卡密:打开发卡平台购买链接(链接由 ShopConfig 热加载提供,空则回落 QQ 临时会话) panel.querySelector('#buy-btn').addEventListener('click', () => { window.open(ShopConfig.linkFor('shop'), '_blank'); }); // 联系开发者:弹出留言框,提交到服务端 /api/feedback(Pad 收 Termux 通知) panel.querySelector('#contact-btn').addEventListener('click', () => { AuthService.openFeedbackModal(); }); // 按钮悬停提示(JS 独立 tooltip,挂 body 避免面板 overflow:hidden 裁剪左侧) panel.querySelectorAll('[data-tooltip]').forEach((el) => { el.addEventListener('mouseenter', function () { const tip = document.createElement('div'); tip.className = 'ai-tooltip-dyn'; tip.textContent = this.dataset.tooltip || ''; // 【2026-10-09】悬停提示配色统一:一律使用原 data-tooltip-tone="blue" 的蓝色调 // (原 default 深灰调已废弃;data-tooltip-tone 属性保留但不再参与判断) tip.dataset.tone = 'blue'; tip.style.cssText = 'position:fixed;z-index:10000000;background:rgba(9,88,217,0.96);color:#fff;border:1px solid rgba(255,255,255,0.22);padding:6px 8px;border-radius:6px;font-size:11px;line-height:1.5;box-shadow:0 4px 12px rgba(9,88,217,0.35);pointer-events:none;max-width:260px;word-break:break-word;white-space:normal;'; if (this.dataset.tooltipWarning) { const warning = document.createElement('div'); warning.textContent = this.dataset.tooltipWarning; warning.style.cssText = 'color:#ffd666;margin-top:5px;font-weight:600;'; tip.appendChild(warning); } document.body.appendChild(tip); const rect = this.getBoundingClientRect(); let left = rect.left; let top = rect.top - tip.offsetHeight - 8; // 超出视口左侧/上方时翻转 if (left + tip.offsetWidth > window.innerWidth) left = rect.right - tip.offsetWidth; if (left < 0) left = 0; if (top < 0) top = rect.bottom + 8; tip.style.left = left + 'px'; tip.style.top = top + 'px'; this._dynTip = tip; }); el.addEventListener('mouseleave', function () { if (this._dynTip) { this._dynTip.remove(); this._dynTip = null; } }); }); // 【2.5.10】折叠说明条(底部计费/价格 + 三个考试参数的说明,通用绑定) // 标题优先取 data-label,未标注时回退到旧的「计费/价格」两档文案 panel.querySelectorAll('.price-note-toggle').forEach((btn) => { btn.addEventListener('click', () => { const note = btn.dataset.note; const body = panel.querySelector('.price-note-body[data-note="' + note + '"]'); if (!body) return; const open = body.classList.toggle('open'); const label = btn.dataset.label || (note === 'fee' ? '💡 计费说明' : '💰 价格说明'); // 带 data-label-open 的是标题行紧凑按钮:收起态用短标题,展开态换成另一短标题 const openLabel = btn.dataset.labelOpen || null; btn.textContent = open ? (openLabel || label + '(点击收起)') : (openLabel ? label : label + '(点击展开)'); }); }); // 复习模式按钮(顶部切换) const reviewModeBtn = panel.querySelector('#review-mode-btn'); const syncReviewBtnState = () => { const on = currentConfig.reviewMode; reviewModeBtn.textContent = on ? '复习模式: 开' : '复习模式: 关'; reviewModeBtn.style.background = on ? 'rgba(24,144,255,0.2)' : '#1890ff'; reviewModeBtn.style.color = on ? '#1890ff' : '#fff'; reviewModeBtn.style.fontWeight = on ? 'bold' : 'normal'; }; syncReviewBtnState(); reviewModeBtn.addEventListener('click', () => { if (!currentConfig.reviewMode) { let dataCount = 0; try { dataCount = Object.values(JSON.parse(currentConfig.reviewData)).reduce((s, a) => s + Object.keys(a).length, 0); } catch (e) {} if (dataCount === 0) { utils.showMessage('⚠️ 请先在总览页采集错题数据', 'warning'); return; } } currentConfig.reviewMode = !currentConfig.reviewMode; GM_setValue('reviewMode', currentConfig.reviewMode); VideoApiAnswer.clearReviewCourse(); // 关闭复习模式 → 清除错题提示词,恢复默认提示词 if (!currentConfig.reviewMode) { DeepSeekService.clearHintData(); VideoApiAnswer.boot(); utils.showMessage('已关闭复习模式,恢复默认提示词', 'info'); } syncReviewBtnState(); updateReviewDataPanel(); DevPanel.updatePromptPreview(); // 开启复习模式:自动将错题数据提供给AI + 检测一次当前课程判断条件 if (currentConfig.reviewMode) { VideoApiAnswer.boot(); // 自动触发:把已采集的错题答案注入 AI 提示词 const hintData = ReviewCollector.buildHint(); if (hintData.length > 0) { DeepSeekService.setHintData(hintData); Logger.milestone(`复习模式开启:自动激活错题提示词 ${hintData.length} 道`); } DevPanel.updatePromptPreview(); utils.showMessage(`已开启复习模式:${hintData.length} 道错题答案已提供给AI`, 'success'); VideoController._allDone = false; // 解除上一次「本章刷完」的收工锁 setTimeout(() => VideoController.reviewCheck(), 300); } }); // 根据自动答题状态更新按钮文案和样式 const updateManualBtn = () => { const btn = panel.querySelector('#manual-answer-btn'); if (!btn) return; const autoActive = currentConfig.autoAnswer && currentConfig.aiAnswerEnabled; btn.textContent = autoActive ? '已开启自动答题,无需点击' : '处理当前题目'; btn.style.background = autoActive ? 'rgba(24,144,255,0.12)' : '#1890ff'; btn.style.color = autoActive ? '#1890ff' : '#fff'; btn.style.cursor = autoActive ? 'default' : 'pointer'; btn.style.boxShadow = 'none'; }; updateManualBtn(); // 开关状态同步:checkbox → switch-track 圆点 const syncSwitchState = () => { panel.querySelectorAll('.switch-wrap input[type="checkbox"]').forEach(el => { const track = el.nextElementSibling; if (track && track.classList.contains('switch-track')) track.classList.toggle('on', el.checked); }); }; syncSwitchState(); // 复选框即时保存:改动即写入 GM,刷新后保持 const checkboxBindings = [ { id: 'ai-answer-switch', key: 'aiAnswerEnabled' }, { id: 'auto-answer-switch', key: 'autoAnswer' }, { id: 'auto-submit-switch', key: 'autoSubmit' }, { id: 'expand-chapters-switch', key: 'expandChapters' }, { id: 'auto-play-switch', key: 'autoPlayVideo' }, { id: 'auto-switch-video-switch', key: 'autoSwitchVideo' }, { id: 'no-pause-switch', key: 'noPause' }, { id: 'exam-auto-submit-switch', key: 'examAutoSubmit' }, ]; checkboxBindings.forEach(({ id, key }) => { const el = panel.querySelector('#' + id); if (!el) return; el.addEventListener('change', () => { currentConfig[key] = el.checked; // 【2.5.21】考试两键为会话态:只改内存、不写盘,刷新即回默认(关) if (key !== 'examEnabled' && key !== 'examAutoSubmit') GM_setValue(key, el.checked); // 实时副作用 if (key === 'autoAnswer') { if (el.checked) { this.startPopupObserver(); VideoApiAnswer.boot(); if (currentConfig.reviewMode) VideoController.reviewCheck(); } else { this.stopPopupObserver(); VideoApiAnswer.clearReviewCourse(); } } if (key === 'aiAnswerEnabled' && el.checked) { VideoApiAnswer.boot(); if (currentConfig.reviewMode) VideoController.reviewCheck(); } if (key === 'expandChapters') { el.checked ? ChapterExpander.startChapterObserver() : ChapterExpander.stopChapterObserver(); VideoController._allDone = false; // 范围变了 → 解除收工锁,允许继续 } if (key === 'autoPlayVideo' && el.checked) { VideoController.initAutoPlay(); } if (key === 'noPause') { el.checked ? VideoController.startNoPauseGuard() : VideoController.stopNoPauseGuard(); } if (key === 'examAutoSubmit') { ExamBatchProcessor.syncSubmitUI(); } syncSwitchState(); updateManualBtn(); utils.showMessage('已保存', 'info'); }); }); // 考试提交槽位:自动提交开关 ↔ 两枚手动按钮(填入答案 / 提交本次考试,同一 250ms 交叉过渡) const examFillBtn = panel.querySelector('#exam-fill-btn'); if (examFillBtn) { examFillBtn.addEventListener('click', async () => { if (examFillBtn.dataset.state === 'submitting') return; if (!ExamBatchProcessor.answers || !ExamBatchProcessor.answers.size) { utils.showMessage('暂无已获取的答案(可先跑一轮批量答题)', 'warning'); return; } examFillBtn.dataset.state = 'submitting'; ExamBatchProcessor.syncSubmitUI(); let ok = false; try { ok = await ExamBatchProcessor.manualFill(); } catch (error) { Logger.add('error', '填入答案异常: ' + (error.message || error)); utils.showMessage('填入答案异常:' + (error.message || error), 'error'); } examFillBtn.dataset.state = 'idle'; ExamBatchProcessor.syncSubmitUI(); // 【2.5.19】成败都不改按钮外观 —— 成功、失败一律回常态,只留 toast if (ok) utils.showMessage('答案已填入考试页面(未提交)', 'success'); }); } const examManualSubmitBtn = panel.querySelector('#exam-manual-submit-btn'); if (examManualSubmitBtn) { examManualSubmitBtn.addEventListener('click', async () => { if (examManualSubmitBtn.dataset.state === 'submitting') return; examManualSubmitBtn.dataset.state = 'submitting'; ExamBatchProcessor.syncSubmitUI(); let result = false; try { result = await ExamBatchProcessor.manualSubmit(); } catch (error) { Logger.add('error', '手动提交异常: ' + (error.message || error)); utils.showMessage('手动提交异常:' + (error.message || error), 'error'); } // true=已提交;'blocked'=被前置条件拦下(没真提交);false=提交失败 // 【2.5.19】成败都不改按钮外观 —— 一律回常态,只留 toast examManualSubmitBtn.dataset.state = 'idle'; ExamBatchProcessor.syncSubmitUI(); if (result === true) utils.showMessage('考试已确认提交', 'success'); }); } ExamBatchProcessor.syncExamModeUI(); // 【2.5.18】重试未答出题目:只把上一轮没拿到答案的题再跑一遍(已答出的不再重复请求、不重复扣费) const examRetryMissingBtn = panel.querySelector('#exam-retry-missing-btn'); if (examRetryMissingBtn) { examRetryMissingBtn.addEventListener('click', () => { ExamBatchProcessor.retryMissing(); }); } // 【2.5.7】清除考试数据:一键清掉本机保存的答案 / 批次进度 / 统计读数 const examClearStoreBtn = panel.querySelector('#exam-clear-store-btn'); if (examClearStoreBtn) { examClearStoreBtn.addEventListener('click', () => { ExamBatchProcessor.clearStoredData(); }); } // 视频倍速即时保存(进度条 1~8x) const speedSelect = panel.querySelector('#video-speed'); const speedLabel = panel.querySelector('#video-speed-label'); if (speedSelect) { const applySpeed = () => { currentConfig.videoSpeed = Math.round(parseFloat(speedSelect.value) * 10) / 10; GM_setValue('videoSpeed', currentConfig.videoSpeed); VideoController.setSpeed(currentConfig.videoSpeed); if (speedLabel) speedLabel.textContent = currentConfig.videoSpeed + 'x'; }; applySpeed(); speedSelect.addEventListener('input', applySpeed); speedSelect.addEventListener('change', () => { applySpeed(); utils.showMessage(`倍速已设为 ${currentConfig.videoSpeed}x`, 'info'); }); } // 【2.5.12】考试参数由滑块改为数字输入框:失焦 / 回车即夹紧写盘。 // clamp 是唯一取值入口,非法或越界输入一律回填成合法值(不做「确认」二次交互) const examNumberBindings = [ { id: 'exam-batch-size', key: 'examBatchSize', clamp: clampExamBatchSize, unit: '题', name: '单轮题数' }, { id: 'exam-concurrency', key: 'examConcurrency', clamp: clampExamConcurrency, unit: '轮', name: '单次提交轮数' }, { id: 'exam-timeout', key: 'examRequestTimeout', clamp: clampExamRequestTimeout, unit: '秒', name: '等待超时' } ]; examNumberBindings.forEach(({ id, key, clamp, unit, name }) => { const input = panel.querySelector('#' + id); if (!input) return; const commit = (announce) => { const value = clamp(input.value); const changed = value !== currentConfig[key]; currentConfig[key] = value; GM_setValue(key, value); input.value = value; if (typeof ExamBatchProcessor !== 'undefined' && ExamBatchProcessor.refreshPlanUI) ExamBatchProcessor.refreshPlanUI(); if (announce && changed) utils.showMessage(`${name}已设为 ${value} ${unit}`, 'info'); }; input.addEventListener('change', () => commit(true)); input.addEventListener('blur', () => commit(false)); input.addEventListener('keydown', event => { if (event.key === 'Enter') { commit(true); input.blur(); } }); }); // 【2.5.12】「开始考试」按钮 —— 原「考试模式」开关的按钮形态,语义不变(是否处理考试的总闸门) // 【2.5.23】补「就地起跑」分支:此前这里只翻转开关,对「已经打开的这场考试」无能为力, // 用户必须先点「开始考试」再打开考试,或只能点「一键完成」才能起跑。 // 现在开启闸门的同时若能找到进行中的考试,就直接开跑(不套预设、不开自动提交,保持本按钮语义)。 const examStartBtn = panel.querySelector('#exam-start-btn'); if (examStartBtn) { examStartBtn.addEventListener('click', () => { const willEnable = !currentConfig.examEnabled; currentConfig.examEnabled = willEnable; // 【2.5.21】会话态:不写盘,刷新后回到关闭 ExamBatchProcessor.syncExamModeUI(); if (!willEnable) { utils.showMessage('已停止考试模式:不再自动处理新的考试', 'info'); return; } // 开启闸门时:若此刻已在进行中的考试里,就地起跑(与「一键完成」第 4 步同源) const vm = ExamBatchProcessor.findActiveExamVm(); const recordId = vm ? String(vm.recordId || '') : ''; if (vm && recordId) { if (ExamBatchProcessor.running) { utils.showMessage('考试模式已开启;已在答题中,无需重复起跑', 'info'); return; } const saved = ExamStore.read(); const alreadySubmitted = (saved && String(saved.recordId) === recordId && saved.submitted) || ExamBatchProcessor.submitted; if (alreadySubmitted) { utils.showMessage('考试模式已开启
检测到本场考试已提交过;如需重跑请先「清除考试数据」', 'warning', 8000); return; } // 起跑前置条件:两个全局开关;没开就明说,不静默失效 if (!currentConfig.autoAnswer || !currentConfig.aiAnswerEnabled) { utils.showMessage('考试模式已开启,但起跑缺少前置开关:请同时打开「自动答题」与「AI 答题」', 'warning', 8000); return; } Logger.milestone('开始考试:检测到进行中的考试,立即开始答题'); utils.showMessage('检测到进行中的考试,立即开始答题', 'success', 6000); ExamBatchProcessor.run(vm.records, recordId).catch(error => { ExamBatchProcessor.running = false; ExamBatchProcessor.errors.push(`批量答题异常:${error.message || error}`); ExamBatchProcessor.updateStatus('运行失败'); }); return; } utils.showMessage('已开启考试模式:进入考试即自动答题' + (currentConfig.examAutoSubmit ? '并自动提交' : ';答完请到面板手动提交'), 'info'); }); } // 【2.5.12】「一键完成」:套用推荐参数 + 开自动提交 + 开考试模式;已在考场则立即起跑 const examOneClickBtn = panel.querySelector('#exam-oneclick-btn'); if (examOneClickBtn) { examOneClickBtn.addEventListener('click', () => { ExamBatchProcessor.oneClickComplete(); }); } panel.querySelector('#manual-answer-btn').addEventListener('click', () => { if (currentConfig.autoAnswer && currentConfig.aiAnswerEnabled) { utils.showMessage('自动答题已开启,无需手动点击', 'info'); return; } const popup = document.querySelector('.ant-modal-wrap'); if (!popup) { utils.showMessage('未检测到答题弹窗', 'warning'); return; } if (ExamProcessor.isExamModal(popup)) { // 单题考试流程已停用:考试题目统一由 ExamBatchProcessor 批量处理 // ExamProcessor.processExamQuestion(popup); utils.showMessage('考试题目已改由批量答题处理,请保持考试页面打开', 'info'); } else { QuestionProcessor.processQuestion(popup); } }); panel.querySelector('#collect-mistakes-btn').addEventListener('click', async () => { // 询问是否清空已有数据 if (confirm('是否先清空已存储的错题数据?\n点"确定"=清空后重新采集\n点"取消"=保留原有数据并合并')) { currentConfig.reviewData = '{}'; GM_setValue('reviewData', '{}'); updateReviewDataPanel(); } utils.showMessage('正在扫描错题章节...', 'info'); await ReviewCollector.collectAll(); updateReviewDataPanel(); }); // 清空已采集数据按钮 panel.querySelector('#clear-review-data-btn').addEventListener('click', () => { if (confirm('确定清空所有已采集的错题数据吗?')) { currentConfig.reviewData = '{}'; GM_setValue('reviewData', '{}'); DeepSeekService.clearHintData(); updateReviewDataPanel(); DevPanel.updatePromptPreview(); utils.showMessage('错题数据已清空', 'success'); } }); // 将错题数据提供给AI(手动一次性激活) panel.querySelector('#provide-hint-btn').addEventListener('click', () => { const hintData = ReviewCollector.buildHint(); if (hintData.length === 0) { utils.showMessage('暂无可提供的错题数据', 'warning'); return; } DeepSeekService.setHintData(hintData); utils.showMessage(`✅ 已将 ${hintData.length} 道错题参考答案提供给AI`, 'success'); Logger.milestone(`手动激活错题提示词: ${hintData.length} 道`); DevPanel.updatePromptPreview(); }); // 刷新复习数据面板 const updateReviewDataPanel = () => { const summaryText = document.getElementById('review-data-text'); const detailDiv = document.getElementById('review-data-detail'); const panel = document.getElementById('review-data-panel'); if (!panel) return; try { const data = JSON.parse(currentConfig.reviewData); const chapters = Object.entries(data).filter(([, v]) => Object.keys(v).length > 0); if (chapters.length === 0) { panel.style.display = 'none'; return; } const totalQ = chapters.reduce((s, [, v]) => s + Object.keys(v).length, 0); panel.style.display = 'block'; if (summaryText) summaryText.textContent = `${chapters.length} 章节 ${totalQ} 道错题 | 点击展开详情`; if (detailDiv) { detailDiv.innerHTML = chapters.map(([ch, qs]) => `
${ch}` + Object.entries(qs).map(([q, a]) => { // a 是 JSON 数组字符串,解析成可读文本 let ansText = a; try { const arr = JSON.parse(a); if (Array.isArray(arr)) ansText = arr.join('、'); } catch (e) {} return `
${q.substring(0, 40)}${q.length>40?'...':''} → ${ansText}
`; }).join('') + `
` ).join(''); } } catch (e) { panel.style.display = 'none'; } }; updateReviewDataPanel(); // ⚙ 按钮:切换独立开发者面板 panel.querySelector('#dev-toggle-btn').addEventListener('click', (e) => { e.stopPropagation(); DevPanel.setOpen(!DevPanel.isOpen); }); }, startPopupObserver: function() { if (popupObserver) return; // 找到页面上可见的、未处理过的答题弹窗(视频+考试) const findUnprocessedPopup = () => { const allPopups = document.querySelectorAll('.ant-modal-wrap'); for (const popup of allPopups) { const style = window.getComputedStyle(popup); if (style.display === 'none' || style.visibility === 'hidden') continue; if (popup.getBoundingClientRect().width === 0) continue; // 情况A:考试弹窗(依赖 .ant-form + 第X题格式) if (currentConfig.examEnabled && ExamProcessor.isExamModal(popup)) { if (examProcessing) continue; // 上一题还在处理中 const form = popup.querySelector('.ant-form'); const row1 = form.children[1]; const currentText = row1 ? row1.textContent.trim() : ''; if (currentText === lastQuestionText && lastQuestionText !== '') continue; return { popup, titleText: currentText, isExam: true }; } // 情况B:视频弹窗(依赖 .sub-title) const titleEl = popup.querySelector('.sub-title'); if (!titleEl) continue; const popupText = popup.textContent; if (!popupText.includes('判断题') && !popupText.includes('单选题') && !popupText.includes('多选题')) continue; const currentText = titleEl.textContent.trim(); if (currentText === lastQuestionText && lastQuestionText !== '') continue; return { popup, titleText: currentText, isExam: false }; } return null; }; // 统一的处理入口 const tryProcess = () => { if (!currentConfig.autoAnswer || !currentConfig.aiAnswerEnabled) return; const result = findUnprocessedPopup(); if (result) { lastQuestionText = result.titleText; const label = result.isExam ? '考试弹窗' : '答题弹窗'; utils.log(`检测到${label},自动处理:`, result.titleText.substring(0, 30)); setTimeout(() => { const s = window.getComputedStyle(result.popup); if (s.display !== 'none' && s.visibility !== 'hidden') { if (result.isExam) { // 单题考试流程已停用:考试题目一律交给 ExamBatchProcessor 批量处理 // if (!ExamBatchProcessor.isActive()) ExamProcessor.processExamQuestion(result.popup); utils.log('考试弹窗已交由批量答题处理(单题流程停用)'); } else if (VideoApiAnswer.enabled()) { VideoApiAnswer.run(); } else if (!currentConfig.reviewMode) { Logger.milestone('开始处理视频弹窗题目'); QuestionProcessor.processQuestion(result.popup); } } }, 500); } }; // 方案A:MutationObserver 监听新增节点 + 属性变化 popupObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { // childList:新弹窗容器被插入DOM(首次弹窗) if (mutation.addedNodes.length > 0) { for (const node of mutation.addedNodes) { if (node.nodeType === 1 && (node.classList.contains('ant-modal-wrap') || node.querySelector('.ant-modal-wrap'))) { tryProcess(); return; } } } // attributes:已有容器从隐藏变为可见(后续弹窗复用) if (mutation.type === 'attributes' && mutation.target.nodeType === 1) { const wrap = mutation.target.closest?.('.ant-modal-wrap') || mutation.target; if (wrap.classList?.contains('ant-modal-wrap') || wrap.querySelector?.('.ant-modal-wrap')) { tryProcess(); return; } } } }); popupObserver.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); // 方案B:轮询兜底(每2秒扫描一次,应对Observer漏掉的场景) popupPollingId = setInterval(tryProcess, 2000); utils.log('答题弹窗监听已启动(Observer + 轮询)'); }, stopPopupObserver: function() { if (popupObserver) { popupObserver.disconnect(); popupObserver = null; } if (popupPollingId) { clearInterval(popupPollingId); popupPollingId = null; } }, // 更新面板授权状态显示 updateAuthStatusUI: function() { const el = document.getElementById('auth-status-text'); if (!el) return; const st = AuthService.getCachedStatus(); if (!st || !st.card_key || st.card_key !== currentConfig.cardKey.trim()) { el.innerHTML = '
未激活
'; return; } const isBind = st.type === 'bind'; // 状态 + 类型(bind 带账号) let head = '
' + '● 已激活' + '' + (isBind ? '绑定账号型' : '额度型') + (isBind && st.account_id ? '(' + st.account_id + ')' : '') + '
'; // 算力 + 约可答(bind 与 quota 都有);「算力」标签与「授权卡密」label 完全一致(13px/500/#555),单位分跟随 // 点击可切换:余额(默认)↔ 总量 const power = st.power != null ? st.power : (st.balance_cent || 0); const est = st.est_questions != null ? st.est_questions : ''; const totalPower = st.total_power != null ? st.total_power : (power + (st.total_used_cent || 0)); // 总量题数:优先用后端字段;旧缓存缺字段时用相同口径 1.9 分/道 兜底计算 const totalEst = st.total_est_questions != null ? st.total_est_questions : Math.floor(totalPower / 1.9); const viewBall = this.powerView !== 'total'; const viewPower = viewBall ? power : totalPower; const viewEst = viewBall ? est : totalEst; const viewLabel = viewBall ? '余额' : '总量'; let rows = '
' + '' + viewLabel + '' + '' + viewPower + ' 分' + (viewEst !== '' ? ' · 约 ' + viewEst + ' 道' : '') + ' ⇄
'; // 到期行:bind 与 quota 通用(quota 自 2026-10-13 起也是限时卡,服务端激活时定格 90 天)。 // 未激活过的卡 expires_at 为空 ⇒ 不渲染该行(与「不限期的旧额度卡」一致)。 if (st.expires_at) { rows += '
' + '到期' + '' + (st.expires_at || '').slice(0, 10) + '
'; } // 上游状态(服务端返回:rich=富裕 / warn=告急 / none=正常,作为独立一行) let up = ''; if (st.upstream_status === 'warn') { up = '
' + '上游' + '⚠️ 告急
'; } else if (st.upstream_status === 'rich') { up = '
' + '上游' + '富裕
'; } el.innerHTML = head + rows + up; } }; // ============================ 开发者独立面板 ============================ const DevPanel = { isOpen: false, animation: null, motionEffects: [], clearMotion: function(panel) { // 开合收尾只还原布局,不触发高级 UI 共用的 padding 等外观过渡。 const transition = panel.style.transition; panel.style.transition = 'none'; if (this.animation) this.animation.cancel(); this.animation = null; this.motionEffects.forEach(effect => effect.cancel()); this.motionEffects = []; const content = panel.querySelector('.dev-morph-content'); if (content) content.replaceWith(...content.childNodes); panel.querySelector('.dev-morph-icon')?.remove(); panel.classList.remove('dev-morphing'); void panel.offsetHeight; panel.style.transition = transition; }, setOpen: function(open) { const panel = document.getElementById('dev-panel'); const button = document.getElementById('dev-toggle-btn'); if (!panel || !button || open === this.isOpen) return; const entryColor = getComputedStyle(button).backgroundColor; utils.resetPanelDepth(panel); this.isOpen = open; button.setAttribute('aria-expanded', String(open)); if (open) button.style.background = 'rgba(24,144,255,0.35)'; // 反向点击保留外壳的形状、位置和内容显露进度。 const properties = ['transform', 'width', 'height', 'borderRadius', 'backgroundColor', 'boxShadow']; const readShell = () => { const style = getComputedStyle(panel); return Object.fromEntries(properties.map(key => [key, style[key]])); }; const current = this.animation && readShell(); const oldContent = panel.querySelector('.dev-morph-content'); const contentOpacity = oldContent ? getComputedStyle(oldContent).opacity : (open ? 0 : 1); const oldIcon = panel.querySelector('.dev-morph-icon'); const iconOpacity = oldIcon ? getComputedStyle(oldIcon).opacity : (open ? 1 : 0); const oldAnchor = this.motionEffects[2]?.effect.target; const anchorOpacity = oldAnchor ? getComputedStyle(oldAnchor).opacity : (open ? 0 : 1); this.clearMotion(panel); panel.dataset.__physicsRunning = ''; panel.classList.remove('squash'); if (open) panel.classList.add('dev-measuring'); panel.classList.add('open'); panel.inert = !open; if (open) { this.refreshLog(); this.updatePromptPreview(); // 隐藏时 Tab 高度为 0;先完成实际布局,避免把高度过渡的起点当成展开终点。 this.syncUiSubTabs(panel); this.syncActiveTab(panel); void panel.offsetHeight; panel.classList.remove('dev-measuring'); } else if (panel.contains(document.activeElement)) { button.focus({ preventScroll: true }); } const finish = () => { panel.classList.toggle('open', open); this.clearMotion(panel); // 外壳先以选中态颜色落回入口,再由按钮自身过渡恢复常态。 if (!open) button.style.background = '#1890ff'; }; if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { finish(); return; } const rect = panel.getBoundingClientRect(); let anchor = button; let source = anchor.getBoundingClientRect(); // 主面板收起时,回到仍可见的主面板入口。 if (!source.width || !source.height || getComputedStyle(button).visibility === 'hidden') { anchor = document.getElementById('ai-helper-panel'); source = anchor.getBoundingClientRect(); } const style = getComputedStyle(panel); const anchorStyle = getComputedStyle(anchor); const expanded = { ...readShell(), transform: 'none', width: rect.width + 'px', height: rect.height + 'px' }; const folded = { transform: `translate(${source.left - rect.left}px, ${source.top - rect.top}px)`, width: source.width + 'px', height: source.height + 'px', borderRadius: Math.min(parseFloat(anchorStyle.borderRadius), source.width / 2, source.height / 2) + 'px', backgroundColor: anchor === button && open ? entryColor : anchorStyle.backgroundColor, boxShadow: anchorStyle.boxShadow }; // 内容保持原字号和排版,只让外壳改变尺寸,避免文字被压扁。 const content = document.createElement('div'); content.className = 'dev-morph-content'; content.style.width = (rect.width - parseFloat(style.borderLeftWidth) - parseFloat(style.borderRightWidth)) + 'px'; content.style.height = (rect.height - parseFloat(style.borderTopWidth) - parseFloat(style.borderBottomWidth)) + 'px'; content.style.padding = style.padding; content.append(...panel.childNodes); panel.appendChild(content); const icon = document.createElement('span'); icon.className = 'dev-morph-icon'; icon.textContent = anchor === button ? '⚙' : '✨'; icon.style.fontSize = anchorStyle.fontSize; panel.appendChild(icon); panel.classList.add('dev-morphing'); const duration = open ? 560 : 440; const timing = { duration, fill: 'both' }; const from = current || (open ? folded : expanded); // 关闭先留出内容退场的时间,再把仍可见的外壳交还入口。 this.animation = panel.animate(open ? [ { ...from, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' }, expanded ] : [ { ...from, offset: 0 }, { ...from, offset: 0.18, easing: 'cubic-bezier(0.65, 0, 0.35, 1)' }, folded ], timing); this.motionEffects = [ content.animate(open ? [ { opacity: contentOpacity, offset: 0 }, { opacity: contentOpacity, offset: 0.38 }, { opacity: 1, offset: 0.85 }, { opacity: 1 } ] : [ { opacity: contentOpacity }, { opacity: 0, offset: 0.18 }, { opacity: 0 } ], timing), icon.animate(open ? [ { opacity: iconOpacity }, { opacity: 0, offset: 0.22 }, { opacity: 0 } ] : [ { opacity: iconOpacity }, { opacity: 0, offset: 0.18 }, { opacity: 0, offset: 0.78 }, { opacity: 1 } ], timing), anchor.animate(open ? [ { opacity: anchorOpacity }, { opacity: anchorOpacity, offset: 0.15 }, { opacity: 1, offset: 0.5 }, { opacity: 1 } ] : [ { opacity: anchorOpacity }, { opacity: 1, offset: 0.3 }, { opacity: 1, offset: 0.7 }, { opacity: 0, offset: 0.95 }, { opacity: 0 } ], timing) ]; this.animation.onfinish = finish; }, updateTabIndicator: function(panel) { const indicator = panel.querySelector('.dev-tab-indicator'); const active = panel.querySelector('.dev-tab-btn.active'); if (!indicator || !active) return; indicator.style.left = active.offsetLeft + 'px'; indicator.style.width = active.offsetWidth + 'px'; }, syncActiveTab: function(panel) { this.updateTabIndicator(panel); const active = panel.querySelector('.dev-tab-btn.active'); const view = panel.querySelector('.dev-tab-view'); const content = active && panel.querySelector('#' + active.dataset.tab); if (view && content) view.style.height = content.offsetHeight + 'px'; }, syncUiSubTabs: function(panel) { const active = panel.querySelector('.dev-ui-sub-tab.active'); const view = panel.querySelector('.dev-ui-sub-view'); const track = panel.querySelector('.dev-ui-sub-track'); const indicator = panel.querySelector('.dev-ui-sub-indicator'); if (!active || !view) return; const tabs = [...panel.querySelectorAll('.dev-ui-sub-tab')]; const index = tabs.indexOf(active); if (track) track.style.transform = 'translateX(' + (-index * 100) + '%)'; if (indicator) { indicator.style.left = active.offsetLeft + 'px'; indicator.style.width = active.offsetWidth + 'px'; } const content = panel.querySelector('#ui-' + active.dataset.uiTab); if (content) view.style.height = content.offsetHeight + 'px'; }, create: function() { if (document.getElementById('dev-panel')) return; const panel = document.createElement('div'); panel.id = 'dev-panel'; panel.innerHTML = `
开发者设置
自定义 System 提示词(留空=按题型用默认)
自定义 User 提示词(可选,加在答题要求最前)
${currentConfig.glassBlurAmount}px
轻强
${currentConfig.panelOpacity}%
透实
${currentConfig.depthStrength}%
0% 静止 · 500% 最强
自动(远=慢)
${currentConfig.tabRubberBand}px
0(硬边界)400(拉伸)
软(0.02)硬(1.00)
按需开启
`; document.body.appendChild(panel); // 高级UI/Tab动画与主面板同步 if (currentConfig.glassBlur) panel.classList.add('panel-glass'); if (currentConfig.tabAnimEnabled) panel.classList.add('tab-anim'); // 开关轨道初始状态同步 panel.querySelectorAll('.switch-wrap input[type="checkbox"]').forEach(el => { const track = el.nextElementSibling; if (track && track.classList.contains('switch-track')) track.classList.toggle('on', el.checked); }); this.bindEvents(panel); }, bindEvents: function(panel) { const highlight = panel.querySelector('.dev-highlight'); // 果冻按钮 panel.querySelectorAll('button').forEach(b => utils.jellyPress(b)); // 关闭按钮 panel.querySelector('#dev-close-btn').addEventListener('click', () => { DevPanel.setOpen(false); }); // 拖拽(rAF 节流,解决拖动卡顿) let dragging = false, ox = 0, oy = 0, dragRafId = null; let dragMoved = false, startClientX = 0, startClientY = 0; let lastPX = 0, lastPY = 0, lastPT = 0, vX = 0, vY = 0; // 整个面板可拖,排除 tab 按钮/textarea/复选框/按钮等交互元素 const isInteractiveEl = (el) => !!el.closest('input, button, textarea, select, label, a, [data-tab]'); const endDrag = () => { dragging = false; if (dragRafId) { cancelAnimationFrame(dragRafId); dragRafId = null; } panel.classList.remove('dragging'); panel.style.transition = ''; document.removeEventListener('mousemove', onDragMove); document.removeEventListener('mouseup', endDrag); const speed = Math.hypot(vX, vY); if (dragMoved && speed >= 60) { if (speed >= 200) utils.emitParticles(lastPX, lastPY, Math.round((speed - 170) / 12)); utils.launchPanelPhysics(panel, () => vX, () => vY); } vX = vY = 0; setTimeout(() => { dragMoved = false; }, 0); }; const onDragMove = (e) => { if (!dragging) return; e.preventDefault(); const now = performance.now(); if (!dragMoved && (Math.abs(e.clientX - startClientX) > 5 || Math.abs(e.clientY - startClientY) > 5)) { dragMoved = true; lastPX = e.clientX; lastPY = e.clientY; lastPT = now; } if (dragMoved) { if (now - lastPT > 16) { const dt = Math.max(1, now - lastPT); vX = vX * 0.7 + ((e.clientX - lastPX) / dt * 1000) * 0.3; vY = vY * 0.7 + ((e.clientY - lastPY) / dt * 1000) * 0.3; lastPX = e.clientX; lastPY = e.clientY; lastPT = now; } } if (dragRafId) return; dragRafId = requestAnimationFrame(() => { dragRafId = null; panel.style.left = (e.clientX - ox) + 'px'; panel.style.top = (e.clientY - oy) + 'px'; }); }; panel.addEventListener('mousedown', e => { if (DevPanel.animation) return; if (isInteractiveEl(e.target)) return; panel.dataset.__physicsRunning = ''; // 打断惯性循环,交给拖拽接管 panel.classList.remove('squash'); dragging = true; panel.classList.add('dragging'); ox = e.clientX - panel.getBoundingClientRect().left; oy = e.clientY - panel.getBoundingClientRect().top; dragMoved = false; vX = vY = 0; lastPX = e.clientX; lastPY = e.clientY; lastPT = performance.now(); panel.style.transition = 'none'; document.addEventListener('mousemove', onDragMove); document.addEventListener('mouseup', endDrag); }); utils.bindPanelDepth(panel); // 内容包装(推拉动画:固定视口 + 平移 track) const devTabView = document.createElement('div'); devTabView.className = 'dev-tab-view'; const devTabTrack = document.createElement('div'); devTabTrack.className = 'dev-tab-track'; panel.querySelectorAll('.dev-tab-content').forEach(c => devTabTrack.appendChild(c)); devTabView.appendChild(devTabTrack); panel.querySelector('.dev-tabs').after(devTabView); panel.querySelector('.dev-body').classList.add('panel-depth-layer'); // UI 页子 tab:外观 / Tab / 动效,与介绍页相同的胶囊 + 横向轨道结构 const uiSubTabs = panel.querySelectorAll('.dev-ui-sub-tab'); const uiSubIndicator = panel.querySelector('.dev-ui-sub-indicator'); const uiSubView = panel.querySelector('.dev-ui-sub-view'); const uiSubTrack = panel.querySelector('.dev-ui-sub-track'); const switchUiSubTab = (name) => { const btn = panel.querySelector('.dev-ui-sub-tab[data-ui-tab="' + name + '"]'); const index = btn ? Array.from(uiSubTabs).indexOf(btn) : 0; uiSubTabs.forEach(tab => tab.classList.toggle('active', tab === btn)); if (uiSubTrack && uiSubView) { uiSubTrack.style.transform = 'translateX(' + (-index * 100) + '%)'; const content = panel.querySelector('#ui-' + name); if (content) uiSubView.style.height = content.offsetHeight + 'px'; } if (uiSubIndicator && btn) { uiSubIndicator.style.left = btn.offsetLeft + 'px'; uiSubIndicator.style.width = btn.offsetWidth + 'px'; } if (panel.classList.contains('open')) DevPanel.syncActiveTab(panel); }; let uiSubJustDragged = false; uiSubTabs.forEach(btn => btn.addEventListener('click', (e) => { if (uiSubJustDragged && e.isTrusted) { uiSubJustDragged = false; return; } switchUiSubTab(btn.dataset.uiTab); })); switchUiSubTab('appearance'); // UI 子 tab 复用主 tab 的拖拽、橡皮筋与弹簧参数。 const uiSubTabsEl = panel.querySelector('.dev-ui-sub-tabs'); const uiSubFollow = utils.createSpringFollower(v => { uiSubIndicator.style.left = v + 'px'; }); let uiSubDragging = false, uiSubMoved = false, uiSubStartX = 0, uiSubStartLeft = 0; uiSubTabsEl.addEventListener('mousedown', (e) => { uiSubDragging = true; uiSubMoved = false; uiSubStartX = e.clientX; uiSubStartLeft = parseFloat(uiSubIndicator.style.left) || uiSubIndicator.offsetLeft || 0; uiSubFollow.reset(uiSubStartLeft); }); document.addEventListener('mousemove', (e) => { if (!uiSubDragging) return; const dx = e.clientX - uiSubStartX; if (!uiSubMoved && Math.abs(dx) > 5) { uiSubMoved = true; uiSubIndicator.style.transition = 'none'; } if (uiSubMoved) { const maxLeft = uiSubTabsEl.clientWidth - uiSubIndicator.offsetWidth; uiSubFollow.seek(utils.rubberBand(uiSubStartLeft + dx, 0, maxLeft)); } }); document.addEventListener('mouseup', () => { if (!uiSubDragging) return; uiSubDragging = false; if (!uiSubMoved) return; uiSubMoved = false; uiSubFollow.stop(); uiSubIndicator.style.transition = ''; const center = uiSubIndicator.offsetLeft + uiSubIndicator.offsetWidth / 2; let nearest = 0, distance = Infinity; uiSubTabs.forEach((tab, index) => { const tabCenter = tab.offsetLeft + tab.offsetWidth / 2; const gap = Math.abs(tabCenter - center); if (gap < distance) { distance = gap; nearest = index; } }); uiSubJustDragged = true; setTimeout(() => { uiSubJustDragged = false; }, 200); switchUiSubTab(uiSubTabs[nearest].dataset.uiTab); }); // 标签页切换(胶囊滑块跟随 + 推拉动画,与主面板同步) const devIndicator = panel.querySelector('.dev-tab-indicator'); const updateDevIndicator = () => DevPanel.updateTabIndicator(panel); let devJustDragged = false; const switchDevTab = (name) => { const btn = panel.querySelector('.dev-tab-btn[data-tab="' + name + '"]'); const curIdx = btn ? Array.from(panel.querySelectorAll('.dev-tab-btn')).indexOf(btn) : 0; panel.querySelectorAll('.dev-tab-btn').forEach(b => b.classList.toggle('active', b === btn)); if (devTabTrack && devTabView) { devTabTrack.style.transform = 'translateX(' + (-curIdx * 100) + '%)'; if (name === 'tab-ui') { const activeUiTab = panel.querySelector('.dev-ui-sub-tab.active'); if (activeUiTab) switchUiSubTab(activeUiTab.dataset.uiTab); } const content = panel.querySelector('#' + name); if (content) devTabView.style.height = content.offsetHeight + 'px'; } if (name === 'tab-log') DevPanel.refreshLog(); updateDevIndicator(); }; panel.querySelectorAll('.dev-tab-btn').forEach(btn => { btn.addEventListener('click', (e) => { if (devJustDragged && e.isTrusted) { devJustDragged = false; return; } // 只吞真实点击,不吞程序化 click() switchDevTab(btn.dataset.tab); }); }); switchDevTab('tab-prompts'); const devContentResize = new ResizeObserver(() => { if (!panel.classList.contains('open')) return; DevPanel.syncUiSubTabs(panel); DevPanel.syncActiveTab(panel); }); panel.querySelectorAll('.dev-tab-content').forEach(content => devContentResize.observe(content)); panel.querySelectorAll('.dev-ui-sub-content').forEach(content => devContentResize.observe(content)); // ===== dev 滑块拖拽:按住可拖动,越界弹簧跟随,松手落在最近 tab 并执行切换动画 ===== const devTabsEl = panel.querySelector('.dev-tabs'); const devBtns = panel.querySelectorAll('.dev-tab-btn'); const devFollow = utils.createSpringFollower(v => { devIndicator.style.left = v + 'px'; }); let devDragging = false, devMoved = false, devStartX = 0, devStartLeft = 0; devTabsEl.addEventListener('mousedown', (e) => { devDragging = true; devMoved = false; devStartX = e.clientX; devStartLeft = parseFloat(devIndicator.style.left) || devIndicator.offsetLeft || 0; devFollow.reset(devStartLeft); }); document.addEventListener('mousemove', (e) => { if (!devDragging) return; const dx = e.clientX - devStartX; if (!devMoved && Math.abs(dx) > 5) { devMoved = true; devIndicator.style.transition = 'none'; } if (devMoved) { const target = devStartLeft + dx; const minL = 0; const maxL = devTabsEl.clientWidth - devIndicator.offsetWidth; devFollow.seek(utils.rubberBand(target, minL, maxL)); } }); document.addEventListener('mouseup', () => { if (!devDragging) return; devDragging = false; if (!devMoved) return; devMoved = false; devFollow.stop(); devIndicator.style.transition = ''; const indCenter = devIndicator.offsetLeft + devIndicator.offsetWidth / 2; let nearest = 0, best = Infinity; devBtns.forEach((t, i) => { const cx = t.offsetLeft + t.offsetWidth / 2; const d = Math.abs(cx - indCenter); if (d < best) { best = d; nearest = i; } }); devJustDragged = true; setTimeout(() => { devJustDragged = false; }, 200); devBtns[nearest].click(); // 触发 dev 切换动画 }); panel.querySelector('#refresh-log-btn').addEventListener('click', () => DevPanel.refreshLog()); // 详细日志开关:即时保存并刷新(无需再点主面板保存配置) const logDetailSwitch = panel.querySelector('#log-detail-switch'); if (logDetailSwitch) { logDetailSwitch.addEventListener('change', () => { currentConfig.logDetail = logDetailSwitch.checked; GM_setValue('logDetail', currentConfig.logDetail); const track = logDetailSwitch.nextElementSibling; if (track && track.classList.contains('switch-track')) track.classList.toggle('on', logDetailSwitch.checked); DevPanel.refreshLog(); utils.showMessage('日志详细模式已' + (logDetailSwitch.checked ? '开启' : '关闭'), 'info'); }); } panel.querySelector('#clear-log-btn').addEventListener('click', () => { Logger.clear(); DevPanel.refreshLog(); utils.showMessage('日志已清空', 'info'); }); // 趣味物理开关:同步外观、配置和运行中的特效。 const applyFunUI = () => { document.querySelectorAll('#ai-helper-panel, #dev-panel').forEach(p => { p.classList.toggle('fun-jelly-switch', currentConfig.funJellySwitch); if (!currentConfig.funInertia) { p.dataset.__physicsRunning = ''; p.classList.remove('squash'); } if (!currentConfig.funWaterBall) p.classList.remove('water-ball'); if (!currentConfig.funJelly) { p.querySelectorAll('button').forEach(b => b.classList.remove('pressed', 'jelly-pop')); } }); if (!currentConfig.funParticles) document.getElementById('ui-particle-layer')?.replaceChildren(); }; [ ['dev-fun-jelly', 'funJelly'], ['dev-fun-jelly-switch', 'funJellySwitch'], ['dev-fun-inertia', 'funInertia'], ['dev-fun-particles', 'funParticles'], ['dev-fun-waterball', 'funWaterBall'] ].forEach(([id, key]) => { const input = panel.querySelector('#' + id); input.addEventListener('change', () => { currentConfig[key] = input.checked; GM_setValue(key, input.checked); input.nextElementSibling.classList.toggle('on', input.checked); applyFunUI(); }); }); applyFunUI(); const depthSlider = panel.querySelector('#dev-depth-strength'); depthSlider.addEventListener('input', () => { currentConfig.depthStrength = Number(depthSlider.value); GM_setValue('depthStrength', currentConfig.depthStrength); panel.querySelector('#dev-depth-strength-label').textContent = currentConfig.depthStrength + '%'; document.querySelectorAll('#ai-helper-panel, #dev-panel').forEach(p => utils.resetPanelDepth(p)); }); panel.querySelector('#dev-depth-reset').addEventListener('click', () => { depthSlider.value = '100'; depthSlider.dispatchEvent(new Event('input')); }); // 毛玻璃背景模糊:主面板与开发者面板同步 const devGlassBlurSwitch = panel.querySelector('#dev-glass-blur-switch'); if (devGlassBlurSwitch) { const applyGlassBlur = () => { const mainPanel = document.getElementById('ai-helper-panel'); if (mainPanel) { if (!currentConfig.glassBlur) utils.resetPanelDepth(mainPanel); mainPanel.classList.toggle('panel-glass', currentConfig.glassBlur); mainPanel.style.setProperty('--glass-blur', currentConfig.glassBlurAmount + 'px'); mainPanel.style.setProperty('--panel-opacity', currentConfig.panelOpacity / 100); } panel.classList.toggle('panel-glass', currentConfig.glassBlur); if (!currentConfig.glassBlur) utils.resetPanelDepth(panel); panel.style.setProperty('--glass-blur', currentConfig.glassBlurAmount + 'px'); panel.style.setProperty('--panel-opacity', currentConfig.panelOpacity / 100); }; devGlassBlurSwitch.addEventListener('change', () => { currentConfig.glassBlur = devGlassBlurSwitch.checked; GM_setValue('glassBlur', currentConfig.glassBlur); applyGlassBlur(); const track = devGlassBlurSwitch.nextElementSibling; if (track && track.classList.contains('switch-track')) track.classList.toggle('on', devGlassBlurSwitch.checked); utils.showMessage('高级ui已' + (currentConfig.glassBlur ? '开启' : '关闭'), 'info'); }); const devGlassBlurAmount = panel.querySelector('#dev-glass-blur-amount'); const devGlassBlurLabel = panel.querySelector('#dev-glass-blur-label'); if (devGlassBlurAmount) { devGlassBlurAmount.addEventListener('input', () => { currentConfig.glassBlurAmount = Number(devGlassBlurAmount.value); GM_setValue('glassBlurAmount', currentConfig.glassBlurAmount); if (devGlassBlurLabel) devGlassBlurLabel.textContent = currentConfig.glassBlurAmount + 'px'; applyGlassBlur(); }); } const devPanelOpacity = panel.querySelector('#dev-panel-opacity'); const devPanelOpacityLabel = panel.querySelector('#dev-panel-opacity-label'); if (devPanelOpacity) { devPanelOpacity.addEventListener('input', () => { currentConfig.panelOpacity = Number(devPanelOpacity.value); GM_setValue('panelOpacity', currentConfig.panelOpacity); if (devPanelOpacityLabel) devPanelOpacityLabel.textContent = currentConfig.panelOpacity + '%'; applyGlassBlur(); }); } applyGlassBlur(); } // Tab 切换动画开关 const devTabAnimSwitch = panel.querySelector('#dev-tab-anim-switch'); if (devTabAnimSwitch) { devTabAnimSwitch.addEventListener('change', () => { currentConfig.tabAnimEnabled = devTabAnimSwitch.checked; GM_setValue('tabAnimEnabled', currentConfig.tabAnimEnabled); const mainPanel = document.getElementById('ai-helper-panel'); if (mainPanel) mainPanel.classList.toggle('tab-anim', currentConfig.tabAnimEnabled); panel.classList.toggle('tab-anim', currentConfig.tabAnimEnabled); const track = devTabAnimSwitch.nextElementSibling; if (track && track.classList.contains('switch-track')) track.classList.toggle('on', devTabAnimSwitch.checked); utils.showMessage('Tab 切换动画已' + (currentConfig.tabAnimEnabled ? '开启' : '关闭'), 'info'); }); } // Tab 拖拽弹性:模式开关 + 拉伸距离 + 弹簧软硬 K const devRubberBand = panel.querySelector('#dev-rubber-band'); const devRubberLabel = panel.querySelector('#dev-rubber-band-label'); const devKSlider = panel.querySelector('#dev-k-slider'); const devKLabel = panel.querySelector('#dev-k-label'); const devKItem = panel.querySelector('#dev-k-item'); const devSpringModeSwitch = panel.querySelector('#dev-spring-mode-switch'); const devSpringModeLabel = panel.querySelector('#dev-spring-mode-label'); // 刷新 UI:K 滑条显隐/数值/可编辑性,以及模式文案 const refreshSpringUI = () => { const auto = currentConfig.tabSpringMode !== 'manual'; const K = auto ? utils.springK() : (currentConfig.tabSpringK); if (devKItem) devKItem.style.opacity = auto ? '0.4' : '1'; if (devKSlider) { devKSlider.disabled = auto; devKSlider.value = String(Math.round(K * 100)); } if (devKLabel) devKLabel.textContent = 'K=' + Number(K).toFixed(2); if (devSpringModeLabel) devSpringModeLabel.textContent = auto ? '自动(远=慢)' : '独立(可分开调)'; const track = devSpringModeSwitch && devSpringModeSwitch.nextElementSibling; if (track && track.classList.contains('switch-track')) track.classList.toggle('on', !auto); }; if (devSpringModeSwitch) { devSpringModeSwitch.addEventListener('change', () => { currentConfig.tabSpringMode = devSpringModeSwitch.checked ? 'manual' : 'auto'; GM_setValue('tabSpringMode', currentConfig.tabSpringMode); if (!devSpringModeSwitch.checked) { // 切回自动:同步 K 现值到配置(两模式下持久化一致) const K = utils.springK(); currentConfig.tabSpringK = K; GM_setValue('tabSpringK', K); } refreshSpringUI(); utils.showMessage('弹性模式:' + (devSpringModeSwitch.checked ? '独立调节(远近/软硬分开)' : '自动联动(拉得远=追得慢)'), 'info'); }); } if (devRubberBand) { devRubberBand.addEventListener('input', () => { currentConfig.tabRubberBand = Math.round(Number(devRubberBand.value) / 10) * 10; GM_setValue('tabRubberBand', currentConfig.tabRubberBand); if (devRubberLabel) devRubberLabel.textContent = currentConfig.tabRubberBand + 'px'; // auto 模式下 OVER 改变会联动 K,同步刷新显示 if (currentConfig.tabSpringMode !== 'manual') { currentConfig.tabSpringK = utils.springK(); GM_setValue('tabSpringK', currentConfig.tabSpringK); if (devKSlider) devKSlider.value = String(Math.round(currentConfig.tabSpringK * 100)); if (devKLabel) devKLabel.textContent = 'K=' + currentConfig.tabSpringK.toFixed(2); } }); } if (devKSlider) { devKSlider.addEventListener('input', () => { if (devKSlider.disabled) return; // auto 模式禁手动 currentConfig.tabSpringK = Math.round(Number(devKSlider.value)) / 100; GM_setValue('tabSpringK', currentConfig.tabSpringK); if (devKLabel) devKLabel.textContent = 'K=' + currentConfig.tabSpringK.toFixed(2); }); } refreshSpringUI(); // 保存提示词(保存按钮与恢复默认共用:恢复默认 = 清空后立即保存生效) const savePrompts = () => { let p1 = (document.getElementById('custom-prompt') || {}).value.trim(); let p2 = (document.getElementById('custom-user-prompt') || {}).value.trim(); // 超限截断(防单次 AI 消耗暴涨) if (p1.length > PROMPT_MAX_LEN) { p1 = p1.slice(0, PROMPT_MAX_LEN); utils.showMessage('自定义 System 提示词超长,已截断至 ' + PROMPT_MAX_LEN + ' 字', 'warning'); } if (p2.length > PROMPT_MAX_LEN) { p2 = p2.slice(0, PROMPT_MAX_LEN); utils.showMessage('自定义 User 提示词超长,已截断至 ' + PROMPT_MAX_LEN + ' 字', 'warning'); } currentConfig.customPrompt = p1; currentConfig.customUserPrompt = p2; GM_setValue('customPrompt', currentConfig.customPrompt); GM_setValue('customUserPrompt', currentConfig.customUserPrompt); DevPanel.updatePromptPreview(); utils.showMessage('提示词已保存并生效', 'success'); Logger.milestone('自定义提示词已保存(System/User)'); }; panel.querySelector('#save-prompts-btn').addEventListener('click', savePrompts); // 恢复默认:清空两个输入框后立即执行一次保存,让默认提示词当场生效 panel.querySelector('#reset-prompts-btn').addEventListener('click', () => { panel.querySelector('#custom-prompt').value = ''; panel.querySelector('#custom-user-prompt').value = ''; savePrompts(); utils.showMessage('已恢复默认提示词并生效', 'success'); }); // 测试连接:用当前生效提示词实际调用AI,展示完整prompt与AI回复 panel.querySelector('#test-prompt-btn').addEventListener('click', async () => { const resultEl = document.getElementById('test-prompt-result'); if (!resultEl) return; // 优先取当前答题弹窗的真实题目,否则用示例题 let question = '实验室发生火灾时应立即用水扑救。'; let options = ['正确', '错误']; let qtype = 'judgment'; const popup = document.querySelector('.ant-modal-wrap'); if (popup) { const info = ExamProcessor.isExamModal(popup) ? ExamProcessor.extractExamQuestion(popup) : QuestionProcessor.extractQuestionInfo(popup); if (info && info.type && info.text) { question = info.text; options = info.options.map(o => o.text); qtype = info.type; } } utils.showMessage('正在调用AI测试...', 'info'); const r = await DeepSeekService.testPrompt(question, options, qtype); if (!r) return; const customStatus = (currentConfig.customPrompt && currentConfig.customPrompt.trim()) ? `已启用` : `默认`; const hintStatus = DeepSeekService.getHintStatus() > 0 ? `已激活(${DeepSeekService.getHintStatus()}道错题)` : `未激活`; resultEl.style.display = 'block'; resultEl.innerHTML = `
自定义提示词:${customStatus} | 错题提示词:${hintStatus} | 题型:${qtype} ${popup ? '' : '(示例题,无答题弹窗)'}
System 提示词(角色/格式约束)
User 提示词(错题参考 + 题目)
AI 原始回复
`; resultEl.querySelector('#test-system-prompt').value = r.systemPrompt; resultEl.querySelector('#test-user-prompt').value = r.userPrompt; resultEl.querySelector('#test-ai-reply').value = r.raw + (r.status === 'ok' ? '' : '\n\n[请求异常: ' + r.status + ']'); // 失败时右上角 toast 提示(本地余额仍有值 + 上游类错误 → 明确"联系开发者"文案) if (r.status !== 'ok') { const toastMsg = (AuthService.isUpstreamFailure(r.status) && AuthService.hasBalance()) ? '上游算力用尽,请联系开发者' : ('提示词测试失败:' + (r.raw || r.status)); utils.showMessage(toastMsg, 'error'); } Logger.milestone(`提示词测试完成: ${qtype}(${popup ? '真实题目' : '示例题'})`); }); // 定期自动刷新日志 setInterval(() => { if (panel.classList.contains('open')) DevPanel.refreshLog(); }, 3000); }, refreshLog: function() { const logView = document.getElementById('log-view'); if (logView) { logView.value = Logger.getText(); logView.scrollTop = logView.scrollHeight; } }, // 更新"当前生效提示词"预览(双结构:System + User) updatePromptPreview: function() { const statusEl = document.getElementById('prompt-status'); const previewEl = document.getElementById('prompt-preview'); if (!statusEl || !previewEl) return; const hintCount = DeepSeekService.getHintStatus(); const hasCustom = !!(currentConfig.customPrompt && currentConfig.customPrompt.trim()); // 状态区:自定义提示词 + 错题答案 两个维度 const customText = hasCustom ? `自定义提示词已启用` : `默认提示词(未自定义)`; const hintText = hintCount > 0 ? `|错题答案已激活(${hintCount}道)` : ''; statusEl.innerHTML = `状态:${customText}${hintText}`; // 双结构预览:System(你的要求/格式约束)+ User(错题参考 + 题目) const system = DeepSeekService.buildSystemPrompt('single'); const user = DeepSeekService.buildUserPrompt('(示例题目)', ['A选项', 'B选项', 'C选项', 'D选项'], 'single', activeHintData); previewEl.value = '【System 提示词 — 你的要求/格式约束】\n' + system + '\n\n【User 提示词 — 错题参考 + 题目】\n' + user; } }; // ============================ 视频题 API + AI 模式 ============================ // 课程接口只负责取题和提交;答案决策仍由授权服务后的 AI 完成。 const VideoApiAnswer = { token: '', courseId: '', graphId: '', hookInstalled: false, runPromise: null, reloadTimer: null, submitted: new Set(), modalKillerInstalled: false, reviewCourseId: '', reviewSwitchAfterAnswer: false, available: function() { return currentConfig.videoAnswerMode === 'api-ai' && currentConfig.autoAnswer && currentConfig.aiAnswerEnabled; }, enabled: function() { if (!this.available()) return false; return !currentConfig.reviewMode || (!!this.reviewCourseId && this.reviewCourseId === String(this.courseId)); }, clearReviewCourse: function() { this.reviewCourseId = ''; this.reviewSwitchAfterAnswer = false; }, startReviewCourse: function(switchAfterAnswer) { this.readContext(); const courseId = String(this.courseId || ''); if (!courseId) { Logger.add('error', '复习模式后台答题无法获取课程 ID'); return; } const changed = this.reviewCourseId !== courseId; this.reviewCourseId = courseId; this.reviewSwitchAfterAnswer = !!switchAfterAnswer; if (changed) this.submitted.clear(); const start = () => { if (this.enabled()) this.run(); }; if (this.runPromise) { if (changed) this.runPromise.finally(start); } else { start(); } }, captureCourse: function(url) { if (typeof url !== 'string' || !url.includes('/jcedutec/courseSource/')) return; const match = url.match(/[?&]id=(\d+)/); if (!match || !match[1] || match[1] === String(this.courseId)) return; this.courseId = match[1]; this.submitted.clear(); if (this.reloadTimer) clearTimeout(this.reloadTimer); this.reloadTimer = setTimeout(() => { if (this.enabled()) this.run(); }, 600); }, hookPageRequests: function() { if (this.hookInstalled) return; this.hookInstalled = true; const self = this; const origOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function(method, url) { try { this.__videoApiUrl = url; } catch (e) {} return origOpen.apply(this, arguments); }; const origSetHeader = XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.setRequestHeader = function(name, value) { try { if (String(name).toLowerCase() === 'x-access-token' && value && !self.token) self.token = String(value); if (this.__videoApiUrl) self.captureCourse(this.__videoApiUrl); } catch (e) {} return origSetHeader.apply(this, arguments); }; const origFetch = window.fetch; if (typeof origFetch === 'function') { window.fetch = function(input, init) { try { const url = typeof input === 'string' ? input : (input && input.url) || ''; const headers = init && init.headers; const value = headers && typeof headers.get === 'function' ? headers.get('x-access-token') : headers && (headers['x-access-token'] || headers['X-Access-Token']); if (value && !self.token) self.token = String(value); self.captureCourse(url); } catch (e) {} return origFetch.apply(this, arguments); }; } }, readContext: function() { try { const params = new URL(location.href).searchParams; this.courseId = this.courseId || params.get('id') || ''; this.graphId = this.graphId || params.get('graphId') || ''; if (!this.token) { const raw = localStorage.getItem('pro__Access-Token'); if (raw) { try { this.token = String(JSON.parse(raw).value || raw); } catch (e) { this.token = raw; } } } } catch (e) {} }, api: async function(method, path, body) { const headers = this.token ? { 'x-access-token': this.token } : {}; const url = method === 'GET' ? path + (path.includes('?') ? '&' : '?') + '_t=' + Date.now() : path; const init = { method, headers }; if (body) { headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); } const response = await fetch(url, init); return response.json().catch(() => ({})); }, fetchQuestions: function(courseId, graphId) { return this.api('GET', '/api/jeecg-boot/jcedutec/courseSource/queryCourseQuestionRelaByMainId?id=' + encodeURIComponent(courseId) + '&graphId=' + encodeURIComponent(graphId)) .then(data => Array.isArray(data.result) ? data.result : []); }, normalizeQuestion: function(q) { const options = ['a', 'b', 'c', 'd'].map((key, index) => ({ letter: String.fromCharCode(65 + index), text: String(q['option' + key] || '').replace(/\s+/g, ' ').trim() })).filter(option => option.text); const kind = String(q.kind); return { raw: q, text: String(q.stem || q.question || '').replace(/\s+/g, ' ').trim(), type: kind === '1' ? 'judgment' : kind === '3' ? 'multiple' : 'single', options }; }, answerToLetters: function(info, answers) { if (!Array.isArray(answers) || !answers.length) return []; const letters = []; for (const raw of answers) { const answer = String(raw || '').trim(); const direct = answer.toUpperCase().replace(/[::。.、,,\s]/g, '').match(/^[A-D]$/); if (direct) { letters.push(direct[0]); continue; } if (info.type === 'judgment' && /正确|错误|对|错/.test(answer)) { const positive = /正确|对/.test(answer); const option = info.options.find(item => positive ? /正确|对/.test(item.text) : /错误|错/.test(item.text)); if (option) letters.push(option.letter); continue; } let best = null, bestScore = 0; for (const option of info.options) { const score = utils.similarity(option.text, answer); if (score > bestScore) { best = option; bestScore = score; } } if (best && bestScore >= currentConfig.matchRate) letters.push(best.letter); } return [...new Set(letters)]; }, submit: function(q, letters, courseId, graphId) { const option = String(q.kind) === '3' ? letters : letters[0] || ''; return this.api('POST', '/api/jeecg-boot/jcedutec/courseSource/submitAnswer', { questionId: q.id, id: courseId, graphId, option }); }, closeVideoModal: function() { const modal = [...document.querySelectorAll('.ant-modal-wrap')].find(item => item.textContent && item.textContent.includes('视频习题') && item.getClientRects().length > 0 ); if (!modal) return; const close = modal.querySelector('.ant-modal-close'); if (close) close.click(); else modal.remove(); setTimeout(() => { const video = document.querySelector('video'); if (video && video.paused) video.play().catch(() => {}); }, 100); }, startModalKiller: function() { if (this.modalKillerInstalled) return; this.modalKillerInstalled = true; const tryClose = () => { if (this.enabled()) this.closeVideoModal(); }; new MutationObserver(tryClose).observe(document.body, { childList: true, subtree: true }); setInterval(tryClose, 800); }, run: async function() { if (!this.enabled() || this.runPromise) return this.runPromise; this.runPromise = (async () => { this.readContext(); if (!this.courseId || !this.graphId || !this.token) return; const courseId = this.courseId, graphId = this.graphId; const questions = await this.fetchQuestions(courseId, graphId); let allSubmitted = questions.length > 0; for (const q of questions) { if (!this.enabled() || courseId !== this.courseId) { allSubmitted = false; break; } if (this.submitted.has(q.id)) continue; const info = this.normalizeQuestion(q); if (!info.text || !info.options.length) { allSubmitted = false; continue; } const answers = await DeepSeekService.getAnswerFromDeepSeek(info.text, info.options.map(option => option.text), info.type); if (!this.enabled() || courseId !== this.courseId) { allSubmitted = false; break; } const letters = this.answerToLetters(info, answers); if (!letters.length || (info.type !== 'multiple' && letters.length !== 1)) { allSubmitted = false; Logger.add('error', '视频题 API 模式答案解析失败', info.text.slice(0, 40)); continue; } const result = await this.submit(q, letters, courseId, graphId); if (result && (result.success === true || result.code === 200)) { this.submitted.add(q.id); Logger.milestone('视频题 API 提交成功: ' + info.text.slice(0, 20) + '...'); } else { allSubmitted = false; Logger.add('error', '视频题 API 提交失败', JSON.stringify(result)); } } this.closeVideoModal(); const shouldSwitch = currentConfig.reviewMode && this.reviewSwitchAfterAnswer && this.reviewCourseId === courseId && allSubmitted && questions.every(q => this.submitted.has(q.id)); if (shouldSwitch) { this.clearReviewCourse(); Logger.milestone('复习模式:课程已学完且题目已全部修正,切换下一课程'); VideoController.switchToNextCourse(true); } })().catch(error => Logger.add('error', '视频题 API 模式异常: ' + error.message)).finally(() => { this.runPromise = null; }); return this.runPromise; }, boot: function() { if (!this.available()) return; this.hookPageRequests(); this.readContext(); this.startModalKiller(); if (this.enabled()) this.run(); } }; // ============================ 答题处理逻辑 (完整还原) ============================ const QuestionProcessor = { extractQuestionInfo: function(popup) { const info = { type: null, text: '', options: [] }; try { const titleElement = popup.querySelector('.sub-title'); if (titleElement) { const txt = titleElement.textContent; info.text = utils.cleanText(utils.removeHtml(txt)).replace(/^[((]?(判断题|单选题|多选题)[))][::]?\s*/, ''); if (txt.includes('判断题')) info.type = 'judgment'; else if (txt.includes('单选题')) info.type = 'single'; else if (txt.includes('多选题')) info.type = 'multiple'; } const optElems = popup.querySelectorAll('.ant-checkbox-wrapper, .ant-radio-wrapper'); optElems.forEach((opt, index) => { let optTxt = (opt.querySelector('.option') ? opt.querySelector('.option').textContent : opt.textContent).trim(); optTxt = optTxt.replace(/^[A-D][::\.、]\s*/, '').replace(/^[0-9][::\.、]\s*/, ''); if (optTxt) info.options.push({ index, text: optTxt, inputElement: opt.querySelector('input'), element: opt }); }); return info; } catch (e) { return null; } }, generateRandomAnswer: function(info) { if (info.type === 'judgment') return Math.random() > 0.5 ? ['正确'] : ['错误']; if (info.type === 'single' && info.options.length) return [info.options[Math.floor(Math.random() * info.options.length)].text]; if (info.type === 'multiple' && info.options.length) { const count = Math.max(1, Math.floor(Math.random() * info.options.length)); const shuffled = [...info.options].sort(() => Math.random() - 0.5); return shuffled.slice(0, count).map(o => o.text); } return ['']; }, clearAllSelections: function(popup) { try { // 静默取消选中,不触发click(避免React事件处理器意外提交) popup.querySelectorAll('input:checked').forEach(input => { input.checked = false; input.dispatchEvent(new Event('change', { bubbles: true })); }); } catch (e) {} }, autoSubmitAnswer: function(popup) { if (!currentConfig.autoSubmit) return; let submitted = false; // 成功一次后阻止后续重试 [500, 1000, 1500].forEach(delay => { setTimeout(() => { if (submitted) return; const btn = utils.findSubmitButton(popup); if (btn && !btn.disabled && btn.offsetParent) { if (utils.triggerButtonClick(btn)) { submitted = true; Logger.milestone('视频答题已自动提交'); utils.showMessage('已自动提交答案', 'success'); } } }, delay); }); }, setAnswerToPage: async function(info, answers, popup) { let finalAnswers = answers; let useRandom = false; // [诊断] 记录 AI 原始答案与页面选项,用于定位"AI答对却勾错" Logger.milestone(`[诊断] AI答案=${JSON.stringify(answers)} | 选项文本=${JSON.stringify(info.options.map(o => o.text))}`); if (!answers || !answers.length || !utils.fuzzyMatchIndex(info.options.map(o => o.text), answers, currentConfig.matchRate).length) { if (currentConfig.randomAnswer) { Logger.milestone(`[诊断] AI答案匹配失败,触发随机答题!`); finalAnswers = this.generateRandomAnswer(info); useRandom = true; } else { utils.log('无可用答案且未开启随机答题'); return false; } } this.clearAllSelections(popup); await utils.wait(200); const matchIndices = utils.fuzzyMatchIndex(info.options.map(o => o.text), finalAnswers, currentConfig.matchRate); if (matchIndices.length === 0 && useRandom) { matchIndices.push(Math.floor(Math.random() * info.options.length)); } Logger.milestone(`[诊断] finalAnswers=${JSON.stringify(finalAnswers)} | matchIndices=${JSON.stringify(matchIndices)} | useRandom=${useRandom}`); let successCount = 0; let expectedCount = 0; if (info.type === 'judgment') { expectedCount = 1; const isCorrect = finalAnswers[0].includes('对') || finalAnswers[0].includes('正确') || finalAnswers[0] === 'A'; for (const opt of info.options) { if ((isCorrect && (opt.text.includes('对') || opt.text.includes('正确'))) || (!isCorrect && (opt.text.includes('错') || opt.text.includes('错误')))) { const verified = await utils.clickAndVerify(opt); if (verified) successCount++; break; } } } else if (info.type === 'single') { // 单选:只勾选相似度最高的一个 // (避免"包含"规则导致多个选项误匹配后全点,radio互斥最终停在最后一个) expectedCount = 1; let bestIdx = matchIndices[0]; let bestScore = -1; for (const idx of matchIndices) { if (!info.options[idx]) continue; const score = utils.similarity(info.options[idx].text, finalAnswers[0]); if (score > bestScore) { bestScore = score; bestIdx = idx; } } if (info.options[bestIdx]) { const verified = await utils.clickAndVerify(info.options[bestIdx]); if (verified) successCount++; } } else { expectedCount = matchIndices.length; for (const idx of matchIndices) { if (info.options[idx]) { const verified = await utils.clickAndVerify(info.options[idx]); if (verified) successCount++; } } } if (successCount > 0 && successCount === expectedCount) { utils.showMessage(`答题成功(${useRandom ? '随机答案' : 'AI答案'})`, 'success'); setTimeout(() => { this.autoSubmitAnswer(popup); }, 300); return true; } else { utils.showMessage(`答题选项点击未生效,阻止自动提交`, 'error'); return false; } }, processQuestion: async function(popup) { if (isAnswering) { utils.showMessage('正在处理,请稍等', 'warning'); return; } isAnswering = true; try { const info = this.extractQuestionInfo(popup); if (!info || !info.type) { utils.showMessage('无法识别题目', 'error'); return; } Logger.milestone('检测到视频题目: ' + info.type + ' - ' + info.text.substring(0, 30) + '...'); const options = info.options.map(o => o.text); // 调用AI(错题提示词由手动"提供给AI"按钮激活,activeHintData 全局复用) const aiAnswers = await DeepSeekService.getAnswerFromDeepSeek(info.text, options, info.type); const result = await this.setAnswerToPage(info, aiAnswers, popup); if (result) { Logger.milestone('视频答题成功: ' + info.text.substring(0, 20) + '...'); } } catch (e) { Logger.add('error', '视频答题异常: ' + e.message); utils.showMessage(`答题异常: ${e.message}`, 'error'); } finally { isAnswering = false; } } }; // ============================ 考试模式处理模块 ============================ const ExamProcessor = { // 检测是否为考试弹窗(.ant-modal-wrap 内含 .ant-form + 第X题格式) isExamModal: function(modal) { if (!modal) return false; const form = modal.querySelector('.ant-form'); if (!form || form.children.length < 2) return false; const row1 = form.children[1]; return row1 && /第\d+题/.test(row1.textContent); }, // 从考试弹窗提取题目信息(复用 QuestionProcessor 的输出格式) extractExamQuestion: function(modal) { const info = { type: null, text: '', options: [], isExam: true }; try { const form = modal.querySelector('.ant-form'); if (!form || form.children.length < 2) return null; const row1 = form.children[1]; // 第X题:(题型)题目 const rawText = row1.textContent.trim(); // 提取题型 const typeMatch = rawText.match(/(判断题|单选题|多选题)/); if (typeMatch) { if (typeMatch[1] === '判断题') info.type = 'judgment'; else if (typeMatch[1] === '单选题') info.type = 'single'; else if (typeMatch[1] === '多选题') info.type = 'multiple'; } // 提取题干(去掉"第X题:(题型)"前缀) info.text = rawText.replace(/^第\d+题[::]\s*[((]\s*(判断题|单选题|多选题)\s*[))]\s*/, '').trim(); // 提取选项(.ant-radio-wrapper 不含文本,需从父容器 .can-scroll 按 A:B:C:D:分割) const optionsDiv = form.children[2]; // .can-scroll if (optionsDiv) { const radioWrappers = optionsDiv.querySelectorAll('.ant-radio-wrapper'); const raw = optionsDiv.textContent || ''; // 按 A:B:C:D:分割出选项文本 const parts = raw.split(/[A-D][::]/); parts.forEach((part, idx) => { if (idx === 0 || !part) return; // 跳过第一个空段 const cleaned = part.replace(/\s+/g, ' ').trim(); if (cleaned && idx - 1 < radioWrappers.length) { const wrapper = radioWrappers[idx - 1]; info.options.push({ index: idx - 1, text: cleaned, element: wrapper, inputElement: wrapper ? wrapper.querySelector('input[type="radio"]') : null }); } }); } return info; } catch (e) { utils.log('提取考试题目失败:', e); return null; } }, // 选中考试选项 selectExamOption: function(option) { if (!option) return false; try { if (option.inputElement) { option.inputElement.click(); option.inputElement.checked = true; option.inputElement.dispatchEvent(new Event('change', { bubbles: true })); } else if (option.element) { option.element.click(); } return true; } catch (e) { utils.log('选中考试选项失败:', e); return false; } }, // 找到考试按钮(下一题/提 交) findExamButton: function(modal) { const form = modal.querySelector('.ant-form'); if (!form || form.children.length < 4) return null; const btnDiv = form.children[3]; // .custom-btn if (!btnDiv) return null; // 优先找"提 交",其次"下一题" const buttons = btnDiv.querySelectorAll('button'); for (const btn of buttons) { const t = btn.textContent.trim().replace(/\s+/g, ''); if (t === '提交' || t === '下一题') return btn; } return buttons[buttons.length - 1] || null; }, // 判断是否是最后一题(按钮是"提 交") isLastQuestion: function(modal) { const btn = this.findExamButton(modal); return btn ? btn.textContent.trim().replace(/\s+/g, '') === '提交' : false; }, // 点击末题提交后,等待并确认平台的二次确认框。 confirmExamSubmission: function() { return new Promise((resolve) => { let settled = false; const finish = (ok) => { if (settled) return; settled = true; observer.disconnect(); clearTimeout(timer); resolve(ok); }; const tryConfirm = () => { const dialog = [...document.querySelectorAll('.ant-modal-confirm')].reverse().find(item => item.getClientRects().length > 0 && /是否提交|确认提交试卷/.test(item.textContent || '') ); if (!dialog) return; const buttons = [...dialog.querySelectorAll('button')]; const confirmBtn = buttons.find(button => !button.disabled && /^(确定|确认|OK)$/i.test(button.textContent.trim().replace(/\s+/g, '')) ) || dialog.querySelector('button.ant-btn-primary:not([disabled])'); if (!confirmBtn) return; confirmBtn.click(); finish(true); }; const observer = new MutationObserver(tryConfirm); observer.observe(document.body, { childList: true, subtree: true }); const timer = setTimeout(() => finish(false), 2000); tryConfirm(); }); }, // 处理单道考试题 processExamQuestion: async function(modal) { if (examProcessing) return; examProcessing = true; try { const info = this.extractExamQuestion(modal); if (!info || !info.type) { utils.log('无法识别考试题目'); examProcessing = false; return; } Logger.milestone('检测到考试题目: ' + info.type + ' - ' + info.text.substring(0, 30) + '...'); utils.log('考试题目:', { type: info.type, text: info.text.substring(0, 30) + '...', optionsCount: info.options.length }); // 调用 AI 获取答案(错题提示词由手动"提供给AI"按钮激活) const options = info.options.map(o => o.text); const aiAnswers = await DeepSeekService.getAnswerFromDeepSeek(info.text, options, info.type); // 匹配答案 let finalAnswers = aiAnswers; let useRandom = false; if (!aiAnswers || aiAnswers.length === 0 || !utils.fuzzyMatchIndex(info.options.map(o => o.text), aiAnswers, currentConfig.matchRate).length) { if (currentConfig.randomAnswer) { finalAnswers = info.type === 'judgment' ? (Math.random() > 0.5 ? ['正确'] : ['错误']) : [info.options[Math.floor(Math.random() * info.options.length)].text]; useRandom = true; } } if (!finalAnswers || finalAnswers.length === 0) { Logger.add('error', '考试无可用答案'); utils.log('无可用答案'); examProcessing = false; return; } // 匹配选项索引 const matchIndices = utils.fuzzyMatchIndex(info.options.map(o => o.text), finalAnswers, currentConfig.matchRate); if (matchIndices.length === 0 && useRandom) { matchIndices.push(Math.floor(Math.random() * info.options.length)); } // 选中答案 let selected = false; if (info.type === 'judgment') { const isCorrect = finalAnswers[0].includes('正确') || finalAnswers[0].includes('对'); for (const opt of info.options) { const t = opt.text; if ((isCorrect && (t.includes('正确') || t.includes('对'))) || (!isCorrect && (t.includes('错误') || t.includes('错')))) { selected = this.selectExamOption(opt); break; } } } else { for (const idx of matchIndices) { if (info.options[idx]) { selected = this.selectExamOption(info.options[idx]) || selected; } } } if (selected) { const answerLabel = useRandom ? '随机答案' : 'AI答案'; Logger.milestone('考试答题成功: ' + info.text.substring(0, 20) + '... (' + answerLabel + ')'); utils.showMessage(`考试答题成功(${answerLabel})`, 'success'); } // 自动翻到下一题 或 自动提交(独立开关) setTimeout(async () => { const btn = this.findExamButton(modal); if (!btn || !btn.offsetParent || btn.disabled) return; const btnText = btn.textContent.trim().replace(/\s+/g, ''); if (btnText === '提交') { if (currentConfig.examAutoSubmit) { Logger.milestone('自动提交考试'); utils.log('自动提交考试'); btn.click(); const confirmed = await this.confirmExamSubmission(); if (confirmed) { utils.showMessage('考试已确认提交', 'success'); } else { Logger.add('error', '考试二次确认按钮未找到'); utils.showMessage('未找到考试二次确认,请手动确认提交', 'warning'); } } else { Logger.milestone('末题已答,等待手动提交'); utils.log('末题已答,等待手动提交'); } } else if (btnText === '下一题') { if (currentConfig.examAutoAdvance) { utils.log('自动翻下一题'); btn.click(); } } }, currentConfig.examAdvanceDelay); } catch (e) { Logger.add('error', '考试处理异常: ' + e.message); utils.log('考试处理异常:', e); } finally { // 延迟释放锁,给翻页留时间 setTimeout(() => { examProcessing = false; }, currentConfig.examAdvanceDelay + 500); } } }; // ============================ 考试批量并发答题 ============================ // 【2.5.7】考试数据本地存储:单场次一份,逐批落盘。 // 用途:刷新/崩溃后能续跑 —— 已全部拿到答案的批次直接跳过,不重复请求、不重复扣费。 const EXAM_STORE_KEY = 'examJobStore'; const ExamStore = { read: function() { const raw = GM_getValue(EXAM_STORE_KEY, null); return raw && typeof raw === 'object' ? raw : null; }, write: function(job) { GM_setValue(EXAM_STORE_KEY, job); }, wipe: function() { // 【2.5.21】GM_deleteValue 未在 @grant 中声明(沙箱不注入该 API), // 调用即抛 TypeError 并中断后续清理。写入 null 与删除在 read() 语义上等价。 GM_setValue(EXAM_STORE_KEY, null); } }; const ExamBatchProcessor = { hookInstalled: false, pending: false, running: false, activeRecordId: '', runId: 0, answers: new Map(), errors: [], completedBatches: 0, totalBatches: 0, runningBatches: 0, totalQuestions: 0, lastRecords: [], // 【2.5.18】本场题目的归一化全集(seq 按原始下标定格);「重试未答出题目」靠它拿正确题号 questionModels: [], // 【2.5.7】统计与调度状态 stats: { requests: 0, failedBatches: 0 }, // requests = 真正发出的请求数(≈ 扣费次数) submitted: false, netFailStreak: 0, // 连续"网络类"失败批数(轻量熔断用) aborted: false, // 熔断停手标志 abortReason: '', nextTokenAt: 0, // 令牌桶:下一张票的可用时刻 adapt: { effective: 0, window: [], successStreak: 0, rateLimited: false }, available: function() { return currentConfig.examEnabled && currentConfig.autoAnswer && currentConfig.aiAnswerEnabled; }, isActive: function() { return this.pending || !!this.activeRecordId; }, plan: function() { const batchSize = clampExamBatchSize(currentConfig.examBatchSize); const concurrency = clampExamConcurrency(currentConfig.examConcurrency); const total = this.totalQuestions || 0; const submits = total ? Math.ceil(total / batchSize) : 0; const waves = submits ? Math.ceil(submits / concurrency) : 0; return { batchSize: batchSize, concurrency: concurrency, total: total, submits: submits, waves: waves }; }, refreshPlanUI: function() { const el = document.getElementById('exam-plan-summary'); if (!el) return; const p = this.plan(); el.textContent = p.total ? `本次考试:共 ${p.total} 题|单轮 ${p.batchSize} 题|单次并发 ${p.concurrency} 轮\n总体提交次数:${p.submits} 次(分 ${p.waves} 波发完)` : `本次考试:单轮 ${p.batchSize} 题|单次并发 ${p.concurrency} 轮\n总体提交次数:考试开始后按实际题数自动计算`; }, updateStatus: function(message) { const el = document.getElementById('exam-batch-status'); this.refreshPlanUI(); if (!el) return; const upper = clampExamConcurrency(currentConfig.examConcurrency); const effective = this.adapt && this.adapt.effective ? this.adapt.effective : upper; const progress = this.totalBatches ? `批次 ${this.completedBatches}/${this.totalBatches}|并发 ${this.runningBatches}/${effective}${effective < upper ? `(上限 ${upper},已降级)` : ''}|有效答案 ${this.answers.size}|请求 ${this.stats.requests} 次` : ''; const lastError = this.errors.length ? this.errors[this.errors.length - 1] : ''; el.textContent = `批量答题:${message}${progress ? '\n' + progress : ''}${lastError ? `\n失败:${this.errors.length}|${lastError}` : ''}`; el.style.color = this.errors.length ? '#cf1322' : '#555'; }, normalizeQuestion: function(q, index) { const options = ['a', 'b', 'c', 'd'].map((key, optionIndex) => ({ letter: String.fromCharCode(65 + optionIndex), text: String(q['option' + key] || '').replace(/\s+/g, ' ').trim() })).filter(option => option.text); const kind = String(q.kind); return { id: String(q.id), seq: index + 1, kind, type: kind === '1' ? 'judgment' : kind === '3' ? 'multiple' : 'single', question: String(q.stem || q.question || '').replace(/\s+/g, ' ').trim(), options }; }, buildMessages: function(batch) { const baseSystem = DeepSeekService.buildSystemPrompt('default'); const systemPrompt = baseSystem + '\n你将一次回答多道题。只能返回 JSON,不要解释,不要使用 Markdown。每项必须保留原题号 seq;answer 必须是字母数组。判断题和单选题数组只能有一个字母,多选题可有多个字母。'; let userPrompt = ''; if (!currentConfig.reviewMode && currentConfig.customUserPrompt && currentConfig.customUserPrompt.trim()) { userPrompt += currentConfig.customUserPrompt.trim() + '\n\n'; } if (activeHintData && activeHintData.length) { userPrompt += '历史错题参考答案:\n' + JSON.stringify(activeHintData) + '\n\n'; } userPrompt += '请回答以下题目:\n' + JSON.stringify(batch.map(q => ({ seq: q.seq, type: q.type, question: q.question, options: Object.fromEntries(q.options.map(option => [option.letter, option.text])) }))) + '\n返回格式:{"answers":[{"seq":题号,"answer":["A"]}]}'; return { systemPrompt, userPrompt, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt } ] }; }, parseResponse: function(raw, batch) { const expected = new Map(batch.map(question => [question.id, question])); const parsedAnswers = new Map(); try { const cleaned = String(raw || '').replace(/```(?:json)?/gi, '').replace(/```/g, '').trim(); const start = cleaned.indexOf('{'); const end = cleaned.lastIndexOf('}'); if (start < 0 || end <= start) return parsedAnswers; const data = JSON.parse(cleaned.slice(start, end + 1)); if (!Array.isArray(data.answers)) return parsedAnswers; for (const item of data.answers) { const id = String(item && item.id || ''); const question = expected.get(id) || batch.find(candidate => candidate.seq === Number(item && item.seq)); if (!question || parsedAnswers.has(question.id)) continue; const rawLetters = Array.isArray(item.answer) ? item.answer : [item.answer]; const allowed = new Set(question.options.map(option => option.letter)); const letters = [...new Set(rawLetters.flatMap(value => String(value || '').toUpperCase().match(/[A-D]/g) || [] ).filter(letter => allowed.has(letter)))].sort(); if (question.type === 'multiple' ? letters.length > 0 : letters.length === 1) { parsedAnswers.set(question.id, letters); } } } catch (error) {} return parsedAnswers; }, // 网络类失败可以重发;余额/卡密/提示词拦截类重发只会白扣额度 // 【2.5.9】TIMEOUT 已移出:超时即失败,不拆批、不原样重发(客户端超时≠服务端没扣费, // 重发有双倍扣费风险,且白等最坏 216 秒)。用户可调小「单轮题数」或调大「等待超时」重跑。 isRetryable: function(code) { return ['NETWORK_ERROR', 'BAD_RESPONSE', 'RATE_LIMITED'].indexOf(String(code || '')) >= 0; }, isNetworkCode: function(code) { return EXAM_NET_CODES.indexOf(String(code || '')) >= 0; }, // 【2.5.7】令牌桶:同步"预约"下一张票,保证全池任意两次请求的间隔 ≥ EXAM_MIN_REQUEST_INTERVAL。 // 预约段内没有 await,所以并发调用会被原子地排成队列 —— 并发 8 也不会同时出站。 reserveRequestSlot: async function(runId) { const myTurn = Math.max(Date.now(), this.nextTokenAt); this.nextTokenAt = myTurn + EXAM_MIN_REQUEST_INTERVAL; const wait = myTurn - Date.now(); if (wait > 0) await new Promise(resolve => setTimeout(resolve, wait)); return runId === this.runId; }, callBatch: async function(batch, runId) { if (runId !== this.runId || !batch.length) return { answers: new Map(), canRetry: false, retryable: false }; const range = `第 ${batch[0].seq}-${batch[batch.length - 1].seq} 题`; const prompt = this.buildMessages(batch); const injection = DeepSeekService.checkInjection(prompt.systemPrompt + '\n' + prompt.userPrompt); if (injection.length) { return { answers: new Map(), canRetry: false, retryable: false, failInfo: { range: range, code: 'INJECTION_BLOCKED', message: '提示词被拦截' } }; } // 【2.5.7】令牌桶整形:即便并发拉到 8,也按最小间隔匀速出站,避免瞬时峰值被限流 const slotOk = await this.reserveRequestSlot(runId); if (!slotOk) return { answers: new Map(), canRetry: false, retryable: false }; this.stats.requests++; // 【2.5.9】超时取「等待超时」滑块值(秒 → 毫秒),每次请求现读,改滑块即时生效 const response = await AuthService.answer(prompt.messages, clampExamRequestTimeout(currentConfig.examRequestTimeout) * 1000); if (runId !== this.runId) return { answers: new Map(), canRetry: false, retryable: false }; if (!response || !response.success) { const code = response && response.code || 'UNKNOWN_ERROR'; const message = response && response.msg || 'AI 请求失败'; return { answers: new Map(), canRetry: false, retryable: this.isRetryable(code), failInfo: { range: range, code: code, message: message } }; } if (response.data && response.data.balance_cent !== undefined) { const cached = AuthService.getCachedStatus(); if (cached) { cached.balance_cent = Math.min(Number(cached.balance_cent), Number(response.data.balance_cent)); AuthService.setCachedStatus(cached); } if (ControlPanel.updateAuthStatusUI) ControlPanel.updateAuthStatusUI(); } return { answers: this.parseResponse(response.data && response.data.content, batch), canRetry: true, retryable: false }; }, // 整批重发:网络类失败按 2s / 4s 退避重试,最多 EXAM_RETRY_ATTEMPTS 次尝试 callBatchWithRetry: async function(batch, runId) { let last = null; for (let attempt = 1; attempt <= EXAM_RETRY_ATTEMPTS; attempt++) { if (runId !== this.runId) return { answers: new Map(), canRetry: false, retryable: false }; last = await this.callBatch(batch, runId); if (last.answers.size || !last.retryable || attempt >= EXAM_RETRY_ATTEMPTS) break; // 【2.5.7】退避加抖动(0.5~1.5 倍):避免多个 worker 在同一毫秒一起重发又撞一次 const wait = Math.round(EXAM_RETRY_BASE_DELAY * Math.pow(2, attempt - 1) * (1 - EXAM_JITTER + Math.random() * 2 * EXAM_JITTER)); const info = last.failInfo || {}; this.updateStatus(`${info.range || '当前批'} 请求失败,${Math.round(wait / 1000)} 秒后重试(第 ${attempt + 1}/${EXAM_RETRY_ATTEMPTS} 次尝试)`); Logger.add('info', `${info.range || '当前批'}请求失败,即将重试`, `${info.code || ''} - ${info.message || ''};等待 ${wait}ms`); await new Promise(resolve => setTimeout(resolve, wait)); } if (last && !last.answers.size && last.failInfo) { this.errors.push(`${last.failInfo.range}:${last.failInfo.message}`); Logger.add('error', `${last.failInfo.range}批量答题失败`, `${last.failInfo.code} - ${last.failInfo.message}(已尝试 ${EXAM_RETRY_ATTEMPTS} 次)`); } return last || { answers: new Map(), canRetry: false, retryable: false }; }, // 返回 { answers, failCode }:failCode 为 null 表示整批拿全,否则是最后一次失败的错误码 answerBatch: async function(batch, runId) { const firstResult = await this.callBatchWithRetry(batch, runId); const first = firstResult.answers; const missing = batch.filter(question => !first.has(question.id)); let lastCode = (firstResult.failInfo && firstResult.failInfo.code) || null; if (missing.length && firstResult.canRetry && runId === this.runId) { const retryResult = await this.callBatchWithRetry(missing, runId); for (const [id, answer] of retryResult.answers) first.set(id, answer); if (retryResult.failInfo) lastCode = retryResult.failInfo.code; if (first.size !== batch.length) { this.errors.push(`第 ${batch[0].seq}-${batch[batch.length - 1].seq} 题仍缺 ${batch.length - first.size} 个答案`); } } return { answers: first, failCode: first.size === batch.length ? null : String(lastCode || 'INCOMPLETE_RESPONSE') }; }, // 【2.5.7】自适应降级 + 轻量熔断的记账入口:每批结束后调用一次 recordBatchOutcome: function(batch, outcome) { const ok = !!(outcome && outcome.answers && outcome.answers.size === batch.length); const code = outcome && outcome.failCode ? String(outcome.failCode) : (ok ? '' : 'INCOMPLETE_RESPONSE'); if (!ok) this.stats.failedBatches++; // —— 自适应降级:按滑动窗口失败率调整有效并发(滑块给的是上限)—— this.adapt.window.push(ok ? 1 : 0); while (this.adapt.window.length > EXAM_ADAPT_WINDOW) this.adapt.window.shift(); const upper = clampExamConcurrency(currentConfig.examConcurrency); if (this.adapt.effective > upper) this.adapt.effective = upper; if (this.adapt.effective < 1) this.adapt.effective = 1; const fails = this.adapt.window.filter(value => !value).length; if (this.adapt.window.length >= EXAM_ADAPT_WINDOW && fails / this.adapt.window.length > EXAM_ADAPT_FAIL_RATIO && this.adapt.effective > 1) { const ratio = Math.round(fails / this.adapt.window.length * 100); this.adapt.effective = 1; Logger.add('warning', '考试并发已降级', `近 ${this.adapt.window.length} 批失败率 ${ratio}% → 有效并发降为 1`); } if (ok) { this.adapt.successStreak++; if (this.adapt.successStreak >= EXAM_ADAPT_RECOVER_STREAK && !this.adapt.rateLimited && this.adapt.effective < upper) { this.adapt.effective++; this.adapt.successStreak = 0; Logger.add('info', '考试并发已回升', `连续 ${EXAM_ADAPT_RECOVER_STREAK} 批成功 → 有效并发升为 ${this.adapt.effective}`); } } else { this.adapt.successStreak = 0; } if (code === 'RATE_LIMITED') this.adapt.rateLimited = true; // 被限流过:不再自动升档 // —— 轻量熔断:只认"网络类"连环失败(余额/上游类不重试,谈不上连环白等)—— if (!ok && this.isNetworkCode(code)) this.netFailStreak++; else this.netFailStreak = 0; if (this.netFailStreak >= EXAM_BREAKER_FAILS) this.tripBreaker(); }, // 轻量熔断:连续网络类失败 ⇒ 整池停手(不做冷却/探测/半开),已得答案保留 tripBreaker: function() { if (this.aborted) return; this.aborted = true; this.abortReason = `网络异常,已停止答题(连续 ${this.netFailStreak} 批网络类失败,已得答案保留)`; Logger.add('error', '考试批量已熔断停手', this.abortReason); utils.showMessage('⚠️ 网络异常,已停止答题
已得答案已保留:可等网络恢复后重新运行继续,或关闭自动提交后手动提交', 'error', 10000); this.persist(); this.updateStatus(this.abortReason); }, // 续跑判定:整批题目都已在答案表里 ⇒ 这批不必再请求(省一次扣费) isBatchSatisfied: function(batch) { return batch.every(question => this.answers.has(String(question.id))); }, // 【2.5.7】逐批落盘:答案 + 进度 + 统计;刷新/崩溃后可续跑 persist: function() { if (!this.activeRecordId) return; try { ExamStore.write({ version: 1, recordId: String(this.activeRecordId), savedAt: Date.now(), total: this.totalQuestions, answers: [...this.answers.entries()].map(([id, letters]) => [id, [...letters]]), stats: { requests: this.stats.requests, failedBatches: this.stats.failedBatches }, submitted: !!this.submitted }); } catch (error) { Logger.add('warning', '考试进度落盘失败', error.message || String(error)); } }, // 续跑恢复:同场次且题目 id 仍对得上才采用;题库/场次变了就忽略,重新答题 restore: function(recordId, questions) { const saved = ExamStore.read(); if (!saved || String(saved.recordId) !== String(recordId) || !Array.isArray(saved.answers)) return 0; const valid = new Set(questions.map(question => String(question.id))); let restored = 0; for (const pair of saved.answers) { if (!Array.isArray(pair) || pair.length < 2) continue; const id = String(pair[0]); const letters = Array.isArray(pair[1]) ? pair[1].map(value => String(value)) : []; if (!valid.has(id) || !letters.length) continue; this.answers.set(id, letters); restored++; } if (saved.stats) { this.stats.requests = Number(saved.stats.requests) || 0; this.stats.failedBatches = Number(saved.stats.failedBatches) || 0; } this.submitted = !!saved.submitted; return restored; }, // 「清除考试数据」:一键清掉本机保存的考试相关数据(答案 / 批次进度 / 统计读数) clearStoredData: function() { if (this.running) { utils.showMessage('正在答题,请等本轮结束后再清除', 'warning'); return false; } ExamStore.wipe(); this.answers = new Map(); this.errors = []; this.stats = { requests: 0, failedBatches: 0 }; this.netFailStreak = 0; this.aborted = false; this.abortReason = ''; this.submitted = false; this.resetSlotButtons(); this.completedBatches = 0; this.totalBatches = 0; this.totalQuestions = 0; this.runningBatches = 0; this.activeRecordId = ''; // 【2.5.18】题目全集一并丢弃,避免「重试未答出题目」拿上一场的题号 this.questionModels = []; this.adapt = { effective: clampExamConcurrency(currentConfig.examConcurrency), window: [], successStreak: 0, rateLimited: false }; this.syncSubmitUI(); this.updateStatus('考试数据已清除'); Logger.add('info', '已清除考试数据', '答案 / 批次进度 / 统计读数均已清空'); utils.showMessage('已清除本机保存的考试数据', 'info'); return true; }, findExamVm: function(recordId) { const root = document.querySelector('#app') && document.querySelector('#app').__vue__; const stack = root ? [root] : []; const seen = new Set(); while (stack.length) { const vm = stack.pop(); if (!vm || seen.has(vm)) continue; seen.add(vm); if (Array.isArray(vm.records) && String(vm.recordId || '') === String(recordId) && vm.param) return vm; for (const child of vm.$children || []) stack.push(child); } return null; }, // 【2.5.12】不依赖 recordId 找「正在进行中的考试」页面:有题目数组 + 作答状态 + 提交方法即是 // 用途:「一键完成」时用户已进考场但 startExam 请求已被闸门错过,就地起跑 findActiveExamVm: function() { const root = document.querySelector('#app') && document.querySelector('#app').__vue__; const stack = root ? [root] : []; const seen = new Set(); while (stack.length) { const vm = stack.pop(); if (!vm || seen.has(vm)) continue; seen.add(vm); if (Array.isArray(vm.records) && vm.records.length && vm.param && typeof vm.submit === 'function') return vm; for (const child of vm.$children || []) stack.push(child); } return null; }, syncDisplayedQuestion: function(vm) { const currentId = String(vm.model && vm.model.id || ''); const value = vm.param[currentId]; if (String(vm.model && vm.model.kind) === '3') { vm.options = Array.isArray(value) ? [...value] : []; vm.option = ''; } else { vm.option = typeof value === 'string' ? value : ''; vm.options = []; } }, // 【2.5.12】考试模式相关 UI 的统一同步入口:开始按钮 / 自动提交开关 / 三个参数输入框 / 提交槽位 // 「一键完成」改了配置后由它把界面一次性拉齐,避免界面与真实配置不一致 syncExamModeUI: function() { const btn = document.getElementById('exam-start-btn'); if (btn) { const on = !!currentConfig.examEnabled; btn.dataset.on = on ? '1' : '0'; btn.textContent = on ? '■ 已开始 · 点击停止' : '▶ 开始考试'; } const autoSwitch = document.getElementById('exam-auto-submit-switch'); if (autoSwitch) { autoSwitch.checked = !!currentConfig.examAutoSubmit; const track = autoSwitch.nextElementSibling; if (track && track.classList.contains('switch-track')) track.classList.toggle('on', autoSwitch.checked); } this.syncExamNumberInputs(); // 【2.5.15】计划摘要同样由当前配置派生,一并重算 —— 否则「一键完成」改了参数后这里会留旧值 this.refreshPlanUI(); this.syncSubmitUI(); }, // 三个参数输入框回填(配置被程序改写时同步显示值) syncExamNumberInputs: function() { const pairs = [ ['exam-batch-size', currentConfig.examBatchSize], ['exam-concurrency', currentConfig.examConcurrency], ['exam-timeout', currentConfig.examRequestTimeout] ]; for (const [id, value] of pairs) { const el = document.getElementById(id); if (el) el.value = value; } }, // 【2.5.12】一键完成:套用推荐预设参数 + 开启自动提交 + 开启考试模式; // 若检测到已在考场(页面已有题目),就地起跑,不必等 startExam 请求再被捕获 oneClickComplete: async function() { if (this.running) { utils.showMessage('正在答题中,无需重复点击', 'info'); return; } // 1) 预设参数:预设 === 三个默认值(单一来源),稳优先,无需用户判断 currentConfig.examBatchSize = EXAM_BATCH_SIZE_DEFAULT; currentConfig.examConcurrency = EXAM_CONCURRENCY_DEFAULT; currentConfig.examRequestTimeout = EXAM_TIMEOUT_DEFAULT; GM_setValue('examBatchSize', currentConfig.examBatchSize); GM_setValue('examConcurrency', currentConfig.examConcurrency); GM_setValue('examRequestTimeout', currentConfig.examRequestTimeout); // 2) 自动提交:一键完成的题眼 —— 跑完即交,不需要再回来点按钮 // 【2.5.21】会话态:仅本场有效,不写盘 currentConfig.examAutoSubmit = true; // 3) 考试模式总闸门 currentConfig.examEnabled = true; this.syncExamModeUI(); const presetText = `单轮 ${currentConfig.examBatchSize} 题|并发 ${currentConfig.examConcurrency} 轮|超时 ${currentConfig.examRequestTimeout} 秒`; Logger.milestone(`一键完成:已套用预设(${presetText})并开启自动提交与考试模式`); // 4) 已在考场则立即起跑;未进考场则等 startExam 请求被捕获 const vm = this.findActiveExamVm(); const recordId = vm ? String(vm.recordId || '') : ''; if (vm && recordId) { const saved = ExamStore.read(); const alreadySubmitted = (saved && String(saved.recordId) === recordId && saved.submitted) || this.submitted; if (alreadySubmitted) { utils.showMessage(`已套用预设参数并开启自动提交
检测到本场考试已提交过;如需重跑请先「清除考试数据」`, 'warning', 8000); return; } utils.showMessage(`已套用预设参数(${presetText})并开启自动提交
检测到进行中的考试,立即开始答题`, 'success', 6000); this.run(vm.records, recordId).catch(error => { this.running = false; this.errors.push(`批量答题异常:${error.message || error}`); this.updateStatus('运行失败'); }); } else { utils.showMessage(`已套用预设参数(${presetText})并开启自动提交
进入考试后将自动完成`, 'success', 6000); } // 5) 考试批处理还依赖两个全局开关,没开就明说,不静默失效 const missing = []; if (!currentConfig.autoAnswer) missing.push('「自动答题」'); if (!currentConfig.aiAnswerEnabled) missing.push('「AI 答题」'); if (missing.length) { utils.showMessage(`还需在面板开启 ${missing.join(' 和 ')},否则不会自动答题`, 'warning', 9000); } }, // 提交槽位 UI:开关常驻(关闭态也保留,随时可切回);手动态两枚按钮(填入答案 / 提交本次考试) // 同排显示、自动态一起淡出 —— 层级与显隐规则与拆分前一致 // 「无答案」不用 disabled,改成 data-empty 视觉 + data-tooltip 悬停提示 syncSubmitUI: function() { const slot = document.querySelector('#exam-submit-slot'); const label = document.querySelector('#exam-submit-label'); const fillBtn = document.querySelector('#exam-fill-btn'); const submitBtn = document.querySelector('#exam-manual-submit-btn'); if (!slot || !submitBtn) return; const auto = !!currentConfig.examAutoSubmit; if (label) label.textContent = auto ? '自动提交' : '手动提交'; slot.classList.toggle('is-auto', auto); slot.classList.toggle('is-manual', !auto); // 【2.5.20】两枚按钮的可用性判据不同,不能共用一个 has: // 填入 —— 看脚本手里有没有答案(没答案就没东西可写) // 提交 —— 看卷面上有没有答案(清过数据 / 刷新过都该能交,这才是「能不能交」的真判据) const fillHas = !!(this.answers && this.answers.size); const submitHas = this.pageFilledCount() > 0; // 【2.5.19】文案表只剩 idle / busy —— 按钮不再有「已填入 / 已提交 / 重试填入 / 重试提交」任何终态 this.renderSlotButton(fillBtn, fillHas, { idle: '填入答案', busy: '填入中…' }, { empty: '暂无已获取的答案,请先让批量答题跑一轮', idle: '把已获取的答案写进考试页面(只填不交;答案不完整也会填入)', busy: '正在把答案填入页面,请稍候' }); this.renderSlotButton(submitBtn, submitHas, { idle: '提交本次考试', busy: '提交中…' }, { empty: '卷面上还没有作答,先点「填入答案」或在页面上自己答;空卷提交会白耗一次机会', idle: '把页面上的答案提交(提交前二次确认;答案不完整会先告警)', busy: '正在提交本次考试,请勿重复点击' }); }, // 单枚槽位按钮的文案 / 灰态 / 悬停提示(has = 脚本手里是否已有答案) // 【2.5.19】只剩 idle / submitting 两态:成功与失败都不挂终态、一律回 idle,成败由 toast 说 renderSlotButton: function(btn, has, texts, tips) { if (!btn) return; const state = btn.dataset.state || 'idle'; if (state === 'submitting') { btn.dataset.empty = '0'; btn.textContent = texts.busy; btn.dataset.tooltip = tips.busy; return; } btn.dataset.empty = has ? '0' : '1'; btn.textContent = texts.idle; btn.dataset.tooltip = has ? tips.idle : tips.empty; }, // 【2.5.16】新一场考试 / 清空数据时把两枚槽位按钮复位 —— 保证不残留 busy 态 // (2.5.19 起按钮已无成功/失败终态,此复位仍必要:防上一轮卡在「填入中…/提交中…」) resetSlotButtons: function() { for (const id of ['exam-fill-btn', 'exam-manual-submit-btn']) { const btn = document.getElementById(id); if (btn) btn.dataset.state = 'idle'; } }, // 只把答案写入页面(幂等、不提交);allowPartial=true 时允许部分填写 applyAnswersToPage: async function(records, recordId, runId, allowPartial) { if (runId !== this.runId) return null; if (!allowPartial && this.answers.size !== records.length) return null; const vm = this.findExamVm(recordId); if (!vm) { this.errors.push('无法访问考试页面答案状态'); this.updateStatus('填写失败'); return null; } let filled = 0; for (const record of records) { const id = String(record.id); const letters = this.answers.get(id); if (!letters || !letters.length) { if (allowPartial) continue; return null; } const value = String(record.kind) === '3' ? [...letters] : letters[0]; if (typeof vm.$set === 'function') vm.$set(vm.param, id, value); else vm.param[id] = value; filled++; } this.syncDisplayedQuestion(vm); if (typeof vm.$nextTick === 'function') await new Promise(resolve => vm.$nextTick(resolve)); this.updateStatus(`答案已填入页面 ${filled}/${records.length}`); return vm; }, // 提交:跳到末题 + 触发页面提交 + 等二次确认 commitExam: async function(records, vm) { if (!vm || typeof vm.submit !== 'function') { this.errors.push('无法访问考试页面答案状态'); this.updateStatus('等待手动提交'); // 【2.5.19】按钮不再留失败态,失败只靠 toast 说话 —— 这条分支原先只改状态条,补上 toast 免丢信息 utils.showMessage('无法访问考试提交按钮,请在页面上手动提交', 'warning', 8000); return false; } if (records && records.length) { const last = records[records.length - 1]; vm.index = records.length - 1; vm.seria = `第${records.length}题`; vm.model = Object.assign({}, last); vm.model.kind_text = String(last.kind) === '1' ? '判断题' : String(last.kind) === '3' ? '多选题' : '单选题'; this.syncDisplayedQuestion(vm); if (typeof vm.$nextTick === 'function') await new Promise(resolve => vm.$nextTick(resolve)); } const confirmation = ExamProcessor.confirmExamSubmission(); vm.submit(); const confirmed = await confirmation; this.submitted = !!confirmed; this.persist(); this.updateStatus(confirmed ? '已确认提交,等待评分结果' : '二次确认失败,请手动提交'); if (!confirmed) utils.showMessage('提交二次确认未完成,请在页面上手动确认', 'warning'); return confirmed; }, // 缺失题号文本(先带原始序号再过滤 —— 不能 filter 后再取 index,会错位成过滤后的下标) missingSeqText: function(records) { const missingSeqs = records .map((record, index) => ({ record: record, seq: index + 1 })) .filter(item => !this.answers.has(String(item.record.id))) .map(item => item.seq); return this.summarizeSeqs(missingSeqs); }, // 【2.5.16】「提交本次考试」入口:纯提交(不再顺带填入答案 —— 填入由另一枚按钮负责) // 底部拦截:页面上一个答案都没有时不提交,避免白耗一次考试机会 manualSubmit: async function() { // 【2.5.18】本场已提交过就不再交(原先靠按钮的「已提交」终态拦;按钮不再留态,改在入口拦) if (this.submitted) { Logger.add('warning', '提交被拦截:本场考试已提交过'); utils.showMessage('本场考试已提交过,已拦截重复提交
如需重跑请先「清除考试数据」', 'warning', 8000); this.updateStatus('提交已拦截:本场已提交过'); return 'blocked'; } // 【2.5.20】不再依赖 activeRecordId / lastRecords(清过数据它们就是空的,会把提交整个废掉): // 找不到内存记录时从页面上反查考试实例,判据一律以「卷面上有没有答案」为准 const ctx = this.resolveExamContext(); const vm = ctx.vm; const records = ctx.records; if (!vm) { utils.showMessage('无法访问考试页面,请保持在考试页面后重试', 'warning'); this.updateStatus('提交失败:页面不可访问'); return false; } const filled = this.countFilledOnPage(vm, records.length ? records : null); if (!filled) { Logger.add('warning', '提交被拦截:页面上没有任何答案'); utils.showMessage('页面上还没有答案,已拦截本次提交
请先点「填入答案」,或自行在页面上作答后再提交', 'warning', 8000); this.updateStatus('提交已拦截:页面无答案'); return 'blocked'; } // 完整性只在「脚本手里确有答案清单」时才谈 —— 清过数据后 answers 为空, // 若照旧比对会误报「答案不完整 0/100」,而卷面其实是满的 if (this.answers.size && records.length && this.answers.size !== records.length) { Logger.milestone(`提交:答案不完整 ${this.answers.size}/${records.length}(页面已填 ${filled})`); utils.showMessage(`⚠️ 按手动意愿提交(答案不完整 ${this.answers.size}/${records.length})
缺失题号:${this.missingSeqText(records)}`, 'warning', 8000); } else if (records.length) { Logger.milestone(`提交:答案完整,页面已填 ${filled}/${records.length}`); } else { Logger.milestone(`提交:页面已填 ${filled} 题(题目清单不在手上,以卷面为准)`); } return await this.commitExam(records, vm); }, // 【2.5.16】「填入答案」入口:只把已得答案写进页面,不提交 manualFill: async function() { if (!this.answers || !this.answers.size) { utils.showMessage('暂无已获取的答案,请先让批量答题跑一轮', 'warning'); return false; } // 【2.5.20】同上:内存记录缺失时从页面反查,别让「清过数据」把填入也废掉 const ctx = this.resolveExamContext(); const records = ctx.records; const recordId = ctx.recordId; if (!records.length || !recordId) { utils.showMessage('尚无可填入的考试数据,请先让批量答题跑一轮', 'warning'); return false; } const vm = await this.applyAnswersToPage(records, recordId, this.runId, true); if (!vm) { utils.showMessage('无法访问考试页面,请保持在考试页面后重试', 'warning'); this.updateStatus('填入失败:页面不可访问'); return false; } const filled = this.countFilledOnPage(vm, records); if (this.answers.size !== records.length) { Logger.milestone(`填入答案:不完整 ${this.answers.size}/${records.length}(页面已填 ${filled})`); utils.showMessage(`已填入已得的答案 ${this.answers.size}/${records.length}
缺失题号:${this.missingSeqText(records)}`, 'warning', 8000); } else { Logger.milestone(`填入答案:完整 ${this.answers.size}/${records.length},未提交`); } return true; }, // 数一下页面上真的有答案的题数(提交前的拦截依据:看页面真实状态,不看脚本记忆) // 【2.5.20】records 允许缺省 —— 清除过数据 / 刷新过之后题目清单可能不在手上, // 此时直接数 param 里的非空项;判据始终是「卷面上有没有答案」,与脚本内存无关 countFilledOnPage: function(vm, records) { if (!vm || !vm.param) return 0; const isNonEmpty = value => Array.isArray(value) ? value.length > 0 : (value !== undefined && value !== null && String(value) !== ''); let n = 0; if (Array.isArray(records) && records.length) { for (const record of records) { if (isNonEmpty(vm.param[String(record.id)])) n++; } return n; } for (const key of Object.keys(vm.param)) { if (isNonEmpty(vm.param[key])) n++; } return n; }, // 【2.5.20】解析当前考试上下文:优先用脚本内存;内存被清过(activeRecordId 为空)时 // 直接从页面上反查正在进行的考试实例 —— 这样「清除考试数据」不会让提交功能失效 resolveExamContext: function() { let records = this.lastRecords || []; let recordId = this.activeRecordId ? String(this.activeRecordId) : ''; let vm = recordId ? this.findExamVm(recordId) : null; if (!vm) { const active = this.findActiveExamVm(); if (active) { vm = active; if (!records.length && Array.isArray(active.records)) records = active.records; if (!recordId) recordId = String(active.recordId || ''); } } return { vm: vm, records: records, recordId: recordId }; }, // 【2.5.20】卷面上已作答的题数(找不到考试页面时为 0) // 「提交本次考试」的可用性判据用它 —— 只要卷子有答案就该能交,哪怕脚本手里是空的 pageFilledCount: function() { const active = this.findActiveExamVm(); if (!active) return 0; return this.countFilledOnPage(active, Array.isArray(active.records) && active.records.length ? active.records : null); }, fillAnswers: async function(records, recordId, runId) { const vm = await this.applyAnswersToPage(records, recordId, runId, false); if (runId !== this.runId) return false; if (!vm) return false; if (!currentConfig.examAutoSubmit) { Logger.milestone(`自动提交已关闭:答案已填入页面 ${this.answers.size}/${records.length},等待手动提交`); this.updateStatus(`答案已填入页面 ${this.answers.size}/${records.length}\n自动提交已关闭 → 请点击面板「手动提交」`); this.syncSubmitUI(); return true; } return await this.commitExam(records, vm); }, // 把缺失题号压成区间文本:61,62,...,70 -> 61-70 summarizeSeqs: function(seqs) { if (!seqs.length) return '无'; const sorted = [...seqs].sort((a, b) => a - b); const parts = []; let start = sorted[0]; let prev = sorted[0]; for (let i = 1; i <= sorted.length; i++) { const current = sorted[i]; if (current === prev + 1) { prev = current; continue; } parts.push(start === prev ? String(start) : `${start}-${prev}`); start = current; prev = current; } return parts.join('、'); }, // 【2.5.18】手动重试:只把「上一轮没答出」的题重新交给 AI 跑一遍 // 与「重跑整场」的区别:已答出的题不再请求(不重复扣费),已得答案与统计读数都保留 retryMissing: async function() { if (this.running) { utils.showMessage('正在答题,请等本轮结束后再重试', 'warning'); return false; } if (this.submitted) { utils.showMessage('本场考试已提交过,无需重试
如需重跑请先「清除考试数据」', 'warning', 8000); return false; } const records = this.lastRecords || []; const recordId = this.activeRecordId; if (!records.length || !recordId) { utils.showMessage('尚无可重试的考试数据,请先让批量答题跑一轮', 'warning'); return false; } const allQuestions = this.questionModels && this.questionModels.length ? this.questionModels : records.map((record, index) => this.normalizeQuestion(record, index)); const missing = allQuestions.filter(question => !this.answers.has(String(question.id))); if (!missing.length) { utils.showMessage('全部题目都已答出,无需重试', 'info'); return false; } const missingText = this.summarizeSeqs(missing.map(question => question.seq)); Logger.milestone(`手动重试未答出题目 ${missing.length}/${allQuestions.length},题号:${missingText}`); utils.showMessage(`重新请求 ${missing.length} 道未答出的题目
题号:${missingText}`, 'info', 6000); this.run(records, recordId, { append: true }).catch(error => { this.running = false; this.errors.push(`重试未答出题目异常:${error.message || error}`); this.updateStatus('重试失败'); }); return true; }, // 【2.5.18】options.append = 重试模式:保留已得答案 / 统计 / 已提交标记,只把「未答出」的题再跑一遍 run: async function(records, recordId, options) { const append = !!(options && options.append); const runId = ++this.runId; this.running = true; this.activeRecordId = String(recordId); if (!append) { this.lastRecords = records; this.answers = new Map(); this.errors = []; // 【2.5.7】每轮开始重置统计与调度状态(续跑恢复会随后覆盖统计) this.stats = { requests: 0, failedBatches: 0 }; this.submitted = false; // 【2.5.16】新一场考试:槽位按钮状态一并复位,否则上一场遗留的态会一直挂着 this.resetSlotButtons(); } this.netFailStreak = 0; this.aborted = false; this.abortReason = ''; this.nextTokenAt = 0; this.syncSubmitUI(); // 本轮进度计数每轮从零起(重试轮也一样) this.completedBatches = 0; this.runningBatches = 0; // 本轮运行的参数在开始时定格:中途拖动滑块不会打乱正在跑的批次划分 const batchSize = clampExamBatchSize(currentConfig.examBatchSize); const concurrency = clampExamConcurrency(currentConfig.examConcurrency); // 题目全集(本场不变量):seq 按原始下标定格,重试子集沿用同一套序号,缺失题号才不会错位 const allQuestions = append && this.questionModels && this.questionModels.length ? this.questionModels : records.map((record, index) => this.normalizeQuestion(record, index)); this.questionModels = allQuestions; this.totalQuestions = allQuestions.length; // 正常模式跑全集;重试模式只跑「答案表里还没有」的那部分(已答出的不重复请求) const questions = append ? allQuestions.filter(question => !this.answers.has(String(question.id))) : allQuestions; this.refreshPlanUI(); const invalid = questions.filter(question => !question.id || !question.question || !question.options.length); if (invalid.length) { this.errors.push(`${invalid.length} 道题缺少题干或选项`); this.running = false; this.updateStatus('题目解析失败'); return; } // 【2.5.7】续跑:同场次已有落盘答案则恢复;已拿全答案的批次随后会被跳过(不重复扣费) const restored = this.restore(String(recordId), allQuestions); if (restored) { Logger.add('info', '考试续跑', `已从本地恢复 ${restored} 条答案,已完成的批次将跳过`); } // 【2.5.18】恢复出的「已提交」不再挂到按钮上(按钮无终态);重复提交由 manualSubmit 入口拦截 this.syncSubmitUI(); const ready = await AuthService.ensureReady(); if (!ready.ok || runId !== this.runId) { if (!ready.ok) this.errors.push(ready.error); this.running = false; this.updateStatus('授权校验失败'); return; } const batches = []; for (let index = 0; index < questions.length; index += batchSize) { batches.push(questions.slice(index, index + batchSize)); } this.totalBatches = batches.length; this.adapt = { effective: concurrency, window: [], successStreak: 0, rateLimited: false }; this.updateStatus(`已捕获 ${questions.length} 题,AI 答题中${restored ? `(续跑,已恢复 ${restored} 条)` : ''}`); let cursor = 0; const pool = { active: 0 }; // 许可闸门:worker 数固定为滑块上限,但同一时刻真正出站的批数由 adapt.effective 放行 const acquire = async () => { while (runId === this.runId && !this.aborted && pool.active >= this.adapt.effective) { await new Promise(resolve => setTimeout(resolve, 60)); } if (runId !== this.runId || this.aborted) return false; pool.active++; return true; }; const worker = async () => { while (runId === this.runId && !this.aborted) { const batchIndex = cursor++; if (batchIndex >= batches.length) return; const batch = batches[batchIndex]; // 续跑:整批已拿到答案 ⇒ 跳过,不再请求(省扣费) if (this.isBatchSatisfied(batch)) { this.completedBatches++; this.updateStatus(`第 ${batchIndex + 1} 批已有答案,跳过`); continue; } const allowed = await acquire(); if (!allowed) return; this.runningBatches++; this.updateStatus(`处理第 ${batchIndex + 1} 批`); try { const outcome = await this.answerBatch(batch, runId); if (runId !== this.runId) return; for (const [id, answer] of outcome.answers) this.answers.set(id, answer); this.recordBatchOutcome(batch, outcome); this.persist(); } catch (error) { this.errors.push(`第 ${batchIndex + 1} 批异常:${error.message || error}`); this.recordBatchOutcome(batch, { answers: new Map(), failCode: 'EXCEPTION' }); this.persist(); } finally { pool.active--; this.runningBatches--; this.completedBatches++; this.updateStatus(`第 ${batchIndex + 1} 批完成`); } } }; await Promise.all(Array.from({ length: Math.min(concurrency, batches.length) }, worker)); if (runId !== this.runId) return; this.running = false; this.persist(); this.syncSubmitUI(); if (this.aborted) { this.updateStatus(this.abortReason); return; } // 【2.5.18】完整性一律按「本场全集」判定 —— 重试轮只跑了缺失子集,不能用子集长度比 if (this.answers.size !== allQuestions.length) { const missingSeqs = allQuestions.filter(question => !this.answers.has(question.id)).map(question => question.seq); const missingText = this.summarizeSeqs(missingSeqs); this.updateStatus(`答案不完整 ${this.answers.size}/${allQuestions.length},已停止自动提交\n缺失题号:${missingText}`); Logger.add('error', `考试答案不完整 ${this.answers.size}/${allQuestions.length},缺失题号:${missingText}`); utils.showMessage(`⚠️ 考试答案不完整(${this.answers.size}/${allQuestions.length}),已停止提交
缺失题号:${missingText}
可点「重试未答出题目」再试,或先调大「等待超时」`, 'warning', 9000); return; } // 填入用整场的原始题面(重试轮传子集会让 applyAnswersToPage 的完整性校验对不上) await this.fillAnswers(this.lastRecords || records, recordId, runId); this.syncSubmitUI(); }, captureStartExam: function(data) { this.pending = false; const result = data && data.success && data.result; const records = result && result.records; if (!this.available() || !Array.isArray(records) || !records.length || !result.id) { if (!this.activeRecordId) this.updateStatus('等待考试开始'); return; } if (this.activeRecordId === String(result.id)) return; this.run(records, String(result.id)).catch(error => { this.running = false; this.errors.push(`批量答题异常:${error.message || error}`); this.updateStatus('运行失败'); }); }, boot: function() { if (this.hookInstalled) return; this.hookInstalled = true; const self = this; const originalOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function(method, url) { this.__examBatchUrl = String(url || ''); if (this.__examBatchUrl.includes('/jcedutec/exam/startExam')) { self.pending = true; self.updateStatus('等待考试题目'); } return originalOpen.apply(this, arguments); }; const originalSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function() { if (this.__examBatchUrl && this.__examBatchUrl.includes('/jcedutec/exam/startExam')) { this.addEventListener('load', () => { let data = this.response; if (typeof data === 'string') { try { data = JSON.parse(data); } catch (error) { data = null; } } self.captureStartExam(data); }, { once: true }); this.addEventListener('error', () => { self.pending = false; self.updateStatus('考试题目请求失败'); }, { once: true }); } return originalSend.apply(this, arguments); }; const originalFetch = window.fetch; if (typeof originalFetch === 'function') { window.fetch = async function(input, init) { const url = typeof input === 'string' ? input : input && input.url || ''; if (String(url).includes('/jcedutec/exam/startExam')) { self.pending = true; self.updateStatus('等待考试题目'); } const response = await originalFetch.apply(this, arguments); if (String(url).includes('/jcedutec/exam/startExam')) { response.clone().json().then(data => self.captureStartExam(data)).catch(() => { self.pending = false; self.updateStatus('考试题目响应解析失败'); }); } return response; }; } if ((location.hostname === '127.0.0.1' || location.protocol === 'file:') && !window.__labSafetyExamMockInstalled) { window.__labSafetyExamMockInstalled = true; window.addEventListener('lab-safety:mock-exam', event => this.captureStartExam(event.detail)); } this.updateStatus('等待考试开始'); } }; // 只暴露诊断快照,不暴露答案内容或内部可变对象。 window.__labSafetyExamBatchDebug = () => ({ pending: ExamBatchProcessor.pending, running: ExamBatchProcessor.running, activeRecordId: ExamBatchProcessor.activeRecordId, completedBatches: ExamBatchProcessor.completedBatches, totalBatches: ExamBatchProcessor.totalBatches, totalQuestions: ExamBatchProcessor.totalQuestions, questionModels: ExamBatchProcessor.questionModels.length, submitted: ExamBatchProcessor.submitted, runningBatches: ExamBatchProcessor.runningBatches, answerCount: ExamBatchProcessor.answers.size, errors: [...ExamBatchProcessor.errors] }); const VideoController = { setSpeed: function(speed) { const video = document.querySelector('video') || document.querySelector('#cc_F7B76307B77D90F213358DE27D943A99'); if (video) { video.playbackRate = speed; utils.log(`⚡ 倍速已切换为 ${speed}x`); } }, initVideoSpeed: function() { setTimeout(() => this.setSpeed(currentConfig.videoSpeed), 1500); }, // ========== 不许暂停:视频未结束时自动恢复播放 ========== noPauseTimer: null, guardianVideo: null, startNoPauseGuard: function() { this.stopNoPauseGuard(); const check = () => { if (!currentConfig.noPause) return; this.guardianVideo = document.querySelector('video') || this.guardianVideo; const v = this.guardianVideo; if (v) { if (v.paused && !v.ended) v.play().catch(() => {}); v.playbackRate = currentConfig.videoSpeed; } }; check(); this.noPauseTimer = setInterval(check, 500); }, stopNoPauseGuard: function() { if (this.noPauseTimer) { clearInterval(this.noPauseTimer); this.noPauseTimer = null; } this.guardianVideo = null; }, // ========== 视频自动播放与切换(集成自实安课堂脚本) ========== // ⚠️ 课程树是三层:根「机电类」→ 章节 → 节(课)。 // 旧实现用 `.ant-tree-child-tree li` 取"课程列表",实际拿到的是章节与已展开章节的节 // 混在一起的扁平表,导致章末判断要等到整棵树最后一个节点才成立 —— // 表现为刷完一章还继续切了两门课。现在一律以「选中项所在章节的节列表」为准, // 跨章节/全树由 expandChapters(展开课程树)开关决定。 _allDone: false, // 收工锁:置 true 后不再响应视频结束事件 _depthOf: function(li) { let d = 0, n = li; while (n && n.tagName === 'LI') { d++; const ul = n.parentElement; n = ul && ul.closest('li'); } return d; }, // 当前选中的节点:取最深的 `.ant-tree-node-content-wrapper.ant-tree-node-selected` // (内容层类最准;li 级的 ant-tree-treenode-selected 会把父节点也算上) _selectedNode: function() { const wrappers = [...document.querySelectorAll('.ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected')]; let best = null, bestDepth = -1; wrappers.forEach((w) => { const li = w.closest('li'); if (!li) return; const d = this._depthOf(li); if (d > bestDepth) { bestDepth = d; best = li; } }); if (best) return best; const fallback = [...document.querySelectorAll('.ant-tree li')].filter(x => x.classList.contains('ant-tree-treenode-selected')); if (!fallback.length) return null; return fallback.reduce((a, b) => (this._depthOf(a) >= this._depthOf(b) ? a : b)); }, // 某个 li 的直接子 li(antd 的子节点容器是 li 内的 ul) _childLIs: function(li) { if (!li) return []; let ul = li.querySelector(':scope > ul'); if (!ul) { const cand = li.querySelector('ul'); if (cand && cand.closest('li') === li) ul = cand; } return ul ? [...ul.children].filter(el => el.tagName === 'LI') : []; }, // 节点所属章节(节 → 章节 li);本身就是章节时返回 null _chapterOf: function(node) { const ul = node && node.parentElement; const ch = ul && ul.tagName === 'UL' ? ul.parentElement.closest('li') : null; return ch || null; }, // 当前章节的节列表(只取直接子 li,绝不把章节/根节点混进来) _getChapterLessons: function(node) { const cur = node || this._selectedNode(); if (!cur) return []; const ch = this._chapterOf(cur); if (ch) return this._childLIs(ch); const kids = this._childLIs(cur); return kids.length ? kids : [cur]; }, // 所有已展开章节的节 _getAllLessons: function() { const root = document.querySelector('.ant-tree > li'); if (!root) return []; const chapters = this._childLIs(root); if (!chapters.length) return [root]; const out = []; chapters.forEach((ch) => { const kids = this._childLIs(ch); if (kids.length) out.push(...kids); else out.push(ch); // 章节未展开:以章节自身占位,点击它会展开 }); return out; }, // 兼容旧调用 _getChildCourses: function() { return this._getAllLessons(); }, getCurrentCourseIndex: function() { const cur = this._selectedNode(); if (!cur) return -1; return this._getChapterLessons(cur).indexOf(cur); }, // 当前范围内的"下一门课":范围由「展开课程树」开关决定 // 开 → 全树,绕满一圈回到自己 = 全部完成; // 关 → 只在本章内走,章末返回 null = 本章刷完,该收工。 getNextCourse: function() { const cur = this._selectedNode(); if (!cur) return null; if (currentConfig.expandChapters) { const all = this._getAllLessons(); const idx = all.indexOf(cur); if (idx === -1 || all.length < 2) return null; return all[(idx + 1) % all.length]; } const lessons = this._getChapterLessons(cur); const idx = lessons.indexOf(cur); if (idx === -1 || idx >= lessons.length - 1) return null; return lessons[idx + 1]; }, // 复习模式收工提示:范围口径同 getNextCourse(关=只刷当前章节) _notifyReviewDone: function() { if (!currentConfig.expandChapters) { this._allDone = true; // 只刷本章:上收工锁,视频再结束也不自动切 const n = this._getChapterLessons().length; Logger.milestone(`复习模式: 本章 ${n} 节已完成 ✅`); utils.showBigNotice('🎉 当前章节已刷完!', `本章 ${n} 节均已学完、无错题、题目答满;展开课程树可继续复习其他章节`, 'success'); return; } const all = this._getAllLessons(); Logger.milestone('复习模式: 全部完成 ✅'); utils.showBigNotice('🎉 复习模式:全部完成!', `课程树 ${all.length} 节均已学完、无错题、题目答满,可关闭页面`, 'success'); }, // 切到指定节:点标题 → 等视频 → 设倍速并起播 → 绑结束监听 _gotoNode: async function(li) { if (!li) return false; const link = li.querySelector('.ant-tree-node-content-wrapper'); if (!link) return false; const title = link.getAttribute('title') || link.textContent || '未知课程'; if (currentConfig.reviewMode) VideoApiAnswer.clearReviewCourse(); link.click(); utils.log(`切换到课程:${title}`); await utils.wait(currentConfig.videoLoadDelay); if (currentConfig.reviewMode) { await this.reviewCheck(); return true; } const video = document.querySelector('video'); if (video) { video.playbackRate = currentConfig.videoSpeed; setTimeout(() => { video.play().then(() => utils.log('视频开始自动播放')).catch(() => video.click()); }, 500); this.attachVideoEndListener(video); } return true; }, // 下一章节的第一节(章节未展开时先点开);没有下一章节返回 false _gotoNextChapterFirst: async function(chapterLi) { if (!chapterLi) return false; let ch = chapterLi.nextElementSibling; while (ch && ch.tagName !== 'LI') ch = ch.nextElementSibling; if (!ch) return false; let kids = this._childLIs(ch); if (!kids.length) { // 未展开:点章节把它展开(switcher 更稳,不触发"选中章节") const sw = ch.querySelector('.ant-tree-switcher'); (sw || ch.querySelector('.ant-tree-node-content-wrapper')).click(); await utils.wait(Math.max(currentConfig.chapterExpandDelay, 800)); kids = this._childLIs(ch); } if (!kids.length) { // 章节下确实没有节 → 当作该章已完成,继续往后找 return this._gotoNextChapterFirst(ch); } return this._gotoNode(kids[0]); }, // 复习模式:循环检测当前课程的判断条件(学完+无错+全答), // 满足则切到下一门继续检测(每切一次树顶统计实时刷新,无预判), // 不满足则停留当前课开始复习;绕满一圈则全部完成 reviewCheck: async function() { if (!currentConfig.reviewMode) return; VideoApiAnswer.clearReviewCourse(); try { // 等待章节树加载/展开完成,确保能拿到课程列表与树顶统计 await utils.wait(currentConfig.chapterExpandDelay + 300); // 范围:展开课程树=开 → 全树;关 → 只检当前章节(与 getNextCourse 同口径) const scope = currentConfig.expandChapters ? this._getAllLessons() : this._getChapterLessons(); const total = scope.length; if (total === 0) { utils.log('复习模式:未获取到课程列表'); return; } for (let step = 0; step < total; step++) { // 中途关闭复习模式 → 立即停止跳过/切换 if (!currentConfig.reviewMode) { utils.log('复习模式已关闭,停止跳过'); return; } const current = this._selectedNode(); const courseName = (current && current.textContent.replace(/\s+/g, ' ').trim().substring(0, 20)) || '未知课程'; const isLearned = !!(current && current.textContent.includes('(学完)')); // 树顶统计实时跟随当前选中课程,每切换一次就刷新一次 const stat = ReviewCollector.getCurrentChapterStatus(); const done = isLearned && (stat.wrong === 0) && (stat.answered === stat.totalQ) && (stat.totalQ > 0); const onlyQuestionRequirements = isLearned && stat.totalQ > 0 && (stat.wrong > 0 || stat.answered !== stat.totalQ); if (!done) { // 当前课程需复习 → 停留,开始播放;并具体说明不满足的原因 const reasons = []; if (!isLearned) reasons.push('未学完'); if (stat.wrong > 0) reasons.push(`答错 ${stat.wrong} 题`); if (stat.answered !== stat.totalQ) reasons.push(`未答完(${stat.answered}/${stat.totalQ})`); if (stat.totalQ === 0) reasons.push('统计读取异常(总题数=0)'); const reasonText = reasons.length ? `(${reasons.join(' · ')})` : ''; const msg = `📖 当前课程需复习,停留开始复习 ${reasonText}`; Logger.milestone(`复习模式:${msg}|${courseName}`); utils.showMessage(msg, 'warning'); VideoApiAnswer.startReviewCourse(onlyQuestionRequirements); const video = document.querySelector('video'); if (video) { video.playbackRate = currentConfig.videoSpeed; video.play().then(() => utils.log('复习视频开始自动播放')).catch(() => video.click()); this.attachVideoEndListener(video); } return; } // 当前课程已满足条件 → 跳过,切到下一门继续检测 const msg = `✅ 当前课程已满足条件(已学完 · 无错题 · 已答 ${stat.answered}/${stat.totalQ})→ 跳到下一节`; Logger.milestone(`复习模式:${msg}|${courseName}`); utils.showMessage(msg, 'success'); const next = this.getNextCourse(); if (!next || next === current) { // 只刷本章:章末 → 全屏提示并停;全树:绕满一圈 → 全部完成 this._notifyReviewDone(); return; } const courseLink = next.querySelector('.ant-tree-node-content-wrapper'); if (courseLink) courseLink.click(); await utils.wait(currentConfig.videoLoadDelay); } this._notifyReviewDone(); } catch (err) { utils.log('❌ 复习模式检测失败:', err); } }, switchToNextCourse: async function(force) { if (!currentConfig.autoSwitchVideo && !force) return; if (this._allDone) { utils.log('已全部完成,忽略本次切换'); return; } const cur = this._selectedNode(); if (!cur) { utils.log('未定位到当前课程,停止切换'); return; } const lessons = this._getChapterLessons(cur); const idx = lessons.indexOf(cur); if (idx === -1) { utils.log('当前节点不在本章节课程列表内,停止切换'); return; } // ① 本章还有下一节 → 直接切 if (idx < lessons.length - 1) { await this._gotoNode(lessons[idx + 1]); return; } // ② 本章最后一节播完:只刷当前章节时,这里就是终点 if (!currentConfig.expandChapters) { this._allDone = true; utils.log(`🎉 本章 ${lessons.length} 节已走完,停止自动切换`); utils.showBigNotice('🎉 当前章节已刷完!', `本章 ${lessons.length} 节已全部播完,可展开其他章节继续刷(多窗口并行刷课)`, 'success'); return; } // ③ 展开全部章节 → 接下一章第一节;没有下一章即为整棵树走完 const chapterLi = this._chapterOf(cur); const moved = await this._gotoNextChapterFirst(chapterLi); if (!moved) { const all = this._getAllLessons(); this._allDone = true; utils.log(`🎉 课程树 ${all.length} 节已走完,停止自动切换`); utils.showBigNotice('🎉 全部课程已刷完!', `课程树 ${all.length} 节已全部播完,可关闭页面`, 'success'); } }, attachVideoEndListener: function(video) { if (!video || videoEndListenerAttached) return; videoEndListenerAttached = true; video.addEventListener('ended', () => { utils.log('当前视频播放完成,准备切换下一个'); videoEndListenerAttached = false; VideoController.switchToNextCourse(); }, { once: true }); }, initAutoPlay: async function() { if (!currentConfig.autoPlayVideo) return; this._allDone = false; // 重新开跑:清掉上一次的"全部完成"锁 try { // 复习模式:打开时检测一次判断条件,满足则直接切换,否则停留当前课开始复习 if (currentConfig.reviewMode) { await this.reviewCheck(); return; } const video = document.querySelector('video'); if (video) { video.playbackRate = currentConfig.videoSpeed; video.play().then(() => utils.log('初始视频开始自动播放')).catch(() => video.click()); this.attachVideoEndListener(video); utils.log('📹 视频自动播放/切换功能初始化完成'); } } catch (err) { utils.log('❌ 视频功能初始化失败:', err); } } }; // ============================ 章节自动展开模块 ============================ const ChapterExpander = { PARENT_SELECTOR: '.ant-tree > .ant-tree-treenode-switcher-close', SWITCHER_SELECTOR: '.ant-tree-switcher', PROCESSED_CLASS: 'expanded-by-script', expandParentNodes: async function() { if (!currentConfig.expandChapters || chapterExpandPending) return; chapterExpandPending = true; try { await utils.wait(800); // 等待页面渲染 const parentNodes = document.querySelectorAll(this.PARENT_SELECTOR); if (parentNodes.length === 0) { utils.log('📌 无未展开的一级父章节'); return; } for (let node of parentNodes) { if (node.classList.contains(this.PROCESSED_CLASS)) continue; const switcher = node.querySelector(this.SWITCHER_SELECTOR); if (switcher && !node.classList.contains('ant-tree-treenode-switcher-open')) { switcher.click(); node.classList.add(this.PROCESSED_CLASS); const chapterName = node.querySelector('.ant-tree-title')?.textContent || '未知父章节'; utils.log(`✅ 展开一级父章节:${chapterName}`); await utils.wait(currentConfig.chapterExpandDelay); } } utils.log('🎉 所有一级父章节已展开完成'); } catch (error) { utils.log('❌ 父章节展开失败:', error.message); } finally { chapterExpandPending = false; } }, startChapterObserver: function() { if (chapterObserver || !currentConfig.expandChapters) return; chapterObserver = new MutationObserver(() => { if (!currentConfig.expandChapters) return; ChapterExpander.expandParentNodes(); }); chapterObserver.observe(document.body, { childList: true, subtree: true }); }, stopChapterObserver: function() { if (chapterObserver) { chapterObserver.disconnect(); chapterObserver = null; } } }; // ============================ 复习模式:错题采集 + 智能跳过 ============================ const ReviewCollector = { // 解析 myCourse 页面章节数据(真实DOM:div.right > div.title + p 统计) scanChapterCards: function() { const cards = []; const rightDivs = document.querySelectorAll('div.right'); for (const div of rightDivs) { const titleEl = div.querySelector('.title'); if (!titleEl) continue; const chapter = titleEl.textContent.trim(); let done = 0, total = 0, answered = 0, totalQ = 0, wrong = 0; const ps = div.querySelectorAll('p'); for (const p of ps) { const t = p.textContent.trim(); const doneMatch = t.match(/已完成\/总课程数[::](\d+)\/(\d+)/); if (doneMatch) { done = parseInt(doneMatch[1]); total = parseInt(doneMatch[2]); continue; } const qMatch = t.match(/已答题\/总习题数[::](\d+)\/(\d+)/); if (qMatch) { answered = parseInt(qMatch[1]); totalQ = parseInt(qMatch[2]); continue; } const wMatch = t.match(/答错题数[::](\d+)/); if (wMatch) wrong = parseInt(wMatch[1]); } cards.push({ chapter, done, total, answered, totalQ, wrong, element: div }); } return cards; }, // 判断章节是否已完成 isChapterDone: function(card) { return card.done === card.total && card.answered === card.totalQ && card.wrong === 0; }, // 采集单个章节的错题正确答案(点击卡片内"查看"→ 弹窗逐题抓取) collectChapterAnswers: async function(card) { const answers = {}; // 卡片内找 查看 const viewLink = card.element.querySelector('a'); if (!viewLink || viewLink.textContent.trim() !== '查看') { utils.log('章节无查看链接:', card.chapter); return answers; } // 确保没有残留弹窗遮挡 const existingModal = document.querySelector('.ant-modal-wrap'); if (existingModal) { const closeBtn = existingModal.querySelector('.ant-modal-close'); if (closeBtn) closeBtn.click(); await utils.wait(400); } viewLink.click(); await utils.wait(1000); const modal = document.querySelector('.ant-modal-wrap'); if (!modal || !modal.textContent.includes('错题详情')) { utils.log('错题弹窗未打开:', card.chapter); return answers; } // 总题数 let maxPages = 1; const totalMatch = modal.textContent.match(/共(\d+)题/); if (totalMatch) maxPages = parseInt(totalMatch[1]); utils.log('采集章节:', card.chapter, '总错题数:', maxPages); // 逐题抓取 for (let i = 0; i < maxPages; i++) { // 提取题目 + 正确答案(存实际选项文本,不存字母) const stemEl = modal.querySelector('span.stem'); const questionText = stemEl ? stemEl.textContent.replace(/\s+/g, ' ').trim() : ''; // 正确答案字母 let correctLetters = ''; const answerValueEl = [...modal.querySelectorAll('span')].find(s => s.classList.contains('fw600') && s.classList.contains('blue')); if (answerValueEl) correctLetters = answerValueEl.textContent.replace(/\s+/g, '').trim(); if (!correctLetters) { const answerEl = [...modal.querySelectorAll('span')].find(s => s.textContent.includes('正确答案:')); if (answerEl) correctLetters = answerEl.textContent.replace('正确答案:', '').replace(/\s+/g, '').trim(); } // 解析选项:找到 .option-item 或 A:xxx 元素,建立字母→文本映射 const optionMap = {}; const optionEls = [...modal.querySelectorAll('.option-item')]; if (optionEls.length === 0) { // 兜底:找以 A:开头的元素 optionEls.push(...[...modal.querySelectorAll('div, span')].filter(el => { const t = el.textContent.trim(); return /^[A-D][::]/.test(t) && el.querySelectorAll('*').length < 5; })); } for (const el of optionEls) { const t = el.textContent.replace(/\s+/g, ' ').trim(); const m = t.match(/^([A-D])[::]\s*(.+)/); if (m && m[2]) optionMap[m[1]] = m[2].trim(); } // 字母转实际文本 if (questionText && correctLetters && Object.keys(optionMap).length > 0) { const letters = correctLetters.split(/[,,\s]+/).filter(l => optionMap[l]); const answerTexts = letters.map(l => optionMap[l]); answers[questionText] = JSON.stringify(answerTexts); // 存JSON数组 utils.log(' ✅', questionText.substring(0, 30) + '...', '→', answerTexts.join(', ')); } // 点"下一题"(最后一题不点) const nextBtn = [...modal.querySelectorAll('button')].find(b => b.textContent.trim() === '下一题'); if (i < maxPages - 1 && nextBtn && !nextBtn.disabled) { nextBtn.click(); await utils.wait(600); } } // 关闭弹窗 const closeBtn = modal.querySelector('.ant-modal-close'); if (closeBtn) closeBtn.click(); await utils.wait(400); return answers; }, // 总入口:扫描 myCourse → 逐章采集错题 → 存入 GM_setValue collectAll: async function() { const cards = this.scanChapterCards(); const errorCards = cards.filter(c => c.wrong > 0); if (errorCards.length === 0) { utils.showMessage('未发现错题章节', 'info'); return; } utils.showMessage(`发现 ${errorCards.length} 个错题章节,开始采集...`, 'info'); const allAnswers = JSON.parse(currentConfig.reviewData); for (const card of errorCards) { utils.log('采集章节:', card.chapter, '错题数:', card.wrong); const answers = await this.collectChapterAnswers(card); if (Object.keys(answers).length > 0) { // 合并:保留已有数据,只补新题 const existing = allAnswers[card.chapter] || {}; allAnswers[card.chapter] = { ...existing, ...answers }; utils.log(`章节 ${card.chapter} 合并完成:原有${Object.keys(existing).length}题 + 新增${Object.keys(answers).length}题`); } } currentConfig.reviewData = JSON.stringify(allAnswers); GM_setValue('reviewData', currentConfig.reviewData); const totalQuestions = Object.values(allAnswers).reduce((sum, a) => sum + Object.keys(a).length, 0); utils.showMessage(`采集完成:${totalQuestions} 道错题正确答案已存储`, 'success'); Logger.milestone(`错题采集完成: ${totalQuestions} 道`); }, // 构建传给AI的错题提示词上下文(收集所有章节的错题数据) buildHint: function() { try { const all = JSON.parse(currentConfig.reviewData); const hints = []; for (const [chapter, qs] of Object.entries(all)) { for (const [q, v] of Object.entries(qs)) { let ans = v; try { ans = JSON.parse(v); } catch (e) {} hints.push({ question: q, answer: ans }); } } return hints; } catch (e) { return []; } }, // 读取当前章节的完成状态(从 courseDetail 页面) getCurrentChapterStatus: function() { const stats = { done: false, answered: 0, totalQ: 0, wrong: 0 }; const tree = document.querySelector('.ant-tree'); const treeContainer = tree?.parentElement; if (treeContainer) { const ps = treeContainer.querySelectorAll('p'); for (const p of ps) { const text = p.textContent; const qMatch = text.match(/总题数[::]\s*(\d+)/); if (qMatch) stats.totalQ = parseInt(qMatch[1]); const aMatch = text.match(/已答[::]\s*(\d+)/); if (aMatch) stats.answered = parseInt(aMatch[1]); const wMatch = text.match(/答错[::]\s*(\d+)/); if (wMatch) stats.wrong = parseInt(wMatch[1]); } } // 检查当前选中课程是否有"(学完)"(必须取最深的选中节点:li 级的 // ant-tree-treenode-selected 可能命中根节点,其 textContent 含全部子节点) const selectedNode = VideoController._selectedNode(); if (selectedNode && selectedNode.textContent.includes('(学完)')) { stats.done = true; } return stats; } }; function init() { // 1. 创建控制面板 ControlPanel.createPanel(); ControlPanel.updateAuthStatusUI(); // 1.5. 创建独立开发者面板(默认隐藏) DevPanel.create(); DevPanel.updatePromptPreview(); // 1.8. 复习模式已开启时,恢复错题数据注入 //(刷新后 activeHintData 重置为 null,仅点按钮开启才会注入,需在此恢复) if (currentConfig.reviewMode) { const hintData = ReviewCollector.buildHint(); if (hintData.length > 0) { DeepSeekService.setHintData(hintData); Logger.milestone(`刷新后恢复复习模式错题提示词: ${hintData.length} 道`); } } // 2. 初始化视频倍速 VideoController.initVideoSpeed(); // 2.5 不许暂停守卫(默认开启,视频未结束时自动恢复播放) if (currentConfig.noPause) { VideoController.startNoPauseGuard(); } // 3. 自动展开章节 if (currentConfig.expandChapters) { ChapterExpander.expandParentNodes(); ChapterExpander.startChapterObserver(); } // 4. 初始化视频自动播放与切换 if (currentConfig.autoPlayVideo) { setTimeout(() => VideoController.initAutoPlay(), 1500); } // 视频题走课程接口;考试题由批量处理器捕获整卷后并发处理。 VideoApiAnswer.boot(); // 考试开始接口会一次返回整张试卷;捕获后按批并发答题。 ExamBatchProcessor.boot(); // 5. 启动答题弹窗监听 if (currentConfig.autoAnswer) { ControlPanel.startPopupObserver(); } // 6. 全局DOM监听:动态内容变化时重新绑定视频事件 let videoBindPending = false; const globalObserver = new MutationObserver(() => { if (videoBindPending) return; videoBindPending = true; queueMicrotask(() => { videoBindPending = false; const video = document.querySelector('video'); if (video && currentConfig.autoPlayVideo && !videoEndListenerAttached) { video.playbackRate = currentConfig.videoSpeed; VideoController.attachVideoEndListener(video); } }); }); globalObserver.observe(document.body, { childList: true, subtree: true }); console.log(`🚀 ${SCRIPT_INFO.name} v${SCRIPT_INFO.version} 全部功能初始化完成`); } if (document.readyState === 'complete') init(); else window.addEventListener('load', init); })();