记一次某学习平台全自动刷课开发过程
从零到一:打造“终极全能网课助手”的开发心路历程
最近完成了一个项目——“全能网课助手”,它能实现从自动选课、倍速播放到自动答题的全流程自动化。这个过程踩了不少坑。
阶段一:需求的诞生——只想“偷个懒”
一切始于最朴素的需求,最初的核心需求被拆解为三个基本目标:
自动找课:能自动找到没学完的课程并点进去。
自动播放:进入视频页后,能自动开始播放,最好能倍速。
自动答题:解决视频中途弹出的计算题。
有了这三个目标,正式开始。
阶段二:V1.0 雏形——核心功能的逐个击破
采用“分而治之”的策略,先在不同的页面上,把核心功能一个个实现。
攻克视频播放页:一切从 querySelector 开始
这是最直观、最容易入手的一环。
思路:
找到页面上的 <video> 元素。
调用它的 .play() 方法。
设置它的 .playbackRate 属性来控制倍速。
监听它的 ended 事件,结束后跳转回列表页。
遇到的第一个坑:动态加载的视频元素
有时候脚本运行时,视频元素还没被页面的 JavaScript 完全加载出来,document.querySelector('video') 返回 null,导致脚本报错。最初用 setTimeout 延迟执行,但这很不稳定。
解决方案:引入 MutationObserver。这是一个强大的 API,能“监视”DOM 的变化。让它监视整个 document,一旦有新节点被添加,就检查是不是想要的
征服课程列表页:从静态到动态
搞定了播放页,回到课程列表页,处理“自动选课”和“自动翻页”。
思路:
获取所有未完成的课程行。
遍历它们,找到第一个进度不是 100% 的。
点击该行的“继续学习”按钮。
如果本页都完成了,就找到“下一页”按钮并点击。
实现:
通过审查元素,发现未完成的课程行有一个特定的 ng-repeat 属性,进度信息在一个 标签里。于是用 document.querySelectorAll 拿到所有行,写了个循环来判断。
翻页逻辑也类似,找到当前页码,然后点击它的下一个兄弟元素。
遇到的第二个坑:框架生成的“假”点击事件
发现对“继续学习”按钮调用 .click() 方法居然没反应!F12 查看元素,发现它绑定的是 AngularJS 的 ng-click 事件。这种由前端框架绑定的事件,有时无法通过简单的 DOM API 触发。
解决方案:模拟一个真实的鼠标点击事件。通过 new MouseEvent('click', { bubbles: true, cancelable: true }) 创建一个事件对象,然后用 element.dispatchEvent() 派发出去。果然,页面成功跳转了!
阶段三:V2.0 整合与健壮性——让它成为一个“产品”
核心功能都实现了,但代码散乱,体验也很差。需要把它们整合起来,并提升健壮性。
思路:
页面路由:脚本需要知道自己当前在哪个页面,然后执行对应的逻辑。
UI交互:用户可能想手动调速,或者临时关闭自动答题。需要一个控制面板。
配置持久化:用户的设置(比如倍速)应该被记住。
实现:
主逻辑 mainLogic():在脚本入口,通过 window.location.href.includes() 判断 URL,如果包含课程列表页的特征字符串,就调用 handleCourseListPage();如果包含视频播放页的,就调用 handleVideoPage()。一个简单的页面路由器就成型了。
控制面板:使用 GM_addStyle 和 document.body.insertAdjacentHTML 动态创建了一个悬浮的控制面板。包含了速度输入框、预设按钮、答题开关等。还给它加了拖动功能,提升用户体验。
数据存储:利用油猴提供的 GM_setValue 和 GM_getValue API,将用户的播放速度、自动答题开关状态保存下来。每次启动脚本时先读取配置,确保体验的一致性。
到这里,脚本已经相当好用了。它能自动循环地完成整个学习流程,并且提供了足够的用户控制选项。
阶段四:V3.0/3.7 体验飞跃——“黑盒”变“透明”
虽然功能强大,但用户在使用时会有一个很大的痛点:脚本到底在干嘛? 它是在找课?卡住了?还是已经完成了?整个过程像个黑盒。为了解决这个问题,决定引入一个全新的特性——全局状态监视器。
思路:
在页面角落创建一个小悬浮窗,专门用来显示脚本的当前状态。
用不同的颜色来表示不同的状态(例如:蓝色-进行中,绿色-成功,黄色-警告,红色-错误)。
在代码的每一个关键步骤,都去更新这个悬浮窗里的信息。
实现:
UI创建:和控制面板类似,用 HTML 和 CSS 创建了一个简洁的状态栏。
中央状态管理函数 updateStatus(message, type):封装了一个核心函数,它负责更新状态栏的文本和样式。比如调用 updateStatus('找到目标课程,准备跳转…', 'info'),状态栏就会显示蓝底的提示信息。
深度集成:把这个函数“埋”进了代码的各个角落:
开始找课时,调用 updateStatus('正在扫描课程…')。
找到视频时,调用 updateStatus('视频已锁定,准备播放…', 'success')。
遇到答题时,调用 updateStatus('检测到题目,正在作答…', 'warning')。
等待超时时,调用 updateStatus('等待元素超时,请检查页面', 'error')。
全部完成后,调用 updateStatus('🎉 所有课程均已完成!', 'success')。
这个改进带来了质的飞跃。用户可以实时看到脚本的“心路历程”,极大地提升了信任感和体验的确定性。
最终思考与总结
回顾整个开发过程,总结出几点心得:
从简到繁,迭代开发:不要一开始就想做一个完美的“大而全”的东西。先把最核心的功能实现,然后再逐步添加UI、提升健壮性、优化体验。
拥抱现代 Web API:像 MutationObserver 这样的 API 是解决动态内容问题的利器,远比 setInterval 轮询要高效和优雅。
注重用户体验:一个功能再强大的工具,如果用户不知道它在干什么,体验也是糟糕的。一个简单的状态监视器,就能让“后台工具”变得“前台化”,让用户感到安心。
保护劳动成果:在分享的同时,也要考虑如何保护自己的代码。学习并使用代码混淆,是一种在开源精神与个人权益之间取得平衡的好方法。
从一行 document.querySelector 开始,到一个拥有状态反馈、可配置、全自动的“终极助手”,这个过程不仅锻炼了编码能力,更深刻体会到“以用户为中心”进行软件设计的乐趣。
终极全能网课助手 (v3.7) - 实现逻辑详解
I. 概述 (Overview)
本脚本的核心设计理念是实现“一次启动,全程托管”的全自动学习体验。它通过模拟用户的常规操作(页面跳转、点击、播放、答题)来完成整个学习流程,并引入了全新的实时状态监视器,为用户提供了前所未有的透明度和掌控感。
脚本的执行逻辑是状态驱动和页面感知的。它首先判断当前所在的页面(课程列表页 vs. 视频播放页),然后执行该页面对应的自动化任务。
II. 核心特性与技术实现
全自动课程导航 (Fully Automatic Course Navigation)
逻辑: 脚本自动寻找列表中第一个未完成(进度 < 100%)的课程,并进入学习。
实现:
目标识别: 在课程列表页,脚本通过
document.querySelectorAll精准定位到所有未通过的课程行 (tr[ng-repeat="item in model.notPass.list"])。进度判断: 遍历每一行,查找包含“课程进度”字样的
<span>标签,并解析其进度值。如果进度不是100%,则将其标记为下一个学习目标。模拟点击: 找到目标课程后,脚本会定位到“继续学习”按钮 (
a[ng-click^="events.checkHasPhoto"])。关键在于,它不使用简单的.click()方法,而是通过new MouseEvent()创建一个真实的点击事件,并使用dispatchEvent()触发。这能更好地兼容 AngularJS 等前端框架,确保点击事件被正确监听和处理。自动翻页: 如果当前页所有课程均已完成,脚本会查找分页组件,定位“当前页”按钮 (
a.current),并点击其下一个兄弟元素 (nextElementSibling) 来实现翻页,直到找不到下一页或所有课程完成。动态内容监控: 考虑到课程列表可能是动态加载的,脚本使用
MutationObserver监听课程表格<tbody>的变化。一旦内容发生改变(如翻页后),就会重新触发扫描逻辑,确保不会错过任何课程。
鲁棒的视频播放控制 (Robust Video Playback Control)
逻辑: 在视频播放页,脚本必须稳定地找到视频元素,并对其进行播放、倍速、静音等控制,同时处理播放结束后的跳转。
实现:
视频元素查找 (
findAndSetupVideoRobustly): 这是脚本最关键的鲁棒性设计之一。由于视频<video>元素可能是由 JavaScript 动态插入到页面中的,直接查询可能会失败。因此,脚本采用双重保障机制:立即查询: 首先尝试用
document.querySelector('video')直接查找。备用方案 (MutationObserver): 如果没找到,则启动一个
MutationObserver来监听整个document的节点添加事件。一旦有任何节点被添加到页面中,它就会检查这个节点是不是<video>标签或者其子孙节点中是否包含<video>。一旦找到,立即捕获该元素,执行后续操作,并停止该观察者以节省性能。
事件绑定 (
handleVideoFound):canplay: 监听此事件确保视频已准备好播放,然后调用.play()方法,并通过 Promise 的.catch()块处理可能出现的“自动播放失败”异常。ended: 监听视频播放结束事件。结束后,延迟3秒,然后通过window.location.href导航回课程列表页,形成自动化闭环。
用户设置持久化: 用户的播放速度和自动答题开关设置,通过
GM_setValue和GM_getValue存储在油猴脚本的本地存储中,确保下次打开时配置依然生效。
智能自动答题 (Intelligent Auto-Answering)
逻辑: 在视频播放过程中,实时监控是否有弹题窗口出现,并自动计算答案并提交。
实现:
弹窗监控: 同样利用
MutationObserver监控document.body。这种方式远比setInterval轮询检查高效。一旦检测到DOM变化,就触发solveMathQuiz函数。问题解析:
solveMathQuiz函数首先检查答题弹窗 (.m-exam-dialog) 是否存在。然后,提取问题文本,使用正则表达式(\d+)\s*([+\-*/])\s*(\d+)匹配出两个操作数和一个操作符。答案计算: 使用
switch语句根据操作符计算出正确答案。选项匹配与提交: 遍历所有选项,将其文本内容转换为数字,与计算结果进行比对。找到匹配的选项后,模拟点击其对应的单选框 (
input[type="radio"]),并延迟一个随机的短暂时间(500-1000ms)后,点击提交按钮,模拟人类作答的延迟。
✨ 全新状态监视器 (Brand New Status Monitor)
逻辑: 这是 v3.7 的核心用户体验升级。它提供一个全局的、非侵入式的悬浮窗,实时反馈脚本的当前状态。
实现:
UI创建: 脚本启动时,通过
document.body.insertAdjacentHTML将状态监视器的 HTML 结构注入到页面底部。同时,使用GM_addStyle添加对应的 CSS 样式,实现其悬浮、美观的布局和不同状态的颜色定义。中央状态更新函数 (
updateStatus): 这是整个状态反馈系统的核心。它接收两个参数:message(要显示的消息) 和type(状态类型,如info,success,warning,error)。状态与样式联动: 该函数通过修改消息元素的
textContent和className来工作。CSS 中预设了.status-info,.status-success等类名,它们分别对应不同的背景色和文字颜色。当updateStatus函数改变类名时,监视器的外观也随之改变。深度集成: 脚本在每一个关键执行步骤(如“开始扫描”、“找到课程”、“跳转中”、“正在播放”、“检测到题目”等)都调用了
updateStatus函数,从而实现了精细、实时的状态播报。
III. 整体工作流 (Overall Workflow)
脚本注入: 用户访问
zdkj.v.zzu.edu.cn网站下的任何页面,脚本启动。初始化: 创建状态监视器UI,并进入
mainLogic函数。路由判断:
If URL 包含课程列表页标识 (
/center/myStudy/goods/detail):执行
handleCourseListPage逻辑。状态监视器显示:“正在课程列表页寻找目标...”。
找到未完成课程后,状态变为:“找到目标...3秒后跳转...”。
执行跳转。
Else If URL 包含视频播放页标识 (
/play/#/learn/):执行
handleVideoPage逻辑。状态监视器显示:“当前在视频播放页,加载助手中...”。
加载控制面板,并用
MutationObserver寻找<video>元素。找到视频后,状态变为:“视频元素已锁定,准备播放...”。
开始播放,状态变为:“▶️ 正在以 X.Xx 速度播放视频...”。
若弹出题目,状态变为:“检测到题目,正在自动作答...”。
视频播放结束,状态变为:“✅ 视频播放完毕,准备返回...”。
跳转回课程列表页。
循环: 页面跳转回课程列表页后,脚本重新执行第3步,形成一个从“找课”到“看完”再到“找下一课”的完美闭环,直至所有课程完成。
任务完成: 当在课程列表页找不到任何未完成课程,也无法翻页时,脚本判断所有任务已完成,弹出提示,状态监视器显示:“🎉 所有课程均已完成!”。
源代码
// ==UserScript==
// @name 终极全能网课助手 (自动选课 + 视频控制 + 自动答题) v3.7
// @namespace https://halo.kai666.asia/
// @version 3.7
// @description 增加了一个全局状态监视器悬浮窗,实时显示脚本的当前工作状态(如:寻找课程、播放视频、自动答题、任务完成等),并用不同颜色提示,极大提升了用户体验和透明度。
// @author WaNG
// @match https://*.zzu.edu.cn/*
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant unsafeWindow
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
const SCRIPT_NAME = "[全能助手 v3.7]";
// ====================================================================
// 1. 全局配置
// ====================================================================
const MAX_WAIT_SECONDS = 20;
const courseListPageUrl = 'center/myStudy/goods/detail';
const videoPageUrlIdentifier = '/play/#/learn/';
const targetYear = '2025';
const quizSelectors = {
quizContainer: '.m-exam-dialog',
questionText: '[data-id="topic"]',
options: '.d-slt',
optionValueText: '.ipt-txt-content',
optionRadioButton: 'input[type="radio"]',
submitButton: 'button[data-action="answer"]'
};
let videoElement = null;
let videoObserver = null;
const config = {
isAutoAnswerEnabled: GM_getValue('autoAnswerEnabled', true),
lastPlaybackRate: GM_getValue('lastPlaybackRate', 1.0),
};
// ====================================================================
// 2. ✨ 全局UI & 状态管理器 ✨
// ====================================================================
function createStatusPanel() {
const panelHTML = `
<div id="gm-status-panel">
<div id="gm-status-header">脚本状态</div>
<div id="gm-status-message">初始化中...</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', panelHTML);
}
function addStatusPanelStyles() {
GM_addStyle(`
#gm-status-panel {
position: fixed;
bottom: 20px;
right: 20px;
width: 250px;
z-index: 100000;
border-radius: 8px;
background-color: #fff;
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
overflow: hidden;
}
#gm-status-header {
padding: 8px 12px;
background-color: #6c757d;
color: white;
font-weight: bold;
font-size: 14px;
}
#gm-status-message {
padding: 12px;
font-size: 14px;
color: #333;
background-color: #e9ecef;
transition: background-color 0.3s ease;
}
#gm-status-message.status-info { background-color: #cfe2ff; color: #084298; }
#gm-status-message.status-success { background-color: #d1e7dd; color: #0f5132; }
#gm-status-message.status-warning { background-color: #fff3cd; color: #664d03; }
#gm-status-message.status-error { background-color: #f8d7da; color: #842029; }
`);
}
function updateStatus(message, type = 'info') {
const statusElement = document.getElementById('gm-status-message');
if (statusElement) {
statusElement.textContent = message;
statusElement.className = `status-${type}`;
}
// 同时在控制台打印,方便调试
switch(type) {
case 'error': console.error(`${SCRIPT_NAME} ${message}`); break;
case 'warning': console.warn(`${SCRIPT_NAME} ${message}`); break;
default: console.log(`${SCRIPT_NAME} ${message}`);
}
}
// ====================================================================
// 3. 核心辅助函数
// ====================================================================
function waitForElement(selector, callback, timeout) {
const startTime = Date.now();
const interval = setInterval(() => {
const element = document.querySelector(selector);
if (element) {
updateStatus(`关键元素 "${selector}" 已找到!`, 'success');
clearInterval(interval);
callback();
} else if (Date.now() - startTime > timeout) {
clearInterval(interval);
updateStatus(`等待超时: 未找到 "${selector}"。请刷新或检查页面。`, 'error');
}
}, 500);
}
// ====================================================================
// 4. 页面路由主逻辑
// ====================================================================
function mainLogic() {
const currentPageUrl = window.location.href;
if (currentPageUrl.includes(courseListPageUrl)) {
updateStatus('当前在课程列表页,准备扫描...');
waitForElement('.class-table tbody', handleCourseListPage, MAX_WAIT_SECONDS * 1000);
} else if (currentPageUrl.includes(videoPageUrlIdentifier)) {
updateStatus('当前在视频播放页,加载助手中...');
handleVideoPage();
} else {
updateStatus('当前页面非目标,脚本待命中...', 'info');
}
}
// ====================================================================
// 5. 课程列表页逻辑
// ====================================================================
function handleCourseListPage() {
const tableBody = document.querySelector('.class-table tbody');
if (!tableBody) {
updateStatus('课程列表tbody未找到!', 'error');
return;
}
const observer = new MutationObserver(() => {
clearTimeout(observer.debounce);
observer.debounce = setTimeout(processCurrentPage, 500);
});
observer.observe(tableBody, { childList: true });
processCurrentPage();
}
async function processCurrentPage() {
updateStatus('正在扫描当前页课程...', 'info');
const courseRows = document.querySelectorAll('.class-table tr[ng-repeat="item in model.notPass.list"]');
let foundIncomplete = false;
for (const row of courseRows) {
const allSpans = row.querySelectorAll('.info .mt5 span');
const progressSpan = Array.from(allSpans).find(span => span.innerText.includes('课程进度:'));
const progressLabel = progressSpan ? progressSpan.querySelector('label') : null;
if (!progressLabel || progressLabel.innerText.trim() === '100%') continue;
const courseName = row.querySelector('.info .tit a').title;
const nextCourseButton = row.querySelector('a[ng-click^="events.checkHasPhoto"]');
if (nextCourseButton) {
foundIncomplete = true;
updateStatus(`找到目标: [${progressLabel.innerText}] ${courseName},3秒后跳转...`, 'info');
setTimeout(() => {
updateStatus('正在执行跳转...', 'info');
const clickEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: unsafeWindow
});
nextCourseButton.dispatchEvent(clickEvent);
}, 3000);
return;
}
}
if (!foundIncomplete) {
updateStatus('本页课程均已完成,检查翻页...', 'info');
const paginationContainer = document.querySelector('.m-pages .pages-num');
if (!paginationContainer) {
updateStatus('🎉 所有课程均已完成!任务结束。', 'success');
alert("所有课程进度均为100%,任务完成!");
return;
}
const currentPageButton = paginationContainer.querySelector('a.current');
const nextPageButton = currentPageButton ? currentPageButton.nextElementSibling : null;
if (nextPageButton && nextPageButton.tagName === 'A') {
updateStatus(`准备翻页到第 ${nextPageButton.innerText} 页...`, 'info');
setTimeout(() => nextPageButton.click(), 3000);
} else {
updateStatus('🎉 所有课程均已完成!任务结束。', 'success');
alert("所有课程进度均为100%,任务完成!");
}
}
}
// ====================================================================
// 6. 视频播放页逻辑
// ====================================================================
function handleVideoPage() {
addPanelStyles();
createControlPanel();
makePanelDraggable();
addEventListenersToPanel();
findAndSetupVideoRobustly();
const quizObserver = new MutationObserver(() => setTimeout(solveMathQuiz, 300));
quizObserver.observe(document.body, { childList: true, subtree: true });
updateStatus('视频页助手加载完毕,监视中...', 'info');
}
function findAndSetupVideoRobustly() {
const existingVideo = document.querySelector('video');
if (existingVideo) {
handleVideoFound(existingVideo);
return;
}
videoObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.addedNodes.length) {
for (const node of mutation.addedNodes) {
if (node.tagName === 'VIDEO') {
handleVideoFound(node);
return;
}
if (typeof node.querySelector === 'function') {
const video = node.querySelector('video');
if (video) {
handleVideoFound(video);
return;
}
}
}
}
}
});
videoObserver.observe(document.documentElement, { childList: true, subtree: true });
}
function handleVideoFound(video) {
if (videoElement) return; // 防止重复初始化
videoElement = video;
updateStatus('视频元素已锁定,准备播放...', 'info');
if (videoObserver) {
videoObserver.disconnect();
videoObserver = null;
}
setPlaybackRate(config.lastPlaybackRate);
videoElement.addEventListener('ended', () => {
updateStatus('✅ 视频播放结束!3秒后返回...', 'success');
setTimeout(() => {
window.location.href = `https://zdkj.v.zzu.edu.cn/center/myStudy/goods/detail?year=${targetYear}`;
}, 3000);
});
videoElement.muted = true;
videoElement.addEventListener('canplay', () => {
const playPromise = videoElement.play();
if (playPromise !== undefined) {
playPromise.catch(error => {
updateStatus('自动播放失败,请手动点击播放', 'error');
});
}
}, { once: true });
}
// ====================================================================
// 7. 视频页核心功能
// ====================================================================
function solveMathQuiz() {
if (!config.isAutoAnswerEnabled) return;
const quizContainer = document.querySelector(quizSelectors.quizContainer);
if (!quizContainer) return;
const questionElement = quizContainer.querySelector(quizSelectors.questionText);
const submitButton = quizContainer.querySelector(quizSelectors.submitButton);
if (!questionElement || !submitButton || submitButton.disabled) return;
updateStatus('检测到题目,正在自动作答...', 'warning');
const questionText = questionElement.textContent.trim();
const match = questionText.replace(/x|X|×/g, '*').replace(/÷/g, '/').match(/(\d+)\s*([+\-*/])\s*(\d+)/);
if (match) {
const num1 = parseFloat(match[1]), operator = match[2], num2 = parseFloat(match[3]);
let result;
switch (operator) {
case '+': result = num1 + num2; break;
case '-': result = num1 - num2; break;
case '*': result = num1 * num2; break;
case '/': result = num1 / num2; break;
default: return;
}
const optionElements = quizContainer.querySelectorAll(quizSelectors.options);
let answerFound = false;
for (const option of optionElements) {
const optionValueText = option.querySelector(quizSelectors.optionValueText).textContent.trim();
const optionValue = parseFloat(optionValueText);
if (optionValue === result) {
option.querySelector(quizSelectors.optionRadioButton).click();
answerFound = true;
break;
}
}
if (answerFound) {
setTimeout(() => {
submitButton.click();
updateStatus(`▶️ 正在以 ${videoElement.playbackRate}x 速度播放...`, 'info');
}, 500 + Math.random() * 500);
}
}
}
function setPlaybackRate(rate) {
if (!videoElement) return;
const newRate = parseFloat(rate);
if (!isNaN(newRate) && newRate > 0) {
videoElement.playbackRate = newRate;
config.lastPlaybackRate = newRate;
GM_setValue('lastPlaybackRate', newRate);
updateStatus(`▶️ 正在以 ${newRate}x 速度播放...`, 'info');
const speedInput = document.getElementById('kh-speed-input');
if (speedInput) speedInput.value = newRate;
}
}
// ====================================================================
// 8. UI 及其他辅助函数
// ====================================================================
function jumpToTime(timeStr) {
if (videoElement) {
const parts = timeStr.split(':').map(part => parseInt(part, 10)).reverse();
let seconds = 0;
if (parts.length > 0) seconds += parts[0];
if (parts.length > 1) seconds += parts[1] * 60;
if (parts.length > 2) seconds += parts[2] * 3600;
if (!isNaN(seconds)) {
videoElement.currentTime = seconds;
}
}
}
function createControlPanel() {
const panelHTML = `
<div id="course-helper-panel">
<div id="kh-header">全能网课助手v3.7 (可拖动)</div>
<div id="kh-content">
<div class="kh-section">
<label>播放速度:</label>
<input type="number" id="kh-speed-input" value="${config.lastPlaybackRate}" min="0.1" step="0.1">
<button id="kh-set-speed-btn">设置</button>
</div>
<div class="kh-section kh-presets">
<button data-speed="1">1.0x</button>
<button data-speed="1.5">1.5x</button>
<button data-speed="2">2.0x</button>
<button data-speed="4">4.0x</button>
<button data-speed="8">8.0x</button>
</div>
<div class="kh-section">
<label>时间跳转:</label>
<input type="text" id="kh-time-input" placeholder="分:秒 或 时:分:秒">
<button id="kh-jump-time-btn">跳转</button>
</div>
<div class="kh-section kh-toggle">
<label for="kh-auto-answer-toggle">自动答题</label>
<label class="kh-switch">
<input type="checkbox" id="kh-auto-answer-toggle" ${config.isAutoAnswerEnabled ? 'checked' : ''}>
<span class="kh-slider"></span>
</label>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', panelHTML);
}
function addPanelStyles() {
GM_addStyle(`
#course-helper-panel {
position: fixed;
top: 100px;
left: 20px;
z-index: 99999;
background: #f1f1f1;
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
font-family: sans-serif;
width: 280px;
}
#kh-header {
padding: 10px;
background: #3498db;
color: white;
font-weight: bold;
cursor: move;
border-radius: 8px 8px 0 0;
text-align: center;
}
#kh-content {
padding: 15px;
display: flex;
flex-direction: column;
gap: 15px;
}
.kh-section {
display: flex;
align-items: center;
gap: 8px;
}
.kh-section label {
flex-shrink: 0;
font-size: 14px;
}
.kh-section input[type="number"],
.kh-section input[type="text"] {
width: 100%;
padding: 5px;
border: 1px solid #ccc;
border-radius: 4px;
}
.kh-section button {
padding: 5px 10px;
border: none;
background: #3498db;
color: white;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
.kh-section button:hover {
background: #2980b9;
}
.kh-presets button {
flex-grow: 1;
background: #95a5a6;
}
.kh-presets button:hover {
background: #7f8c8d;
}
.kh-toggle {
justify-content: space-between;
}
.kh-switch {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
}
.kh-switch input {
opacity: 0;
width: 0;
height: 0;
}
.kh-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 24px;
}
.kh-slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .kh-slider {
background-color: #2196F3;
}
input:checked + .kh-slider:before {
transform: translateX(26px);
}
`);
}
function makePanelDraggable() {
const panel = document.getElementById('course-helper-panel');
const header = document.getElementById('kh-header');
let isDragging = false, offsetX, offsetY;
header.addEventListener('mousedown', (e) => {
isDragging = true;
offsetX = e.clientX - panel.offsetLeft;
offsetY = e.clientY - panel.offsetTop;
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', (e) => {
if (isDragging) {
panel.style.left = `${e.clientX - offsetX}px`;
panel.style.top = `${e.clientY - offsetY}px`;
}
});
document.addEventListener('mouseup', () => {
isDragging = false;
document.body.style.userSelect = 'auto';
});
}
function addEventListenersToPanel() {
document.getElementById('kh-set-speed-btn').addEventListener('click', () => setPlaybackRate(document.getElementById('kh-speed-input').value));
document.querySelector('.kh-presets').addEventListener('click', (e) => {
if (e.target.tagName === 'BUTTON') setPlaybackRate(e.target.dataset.speed);
});
document.getElementById('kh-jump-time-btn').addEventListener('click', () => jumpToTime(document.getElementById('kh-time-input').value));
document.getElementById('kh-time-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') jumpToTime(e.target.value);
});
document.getElementById('kh-auto-answer-toggle').addEventListener('change', (e) => {
config.isAutoAnswerEnabled = e.target.checked;
GM_setValue('autoAnswerEnabled', config.isAutoAnswerEnabled);
updateStatus(`自动答题已${config.isAutoAnswerEnabled ? '开启' : '关闭'}`, 'info');
});
}
// ====================================================================
// 9. 脚本启动入口
// ====================================================================
(function initialize() {
addStatusPanelStyles();
createStatusPanel();
setTimeout(mainLogic, 500); // 稍作延迟以确保body完全加载
})();
})();