// ==UserScript==
// @name 福建网络干部学院课程自动播放
// @name:en Fujian Cadre Network College Course Auto Player
// @namespace https://greasyforks.org/users/1410751-liuxing7954
// @version 2.2.0
// @description 自动播放福建网络干部学院课程视频,支持自动切换章节和科目,自动保存进度,自动处理token
// @description:en Auto play Fujian Cadre Network College course videos, auto switch chapters and subjects, auto save progress, auto handle token
// @author liuxing7954
// @match *://www.fsa.gov.cn/*
// @match https://www.fsa.gov.cn/video*
// @match *://www.fsa.gov.cn/videoChoose*
// @match https://www.fsa.gov.cn/videoChoose*
// @license MIT
// @icon https://www.fsa.gov.cn/favicon.ico
// @grant none
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
/**
* 应用程序常量配置
* 集中管理所有配置项,便于维护和修改
*/
const CONSTANTS = {
/**
* localStorage存储键值配置
* 用于持久化保存用户设置和应用状态
*/
STORAGE_KEYS: {
DEBUG_MODE: 'debugMode', // 调试模式开关状态
COURSE_MODE: 'courseMode', // 课程模式:elective(选修) / required(必修)
STUDENT_ID: 'studentId', // 学生ID,用于API请求
TOKEN: 'wlxytk', // 网络学院认证token
REFRESH_TOKEN: 'rt', // 刷新token,用于续期认证
CONSOLE_POSITION: 'console_position', // 控制台窗口位置
PANEL_POSITION: 'config-panel_position' // 配置面板窗口位置
},
/**
* 时间间隔配置(毫秒)
* 控制各种定时任务的执行频率
*/
INTERVALS: {
DEBUG: 5000, // 调试模式下的主循环间隔(5秒,便于调试)
NORMAL: 20000, // 正常模式下的主循环间隔(20秒,减少服务器负载)
SAVE_PROGRESS: 2000, // 保存进度的延迟时间(2秒)
INITIALIZATION: 5000, // 页面初始化等待时间(5秒)
VIDEO_CHECK: 1000, // 检查视频元素的间隔(1秒)
PAGE_RELOAD: 5000 // 页面重新加载延迟(5秒)
},
/**
* 进度相关配置
* 控制课程完成度的判断标准
*/
PROGRESS: {
TARGET: 100, // 章节完成的目标进度百分比
MAX_VIDEO_ATTEMPTS: 10 // 等待视频加载的最大尝试次数
},
/**
* 用户界面配置
* 控制UI组件的显示效果
*/
UI: {
MAX_LOG_LINES_DEBUG: 20, // 调试模式下控制台最大日志行数
MAX_LOG_LINES_NORMAL: 10, // 正常模式下控制台最大日志行数
NOTIFICATION_DURATION: 2000 // 通知消息显示时长(2秒)
},
/**
* API接口地址配置
* 福建网络干部学院的课程数据接口
*/
API: {
// 选修课程列表接口
ELECTIVE: 'https://www.fsa.gov.cn/api/study/my/elective/myElectives',
// 必修课程列表接口
REQUIRED: 'https://www.fsa.gov.cn/api/study/years/yearsCourseware/annualPortalCourseList'
},
/**
* DOM选择器配置
* 用于定位页面中的关键元素
*/
SELECTORS: {
VIDEO: 'video', // 视频播放元素
CURRENT_COURSE: '.kc-list li h5[style*="color: rgb(166, 0, 0)"]', // 当前选中的课程(红色标题)
CHAPTERS: '.kc-list li', // 所有章节列表
PROGRESS_SPAN: '.kc-info span:last-child', // 章节进度显示文本
SAVE_BUTTON: 'button.el-button.btn span:first-child', // 保存进度按钮
COURSE_TITLE: 'h5', // 课程标题元素
// 课程选择页面的第一个课程选项
FIRST_COURSE: '#app > div.video-page-main > div.page-content > div > div.choose-body > div:nth-child(2) > span.choose-content',
// 文章阅读页面的状态显示
READING_STATUS: '#app > div.index-content > div.page-content > div.time-d-box > div > span',
START_READING_BUTTON: 'button:nth-child(2)' // 开始阅读按钮
}
};
/**
* 拖拽管理器 - 处理元素拖拽功能
* 提供通用的元素拖拽能力,支持位置记忆和边界检测
*/
class DragManager {
/**
* 构造函数 - 初始化拖拽状态
*/
constructor() {
this.isDragging = false; // 是否正在拖拽
this.currentElement = null; // 当前被拖拽的元素
this.startX = 0; // 拖拽开始时的鼠标X坐标
this.startY = 0; // 拖拽开始时的鼠标Y坐标
this.elementStartX = 0; // 拖拽开始时元素的X坐标
this.elementStartY = 0; // 拖拽开始时元素的Y坐标
this.originalStyles = {}; // 保存元素原始样式,用于拖拽结束后恢复
}
/**
* 使元素可拖拽
* @param {HTMLElement} element - 要拖拽的目标元素
* @param {HTMLElement} handle - 拖拽手柄元素(通常是标题栏)
* @param {string|null} storageKey - localStorage存储键,用于记忆位置
*/
makeDraggable(element, handle, storageKey = null) {
if (!element || !handle) return;
// 恢复上次保存的位置
if (storageKey) {
this.restorePosition(element, storageKey);
}
// 设置拖拽手柄样式
handle.style.cursor = 'move'; // 显示移动光标
handle.style.userSelect = 'none'; // 禁止文本选择
// 绑定鼠标按下事件 - 开始拖拽
handle.addEventListener('mousedown', (e) => {
e.preventDefault(); // 阻止默认行为(如文本选择)
this.startDrag(e, element, storageKey);
});
// 全局鼠标移动事件 - 拖拽过程
document.addEventListener('mousemove', (e) => {
if (this.isDragging && this.currentElement === element) {
this.drag(e);
}
});
// 全局鼠标释放事件 - 结束拖拽
document.addEventListener('mouseup', () => {
if (this.isDragging && this.currentElement === element) {
this.stopDrag(storageKey);
}
});
}
/**
* 开始拖拽操作
* @param {MouseEvent} e - 鼠标按下事件对象
* @param {HTMLElement} element - 被拖拽的目标元素
* @param {string} storageKey - localStorage存储键,用于保存位置
*/
startDrag(e, element, storageKey) {
this.isDragging = true; // 标记为正在拖拽状态
this.currentElement = element; // 记录当前拖拽的元素
// 记录拖拽开始时的鼠标坐标(相对于视口)
this.startX = e.clientX;
this.startY = e.clientY;
// 获取元素当前在屏幕上的绝对位置
const rect = element.getBoundingClientRect();
this.elementStartX = rect.left; // 元素左边距离视口左边的距离
this.elementStartY = rect.top; // 元素上边距离视口上边的距离
// 保存元素的原始样式,确保拖拽结束后能完全恢复
this.originalStyles = {
transition: element.style.transition, // 过渡动画
zIndex: element.style.zIndex, // 层级
opacity: element.style.opacity, // 透明度
transform: element.style.transform, // 变换效果
filter: element.style.filter // 滤镜效果(包含backdrop-filter)
};
// 应用拖拽时的视觉反馈样式
element.style.transition = 'none'; // 暂时禁用过渡动画,确保拖拽流畅
element.style.zIndex = '10002'; // 提升层级,确保在最上层显示
element.style.opacity = '0.9'; // 轻微透明,提示正在拖拽
element.style.transform = 'scale(1.02)'; // 轻微放大,增强视觉反馈
// 在原有滤镜基础上添加投影效果,保持现有的毛玻璃效果
const currentFilter = element.style.filter || '';
element.style.filter = currentFilter + ' drop-shadow(0 10px 30px rgba(0,0,0,0.3))';
// 改变全局光标样式,提示用户正在拖拽操作
document.body.style.cursor = 'move'; // 显示移动光标
document.body.style.userSelect = 'none'; // 禁止文本选择,避免拖拽时误选
}
/**
* 拖拽过程中的位置更新
* @param {MouseEvent} e - 鼠标移动事件对象
*/
drag(e) {
if (!this.isDragging || !this.currentElement) return;
// 计算鼠标相对于拖拽开始位置的移动距离
const deltaX = e.clientX - this.startX; // X轴移动距离
const deltaY = e.clientY - this.startY; // Y轴移动距离
// 根据移动距离计算元素的新位置
let newX = this.elementStartX + deltaX;
let newY = this.elementStartY + deltaY;
// 边界检测 - 防止元素被拖拽到屏幕可视区域外
const element = this.currentElement;
const rect = element.getBoundingClientRect();
const maxX = window.innerWidth - rect.width; // 最大X坐标(右边界)
const maxY = window.innerHeight - rect.height; // 最大Y坐标(下边界)
// 限制坐标范围:左上角不能超出(0,0),右下角不能超出屏幕边界
newX = Math.max(0, Math.min(newX, maxX));
newY = Math.max(0, Math.min(newY, maxY));
// 将计算后的新位置应用到元素上
element.style.left = newX + 'px'; // 设置左边距
element.style.top = newY + 'px'; // 设置上边距
element.style.right = 'auto'; // 清除右边距定位
element.style.bottom = 'auto'; // 清除下边距定位
}
/**
* 结束拖拽操作,恢复元素状态
* @param {string} storageKey - localStorage存储键,用于保存最终位置
*/
stopDrag(storageKey) {
if (!this.isDragging || !this.currentElement) return;
const element = this.currentElement;
// 恢复所有原始样式,确保不破坏元素的原有外观
element.style.transition = 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'; // 添加平滑过渡动画
element.style.zIndex = this.originalStyles.zIndex || ''; // 恢复层级
element.style.opacity = this.originalStyles.opacity || ''; // 恢复透明度
element.style.transform = this.originalStyles.transform || '';// 恢复变换效果
element.style.filter = this.originalStyles.filter || ''; // 恢复滤镜效果(重要!保持毛玻璃效果)
// 恢复全局光标和选择状态
document.body.style.cursor = ''; // 恢复默认光标
document.body.style.userSelect = ''; // 恢复文本选择功能
// 将当前位置保存到localStorage,下次页面加载时自动恢复
if (storageKey) {
this.savePosition(element, storageKey);
}
// 重置拖拽管理器的内部状态
this.isDragging = false; // 标记拖拽结束
this.currentElement = null; // 清空当前元素引用
this.originalStyles = {}; // 清空样式缓存
}
/**
* 保存元素当前位置到localStorage
* @param {HTMLElement} element - 要保存位置的元素
* @param {string} storageKey - localStorage存储键前缀
*/
savePosition(element, storageKey) {
const rect = element.getBoundingClientRect();
const position = {
left: rect.left, // 元素距离视口左边的距离
top: rect.top // 元素距离视口上边的距离
};
// 将位置信息序列化并保存到localStorage
StorageManager.set(`${storageKey}_position`, JSON.stringify(position));
}
/**
* 从localStorage恢复元素位置
* @param {HTMLElement} element - 要恢复位置的元素
* @param {string} storageKey - localStorage存储键前缀
*/
restorePosition(element, storageKey) {
const savedPosition = StorageManager.get(`${storageKey}_position`);
if (savedPosition) {
try {
// 解析保存的位置信息
const position = JSON.parse(savedPosition);
// 应用保存的位置,使用fixed定位
element.style.left = position.left + 'px'; // 设置左边距
element.style.top = position.top + 'px'; // 设置上边距
element.style.right = 'auto'; // 清除右边距定位
element.style.bottom = 'auto'; // 清除下边距定位
} catch (error) {
console.warn('Failed to restore position:', error);
}
}
}
}
/**
* 工具类 - 提供通用工具方法
* 包含异步操作、时间格式化、DOM操作等常用功能
*/
class Utils {
/**
* 异步延时函数
* @param {number} ms - 等待时间(毫秒)
* @returns {Promise} - 延时Promise对象
*/
static async sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 格式化当前时间为本地时间字符串
* @returns {string} - 格式化的时间字符串(如:14:30:25)
*/
static formatTime() {
return new Date().toLocaleTimeString();
}
/**
* 解析进度文本,提取百分比数值
* @param {string} progressText - 进度文本(如:"进度:85.5%")
* @returns {number} - 解析出的进度数值(如:85.5),解析失败返回0
*/
static parseProgress(progressText) {
return parseFloat(progressText.replace('进度:', '')) || 0;
}
/**
* 验证元素是否为有效的DOM元素
* @param {*} element - 要检验的对象
* @returns {boolean} - 是否为有效的DOM元素
*/
static isValidElement(element) {
return element && element instanceof Element;
}
/**
* 安全的单元素选择器,避免无效选择器导致的异常
* @param {string} selector - CSS选择器字符串
* @param {Element|Document} parent - 父元素,默认为document
* @returns {Element|null} - 找到的元素或null
*/
static safeQuerySelector(selector, parent = document) {
try {
return parent.querySelector(selector);
} catch (error) {
console.warn(`Invalid selector: ${selector}`, error);
return null;
}
}
/**
* 安全的多元素选择器,避免无效选择器导致的异常
* @param {string} selector - CSS选择器字符串
* @param {Element|Document} parent - 父元素,默认为document
* @returns {NodeList|Array} - 找到的元素列表或空数组
*/
static safeQuerySelectorAll(selector, parent = document) {
try {
return parent.querySelectorAll(selector);
} catch (error) {
console.warn(`Invalid selector: ${selector}`, error);
return [];
}
}
}
/**
* 存储管理器 - 处理localStorage操作
* 提供安全的本地存储读写功能,包含错误处理和类型转换
*/
class StorageManager {
/**
* 从localStorage获取字符串值
* @param {string} key - 存储键名
* @param {*} defaultValue - 默认值,当key不存在时返回
* @returns {string|*} - 存储的值或默认值
*/
static get(key, defaultValue = null) {
try {
const value = localStorage.getItem(key);
return value !== null ? value : defaultValue;
} catch (error) {
console.warn(`Failed to get storage item: ${key}`, error);
return defaultValue;
}
}
/**
* 向localStorage设置字符串值
* @param {string} key - 存储键名
* @param {string} value - 要存储的值
* @returns {boolean} - 是否设置成功
*/
static set(key, value) {
try {
localStorage.setItem(key, value);
return true;
} catch (error) {
console.warn(`Failed to set storage item: ${key}`, error);
return false;
}
}
/**
* 从localStorage获取布尔值
* @param {string} key - 存储键名
* @param {boolean} defaultValue - 默认布尔值
* @returns {boolean} - 解析后的布尔值
*/
static getBool(key, defaultValue = false) {
return this.get(key) === 'true' || defaultValue;
}
/**
* 向localStorage设置布尔值
* @param {string} key - 存储键名
* @param {boolean} value - 要存储的布尔值
* @returns {boolean} - 是否设置成功
*/
static setBool(key, value) {
return this.set(key, String(Boolean(value)));
}
}
/**
* 配置管理器 - 集中管理所有配置
* 负责应用程序的配置状态管理,包括调试模式、运行间隔、课程模式等
*/
class ConfigManager {
/**
* 构造函数 - 初始化配置项
* 从localStorage读取保存的配置,提供默认值
*/
constructor() {
// 调试模式相关配置
this.debug = {
enabled: StorageManager.getBool(CONSTANTS.STORAGE_KEYS.DEBUG_MODE, false), // 是否开启调试模式
interval: CONSTANTS.INTERVALS.DEBUG, // 调试模式运行间隔(5秒)
maxLogLines: CONSTANTS.UI.MAX_LOG_LINES_DEBUG // 调试模式最大日志行数
};
// 正常模式相关配置
this.normal = {
interval: CONSTANTS.INTERVALS.NORMAL, // 正常模式运行间隔(20秒)
maxLogLines: CONSTANTS.UI.MAX_LOG_LINES_NORMAL // 正常模式最大日志行数
};
// 课程相关配置
this.course = {
targetProgress: CONSTANTS.PROGRESS.TARGET, // 章节完成的目标进度(100%)
mode: StorageManager.get(CONSTANTS.STORAGE_KEYS.COURSE_MODE, 'elective') // 课程模式:elective(选修) / required(必修)
};
}
/**
* 获取当前是否处于调试模式
* @returns {boolean} - 是否启用调试模式
*/
get isDebugEnabled() {
return this.debug.enabled;
}
/**
* 获取当前运行间隔
* @returns {number} - 根据调试模式返回对应的运行间隔(毫秒)
*/
get currentInterval() {
return this.isDebugEnabled ? this.debug.interval : this.normal.interval;
}
/**
* 获取最大日志行数
* @returns {number} - 根据调试模式返回对应的最大日志行数
*/
get maxLogLines() {
return this.isDebugEnabled ? this.debug.maxLogLines : this.normal.maxLogLines;
}
/**
* 获取当前课程模式对应的API地址
* @returns {string} - 课程列表API地址
*/
get courseApiUrl() {
return this.course.mode === 'required' ? CONSTANTS.API.REQUIRED : CONSTANTS.API.ELECTIVE;
}
/**
* 切换调试模式开关
* 同时更新localStorage中的设置
*/
toggleDebug() {
this.debug.enabled = !this.debug.enabled;
StorageManager.setBool(CONSTANTS.STORAGE_KEYS.DEBUG_MODE, this.debug.enabled);
}
/**
* 设置课程模式
* @param {string} mode - 课程模式:'elective'(选修) 或 'required'(必修)
*/
setCourseMode(mode) {
this.course.mode = mode;
StorageManager.set(CONSTANTS.STORAGE_KEYS.COURSE_MODE, mode);
}
}
/**
* 日志管理器 - 处理日志输出和显示
*/
class LogManager {
constructor(config) {
this.config = config;
this.consoleDiv = null;
this.headerDiv = null;
this.bodyDiv = null;
this.isMinimized = false;
this.dragManager = new DragManager();
this.init();
}
init() {
this.createConsole();
this.bindEvents();
this.enableDragging();
}
createConsole() {
// 创建主容器
this.consoleDiv = document.createElement('div');
this.consoleDiv.id = 'custom-console';
this.consoleDiv.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
width: 500px;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
z-index: 9999;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05);
backdrop-filter: blur(20px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
`;
// 创建头部
this.headerDiv = document.createElement('div');
this.headerDiv.style.cssText = `
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
font-size: 13px;
cursor: move;
user-select: none;
`;
this.headerDiv.innerHTML = `
<div style="display: flex; align-items: center; flex: 1; cursor: move;" id="console-drag-handle">
<div style="width: 8px; height: 8px; background: #4ade80; border-radius: 50%; margin-right: 8px; box-shadow: 0 0 8px rgba(74, 222, 128, 0.6);"></div>
<span>脚本控制台</span>
<div style="margin-left: 8px; opacity: 0.7; font-size: 10px;">拖拽移动</div>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<button id="clearLog" style="
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s ease;
" onmouseover="this.style.background='rgba(255,255,255,0.3)'" onmouseout="this.style.background='rgba(255,255,255,0.2)'">清空</button>
<div id="minimizeBtn" style="
width: 16px;
height: 16px;
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
transition: all 0.2s ease;
" onmouseover="this.style.background='rgba(255,255,255,0.3)'" onmouseout="this.style.background='rgba(255,255,255,0.2)'">−</div>
</div>
`;
// 创建主体
this.bodyDiv = document.createElement('div');
this.bodyDiv.style.cssText = `
height: 300px;
overflow-y: auto;
padding: 16px;
font-size: 12px;
line-height: 1.5;
color: #e2e8f0;
background: rgba(0, 0, 0, 0.2);
`;
// 自定义滚动条样式
const style = document.createElement('style');
style.textContent = `
#custom-console div::-webkit-scrollbar {
width: 6px;
}
#custom-console div::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
#custom-console div::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 3px;
}
#custom-console div::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
`;
document.head.appendChild(style);
this.consoleDiv.appendChild(this.headerDiv);
this.consoleDiv.appendChild(this.bodyDiv);
document.body.appendChild(this.consoleDiv);
}
bindEvents() {
// 最小化/最大化功能
const minimizeBtn = document.getElementById('minimizeBtn');
minimizeBtn?.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleMinimize();
});
// 清空日志功能
const clearBtn = document.getElementById('clearLog');
clearBtn?.addEventListener('click', (e) => {
e.stopPropagation();
this.clearLogs();
});
}
enableDragging() {
const dragHandle = document.getElementById('console-drag-handle');
if (dragHandle) {
this.dragManager.makeDraggable(this.consoleDiv, dragHandle, 'console');
}
}
toggleMinimize() {
this.isMinimized = !this.isMinimized;
const minimizeBtn = document.getElementById('minimizeBtn');
if (this.isMinimized) {
this.bodyDiv.style.display = 'none';
this.consoleDiv.style.height = 'auto';
minimizeBtn.textContent = '+';
} else {
this.bodyDiv.style.display = 'block';
this.consoleDiv.style.height = 'auto';
minimizeBtn.textContent = '−';
}
}
clearLogs() {
this.bodyDiv.innerHTML = '';
this.log('日志已清空', true);
}
log(message, isDebug = false) {
if (isDebug && !this.config.isDebugEnabled) return;
const logLine = this.createLogLine(message, isDebug);
this.bodyDiv.appendChild(logLine);
this.bodyDiv.scrollTop = this.bodyDiv.scrollHeight;
this.limitLogLines();
// 控制台输出
const consoleMessage = `${Utils.formatTime()} - ${message}`;
if (isDebug) {
console.debug(`[DEBUG] ${consoleMessage}`);
} else {
console.log(consoleMessage);
}
}
createLogLine(message, isDebug) {
const logLine = document.createElement('div');
const time = Utils.formatTime();
// 根据消息类型设置不同的样式
let messageColor = '#e2e8f0';
let icon = '•';
if (isDebug) {
messageColor = '#10b981';
icon = '🔍';
} else if (message.includes('错误') || message.includes('失败')) {
messageColor = '#ef4444';
icon = '❌';
} else if (message.includes('成功') || message.includes('完成')) {
messageColor = '#22c55e';
icon = '✅';
} else if (message.includes('警告')) {
messageColor = '#f59e0b';
icon = '⚠️';
}
logLine.style.cssText = `
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
animation: fadeIn 0.3s ease-in-out;
word-wrap: break-word;
color: ${messageColor};
`;
logLine.innerHTML = `
<span style="
color: #64748b;
font-size: 10px;
min-width: 60px;
margin-top: 1px;
">${time}</span>
<span style="
margin-top: 1px;
min-width: 16px;
">${icon}</span>
<span style="
flex: 1;
word-break: break-word;
">${isDebug ? '[DEBUG] ' : ''}${message}</span>
`;
// 添加淡入动画
const style = document.createElement('style');
style.textContent = `
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
`;
if (!document.head.querySelector('style[data-log-animation]')) {
style.setAttribute('data-log-animation', 'true');
document.head.appendChild(style);
}
return logLine;
}
limitLogLines() {
while (this.bodyDiv.children.length > this.config.maxLogLines) {
this.bodyDiv.removeChild(this.bodyDiv.firstChild);
}
}
error(message, error = null) {
const errorMessage = error ? `${message}: ${error.message}` : message;
this.log(`❌ ${errorMessage}`);
if (this.config.isDebugEnabled && error?.stack) {
this.log(`Stack: ${error.stack}`, true);
}
}
}
/**
* 视频控制器 - 处理视频播放操作
* 负责课程视频的播放控制,这是自动挂课的核心业务逻辑
*/
class VideoController {
/**
* 构造函数
* @param {LogManager} logger - 日志管理器实例
*/
constructor(logger) {
this.logger = logger;
}
/**
* 获取页面中的视频播放器元素
* @returns {HTMLVideoElement|null} - 视频元素或null
*/
getPlayer() {
const video = Utils.safeQuerySelector(CONSTANTS.SELECTORS.VIDEO);
if (!video && this.logger.config.isDebugEnabled) {
this.logger.log('未找到视频元素', true);
}
return video;
}
/**
* 等待视频元素加载完成
* 由于页面动态加载,需要轮询等待视频元素出现
* @param {number} maxAttempts - 最大尝试次数,避免无限等待
* @returns {Promise<HTMLVideoElement|null>} - 返回视频元素或null
*/
async waitForVideo(maxAttempts = CONSTANTS.PROGRESS.MAX_VIDEO_ATTEMPTS) {
for (let attempts = 0; attempts < maxAttempts; attempts++) {
const video = this.getPlayer();
if (video) {
this.logVideoInfo(video);
return video;
}
this.logger.log(`等待视频加载,第 ${attempts + 1} 次尝试...`, true);
await Utils.sleep(CONSTANTS.INTERVALS.VIDEO_CHECK);
}
this.logger.log(`视频加载超时,已尝试 ${maxAttempts} 次`, true);
return null;
}
/**
* 记录视频基本信息,用于调试
* @param {HTMLVideoElement} video - 视频元素
*/
logVideoInfo(video) {
if (!this.logger.config.isDebugEnabled) return;
this.logger.log('视频元素已就绪', true);
this.logger.log(`视频状态: ${video.paused ? '已暂停' : '播放中'}`, true);
this.logger.log(`视频时长: ${video.duration || 0}秒`, true);
this.logger.log(`当前进度: ${video.currentTime || 0}秒`, true);
}
/**
* 播放视频 - 自动挂课的核心功能
* 实现视频的自动播放,确保课程能够正常进行
* @returns {Promise<boolean>} - 播放是否成功
*/
async play() {
const video = await this.waitForVideo();
if (!video) return false;
try {
// 检查视频是否已在播放
if (video.paused) {
// 设置静音模式,避免浏览器自动播放策略限制
video.muted = true;
// 调用播放方法,可能返回Promise
await video.play();
this.logger.log('视频已开始播放(静音模式)', true);
this.logPlaybackInfo(video);
return true;
} else {
this.logger.log('视频已经在播放中', true);
}
} catch (error) {
// 播放失败可能由于浏览器安全策略,需要用户交互
this.logger.error('播放视频出错', error);
}
return false;
}
/**
* 记录视频播放相关信息
* @param {HTMLVideoElement} video - 视频元素
*/
logPlaybackInfo(video) {
if (!this.logger.config.isDebugEnabled) return;
this.logger.log(`视频播放速率: ${video.playbackRate}`, true);
this.logger.log(`视频音量: ${video.volume}`, true);
}
/**
* 暂停视频播放
* @returns {boolean} - 暂停是否成功
*/
pause() {
const video = this.getPlayer();
if (!video) return false;
if (!video.paused) {
video.pause();
this.logger.log('暂停播放视频', true);
this.logger.log(`暂停时视频进度: ${video.currentTime}秒`, true);
return true;
} else {
this.logger.log('视频已经处于暂停状态', true);
}
return false;
}
/**
* 获取视频当前状态 - 用于监控播放进度
* @returns {Object|null} - 视频状态对象,包含播放进度等信息
*/
getStatus() {
const video = this.getPlayer();
if (!video) return null;
const status = {
currentTime: video.currentTime || 0, // 当前播放时间(秒)
duration: video.duration || 0, // 视频总时长(秒)
paused: video.paused, // 是否暂停
playbackRate: video.playbackRate || 1, // 播放倍速
volume: video.volume || 0 // 音量 (0-1)
};
this.logVideoStatus(status);
return status;
}
/**
* 记录详细的视频状态信息,便于监控和调试
* @param {Object} status - 视频状态对象
*/
logVideoStatus(status) {
if (!this.logger.config.isDebugEnabled) return;
this.logger.log('当前视频状态:', true);
this.logger.log(`- 当前时间: ${status.currentTime}秒`, true);
this.logger.log(`- 总时长: ${status.duration}秒`, true);
this.logger.log(`- 播放状态: ${status.paused ? '已暂停' : '播放中'}`, true);
this.logger.log(`- 播放速率: ${status.playbackRate}`, true);
this.logger.log(`- 音量: ${status.volume}`, true);
}
}
/**
* API客户端 - 处理网络请求
* 负责与福建网络干部学院API的通信,包括认证、课程列表获取等
*/
class ApiClient {
/**
* 构造函数
* @param {LogManager} logger - 日志管理器实例
*/
constructor(logger) {
this.logger = logger;
}
/**
* 获取API请求的认证头信息
* 从localStorage读取token和refreshToken,用于API认证
* @returns {Object} - 包含认证信息的HTTP头
* @throws {Error} - 当缺少认证信息时抛出错误
*/
getAuthHeaders() {
const token = StorageManager.get(CONSTANTS.STORAGE_KEYS.TOKEN);
const refreshToken = StorageManager.get(CONSTANTS.STORAGE_KEYS.REFRESH_TOKEN);
if (!token || !refreshToken) {
throw new Error('缺少认证信息');
}
return {
'Content-Type': 'application/json',
'Authorization': token, // 主要认证token
'RefreshAuthorization': refreshToken // 刷新token,用于续期
};
}
/**
* 获取课程列表 - 核心API调用方法
* 向干部学院服务器请求学生的课程列表数据
* @param {string} url - API接口地址(选修课或必修课)
* @returns {Promise<Object>} - 返回课程列表数据
* @throws {Error} - 当请求失败时抛出错误
*/
async fetchCourseList(url) {
const studentId = StorageManager.get(CONSTANTS.STORAGE_KEYS.STUDENT_ID);
if (!studentId) {
throw new Error('未找到学生ID');
}
// 构建API请求体 - 符合干部学院API规范
const requestBody = {
studentId: studentId, // 学生唯一标识
size: 30, // 每页课程数量
current: 1 // 当前页码
};
this.logRequestInfo(url, requestBody);
// 发送POST请求获取课程数据
const response = await fetch(url, {
method: 'POST',
headers: this.getAuthHeaders(), // 包含认证信息
body: JSON.stringify(requestBody)
});
// 处理可能的token刷新
await this.handleTokenRefresh(response);
if (!response.ok) {
throw new Error(`请求失败: ${response.status} ${response.statusText}`);
}
const data = await response.json();
this.logResponseInfo(data);
return data;
}
/**
* 记录API请求信息,用于调试
* @param {string} url - 请求地址
* @param {Object} requestBody - 请求体
*/
logRequestInfo(url, requestBody) {
if (!this.logger.config.isDebugEnabled) return;
this.logger.log('准备获取课程列表...', true);
this.logger.log(`请求URL: ${url}`, true);
this.logger.log(`请求体: ${JSON.stringify(requestBody)}`, true);
}
/**
* 处理token刷新逻辑
* 服务器可能在响应头中返回新的token,需要及时更新本地存储
* @param {Response} response - HTTP响应对象
*/
async handleTokenRefresh(response) {
if (response.status !== 200) return;
// 从响应头获取新的token(可能是小写或大写Authorization)
const newToken = response.headers.get('authorization') || response.headers.get('Authorization');
const newRefreshToken = response.headers.get('refre');
if (this.logger.config.isDebugEnabled) {
this.logger.log('Token更新信息:', true);
this.logger.log(`- 新token: ${newToken ? '已获取' : '未获取'}`, true);
this.logger.log(`- 新refreshToken: ${newRefreshToken ? '已获取' : '未获取'}`, true);
}
// 更新本地存储中的认证信息
if (newToken) {
StorageManager.set(CONSTANTS.STORAGE_KEYS.TOKEN, newToken);
}
if (newRefreshToken) {
StorageManager.set(CONSTANTS.STORAGE_KEYS.REFRESH_TOKEN, newRefreshToken);
}
}
/**
* 记录API响应信息,用于调试和监控
* @param {Object} data - API返回的数据
*/
logResponseInfo(data) {
if (!this.logger.config.isDebugEnabled) return;
this.logger.log('课程列表响应:', true);
this.logger.log(`- 总课程数: ${data.data?.total || 0}`, true);
this.logger.log(`- 当前页: ${data.data?.current || 0}`, true);
this.logger.log(`- 每页大小: ${data.data?.size || 0}`, true);
this.logger.log(`- 课程记录数: ${data.data?.records?.length || 0}`, true);
}
}
/**
* 课程管理器 - 处理课程相关操作
* 负责课程进度检测、保存、切换等核心业务逻辑,是自动挂课的关键组件
*/
class CourseManager {
/**
* 构造函数
* @param {ConfigManager} config - 配置管理器实例
* @param {LogManager} logger - 日志管理器实例
*/
constructor(config, logger) {
this.config = config;
this.logger = logger;
this.apiClient = new ApiClient(logger); // API客户端,用于获取课程列表
}
/**
* 获取当前课程信息 - 核心方法
* 解析页面中当前选中的课程及其进度信息
* @returns {Object|null} - 课程信息对象或null
*/
getCurrentCourseInfo() {
// 查找页面中标红的当前课程(通过CSS样式识别)
const currentCourse = Utils.safeQuerySelector(CONSTANTS.SELECTORS.CURRENT_COURSE);
if (!currentCourse) {
this.logger.log('未找到当前课程');
return null;
}
// 获取课程进度信息
const progressSpan = Utils.safeQuerySelector(CONSTANTS.SELECTORS.PROGRESS_SPAN, currentCourse.parentElement);
const progressText = progressSpan ? progressSpan.textContent : '进度:0.0%';
const progress = Utils.parseProgress(progressText);
const courseInfo = {
element: currentCourse.parentElement, // DOM元素引用
title: currentCourse.textContent.trim(), // 课程标题
progress: progress, // 进度数值(0-100)
progressText: progressText // 原始进度文本
};
this.logCourseInfo(courseInfo);
return courseInfo;
}
/**
* 记录课程信息,用于调试
* @param {Object} courseInfo - 课程信息对象
*/
logCourseInfo(courseInfo) {
if (!this.logger.config.isDebugEnabled) return;
this.logger.log('当前课程信息:', true);
this.logger.log(`- 课程标题: ${courseInfo.title}`, true);
this.logger.log(`- 进度文本: ${courseInfo.progressText}`, true);
this.logger.log(`- 进度数值: ${courseInfo.progress}%`, true);
}
/**
* 保存课程进度 - 关键业务功能
* 自动点击保存按钮,确保学习进度被记录到服务器
*/
saveProgress() {
const saveButton = Utils.safeQuerySelector(CONSTANTS.SELECTORS.SAVE_BUTTON);
const button = saveButton?.closest('button');
if (button) {
this.logger.log(`正在点击保存进度按钮: ${button.textContent.trim()}`, true);
button.click(); // 模拟用户点击保存按钮
this.logger.log('自动保存进度');
} else {
this.logger.log('未找到保存进度按钮');
this.logAvailableButtons();
}
}
/**
* 记录页面中所有可用按钮,用于调试
*/
logAvailableButtons() {
if (!this.logger.config.isDebugEnabled) return;
this.logger.log('页面按钮列表:', true);
const buttons = Utils.safeQuerySelectorAll('button');
buttons.forEach((btn, index) => {
this.logger.log(`按钮${index + 1}: ${btn.textContent.trim()}`, true);
});
}
/**
* 检查当前科目的所有章节是否都已完成 - 核心业务逻辑
* 遍历所有章节,检查进度是否达到目标值(100%)
* @returns {boolean} - 所有章节是否都已完成
*/
areAllChaptersCompleted() {
const chapters = Utils.safeQuerySelectorAll(CONSTANTS.SELECTORS.CHAPTERS);
this.logger.log(`检查所有章节状态,共找到 ${chapters.length} 个章节`, true);
for (const chapter of chapters) {
// 获取每个章节的进度信息
const progressSpan = Utils.safeQuerySelector(CONSTANTS.SELECTORS.PROGRESS_SPAN, chapter);
const progressText = progressSpan ? progressSpan.textContent : '进度:0.0%';
const progress = Utils.parseProgress(progressText);
const chapterTitle = Utils.safeQuerySelector(CONSTANTS.SELECTORS.COURSE_TITLE, chapter)?.textContent.trim() || '未知章节';
this.logger.log(`章节: ${chapterTitle} - 进度: ${progressText}`, true);
// 如果发现任何一个章节未完成,返回false
if (progress < this.config.course.targetProgress) {
return false;
}
}
return true; // 所有章节都已完成
}
/**
* 获取课程列表数据
* @returns {Promise<Object|null>} - 课程列表数据或null
*/
async fetchCourseList() {
try {
const url = this.config.courseApiUrl; // 根据配置获取对应的API地址
return await this.apiClient.fetchCourseList(url);
} catch (error) {
this.logger.error('获取课程列表失败', error);
return null;
}
}
/**
* 查找下一个未完成的科目 - 自动切换科目的核心逻辑
* 从服务器获取所有科目,找到第一个未完成的科目
* @returns {Promise<Object|null>} - 下一个未完成的科目或null
*/
async findNextUnfinishedSubject() {
this.logger.log('开始查找未完成科目...', true);
const courseList = await this.fetchCourseList();
if (!courseList?.data?.records) {
this.logger.log('获取课程列表失败或列表为空');
return null;
}
this.logger.log(`共找到 ${courseList.data.records.length} 个科目`, true);
// 查找第一个未完成的科目
const nextSubject = courseList.data.records.find(course => {
// 通过比较已学时数和总学时数判断是否完成
const isCompleted = course.creditsEarned === course.credit;
this.logSubjectInfo(course, isCompleted);
return !isCompleted;
});
return nextSubject;
}
/**
* 记录科目信息,用于调试
* @param {Object} course - 科目数据
* @param {boolean} isCompleted - 是否已完成
*/
logSubjectInfo(course, isCompleted) {
if (!this.logger.config.isDebugEnabled) return;
this.logger.log(`科目: ${course.courseware.coursewareName}`, true);
this.logger.log(`- 已学时数: ${course.creditsEarned}/${course.credit}`, true);
this.logger.log(`- 状态: ${isCompleted ? '已完成' : '未完成'}`, true);
}
/**
* 导航到指定科目 - 自动跳转功能
* 构建科目URL并跳转到对应页面
* @param {Object} courseware - 科目课件信息
*/
navigateToSubject(courseware) {
const url = `https://www.fsa.gov.cn/videoChoose?newId=${courseware.id}`;
this.logNavigationInfo(courseware, url);
window.location.href = url; // 页面跳转
}
/**
* 记录导航信息,用于调试
* @param {Object} courseware - 科目课件信息
* @param {string} url - 跳转URL
*/
logNavigationInfo(courseware, url) {
if (!this.logger.config.isDebugEnabled) return;
this.logger.log('准备跳转到新科目:', true);
this.logger.log(`- 科目ID: ${courseware.id}`, true);
this.logger.log(`- 科目名称: ${courseware.coursewareName}`, true);
this.logger.log(`- 跳转URL: ${url}`, true);
}
}
/**
* 页面管理器 - 处理不同页面的逻辑
* 负责协调视频控制器和课程管理器,实现自动挂课的主要业务流程
*/
class PageManager {
/**
* 构造函数
* @param {ConfigManager} config - 配置管理器
* @param {LogManager} logger - 日志管理器
* @param {VideoController} videoController - 视频控制器
* @param {CourseManager} courseManager - 课程管理器
*/
constructor(config, logger, videoController, courseManager) {
this.config = config;
this.logger = logger;
this.videoController = videoController;
this.courseManager = courseManager;
}
/**
* 视频页面主循环 - 自动挂课的核心业务流程
* 定期执行以下操作:保存进度、检查视频状态、处理课程切换
*/
async videoPageLoop() {
this.logger.log('开始新一轮视频页面检查...', true);
// 1. 保存当前学习进度到服务器
this.courseManager.saveProgress();
// 2. 检查视频播放状态,确保视频在播放
const videoStatus = this.videoController.getStatus();
if (videoStatus?.paused) {
await this.videoController.play();
}
// 3. 等待一段时间让进度更新
await Utils.sleep(CONSTANTS.INTERVALS.SAVE_PROGRESS);
// 4. 处理课程进度和切换逻辑
await this.handleCourseProgress();
}
/**
* 处理课程进度和切换逻辑 - 核心业务决策
* 检查当前章节是否完成,决定是否需要切换章节或科目
*/
async handleCourseProgress() {
try {
const currentInfo = this.courseManager.getCurrentCourseInfo();
if (!currentInfo) return;
// 检查当前章节是否已达到完成标准
if (currentInfo.progress >= this.config.course.targetProgress) {
this.logger.log('当前章节完成,检查其他章节...', true);
// 检查所有章节是否都已完成
if (this.courseManager.areAllChaptersCompleted()) {
this.logger.log('所有章节已完成,准备切换科目...');
await this.switchToNextSubject(); // 切换到下一个科目
} else {
this.switchToNextChapter(); // 切换到下一个章节
}
}
} catch (error) {
this.logger.error('处理课程切换时发生错误', error);
}
}
/**
* 文章页面主循环 - 处理文章阅读类型的课程
* 定期检查阅读状态,处理课程切换
*/
async articlePageLoop() {
this.logger.log('开始新一轮文章页面检查...', true);
await Utils.sleep(CONSTANTS.INTERVALS.SAVE_PROGRESS);
await this.handleArticleProgress();
}
/**
* 处理文章阅读进度
* 检查阅读状态,决定是否切换到下一个科目
*/
async handleArticleProgress() {
try {
const statusElement = Utils.safeQuerySelector(CONSTANTS.SELECTORS.READING_STATUS);
if (!statusElement) {
this.logger.log('未找到阅读状态元素');
return;
}
const statusText = statusElement.innerText;
this.logger.log(`当前阅读状态: ${statusText}`, true);
if (statusText !== '已完成') {
this.logger.log(`当前阅读时长:${statusText}`);
} else {
this.logger.log('所有章节已完成,准备切换科目...');
await this.switchToNextSubject();
}
} catch (error) {
this.logger.error('处理课程切换时发生错误', error);
}
}
/**
* 切换到下一个未完成的章节 - 章节级别的自动切换
* 遍历所有章节,找到第一个未完成的章节并点击
*/
switchToNextChapter() {
this.logger.log('开始查找下一个未完成章节...', true);
const chapters = Utils.safeQuerySelectorAll(CONSTANTS.SELECTORS.CHAPTERS);
for (const chapter of chapters) {
// 获取章节进度信息
const progressSpan = Utils.safeQuerySelector(CONSTANTS.SELECTORS.PROGRESS_SPAN, chapter);
const progressText = progressSpan ? progressSpan.textContent : '进度:0.0%';
const progress = Utils.parseProgress(progressText);
const chapterTitle = Utils.safeQuerySelector(CONSTANTS.SELECTORS.COURSE_TITLE, chapter)?.textContent.trim() || '未知章节';
this.logger.log(`检查章节: ${chapterTitle} - 进度: ${progressText}`, true);
// 找到第一个未完成的章节
if (progress < this.config.course.targetProgress) {
this.logger.log('------------------------');
this.logger.log('准备切换到章节: ' + chapterTitle);
this.logger.log('当前进度: ' + progressText);
this.logger.log('------------------------');
// 点击章节标题进行切换
this.logger.log(`点击章节: ${chapterTitle}`, true);
const titleElement = Utils.safeQuerySelector(CONSTANTS.SELECTORS.COURSE_TITLE, chapter);
if (titleElement) {
titleElement.click(); // 模拟用户点击
}
break;
}
}
}
/**
* 切换到下一个未完成的科目 - 科目级别的自动切换
* 这是自动挂课的最高级别切换,完成一个科目后自动进入下一个
*/
async switchToNextSubject() {
this.logger.log('开始查找下一个未完成科目...', true);
const nextSubject = await this.courseManager.findNextUnfinishedSubject();
if (nextSubject) {
this.logger.log(`找到下一个未完成的科目: ${nextSubject.courseware.coursewareName}`);
this.logger.log(`当前进度: ${nextSubject.creditsEarned}/${nextSubject.credit}`);
this.courseManager.navigateToSubject(nextSubject.courseware); // 跳转到新科目
} else {
this.logger.log('恭喜!所有科目都已完成!');
}
}
initVideoPage() {
this.logger.log('初始化视频播放页面...', true);
setTimeout(() => {
this.videoPageLoop();
this.logger.log(`设置主循环间隔: ${this.config.currentInterval}ms`, true);
setInterval(() => this.videoPageLoop(), this.config.currentInterval);
}, CONSTANTS.INTERVALS.INITIALIZATION);
}
initArticlePage() {
this.logger.log('初始化文章阅读页面...', true);
setTimeout(() => {
this.clickStartReadingButton();
this.articlePageLoop();
this.logger.log(`设置主循环间隔: ${this.config.currentInterval}ms`, true);
setInterval(() => this.articlePageLoop(), this.config.currentInterval);
}, CONSTANTS.INTERVALS.INITIALIZATION);
}
clickStartReadingButton() {
const buttons = Utils.safeQuerySelectorAll('button');
const startButton = buttons[1]; // 第二个按钮是开始阅读按钮
if (startButton) {
this.logger.log('点击开始阅读按钮...', true);
startButton.click();
}
}
initCourseChoosePage() {
this.logger.log('初始化课程选择页面...', true);
setTimeout(() => {
const firstCourse = Utils.safeQuerySelector(CONSTANTS.SELECTORS.FIRST_COURSE);
if (firstCourse) {
this.logger.log(`找到第一条课程: ${firstCourse.textContent}`, true);
firstCourse.click();
this.logger.log('已选择第一条课程');
this.logger.log('5秒后将刷新页面...', true);
setTimeout(() => window.location.reload(), CONSTANTS.INTERVALS.PAGE_RELOAD);
} else {
this.logger.log('未找到课程选项');
this.logPageContent();
}
}, CONSTANTS.INTERVALS.VIDEO_CHECK);
}
logPageContent() {
if (!this.logger.config.isDebugEnabled) return;
this.logger.log('页面内容:', true);
const pageContent = Utils.safeQuerySelector('.page-content');
this.logger.log(pageContent ? pageContent.innerHTML : '未找到page-content', true);
}
}
/**
* 配置面板 - 处理用户界面配置
*/
class ConfigPanel {
constructor(config, logger) {
this.config = config;
this.logger = logger;
this.isVisible = true;
this.dragManager = new DragManager();
}
create() {
const panel = this.createPanelElement();
document.body.appendChild(panel);
this.bindEvents();
this.enableDragging();
}
createPanelElement() {
const panel = document.createElement('div');
panel.innerHTML = `
<div id="config-panel" style="
position: fixed;
top: 20px;
right: 20px;
z-index: 10000;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', sans-serif;
box-shadow: 0 20px 40px rgba(102, 126, 234, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px);
min-width: 280px;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
">
<!-- 头部 -->
<div id="panel-header" style="
background: rgba(255, 255, 255, 0.1);
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: move;
user-select: none;
">
<div style="display: flex; align-items: center; gap: 10px; flex: 1;" id="panel-drag-handle">
<div style="
width: 24px;
height: 24px;
background: linear-gradient(45deg, #4ade80, #22c55e);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: white;
font-weight: bold;
">⚙️</div>
<div style="flex: 1;">
<div style="color: white; font-weight: 600; font-size: 15px;">脚本控制</div>
<div style="color: rgba(255,255,255,0.7); font-size: 11px;">v2.2.0 • 拖拽移动</div>
</div>
</div>
<div id="panel-toggle" style="
width: 20px;
height: 20px;
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
" onmouseover="this.style.background='rgba(255,255,255,0.3)'" onmouseout="this.style.background='rgba(255,255,255,0.2)'">−</div>
</div>
<!-- 主体内容 -->
<div id="panel-body" style="
background: rgba(255, 255, 255, 0.95);
padding: 20px;
">
<!-- 调试模式 -->
<div style="margin-bottom: 20px;">
<div style="
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.05);
cursor: pointer;
transition: all 0.2s ease;
" onmouseover="this.style.transform='translateY(-1px)'; this.style.boxShadow='0 4px 12px rgba(0,0,0,0.1)'" onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='none'">
<div style="display: flex; align-items: center; gap: 12px;">
<div id="debugIcon" style="
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
${this.config.isDebugEnabled ?
'background: linear-gradient(135deg, #22c55e, #16a34a); color: white;' :
'background: linear-gradient(135deg, #64748b, #475569); color: white;'
}
transition: all 0.3s ease;
">${this.config.isDebugEnabled ? '🟢' : '🔴'}</div>
<div>
<div style="font-weight: 600; color: #1e293b; font-size: 14px;">调试模式</div>
<div style="color: #64748b; font-size: 12px;">显示详细运行日志</div>
</div>
</div>
<div style="
position: relative;
width: 48px;
height: 24px;
background: ${this.config.isDebugEnabled ? '#22c55e' : '#cbd5e1'};
border-radius: 12px;
transition: all 0.3s ease;
cursor: pointer;
" id="debugToggle">
<div style="
position: absolute;
top: 2px;
left: ${this.config.isDebugEnabled ? '26px' : '2px'};
width: 20px;
height: 20px;
background: white;
border-radius: 10px;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
"></div>
</div>
</div>
</div>
<!-- 课程模式 -->
<div style="margin-bottom: 16px;">
<label style="
display: block;
font-weight: 600;
color: #374151;
font-size: 13px;
margin-bottom: 8px;
">课程模式</label>
<div style="position: relative;">
<select id="courseMode" style="
width: 100%;
padding: 12px 16px;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
font-size: 14px;
color: #1e293b;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
appearance: none;
background-image: url('data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"12\" height=\"12\" viewBox=\"0 0 12 12\"><path fill=\"%23374151\" d=\"M6 8L2 4h8L6 8z\"/></svg>');
background-repeat: no-repeat;
background-position: right 12px center;
" onfocus="this.style.borderColor='#667eea'; this.style.boxShadow='0 0 0 3px rgba(102, 126, 234, 0.1)'" onblur="this.style.borderColor='rgba(0,0,0,0.1)'; this.style.boxShadow='none'">
<option value="elective" ${this.config.course.mode === 'elective' ? 'selected' : ''}>📚 选修课程</option>
<option value="required" ${this.config.course.mode === 'required' ? 'selected' : ''}>📖 必修课程</option>
</select>
</div>
</div>
<!-- 状态信息 -->
<div style="
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border: 1px solid rgba(245, 158, 11, 0.2);
border-radius: 12px;
padding: 12px 16px;
margin-top: 16px;
">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px;">
<div style="color: #92400e; font-size: 14px;">ℹ️</div>
<div style="color: #92400e; font-weight: 600; font-size: 13px;">运行状态</div>
</div>
<div style="color: #451a03; font-size: 12px; line-height: 1.4;">
脚本正在自动运行中...<br>
<span style="color: #92400e;">⏱️ 间隔: ${this.config.isDebugEnabled ? '5秒' : '20秒'}</span>
</div>
</div>
</div>
</div>
`;
return panel;
}
bindEvents() {
// 面板收缩/展开
const panelToggle = document.getElementById('panel-toggle');
const panelBody = document.getElementById('panel-body');
const togglePanel = () => {
this.isVisible = !this.isVisible;
if (this.isVisible) {
panelBody.style.display = 'block';
panelToggle.textContent = '−';
} else {
panelBody.style.display = 'none';
panelToggle.textContent = '+';
}
};
panelToggle?.addEventListener('click', (e) => {
e.stopPropagation();
togglePanel();
});
// 调试模式切换
const debugToggle = document.getElementById('debugToggle');
const debugIcon = document.getElementById('debugIcon');
const updateDebugUI = () => {
const toggle = document.getElementById('debugToggle');
const icon = document.getElementById('debugIcon');
const ball = toggle?.querySelector('div');
if (this.config.isDebugEnabled) {
toggle.style.background = '#22c55e';
ball.style.left = '26px';
icon.style.background = 'linear-gradient(135deg, #22c55e, #16a34a)';
icon.textContent = '🟢';
} else {
toggle.style.background = '#cbd5e1';
ball.style.left = '2px';
icon.style.background = 'linear-gradient(135deg, #64748b, #475569)';
icon.textContent = '🔴';
}
};
debugToggle?.addEventListener('click', () => {
this.config.toggleDebug();
updateDebugUI();
this.logger.log(`调试模式已${this.config.isDebugEnabled ? '开启' : '关闭'}`);
});
// 课程模式切换
const courseModeSelect = document.getElementById('courseMode');
courseModeSelect?.addEventListener('change', (e) => {
this.config.setCourseMode(e.target.value);
this.logger.log(`已切换到${this.config.course.mode === 'required' ? '必修' : '选修'}模式`);
});
}
enableDragging() {
const dragHandle = document.getElementById('panel-drag-handle');
const panel = document.getElementById('config-panel');
if (dragHandle && panel) {
this.dragManager.makeDraggable(panel, dragHandle, 'config-panel');
}
}
}
/**
* 通知管理器 - 处理用户通知
*/
class NotificationManager {
static notifications = [];
static maxNotifications = 3;
static show(message, type = 'success', duration = CONSTANTS.UI.NOTIFICATION_DURATION) {
const notification = this.createNotification(message, type);
this.addNotification(notification);
// 设置自动消失
setTimeout(() => {
this.removeNotification(notification);
}, duration);
return notification;
}
static createNotification(message, type) {
const notification = document.createElement('div');
// 获取类型相关的样式
const typeConfig = this.getTypeConfig(type);
notification.style.cssText = `
position: fixed;
top: 80px;
right: 20px;
background: ${typeConfig.background};
color: white;
padding: 16px 20px;
border-radius: 12px;
z-index: 10001;
box-shadow: 0 10px 25px ${typeConfig.shadow}, 0 0 0 1px rgba(255, 255, 255, 0.1);
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif;
font-size: 14px;
max-width: 320px;
min-width: 200px;
word-wrap: break-word;
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
transform: translateX(100%);
opacity: 0;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
`;
notification.innerHTML = `
<div style="display: flex; align-items: flex-start; gap: 12px;">
<div style="
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
margin-top: 1px;
flex-shrink: 0;
">${typeConfig.icon}</div>
<div style="flex: 1;">
<div style="font-weight: 600; margin-bottom: 2px;">${typeConfig.title}</div>
<div style="opacity: 0.9; font-size: 13px; line-height: 1.4;">${message}</div>
</div>
<div style="
width: 16px;
height: 16px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
cursor: pointer;
flex-shrink: 0;
transition: all 0.2s ease;
" onmouseover="this.style.background='rgba(255,255,255,0.3)'" onmouseout="this.style.background='rgba(255,255,255,0.2)'">×</div>
</div>
`;
// 点击关闭
notification.addEventListener('click', () => {
this.removeNotification(notification);
});
document.body.appendChild(notification);
// 触发进入动画
requestAnimationFrame(() => {
notification.style.transform = 'translateX(0)';
notification.style.opacity = '1';
});
return notification;
}
static getTypeConfig(type) {
const configs = {
success: {
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
shadow: 'rgba(16, 185, 129, 0.4)',
icon: '✓',
title: '成功'
},
error: {
background: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
shadow: 'rgba(239, 68, 68, 0.4)',
icon: '✕',
title: '错误'
},
warning: {
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
shadow: 'rgba(245, 158, 11, 0.4)',
icon: '⚠',
title: '警告'
},
info: {
background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
shadow: 'rgba(59, 130, 246, 0.4)',
icon: 'i',
title: '提示'
}
};
return configs[type] || configs.success;
}
static addNotification(notification) {
this.notifications.push(notification);
this.updatePositions();
// 如果通知过多,移除最旧的
if (this.notifications.length > this.maxNotifications) {
const oldest = this.notifications.shift();
this.removeNotification(oldest);
}
}
static removeNotification(notification) {
const index = this.notifications.indexOf(notification);
if (index > -1) {
this.notifications.splice(index, 1);
// 退出动画
notification.style.transform = 'translateX(100%)';
notification.style.opacity = '0';
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 300);
this.updatePositions();
}
}
static updatePositions() {
this.notifications.forEach((notification, index) => {
const offset = index * 90; // 每个通知间距90px
notification.style.top = `${80 + offset}px`;
});
}
// 便捷方法
static success(message, duration) {
return this.show(message, 'success', duration);
}
static error(message, duration) {
return this.show(message, 'error', duration);
}
static warning(message, duration) {
return this.show(message, 'warning', duration);
}
static info(message, duration) {
return this.show(message, 'info', duration);
}
}
/**
* 应用程序主类 - 自动挂课脚本的入口和协调中心
* 负责初始化所有组件,根据页面类型启动相应的业务流程
*/
class AutoPlayApp {
/**
* 构造函数 - 初始化所有核心组件
* 按照依赖关系创建各个管理器实例
*/
constructor() {
// 基础组件
this.config = new ConfigManager(); // 配置管理器
this.logger = new LogManager(this.config); // 日志管理器
// 业务组件
this.videoController = new VideoController(this.logger); // 视频控制器
this.courseManager = new CourseManager(this.config, this.logger); // 课程管理器
this.pageManager = new PageManager(this.config, this.logger, this.videoController, this.courseManager); // 页面管理器
// UI组件
this.configPanel = new ConfigPanel(this.config, this.logger); // 配置面板
}
/**
* 应用程序初始化 - 启动自动挂课功能
* 创建UI界面,等待页面稳定,然后根据页面类型启动相应流程
*/
async initialize() {
try {
// 1. 创建用户界面
this.configPanel.create();
// 2. 显示启动通知,告知用户脚本已开始工作
NotificationManager.success("脚本已启动,正在初始化...");
// 3. 等待页面完全加载和稳定
await Utils.sleep(CONSTANTS.INTERVALS.SAVE_PROGRESS);
this.logger.log('等待2s后开始初始化');
// 4. 根据当前页面URL启动对应的业务流程
this.initializePageByUrl();
} catch (error) {
this.logger.error('初始化失败', error);
}
}
/**
* 根据页面URL初始化对应的业务流程
* 福建干部网络学院有三种主要页面类型,需要不同的处理逻辑
*/
initializePageByUrl() {
const currentURL = window.location.href;
if (currentURL.includes('/video?')) {
// 视频学习页面 - 主要的挂课页面,包含视频播放和章节切换
this.pageManager.initVideoPage();
} else if (currentURL.includes('/videoChoose')) {
// 课程选择页面 - 科目选择页面,自动选择第一个课程
this.pageManager.initCourseChoosePage();
} else if (currentURL.includes('/study-artical')) {
// 文章阅读页面 - 文档类课程页面,需要等待阅读时间
this.pageManager.initArticlePage();
} else {
this.logger.log('未知页面类型');
}
}
}
/**
* ========================================
* 应用程序入口 - 自动挂课脚本启动点
* ========================================
*/
// 创建应用程序实例
const app = new AutoPlayApp();
// 根据页面加载状态决定初始化时机
if (document.readyState === 'loading') {
// 页面还在加载中,等待DOM内容加载完成
document.addEventListener('DOMContentLoaded', () => app.initialize());
} else {
// 页面已经加载完成,立即初始化
app.initialize();
}
})();