// ==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.badge}
| 卡密类型 | ${g.type} |
| 算力额度 | ${g.power} |
| 约可答题 | ${g.questions} |
| 有效期 | ${g.validity} |
| 单题成本 | ${g.unitCost} |
🎯 ${g.fit}
${g.desc}
`).join('');
panel.innerHTML = `
未激活
总体提交次数:考试开始后自动计算
批量答题:等待考试开始
算力按 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 提示词(可选,加在答题要求最前)
`;
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);
})();