// ==UserScript==
// @name 【完全免费】2026年基础教育暑期教师研修·一键秒学助手
// @namespace sumuve.local
// @version 1.0.1
// @description 一键完成2026年基础教育暑期教师研修全部学时(完全免费·本地运行)
// @author sumuve
// @icon data:image/x-icon;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAA7GIebOxiHuPsYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe4+xiHmwAAAAA7GIebOxiHv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIebOxiHuPsYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHuPsYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xjH//2tJP//vj1///////+8+3/+dC8//KTZf/sZCH/7GIe/+xiHv/taCb/7GIe/+xiHv/sYh7/7GIe/+94Pf/61cL///////3x6//61sX/+c65//ve0P/97+j//fDq//nPuv/1rYr/73c8/+xiHv/sYh7/7GIe/+xjH//85dr///////zp3//wgUr/7GIe/+96QP/++vj///v6//Wui//sZCL/7GIe/+xiHv/sYh7/7GIe/+xiHv/uczb////////////97OT/9riZ/+xiHv/wgkz/////////////////735G/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GYj//728v///////Orh//jHr//udjr/7GIe//nNuP////////////ObcP/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/zmGz//vn2///7+v///fv/+tXD//vdzv/////////////+/v/ve0H/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe//KRYv////////////////////////7+//3x7P/zoHb/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/9a6L//728//++ff/9raX/+xkIv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GMg/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe4+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe4+xiHmzsYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHmwAAAAA7GIebOxiHuPsYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe/+xiHv/sYh7/7GIe4+xiHmwAAAAAwAMAAIABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAAwAMAAA==
// @match https://basic.smartedu.cn/*
// @match https://teacher.ykt.eduyun.cn/*
// @connect s-file-1.ykt.cbern.com.cn
// @connect s-file-2.ykt.cbern.com.cn
// @connect x-study-record-api.ykt.eduyun.cn
// @connect elearning-train-gateway.ykt.eduyun.cn
// @connect elearning-train-gateway-safe.ykt.eduyun.cn
// @connect elearning-train-api.ykt.eduyun.cn
// @connect x-api.ykt.eduyun.cn
// @require https://fastly.jsdelivr.net/npm/crypto-js@4.2.0/crypto-js.min.js
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant unsafeWindow
// @run-at document-end
// @license MIT
// @antifeature ads
// @antifeature referral-link
// @tag 暑期研修 秒学助手 智慧教育 教师培训 油猴脚本
// ==/UserScript==
(function() {
'use strict';
// ==================== 全局配置 ====================
const CONFIG = {
TRAIN_ID: 'dc6d78f2-bad8-4d09-b8da-0d758803dbe4',
LIBRARY_ID: 'bb042e69-9a11-49a1-af22-0c3fab2e92b9',
TAG: '2026年"暑期教师研修"专题(基础教育)',
TARGET_PERIOD: 10,
PERIOD_RATIO: 2700,
SDP_APP_ID: 'e5649925-441d-4a53-b525-51a2f1c4e0a8',
AUTO_START_KEY: 'sumuve_auto_start',
WELCOME_SHOWN_KEY: 'sumuve_welcome_shown_v1',
PANEL_POS_KEY: 'sumuve_panel_pos',
FLOW_STATE_KEY: 'sumuve_flow_state_v1', // 跨页面流程状态
PAUSED_KEY: 'sumuve_paused_v1', // 暂停状态持久化
STOPPED_KEY: 'sumuve_stopped_v1', // 停止状态持久化
LOGS_KEY: 'sumuve_logs_v1', // 日志持久化
PERIOD_ASKED_KEY: 'sumuve_period_asked_v1', // 学时达标是否已问过
SCRIPT_NAME: '一键秒学助手',
AUTHOR_QR_BASE64: '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',
};
// 11门课程清单(含详情页URL和首个资源ID)
// maxPeriod: 认定学时要求(小时),从 train_courses.json 的 max_period 字段获取
// -1 表示选修课(按阶段汇总认定)
// phaseId: 选修课阶段ID(从 train_courses.json 的 phase_id 字段获取)
const TAG_ENCODED = encodeURIComponent(CONFIG.TAG);
const ELECTIVE_PHASE_ID = 'd763f410-d875-4c11-a81e-dc92b6ae0788'; // 选修课阶段ID
const COURSES = [
// 必修课(4门,共7学时)
{ id: '79d28f6c-0b8a-443d-bdda-a78165c462f3', name: '大力弘扬教育家精神', period: 2, maxPeriod: 2, phaseId: null, firstResourceId: '62be7ce3-c91b-43c3-9c8b-dba1ffcb9cdd' },
{ id: '165d9433-5486-43e4-926e-3f33b92635e4', name: '数智素养提升', period: 3, maxPeriod: 3, phaseId: null, firstResourceId: '64c2bf2c-c23e-47f8-8dcf-bccc203174b6' },
{ id: 'b2ec4202-c747-4ad2-a1b1-35a6c277c856', name: '科学素养提升', period: 1, maxPeriod: 1, phaseId: null, firstResourceId: '77b207ae-98cd-41e7-836c-afc8308871a6' },
{ id: 'ca2a448a-5ff5-4183-92c2-6506f6696228', name: '心理健康教育能力提升', period: 1, maxPeriod: 1, phaseId: null, firstResourceId: 'a80fc623-af06-4b31-be65-28850719005c' },
// 选修课(7门,共3学时,按阶段汇总)
{ id: 'a09d7192-d7d6-4451-8828-a96d3a5e66ca', name: '学校美育浸润行动', period: 0, maxPeriod: -1, phaseId: ELECTIVE_PHASE_ID, firstResourceId: 'e3206646-a292-4009-ab79-6f753f9e7c69' },
{ id: '6a46308e-17ff-456a-a320-b5bdb494e93d', name: '学科美育教学改革专题培训', period: 0, maxPeriod: -1, phaseId: ELECTIVE_PHASE_ID, firstResourceId: '87d178d5-5e65-4898-902d-7cec80479565' },
{ id: 'f120b8f7-1768-440e-ae79-3e79dd0650f2', name: '有效开展每日综合体育活动', period: 0, maxPeriod: -1, phaseId: ELECTIVE_PHASE_ID, firstResourceId: 'b61073dd-5fb7-4e8f-b210-25ae7f73fdbb' },
{ id: '0b69a573-7585-4450-8bae-48d3e3fd5cc1', name: '跨学科实验设计', period: 0, maxPeriod: -1, phaseId: ELECTIVE_PHASE_ID, firstResourceId: 'd8be0f8f-aedc-4301-bde3-578035e400d1' },
{ id: '12a71d1f-8c22-418e-8ffe-ab0a6741fd74', name: '孤独症教育骨干教师培训', period: 0, maxPeriod: -1, phaseId: ELECTIVE_PHASE_ID, firstResourceId: '1784a0de-8870-4665-9b79-2c8c4dee8bc8' },
{ id: 'c7044ffc-64b4-44b9-a866-0c117ff104d1', name: '融合教育通识课程', period: 0, maxPeriod: -1, phaseId: ELECTIVE_PHASE_ID, firstResourceId: '91284489-20bf-42a5-97a7-b19dc11ce068' },
{ id: '87102185-fa21-4565-ad94-ade986c1c141', name: '面向普教校长与教师的融合教育通识课实录', period: 0, maxPeriod: -1, phaseId: ELECTIVE_PHASE_ID, firstResourceId: '9682ae4b-66ae-4185-8501-420547877a37' },
];
// 为每门课程生成详情页URL
COURSES.forEach(c => {
c.detailUrl = `https://basic.smartedu.cn/teacherTraining/courseDetail?courseId=${c.id}&tag=${TAG_ENCODED}&channelId=&libraryId=${CONFIG.LIBRARY_ID}&breadcrumb=${TAG_ENCODED}&resourceId=${c.firstResourceId}`;
});
// ==================== 工具函数 ====================
const Utils = {
wait: (ms) => new Promise(r => setTimeout(r, ms)),
log: (msg) => {
const time = new Date().toLocaleTimeString();
console.log(`[${CONFIG.SCRIPT_NAME}] ${time} ${msg}`);
},
// 生成随机字符串
randomStr: (len) => {
const chars = '0123456789ABCDEFGHIJKLMNOPQRTUVWXZYS';
let res = '';
for (let i = 0; i < len; i++) {
res += chars[Math.floor(Math.random() * chars.length)];
}
return res;
},
// 生成nonce
generateNonce: (diff) => {
return (Date.now() + parseInt(diff || 0, 10)) + ':' + Utils.randomStr(8);
},
// 从localStorage获取动态token
getDynamicToken: () => {
try {
const pattern = /^ND_UC_AUTH-([0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12})&ncet-xedu&token$/;
for (let key of Object.keys(localStorage)) {
if (pattern.test(key)) {
const raw = localStorage.getItem(key);
const parsed = JSON.parse(raw);
const tokenObj = JSON.parse(parsed.value);
return {
key: key,
appId: key.match(pattern)[1],
token: tokenObj
};
}
}
} catch (err) {
Utils.log('获取Token失败: ' + err.message);
}
return null;
},
// 获取userId
getUserId: () => {
try {
const tokenInfo = Utils.getDynamicToken();
if (tokenInfo && tokenInfo.token && tokenInfo.token.user_id) {
return tokenInfo.token.user_id;
}
} catch (_) {}
return null;
},
// 计算MAC签名
calcMac: (url, nonce, method, macKey) => {
const u = new URL(url);
const base = nonce + '\n' + method.toUpperCase() + '\n' + u.pathname + '\n' + u.hostname + '\n';
return CryptoJS.HmacSHA256(base, macKey).toString(CryptoJS.enc.Base64);
},
// 生成MAC认证头
getMacAuthHeader: (url, method, tokenInfo) => {
if (!tokenInfo || !tokenInfo.token) return null;
const nonce = Utils.generateNonce(tokenInfo.token.diff);
const mac = Utils.calcMac(url, nonce, method, tokenInfo.token.mac_key);
return `MAC id="${tokenInfo.token.access_token}",nonce="${nonce}",mac="${mac}"`;
},
// GM_xmlhttpRequest Promise封装
request: (options) => {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
...options,
onload: (res) => {
if (res.status >= 200 && res.status < 300) {
try {
const data = JSON.parse(res.responseText);
resolve({ data, res });
} catch {
resolve({ data: res.responseText, res });
}
} else {
reject(new Error(`HTTP ${res.status}: ${res.responseText}`));
}
},
onerror: (err) => reject(err),
ontimeout: () => reject(new Error('请求超时')),
});
});
},
};
// ==================== 欢迎弹窗 ====================
class WelcomeDialog {
constructor() {
this.shown = GM_getValue(CONFIG.WELCOME_SHOWN_KEY, false);
this.dialog = null;
this.floatingBall = null;
this.fbDragState = { isDragging: false, startX: 0, startY: 0, offsetLeft: 0, offsetTop: 0, moved: false };
}
shouldShow() {
const p = location.pathname;
// 每次回到首页都显示欢迎弹窗(不受 shown 标记限制)
return p === '/' || p === '/index' || p === '/teacherTraining' || p === '/training';
}
show() {
if (this.dialog) return;
const overlay = document.createElement('div');
overlay.id = 'sumuve-welcome-overlay';
overlay.style.cssText = `
position:fixed;top:0;left:0;width:100%;height:100%;
background:rgba(0,0,0,0.5);z-index:2147483647;
display:flex;align-items:center;justify-content:center;
font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
`;
const box = document.createElement('div');
box.style.cssText = `
background:#fff;border-radius:16px;padding:32px;
box-shadow:0 20px 60px rgba(0,0,0,0.3);
max-width:400px;width:90%;text-align:center;animation:sumuveFadeIn 0.3s ease;
`;
box.innerHTML = `
一键秒学助手
2026年基础教育暑期教师研修
脚本代开发请联系作者sumuve
`;
overlay.appendChild(box);
document.body.appendChild(overlay);
this.dialog = overlay;
// 绑定事件
box.querySelector('#sumuve-start-btn').addEventListener('click', () => this.onStartLearn());
box.querySelector('#sumuve-later-btn').addEventListener('click', () => this.onLater());
// ESC关闭
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.dialog) this.onLater();
});
// 添加动画
GM_addStyle(`
@keyframes sumuveFadeIn {
from { opacity:0; transform:scale(0.9); }
to { opacity:1; transform:scale(1); }
}
`);
}
hide() {
if (this.dialog) {
this.dialog.remove();
this.dialog = null;
}
}
async onStartLearn() {
this.hide();
// 检测登录状态
const token = Utils.getDynamicToken();
if (!token) {
this.showLoginPrompt(() => {
// 登录提示关闭后,重新显示欢迎弹窗(用户仍未登录,可再次尝试或点稍后)
this.show();
});
return;
}
// 触发主流程
MainController.getInstance().start();
}
onLater() {
this.hide();
if (this.fromComplete) {
// 来自完成学时流程:显示绿色✓小圆球
showFinishBallAfterComplete();
} else {
// 首页欢迎弹窗:显示蓝色▶小圆球
this.showFloatingBall();
}
}
// 显示可拖动小圆球
showFloatingBall() {
if (this.floatingBall) return;
const ball = document.createElement('div');
ball.id = 'sumuve-floating-ball';
const savedPos = this._restoreBallPosition();
const left = savedPos ? savedPos.left : Math.max(10, window.innerWidth - 80);
const top = savedPos ? savedPos.top : Math.max(10, window.innerHeight / 2 - 30);
ball.style.cssText = `
position:fixed;left:${left}px;top:${top}px;
width:60px;height:60px;border-radius:50%;
background:linear-gradient(135deg,#1e62ec,#3b82f6);
box-shadow:0 6px 20px rgba(30,98,236,0.4);
display:flex;align-items:center;justify-content:center;
z-index:2147483646;cursor:pointer;
user-select:none;transition:transform 0.15s ease,box-shadow 0.15s ease;
animation:sumuveBallPop 0.3s ease;
`;
ball.innerHTML = `▶`;
document.body.appendChild(ball);
this.floatingBall = ball;
this._bindBallEvents(ball);
// 悬浮入场动画
GM_addStyle(`
@keyframes sumuveBallPop {
from { opacity:0; transform:scale(0.3); }
to { opacity:1; transform:scale(1); }
}
`);
}
_bindBallEvents(ball) {
const s = this.fbDragState;
// 点击重新展开弹窗(注意区分拖拽和点击)
ball.addEventListener('click', (e) => {
if (s.moved) { s.moved = false; return; }
this.hideFloatingBall();
this.show();
});
// 右键永久隐藏(作为应急出口)
ball.addEventListener('contextmenu', (e) => {
e.preventDefault();
this.hideFloatingBall(true);
});
// 鼠标按下 — 开始拖拽
ball.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
const rect = ball.getBoundingClientRect();
s.isDragging = true;
s.moved = false;
s.startX = e.clientX;
s.startY = e.clientY;
s.offsetLeft = rect.left;
s.offsetTop = rect.top;
ball.style.transition = 'none';
ball.style.transform = 'scale(0.95)';
});
document.addEventListener('mousemove', (e) => {
if (!s.isDragging) return;
const dx = e.clientX - s.startX;
const dy = e.clientY - s.startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) s.moved = true;
let nx = s.offsetLeft + dx;
let ny = s.offsetTop + dy;
// 边界限制
nx = Math.max(0, Math.min(window.innerWidth - 60, nx));
ny = Math.max(0, Math.min(window.innerHeight - 60, ny));
ball.style.left = nx + 'px';
ball.style.top = ny + 'px';
ball.style.right = 'auto';
ball.style.bottom = 'auto';
});
document.addEventListener('mouseup', () => {
if (!s.isDragging) return;
s.isDragging = false;
ball.style.transform = '';
ball.style.transition = 'transform 0.15s ease,box-shadow 0.15s ease';
// 自动吸附到左右边缘
const rect = ball.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
let targetLeft = rect.left;
if (cx < window.innerWidth / 2) targetLeft = 10;
else targetLeft = window.innerWidth - 70;
ball.style.transition = 'left 0.25s ease,transform 0.15s ease,box-shadow 0.15s ease';
ball.style.left = targetLeft + 'px';
ball.style.right = 'auto';
// 保存位置
setTimeout(() => this._saveBallPosition(ball), 300);
});
// 触屏支持
ball.addEventListener('touchstart', (e) => {
if (e.touches.length !== 1) return;
const t = e.touches[0];
const rect = ball.getBoundingClientRect();
s.isDragging = true;
s.moved = false;
s.startX = t.clientX;
s.startY = t.clientY;
s.offsetLeft = rect.left;
s.offsetTop = rect.top;
ball.style.transition = 'none';
}, { passive: true });
document.addEventListener('touchmove', (e) => {
if (!s.isDragging || e.touches.length !== 1) return;
const t = e.touches[0];
const dx = t.clientX - s.startX;
const dy = t.clientY - s.startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) s.moved = true;
let nx = s.offsetLeft + dx;
let ny = s.offsetTop + dy;
nx = Math.max(0, Math.min(window.innerWidth - 60, nx));
ny = Math.max(0, Math.min(window.innerHeight - 60, ny));
ball.style.left = nx + 'px';
ball.style.top = ny + 'px';
ball.style.right = 'auto';
ball.style.bottom = 'auto';
}, { passive: true });
document.addEventListener('touchend', () => {
if (!s.isDragging) return;
s.isDragging = false;
const rect = ball.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
let targetLeft = rect.left;
if (cx < window.innerWidth / 2) targetLeft = 10;
else targetLeft = window.innerWidth - 70;
ball.style.transition = 'left 0.25s ease,transform 0.15s ease';
ball.style.left = targetLeft + 'px';
setTimeout(() => this._saveBallPosition(ball), 300);
});
}
hideFloatingBall(permanent = false) {
if (this.floatingBall) {
this.floatingBall.remove();
this.floatingBall = null;
}
if (permanent) {
GM_deleteValue && GM_deleteValue('sumuve_fb_pos');
}
}
_saveBallPosition(ball) {
try {
const rect = ball.getBoundingClientRect();
GM_setValue('sumuve_fb_pos', JSON.stringify({ left: rect.left, top: rect.top }));
} catch (_) {}
}
_restoreBallPosition() {
try {
const raw = GM_getValue('sumuve_fb_pos', null);
if (!raw) return null;
const p = JSON.parse(raw);
return p.left != null ? p : null;
} catch (_) { return null; }
}
showLoginPrompt(onClose) {
const overlay = document.createElement('div');
overlay.style.cssText = `
position:fixed;top:0;left:0;width:100%;height:100%;
background:rgba(0,0,0,0.5);z-index:2147483647;
display:flex;align-items:center;justify-content:center;
`;
const box = document.createElement('div');
box.style.cssText = 'background:#fff;border-radius:16px;padding:28px;max-width:360px;width:90%;text-align:center;';
box.innerHTML = `
🔒
请先登录
您尚未登录智慧教育平台,请先登录后再使用一键秒学功能。
`;
overlay.appendChild(box);
document.body.appendChild(overlay);
// 绑定关闭事件,关闭后触发回调
box.querySelector('#sumuve-login-ok').addEventListener('click', () => {
overlay.remove();
if (typeof onClose === 'function') onClose();
});
}
}
// ==================== 进度面板 ====================
class ProgressPanel {
constructor() {
this.panel = null;
// 从持久化存储恢复历史日志(最多100条)
this.logLines = [];
try {
const raw = GM_getValue(CONFIG.LOGS_KEY, '');
if (raw) {
const arr = JSON.parse(raw);
if (Array.isArray(arr)) this.logLines = arr.slice(-100);
}
} catch (_) {}
this.visible = true;
}
static getInstance() {
if (!ProgressPanel._instance) {
ProgressPanel._instance = new ProgressPanel();
}
return ProgressPanel._instance;
}
create() {
if (this.panel) return;
const div = document.createElement('div');
div.id = 'sumuve-panel';
div.style.cssText = `
position:fixed;right:20px;bottom:20px;z-index:2147483646;
width:360px;background:rgba(255,255,255,0.95);backdrop-filter:blur(10px);
border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,0.15);
font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
font-size:13px;color:#374151;overflow:hidden;
border:1px solid rgba(0,0,0,0.05);
`;
div.innerHTML = `
📚 已学习学时
0.00 学时
✅ 已认定学时
0.00/10 学时
`;
document.body.appendChild(div);
this.panel = div;
// 绑定事件
div.querySelector('#sumuve-panel-close').addEventListener('click', () => this.hide()); // 关闭后显示小圆球
div.querySelector('#sumuve-pause-btn').addEventListener('click', () => {
const mc = MainController.getInstance();
// 从持久化状态判断是否在运行(跨页面后 state.running 已重置)
let flowRunning = false;
try {
const raw = GM_getValue(CONFIG.FLOW_STATE_KEY, '');
if (raw) flowRunning = !!JSON.parse(raw).running;
} catch (_) {}
if (!mc.state.running && !flowRunning) {
// 未运行:启动秒学
mc.start();
} else {
// 运行中:不重复启动
this.log('ℹ️ 学习正在进行中...');
}
});
div.querySelector('#sumuve-stop-btn').addEventListener('click', () => {
const mc = MainController.getInstance();
// 确保停止时 state 也同步
mc.state.running = true;
mc.stop();
});
div.querySelector('#sumuve-download-log').addEventListener('click', () => this.downloadLog());
// 拖拽
this._makeDraggable(div.querySelector('#sumuve-panel-header'), div);
// 恢复保存的位置
this._restorePosition(div);
}
_restorePosition(target) {
try {
const saved = GM_getValue(CONFIG.PANEL_POS_KEY, null);
if (saved) {
const pos = JSON.parse(saved);
if (typeof pos.left === 'number' && typeof pos.top === 'number') {
target.style.left = pos.left + 'px';
target.style.top = pos.top + 'px';
target.style.right = 'auto';
target.style.bottom = 'auto';
}
}
} catch (_) {}
}
_savePosition(target) {
try {
const rect = target.getBoundingClientRect();
GM_setValue(CONFIG.PANEL_POS_KEY, JSON.stringify({
left: rect.left,
top: rect.top,
}));
} catch (_) {}
}
_makeDraggable(handle, target) {
let isDragging = false, startX, startY, initLeft, initTop;
handle.addEventListener('mousedown', (e) => {
isDragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = target.getBoundingClientRect();
initLeft = rect.left;
initTop = rect.top;
target.style.transition = 'none';
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
target.style.left = (initLeft + dx) + 'px';
target.style.top = (initTop + dy) + 'px';
target.style.right = 'auto';
target.style.bottom = 'auto';
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
target.style.transition = '';
// 拖拽结束后保存位置
this._savePosition(target);
}
});
}
toggle() {
const body = this.panel.querySelector('#sumuve-panel-body');
body.style.display = body.style.display === 'none' ? '' : 'none';
}
show() {
if (!this.panel) this.create();
this.panel.style.display = 'block';
// 如果有小圆球则隐藏
this._hideFloatBall();
}
hide(showFloatBall = true) {
if (this.panel) this.panel.style.display = 'none';
// 关闭面板时显示小圆球,方便重新打开(首页不需要)
if (showFloatBall) this._showFloatBall();
}
// 面板专用小圆球
_showFloatBall() {
if (this._floatBall) return;
const ball = document.createElement('div');
ball.id = 'sumuve-panel-floatball';
ball.style.cssText = `
position:fixed;right:20px;top:50%;transform:translateY(-50%);
width:48px;height:48px;border-radius:50%;
background:linear-gradient(135deg,#1e62ec,#3b82f6);
box-shadow:0 4px 16px rgba(30,98,236,0.4);
display:flex;align-items:center;justify-content:center;
z-index:2147483646;cursor:pointer;user-select:none;
transition:left 0.25s ease,top 0.25s ease;
`;
ball.innerHTML = `⚡`;
ball.title = '点击展开进度面板,拖动可移动位置';
// 拖拽 + 点击区分
let isDragging = false, moved = false, startX = 0, startY = 0, initLeft = 0, initTop = 0;
ball.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
const rect = ball.getBoundingClientRect();
isDragging = true;
moved = false;
startX = e.clientX;
startY = e.clientY;
initLeft = rect.left;
initTop = rect.top;
ball.style.transition = 'none';
ball.style.transform = 'scale(0.92)';
ball.style.right = 'auto';
ball.style.bottom = 'auto';
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true;
let nx = Math.max(0, Math.min(window.innerWidth - 48, initLeft + dx));
let ny = Math.max(0, Math.min(window.innerHeight - 48, initTop + dy));
ball.style.left = nx + 'px';
ball.style.top = ny + 'px';
});
document.addEventListener('mouseup', () => {
if (!isDragging) return;
isDragging = false;
ball.style.transform = '';
ball.style.transition = 'left 0.25s ease,top 0.25s ease';
// 点击(未拖动)则展开面板
if (!moved) {
this.show();
return;
}
// 拖动结束:吸附到最近边缘
const rect = ball.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const targetLeft = cx < window.innerWidth / 2 ? 10 : window.innerWidth - 58;
ball.style.left = targetLeft + 'px';
});
// 触屏支持
ball.addEventListener('touchstart', (e) => {
if (e.touches.length !== 1) return;
const t = e.touches[0];
const rect = ball.getBoundingClientRect();
isDragging = true;
moved = false;
startX = t.clientX;
startY = t.clientY;
initLeft = rect.left;
initTop = rect.top;
ball.style.transition = 'none';
ball.style.right = 'auto';
ball.style.bottom = 'auto';
}, { passive: true });
document.addEventListener('touchmove', (e) => {
if (!isDragging || e.touches.length !== 1) return;
const t = e.touches[0];
const dx = t.clientX - startX;
const dy = t.clientY - startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true;
let nx = Math.max(0, Math.min(window.innerWidth - 48, initLeft + dx));
let ny = Math.max(0, Math.min(window.innerHeight - 48, initTop + dy));
ball.style.left = nx + 'px';
ball.style.top = ny + 'px';
}, { passive: true });
document.addEventListener('touchend', () => {
if (!isDragging) return;
isDragging = false;
ball.style.transition = 'left 0.25s ease,top 0.25s ease';
if (!moved) {
this.show();
return;
}
const rect = ball.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const targetLeft = cx < window.innerWidth / 2 ? 10 : window.innerWidth - 58;
ball.style.left = targetLeft + 'px';
});
document.body.appendChild(ball);
this._floatBall = ball;
}
_hideFloatBall() {
if (this._floatBall) {
this._floatBall.remove();
this._floatBall = null;
}
}
updatePeriod(earned, target) {
// 兼容旧调用:earned=已学习,target=目标(默认10)
this.updateEarned(earned, target);
}
updateEarned(earned, target) {
// 已学习学时不限定10,进度条自适应:未达10按10为分母,超过10则满格
const denom = Math.max(earned, target || CONFIG.TARGET_PERIOD);
const pct = denom > 0 ? Math.min(100, (earned / denom) * 100) : 0;
const text = document.getElementById('sumuve-earned-text');
const bar = document.getElementById('sumuve-earned-bar');
if (text) text.textContent = `${earned.toFixed(2)} 学时`;
if (bar) bar.style.width = pct + '%';
}
updateCertified(cert, target) {
target = target || CONFIG.TARGET_PERIOD;
const pct = target > 0 ? Math.min(100, (cert / target) * 100) : 0;
const text = document.getElementById('sumuve-cert-text');
const bar = document.getElementById('sumuve-cert-bar');
if (text) text.textContent = `${cert.toFixed(2)}/${target} 学时`;
if (bar) bar.style.width = pct + '%';
}
updateVideo(done, total) {
const pct = total > 0 ? Math.min(100, (done / total) * 100) : 0;
const text = document.getElementById('sumuve-video-text');
const bar = document.getElementById('sumuve-video-bar');
if (text) text.textContent = `${done}/${total} 个`;
if (bar) bar.style.width = pct + '%';
}
updateTask(name, mode) {
const taskEl = document.getElementById('sumuve-current-task');
const modeEl = document.getElementById('sumuve-current-mode');
if (taskEl) taskEl.textContent = name || '等待开始...';
if (modeEl) modeEl.textContent = mode || '--';
}
log(message) {
Utils.log(message);
this.logLines.push(`${new Date().toLocaleTimeString()} ${message}`);
if (this.logLines.length > 100) this.logLines = this.logLines.slice(-100);
// 持久化到GM存储,刷新不丢失
try {
GM_setValue(CONFIG.LOGS_KEY, JSON.stringify(this.logLines));
} catch (_) {}
const logsEl = document.getElementById('sumuve-logs');
if (logsEl) {
logsEl.innerHTML = this.logLines.map(l => `${l}
`).join('');
logsEl.scrollTop = logsEl.scrollHeight;
}
}
// 下载日志为Markdown文件
downloadLog() {
const now = new Date();
const ts = (d) => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') + '_' + String(d.getHours()).padStart(2, '0') + '-' + String(d.getMinutes()).padStart(2, '0') + '-' + String(d.getSeconds()).padStart(2, '0');
let md = `# 一键秒学助手 运行日志\n\n`;
md += `**导出时间**: ${now.toLocaleString('zh-CN')}\n\n`;
md += `---\n\n`;
md += `### 日志内容\n\n`;
md += `\`\`\`\n`;
this.logLines.forEach(line => {
md += line + '\n';
});
md += `\`\`\`\n`;
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `秒学助手日志_${ts(now)}.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.log('📥 日志已导出为Markdown文件');
}
updatePauseButton(state) {
const btn = document.getElementById('sumuve-pause-btn');
if (!btn) return;
if (state === 'idle') {
btn.textContent = '开始';
btn.style.background = '#ecfdf5';
btn.style.color = '#047857';
btn.disabled = false;
btn.style.cursor = 'pointer';
btn.style.opacity = '1';
} else if (state === 'running') {
btn.textContent = '运行中';
btn.style.background = '#f3f4f6';
btn.style.color = '#9ca3af';
btn.disabled = true;
btn.style.cursor = 'not-allowed';
btn.style.opacity = '0.7';
} else if (state === 'paused') {
// 兼容残留调用,不再使用暂停状态
btn.textContent = '开始';
btn.style.background = '#ecfdf5';
btn.style.color = '#047857';
btn.disabled = false;
btn.style.cursor = 'pointer';
btn.style.opacity = '1';
}
}
}
// ==================== 秒学模块 ====================
class SecondLearning {
constructor() {
this.tokenInfo = null;
}
init() {
this.tokenInfo = Utils.getDynamicToken();
if (!this.tokenInfo) throw new Error('未获取到登录凭证');
}
// 上报视频位置(秒学核心)
async reportVideoPosition(resourceId, userId, duration) {
const url = `https://x-study-record-api.ykt.eduyun.cn/v1/resource_learning_positions/${resourceId}/${userId}`;
const auth = Utils.getMacAuthHeader(url, 'PUT', this.tokenInfo);
if (!auth) throw new Error('MAC认证失败');
const result = await Utils.request({
url: url,
method: 'PUT',
headers: {
'authorization': auth,
'content-type': 'application/json',
'sdp-app-id': this.tokenInfo.appId,
},
data: JSON.stringify({ position: Math.max(0, duration - 3) }),
});
return result;
}
// 上报课程进度
async reportCourseProgress(trainId, courseId, progressData) {
const url = `https://elearning-train-gateway.ykt.eduyun.cn/v1/spi/trains/${trainId}/courses/${courseId}/progress/actions/async`;
const auth = Utils.getMacAuthHeader(url, 'POST', this.tokenInfo);
if (!auth) throw new Error('MAC认证失败');
const result = await Utils.request({
url: url,
method: 'POST',
headers: {
'authorization': auth,
'content-type': 'application/json',
'sdp-app-id': this.tokenInfo.appId,
},
data: JSON.stringify(progressData),
});
return result;
}
// 查询已获学时(使用 courses_period API,最可靠的数据源)
// 注意:选修课的学时需要从阶段ID(phaseId)读取,避免重复计算
async queryEarnedPeriod(userId, trainId) {
try {
const url = `https://elearning-train-api.ykt.eduyun.cn/v1/users/${userId}/trains/${trainId}/courses_period/actions/list`;
const auth = Utils.getMacAuthHeader(url, 'GET', this.tokenInfo);
if (auth) {
const result = await Utils.request({
url: url,
method: 'GET',
headers: {
'authorization': auth,
'sdp-app-id': this.tokenInfo.appId,
},
});
if (result && result.data) {
const periodData = result.data;
// 已学习学时计算:
// 1. 累加必修课的已学习学时
// 2. 加上选修课阶段(phaseId)的学时
let totalEarned = 0;
for (const course of COURSES) {
if (course.maxPeriod > 0) {
// 必修课:直接累加已学习学时
const earned = Number(periodData[course.id]) || 0;
if (earned > 0) totalEarned += earned;
}
}
// 选修课:使用阶段学时(避免与单课程重复)
const phaseEarned = Number(periodData[ELECTIVE_PHASE_ID]) || 0;
if (phaseEarned > 0) totalEarned += phaseEarned;
periodData._totalEarned = totalEarned;
// 已认定学时:通过 _calculateCertifiedFromPeriodData 计算
periodData._certified = this._calculateCertifiedFromPeriodData(periodData);
return periodData;
}
}
} catch (_) {}
return { _totalEarned: 0, _certified: 0 };
}
// 从 courses_period 数据中计算已认定学时
// 判断标准:
// 1. 必修课:已学习学时 >= maxPeriod(认定要求)
// 2. 选修课:按阶段汇总,读取 phaseId 对应的学时
_calculateCertifiedFromPeriodData(periodData) {
let certified = 0;
let requiredCertified = 0;
let electiveCertified = 0;
// 必修课:逐课程判断
for (const course of COURSES) {
if (course.maxPeriod > 0) {
// 必修课:已学习 >= maxPeriod 视为已认定
const earned = Number(periodData[course.id]) || 0;
const status = Number(periodData[course.id + '-status']) || 0;
if (earned >= course.maxPeriod || status >= 1) {
certified += course.period; // 使用配置的认定学时
requiredCertified += course.period;
}
}
}
// 选修课:按阶段汇总认定(phaseId 对应的学时)
// 从 API 返回的数据中读取阶段学时
const phaseEarned = Number(periodData[ELECTIVE_PHASE_ID]) || 0;
const phaseStatus = Number(periodData[ELECTIVE_PHASE_ID + '-status']) || 0;
if (phaseEarned > 0 || phaseStatus >= 1) {
// 选修课认定学时 = 阶段已学习学时(最多3学时)
electiveCertified = Math.min(phaseEarned, 3);
certified += electiveCertified;
}
// 调试日志:输出详细的学时计算结果
Utils.log(`[学时计算] 必修认定:${requiredCertified.toFixed(2)} 选修认定:${electiveCertified.toFixed(2)} 总认定:${certified.toFixed(2)} 选修已学习:${phaseEarned.toFixed(2)}`);
return certified;
}
// res_stats 接口返回的是页面访问统计数据(PV/UV),不适用于学时计算
// 保留此方法用于其他可能的扩展场景
async _queryCertifiedFromResStats() {
// 该 API 返回的是课程的 PV/UV 等统计数据,不包含学时信息
// 学时计算应使用 courses_period API 或 DOM 抓取
return null;
}
// 获取课程资源列表
async getCourseResources(courseId) {
try {
// 先获取课程详情,拿到activity_set_id
const infoUrl = `https://s-file-1.ykt.cbern.com.cn/teach/s_course/v2/business_courses/${courseId}/course_relative_infos/zh-CN.json`;
const infoRes = await fetch(infoUrl, { credentials: 'omit' });
const infoData = await infoRes.json();
const activitySetId = infoData?.course_detail?.activity_set_id;
if (!activitySetId) throw new Error('未找到activity_set_id');
// 获取资源树
const fullsUrl = `https://s-file-2.ykt.cbern.com.cn/teach/s_course/v2/activity_sets/${activitySetId}/fulls.json`;
const fullsRes = await fetch(fullsUrl, { credentials: 'omit' });
const fullsData = await fullsRes.json();
const resources = [];
const walk = (nodes) => {
nodes.forEach(node => {
if (node.node_type === 'catalog' && node.child_nodes) {
walk(node.child_nodes);
} else if (node.node_type === 'activity') {
const activity = node.relations?.activity;
if (activity && activity.activity_resources) {
activity.activity_resources.forEach(res => {
if (res.resource_type === 'video') {
resources.push({
resourceId: res.resource_id,
title: node.node_name || '',
duration: Number(res.study_time || res.video_extend?.duration || 0),
});
}
});
}
}
});
};
if (fullsData.nodes) walk(fullsData.nodes);
return resources;
} catch (err) {
Utils.log('获取课程资源失败: ' + err.message);
return [];
}
}
}
// ==================== 自动学习模块 ====================
class AutoLearning {
constructor() {
this.video = null;
this.watchdog = null;
this.onEndedCallback = null;
}
start(videoElement, onEnded, skipToEnd = false) {
// 如果是同一个 video,先清理旧的事件监听器
if (this.video && this.video !== videoElement) {
// 旧 video 会被 GC 回收,无需手动 removeEventListener
}
this.video = videoElement;
this.onEndedCallback = onEnded;
// 记录自动播放开始时间,用于确保至少播放4秒后才允许判定结束
this._playStartTime = Date.now();
this._skipDone = false;
this._configureVideo(skipToEnd);
this._startWatchdog();
this._bindVisibilityHandler();
// 使用命名函数以便后续清理
if (!this._endedHandler) {
this._endedHandler = () => this._handleEnded();
this._pauseHandler = () => this._handlePause();
}
this.video.addEventListener('ended', this._endedHandler);
this.video.addEventListener('pause', this._pauseHandler);
}
stop() {
if (this.watchdog) {
clearInterval(this.watchdog);
this.watchdog = null;
}
if (this._visibilityHandler) {
document.removeEventListener('visibilitychange', this._visibilityHandler);
this._visibilityHandler = null;
}
// 清理播放状态
this._playStartTime = null;
// 移除旧 video 的事件监听器
if (this.video && this._endedHandler) {
this.video.removeEventListener('ended', this._endedHandler);
this.video.removeEventListener('pause', this._pauseHandler);
}
// 真正暂停页面上的所有 video 元素(不只清引用)
if (this.video) {
try {
this.video.pause();
} catch (_) {}
this.video = null;
}
document.querySelectorAll('video').forEach(v => {
try { v.pause(); } catch (_) {}
});
}
_configureVideo(skipToEnd = false) {
if (!this.video) return;
this.video.muted = true;
this.video.playbackRate = 2;
// 如果视频已结束(ended=true),先重置currentTime来解除结束状态
if (this.video.ended) {
try {
this.video.currentTime = 0;
} catch (_) {}
}
// 跳转至视频最后5秒(确保 duration 已就绪)
const doSkip = () => {
const dur = Number(this.video.duration || 0);
if (skipToEnd && dur > 5 && isFinite(dur)) {
this.video.currentTime = Math.max(0, dur - 5);
}
};
// duration 可能还没加载,监听 loadedmetadata
if (this.video.readyState >= 1 && Number(this.video.duration || 0) > 0) {
doSkip();
} else {
this.video.addEventListener('loadedmetadata', doSkip, { once: true });
// 兜底:1秒后再试一次
setTimeout(doSkip, 1000);
}
if (this.video.paused) {
this.video.play().catch(() => {});
}
}
_startWatchdog() {
// 先清除旧的 watchdog(防止重复创建)
if (this.watchdog) {
clearInterval(this.watchdog);
this.watchdog = null;
}
this.watchdog = setInterval(() => {
if (!this.video) return;
// 暂停或停止状态:强制 pause video,并不再恢复播放
const paused = GM_getValue(CONFIG.PAUSED_KEY, '') === '1';
const stopped = GM_getValue(CONFIG.STOPPED_KEY, '') === '1';
if (paused || stopped) {
try { this.video.pause(); } catch (_) {}
this._dismissModals();
return;
}
// 保持静音和倍速
this.video.muted = true;
this.video.playbackRate = 2;
if (this.video.paused && !this.video.ended) {
this.video.play().catch(() => {});
}
// 确保跳转到最后5秒(平台可能重置进度)
const dur = Number(this.video.duration || 0);
const cur = Number(this.video.currentTime || 0);
if (dur > 5 && isFinite(dur) && cur < dur - 5) {
this.video.currentTime = Math.max(0, dur - 5);
}
// 检测视频结束:必须满足以下任一条件
// 1. video.ended 事件触发(最可靠)
// 2. 已播放至少4秒 且 当前时间已接近末尾(cur >= dur - 0.5)
// 注意:不能用 cur/dur >= 0.98,因为跳到最后5秒时比值已超过0.98
const elapsed = Date.now() - (this._playStartTime || 0);
const playedEnough = elapsed >= 4000; // 至少播放4秒
if (dur > 0 && this.video.ended) {
this._handleEnded();
} else if (dur > 0 && playedEnough && cur >= dur - 0.5) {
// 已播放足够时间且接近末尾,视为完成
this._handleEnded();
}
// 关闭弹窗
this._dismissModals();
}, 2000);
}
_handlePause() {
if (!this.video || this.video.ended) return;
// 如果已停止,不自动恢复播放
if (GM_getValue(CONFIG.STOPPED_KEY, '') === '1') return;
setTimeout(() => {
if (GM_getValue(CONFIG.STOPPED_KEY, '') === '1') return;
if (this.video && this.video.paused && !this.video.ended) {
this.video.play().catch(() => {});
}
}, 500);
}
_handleEnded() {
if (this.onEndedCallback) {
this.onEndedCallback();
this.onEndedCallback = null; // 防止重复触发
}
}
_bindVisibilityHandler() {
this._visibilityHandler = () => {
if (!this.video) return;
if (document.visibilityState === 'visible' && this.video.paused && !this.video.ended) {
this.video.play().catch(() => {});
}
};
document.addEventListener('visibilitychange', this._visibilityHandler);
}
_dismissModals() {
const selectors = [
'.ant-modal-wrap','.el-dialog__wrapper','.swal2-container',
'[class*="modal"][class*="show"]','[class*="dialog"][class*="open"]'
];
selectors.forEach(sel => {
document.querySelectorAll(sel).forEach(el => {
const closeBtn = el.querySelector('.ant-modal-close,.el-dialog__close,.swal2-close,[class*="close"]');
if (closeBtn) closeBtn.click();
});
});
}
}
// ==================== 主控制器 ====================
class MainController {
constructor() {
this.state = {
running: false,
paused: false,
courses: [],
currentCourseIndex: -1,
currentVideoIndex: -1,
doneVideos: 0,
totalVideos: 0,
earnedPeriod: 0,
targetPeriod: CONFIG.TARGET_PERIOD,
mode: 'idle', // idle / second-learning / auto-learning
};
this.secondLearning = new SecondLearning();
this.autoLearning = new AutoLearning();
this.panel = ProgressPanel.getInstance();
}
static getInstance() {
if (!MainController._instance) {
MainController._instance = new MainController();
}
return MainController._instance;
}
// ========== 跨页面流程状态机 ==========
_loadFlowState() {
try {
const raw = GM_getValue(CONFIG.FLOW_STATE_KEY, '');
if (raw) {
const f = JSON.parse(raw);
// 兼容旧版状态:补全新字段
if (!f.processedCourseIds) f.processedCourseIds = {};
if (!f.processedResourceIds) f.processedResourceIds = {};
if (f.secondLearnDone) delete f.secondLearnDone; // 已废弃
if (f.autoLearnIdx === undefined) f.autoLearnIdx = -1; // -1=秒学阶段
if (f.certifiedPeriod === undefined) f.certifiedPeriod = 0;
if (!f.startTime) f.startTime = Date.now(); // 记录开始时间
return f;
}
} catch (_) {}
return {
running: false,
courseIdx: 0,
videoIdx: 0,
doneVideos: 0,
totalVideos: 0,
earnedPeriod: 0,
certifiedPeriod: 0,
processedCourseIds: {},
processedResourceIds: {},
autoLearnIdx: -1, // -1=秒学阶段, >=0=自动学习阶段
startTime: Date.now() // 记录开始时间
};
}
_saveFlowState(s) {
GM_setValue(CONFIG.FLOW_STATE_KEY, JSON.stringify(s));
}
_clearFlowState() {
GM_setValue(CONFIG.FLOW_STATE_KEY, '');
GM_setValue(CONFIG.PERIOD_ASKED_KEY, '');
}
// 构建课程详情页URL(指定video resourceId)
_buildDetailUrl(course, resourceId) {
const rid = resourceId || course.firstResourceId;
return `https://basic.smartedu.cn/teacherTraining/courseDetail?courseId=${course.id}&tag=${encodeURIComponent(CONFIG.TAG)}&channelId=&libraryId=${CONFIG.LIBRARY_ID}&breadcrumb=${encodeURIComponent(CONFIG.TAG)}&resourceId=${rid}`;
}
async start() {
if (this.state.running) return;
// 清除停止标记,启动新的一次运行
GM_setValue(CONFIG.STOPPED_KEY, '');
this.state.running = true;
this.panel.show();
// 清除暂停残留(不再使用暂停功能)
GM_setValue(CONFIG.PAUSED_KEY, '');
this.state.paused = false;
this.panel.updatePauseButton('running');
this.panel.log('🚀 一键秒学已启动');
try {
this.secondLearning.init();
const userId = Utils.getUserId();
if (!userId) throw new Error('无法获取用户ID,请先登录');
this.state.userId = userId;
// 初始化流程状态
const flow = this._loadFlowState();
// 检测是否有未完成的断点(即使 flow.running=false)
const hasProgress = flow.courseIdx > 0 || flow.autoLearnIdx >= 0 || Object.keys(flow.processedCourseIds || {}).length > 0;
if (!hasProgress) {
// 首次启动 → 重置流程状态
flow.running = true;
flow.courseIdx = 0;
flow.videoIdx = 0;
flow.doneVideos = 0;
flow.totalVideos = 0;
flow.processedCourseIds = {};
flow.processedResourceIds = {};
flow.autoLearnIdx = -1; // -1=秒学阶段
flow.startTime = Date.now(); // 记录开始时间
// 预估总视频数
let total = 0;
for (const c of COURSES) {
try {
const rs = await this.secondLearning.getCourseResources(c.id);
total += rs.length;
} catch (_) {}
}
flow.totalVideos = total;
this._saveFlowState(flow);
this.state.totalVideos = total;
this.panel.log('🆕 首次启动,从头开始学习');
} else {
// 有断点 → 从断点继续
flow.running = true;
this._saveFlowState(flow);
this.state.totalVideos = flow.totalVideos || 0;
this.state.doneVideos = flow.doneVideos || 0;
this.panel.log(`📍 从断点继续:课程[${flow.courseIdx + 1}/${COURSES.length}],视频索引=${flow.autoLearnIdx}`);
}
// 初始化学时显示
try {
const pd = await this.secondLearning.queryEarnedPeriod(userId, CONFIG.TRAIN_ID);
this.state.earnedPeriod = pd._totalEarned || 0;
this.state.certifiedPeriod = pd._certified || 0;
this.panel.updateEarned(this.state.earnedPeriod, CONFIG.TARGET_PERIOD);
this.panel.updateCertified(this.state.certifiedPeriod, CONFIG.TARGET_PERIOD);
this.panel.log(`💡 已学习 ${this.state.earnedPeriod.toFixed(2)} 学时 | 已认定 ${this.state.certifiedPeriod.toFixed(2)} / 目标 ${CONFIG.TARGET_PERIOD} 学时`);
} catch (_) {}
// 判断当前页面:如果不是课程详情页,就跳转到第一个未完成课程的详情页
const path = location.pathname;
if (path.includes('/teacherTraining/courseDetail')) {
// 在课程详情页,执行当前页视频处理流程
await this._processCurrentDetailPage();
} else {
// 非详情页,跳转到第一个未完成课程
await this._jumpToNextPendingCourse();
}
} catch (err) {
this.panel.log('❌ 错误: ' + err.message);
console.error(err);
}
}
// 找到下一个未完成课程并跳转(必修4门优先 → 选修 → 总认定达10停止)
async _jumpToNextPendingCourse() {
if (!this._checkContinue()) return;
const flow = this._loadFlowState();
const userId = this.state.userId || Utils.getUserId();
let periodData = null;
try {
periodData = await this.secondLearning.queryEarnedPeriod(userId, CONFIG.TRAIN_ID);
this.state.earnedPeriod = periodData._totalEarned || 0;
this.state.certifiedPeriod = periodData._certified || 0;
this.panel.updateEarned(this.state.earnedPeriod, CONFIG.TARGET_PERIOD);
this.panel.updateCertified(this.state.certifiedPeriod, CONFIG.TARGET_PERIOD);
this.panel.log(`📊 当前已学习 ${this.state.earnedPeriod.toFixed(2)} 学时 | 已认定 ${this.state.certifiedPeriod.toFixed(2)}/${CONFIG.TARGET_PERIOD} 学时`);
} catch (_) {}
this.panel.log('━━━ 开始检查课程完成情况 ━━━');
for (let i = 0; i < COURSES.length; i++) {
const c = COURSES[i];
const status = periodData ? Number(periodData[`${c.id}-status`] || 0) : 0;
const earned = periodData ? Number(periodData[c.id] || 0) : 0;
const required = c.period || 0.01;
const isCertDone = (status >= 1) || (earned >= required - 1e-6);
const courseType = c.period > 0 ? `必修(认定${c.period}学时)` : '选修';
const earnedStr = earned.toFixed(2);
const reqStr = c.period > 0 ? c.period.toFixed(2) : '—';
if (isCertDone) {
this.panel.log(`✅ 课程[${i + 1}/${COURSES.length}] ${c.name} (${courseType}) 已达标(已学习${earnedStr}/认定要求${reqStr}),跳过`);
continue;
}
// 找到未达标课程
flow.courseIdx = i;
flow.videoIdx = 0;
flow.autoLearnIdx = -1; // -1 = 从秒学阶段开始(强制秒学)
this._saveFlowState(flow);
const url = this._buildDetailUrl(c);
this.panel.log(`➡️ 课程[${i + 1}/${COURSES.length}] ${c.name} (${courseType}) 未达标(已学习${earnedStr}/认定要求${reqStr}),开始学习`);
this.panel.updateTask(c.name, '跳转课程详情页...');
GM_setValue(CONFIG.AUTO_START_KEY, '1');
setTimeout(() => { window.location.href = url; }, 600);
return;
}
this.panel.log('━━━ 所有课程已检查完毕 ━━━');
await this._checkComplete(true);
}
// 在课程详情页时的处理流程:
// 阶段1:先秒学整个课程的所有视频(API上报,不跳转)
// 阶段2:再逐个跳转视频页面,自动播放最后5秒
async _processCurrentDetailPage() {
if (!this._checkContinue()) return;
const flow = this._loadFlowState();
if (!flow.running) return;
const urlParams = new URLSearchParams(location.search);
const courseId = urlParams.get('courseId');
const resourceId = urlParams.get('resourceId');
const course = COURSES.find(c => c.id === courseId);
if (!course) {
this.panel.log('⚠️ 未找到课程信息');
return;
}
flow.courseIdx = COURSES.findIndex(c => c.id === courseId);
const courseType = course.period > 0 ? `必修(认定${course.period}学时)` : '选修';
this.panel.log(`━━━ 进入课程[${flow.courseIdx + 1}/${COURSES.length}]: ${course.name} (${courseType}) ━━━`);
this.panel.updateTask(course.name, '加载视频资源...');
// 获取课程所有视频资源
let resources;
try {
resources = await this.secondLearning.getCourseResources(course.id);
} catch (err) {
this.panel.log('❌ 获取课程资源失败: ' + err.message);
return;
}
if (!resources.length) {
this.panel.log('⚠️ 课程无视频资源,跳到下一课');
flow.processedCourseIds[course.id] = true;
flow.courseIdx++;
this._saveFlowState(flow);
await this._maybeAskPeriod();
await this._jumpToNextPendingCourse();
return;
}
this.panel.log(`📋 课程共 ${resources.length} 个视频`);
// autoLearnIdx: -1 = 秒学阶段, >=0 = 自动学习阶段(对应视频索引)
if (flow.autoLearnIdx === undefined || flow.autoLearnIdx === null) flow.autoLearnIdx = -1;
const isInSecondLearnPhase = (flow.autoLearnIdx === -1);
// ========== 阶段1:秒学所有视频(强制执行,不跳过)==========
if (isInSecondLearnPhase) {
this.panel.log(`⏩ 阶段1:开始秒学全部 ${resources.length} 个视频(强制执行)`);
for (let i = 0; i < resources.length; i++) {
if (!this._checkContinue()) return;
// 暂停检查:暂停时阻塞等待,恢复后继续(停止则中断)
if (!await this._waitIfPaused()) return;
if (!this._checkContinue()) return;
const res = resources[i];
const title = res.title || `视频${i + 1}`;
const already = !!flow.processedResourceIds[res.resourceId];
try {
this.panel.updateTask(`${course.name}`, `秒学中 [${i + 1}/${resources.length}] ${title}`);
if (already) {
this.panel.log(` [${i + 1}/${resources.length}] 秒学中: ${title}(之前已秒学过,重新执行)`);
} else {
this.panel.log(` [${i + 1}/${resources.length}] 秒学中: ${title}`);
}
await this.secondLearning.reportVideoPosition(res.resourceId, this.state.userId, res.duration || 300);
flow.processedResourceIds[res.resourceId] = true;
if (!already) {
flow.doneVideos = (flow.doneVideos || 0) + 1;
this.state.doneVideos = flow.doneVideos;
}
this.panel.log(` [${i + 1}/${resources.length}] ✅ 秒学成功: ${title}`);
await Utils.wait(300);
} catch (err) {
this.panel.log(` [${i + 1}/${resources.length}] ❌ 秒学失败: ${title} - ${err.message}`);
}
}
// 秒学阶段完成 → 切换到自动学习阶段
flow.autoLearnIdx = 0;
this._saveFlowState(flow);
this.panel.log(`✅ 阶段1完成:课程「${course.name}」全部视频秒学完毕`);
// 秒学完刷新一次学时
await this._refreshPeriod();
// 检查是否已停止
if (!this._checkContinue()) return;
} else {
this.panel.log(`⏩ 阶段1已完成(秒学阶段已跳过),直接进入自动学习`);
}
// ========== 阶段2:自动学习(逐个视频播放最后5秒)==========
if (flow.autoLearnIdx < 0) flow.autoLearnIdx = 0;
this.panel.log(`▶️ 阶段2:开始自动学习(播放最后5秒确保认定)`);
// 确定当前应该学习的视频索引
let targetIdx = flow.autoLearnIdx;
if (targetIdx >= resources.length) {
// 所有视频自动学习完毕
this._onCourseAutoLearnDone(course, flow);
return;
}
const targetRes = resources[targetIdx];
const targetTitle = targetRes.title || `视频${targetIdx + 1}`;
// 如果当前页面不是目标视频,跳转过去
if (resourceId !== targetRes.resourceId) {
this.panel.log(` [${targetIdx + 1}/${resources.length}] 跳转至视频: ${targetTitle}`);
this.panel.updateTask(course.name, `加载视频 [${targetIdx + 1}/${resources.length}]`);
const jumpUrl = this._buildDetailUrl(course, targetRes.resourceId);
GM_setValue(CONFIG.AUTO_START_KEY, '1');
this._saveFlowState(flow);
setTimeout(() => { window.location.href = jumpUrl; }, 500);
return;
}
// 当前页面正好是目标视频 → 暂停检查后自动播放最后5秒
if (!this._checkContinue()) return;
if (!await this._waitIfPaused()) return;
if (!this._checkContinue()) return;
this.panel.log(` [${targetIdx + 1}/${resources.length}] 自动学习中: ${targetTitle}(播放最后5秒)`);
this.panel.updateTask(course.name, `自动学习 [${targetIdx + 1}/${resources.length}] ${targetTitle}`);
this.state.mode = 'auto-learning';
try {
await this._autoPlayCurrentVideo();
} catch (err) {
this.panel.log(` [${targetIdx + 1}/${resources.length}] ⚠️ 自动播放异常: ${err.message}`);
}
this.state.mode = 'idle';
// 关键检查:如果已停止,不要继续执行后续流程
if (!this._checkContinue()) return;
this.panel.log(` [${targetIdx + 1}/${resources.length}] ✅ 自动学习完成: ${targetTitle}`);
// 刷新学时
await this._refreshPeriod();
// 检查是否已停止(关键:防止停止后继续跳转下一个视频)
if (!this._checkContinue()) return;
// 检查认定学时是否达标
const shouldContinue = await this._maybeAskPeriod();
if (shouldContinue === false) return;
// 移动到下一个视频
flow.autoLearnIdx = targetIdx + 1;
this._saveFlowState(flow);
if (flow.autoLearnIdx >= resources.length) {
// 所有视频自动学习完毕
this._onCourseAutoLearnDone(course, flow);
} else {
// 跳转到下一个视频
const nextRes = resources[flow.autoLearnIdx];
const nextTitle = nextRes.title || `视频${flow.autoLearnIdx + 1}`;
this.panel.log(` ➡️ 下一个视频: ${nextTitle}`);
this.panel.updateTask(course.name, `加载视频 [${flow.autoLearnIdx + 1}/${resources.length}]`);
const jumpUrl = this._buildDetailUrl(course, nextRes.resourceId);
GM_setValue(CONFIG.AUTO_START_KEY, '1');
this._saveFlowState(flow);
setTimeout(() => { window.location.href = jumpUrl; }, 500);
}
}
// 课程所有视频自动学习完毕
async _onCourseAutoLearnDone(course, flow) {
if (!this._checkContinue()) return;
// 上报课程进度
try {
await this._reportCourseProgress(course);
} catch (_) {}
flow.processedCourseIds[course.id] = true;
flow.courseIdx++;
flow.autoLearnIdx = -1; // 下一课从秒学阶段开始(强制秒学)
this._saveFlowState(flow);
this.panel.log(`✅ 课程「${course.name}」全部学习完成`);
// 刷新学时
await this._refreshPeriod();
this.panel.log(`📊 当前已学习 ${this.state.earnedPeriod.toFixed(2)} 学时 | 已认定 ${this.state.certifiedPeriod.toFixed(2)}/${CONFIG.TARGET_PERIOD} 学时`);
// 检查认定学时是否达标
const shouldContinue = await this._maybeAskPeriod();
if (shouldContinue === false) return;
// 跳到下一课
this.panel.log(`➡️ 进入下一个课程...`);
await this._jumpToNextPendingCourse();
}
// 等待当前页面 video 出现并播放最后5秒
_autoPlayCurrentVideo() {
return new Promise((resolve) => {
// 保存 resolve 引用,以便 stop() 时能强制终止
this._autoPlayResolve = resolve;
const totalTimeout = setTimeout(() => {
this.panel.log('⏱️ 自动播放超时(25秒),继续下一步');
this._clearAutoPlayTimers();
this._autoPlayResolve = null;
resolve();
}, 25000);
this._autoPlayTimers = { totalTimeout };
// 等 video 元素出现
let tries = 0;
const waitVideo = setInterval(() => {
// 检查停止状态
if (GM_getValue(CONFIG.STOPPED_KEY, '') === '1') {
clearInterval(waitVideo);
clearTimeout(totalTimeout);
this._clearAutoPlayTimers();
this._autoPlayResolve = null;
resolve();
return;
}
tries++;
const video = document.querySelector('video');
if (video && video.src) {
clearInterval(waitVideo);
this.panel.log('▶️ 开始自动播放视频(跳至最后5秒,静音2倍速)');
// 记录 play start time 用于确保至少播放4秒
this.autoLearning._playStartTime = Date.now();
this.autoLearning._skipDone = false;
// 启动自动学习(带回调)
this.autoLearning.start(video, () => {
this._clearAutoPlayTimers();
this.autoLearning.stop();
this.panel.log('⏹️ 视频播放完毕');
clearTimeout(totalTimeout);
this._autoPlayResolve = null;
setTimeout(resolve, 1500);
}, true);
return;
}
if (tries > 40) { // 约20秒没等到video
clearInterval(waitVideo);
this.panel.log('ℹ️ 未检测到video元素,跳过自动播放');
clearTimeout(totalTimeout);
this._clearAutoPlayTimers();
this._autoPlayResolve = null;
setTimeout(resolve, 1500);
}
}, 500);
});
}
_clearAutoPlayTimers() {
if (this._autoPlayTimers) {
if (this._autoPlayTimers.totalTimeout) clearTimeout(this._autoPlayTimers.totalTimeout);
if (this._autoPlayTimers.watchdogTimer) clearTimeout(this._autoPlayTimers.watchdogTimer);
this._autoPlayTimers = null;
}
// 如果 _autoPlayCurrentVideo 还在等待,强制 resolve
if (this._autoPlayResolve) {
const resolve = this._autoPlayResolve;
this._autoPlayResolve = null;
resolve();
}
}
async _refreshPeriod() {
try {
// 优先从页面DOM抓取(最准确,与首页显示同步)
// 支持 /training/ 和 /teacherTraining 两种路径
const isTrainingPage = location.pathname.includes('/training/') || location.pathname.includes('/teacherTraining');
if (isTrainingPage) {
const domData = this._parsePeriodFromDOM();
if (domData) {
this.state.earnedPeriod = domData.earned;
this.state.certifiedPeriod = domData.certified;
this.panel.updateEarned(this.state.earnedPeriod, CONFIG.TARGET_PERIOD);
this.panel.updateCertified(this.state.certifiedPeriod, CONFIG.TARGET_PERIOD);
this.panel.log(`📊 已学习: ${this.state.earnedPeriod.toFixed(2)} | 已认定: ${this.state.certifiedPeriod.toFixed(2)} / 目标: ${CONFIG.TARGET_PERIOD} (DOM同步)`);
return;
}
}
// 如果DOM抓取失败或不在研修页,回退到API
const pd = await this.secondLearning.queryEarnedPeriod(this.state.userId, CONFIG.TRAIN_ID);
this.state.earnedPeriod = pd._totalEarned || 0;
this.state.certifiedPeriod = pd._certified || 0;
this.panel.updateEarned(this.state.earnedPeriod, CONFIG.TARGET_PERIOD);
this.panel.updateCertified(this.state.certifiedPeriod, CONFIG.TARGET_PERIOD);
this.panel.log(`📊 已学习: ${this.state.earnedPeriod.toFixed(2)} | 已认定: ${this.state.certifiedPeriod.toFixed(2)} / 目标: ${CONFIG.TARGET_PERIOD} (API)`);
} catch (err) {
this.panel.log('⚠️ 学时刷新失败: ' + err.message);
}
}
// 从页面DOM解析学时(首页适用)
_parsePeriodFromDOM() {
try {
const text = document.body.innerText;
// 匹配已学习学时:实际格式 "已学习\n17.27学时"
const earnMatch = text.match(/已学习\s*([\d.]+)\s*学时/);
// 匹配已认定学时:实际格式 "已认定 / 要求认定\n8.39 / 10学时"
const certMatch = text.match(/已认定\s*\/\s*要求认定\s*([\d.]+)\s*\/\s*([\d.]+)\s*学时/);
// 备选:其他可能的格式
const altCertMatch = text.match(/已认定\s*([\d.]+)\s*\/\s*([\d.]+)\s*学时/);
if (earnMatch || certMatch || altCertMatch) {
let certified = this.state.certifiedPeriod;
let earned = this.state.earnedPeriod;
if (earnMatch) {
earned = parseFloat(earnMatch[1]);
}
if (certMatch) {
certified = parseFloat(certMatch[1]);
} else if (altCertMatch && altCertMatch[1] !== '要求认定') {
// 确保不是匹配到 "已认定 / 要求认定" 这种
certified = parseFloat(altCertMatch[1]);
}
return { earned, certified };
}
// 另一种备选:课程卡片中的汇总格式
const summaryMatch = text.match(/总进度[\s\S]*?已学习\s*([\d.]+)\s*学时[\s\S]*?已认定\s*\/\s*要求认定\s*([\d.]+)\s*\/\s*([\d.]+)/);
if (summaryMatch) {
return {
earned: parseFloat(summaryMatch[1]),
certified: parseFloat(summaryMatch[2])
};
}
} catch (_) {}
return null;
}
// 学时达标弹窗(以"已认定学时"达到10为准):继续学习 / 完成学习 → 返回 true=继续,false=完成
_maybeAskPeriod() {
return new Promise((resolve) => {
if (this.state.certifiedPeriod < CONFIG.TARGET_PERIOD) return resolve(true);
if (GM_getValue(CONFIG.PERIOD_ASKED_KEY, '') === '1') return resolve(true); // 已选继续
// 弹窗询问
const overlay = document.createElement('div');
overlay.style.cssText = `
position:fixed;top:0;left:0;width:100%;height:100%;
background:rgba(0,0,0,0.55);z-index:2147483647;
display:flex;align-items:center;justify-content:center;
font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
`;
overlay.innerHTML = `
🎯
已完成规定认定学时
已认定 ${this.state.certifiedPeriod.toFixed(2)} / 目标 ${CONFIG.TARGET_PERIOD} 学时
是否继续学习剩余课程?
`;
document.body.appendChild(overlay);
overlay.querySelector('#sumuve-period-continue').addEventListener('click', () => {
overlay.remove();
GM_setValue(CONFIG.PERIOD_ASKED_KEY, '1');
resolve(true);
});
overlay.querySelector('#sumuve-period-finish').addEventListener('click', () => {
overlay.remove();
this.stop();
this._showCompleteDialog();
resolve(false);
});
});
}
async _reportCourseProgress(course) {
const progressData = {
user_id: this.state.userId,
resource_id: course.id,
resource_name: course.name,
resource_type: 't_course',
progress: 100,
status: 2,
ext_info: JSON.stringify({
cv: 1,
platform: 'web',
tags: CONFIG.TAG,
origin: CONFIG.TAG,
additional_params: { library_id: CONFIG.LIBRARY_ID }
})
};
try {
await this.secondLearning.reportCourseProgress(CONFIG.TRAIN_ID, course.id, progressData);
} catch (_) {}
}
async _checkComplete(forceCheck) {
if (forceCheck) await this._refreshPeriod();
// 以"已认定学时"达到10为准
if (this.state.certifiedPeriod >= CONFIG.TARGET_PERIOD) {
this._showCompleteDialog();
} else {
this.panel.log(`⚠️ 认定学时未达标 (${this.state.certifiedPeriod.toFixed(2)}/${CONFIG.TARGET_PERIOD}),继续学习`);
}
}
_showCompleteDialog() {
this.state.running = false;
const flow = this._loadFlowState();
flow.running = false;
this._saveFlowState(flow);
GM_setValue(CONFIG.AUTO_START_KEY, '');
this.panel.log('🎉 恭喜!认定学时已达标!');
this.autoLearning.stop();
// 隐藏进度面板(不显示小圆球,弹窗流程结束后自行处理)
this.panel.hide(false);
// 计算总用时
const totalSeconds = flow.startTime ? Math.floor((Date.now() - flow.startTime) / 1000) : 0;
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const totalTimeStr = hours > 0 ? `${hours}时${minutes}分${seconds}秒` : `${minutes}分${seconds}秒`;
const overlay = document.createElement('div');
overlay.style.cssText = `
position:fixed;top:0;left:0;width:100%;height:100%;
background:rgba(0,0,0,0.5);z-index:2147483647;
display:flex;align-items:center;justify-content:center;
font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
`;
overlay.innerHTML = `
🎉
恭喜!您已完成全部认定学时
已学习学时:${this.state.earnedPeriod.toFixed(2)}
已认定学时:${this.state.certifiedPeriod.toFixed(2)} / ${this.state.targetPeriod}
总用时:${totalTimeStr}
可关闭此页面,安心去做其他事情啦~
`;
document.body.appendChild(overlay);
overlay.querySelector('#sumuve-complete-ok').addEventListener('click', () => {
overlay.remove();
this._showPromoDialog();
});
}
// 完成后引流弹窗
_showPromoDialog() {
const overlay = document.createElement('div');
overlay.style.cssText = `
position:fixed;top:0;left:0;width:100%;height:100%;
background:rgba(0,0,0,0.5);z-index:2147483647;
display:flex;align-items:center;justify-content:center;
font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
`;
overlay.innerHTML = `
学习已完成
脚本代开发请联系作者sumuve
`;
document.body.appendChild(overlay);
overlay.querySelector('#sumuve-promo-finish').addEventListener('click', () => {
overlay.remove();
this._showFinishBall();
});
}
// 完成后显示小圆球,点击重新显示欢迎弹窗
_showFinishBall() {
const ball = document.createElement('div');
ball.id = 'sumuve-finish-ball';
const left = Math.max(10, window.innerWidth - 80);
const top = Math.max(10, window.innerHeight / 2 - 30);
ball.style.cssText = `
position:fixed;left:${left}px;top:${top}px;
width:60px;height:60px;border-radius:50%;
background:linear-gradient(135deg,#22c55e,#16a34a);
box-shadow:0 6px 20px rgba(34,197,94,0.4);
display:flex;align-items:center;justify-content:center;
z-index:2147483646;cursor:pointer;
user-select:none;transition:transform 0.15s ease,box-shadow 0.15s ease;
animation:sumuveBallPop 0.3s ease;
`;
ball.innerHTML = `✓`;
ball.title = '点击重新开始学习';
document.body.appendChild(ball);
// 拖拽和点击逻辑
let isDragging = false, moved = false, startX = 0, startY = 0, initLeft = 0, initTop = 0;
ball.addEventListener('click', (e) => {
if (moved) { moved = false; return; }
// 点击:显示欢迎弹窗
ball.remove();
const welcome = new WelcomeDialog();
welcome.show();
});
ball.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
const rect = ball.getBoundingClientRect();
isDragging = true;
moved = false;
startX = e.clientX;
startY = e.clientY;
initLeft = rect.left;
initTop = rect.top;
ball.style.transition = 'none';
ball.style.transform = 'scale(0.95)';
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true;
let nx = initLeft + dx;
let ny = initTop + dy;
nx = Math.max(0, Math.min(window.innerWidth - 60, nx));
ny = Math.max(0, Math.min(window.innerHeight - 60, ny));
ball.style.left = nx + 'px';
ball.style.top = ny + 'px';
ball.style.right = 'auto';
ball.style.bottom = 'auto';
});
document.addEventListener('mouseup', () => {
if (!isDragging) return;
isDragging = false;
ball.style.transform = '';
ball.style.transition = 'transform 0.15s ease,box-shadow 0.15s ease';
const rect = ball.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
let targetLeft = rect.left;
if (cx < window.innerWidth / 2) targetLeft = 10;
else targetLeft = window.innerWidth - 70;
ball.style.transition = 'left 0.25s ease,transform 0.15s ease,box-shadow 0.15s ease';
ball.style.left = targetLeft + 'px';
ball.style.right = 'auto';
});
// 触屏支持
ball.addEventListener('touchstart', (e) => {
if (e.touches.length !== 1) return;
const t = e.touches[0];
const rect = ball.getBoundingClientRect();
isDragging = true;
moved = false;
startX = t.clientX;
startY = t.clientY;
initLeft = rect.left;
initTop = rect.top;
ball.style.transition = 'none';
}, { passive: true });
document.addEventListener('touchmove', (e) => {
if (!isDragging || e.touches.length !== 1) return;
const t = e.touches[0];
const dx = t.clientX - startX;
const dy = t.clientY - startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true;
let nx = initLeft + dx;
let ny = initTop + dy;
nx = Math.max(0, Math.min(window.innerWidth - 60, nx));
ny = Math.max(0, Math.min(window.innerHeight - 60, ny));
ball.style.left = nx + 'px';
ball.style.top = ny + 'px';
ball.style.right = 'auto';
ball.style.bottom = 'auto';
}, { passive: true });
document.addEventListener('touchend', () => {
if (!isDragging) return;
isDragging = false;
const rect = ball.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
let targetLeft = rect.left;
if (cx < window.innerWidth / 2) targetLeft = 10;
else targetLeft = window.innerWidth - 70;
ball.style.transition = 'left 0.25s ease,transform 0.15s ease';
ball.style.left = targetLeft + 'px';
});
}
async _waitIfPaused() {
// 持久化暂停:从GM存储读取暂停状态
// 遇到停止标记立即返回 false 中断调用者
while (true) {
if (GM_getValue(CONFIG.STOPPED_KEY, '') === '1') return false;
if (GM_getValue(CONFIG.PAUSED_KEY, '') !== '1') break;
this.state.paused = true;
await Utils.wait(500);
}
this.state.paused = false;
return this._checkContinue();
}
// 快速检查是否可继续(state.running && 未点停止)
_checkContinue() {
if (GM_getValue(CONFIG.STOPPED_KEY, '') === '1') return false;
return !!this.state.running;
}
togglePause() {
this.state.paused = !this.state.paused;
GM_setValue(CONFIG.PAUSED_KEY, this.state.paused ? '1' : '');
this.panel.updatePauseButton(this.state.paused ? 'paused' : 'running');
this.panel.log(this.state.paused ? '⏸️ 已暂停' : '▶️ 已继续');
// 暂停时立即停止当前视频播放
if (this.state.paused) this.autoLearning.stop();
}
stop() {
// 先写停止标记,所有异步循环都会立即中断
GM_setValue(CONFIG.STOPPED_KEY, '1');
this.state.running = false;
this.state.paused = false;
GM_setValue(CONFIG.PAUSED_KEY, '');
// 立即停止 watchdog + 暂停页面上所有 video
this.autoLearning.stop();
// 清除自动播放相关定时器
this._clearAutoPlayTimers();
// 清除自动启动标记(停止后不自动恢复)
GM_setValue(CONFIG.AUTO_START_KEY, '');
// 重置 flow.running,确保刷新页面后不会自动恢复
// 但保留其他断点数据(courseIdx, autoLearnIdx等),以便下次手动开始时继续
try {
const flow = this._loadFlowState();
flow.running = false;
this._saveFlowState(flow);
} catch (_) {}
this.panel.updatePauseButton('idle');
this.panel.log('🛑 已停止,点击"开始"继续学习');
}
}
// ==================== 初始化入口 ====================
// 立即显示面板并查询学时(带重试)
async function showPanelAndQueryPeriod() {
const panel = ProgressPanel.getInstance();
panel.show();
const isCourseDetailPage = location.pathname.includes('/teacherTraining/courseDetail');
// 优先从DOM抓取(最准确,与首页显示同步)
// 注意:课程详情页不抓取DOM,避免抓到单个课程的学时而非总学时
const tryDom = () => {
try {
const text = document.body.innerText;
const earnMatch = text.match(/已学习\s*([\d.]+)\s*学时/);
const certMatch = text.match(/已认定\s*\/\s*要求认定\s*([\d.]+)\s*\/\s*([\d.]+)\s*学时/);
if (earnMatch || certMatch) {
const earned = earnMatch ? parseFloat(earnMatch[1]) : null;
const certified = certMatch ? parseFloat(certMatch[1]) : null;
return { earned, certified };
}
} catch (_) {}
return null;
};
// 非课程详情页:立即尝试一次DOM抓取
let domData = null;
if (!isCourseDetailPage) {
domData = tryDom();
if (domData && domData.earned !== null) {
panel.updateEarned(domData.earned, CONFIG.TARGET_PERIOD);
}
if (domData && domData.certified !== null) {
panel.updateCertified(domData.certified, CONFIG.TARGET_PERIOD);
}
if (domData && (domData.earned !== null || domData.certified !== null)) {
const e = domData.earned !== null ? domData.earned.toFixed(2) : '--';
const c = domData.certified !== null ? domData.certified.toFixed(2) : '--';
panel.log(`📊 已学习 ${e} 学时 | 已认定 ${c}/${CONFIG.TARGET_PERIOD} 学时 (DOM)`);
}
}
// 查询API(带重试,确保token准备好)
// 课程详情页直接回退到API;首页如果DOM抓取失败也回退到API
for (let retry = 0; retry < 5; retry++) {
try {
const tokenInfo = Utils.getDynamicToken();
const userId = Utils.getUserId();
if (tokenInfo && userId) {
const sl = new SecondLearning();
sl.tokenInfo = tokenInfo;
const periodData = await sl.queryEarnedPeriod(userId, CONFIG.TRAIN_ID);
// 非课程详情页:优先使用DOM数据(API可能滞后),DOM无数据则用API
let earned, cert;
if (!isCourseDetailPage) {
const freshDom = tryDom();
earned = (freshDom && freshDom.earned !== null) ? freshDom.earned : (periodData._totalEarned || 0);
cert = (freshDom && freshDom.certified !== null) ? freshDom.certified : (periodData._certified || 0);
} else {
// 课程详情页:直接使用API数据
earned = periodData._totalEarned || 0;
cert = periodData._certified || 0;
}
panel.updateEarned(earned, CONFIG.TARGET_PERIOD);
panel.updateCertified(cert, CONFIG.TARGET_PERIOD);
panel.log(`📊 已学习 ${earned.toFixed(2)} 学时 | 已认定 ${cert.toFixed(2)}/${CONFIG.TARGET_PERIOD} 学时 (API)`);
return;
}
} catch (_) {}
await Utils.wait(1000);
}
}
function init() {
const autoStart = GM_getValue(CONFIG.AUTO_START_KEY, '');
const path = location.pathname;
const isTrainingPage = path.includes('/training') || path.includes('/teacherTraining') || path.includes('/x-course');
// 检查是否有真正的进行中流程(AUTO_START + flow.running 双条件)
let flowRunning = false;
if (autoStart === '1') {
try {
const raw = GM_getValue(CONFIG.FLOW_STATE_KEY, '');
if (raw) {
const f = JSON.parse(raw);
flowRunning = !!f.running;
}
} catch (_) {}
if (!flowRunning) {
// AUTO_START 残留但流程已结束 → 清除残留
GM_setValue(CONFIG.AUTO_START_KEY, '');
}
}
// 自动恢复仅限课程详情页(跨页面跳转用),首页/入口页永远手动启动
const isCourseDetailPage = path.includes('/teacherTraining/courseDetail');
if (autoStart === '1' && flowRunning && isCourseDetailPage) {
// 自动恢复模式:在课程详情页有进行中的流程,恢复执行
const run = () => {
showPanelAndQueryPeriod();
// 同时启动学时同步监控(即使在运行模式下也需要检测达标)
startPeriodSyncMonitor();
setTimeout(() => {
MainController.getInstance().start();
}, 2000);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run);
} else {
run();
}
return;
}
// 非自动恢复场景:如果AUTO_START残留但不在详情页,清除它
if (autoStart === '1' && !isCourseDetailPage) {
GM_setValue(CONFIG.AUTO_START_KEY, '');
}
// 首页:显示欢迎弹窗,隐藏进度面板
const welcome = new WelcomeDialog();
if (welcome.shouldShow()) {
// 确保进度面板在首页隐藏(不显示小圆球)
const panel = ProgressPanel.getInstance();
panel.hide(false);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => welcome.show());
} else {
welcome.show();
}
} else if (isTrainingPage && !isCourseDetailPage) {
// 研修相关页面(非首页、非详情页):常驻显示进度面板并立即查询学时
const run = () => {
const panel = ProgressPanel.getInstance();
panel.show();
panel.updatePauseButton('idle');
panel.log('💡 进度面板已就绪,点击"秒学"按钮开始学习');
showPanelAndQueryPeriod();
// 启动持续同步监控:每3秒检查一次DOM并自动更新面板
startPeriodSyncMonitor();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run);
} else {
run();
}
} else if (isCourseDetailPage) {
// 课程详情页:也显示进度面板并启动学时同步
const run = () => {
const panel = ProgressPanel.getInstance();
panel.show();
panel.updatePauseButton('idle');
panel.log('💡 课程详情页 - 进度面板已就绪');
showPanelAndQueryPeriod();
// 启动持续同步监控(课程详情页使用API回退)
startPeriodSyncMonitor();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run);
} else {
run();
}
}
}
// 持续同步学时监控(确保页面数据变更时自动同步到面板)
let _periodSyncTimer = null;
let _lastSynced = { earned: -1, certified: -1 };
let _hasShownCompleteDialog = false; // 防重复弹窗标记
function startPeriodSyncMonitor() {
if (_periodSyncTimer) clearInterval(_periodSyncTimer);
const syncOnce = async () => {
const panel = ProgressPanel.getInstance();
if (!panel || !panel.panel) return;
try {
const isCourseDetailPage = location.pathname.includes('/teacherTraining/courseDetail');
let earned = null, certified = null;
let source = 'API';
if (!isCourseDetailPage) {
// 非课程详情页(首页/研修专题页/视频播放页):优先从DOM抓取总学时
const text = document.body.innerText;
// 严格匹配总进度格式:"已学习 X.XX 学时" 和 "已认定 / 要求认定 X.XX / X.XX 学时"
const earnMatch = text.match(/已学习\s*([\d.]+)\s*学时/);
const certMatch = text.match(/已认定\s*\/\s*要求认定\s*([\d.]+)\s*\/\s*([\d.]+)\s*学时/);
if (earnMatch) earned = parseFloat(earnMatch[1]);
if (certMatch) certified = parseFloat(certMatch[1]);
source = 'DOM';
}
// 课程详情页 或 DOM抓取失败时:回退到API
if (earned === null || certified === null) {
try {
const tokenInfo = Utils.getDynamicToken();
const userId = Utils.getUserId();
if (tokenInfo && userId) {
const sl = new SecondLearning();
sl.tokenInfo = tokenInfo;
const periodData = await sl.queryEarnedPeriod(userId, CONFIG.TRAIN_ID);
if (earned === null) earned = periodData._totalEarned || 0;
if (certified === null) certified = periodData._certified || 0;
}
} catch (_) {}
}
// 仅在数值变化时更新(避免无谓刷新)
if (earned !== null && earned !== _lastSynced.earned) {
panel.updateEarned(earned, CONFIG.TARGET_PERIOD);
_lastSynced.earned = earned;
}
if (certified !== null && certified !== _lastSynced.certified) {
panel.updateCertified(certified, CONFIG.TARGET_PERIOD);
_lastSynced.certified = certified;
// 检测已认定学时是否达到10:无论是否在运行学习任务,只要达标就弹窗
if (certified >= CONFIG.TARGET_PERIOD && !_hasShownCompleteDialog) {
_hasShownCompleteDialog = true; // 标记已弹窗,避免重复
const mc = MainController.getInstance();
if (mc.state.running) {
// 如果正在运行,先停止再弹窗
panel.log(`🎉 已认定学时达到 ${certified.toFixed(2)},停止学习`);
mc.stop();
mc._showCompleteDialog();
} else {
// 未运行学习任务时,独立弹出完成提示
panel.log(`🎉 已认定学时达到 ${certified.toFixed(2)}/${CONFIG.TARGET_PERIOD},认定学时已完成`);
showPeriodCompleteDialog(earned || 0, certified);
}
// 隐藏进度面板(不显示小圆球,弹窗流程结束后自行处理)
panel.hide(false);
}
}
if (earned !== null || certified !== null) {
const e = earned !== null ? earned.toFixed(2) : '--';
const c = certified !== null ? certified.toFixed(2) : '--';
panel.log(`🔄 同步: 已学习 ${e} 学时 | 已认定 ${c}/${CONFIG.TARGET_PERIOD} 学时 (${source})`);
}
} catch (_) {}
};
// 立即执行一次
syncOnce();
// 每3秒检查一次
_periodSyncTimer = setInterval(syncOnce, 3000);
}
// 独立的完成认定学时弹窗(非运行学习任务时调用)
function showPeriodCompleteDialog(earnedPeriod, certifiedPeriod) {
// 读取流程状态中的开始时间计算总用时
let totalTimeStr = '0分0秒';
try {
const raw = GM_getValue(CONFIG.FLOW_STATE_KEY, '');
if (raw) {
const flow = JSON.parse(raw);
if (flow.startTime) {
const totalSeconds = Math.floor((Date.now() - flow.startTime) / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
totalTimeStr = hours > 0 ? `${hours}时${minutes}分${seconds}秒` : `${minutes}分${seconds}秒`;
}
}
} catch (_) {}
const overlay = document.createElement('div');
overlay.style.cssText = `
position:fixed;top:0;left:0;width:100%;height:100%;
background:rgba(0,0,0,0.5);z-index:2147483647;
display:flex;align-items:center;justify-content:center;
font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
`;
overlay.innerHTML = `
🎊
您已完成规定认定学时
已学习学时:${earnedPeriod.toFixed(2)}
已认定学时:${certifiedPeriod.toFixed(2)} / ${CONFIG.TARGET_PERIOD}
总用时:${totalTimeStr}
恭喜!已达到规定的10个认定学时要求
`;
document.body.appendChild(overlay);
overlay.querySelector('#sumuve-period-complete-ok').addEventListener('click', () => {
overlay.remove();
// 弹窗关闭后显示引流弹窗(与主流程保持一致)
showPromoDialogStandalone();
});
}
// 独立的引流弹窗(非运行学习任务时调用)
function showPromoDialogStandalone() {
const overlay = document.createElement('div');
overlay.style.cssText = `
position:fixed;top:0;left:0;width:100%;height:100%;
background:rgba(0,0,0,0.5);z-index:2147483647;
display:flex;align-items:center;justify-content:center;
font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
`;
overlay.innerHTML = `
学习已完成
脚本代开发请联系作者sumuve
`;
document.body.appendChild(overlay);
overlay.querySelector('#sumuve-promo-finish-standalone').addEventListener('click', () => {
overlay.remove();
// 点击结束后显示小圆球
showFinishBallAfterComplete();
});
}
// 完成后显示小圆球(独立函数,供非运行状态时调用)
function showFinishBallAfterComplete() {
const ball = document.createElement('div');
ball.id = 'sumuve-finish-ball';
const left = Math.max(10, window.innerWidth - 80);
const top = Math.max(10, window.innerHeight / 2 - 30);
ball.style.cssText = `
position:fixed;left:${left}px;top:${top}px;
width:60px;height:60px;border-radius:50%;
background:linear-gradient(135deg,#22c55e,#16a34a);
box-shadow:0 6px 20px rgba(34,197,94,0.4);
display:flex;align-items:center;justify-content:center;
z-index:2147483646;cursor:pointer;
user-select:none;transition:transform 0.15s ease,box-shadow 0.15s ease;
animation:sumuveBallPop 0.3s ease;
`;
ball.innerHTML = `✓`;
ball.title = '点击重新开始学习';
document.body.appendChild(ball);
// 拖拽和点击逻辑
let isDragging = false, moved = false, startX = 0, startY = 0, initLeft = 0, initTop = 0;
ball.addEventListener('click', (e) => {
if (moved) { moved = false; return; }
ball.remove();
const welcome = new WelcomeDialog();
welcome.fromComplete = true; // 标记来自完成学时流程
welcome.show();
});
ball.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
const rect = ball.getBoundingClientRect();
isDragging = true;
moved = false;
startX = e.clientX;
startY = e.clientY;
initLeft = rect.left;
initTop = rect.top;
ball.style.transition = 'none';
ball.style.transform = 'scale(0.95)';
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true;
let nx = initLeft + dx;
let ny = initTop + dy;
nx = Math.max(0, Math.min(window.innerWidth - 60, nx));
ny = Math.max(0, Math.min(window.innerHeight - 60, ny));
ball.style.left = nx + 'px';
ball.style.top = ny + 'px';
ball.style.right = 'auto';
ball.style.bottom = 'auto';
});
document.addEventListener('mouseup', () => {
if (!isDragging) return;
isDragging = false;
ball.style.transform = '';
ball.style.transition = 'transform 0.15s ease,box-shadow 0.15s ease';
const rect = ball.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
let targetLeft = rect.left;
if (cx < window.innerWidth / 2) targetLeft = 10;
else targetLeft = window.innerWidth - 70;
ball.style.transition = 'left 0.25s ease,transform 0.15s ease,box-shadow 0.15s ease';
ball.style.left = targetLeft + 'px';
ball.style.right = 'auto';
});
// 触屏支持
ball.addEventListener('touchstart', (e) => {
if (e.touches.length !== 1) return;
const t = e.touches[0];
const rect = ball.getBoundingClientRect();
isDragging = true;
moved = false;
startX = t.clientX;
startY = t.clientY;
initLeft = rect.left;
initTop = rect.top;
ball.style.transition = 'none';
}, { passive: true });
document.addEventListener('touchmove', (e) => {
if (!isDragging || e.touches.length !== 1) return;
const t = e.touches[0];
const dx = t.clientX - startX;
const dy = t.clientY - startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true;
let nx = initLeft + dx;
let ny = initTop + dy;
nx = Math.max(0, Math.min(window.innerWidth - 60, nx));
ny = Math.max(0, Math.min(window.innerHeight - 60, ny));
ball.style.left = nx + 'px';
ball.style.top = ny + 'px';
ball.style.right = 'auto';
ball.style.bottom = 'auto';
}, { passive: true });
document.addEventListener('touchend', () => {
if (!isDragging) return;
isDragging = false;
const rect = ball.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
let targetLeft = rect.left;
if (cx < window.innerWidth / 2) targetLeft = 10;
else targetLeft = window.innerWidth - 70;
ball.style.transition = 'left 0.25s ease,transform 0.15s ease';
ball.style.left = targetLeft + 'px';
});
}
// 启动
init();
})();