// ==UserScript==
// @name 研修网 · 学习助手(自动连播 + 学情看板)
// @namespace https://ipx.yanxiu.com/
// @version 3.1.2
// @description 教师研修网(ipx.yanxiu.com)学习助手:学情看板(读取「看课/学习」板块的标题与考核时长、1440 分钟达标排程、课程与章节视频清单、进度与得分折算、CSV 导出)+ 播放页自动连播(静音自动播放、播完自动下一节、本门学完自动下一门、后台保活、断点续播、看门狗自动重开、自动评分、随堂测自动关闭续播)。面板可拖动可缩放。平台心跳与防挂机验证保持原生行为(不代答、不伪造心跳)。
// @author 爱国者
// @match https://ipx.yanxiu.com/train2/workspace/*
// @match https://ipx.yanxiu.com/grain/course/*
// @run-at document-start
// @grant none
// @noframes
// @license All Rights Reserved
// ==/UserScript==
/* eslint-disable no-empty, no-cond-assign */
(function () {
'use strict';
if (window.__YX_HELPER__) return;
window.__YX_HELPER__ = true;
/* ================================================================== *
* 0. 常量与配置
* ================================================================== */
const VERSION = '3.1.2';
const API = 'https://ipx-api.yanxiu.com';
const BASIC_AUTH = 'Basic c2FucmVuLXdhbmQtcGM6UjNOaHR2MUkyVVpsR1RmcTBv';
const CLIENT_ID = 'ums-teacher-pc';
const K_CFG = 'YX_HELPER_CFG_V3';
const K_QUEUE = 'YX_HELPER_QUEUE_V3';
const K_CACHE = 'YX_HELPER_CACHE_V3';
const K_QQ = 'YX_HELPER_QQ_V3';
const K_LOCK = 'YX_HELPER_LOCK_V3';
const K_DASH = 'YX_HELPER_DASH_BOX_V3';
const K_MINI = 'YX_HELPER_MINI_BOX_V3';
const K_DONE = 'YX_HELPER_DONE_V3';
const K_PC = 'YX_HELPER_PCOURSE_V3';
const REQUIRE_MIN = 1440; // 需观看分钟数
const MAX_SCORE = 45; // 看课满分
const QQ_GROUP = 'https://qm.qq.com/q/Kacqy4da26';
const QR_ALIPAY = 'https://a1.boltp.com/2026/08/31/6a950ca30913e.jpg';
const QR_WECHAT = 'https://a1.boltp.com/2026/08/31/6a950ca33c5d1.png';
const DEFAULT_CFG = {
muted: true, // 静音播放
autoNext: true, // 播完自动下一节 / 自动下一门
keepAlive: true, // 后台保活(防浏览器冻结/节流)
keepAliveGain: 0.008, // 保活音量
resume: true, // 断点续播:跳过本机已完整播完的章节
watchdog: true, // 看门狗:播放页意外关闭时自动重开
notice: true, // 需要你处理时发桌面通知
sound: true, // 需要你处理时响铃
focusTab: true, // 需要你处理时把播放页拉到前台
autoRate: true, // 自动提交课程评分
rateValue: 5, // 自动评分星数
textWait: 8, // 文本/文档段最短停留秒数(等平台完成标记)
autoReadGuide: false, // 本门全部章节学完后,自动打开并滚动阅读「课程指南/专家介绍」
autoStart: true // 进入播放页自动开始
};
const log = (...a) => console.log('%c[学习助手]', 'color:#2563eb;font-weight:700', ...a);
const num = (v) => (v === undefined || v === null ? 0 : Number(v) || 0);
const clamp = (v, a, b) => Math.min(Math.max(num(v), a), b);
const hhmm = (s) => {
s = Math.max(0, Math.round(num(s)));
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
if (h) return h + '小时' + m + '分';
if (m) return m + '分' + (ss && m < 10 ? ss + '秒' : '');
return ss + '秒';
};
const clock = (s) => {
s = Math.max(0, Math.round(num(s)));
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
const p = (n) => (n < 10 ? '0' + n : '' + n);
return (h ? h + ':' : '') + p(m) + ':' + p(ss);
};
const fmtMin = (s) => Math.round(num(s) / 60);
const $ = (sel, root) => (root || document).querySelector(sel);
const $$ = (sel, root) => Array.prototype.slice.call((root || document).querySelectorAll(sel));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
/* ================================================================== *
* 1. 存储
* ================================================================== */
function readJSON(key, def) {
try { const v = JSON.parse(localStorage.getItem(key) || 'null'); return v == null ? def : v; }
catch (e) { return def; }
}
function writeJSON(key, val) { try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) {} }
let cfg = Object.assign({}, DEFAULT_CFG, readJSON(K_CFG, {}));
const saveCfg = () => writeJSON(K_CFG, cfg);
function loadQueue() { return readJSON(K_QUEUE, null); }
function saveQueue(q) { writeJSON(K_QUEUE, q); }
function loadDone() { return readJSON(K_DONE, {}); }
function markDone(courseId, segIdx) {
if (courseId == null || segIdx == null || segIdx < 0) return;
const d = loadDone();
const k = String(courseId);
const arr = d[k] || (d[k] = []);
if (arr.indexOf(segIdx) < 0) { arr.push(segIdx); arr.sort((a, b) => a - b); }
writeJSON(K_DONE, d);
}
function isDone(courseId, segIdx) {
if (courseId == null) return false;
const arr = loadDone()[String(courseId)];
return Array.isArray(arr) && arr.indexOf(segIdx) >= 0;
}
/* ================================================================== *
* 2. 登录令牌捕获 + API
* ================================================================== */
const auth = { token: '', passport: '', toolId: '', toolIds: [] };
let tokenWaiters = [];
function notifyToken() { tokenWaiters.splice(0).forEach((fn) => fn(auth.token)); }
/** 页面自己会请求 ?toolId=xxx,直接嗅探出来,比任何列表接口都可靠 */
function sniffUrl(u) {
try {
const m = String(u == null ? '' : u).match(/[?&]toolId=(\d{6,})/);
if (m) {
if (auth.toolId !== m[1]) log('嗅探到 toolId', m[1]);
auth.toolId = m[1];
if (auth.toolIds.indexOf(m[1]) < 0) auth.toolIds.push(m[1]);
}
} catch (e) {}
}
(function hookHeaders() {
try {
const OX = window.XMLHttpRequest;
if (OX && OX.prototype) {
const oSet = OX.prototype.setRequestHeader;
OX.prototype.setRequestHeader = function (k, v) {
try {
const key = String(k).toLowerCase();
if (key === 'x-dt-accesstoken' && v) { auth.token = String(v); notifyToken(); }
else if (key === 'x-dt-passport' && v && String(v).length < 200) auth.passport = String(v);
} catch (e) {}
return oSet.apply(this, arguments);
};
const oOpen = OX.prototype.open;
OX.prototype.open = function (method, url) {
try { sniffUrl(url); } catch (e) {}
return oOpen.apply(this, arguments);
};
}
if (window.fetch) {
const of = window.fetch;
window.fetch = function (input, init) {
try {
sniffUrl(typeof input === 'string' ? input : (input && (input.url || input.href)));
const h = (init && init.headers) || (input && input.headers) || {};
const g = (n) => (h instanceof Headers ? h.get(n) : h[n] || h[n.toLowerCase()]);
const t = g('X-DT-accessToken');
if (t) { auth.token = String(t); notifyToken(); }
} catch (e) {}
return of.apply(this, arguments);
};
}
} catch (e) {}
})();
function waitToken(timeout) {
if (auth.token) return Promise.resolve(auth.token);
return new Promise((res) => {
const t = setTimeout(() => res(''), timeout || 20000);
tokenWaiters.push((v) => { clearTimeout(t); res(v); });
});
}
function apiHeaders() {
return {
Accept: 'application/json, text/plain, */*',
'Content-Type': 'application/json',
Authorization: BASIC_AUTH,
'X-DT-accessToken': auth.token,
'X-DT-clientId': CLIENT_ID,
'X-DT-Passport': auth.passport || ''
};
}
async function api(path, body) {
const res = await fetch(API + path, {
method: body === undefined ? 'GET' : 'POST',
headers: apiHeaders(),
credentials: 'include',
body: body === undefined ? undefined : JSON.stringify(body)
});
const txt = await res.text();
let json = {};
try { json = JSON.parse(txt); } catch (e) { throw new Error('接口返回非 JSON'); }
const code = json.status && json.status.code;
if (code !== 200) throw new Error((json.status && json.status.desc) || '接口异常');
return json.data;
}
/* ================================================================== *
* 3. 数据模型(学习空间)
* ================================================================== */
const state = { ctx: null, summary: null, modules: [], courses: [], chapters: {}, boards: [], loading: false };
// toolId 与 workspace(projectId) 绑定:不同培训项目 toolId 不同,必须按 projectId 隔离存储,
// 否则会复用其它项目的 toolId 导致 /userCourseModule 校验失败、拉不到课程列表。
const toolKey = (pid) => 'YX_TOOL_ID_V3_' + (pid || '');
function parseCtx() {
const pm = location.pathname.match(/\/train2\/workspace\/(\d+)/);
const projectId = (pm && pm[1]) || new URLSearchParams(location.search).get('projectId') || '';
if (!projectId) return null;
const qs = new URLSearchParams(location.search);
const toolId = qs.get('toolId') || localStorage.getItem(toolKey(projectId)) || '';
return { projectId, toolId };
}
/** 工具 ID 不在网址里:优先用嗅探到的(页面自己会请求),再退回工具列表 */
async function resolveToolId(ctx) {
const tried = [];
const test = async (tid) => {
if (!tid || tried.indexOf(String(tid)) >= 0) return false;
tried.push(String(tid));
try {
const d = await api('/task-center/course/V1/userCourseModule?projectId=' + ctx.projectId + '&toolId=' + tid);
const mods = (d && d.modules) || [];
if (mods.length) { ctx.foundModules = mods; return true; }
} catch (e) {}
return false;
};
if (await test(ctx.toolId)) return ctx.toolId;
if (await test(auth.toolId)) return auth.toolId;
// 页面还在加载中,等一会儿嗅探结果(member 子页面可能较晚发起带 toolId 的请求,延长窗口)
for (let i = 0; i < 40; i++) {
await sleep(500);
if (auth.toolId && await test(auth.toolId)) return auth.toolId;
}
// 最后兜底:工具列表接口(本项目实测返回空,仅作兼容)
const list = await api('/train-project-center/train/manage/my/substance/list?projectId=' + ctx.projectId + '&roleKey=MEMBER&roleLevel=0').catch(() => null);
const arr = (list && (list.list || list.rows)) || (Array.isArray(list) ? list : []);
const cands = [];
arr.forEach((x) => {
[x.toolId, x.substanceId, x.id, x.toolInfo && x.toolInfo.toolId, x.substanceInfo && x.substanceInfo.toolId]
.forEach((v) => { if (v) cands.push(v); });
});
for (let i = 0; i < cands.length; i++) if (await test(cands[i])) return cands[i];
return '';
}
/* ------------------------------------------------------------------
* 学习板块(「看课 / 学习」工具卡)信息:标题 + 考核要求 + 已完成
* 数据源:POST /task-center/examine/result/tool/query
* body { projectId, toolId, examineSubstance, examineSubstanceRole, examineType:'tool' }
* → { toolName, settingResults:[{ requireStr, finishStr }] }
* 平台返回的是带 的 HTML 片段,这里统一去标签后按纯文本使用(防注入)。
* ------------------------------------------------------------------ */
const stripTags = (h) => String(h == null ? '' : h).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
/** 从页面 DOM 的 Vue 组件实例上读 toolId(新看板每个卡片都有 props.toolId) */
function domToolIds() {
const out = [];
const push = (v) => { const s = String(v == null ? '' : v); if (/^\d{6,}$/.test(s) && out.indexOf(s) < 0) out.push(s); };
const sels = ['.tool-card', '.learn-course-list', '.learn-course-tools__list > *', '.learn-course-tools'];
sels.forEach((sel) => {
$$(sel).forEach((el) => {
try {
const inst = el.__vueParentComponent || el.__vue__ || null;
if (inst && inst.props) push(inst.props.toolId);
const sub = el.__vueParentComponent && el.__vueParentComponent.subTree;
if (sub && sub.component && sub.component.props) push(sub.component.props.toolId);
} catch (e) {}
});
});
return out;
}
/** 拉取每个 toolId 的板块考核信息(标题 + 考核要求 + 已完成)
* examineSubstance 取页面的 roleKey(学员态一般是 MEMBER),拿不到就退到其它组合重试 */
async function fetchBoards(toolIds) {
const ctx = state.ctx || {};
const combos = [['MEMBER', 'MEMBER'], ['MEMBER', '0'], ['STUDENT', 'STUDENT']];
const out = [];
for (const tid of (toolIds || [])) {
let done = null;
for (const [sub, subRole] of combos) {
try {
const d = await api('/task-center/examine/result/tool/query', {
projectId: ctx.projectId, toolId: tid,
examineSubstance: sub, examineSubstanceRole: subRole, examineType: 'tool'
});
const sets = (d && d.settingResults) || [];
if (d && (d.toolName || sets.length)) {
done = {
toolId: tid,
title: stripTags(d.toolName) || '',
sets: sets.map((x) => ({ require: stripTags(x && x.requireStr), finish: stripTags(x && x.finishStr) }))
};
break;
}
} catch (e) {}
}
if (done) out.push(done);
else log('板块信息读取失败', tid);
}
return out;
}
/** 解析出「看课/学习」板块的标题与考核数值,供看板展示与达标计算使用 */
function boardReq() {
const list = state.boards || [];
if (!list.length) return null;
const b = list.find((x) => x.sets && x.sets.length) || list[0];
const s0 = (b.sets && b.sets[0]) || {};
const rt = s0.require || '', ft = s0.finish || '';
const g = (re, str) => { const m = String(str || '').match(re); return m ? num(m[1]) : null; };
const minutes = g(/(\d+)\s*分钟/, rt);
// 注意排除「分钟」:否则「已学习 1475 分钟,已得 45 分」会把 1475 当成得分
const score = g(/总分\s*(\d+)/, rt) || g(/(\d+)\s*分(?!钟)/, rt);
const learnedMin = g(/(\d+)\s*分钟/, ft);
const gotScore = g(/已[得获]\D{0,4}(\d+)\s*分/, ft) || g(/(\d+)\s*分(?!钟)/, ft);
return {
title: b.title || '看课',
minutes: minutes || REQUIRE_MIN,
score: score || MAX_SCORE,
learnedMin, gotScore,
requireText: rt, finishText: ft,
all: list
};
}
// training/member 子页面:项目不返回模块,而是按多个 toolId(学段/学科课程包)直接查询课程列表
async function loadMemberCourses(onStep) {
const ctx = state.ctx;
onStep && onStep('读取学情统计…');
try {
state.summary = await api('/train-project-center/person/summary/statistics?trainProjectId=' + ctx.projectId + '&roleLevel=MEMBER&userRole=MEMBER');
} catch (e) { log('summary 失败', e); }
onStep && onStep('定位看课工具…');
// 真实课程列表来自多个 toolId(学段/学科课程包)。
// 三个来源合并:① 嗅探页面自己发出的请求 ② 读页面 DOM 上 Vue 组件的 props.toolId ③ 本地缓存
const mergeTools = () => {
const out = [];
const push = (v) => { const s = String(v == null ? '' : v); if (/^\d{6,}$/.test(s) && out.indexOf(s) < 0) out.push(s); };
(auth.toolIds || []).forEach(push);
domToolIds().forEach(push);
return out;
};
let collected = mergeTools();
if (!collected.length) {
// 等页面发完带 toolId 的请求:每 500ms 检查,如果连续 6 次没有新增 toolId 则认为已稳定
let lastCount = 0, stable = 0;
for (let i = 0; i < 40; i++) {
await sleep(500);
const now = mergeTools();
if (now.length > lastCount) { lastCount = now.length; stable = 0; }
else { stable++; }
if (now.length && stable >= 6) { collected = now; break; }
}
}
// SPA 首屏渲染可能晚于请求:再给 DOM 一点时间
if (!collected.length) {
for (let i = 0; i < 10; i++) {
await sleep(600);
const d = domToolIds();
if (d.length) { collected = d; break; }
}
}
if (!collected.length) {
const cached = localStorage.getItem(toolKey(ctx.projectId)) || auth.toolId || '';
if (cached) collected = [cached];
}
if (!collected.length) throw new Error('未嗅探到「看课」工具(projectId=' + ctx.projectId + '):该培训页可能未加载课程列表,或登录/权限不足。请确认在该页面能看到课程列表后点 ⟳ 重试,并把 projectId 反馈给开发者。');
ctx.toolId = collected[0];
localStorage.setItem(toolKey(ctx.projectId), collected[0]);
localStorage.setItem('YX_TOOL_ID_V3', collected[0]); // 兼容旧缓存读取
onStep && onStep('读取学习板块…');
state.boards = await fetchBoards(collected);
onStep && onStep('读取课程列表…');
const all = [];
for (const tid of collected) {
let page = 1, totalPage = 1;
do {
const d = await api('/task-center/course/V1/queryCourseList?projectId=' + ctx.projectId + '&toolId=' + tid
+ '&moduleSort=1&roleKey=100&pageIndex=' + page + '&pageSize=50&courseType=&learnStatus=0&courseName=');
(d.rows || []).forEach((r) => {
r._module = r.moduleName || r.subjectName || ('课程包 ' + String(tid).slice(-6));
r._toolId = tid; // 记住该课程所属的 toolId,播放页需要
all.push(r);
});
totalPage = d.totalPage || 1; page++;
} while (page <= totalPage);
}
// 去重:同一门课可能出现在多个 toolId 下,按 id 去重保留第一个
const seen = new Set();
state.courses = all.filter((r) => { if (seen.has(r.id)) return false; seen.add(r.id); return true; });
// 模块列表按课程自带的 moduleName 汇总(新看板有真实模块名;旧 training/member 落到「课程包 xxx」兜底)
const modSeen = {};
state.modules = [];
state.courses.forEach((r) => {
const name = String(r._module || '');
if (!name || modSeen[name]) return;
modSeen[name] = 1;
state.modules.push({ moduleId: name, moduleName: name });
});
onStep && onStep('完成');
saveCache();
return state;
}
async function loadAll(onStep) {
const ctx = state.ctx;
if (!ctx) throw new Error('未识别到项目上下文');
// 「看课/学习」板块页(旧 /training/member 与新 /member)课程结构都是「多 toolId 课程包」,
// 不走 userCourseModule 的单项目模块逻辑
const isBoard = /\/train2\/workspace\/\d+\/(?:training\/)?member\/?$/.test(location.pathname);
if (isBoard) {
log('识别为学习空间 member 板块页,使用多 toolId 课程包模式', location.pathname);
return await loadMemberCourses(onStep);
}
onStep && onStep('读取学情统计…');
try {
state.summary = await api('/train-project-center/person/summary/statistics?trainProjectId=' + ctx.projectId + '&roleLevel=MEMBER&userRole=MEMBER');
} catch (e) { log('summary 失败', e); }
onStep && onStep('定位看课工具…');
const tid = await resolveToolId(ctx);
if (!tid) throw new Error('未找到「看课」工具(projectId=' + ctx.projectId + '):该培训页可能未加载课程模块接口,或登录/权限不足。请确认在该页面能看到课程列表后点 ⟳ 重试,并把 projectId 反馈给开发者。');
ctx.toolId = tid;
localStorage.setItem(toolKey(ctx.projectId), tid);
localStorage.setItem('YX_TOOL_ID_V3', tid); // 兼容旧缓存读取
onStep && onStep('读取课程模块…');
let mods = ctx.foundModules;
if (!mods) {
const modData = await api('/task-center/course/V1/userCourseModule?projectId=' + ctx.projectId + '&toolId=' + ctx.toolId);
mods = (modData && modData.modules) || [];
}
state.modules = mods;
onStep && onStep('读取课程列表…');
const all = [];
for (const mod of state.modules) {
let page = 1, totalPage = 1;
do {
const d = await api('/task-center/course/V1/queryCourseList?projectId=' + ctx.projectId + '&toolId=' + ctx.toolId
+ '&moduleId=' + mod.moduleId + '&pageIndex=' + page + '&pageSize=50&roleKey=100&moduleSort=1&learnStatus=0&courseType=');
(d.rows || []).forEach((r) => { r._module = mod.moduleName; all.push(r); });
totalPage = d.totalPage || 1; page++;
} while (page <= totalPage);
}
state.courses = all;
onStep && onStep('完成');
saveCache();
return state;
}
const sgnm = (sg) => sg.sgnm || sg.sgname || sg.name || '';
const segUrl = (sg) => {
const urls = {};
(sg.sgurl || []).forEach((u) => { urls[u.reso] = u.url; });
return urls.m || urls.l || urls.s || '';
};
/** 把 getUserCoursePage 的返回解析成「课程 + 章节」统一结构 */
function parseCoursePage(d) {
const it = d.courseItemVO || {};
const sm = d.courseSummaryVO || {};
const segs = [];
const chps = (((d.courseApiInfoVO || {}).info) || {}).chps || [];
chps.forEach((ch) => (ch.segs || []).forEach((sg) => {
// 平台源码里 sgmd 1/2 对应 icon-doc(文档/文本段),3+ 对应 icon-media(视频/媒体段)。
// 不能靠 sgurl 判断:某些文本/说明段也会带 sgurl(封面或占位),导致被误判为视频。
const video = num(sg.sgmd) >= 3;
const notes = (sg.sgnotes && sg.sgnotes.istanscode === 1 && sg.sgnotes.sgurl && sg.sgnotes.sgurl.url2) ? sg.sgnotes.sgurl.url2 : '';
segs.push({
chp: ch.chpnm || '', name: sgnm(sg), sec: num(sg.sgtm),
url: segUrl(sg), id: String(sg.sgid || ''), i: segs.length,
sgmd: num(sg.sgmd), video: video,
tips: sg.sgtips || '', tipsType: String(sg.courseware_type || ''),
notes: notes
});
}));
return {
id: String(it.id || ''),
name: it.courseName || '',
teacher: it.mainTeacher || '',
module: it.subjectName || '',
total: num(it.totalDuration) || segs.reduce((a, s) => a + s.sec, 0),
visited: num(sm.visitedDuration),
segCount: segs.length,
config: d.courseConfigVO || {},
segs
};
}
async function loadChapters(course) {
if (state.chapters[course.id]) return state.chapters[course.id];
const d = await api('/task-center/course/getUserCoursePage?courseId=' + course.id
+ '&projectId=' + state.ctx.projectId + '&toolId=' + state.ctx.toolId
+ '&courseSourceId=' + course.courseSourceId + '&isMember=0');
const info = parseCoursePage(d);
state.chapters[course.id] = { list: info.segs, config: info.config, meta: info };
return state.chapters[course.id];
}
function saveCache() {
writeJSON(K_CACHE, { t: Date.now(), ctx: state.ctx, modules: state.modules, courses: state.courses, summary: state.summary, boards: state.boards });
}
function loadCache() {
const c = readJSON(K_CACHE, null);
if (!c || Date.now() - c.t > 6 * 3600e3) return false;
state.ctx = c.ctx || state.ctx;
state.modules = c.modules || [];
state.courses = c.courses || [];
state.summary = c.summary;
state.boards = c.boards || [];
return state.courses.length > 0;
}
function stats() {
const courses = state.courses;
const remainSec = courses.reduce((a, c) => a + num(c.noStudyTime), 0);
const doneSec = courses.reduce((a, c) => a + num(c.completeTime), 0);
const todo = courses.filter((c) => num(c.noStudyTime) > 0);
const watchedMin = fmtMin(doneSec);
// 考核要求优先取平台「学习/看课」板块卡片上的真实数值(如 1440 分钟 / 45 分),取不到时退回项目约定常量
const bq = boardReq();
const reqMin = (bq && bq.minutes) || REQUIRE_MIN;
const maxScore = (bq && bq.score) || MAX_SCORE;
return {
total: courses.length, todo: todo.length, finished: courses.length - todo.length,
remainSec, doneSec, watchedMin, reqMin, maxScore, board: bq,
score: Math.min(maxScore, +(watchedMin / reqMin * maxScore).toFixed(2)),
remainMin: Math.max(0, reqMin - watchedMin)
};
}
/** 最短优先:剩余时长升序,累积到 1440 分钟即达标 */
function shortestQueue() {
const todo = state.courses.filter((c) => num(c.noStudyTime) > 0)
.sort((a, b) => num(a.noStudyTime) - num(b.noStudyTime));
const out = [];
const st0 = stats();
let acc = fmtMin(st0.doneSec);
const target = st0.reqMin || REQUIRE_MIN;
for (const c of todo) {
if (acc >= target) break;
out.push({ id: c.id, sid: c.courseSourceId, name: c.courseName, needSec: num(c.noStudyTime), module: c._module, toolId: c._toolId || c.toolId || '' });
acc += num(c.noStudyTime) / 60;
}
return { items: out, accMin: Math.round(acc) };
}
const playerUrl = (id, sid, toolId) => {
const pc = playerCtx();
const qs = pc ? new URLSearchParams(location.search) : null;
const projectId = (state.ctx && state.ctx.projectId) || (pc && pc.projectId) || (qs && qs.get('projectId')) || '';
const tid = toolId || (state.ctx && state.ctx.toolId) || (pc && pc.toolId) || (qs && qs.get('toolId')) || (auth.toolId) || '';
return 'https://ipx.yanxiu.com/grain/course/' + id + '/detail?projectId=' + projectId
+ '&toolId=' + tid + '&courseSourceId=' + sid + '&role=100';
};
/* ================================================================== *
* 3.5 播放页课程上下文(标题 / 时长 / 章节表)
* ================================================================== */
const pdata = { loading: false, loaded: false, err: '', name: '', teacher: '', total: 0, visited: 0, segs: [], config: {} };
function playerCtx() {
const m = location.pathname.match(/\/grain\/course\/(\d+)\/detail/);
if (!m) return null;
const qs = new URLSearchParams(location.search);
const o = {
courseId: m[1], projectId: qs.get('projectId') || '',
toolId: qs.get('toolId') || '', courseSourceId: qs.get('courseSourceId') || ''
};
// 播放页网址里带着 toolId,回写给学习空间页用
if (o.toolId) {
auth.toolId = o.toolId;
try {
if (localStorage.getItem('YX_TOOL_ID_V3') !== o.toolId) localStorage.setItem('YX_TOOL_ID_V3', o.toolId);
if (localStorage.getItem(toolKey(o.projectId)) !== o.toolId) localStorage.setItem(toolKey(o.projectId), o.toolId);
} catch (e) {}
}
return o;
}
function pcCacheKey(pc) { return K_PC + '_' + VERSION + '_' + pc.courseId; }
function loadPdataFromCache() {
const pc = playerCtx(); if (!pc) return false;
const c = readJSON(pcCacheKey(pc), null);
if (!c || !c.segs || !c.segs.length) return false;
if (Date.now() - (c.t || 0) > 12 * 3600e3) return false;
Object.assign(pdata, c, { loaded: true, loading: false, err: '' });
return true;
}
async function loadPlayerCourse(force) {
const pc = playerCtx(); if (!pc) return;
if (pdata.loading) return;
if (pdata.loaded && !force) return;
pdata.loading = true; pdata.err = '';
fireUpdate();
try {
await waitToken();
const d = await api('/task-center/course/getUserCoursePage?courseId=' + pc.courseId
+ '&projectId=' + pc.projectId + '&toolId=' + pc.toolId
+ '&courseSourceId=' + pc.courseSourceId + '&isMember=0');
const info = parseCoursePage(d);
Object.assign(pdata, {
name: info.name, teacher: info.teacher, total: info.total,
visited: info.visited, segs: info.segs, config: info.config,
loaded: true, loading: false, err: ''
});
writeJSON(pcCacheKey(pc), {
t: Date.now(), name: pdata.name, teacher: pdata.teacher,
total: pdata.total, visited: pdata.visited, segs: pdata.segs, config: pdata.config
});
log('课程数据已加载', pdata.name, pdata.segs.length + ' 节');
} catch (e) {
pdata.loading = false; pdata.err = e.message || String(e);
log('课程数据加载失败', e);
}
fireUpdate();
}
/** 当前节序号:DOM 优先,其次按标题在接口章节表里匹配 */
function curSegIndex() {
const items = resItems();
const i = activeIdx();
if (i >= 0 && i < items.length) {
const t = $('.res-name', items[i]);
const title = t ? (t.getAttribute('title') || t.innerText || '').trim() : '';
if (pdata.segs.length) {
const hit = pdata.segs.findIndex((s) => s.name === title);
if (hit >= 0) return hit;
}
return i;
}
return engine.idx;
}
const curSeg = () => (pdata.segs.length ? pdata.segs[curSegIndex()] || null : null);
/* ================================================================== *
* 4. 样式
* ================================================================== */
const CSS = `
:host{all:initial}
*{box-sizing:border-box;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
.fab{position:fixed;right:24px;bottom:24px;width:56px;height:56px;border-radius:18px;border:0;cursor:pointer;
background:linear-gradient(135deg,#1e3a8a 0%,#2563eb 55%,#f59e0b 130%);
box-shadow:0 10px 30px -8px rgba(30,58,138,.65),0 2px 6px rgba(0,0,0,.12);
color:#fff;font-size:24px;line-height:56px;text-align:center;z-index:2147483000;transition:.25s;font-weight:800}
.fab:hover{transform:translateY(-2px) scale(1.04)}
.fab .dot{position:absolute;right:8px;top:8px;width:9px;height:9px;border-radius:50%;background:#f59e0b;box-shadow:0 0 0 3px rgba(245,158,11,.28)}
.wrap,.mini-panel{position:fixed;display:flex;flex-direction:column;z-index:2147483001;border-radius:20px;overflow:hidden;
background:rgba(255,255,255,.88);backdrop-filter:blur(22px) saturate(180%);-webkit-backdrop-filter:blur(22px) saturate(180%);
border:1px solid rgba(255,255,255,.92);box-shadow:0 24px 60px -18px rgba(15,23,42,.45),0 2px 10px rgba(15,23,42,.08);
color:#0f172a;font-size:13px}
.wrap{width:880px;height:640px;max-width:calc(100vw - 20px);max-height:calc(100vh - 20px);min-width:420px;min-height:300px}
.wrap.hide{display:none}
.mini-panel{width:404px;height:min(600px,72vh);min-width:300px;min-height:180px;border-radius:18px;z-index:2147483002}
.hd{padding:14px 18px;display:flex;align-items:center;gap:11px;
background:linear-gradient(120deg,rgba(30,58,138,.97),rgba(37,99,235,.93) 60%,rgba(245,158,11,.88));color:#fff;flex:0 0 auto}
.hd .logo{width:36px;height:36px;border-radius:11px;background:rgba(255,255,255,.18);border:1px solid rgba(255,255,255,.35);
display:flex;align-items:center;justify-content:center;font-weight:800;font-size:17px;letter-spacing:-1px;flex:0 0 auto}
.hd h1{margin:0;font-size:14.5px;font-weight:700;letter-spacing:.3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.hd p{margin:2px 0 0;font-size:11px;opacity:.9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.hd .sp{flex:1;min-width:6px}
.icobtn{width:28px;height:28px;border-radius:9px;border:1px solid rgba(255,255,255,.35);background:rgba(255,255,255,.14);
color:#fff;cursor:pointer;font-size:13px;line-height:1;flex:0 0 auto;padding:0}
.icobtn:hover{background:rgba(255,255,255,.28)}
.drag{cursor:move;user-select:none}
.strip{padding:11px 18px 12px;background:rgba(248,250,252,.9);border-bottom:1px solid rgba(15,23,42,.07);flex:0 0 auto}
.strip .top{display:flex;align-items:baseline;gap:9px;font-size:12px;color:#475569;margin-bottom:7px;flex-wrap:wrap}
.strip .top b{font-size:15px;color:#0f172a;font-weight:800;letter-spacing:-.3px}
.tabs{display:flex;gap:3px;padding:9px 14px 0;background:rgba(248,250,252,.75);border-bottom:1px solid rgba(15,23,42,.06);overflow-x:auto;flex:0 0 auto}
.tab{padding:7px 14px;border-radius:10px 10px 0 0;border:0;background:transparent;cursor:pointer;font-size:12.5px;color:#475569;font-weight:500;white-space:nowrap}
.tab.on{background:#fff;color:#1d4ed8;font-weight:700}
.bd{padding:14px 16px 16px;overflow:auto;flex:1 1 auto;min-height:0}
.mini-panel .bd{display:flex;flex-direction:column}
.mini-panel .bd > *{flex:0 0 auto;min-height:0}
.mini-panel .bd > .segs.fill{flex:1 1 auto}
.foot{padding:7px 16px;border-top:1px solid rgba(15,23,42,.09);background:#f8fafc;
display:flex;align-items:center;gap:11px;font-size:10.5px;color:#94a3b8;flex:0 0 auto}
.grip{position:absolute;right:1px;bottom:1px;width:18px;height:18px;cursor:nwse-resize;z-index:6;opacity:.55;
background:repeating-linear-gradient(135deg,transparent 0 4px,rgba(37,99,235,.75) 4px 6px,transparent 6px 10px)}
.grip:hover{opacity:1}
.cards{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:12px}
.card{border-radius:14px;padding:11px 13px;background:#fff;border:1px solid rgba(15,23,42,.07);box-shadow:0 1px 2px rgba(15,23,42,.04)}
.card b{display:block;font-size:19px;font-weight:800;letter-spacing:-.4px}
.card span{font-size:11px;color:#64748b}
.card.grad{background:linear-gradient(135deg,#1e3a8a,#2563eb 70%,#f59e0b 145%);color:#fff;border:0}
.card.grad span{color:rgba(255,255,255,.86)}
.bar{height:9px;border-radius:9px;background:rgba(15,23,42,.08);overflow:hidden;margin:6px 0 4px}
.bar i{display:block;height:100%;border-radius:9px;background:linear-gradient(90deg,#2563eb,#f59e0b);transition:.5s}
.bar.thin{height:6px;margin:5px 0 2px}
.sec{font-size:12px;font-weight:700;color:#334155;margin:14px 0 8px;display:flex;align-items:center;gap:8px}
.sec:before{content:'';width:3px;height:13px;border-radius:2px;background:linear-gradient(#2563eb,#f59e0b)}
.sec .r{margin-left:auto;font-weight:500;font-size:11px;color:#64748b}
table{width:100%;border-collapse:collapse;font-size:12px}
th{position:sticky;top:0;background:rgba(248,250,252,.98);text-align:left;padding:7px 8px;color:#64748b;font-weight:600;
border-bottom:1px solid rgba(15,23,42,.08);white-space:nowrap;z-index:1}
td{padding:7px 8px;border-bottom:1px solid rgba(15,23,42,.05);vertical-align:top}
tr:hover td{background:rgba(37,99,235,.045)}
.nm{max-width:330px;line-height:1.45;word-break:break-word}
.pill{display:inline-block;padding:1.5px 7px;border-radius:99px;font-size:10.5px;font-weight:700;white-space:nowrap}
.p-run{background:rgba(37,99,235,.12);color:#1d4ed8}
.p-done{background:rgba(22,163,74,.12);color:#15803d}
.p-todo{background:rgba(100,116,139,.14);color:#475569}
.p-must{background:rgba(245,158,11,.16);color:#b45309}
.p-warn{background:rgba(239,68,68,.14);color:#b91c1c}
.btn{border:0;border-radius:9px;padding:6px 11px;font-size:11.5px;font-weight:600;cursor:pointer;
background:linear-gradient(135deg,#1e3a8a,#2563eb);color:#fff;white-space:nowrap;transition:.2s}
.btn:hover{filter:brightness(1.08);transform:translateY(-1px)}
.btn.ghost{background:rgba(15,23,42,.05);color:#334155}
.btn.ghost:hover{background:rgba(15,23,42,.1)}
.btn.gold{background:linear-gradient(135deg,#f59e0b,#f97316)}
.btn.lg{padding:9px 18px;font-size:13px;border-radius:11px}
.btn.sm{padding:4px 9px;font-size:11px;border-radius:8px}
.btn:disabled{opacity:.5;cursor:not-allowed;transform:none}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.muted{color:#64748b;font-size:11.5px;line-height:1.7}
.empty{padding:26px;text-align:center;color:#64748b;font-size:12.5px}
.kbd{font-family:ui-monospace,Consolas,monospace;background:rgba(15,23,42,.06);border-radius:5px;padding:1px 5px;font-size:11px}
.search{flex:1;min-width:130px;border:1px solid rgba(15,23,42,.14);border-radius:9px;padding:6px 10px;font-size:12px;background:#fff;color:#0f172a}
select,input[type=number]{border:1px solid rgba(15,23,42,.14);border-radius:8px;padding:5px 8px;font-size:12px;background:#fff;color:#0f172a}
.toast{position:absolute;left:50%;bottom:18px;transform:translateX(-50%);background:rgba(15,23,42,.92);color:#fff;
padding:8px 14px;border-radius:10px;font-size:12px;opacity:0;transition:.25s;pointer-events:none;z-index:9;max-width:80%;text-align:center}
.toast.on{opacity:1}
.switch{display:flex;align-items:center;gap:10px;padding:9px 0;border-bottom:1px dashed rgba(15,23,42,.08)}
.switch .lb{flex:1}
.switch .lb b{display:block;font-size:12.5px;font-weight:600}
.switch .lb span{font-size:11px;color:#64748b;line-height:1.5}
.sw{width:42px;height:24px;border-radius:99px;background:rgba(15,23,42,.16);position:relative;cursor:pointer;transition:.2s;flex:0 0 auto}
.sw:after{content:'';position:absolute;width:18px;height:18px;border-radius:50%;background:#fff;top:3px;left:3px;transition:.2s;box-shadow:0 1px 3px rgba(0,0,0,.2)}
.sw.on{background:linear-gradient(135deg,#1e3a8a,#2563eb)}
.sw.on:after{left:21px}
.stat{display:flex;align-items:center;gap:10px;padding:10px 13px;border-radius:13px;margin-bottom:10px;font-size:12.5px;flex:0 0 auto}
.stat.run{background:linear-gradient(120deg,rgba(37,99,235,.1),rgba(37,99,235,.04));border:1px solid rgba(37,99,235,.2);color:#1d4ed8}
.stat.warn{background:linear-gradient(120deg,rgba(239,68,68,.12),rgba(245,158,11,.08));border:1px solid rgba(239,68,68,.28);color:#b91c1c}
.stat.done{background:linear-gradient(120deg,rgba(22,163,74,.12),rgba(22,163,74,.04));border:1px solid rgba(22,163,74,.24);color:#15803d}
.stat .d{width:9px;height:9px;border-radius:50%;background:currentColor;flex:0 0 auto}
.stat.warn .d{animation:pulse 1.1s infinite}
@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.35;transform:scale(1.5)}}
.qrbox{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.qr{border-radius:16px;padding:14px;text-align:center;background:#fff;border:1px solid rgba(15,23,42,.08);box-shadow:0 4px 14px -6px rgba(15,23,42,.16)}
.qr img{width:100%;max-width:210px;border-radius:11px;display:block;margin:0 auto 9px;background:#f8fafc}
.qr b{font-size:13px;display:block}
.qr span{font-size:11px;color:#64748b;display:block;margin-top:3px;line-height:1.5}
.hero{border-radius:16px;padding:16px 18px;color:#fff;background:linear-gradient(135deg,#1e3a8a,#2563eb 60%,#f59e0b 150%);margin-bottom:14px}
.hero h2{margin:0 0 6px;font-size:15.5px;font-weight:800}
.hero p{margin:0;font-size:12px;line-height:1.75;opacity:.95}
.steps{counter-reset:s}
.step{position:relative;padding:0 0 13px 32px;border-left:2px dashed rgba(37,99,235,.28);margin-left:12px}
.step:last-child{border-left-color:transparent;padding-bottom:0}
.step:before{counter-increment:s;content:counter(s);position:absolute;left:-13px;top:-2px;width:24px;height:24px;border-radius:50%;
background:linear-gradient(135deg,#1e3a8a,#2563eb);color:#fff;font-size:11.5px;font-weight:700;display:flex;align-items:center;justify-content:center}
.step b{display:block;font-size:12.5px;margin-bottom:3px}
.step p{margin:0;font-size:11.5px;color:#475569;line-height:1.7}
.fld{display:flex;align-items:center;gap:8px;margin:8px 0}
.fld label{font-size:12px;color:#475569;min-width:96px}
.mask{position:fixed;inset:0;background:rgba(15,23,42,.5);backdrop-filter:blur(4px);z-index:2147483200;display:flex;align-items:center;justify-content:center;animation:fade .25s}
@keyframes fade{from{opacity:0}to{opacity:1}}
.dlg{width:460px;max-width:calc(100vw - 40px);max-height:86vh;overflow:auto;border-radius:20px;
background:rgba(255,255,255,.98);box-shadow:0 30px 70px -20px rgba(15,23,42,.6);animation:pop .3s cubic-bezier(.22,1.4,.4,1)}
@keyframes pop{from{transform:scale(.9) translateY(14px);opacity:0}to{transform:none;opacity:1}}
.dlg .dh{padding:18px 20px;color:#fff;background:linear-gradient(130deg,#1e3a8a,#2563eb 62%,#f59e0b 150%)}
.dlg .dh h3{margin:0;font-size:15.5px;font-weight:800}
.dlg .dh p{margin:5px 0 0;font-size:11.5px;opacity:.92;line-height:1.6}
.dlg .db{padding:16px 20px}
.dlg .df{padding:0 20px 18px;display:flex;gap:9px;justify-content:flex-end}
.segs{border:1px solid rgba(15,23,42,.08);border-radius:12px;overflow:hidden;background:#fff}
.segs.fill{flex:1 1 auto;min-height:120px;display:flex;flex-direction:column}
.segs.fill .sl{flex:1 1 auto;min-height:0;max-height:none}
.segs .sh{padding:7px 11px;background:rgba(248,250,252,.96);font-size:11px;color:#64748b;font-weight:600;
display:flex;gap:8px;align-items:center;border-bottom:1px solid rgba(15,23,42,.06)}
.segs .sl{max-height:230px;overflow:auto}
.segrow{display:flex;gap:8px;align-items:center;padding:6px 11px;border-bottom:1px dashed rgba(15,23,42,.06);font-size:12px}
.segrow:last-child{border-bottom:0}
.segrow .idx{width:22px;flex:0 0 auto;color:#94a3b8;font-size:10.5px;text-align:right}
.segrow .t{flex:1;line-height:1.4;word-break:break-word}
.segrow .t small{display:block;color:#94a3b8;font-size:10.5px;margin-top:1px;word-break:break-all}
.segrow.on{background:rgba(37,99,235,.08)}
.segrow.on .t{font-weight:700;color:#1d4ed8}
.segrow.done .t{color:#15803d}
.segrow .tm{color:#64748b;font-size:11px;flex:0 0 auto;font-variant-numeric:tabular-nums}
.segrow .t .badge{display:inline-block;vertical-align:middle;margin-right:5px;padding:1px 6px;border-radius:6px;font-size:10px;font-weight:600;line-height:1.5}
.badge.vid{background:rgba(37,99,235,.12);color:#1d4ed8}
.badge.mat{background:rgba(245,158,11,.16);color:#b45309}
.badge.doc{background:rgba(21,128,61,.14);color:#15803d}
@media (max-width:900px){.cards{grid-template-columns:repeat(2,1fr)}.qrbox{grid-template-columns:1fr}}
`;
/* ================================================================== *
* 5. UI 基础设施
* ================================================================== */
let root = null, shadow = null, toastEl = null;
function ensureRoot() {
if (root && document.documentElement.contains(root)) return shadow;
root = document.createElement('div');
root.id = 'yx-helper-host';
root.style.cssText = 'all:initial;position:fixed;inset:auto 0 0 auto;z-index:2147483000';
document.documentElement.appendChild(root);
shadow = root.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = CSS;
shadow.appendChild(style);
toastEl = document.createElement('div');
toastEl.className = 'toast';
shadow.appendChild(toastEl);
return shadow;
}
function el(tag, cls, html) {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (html !== undefined) n.innerHTML = html;
return n;
}
function toast(msg, ms) {
ensureRoot();
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.add('on');
clearTimeout(toast._t);
toast._t = setTimeout(() => toastEl.classList.remove('on'), ms || 2100);
}
function copyText(text, okMsg) {
const done = () => toast(okMsg || '已复制到剪贴板', 2400);
const fb = () => {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); done(); } catch (e) { toast('复制失败', 3000); }
ta.remove();
};
try {
if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done, fb); return; }
} catch (e) {}
fb();
}
function modal(opts) {
ensureRoot();
const mask = el('div', 'mask');
const dlg = el('div', 'dlg');
dlg.innerHTML = '' + esc(opts.title) + '
' + (opts.sub || '') + '
'
+ '
' + opts.body + '
'
+ '';
const df = dlg.querySelector('.df');
(opts.buttons || []).forEach((b) => {
const btn = el('button', 'btn ' + (b.cls || 'ghost'), b.text);
btn.addEventListener('click', () => { if (b.onClick) b.onClick(mask); if (b.close !== false) mask.remove(); });
df.appendChild(btn);
});
mask.appendChild(dlg);
mask.addEventListener('click', (e) => { if (e.target === mask) mask.remove(); });
shadow.appendChild(mask);
return { mask, dlg };
}
/* ---- 拖动 + 缩放(位置与尺寸本地记忆) ---- */
function placeBox(node, key, def) {
const b = Object.assign({}, def, readJSON(key, {}));
const maxW = Math.max(300, window.innerWidth - 16), maxH = Math.max(200, window.innerHeight - 16);
const w = clamp(b.w || def.w, def.minW || 260, maxW);
const h = clamp(b.h || def.h, def.minH || 160, maxH);
const left = clamp(b.left == null ? window.innerWidth - w - (def.gapX == null ? 24 : def.gapX) : b.left, 6, Math.max(6, window.innerWidth - w - 6));
const top = clamp(b.top == null ? (def.gapY == null ? 90 : def.gapY) : b.top, 6, Math.max(6, window.innerHeight - 60));
node.style.width = w + 'px';
node.style.height = h + 'px';
node.style.left = left + 'px';
node.style.top = top + 'px';
return { left, top, w, h };
}
function makeMoveResize(node, handle, grip, key, def) {
const save = () => writeJSON(key, {
left: parseInt(node.style.left, 10) || 24,
top: parseInt(node.style.top, 10) || 24,
w: parseInt(node.style.width, 10) || def.w,
h: parseInt(node.style.height, 10) || def.h
});
placeBox(node, key, def);
if (handle) {
let on = false, dx = 0, dy = 0;
handle.addEventListener('pointerdown', (e) => {
if (e.target.closest('button') || e.target.closest('a')) return;
on = true;
const r = node.getBoundingClientRect();
dx = e.clientX - r.left; dy = e.clientY - r.top;
try { handle.setPointerCapture(e.pointerId); } catch (err) {}
e.preventDefault();
});
handle.addEventListener('pointermove', (e) => {
if (!on) return;
const w = node.offsetWidth;
node.style.left = clamp(e.clientX - dx, 4, Math.max(4, window.innerWidth - w - 4)) + 'px';
node.style.top = clamp(e.clientY - dy, 4, Math.max(4, window.innerHeight - 44)) + 'px';
e.preventDefault();
});
const end = (e) => {
if (!on) return;
on = false;
try { handle.releasePointerCapture(e.pointerId); } catch (err) {}
save();
};
handle.addEventListener('pointerup', end);
handle.addEventListener('pointercancel', end);
}
if (grip) {
let on = false, sx = 0, sy = 0, w0 = 0, h0 = 0;
grip.addEventListener('pointerdown', (e) => {
on = true; sx = e.clientX; sy = e.clientY;
w0 = node.offsetWidth; h0 = node.offsetHeight;
try { grip.setPointerCapture(e.pointerId); } catch (err) {}
e.preventDefault(); e.stopPropagation();
});
grip.addEventListener('pointermove', (e) => {
if (!on) return;
node.style.width = clamp(w0 + (e.clientX - sx), def.minW || 300, window.innerWidth - 12) + 'px';
node.style.height = clamp(h0 + (e.clientY - sy), def.minH || 180, window.innerHeight - 12) + 'px';
e.preventDefault();
});
const end = (e) => {
if (!on) return;
on = false;
try { grip.releasePointerCapture(e.pointerId); } catch (err) {}
save();
};
grip.addEventListener('pointerup', end);
grip.addEventListener('pointercancel', end);
}
}
/* ================================================================== *
* 6. 声音 / 桌面通知 / 拉前台
* ================================================================== */
let audioCtx = null;
function getCtx() {
if (!audioCtx) { try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) {} }
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume().catch(() => {});
return audioCtx;
}
function beep(times) {
if (!cfg.sound) return;
const ctx = getCtx(); if (!ctx) return;
const n = Math.min(8, Math.max(1, times || 1));
for (let i = 0; i < n; i++) {
const t0 = ctx.currentTime + i * 0.42;
const o = ctx.createOscillator(), g = ctx.createGain();
o.type = 'sine'; o.frequency.setValueAtTime(880, t0);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(0.32, t0 + 0.03);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.32);
o.connect(g).connect(ctx.destination);
o.start(t0); o.stop(t0 + 0.36);
}
}
function desktopNotify(title, body) {
if (!cfg.notice) return;
try {
if (!('Notification' in window)) return;
if (Notification.permission === 'granted') new Notification(title, { body });
else if (Notification.permission !== 'denied') Notification.requestPermission().then((p) => { if (p === 'granted') new Notification(title, { body }); });
} catch (e) {}
}
function pullToFront() {
if (!cfg.focusTab) return;
try { window.focus(); } catch (e) {}
}
/* ================================================================== *
* 7. 后台保活(防浏览器冻结后台标签页)
* ================================================================== */
const keepAlive = { osc: null, gain: null, wake: null };
function startKeepAlive() {
if (!cfg.keepAlive) return;
try {
const ctx = getCtx(); if (!ctx) return;
if (!keepAlive.osc) {
const o = ctx.createOscillator(), g = ctx.createGain();
o.type = 'sine'; o.frequency.value = 32;
g.gain.value = clamp(num(cfg.keepAliveGain) || 0.008, 0, 0.05);
o.connect(g).connect(ctx.destination);
o.start();
keepAlive.osc = o; keepAlive.gain = g;
}
requestWakeLock();
} catch (e) {}
}
function stopKeepAlive() {
try { if (keepAlive.osc) { keepAlive.osc.stop(); keepAlive.osc.disconnect(); } } catch (e) {}
keepAlive.osc = null; keepAlive.gain = null;
releaseWakeLock();
}
function releaseWakeLock() { try { if (keepAlive.wake) { keepAlive.wake.release(); keepAlive.wake = null; } } catch (e) {} }
function requestWakeLock() {
if (!('wakeLock' in navigator) || keepAlive.wake) return;
try {
navigator.wakeLock.request('screen').then((w) => { keepAlive.wake = w; w.addEventListener('release', () => { keepAlive.wake = null; }); }).catch(() => {});
} catch (e) {}
}
(function resumeAudioOnGesture() {
const once = () => {
const c = getCtx();
if (c && c.state === 'suspended') c.resume().catch(() => {});
if (keepAlive.osc && keepAlive.gain) keepAlive.gain.gain.value = clamp(num(cfg.keepAliveGain) || 0.008, 0, 0.05);
requestWakeLock();
document.removeEventListener('pointerdown', once, true);
document.removeEventListener('keydown', once, true);
document.removeEventListener('click', once, true);
};
document.addEventListener('pointerdown', once, true);
document.addEventListener('keydown', once, true);
document.addEventListener('click', once, true);
})();
/* ================================================================== *
* 8. 自动看课引擎(仅播放页)
* ================================================================== */
const engine = {
running: false, state: 'idle', reason: '', tabId: Math.random().toString(36).slice(2),
course: null, segTitle: '', idx: -1, total: 0, startedAt: 0, segStartedAt: 0,
lastAdvance: 0, lastTick: 0, lastShield: 0, lastLock: 0, blocked: '',
doneFor: '', pending: null, timer: null, resumedFor: '',
quizWarnedFor: '', quizWarnedAt: '',
quizSkippedAt: 0, quizSkippedFor: '',
onUpdate: null, diag: {},
// 被动心跳探针(只观察、绝不修改请求):记录平台真实上报次数与累计观看位置
hb: { count: 0, lastLoc: null, firstLoc: null, lastTs: 0, dur: null },
guideRead: false, scroller: null
};
function fireUpdate() { if (engine.onUpdate) engine.onUpdate(); }
function visible(n) {
if (!n) return false;
const r = n.getBoundingClientRect();
return r.width > 2 && r.height > 2;
}
function resItems() { return $$('.category-list .res-item'); }
function activeIdx() {
const items = resItems();
for (let i = 0; i < items.length; i++) if (items[i].classList.contains('active')) return i;
return -1;
}
function keyText(n) { return (n.innerText || n.textContent || '').replace(/\s+/g, ''); }
function domSegTitle(i) {
const items = resItems();
if (i < 0 || i >= items.length) return '';
const t = $('.res-name', items[i]);
return t ? (t.getAttribute('title') || t.innerText || '').trim() : '';
}
/* 平台(spring-grain)把「选中章节」的点击处理器只挂在 上:
render: n("p",{staticClass:"res-name",on:{click:()=>handleSelectResource([i,a],t)}})
—— 点 .content-wrapper / .res-item 都是死元素,必须点 .res-name。 */
function clickRes(i) {
const items = resItems();
const el = items[i];
if (!el) return false;
const target = $('.res-name', el) || $('.content-wrapper', el) || el;
try { target.click(); } catch (e) {}
return true;
}
/** 平台的「在场确认」提示(只提醒,不代点) */
function idleTip() {
const ac = $('.alarmClock-wrapper');
if (ac && visible(ac)) return ac;
const cands = $$('.action-timer span, .action-timer button, .action-timer a, .vcp-player button, .vcp-error-tips');
for (let i = 0; i < cands.length; i++) {
const t = keyText(cands[i]);
if (/继续计时|继续学习|继续观看|还在看|是否继续|仍在观看|点我/.test(t) && visible(cands[i])) return cands[i];
}
return null;
}
/** 判断是否出现"必须由本人处理"的拦截 */
function detectBlock() {
const ac = $('.answerCard-wrapper');
if (ac && !ac.classList.contains('answerCard-hidden')) {
const c = $('.content', ac);
if (!c || getComputedStyle(c).display !== 'none') return 'answer';
}
/* 「去测验」按钮(.action-timer .beginExam)≠ 拦截!
平台源码 srt-player.vue:examState = typeOfCompleted===3 ? (quizCount&&isArrivedTesting ? ... : 'default') : 'hidden'
—— 只要本课是「测验型」(看课+随堂测),这个按钮就常驻可见(灰底 default「去测验」),
它不是需要本人立刻处理的弹窗。旧版把它当拦截,导致所有测验型课程一进去就永久暂停+响铃。
真正的拦截是看课到 tpOfExam%(默认 95%)时平台自动 beginExam() 弹出的 .answerCard-wrapper,
已由本函数第一段覆盖。故这里排除 .action-timer 内的按钮。 */
const ex = $('.beginExam');
if (ex && !ex.closest('.action-timer') && !ex.classList.contains('hidden') && visible(ex)) return 'answer';
const sc = $('.scoring-wrapper');
if (sc && getComputedStyle(sc).display !== 'none') return 'rate';
if (idleTip()) return 'idle';
return '';
}
/** 测验型课程(看课 + 随堂测)识别 —— 依据 spring-grain srt-player.vue:
* · .action-timer .beginExam 未带 .hidden → 本课 typeOfCompleted===3,含随堂测
* · .info-tpOfExam 文案「看课N%开启随堂测」→ 平台在 N%(默认 95)处自动弹出答题层
* state: default(未到阈值) / active(可作答) / passed(已通过) / failed(未通过) / hidden(无测验)
* 返回 { hasQuiz, tp, state, label } */
function quizInfo() {
const btn = $('.action-timer .beginExam') || $('.beginExam');
const badge = $('.info-tpOfExam');
const hasQuiz = !!btn && !btn.classList.contains('hidden');
let tp = 95;
const m = badge ? keyText(badge).match(/(\d+)\s*%/) : null;
if (m) tp = num(m[1]) || 95;
let state = '';
if (btn) {
if (btn.classList.contains('hidden')) state = 'hidden';
else if (btn.classList.contains('passed')) state = 'passed';
else if (btn.classList.contains('failed')) state = 'failed';
else if (btn.classList.contains('active')) state = 'active';
else state = 'default';
}
return { hasQuiz, tp, state, label: btn ? keyText(btn) : '' };
}
/* ------------------------------------------------------------------
* 评分弹层自动评星
* 平台源码依据(spring-grain 5.8b8fdc2d6707562c27f0.js):
* · dialog/score/scoring.vue: +
* → 没星(rate=0)时「提交」是 disabled,点了也没反应。
* · 组件 rating.vue(src/page/course/components/rating.vue):
* data(){ return { currentValue:this.value, hoverIndex:-1, pointerAtLeftHalf:true } } ← 初值 true!
* setCurrentValue(e,t){ ... this.pointerAtLeftHalf = 2*t.offsetX <= icon.clientWidth; ... } // 仅 mousemove 时更新
* changeValue(e){ this.allowHalf && this.pointerAtLeftHalf ? emit('input', this.currentValue) // ← currentValue 初值 0
* : emit('input', e) } // ← e = 星序号
* 旧版只用 stars[v-1].click():从没派发过 mousemove → pointerAtLeftHalf 一直是 true
* → changeValue 永远 emit currentValue(=0) → rate 恒为 0 →「提交」永久 disabled → 自动评分失效。
* 修复:先按真实指针轨迹在目标星的「右半区」派发 mousemove(2*offsetX > 图标宽 → pointerAtLeftHalf=false、
* currentValue=星序号),再补 click;然后校验「提交」是否解禁,未解禁用 Vue 实例兜底,最后才提交。
* ------------------------------------------------------------------ */
function doRate() {
const sc = $('.scoring-wrapper');
if (!sc || getComputedStyle(sc).display === 'none') return false;
const scope = $('.info-rate', sc) || sc;
const stars = $$('.rate-item', scope).length
? $$('.rate-item', scope)
: $$('.rating i[class*="icon-star"]', scope);
if (!stars.length) return false;
const v = clamp(num(cfg.rateValue) || 5, 1, Math.min(5, stars.length));
const star = stars[v - 1];
const r = star.getBoundingClientRect();
const cx = r.left + (r.width || 24) * 0.85; // 右半区 → 取整星而非半星
const cy = r.top + (r.height || 24) / 2;
const mk = (type) => new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: cx, clientY: cy });
try {
['mouseenter', 'mouseover', 'mousemove', 'mousedown', 'mouseup', 'click']
.forEach((t) => star.dispatchEvent(mk(t)));
} catch (e) { try { star.click(); } catch (e2) {} }
const submitBtn = () => {
const btns = $$('button', sc);
return btns.find((b) => /提交|确定|完成|保存/.test(keyText(b))) || btns[btns.length - 1] || null;
};
const commit = () => { const b = submitBtn(); if (b && !b.disabled) { b.click(); return true; } return false; };
setTimeout(() => {
if (commit()) { log('评分已提交', v); return; }
const vm = ($('.rating', sc) || {}).__vue__; // 兜底:直接驱动 rating 组件
if (vm && typeof vm.$emit === 'function') {
try { vm.$emit('input', v); vm.$emit('change', v); } catch (e) {}
}
setTimeout(() => { if (!commit()) log('评分提交未生效,请手动评星'); }, 350);
}, 250);
engine.blocked = '';
toast('已按你的设置提交评分 ' + v + ' 星');
fireUpdate();
return true;
}
/* ------------------------------------------------------------------
* 随堂测(课程测验)弹窗自动关闭 —— 不代答,只点平台自己的「×」
* 平台源码依据:
* · dialog/examination/multiple/index.vue(随堂测多题)每题内都有
*
* close(){ this.$emit('close'); this.reset(); }
* srt-player.vue 里该回调只做 `isShowAnswerCard = false`。
* · dialog/examination/single/index.vue(随堂小练习)与 single/chime.vue(防挂机「回答问题完成验证」)
* 模板里**没有** .close → 所以「能点到 .close」就等于「这是随堂测」,可安全关闭;
* 防挂机验证题依旧只提醒、不代答、不代关。
* · heartbeatPause() 会 pause 视频并清掉心跳定时器;close() 不会自动恢复播放,
* 所以关闭后需要我们自己 play(),播放一恢复平台的 handlePlaying() 就会 heartbeatStart(),学时照常累计。
* ------------------------------------------------------------------ */
/* 注意:页面上同时存在多个 .answerCard-wrapper(防挂机验证卡常驻 DOM、只是加了 answerCard-hidden),
所以绝不能只取第一个 —— 必须遍历所有「未 hidden」的答题层再找 .form-answers .close。 */
function quizCardRoots() {
return $$('.answerCard-wrapper').filter((el) => !el.classList.contains('answerCard-hidden'));
}
function quizCardCloseBtn() {
const roots = quizCardRoots();
for (let i = 0; i < roots.length; i++) {
const list = $$('.form-answers .close', roots[i]);
for (let k = 0; k < list.length; k++) if (visible(list[k])) return list[k];
}
return null;
}
function quizCardOpen() {
return quizCardRoots().some((el) => !!$('.form-answers', el));
}
/** 点平台自带的「×」关闭随堂测弹窗(不答题),已接管返回 true */
function closeQuizCard() {
const btn = quizCardCloseBtn();
if (!btn) return false;
try {
['mousedown', 'mouseup', 'click'].forEach((t) =>
btn.dispatchEvent(new MouseEvent(t, { bubbles: true, cancelable: true, view: window })));
} catch (e) { try { btn.click(); } catch (e2) {} }
// 兜底:600ms 后仍开着就直接驱动该弹层 Vue 实例的 close()
setTimeout(() => {
quizCardRoots().forEach((w) => {
const root = $('.form-answers', w);
const vm = root && root.__vue__;
if (vm && typeof vm.close === 'function') { try { vm.close(); } catch (e) {} }
});
}, 600);
return true;
}
function findVideo() { return $('.vcp-player video') || document.querySelector('video'); }
function endedMask() { return $('.ended-mask'); }
function endedMaskVisible() {
const m = endedMask();
if (!m) return false;
const s = getComputedStyle(m);
return s.display !== 'none' && s.visibility !== 'hidden' && m.offsetWidth > 10 && m.offsetHeight > 10;
}
function completedTextVisible() {
const body = document.body;
return /已完成本节内容|本节内容已学习完成/.test((body && body.innerText) || '');
}
function segKey() { return engine.idx + '|' + engine.segTitle; }
function syncVideo() {
const v = findVideo();
if (!v) return null;
try {
v.muted = !!cfg.muted;
// 学时按「服务端收到的 25 秒心跳」累计:实测抓包确认 watchTime 是写死常量 25、watchLocation 每 25 秒 +25,
// 与视频实际播放位置无关。倍速只让视频提前播完、反而少报几次心跳 → 学时更少,所以锁死 1x 是最优解。
if (v.playbackRate !== 1) v.playbackRate = 1;
if (cfg.muted) v.volume = 0.0001;
} catch (e) {}
return v;
}
/* 长文本自动滚动:用于「文本/文档段阅读浮层」与「课程指南/专家介绍」——平滑滚到底,模拟阅读、帮助平台判完成 */
const _sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function findScrollable(root) {
root = root || document;
const all = $$('div,p,article,section,main', root);
let best = null, bestLen = 0;
all.forEach((el) => {
const max = el.scrollHeight - el.clientHeight;
const len = (el.innerText || '').length;
if (max > 40 && len > 80 && len > bestLen) { best = el; bestLen = len; }
});
return best;
}
function smoothScroll(container, ms) {
return new Promise((resolve) => {
const max = container.scrollHeight - container.clientHeight;
if (max <= 4) { resolve(); return; }
const start = container.scrollTop || 0;
const t0 = performance.now();
function step(now) {
const p = Math.min(1, (now - t0) / ms);
const e = 1 - Math.pow(1 - p, 3); // ease-out
container.scrollTop = start + (max - start) * e;
if (p < 1) requestAnimationFrame(step);
else resolve();
}
requestAnimationFrame(step);
});
}
// 自动阅读:切到「课程指南」「专家介绍」tab 并平滑滚动其长文本(信息区,不影响学时;仅满足"自动滚动阅读"需求)
async function readGuide() {
if (engine.scroller) return; // 防止重入
engine.scroller = true;
try {
const tabs = $$('.tab, .ivu-tabs-tab'); // 只取真正可点击的 tab,避免点到容器
const names = ['课程指南', '专家介绍'];
for (const name of names) {
const tab = tabs.find((t) => (t.textContent || '').indexOf(name) >= 0);
if (!tab) continue;
tab.click();
await _sleep(700);
const pane = $('.ivu-tabs-content .ivu-tabs-tabpane, .ivu-tabs-content') || $('.ivu-tabs-content');
const el = pane ? findScrollable(pane) : findScrollable(document);
if (el) { toast('正在自动阅读「' + name + '」…', 1800); await smoothScroll(el, 4000); }
await _sleep(300);
}
// 切回目录,避免挡住播放区
const cat = tabs.find((t) => (t.textContent || '').indexOf('目录') >= 0);
if (cat) cat.click();
toast('已自动阅读课程指南 / 专家介绍', 2600);
} finally {
engine.scroller = false;
}
}
/* 被动心跳探针:包装 fetch / XHR,仅读取 hb/report 请求体里的 watchLocation,绝不修改任何请求。
用于面板实时显示「平台已收到几次心跳、累计看到第几分几秒」,让学时积累过程透明可见。 */
function installHbProbe() {
if (window.__yxHbProbe) return;
window.__yxHbProbe = true;
const isHb = (u) => /heartbeat-center\/hb\/report|hbCenter|heartbeat/i.test(u || '');
const parse = (body) => {
if (!body || typeof body !== 'string') return null;
try { return JSON.parse(body); } catch (e) { return null; }
};
const note = (o) => {
if (!o) return;
engine.hb.count++;
if (o.watchLocation != null) engine.hb.lastLoc = num(o.watchLocation);
if (engine.hb.firstLoc == null && o.watchLocation != null) engine.hb.firstLoc = num(o.watchLocation);
engine.hb.lastTs = Date.now();
if (o.watchTime != null) engine.hb.dur = num(o.watchTime);
};
try {
const of = window.fetch;
window.fetch = function (url, opts) {
try {
if (isHb(url) && opts && typeof opts.body === 'string') note(parse(opts.body));
} catch (e) {}
return of.apply(this, arguments);
};
} catch (e) {}
try {
const ox = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (m, u) { this.__yxu = u; return ox.apply(this, arguments); };
const os = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function (b) {
try { if (isHb(this.__yxu)) note(parse(typeof b === 'string' ? b : (b && b.toString ? b.toString() : ''))); } catch (e) {}
return os.apply(this, arguments);
};
} catch (e) {}
}
async function tryPlay() {
const v = syncVideo();
if (!v) return false;
if (!v.paused && v.currentTime > 0) return true;
try { await v.play(); return true; }
catch (e) {
const big = $('.vcp-bigplay');
if (big && visible(big)) { try { big.click(); } catch (e2) {} }
try { await v.play(); return true; } catch (e3) { return false; }
}
}
function advance(reason) {
const now = Date.now();
if (now - engine.lastAdvance < 6000) return;
engine.lastAdvance = now;
if (engine.idx >= 0) markDone(engine.course && engine.course.id, engine.idx);
engine.scroller = false; // 切换节时清掉阅读滚动锁,避免后台标签页 rAF 未触发导致卡死
engine.doneFor = segKey();
const p = { at: now, title: engine.segTitle, idx: engine.idx, tries: 0, clicked: false };
engine.pending = p;
engine.reason = '本节已播完,准备切换下一节…';
log('advance scheduled', reason);
setTimeout(() => { if (engine.running && engine.pending === p) doAdvance(p); }, 2500);
}
function nextVideo(start, requireUndone) {
if (!pdata.segs.length) return -1;
const cid = engine.course && engine.course.id;
for (let k = num(start) + 1; k < pdata.segs.length; k++) {
const s = pdata.segs[k];
if (!s || !s.video) continue;
if (!requireUndone) return k;
if (!isDone(cid, s.i)) return k;
}
return -1;
}
function isTextSeg(i) {
return !!(pdata.segs[i] && !pdata.segs[i].video);
}
// 下一节(文本/文档段也参与队列,不再被跳过)
function nextSeg(start, requireUndone) {
if (!pdata.segs.length) return -1;
const cid = engine.course && engine.course.id;
for (let k = num(start) + 1; k < pdata.segs.length; k++) {
const s = pdata.segs[k];
if (!s) continue;
if (!requireUndone) return k;
if (!isDone(cid, s.i)) return k;
}
return -1;
}
function doAdvance(p) {
const items = resItems();
const cur = engine.idx >= 0 ? engine.idx : activeIdx();
log('doAdvance cur=', cur, 'items=', items.length, 'segs=', pdata.segs.length);
// 1) 官方「下一节」按钮(平台自己的完播信号,最可靠;浮层隐藏时按钮不可见)
const nb = $('.ended-mask .btns .next') || $('.btns .next');
if (visible(nb)) {
p.clicked = true; p.at = Date.now();
nb.click();
engine.state = 'playing'; engine.reason = '已点击「下一节」,正在切换…';
log('advance -> .next');
return;
}
// 2) 目录里的下一节:优先跳“未学完的节”,没有则跳“下一个节”;文本/文档段也参与队列
if (items.length && pdata.segs.length) {
let next = nextSeg(cur, true); // 先找未学完
if (next < 0) next = nextSeg(cur, false); // 兜底:找下一个节(应对无缓存场景)
if (next < 0) {
// 仍没找到:检查前面是否还有未访问的节(混合课程里文本节可能排在视频前面,向后搜索会漏掉)
const cid = engine.course && engine.course.id;
next = pdata.segs.findIndex((s, k) => k !== cur && !isDone(cid, s.i));
}
if (next >= 0) {
p.clicked = true; p.at = Date.now();
const clicked = clickRes(next);
engine.state = 'playing'; engine.reason = '已切换到第 ' + (next + 1) + ' 节';
log('advance -> catalog', next, clicked ? 'clicked' : 'click failed');
if (!clicked) {
engine.pending = null;
engine.state = 'blocked';
engine.reason = '目录点击失败,请手动切到第 ' + (next + 1) + ' 节';
beep(3); pullToFront();
}
return;
}
}
// 3) 本门学完
engine.pending = null;
finishCourse();
}
function loadCachedCourses() {
const c = readJSON(K_CACHE, null);
if (!c || !c.courses || !c.courses.length) return [];
return c.courses;
}
function findNextCourse(curId) {
const all = loadCachedCourses();
if (!all.length) return null;
const cur = all.find((c) => String(c.id) === String(curId));
if (!cur) {
// 当前课程不在缓存:按列表顺序找第一门未学
return all.find((c) => num(c.noStudyTime) > 0) || null;
}
// 同一模块内,当前课程之后的下一门未学
const sameModule = all.filter((c) => c._module === cur._module);
const idx = sameModule.findIndex((c) => String(c.id) === String(curId));
let next = sameModule.slice(idx + 1).find((c) => num(c.noStudyTime) > 0);
if (next) return next;
// 本模块学完,按模块在 state.modules 里的顺序找下一个模块的未学课
const c = readJSON(K_CACHE, null);
const mods = (c && c.modules) || [];
const modIdx = mods.findIndex((m) => String(m.moduleName) === String(cur._module));
for (let i = modIdx + 1; i < mods.length; i++) {
const name = mods[i].moduleName;
next = all.find((c) => c._module === name && num(c.noStudyTime) > 0);
if (next) return next;
}
// 所有模块都学完:从开头找第一门未学(兜底)
return all.find((c) => num(c.noStudyTime) > 0) || null;
}
function finishCourse() {
// 本门全部章节学完后,可选先自动阅读「课程指南/专家介绍」(仅滚动阅读,不影响学时)
if (cfg.autoReadGuide && !engine.guideRead) {
engine.guideRead = true;
engine.state = 'reading';
engine.reason = '本门学完,正在自动阅读课程指南/专家介绍…';
fireUpdate();
readGuide().then(() => { finishCourse(); });
return;
}
const q = loadQueue();
if (!q || !q.items || !q.items.length) {
// 无队列时,尝试从学习空间缓存的课程列表找下一门
const next = findNextCourse(engine.course && engine.course.id);
if (next && String(next.id) !== String(engine.course && engine.course.id)) {
engine.state = 'jumping';
engine.reason = '本门学完,正在进入下一门:' + next.courseName;
toast('本门学完,2 秒后进入「' + next.courseName + '」…', 2600);
fireUpdate();
setTimeout(() => { location.href = playerUrl(next.id, next.courseSourceId, next._toolId); }, 2200);
return;
}
engine.state = 'done'; engine.reason = '本课程已全部播完';
stopEngine(false);
desktopNotify('本课程已学完', (engine.course && engine.course.name ? engine.course.name : '') + ' 全部章节已播完');
beep(4);
return;
}
const here = q.items.findIndex((x) => String(x.id) === String(engine.course && engine.course.id));
const from = here >= 0 ? here : (num(q.idx) || 0);
const nextI = from + 1;
if (nextI >= q.items.length) {
q.running = false; saveQueue(q);
engine.state = 'done'; engine.reason = '队列全部完成 🎉';
desktopNotify('自动看课完成', '队列中的 ' + q.items.length + ' 门课程已全部播完');
beep(5);
fireUpdate();
return;
}
q.idx = nextI; q.running = true; saveQueue(q);
engine.state = 'jumping';
engine.reason = '正在进入下一门:' + q.items[nextI].name;
toast('本门学完,2 秒后进入下一门…', 2600);
fireUpdate();
setTimeout(() => { location.href = playerUrl(q.items[nextI].id, q.items[nextI].sid, q.items[nextI].toolId); }, 2200);
}
function markBlocked(kind) {
if (engine.blocked === kind) return;
engine.blocked = kind;
if (kind === 'answer') {
const qi = quizInfo();
// 随堂测(课程测验)弹层带平台自带的「×」→ 按设置自动关闭并继续播放(绝不代答)
if (cfg.autoSkipQuiz && Date.now() - num(engine.quizSkippedAt) > 5000 && closeQuizCard()) {
engine.blocked = '';
engine.quizSkippedAt = Date.now();
engine.quizSkippedFor = (engine.course && engine.course.id) || '';
engine.reason = '已关闭随堂测弹窗,继续播放(未作答 · 测验成绩需自行补答)';
toast('⏭ 已关闭随堂测弹窗并继续播放(未作答,本课测验成绩需你自行补答)', 6500);
log('随堂测弹窗已自动关闭(不代答)');
setTimeout(() => { if (engine.running) tryPlay(); }, 700);
fireUpdate();
return;
}
engine.reason = qi.hasQuiz
? '平台弹出「随堂测」,请本人作答'
: '平台要求「回答问题完成验证」,请本人作答';
beep(6); pullToFront();
desktopNotify(qi.hasQuiz ? '随堂测已弹出' : '需要你本人作答',
'播放已暂停。这是平台的看课验证题,助手不会替你作答,请回到页面完成后再继续。');
toast(qi.hasQuiz
? '⚠ 随堂测已弹出:请本人作答,答完助手自动继续连播'
: '⚠ 平台弹出验证题,请本人作答(助手不作答)', 6000);
} else if (kind === 'rate') {
if (cfg.autoRate) { if (doRate()) fireUpdate(); return; }
engine.reason = '平台要求给课程评分,请本人评星';
beep(3); pullToFront();
desktopNotify('需要你评分', '请在播放器中给课程评星后继续。');
} else if (kind === 'idle') {
engine.reason = '平台弹出了「继续计时 / 继续学习」确认,请本人点一下';
beep(6); pullToFront();
desktopNotify('需要你确认一下', '播放已暂停。这是平台的在场确认,助手不会替你点,请回到页面点击「继续」。');
toast('⚠ 平台要求点击「继续计时」,请本人点一下(助手不代点)', 5000);
}
fireUpdate();
}
function tick() {
if (!engine.running) return;
const now = Date.now();
engine.lastTick = now;
if (now - engine.lastLock > 3000) { engine.lastLock = now; holdLock(); }
if (!engine.course) {
const pc = playerCtx();
const q = loadQueue();
const item = (q && q.items && q.items.find((x) => String(x.id) === String(pc.courseId))) || null;
engine.course = { id: pc.courseId, sid: pc.courseSourceId, name: (pdata.name || (item ? item.name : '') || document.title || '本课程') };
if (!pdata.loaded && !pdata.loading) loadPlayerCourse();
} else if (pdata.name && engine.course.name !== pdata.name) {
engine.course.name = pdata.name;
}
// 目录与当前节
const items = resItems();
engine.total = pdata.segs.length || items.length;
const i = activeIdx();
if (i >= 0) {
if (engine.idx !== i) engine.segStartedAt = now;
engine.idx = i;
engine.segTitle = domSegTitle(i);
}
// 断点续播:进入本门后,跳到第一个未学完的视频;当前段已学完也立即往后跳
if (cfg.resume && pdata.loaded && pdata.segs.length && engine.resumedFor !== engine.course.id
&& !engine.pending && !engine.blocked && engine.idx >= 0) {
engine.resumedFor = engine.course.id;
const firstUndone = pdata.segs.findIndex((s) => !isDone(engine.course.id, s.i));
if (firstUndone < 0) {
// 本门所有视频段本机都已 markDone,直接结束本门
engine.reason = '本门所有章节本机已学完,准备进入下一门…';
finishCourse();
return;
}
if (firstUndone !== engine.idx && items[firstUndone]) {
engine.reason = '断点续播:跳到第 ' + (firstUndone + 1) + ' 节(前面的章节本机已播完)';
toast('断点续播 → 第 ' + (firstUndone + 1) + ' 节', 2600);
clickRes(firstUndone);
fireUpdate();
return;
}
}
// 运行中途:如果当前段已经被标记为已学完(且不是最后一节),立即 advance
if (cfg.autoNext && cfg.resume && pdata.loaded && engine.idx >= 0 && !engine.pending && !engine.blocked
&& isDone(engine.course.id, engine.idx) && nextSeg(engine.idx, true) >= 0) {
engine.reason = '当前段本机已学完,准备跳到下一个未学段…';
advance('resume-skip');
return;
}
// 等待「下一节」切换完成
if (engine.pending) {
const p = engine.pending;
const changed = (engine.idx !== p.idx) || (engine.segTitle && engine.segTitle !== p.title);
if (changed) {
engine.pending = null; engine.reason = '已进入下一节,继续播放';
log('switch ok ->', engine.segTitle);
} else if (p.clicked && now - p.at > 13000) {
p.tries++;
if (p.tries <= 3) { p.clicked = false; p.at = now; engine.reason = '切换未生效,重试…'; doAdvance(p); }
else {
engine.pending = null;
engine.state = 'blocked';
engine.reason = '自动切换下一节失败,请手动点一下「下一节」或刷新页面';
beep(3); pullToFront();
desktopNotify('切换失败', '未能自动进入下一节,请回到页面手动点击。');
}
}
syncVideo();
fireUpdate();
return;
}
// 测验型课程(随堂测):进入时提示一次;接近平台阈值(默认 95%)时再提前提醒一次。
// 平台在看课进度 >= tpOfExam% 时会自动 beginExam() 弹出答题层,届时由 detectBlock 接管暂停+响铃。
const qi = quizInfo();
if (qi.hasQuiz && qi.state !== 'passed' && engine.quizSkippedFor !== engine.course.id) {
if (engine.quizWarnedFor !== engine.course.id) {
engine.quizWarnedFor = engine.course.id;
toast('本课含随堂测:看课到 ' + qi.tp + '% 会自动弹出答题,'
+ (cfg.autoSkipQuiz ? '助手会自动点「×」关闭并继续播放(不代答)' : '需你本人作答(助手会暂停并提醒)'), 6500);
log('测验型课程', qi, 'autoSkipQuiz=' + cfg.autoSkipQuiz);
}
const vq = findVideo();
const dq = vq ? num(vq.duration) : 0;
if (dq > 0) {
const pq = num(vq.currentTime) / dq * 100;
if (pq >= qi.tp - 3 && engine.quizWarnedAt !== engine.course.id) {
engine.quizWarnedAt = engine.course.id;
if (cfg.autoSkipQuiz) {
beep(1);
toast('⚡ 快到 ' + qi.tp + '% 了,随堂测马上弹出,助手会自动关闭并继续播放(不代答)', 6000);
} else {
beep(2);
toast('⚡ 快到 ' + qi.tp + '% 了,马上自动弹出随堂测,请准备本人作答', 6500);
desktopNotify('随堂测即将弹出', '本课看课到 ' + qi.tp + '% 会自动弹出随堂测,请回到页面准备作答。');
}
}
}
}
// 需要本人处理的拦截
const block = detectBlock();
if (block) { markBlocked(block); syncVideo(); fireUpdate(); return; }
if (engine.blocked) { engine.blocked = ''; engine.reason = '继续播放'; }
const mask = endedMaskVisible();
const completedText = completedTextVisible();
const nb = $('.ended-mask .btns .next') || $('.btns .next');
const key = segKey();
// 四路完播信号(前三路接近片尾;第四路:结束浮层出现即视为播完)
const v = syncVideo();
const dur = v ? num(v.duration) : 0;
const cur = v ? num(v.currentTime) : 0;
const doneByEnd = v && v.ended && dur > 0 && cur >= dur - 2 && cur > 2;
const doneByTail = v && dur > 60 && cur >= dur - 1.5 && cur > 30;
const doneByBtn = v && visible(nb) && v.paused && dur > 0 && cur >= dur * 0.9;
const doneByMask = mask || completedText;
engine.diag = {
video: !!v, dur: Math.round(dur), cur: Math.round(cur), ended: !!(v && v.ended), paused: !!(v && v.paused),
nextBtn: visible(nb), mask: mask, completedText: completedText,
domItems: items.length, apiSegs: pdata.segs.length,
idx: engine.idx, title: engine.segTitle, courseName: pdata.name, resumed: engine.resumedFor
};
if ((doneByEnd || doneByTail || doneByBtn || doneByMask) && engine.doneFor !== key) {
const reason = doneByEnd ? 'ended' : doneByTail ? 'tail' : doneByBtn ? 'nextbtn' : 'mask';
log('完播信号', reason, { dur, cur, mask, completedText });
advance(reason);
return;
}
// 文本/文档段:没有 video,不能按视频完播判定;等平台「已完成本节内容」或停留兜底时间
if (isTextSeg(engine.idx)) {
const tw = clamp(cfg.textWait, 3, 120);
const dwell = now - engine.segStartedAt;
// 若有可滚动的阅读浮层/正文,平滑自动滚动(模拟阅读,帮助平台判定"已读")
if (!engine.scroller) {
const reader = findScrollable(document.querySelector('.reading-courseware, .sg-notes, [class*="reading"], [class*="notes"]') || document.body);
if (reader && reader.scrollHeight - reader.clientHeight > 40) {
engine.scroller = true;
smoothScroll(reader, clamp(tw, 3, 12) * 1000).then(() => { engine.scroller = false; });
}
}
if (dwell >= tw * 1000) {
engine.reason = '文本段已停留 ' + Math.round(dwell / 1000) + '/' + tw + 's,继续下一节';
advance('text-dwell');
} else {
engine.reason = '文本学习中 ' + Math.round(dwell / 1000) + '/' + tw + 's(自动滚动阅读中…)';
fireUpdate();
}
return;
}
if (!v) { engine.reason = '等待播放器加载…'; fireUpdate(); return; }
if (dur > 0 && cur > 0) {
engine.reason = '播放中 ' + clock(cur) + ' / ' + clock(dur) + '(第 ' + (engine.idx + 1) + '/' + (engine.total || '?') + ' 节)';
}
if (v.paused && now - engine.lastShield > 4000) {
engine.lastShield = now;
tryPlay();
}
fireUpdate();
}
/* --- 多标签互斥(平台限制单端播放) --- */
function holdLock() {
writeJSON(K_LOCK, {
id: engine.tabId, ts: Date.now(),
courseId: engine.course && engine.course.id,
name: pdata.name || (engine.course && engine.course.name) || '',
idx: engine.idx, total: engine.total, state: engine.reason
});
}
function otherTabAlive() {
const l = readJSON(K_LOCK, null);
return l && l.id !== engine.tabId && Date.now() - l.ts < 12000;
}
function startEngine(silent) {
if (engine.running) return;
if (otherTabAlive()) {
engine.state = 'blocked';
engine.reason = '检测到另一个标签页正在学习,已暂停本页';
fireUpdate();
return;
}
const pc = playerCtx(); if (!pc) return;
engine.running = true;
engine.startedAt = Date.now();
engine.state = 'playing';
engine.reason = '自动连播已启动';
startKeepAlive();
tryPlay();
clearInterval(engine.timer);
engine.timer = setInterval(tick, 1000);
tick();
if (!silent) toast('自动连播已启动(静音 · 播完自动下一节)', 2600);
log('engine started', pc);
fireUpdate();
}
function stopEngine(manual) {
engine.running = false;
engine.state = 'idle';
if (manual) engine.reason = '已手动暂停';
clearInterval(engine.timer); engine.timer = null;
stopKeepAlive();
writeJSON(K_LOCK, { id: '', ts: 0 });
fireUpdate();
}
/* ================================================================== *
* 9. 播放页悬浮面板
* ================================================================== */
const MINI_DEF = { w: 404, h: 560, minW: 320, minH: 200, gapX: 20, gapY: 86 };
function mountPlayerPanel() {
ensureRoot();
if (shadow.getElementById('yx-mini')) return;
const p = el('div', 'mini-panel');
p.id = 'yx-mini';
p.innerHTML = `
`;
shadow.appendChild(p);
makeMoveResize(p, p.querySelector('#yx-drag'), p.querySelector('#yx-p-grip'), K_MINI, MINI_DEF);
let collapsed = false;
p.querySelector('#yx-p-min').addEventListener('click', () => {
collapsed = !collapsed;
p.querySelector('#yx-p-body').style.display = collapsed ? 'none' : 'block';
p.querySelector('.foot').style.display = collapsed ? 'none' : 'flex';
p.style.height = collapsed ? 'auto' : (readJSON(K_MINI, {}).h || MINI_DEF.h) + 'px';
});
p.querySelector('#yx-p-refresh').addEventListener('click', () => { renderPlayerPanel._sig = ''; loadPlayerCourse(true); toast('正在刷新课程数据…'); });
p.querySelector('#yx-p-qq').addEventListener('click', (e) => { e.preventDefault(); window.open(QQ_GROUP, '_blank'); });
engine.onUpdate = renderPlayerPanel;
renderPlayerPanel();
if (!loadPdataFromCache()) loadPlayerCourse();
}
function segRowsHtml(cur, courseId) {
if (!pdata.segs.length) {
const msg = pdata.loading ? '正在读取章节…' : (pdata.err ? '章节读取失败:' + esc(pdata.err) : '暂无章节数据,点右上角 ⟳ 重试');
return '' + msg + '
';
}
let lastChp = '';
return pdata.segs.map((s, i) => {
let h = '';
if (s.chp && s.chp !== lastChp) {
lastChp = s.chp;
h = '' + esc(s.chp) + '
';
}
const on = i === cur ? ' on' : '';
const done = courseId && isDone(courseId, i) ? ' done' : '';
const sub = on ? '正在播放' : (done ? '本机已播完' : '');
const badge = !s.video ? '文档'
: (s.tips ? '视频·附课件' : '视频');
let mat = '';
if (s.tips) mat += '';
if (s.notes) mat += '';
const playLabel = s.video ? '播' : '看';
return h + ''
+ '' + (i + 1) + ''
+ '' + badge + esc(s.name) + (sub ? '' + sub + '' : '') + ''
+ '' + clock(s.sec) + ''
+ ''
+ mat
+ '
';
}).join('');
}
function renderPlayerPanel() {
const body = shadow && shadow.getElementById('yx-p-body');
if (!body) return;
const st = shadow.getElementById('yx-p-state');
const ct = shadow.getElementById('yx-p-course');
const q = loadQueue();
const qInfo = q && q.items && q.items.length
? ('队列 ' + (Math.min(num(q.idx) + 1, q.items.length)) + '/' + q.items.length)
: '未设置队列';
const v0 = findVideo();
const cur = v0 ? num(v0.currentTime) : 0, dur = v0 ? num(v0.duration) : 0;
const curIdx = curSegIndex();
const seg = pdata.segs.length ? pdata.segs[curIdx] : null;
// 视频时长在流加载完成前不可信(可能只有 12 秒的占位),优先用接口里这一节的时长
const showDur = seg && seg.sec ? seg.sec : dur;
const qi = quizInfo();
const sig = [engine.running, engine.state, engine.reason, engine.blocked, engine.idx, engine.total,
qi.state,
engine.segTitle, qInfo, Math.round(cur / 5), pdata.loading, pdata.loaded, pdata.err,
pdata.segs.length, curIdx, pdata.name, pdata.visited, engine.resumedFor,
cfg.muted, cfg.autoNext, cfg.keepAlive, cfg.autoRate, cfg.resume,
engine.hb.count, engine.hb.lastLoc].join('|');
if (sig === renderPlayerPanel._sig) return;
renderPlayerPanel._sig = sig;
let cls = 'run', txt = engine.reason || '准备中…';
if (engine.blocked === 'answer') { cls = 'warn'; txt = '需要你本人作答验证题'; }
else if (engine.blocked === 'rate') { cls = 'warn'; txt = '需要你给课程评分'; }
else if (engine.blocked === 'idle') { cls = 'warn'; txt = '需要你点一下「继续计时」'; }
else if (qi.state === 'active' || qi.state === 'failed') { cls = 'warn'; txt = qi.state === 'failed' ? '随堂测未通过,请本人重测' : '本课随堂测已开启,请本人作答'; }
else if (engine.state === 'done') { cls = 'done'; txt = engine.reason || '已完成'; }
else if (engine.state === 'blocked') { cls = 'warn'; }
if (st) st.textContent = txt;
if (ct) ct.textContent = pdata.name || (engine.course && engine.course.name) || '自动连播助手';
const pct = showDur > 3 ? Math.min(100, cur / showDur * 100) : 0;
const cid = engine.course && engine.course.id;
const doneN = pdata.segs.filter((s) => isDone(cid, s.i)).length;
body.innerHTML = `
第 ${curIdx >= 0 ? curIdx + 1 : '-'} / ${pdata.segs.length || engine.total || '-'} 节
${esc(engine.segTitle || (seg ? seg.name : '—'))}
${qInfo}${pdata.teacher ? '
' + esc(pdata.teacher) : ''}
本节 ${clock(cur)} / ${clock(showDur)}${pct.toFixed(0)}%
⚡
加速学习 · 已是最快合规路径
心跳已上报 ${engine.hb.count} 次 · 最近位置 ${engine.hb.lastLoc != null ? clock(engine.hb.lastLoc) : '--'}
${engine.running
? '
'
: '
'}
章节列表
${pdata.segs.length ? pdata.segs.length + ' 节 · ' + hhmm(pdata.total) : ''}${doneN ? ' · 已播 ' + doneN + ' 节' : ''}
课程总时长 ${hhmm(pdata.total)}本门已学 ${hhmm(pdata.visited)}
${segRowsHtml(curIdx, cid)}
${cfg.muted ? '🔇 静音' : '🔊 有声'} · ${cfg.autoNext ? '自动下一节' : '手动下一节'} · ${cfg.keepAlive ? '保活开' : '保活关'} · ${cfg.resume ? '断点续播' : '整门重看'} · 评分${cfg.autoRate ? '自动 ' + cfg.rateValue + ' 星' : '手动'}${qi.hasQuiz ? ' ·
随堂测 @' + qi.tp + '%(需本人作答)' : ''}
设置
`;
const bind = (id, fn) => { const n = shadow.getElementById(id); if (n) n.addEventListener('click', fn); };
bind('yx-start', () => { if (engine.running) stopEngine(true); else startEngine(); });
bind('yx-skip', () => { engine.lastAdvance = 0; engine.pending = null; engine.doneFor = ''; advance('manual'); });
bind('yx-nextc', () => finishCourse());
bind('yx-tutor', () => showTutorial());
bind('yx-guide', () => readGuide());
bind('yx-hb-info', () => showHbRules());
bind('yx-donate', () => showDonate());
bind('yx-cfg', (e) => { e.preventDefault(); showSettings(); });
bind('yx-diag', () => {
const info = {
版本: VERSION, 运行: engine.running, 状态: engine.state, 说明: engine.reason,
课程接口名: pdata.name, 课程接口章节数: pdata.segs.length,
当前节: engine.segTitle, 当前节接口时长: seg ? seg.sec : null,
DOM目录数: resItems().length, 视频时长: Math.round(dur), 就绪信号: engine.diag,
随堂测: qi.hasQuiz ? (qi.label + ' / ' + qi.state + ' / ' + qi.tp + '%') : '本课无测验',
静音: cfg.muted, 保活: cfg.keepAlive, 保活音频: keepAlive.osc ? '已启动' : '未启动',
平台弹窗: (function () { const b = detectBlock(); return b === 'answer' ? '验证题弹出中' : b === 'rate' ? '评分弹出中' : b === 'idle' ? '继续计时提示中' : '无'; })(),
页面: location.href
};
copyText(JSON.stringify(info, null, 2), '自检信息已复制,可粘贴到反馈群');
});
body.querySelectorAll('[data-go]').forEach((b) => b.addEventListener('click', () => {
const i = num(b.getAttribute('data-go'));
if (clickRes(i)) toast('已跳到第 ' + (i + 1) + ' 节');
else toast('目录里找不到第 ' + (i + 1) + ' 节');
}));
body.querySelectorAll('[data-tips]').forEach((b) => b.addEventListener('click', () => {
const u = b.getAttribute('data-tips');
if (u) { window.open(u, '_blank', 'noopener'); toast('已在新标签页打开课件 / 资料', 2000); }
}));
body.querySelectorAll('[data-notes]').forEach((b) => b.addEventListener('click', () => {
const u = b.getAttribute('data-notes');
if (u) { window.open(u, '_blank', 'noopener'); toast('已在新标签页打开文稿', 2000); }
}));
}
/* ================================================================== *
* 10. 打赏 / 教程 / 设置 / QQ 群
* ================================================================== */
function showDonate() {
modal({
title: '请我喝杯咖啡 ☕',
sub: '全部功能永久免费,不打赏也一个不少',
body: `
这不是付费墙
脚本的每一项功能都对所有人开放,没有任何"赞助解锁"的隐藏开关。打赏只代表一件事:你愿意让这个工具继续被维护下去。
支付宝扫码打赏
微信扫码打赏
图片可右键「图片另存为」保存。若二维码加载失败,说明图床被浏览器拦了,稍后再试或直接进群找我。
`,
buttons: [{ text: '加入反馈群', cls: 'ghost', close: false, onClick: () => window.open(QQ_GROUP, '_blank') }, { text: '好的', cls: '' }]
});
}
const WHY_HTML = `
为什么不能"秒学完"——平台源码级证据
我把播放器主包(spring-grain)反混淆后拿到了它自己的计时与上报代码,结论是确定的:
| 机制 | 平台实现 | 后果 |
| 计时器 | setInterval(fn,1000) 里 durationTime++ | 按真实墙上时钟累计,与视频播放位置无关 |
| 上报 | 每 25 秒 POST /heartbeat-center/hb/report,watchTime:25 | watchTime 是写死的常量,不是视频进度 |
| 时长要求 | courseCompleteTimeRate:100 | 每门课必须看满 100% 时长 |
| 进度条 | boolDragLock:1 | 禁止拖动,跳片尾无效 |
| 防挂机 | boolFoolproofLock:2 · foolproofDuration:1800 | 挂机检测 |
| 弹题 | openTestPercentage:60 | 60% 处弹验证题,需本人作答 |
| 多端互斥 | boolMultiClientOpen:2(错误码 3028002) | 同一账号只能一个终端学 |
1. 倍速不会更快拿到学时。上报的是墙上时钟,2 倍速只让视频提前播完,学时仍按真实秒数记——一门 60 分钟的课,2 倍速 30 分钟播完,你只拿到约 30 分钟,剩下白丢。所以助手锁死 1 倍速,这是数学上的最优解。
2. 唯一能"秒学完"的办法是绕过播放器直接伪造 25 秒一次的 hb/report 请求刷学时。那是伪造学习记录,结果会进你的继续教育学分,一旦核查是实打实的问题——所以助手不做。
3. 现实结论:1440 分钟 = 24 小时真实时间,这个下限压不掉。助手能把"你必须坐在电脑前"的 24 小时,压成"只需在平台强制本人确认时点一下"。
`;
function showTutorial() {
modal({
title: '使用教程',
sub: '三分钟上手 · 看得懂就会用',
body: `
装好油猴Chrome / Edge 装 Tampermonkey(篡改猴);360 极速浏览器在扩展中心搜 Tampermonkey。装完工具栏会出现黑色方块图标。
安装本脚本把 研修网学习助手.user.js 拖进浏览器窗口,油猴弹出安装页,点「安装」。
登录研修网在要挂机的电脑上登录,然后打开「学习空间」页面(网址含 /train2/workspace/)。右下角出现蓝色「研」按钮即成功。
点「研」→ 自动挂机 → 生成队列 → 开始推荐「最短达标」:按时长从短到长排好,用最少的课凑满 ${REQUIRE_MIN} 分钟。点「开始自动看课」,第一门课自动打开。
然后去干别的播放页自动:静音 → 播放 → 播完自动下一节 → 本门播完自动下一门 → 队列跑完自动停。播放页意外关闭时,学习空间页的「看门狗」会自动重开。
面板怎么用
两个面板都能拖动(按住顶部彩色标题栏)和缩放(拖右下角斜纹),位置大小会自动记住。
播放页面板显示:课程名 / 讲师 / 本节序号 / 本节标题 / 本节时长 / 全章节列表(含每节时长和"本机已播完"标记);点章节右侧「播」可直接跳过去。
必须知道的三件事
1. 别关播放页标签页。可以切到别的窗口、别的标签页;开了「后台保活」浏览器不会冻结它。学习空间页也留着,看门狗靠它重开播放页。
2. 平台会弹「回答问题完成验证」和「继续计时」。这是研修网的防挂机机制。助手会响铃 + 桌面通知 + 把窗口拉到前台提醒你,但不替你作答、也不替你点「继续」——你处理完,自动连播自己继续。
3. 学时按真实时长累计,倍速会少记,所以助手固定 1 倍速;进度条受平台锁定,不可拖动。
常见问题
Q:面板读不到章节标题和时长?点面板右上角 ⟳ 刷新课程数据;仍不行就点「自检」把信息发到反馈群。
Q:切出去回来发现停了?多半是弹验证题,面板会变红提示,答完自动继续。
Q:能开两个播放页吗?不能,平台限制单端(错误码 3028002),助手会自动暂停第二个。
Q:看板数据准吗?来自研修网官方接口,与学习空间一致。
${WHY_HTML}`,
buttons: [{ text: '看打赏', cls: 'gold', onClick: () => setTimeout(showDonate, 60) }, { text: '知道了', cls: '' }]
});
}
function showSettings() {
const rows = [
['muted', '静音播放', '播放时自动静音,不影响你听歌/开会'],
['autoNext', '自动连播', '一节播完自动下一节,本门播完自动下一门'],
['keepAlive', '后台保活', '防止浏览器把后台标签页冻结导致中断'],
['resume', '断点续播', '重新进入某门课时,跳过本机已经完整播完的章节'],
['autoReadGuide', '自动读指南', '本门学完后自动打开并滚动阅读「课程指南/专家介绍」'],
['watchdog', '看门狗', '播放页被关掉或崩溃时,自动重新打开'],
['notice', '桌面通知', '需要你处理时弹出系统通知'],
['sound', '响铃提醒', '需要你处理时播放提示音'],
['focusTab', '自动拉前台', '需要你处理时把播放页窗口拉到最前'],
['autoRate', '自动评分', '平台要求给课程评星时,按你设定的星数自动提交'],
['autoSkipQuiz', '随堂测自动关闭', '随堂测弹窗出现时点平台自带的「×」关闭并继续播放(不代答,测验成绩需自行补答)'],
['autoStart', '进入即自动开始', '打开队列里的课程时自动开始连播']
];
const body = rows.map((r) => `
`).join('')
+ `
倍速锁死为 1 倍速:平台 watchTime 上报的是墙上时钟常量,倍速只会让你少记学时,不会更快达标。
`;
const m = modal({
title: '设置', sub: '改完立即生效', body,
buttons: [
{ text: '取消', cls: 'ghost' },
{ text: '保存', cls: '', onClick: () => {
m.dlg.querySelectorAll('.sw').forEach((n) => { cfg[n.getAttribute('data-k')] = n.classList.contains('on'); });
const rv = m.dlg.querySelector('#yx-rv'); if (rv) cfg.rateValue = clamp(rv.value, 1, 5);
saveCfg();
if (!cfg.keepAlive) stopKeepAlive(); else if (engine.running) startKeepAlive();
syncVideo(); renderPlayerPanel._sig = ''; fireUpdate(); render();
toast('设置已保存');
} }
]
});
m.dlg.querySelectorAll('.sw').forEach((n) => n.addEventListener('click', () => n.classList.toggle('on')));
}
/* 加速学习(合规)说明:真实心跳规则 + 为什么倍速无效 + 一键最优自检 */
function showHbRules() {
const levers = [
['autoNext', '自动连播', '一节播完自动下一节、本门播完自动下一门'],
['keepAlive', '后台保活', '离开电脑时浏览器不冻结后台标签页,挂着也跑'],
['resume', '断点续播', '跳过本机已完整播完的章节,不重复看'],
['autoStart', '进入即自动开始', '打开队列里的课程立刻连播,不用手动点']
];
const off = levers.filter((l) => !cfg[l[0]]);
const stateLine = off.length
? '有 ' + off.length + ' 项未开启,按下面「一键开启最优」即可拉满'
: '当前已是合规最快配置 ✓';
const leverRows = levers.map((l) => `
`).join('');
const m = modal({
title: '加速学习 · 真实心跳规则',
sub: '结论:学时由服务端按 25 秒心跳累计,合规最快 = 1x + 无缝连播 + 保活 + 断点',
body: `
为什么不能"秒学"——真实抓包证据
我用浏览器抓到了平台自己发出的心跳请求,规则是确定的,不是猜的:
| 字段 | 实测值 | 含义 |
| 端点 | POST /heartbeat-center/hb/report | 每 ~25 秒一次 |
| watchTime | 25(写死) | 每次固定 +25 秒,不是视频进度 |
| watchLocation | 累计 +25 / 次 | 累计观看位置,到时长即本节完成 |
| 响应 | {"code":200,"data":"success"} | 服务端按收到次数累计学时 |
① 倍速不会更快,反而少记。学时 = 心跳次数 × 25 秒(真实墙上时钟)。2 倍速只让视频提前播完、心跳次数变少 → 你拿到的学时更少。所以助手锁死 1 倍速。
② 唯一能"秒学完"的办法是伪造 25 秒一次的心跳刷学时,那是伪造学习记录、会进你的继续教育学分,核查是实打实的风险——本项目底线明确不做。
③ 真正最快的合规路径:1x 真实观看 + 无缝自动连播(本门播完自动下一节/下一门)+ 后台保活(离开也跑)+ 断点续播(跳过已看)。下面四项全开就拉满:
速度自检 ${stateLine}
${leverRows}
${off.length ? '' : '已是最优,挂上即可。学到的学时 = 课程真实总时长,压不掉的下限就是真实时间。
'}`,
buttons: [
{ text: '看打赏', cls: 'ghost', onClick: () => setTimeout(showDonate, 60) },
{ text: '知道了', cls: '' }
]
});
m.dlg.querySelectorAll('.sw').forEach((n) => n.addEventListener('click', () => n.classList.toggle('on')));
const opt = m.dlg.querySelector('#yx-opt');
if (opt) opt.addEventListener('click', () => {
levers.forEach((l) => { cfg[l[0]] = true; });
saveCfg();
if (!cfg.keepAlive) {} else if (engine.running) startKeepAlive();
syncVideo(); renderPlayerPanel._sig = ''; fireUpdate();
const b = m.dlg.querySelector('#yx-opt'); if (b) b.remove();
const sec = m.dlg.querySelector('.sec');
if (sec) sec.innerHTML = '速度自检 已拉满最优 ✓';
toast('已开启全部加速开关');
});
}
function showQQOnce() {
if (readJSON(K_QQ, false)) return;
setTimeout(() => {
if (readJSON(K_QQ, false)) return;
writeJSON(K_QQ, true);
modal({
title: '欢迎使用「研修网学习助手」',
sub: 'v' + VERSION + ' · 由 爱国者 制作 · 完全免费',
body: `
用好它,只需记住两句
① 面板可拖动、可缩放;② 平台弹验证题 / 继续计时时回来点一下就行,其余全自动。
遇到问题先别急:播放页面板的「自检」会把当前课程、章节数、播放器状态一次性复制出来,粘贴到群里我一眼就能定位。
章节标题时长读不到、自动切换失败,同样把自检信息发我。
反馈与更新都发在群里
群链接:${QQ_GROUP}
`,
buttons: [{ text: '加入反馈群', cls: 'gold', close: false, onClick: () => window.open(QQ_GROUP, '_blank') }, { text: '开始使用', cls: '' }]
});
}, 2600);
}
/* ================================================================== *
* 11. 学习空间看板
* ================================================================== */
let ui = { tab: 'overview', open: {} };
let courseFilter = 'todo', courseSort = 'left-desc', courseSearch = '';
const DASH_DEF = { w: 880, h: 640, minW: 420, minH: 320, gapX: 24, gapY: 96 };
function mountDashboard() {
ensureRoot();
if (shadow.getElementById('yx-fab')) return;
const fab = el('button', 'fab', '研');
fab.id = 'yx-fab';
fab.title = '研修网学习助手(点击展开)';
fab.addEventListener('click', () => ui.panel.classList.toggle('hide'));
const panel = el('div', 'wrap hide');
panel.id = 'yx-panel';
panel.innerHTML = `
研
学习助手 · 看课
统一按时长考核 · 需观看 ${REQUIRE_MIN} 分钟 / 满分 ${MAX_SCORE} 分
`;
shadow.appendChild(fab); shadow.appendChild(panel);
ui.panel = panel;
ui.body = panel.querySelector('#yx-body');
makeMoveResize(panel, panel.querySelector('#yx-drag'), panel.querySelector('#yx-grip'), K_DASH, DASH_DEF);
panel.querySelector('#yx-min').addEventListener('click', () => panel.classList.add('hide'));
panel.querySelector('#yx-refresh').addEventListener('click', () => boot(true));
panel.querySelectorAll('.tab').forEach((b) => b.addEventListener('click', () => {
panel.querySelectorAll('.tab').forEach((x) => x.classList.remove('on'));
b.classList.add('on'); ui.tab = b.dataset.t; render();
}));
panel.querySelector('#yx-f-qq').addEventListener('click', (e) => { e.preventDefault(); window.open(QQ_GROUP, '_blank'); });
panel.querySelector('#yx-f-donate').addEventListener('click', (e) => { e.preventDefault(); showDonate(); });
panel.querySelector('#yx-f-tutor').addEventListener('click', (e) => { e.preventDefault(); showTutorial(); });
}
function renderStrip() {
const n = shadow && shadow.getElementById('yx-strip');
if (!n) return;
if (state.loading) { n.innerHTML = '正在读取数据…
'; return; }
if (!state.courses.length) {
const lock = readJSON(K_LOCK, null);
const live = lock && lock.id && Date.now() - lock.ts < 20000;
n.innerHTML = '暂无数据点右上角 ⟳ 拉取
'
+ (live ? '播放页正在学习:' + esc(lock.name || '') + (lock.idx >= 0 ? '(第 ' + (lock.idx + 1) + '/' + (lock.total || '?') + ' 节)' : '') + '
' : '');
return;
}
const s = stats();
const pct = Math.min(100, s.watchedMin / s.reqMin * 100);
const lock = readJSON(K_LOCK, null);
const live = lock && lock.id && Date.now() - lock.ts < 20000;
const boardLine = s.board
? '📚 板块「' + esc(s.board.title) + '」'
+ (s.board.requireText ? ' · ' + esc(s.board.requireText) : '') + '
'
: '';
// 面板副标题同步为平台真实考核(板块标题 / 要求分钟 / 满分)
const subEl = shadow && shadow.getElementById('yx-sub');
if (subEl && s.board) {
subEl.textContent = '学习板块「' + s.board.title + '」 · 需观看 ' + s.reqMin + ' 分钟 / 满分 ' + s.maxScore + ' 分';
}
n.innerHTML = `
${s.watchedMin}/ ${s.reqMin} 分钟
${s.score} / ${s.maxScore} 分
· 还差 ${s.remainMin} 分钟
${boardLine}
${live ? '▶ 播放页正在学习:' + esc(lock.name || '') + '' + (lock.idx >= 0 ? ' · 第 ' + (lock.idx + 1) + '/' + (lock.total || '?') + ' 节' : '') + '
' : ''}`;
}
function render() {
renderStrip();
if (!ui.body) return;
if (ui.tab === 'help') { renderHelp(); return; }
if (state.loading) { ui.body.innerHTML = '正在读取学习数据…
'; return; }
if (!state.courses.length) {
ui.body.innerHTML = '暂无数据。
请点右上角 ⟳ 拉取;若失败,请确认已在「学习空间」登录。
';
return;
}
if (ui.tab === 'overview') renderOverview();
else if (ui.tab === 'auto') renderAuto();
else renderLib();
}
function switchTab(t) {
ui.tab = t;
const p = shadow.getElementById('yx-panel');
if (p) p.querySelectorAll('.tab').forEach((x) => x.classList.toggle('on', x.dataset.t === t));
render();
}
function renderOverview() {
const s = stats();
const pct = Math.min(100, s.watchedMin / REQUIRE_MIN * 100);
const mods = state.modules.map((m) => {
const cs = state.courses.filter((c) => c._module === m.moduleName);
const left = cs.reduce((a, c) => a + num(c.noStudyTime), 0);
return '| ' + esc(m.moduleName) + ' | ' + cs.length + ' 门 | '
+ cs.filter((c) => num(c.noStudyTime) === 0).length + ' 门 | ' + hhmm(left) + ' |
';
}).join('');
const sq = shortestQueue();
const planRows = sq.items.slice(0, 8).map((c, i) => `
| ${i + 1} | ${esc(c.name)} | ${esc(c.module || '')} | ${hhmm(c.needSec)} |
|
`).join('');
// 平台「学习/看课」板块卡片(标题 + 考核要求 + 已完成),数据来自 examine/result/tool/query
const boardCard = s.board
? `${esc(s.board.title)}学习板块${s.board.gotScore != null ? ' · 已得 ' + esc(String(s.board.gotScore)) + ' 分' : ''}
`
: '';
const boardLine = s.board && s.board.requireText
? '平台考核:' + esc(s.board.requireText) + (s.board.finishText ? ' | ' + esc(s.board.finishText) : '') + '
'
: '';
ui.body.innerHTML = `
${s.watchedMin} / ${s.reqMin} 分钟已观看时长
${s.score} 分看课折算得分(满 ${s.maxScore})
${hhmm(s.remainSec)}剩余视频时长
${s.todo} 门未完成课程(共 ${s.total} 门)
${boardCard}
达标进度 ${pct.toFixed(1)}% | 还差 ${s.remainMin} 分钟折算满分,约等于再观看 ${hhmm(s.remainSec)} 的视频。
${boardLine}
最短达标路径按剩余时长升序 · 用最少门数凑满 ${s.reqMin} 分钟
看完下面这些课可累计约 ${sq.accMin} 分钟(含已学 ${fmtMin(stats().doneSec)} 分钟),共 ${sq.items.length} 门。
${sq.items.length > 8 ? '仅显示前 8 门,「课程库」里可以看全部。
' : ''}
模块概览
`;
const a = ui.body.querySelector('#yx-goauto'); if (a) a.addEventListener('click', () => switchTab('auto'));
const b2 = ui.body.querySelector('#yx-golib'); if (b2) b2.addEventListener('click', () => switchTab('lib'));
bindOpen();
}
let queueDraft = null;
function renderAuto() {
const done = stats();
if (!queueDraft) queueDraft = Object.assign({ mode: 'shortest' }, shortestQueue());
const q = loadQueue();
const running = q && q.running;
const items = queueDraft.items;
const totalSec = items.reduce((a, c) => a + num(c.needSec), 0);
const lock = readJSON(K_LOCK, null);
const live = lock && lock.id && Date.now() - lock.ts < 20000;
const rows = items.slice(0, 300).map((c, i) => `
| ${i + 1} |
${esc(c.name)} ${esc(c.module || '')} |
${hhmm(c.needSec)} |
|
`).join('');
ui.body.innerHTML = `
${running
? '队列运行中:第 ' + (num(q.idx) + 1) + ' / ' + q.items.length + ' 门' + (q.items[num(q.idx)] ? '(' + esc(q.items[num(q.idx)].name) + ')' : '')
+ (live ? ' · 播放页在线' : ' · 播放页未运行')
: '当前没有正在运行的队列'}
共 ${items.length} 门 · ${hhmm(totalSec)}
${queueDraft.mode === 'shortest'
? '按时长从短到长排序,用最少的课凑满 ' + REQUIRE_MIN + ' 分钟(预计累计 ' + queueDraft.accMin + ' 分钟)。'
: '所有未完成课程按模块顺序排列。'}
观看队列${running && live ? '播放页正在学习' : '等待启动'}
${running ? '' : ''}
点「开始」会打开第一门课的播放页并自动连播。已学 ${done.watchedMin} 分钟,达标还差 ${done.remainMin} 分钟。
播放页被关掉时,本页的「看门狗」${cfg.watchdog ? '会' : '不会(已在设置里关闭)'}自动重新打开它——所以这个页面也要留着。
`;
ui.body.querySelectorAll('.yx-mode').forEach((b) => b.addEventListener('click', () => {
const m = b.dataset.m;
queueDraft = m === 'shortest'
? Object.assign({ mode: 'shortest' }, shortestQueue())
: { mode: 'all', items: state.courses.filter((c) => num(c.noStudyTime) > 0).map((c) => ({ id: c.id, sid: c.courseSourceId, name: c.courseName, needSec: num(c.noStudyTime), module: c._module, toolId: c._toolId || c.toolId || '' })) };
renderAuto();
}));
ui.body.querySelector('#yx-run').addEventListener('click', () => {
const it = queueDraft.items;
if (!it.length) { toast('没有可加入队列的课程'); return; }
saveQueue({ items: it, idx: 0, running: true, startedAt: Date.now() });
toast('队列已保存,正在打开第一门课…', 2400);
setTimeout(() => window.open(playerUrl(it[0].id, it[0].sid, it[0].toolId), '_blank'), 500);
setTimeout(renderAuto, 900);
});
const sr = ui.body.querySelector('#yx-stoprun');
if (sr) sr.addEventListener('click', () => { const q2 = loadQueue(); if (q2) { q2.running = false; saveQueue(q2); } toast('已停止队列'); renderAuto(); });
ui.body.querySelector('#yx-cfg').addEventListener('click', showSettings);
bindOpen();
}
function libList() {
const kw = courseSearch.trim().toLowerCase();
let list = state.courses.filter((c) => {
if (courseFilter === 'todo') return num(c.noStudyTime) > 0;
if (courseFilter === 'done') return num(c.noStudyTime) === 0;
if (courseFilter === 'must') return String(c.typeName || '必修').indexOf('必修') >= 0 || c.type === 102;
return true;
});
if (kw) list = list.filter((c) => (c.courseName || '').toLowerCase().indexOf(kw) >= 0 || (c._module || '').toLowerCase().indexOf(kw) >= 0);
const cmp = {
'left-desc': (a, b) => num(b.noStudyTime) - num(a.noStudyTime),
'left-asc': (a, b) => num(a.noStudyTime) - num(b.noStudyTime),
'dur-desc': (a, b) => num(b.totalDuration) - num(a.totalDuration),
'name': (a, b) => String(a.courseName).localeCompare(String(b.courseName), 'zh')
};
return list.slice().sort(cmp[courseSort] || cmp['left-desc']);
}
function chapterBox(course) {
const d = state.chapters[course.id];
if (!d) return '正在读取章节…
';
if (d.err) return '章节读取失败:' + esc(d.err) + '
';
const list = d.list || [];
if (!list.length) return '这门课没有章节数据。
';
const rows = list.map((s, i) => `
${i + 1}
${esc(s.name)}${esc(s.chapter || s.chp || '')}${isDone(course.id, i) ? ' · 本机已播完' : ''}
${clock(s.sec)}
`).join('');
const total = d.meta ? d.meta.total : list.reduce((a, s) => a + s.sec, 0);
return `${list.length} 节 · 共 ${hhmm(total)}
${d.meta && d.meta.teacher ? esc(d.meta.teacher) : ''}
${rows}
`;
}
function renderLib() {
const list = libList();
const rows = list.map((c) => {
const sec = num(c.totalDuration), learn = num(c.completeTime), left = num(c.noStudyTime);
const pct = sec ? Math.round(learn / sec * 100) : 0;
const st = left === 0 ? '已完成'
: learn > 0 ? '进行中' : '未开始';
const isOpen = !!ui.open[c.id];
return `
|
${esc(c.courseName)} ${esc(c.mainTeacher || '')} |
${esc(c._module)} |
${esc(c.typeName || '必修')} |
${hhmm(sec)} | ${pct}% | ${hhmm(left)} | ${st} |
|
` + (isOpen ? `| ${chapterBox(c)} |
` : '');
}).join('');
ui.body.innerHTML = `
显示 ${list.length} 门。点最左侧 ▸ 展开章节,可看每节标题、时长、播放地址。
| 课程名称 | 模块 | 类型 | 时长 | 进度 | 待看 | 状态 | 操作 |
${rows}
`;
const kw = ui.body.querySelector('#yx-kw');
kw.addEventListener('input', () => {
courseSearch = kw.value;
const p = kw.selectionStart;
renderLib();
const k2 = ui.body.querySelector('#yx-kw');
if (k2) { k2.focus(); try { k2.setSelectionRange(p, p); } catch (e) {} }
});
ui.body.querySelector('#yx-sort').addEventListener('change', (e) => { courseSort = e.target.value; renderLib(); });
ui.body.querySelectorAll('.yx-f').forEach((b) => b.addEventListener('click', () => { courseFilter = b.dataset.f; renderLib(); }));
ui.body.querySelector('#yx-exp').addEventListener('click', exportCoursesCSV);
ui.body.querySelector('#yx-expall').addEventListener('click', exportAllVideosCSV);
ui.body.querySelectorAll('[data-exp]').forEach((b) => b.addEventListener('click', () => {
const id = b.getAttribute('data-exp');
ui.open[id] = !ui.open[id];
if (ui.open[id] && !state.chapters[id]) {
ui.body.querySelector('[data-exp="' + id + '"]').textContent = '▾';
const c = state.courses.find((x) => String(x.id) === String(id));
const holder = b.closest('tr');
const tr = document.createElement('tr');
tr.innerHTML = '' + chapterBox(c) + ' | ';
holder.parentNode.insertBefore(tr, holder.nextSibling);
loadChapters(c).then(() => { if (ui.tab === 'lib' && ui.open[id]) renderLib(); })
.catch((e) => { state.chapters[id] = { list: [], meta: null, err: e.message }; if (ui.tab === 'lib') renderLib(); });
return;
}
renderLib();
}));
ui.body.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyText(b.getAttribute('data-copy'), '已复制 m3u8 播放地址')));
bindOpen();
}
function renderHelp() {
ui.body.innerHTML = `
装好油猴Chrome / Edge 装 Tampermonkey(篡改猴);360 极速浏览器在扩展中心搜 Tampermonkey。
安装本脚本把 研修网学习助手.user.js 拖进浏览器窗口 → 油猴弹窗点「安装」。
登录研修网并打开学习空间网址含 /train2/workspace/,右下角出现蓝色「研」按钮即生效。
自动挂机 → 生成队列 → 开始选「最短达标」可用最少门数凑满 ${REQUIRE_MIN} 分钟。点开始后自动打开第一门课。
去干别的播放页自动静音、自动下一节、本门完自动下一门。两个面板都能拖动和缩放。
必须知道的三件事
1. 别关播放页标签页;切到其他窗口没关系,开了「后台保活」就不会被浏览器冻结。学习空间页也要留着——看门狗靠它重开播放页。
2. 平台在约 60% 处会弹「回答问题完成验证」,长时间无操作还会弹「继续计时」,这是防挂机机制。助手会响铃 + 桌面通知 + 把窗口拉到前台,但不替你作答、也不替你点「继续」。
3. 学时按真实时长累计,倍速会少记,所以固定 1 倍速;进度条被平台锁定,不可拖动。
常见问题
Q:面板读不到章节标题和时长?点面板右上角 ⟳ 刷新课程数据;仍不行就点「自检」把信息发到反馈群。
Q:切出去回来发现停了?多半是弹验证题,面板会变红提示,答完自动继续。
Q:能开两个播放页吗?不能,平台限制单端(错误码 3028002),助手会自动暂停第二个。
Q:数据准吗?来自研修网官方接口,与学习空间一致。
${WHY_HTML}
`;
const a = ui.body.querySelector('#yx-q2'); if (a) a.addEventListener('click', () => window.open(QQ_GROUP, '_blank'));
const b = ui.body.querySelector('#yx-d2'); if (b) b.addEventListener('click', showDonate);
const c = ui.body.querySelector('#yx-t2'); if (c) c.addEventListener('click', showTutorial);
}
function bindOpen() {
$$('.yx-open', ui.body).forEach((b) => b.addEventListener('click', () => {
window.open(playerUrl(b.dataset.id, b.dataset.sid, b.dataset.toolid), '_blank');
}));
}
/* --- CSV 导出 --- */
function download(name, text) {
const blob = new Blob(['\ufeff' + text], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = name; a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 3000);
}
const q1 = (s) => '"' + String(s == null ? '' : s).replace(/"/g, '""') + '"';
function exportCoursesCSV() {
const head = ['模块', '课程ID', '课程名称', '主讲', '类型', '时长', '已学', '待看', '进度%', '播放页'];
const lines = [head.map(q1).join(',')];
state.courses.forEach((c) => lines.push([c._module, c.id, c.courseName, c.mainTeacher, c.typeName || '必修',
hhmm(num(c.totalDuration)), hhmm(num(c.completeTime)), hhmm(num(c.noStudyTime)),
Math.round(num(c.completeTime) / (num(c.totalDuration) || 1) * 100), playerUrl(c.id, c.courseSourceId, c._toolId)].map(q1).join(',')));
download('研修网看课清单.csv', lines.join('\n'));
toast('已导出课程清单');
}
async function exportAllVideosCSV() {
toast('正在抓取章节视频…');
const lines = [['模块', '课程', '章节', '视频标题', '时长', '秒', 'm3u8地址'].map(q1).join(',')];
for (const c of state.courses) {
try {
const d = await loadChapters(c);
d.list.forEach((s) => lines.push([c._module, c.courseName, s.chp, s.name, hhmm(s.sec), s.sec, s.url].map(q1).join(',')));
} catch (e) { log('章节失败', c.courseName, e); }
}
download('研修网章节视频清单.csv', lines.join('\n'));
toast('已导出章节视频清单');
}
/* ================================================================== *
* 12. 看门狗(学习空间页常驻,播放页掉线自动重开)
* ================================================================== */
function watchdog() {
if (!cfg.watchdog) return;
const q = loadQueue();
if (!q || !q.running || !q.items || !q.items.length) return;
const lock = readJSON(K_LOCK, null);
const alive = lock && lock.id && Date.now() - lock.ts < 45000;
if (alive) { watchdog._miss = 0; return; }
watchdog._miss = (watchdog._miss || 0) + 1;
if (watchdog._miss < 3) return; // 连续 3 个周期(≈90 秒)都没心跳才动手
if (Date.now() - (watchdog._last || 0) < 240000) return;
watchdog._last = Date.now(); watchdog._miss = 0;
const it = q.items[num(q.idx)] || q.items[0];
if (!it) return;
log('watchdog 重开播放页', it.name);
toast('看门狗:播放页掉线,正在重新打开「' + it.name + '」', 3200);
writeJSON(K_LOCK, { id: '', ts: 0 });
window.open(playerUrl(it.id, it.sid, it.toolId), '_blank');
}
/* ================================================================== *
* 13. 启动
* ================================================================== */
async function boot(force) {
mountDashboard();
state.ctx = parseCtx();
if (!state.ctx) { render(); return; }
if (!force && loadCache()) { render(); return; }
state.loading = true; render();
try {
await waitToken();
if (!auth.token) throw new Error('未捕获到登录令牌,请刷新页面后重试');
await loadAll();
} catch (e) {
state.loading = false; log('加载失败', e);
renderStrip();
if (ui.body) ui.body.innerHTML = '加载失败:' + esc(e.message) + '
请确认已登录,然后点右上角 ⟳ 重试。
';
return;
}
state.loading = false; queueDraft = null; render(); renderStrip(); toast('数据已更新');
}
function bootPlayer() {
mountPlayerPanel();
installHbProbe();
try { if (document.title.indexOf('研修网') < 0) document.title = '研修网 · 自动连播'; } catch (e) {}
const q = loadQueue();
const pc = playerCtx();
const inQueue = !!(q && q.items && pc && q.items.some((x) => String(x.id) === String(pc.courseId)));
if (q && q.running && cfg.autoStart && inQueue) {
let tries = 0;
const wait = setInterval(() => {
tries++;
if (findVideo() || tries > 40) { clearInterval(wait); startEngine(true); }
}, 500);
} else {
renderPlayerPanel();
}
let m = 0;
const mi = setInterval(() => { m++; syncVideo(); if (m > 20) clearInterval(mi); }, 700);
}
function ready(fn) {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn);
else fn();
}
function route() {
const isPlayer = /\/grain\/course\/\d+\/detail/.test(location.pathname);
const isMember = /\/train2\/workspace\/\d+\/(?:training\/)?member\/?$/.test(location.pathname);
const isDash = /\/train2\/workspace\//.test(location.pathname);
if (isPlayer) {
ready(() => setTimeout(bootPlayer, 1200));
} else if (isDash) {
// 学习空间及其子页面(含 /training/member)统一走数据看板,按 projectId 拉取课程列表并自动学习
log('看板入口', { isMember, path: location.pathname });
ready(() => setTimeout(() => boot(false), 1200));
setInterval(watchdog, 30000);
setInterval(renderStrip, 5000); // 播放页学习进度实时回显
}
ready(() => showQQOnce());
}
try { route(); } catch (e) { log('启动失败', e); }
})();