// ==UserScript==
// @name 医博士继续医学教育在线学习助手
// @namespace https://card.wlxy.live/details/050752C8
// @version 1.0
// @author 柠檬真酸
// @icon https://huaweicloudobs.ahjxjy.cn/895789f9086469785b846d30c0ed95f9.png
// @description 医博士继续医学教育学习辅助:培训计划/我的项目队列、学完自动考试,免费体验与 Pro 授权,2倍速自动挂机高效安全
// @antifeature payment 免费体验3个视频章节,升级Pro不限
// @antifeature membership 需云端授权
// @match https://www.yiboshi.com/*
// @match https://*.yiboshi.com/*
// @connect api.yiboshi.com
// @connect apicloud.yiboshi.com
// @connect source.yiboshi.com
// @connect study-cdn.yiboshi.com
// @connect huaweicloudobs.ahjxjy.cn
// @connect oa14.ahzsksw.cn
// @connect card.wlxy.live
// @grant GM_xmlhttpRequest
// @grant GM_info
// @grant GM_openInTab
// @run-at document-start
// @license All Rights Reserved
// ==/UserScript==
(function () {
"use strict";
const SCRIPT_VERSION = (() => {
try {
if (typeof GM_info !== "undefined" && GM_info && GM_info.script && GM_info.script.version) {
return String(GM_info.script.version);
}
} catch (_) {}
return "1.0.0";
})();
const API_BASE = "https://api.yiboshi.com";
const PLATFORM_CLOUD_BASE = "https://apicloud.yiboshi.com";
const AUTH_STORE_KEY = "ybs_mvp_auth_v1";
const PANEL_POS_KEY = "ybs_panel_pos_v1";
const PANEL_COLLAPSED_KEY = "ybs_panel_collapsed_v1";
const LAST_TRAINING_KEY = "ybs_last_training_id_v1";
const STUDY_RUN_KEY = "ybs_study_run_v1";
const CLOUD_TOKEN_KEY = "ybs_cloud_token_v1";
const CLOUD_LEASE_CACHE_KEY = "ybs_cloud_lease_cache_v1";
const CLOUD_PRO_EXPIRE_CACHE_KEY = "ybs_cloud_pro_expire_cache_v1";
const CLOUD_LAST_STATE_KEY = "ybs_cloud_last_state_v1";
const DEFAULT_CLOUD_API_BASE = "https://oa14.ahzsksw.cn";
const PRO_BUY_URL = "https://card.wlxy.live/details/050752C8";
const PANEL_LOGO_URL = "https://huaweicloudobs.ahjxjy.cn/895789f9086469785b846d30c0ed95f9.png";
const QQ_GROUP_NUMBER = "903117129";
const QQ_GROUP_LINK =
"https://qun.qq.com/universal-share/share?ac=1&authKey=rxdL6YIJ0%2FxOEemjLqTGULvl5aAfJIVQcIvkvnwvmL%2FAmpFZnSafajYHgSXMUXvx&busi_data=eyJncm91cENvZGUiOiI5MDMxMTcxMjkiLCJ0b2tlbiI6IlB2dkFGSm5XRXBrSEhtQVFTUGQzdVNZakhNWDNMbW1kODA2enpoMi9obDh4SWp0YzBDODNFaGtwRU44Z0hyU0siLCJ1aW4iOiIxMjU0MzE1MTQifQ%3D%3D&data=9oyJixSPcigCQW-saV5eXlcMwV9C6J36XySx-rDHwVwNlofvRmd2ze5sLwFtHTbYbG4nAWIUrI0qftC6aTX9xg&svctype=4&tempid=h5_group_info";
const PANEL_NOTICE_FALLBACK = "\u514d\u8d39\u4f53\u9a8c 3 \u4e2a\u89c6\u9891\u7ae0\u8282\uff0c\u5347\u7ea7 Pro \u4e0d\u9650";
const FIXED_STEP_SEC = 20;
const FIXED_SPEED = 1;
const VIOLATION_COOLDOWN_MS = 15000;
const HEARTBEAT_EVERY_TICKS = 8;
const HEARTBEAT_STEP_THRESHOLD = 12;
const OPERATE_HEARTBEAT = 11;
const OPERATE_TICK = 2;
const state = {
running: false,
stopRequested: false,
trainings: [],
projects: [],
projectTree: [],
selectedTrainingId: "",
userId: "",
uuid: "",
logLines: [],
queueDone: 0,
queueTotal: 0,
currentCourse: "",
currentChapter: "",
currentTask: "\u70b9\u5f00\u59cb\u540e\u81ea\u52a8\u5b66\u4e60",
activeSpeed: FIXED_SPEED,
autoLoading: false,
chapterPreview: [],
cloudApiBase: DEFAULT_CLOUD_API_BASE,
cloudToken: String(localStorage.getItem(CLOUD_TOKEN_KEY) || "").trim(),
cloudTier: String(localStorage.getItem(CLOUD_TOKEN_KEY) || "").trim() ? "unknown" : "free",
cloudLease: "",
cloudLeaseExp: 0,
cloudProExpireAt: 0,
cloudRevoked: false,
freeVideoLimit: 3,
freeUsedVideos: 0,
proBuyUrl: PRO_BUY_URL,
panelNoticePath: "/api/ybs/panel-notice",
remotePanelNotice: PANEL_NOTICE_FALLBACK,
};
function log(msg) {
const line = `[${new Date().toLocaleTimeString()}] ${String(msg || "")}`;
state.logLines.unshift(line);
state.logLines = state.logLines.slice(0, 100);
console.log(`[\u533b\u535a\u58eb\u5237\u8bfe] ${msg}`);
renderLog();
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function formatDuration(sec) {
const s = Math.max(0, Number(sec) || 0);
const m = Math.round((s / 60) * 10) / 10;
if (m <= 0) return "0\u5206\u949f";
const text = Number.isInteger(m) ? String(m) : m.toFixed(1);
return text + "\u5206\u949f";
}
function escHtml(s) {
return String(s || "")
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
function isViolationError(err) {
const t = String((err && err.message) || err || "");
return /\u8fdd\u89c4|\u91cd\u65b0\u8fdb\u5165|\u5f02\u5e38\u5b66\u4e60/i.test(t);
}
function randomHex(len) {
const chars = "0123456789abcdef";
let s = "";
for (let i = 0; i < len; i += 1) s += chars[(Math.random() * 16) | 0];
return s;
}
function isPlayPage() {
const href = String(location.href || "").toLowerCase();
const path = String(location.pathname || "").toLowerCase();
const hash = String(location.hash || "").toLowerCase();
const q = String(location.search || "").toLowerCase();
const blob = href + " " + path + " " + hash + " " + q;
return /video-player|videoplayer|\/player\/|courseplay|playvideo|wareplay|coursewareplay|\/play\/|type=play|playtype=/.test(
blob
);
}
function readStudyRun() {
try {
const raw = sessionStorage.getItem(STUDY_RUN_KEY);
if (!raw) return null;
const p = JSON.parse(raw);
if (!p || !p.running || !Array.isArray(p.queue) || !p.queue.length) return null;
if (Date.now() - Number(p.updatedAt || 0) > 6 * 3600 * 1000) {
sessionStorage.removeItem(STUDY_RUN_KEY);
return null;
}
return p;
} catch (_) {
return null;
}
}
function saveStudyRun(partial) {
try {
const prev = readStudyRun() || {};
const next = Object.assign({}, prev, partial || {}, { updatedAt: Date.now() });
if (!next.running) {
sessionStorage.removeItem(STUDY_RUN_KEY);
return;
}
sessionStorage.setItem(STUDY_RUN_KEY, JSON.stringify(next));
} catch (_) {}
}
function clearStudyRun() {
try {
sessionStorage.removeItem(STUDY_RUN_KEY);
} catch (_) {}
}
function parseJwt(token) {
try {
const raw = String(token || "").replace(/^Bearer\s+/i, "").trim();
const part = raw.split(".")[1];
if (!part) return null;
const pad = part + "=".repeat((4 - (part.length % 4)) % 4);
const json = decodeURIComponent(escape(atob(pad.replace(/-/g, "+").replace(/_/g, "/"))));
return JSON.parse(json);
} catch (_) {
return null;
}
}
function buildDeviceInfo(uuid) {
const payload = {
deviceid: randomHex(32),
devicetype: "pc",
devicename: navigator.userAgent,
vername: "",
vercode: "",
sysver: "",
platform: "\u4e2d\u56fd\u5927\u9646",
uuid: uuid || randomHex(32),
};
return btoa(unescape(encodeURIComponent(JSON.stringify(payload))));
}
function loadAuth() {
try {
return JSON.parse(sessionStorage.getItem(AUTH_STORE_KEY) || "{}") || {};
} catch (_) {
return {};
}
}
function syncUserFromAuth(auth) {
const jwt = parseJwt(auth && auth.token);
if (jwt) {
state.userId = String(jwt.uid || jwt.userId || jwt.sub || state.userId || "");
state.uuid = String(jwt.uuid || state.uuid || "");
}
}
function injectAuthHook() {
const code = function () {
if (window.__ybsAuthHooked) return;
window.__ybsAuthHooked = true;
const KEY = "ybs_mvp_auth_v1";
function persist(token, deviceinfo) {
if (!token) return;
let prev = {};
try {
prev = JSON.parse(sessionStorage.getItem(KEY) || "{}") || {};
} catch (_) {}
sessionStorage.setItem(
KEY,
JSON.stringify({
token: String(token).replace(/^Bearer\s+/i, "").trim(),
deviceinfo: deviceinfo || prev.deviceinfo || "",
at: Date.now(),
})
);
}
const origSet = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
try {
if (!this.__ybsHdr) this.__ybsHdr = {};
this.__ybsHdr[String(name).toLowerCase()] = value;
if (String(name).toLowerCase() === "authorization" && value) {
persist(value, this.__ybsHdr.deviceinfo);
}
if (String(name).toLowerCase() === "deviceinfo" && value) {
persist(this.__ybsHdr.authorization, value);
}
} catch (_) {}
return origSet.apply(this, arguments);
};
const origFetch = window.fetch;
window.fetch = function (input, init) {
try {
const hdr = new Headers((init && init.headers) || (input instanceof Request ? input.headers : undefined));
const auth = hdr.get("authorization");
const dev = hdr.get("deviceinfo");
if (auth) persist(auth, dev);
} catch (_) {}
return origFetch.apply(this, arguments);
};
};
const el = document.createElement("script");
el.textContent = "(" + code.toString() + ")();";
(document.documentElement || document.head || document.body).appendChild(el);
el.remove();
}
injectAuthHook();
function isApiSuccess(data, httpStatus) {
if (httpStatus >= 200 && httpStatus < 300) {
if (data && data.code === 0) return true;
if (data && data.success === true) return true;
if (data && data.status === 200) return true;
if (data && data.code == null && data.status == null && data.data != null) return true;
if (data && data.code == null && data.status == null) return true;
}
return false;
}
function projStateLabel(stateId) {
if (stateId == null || stateId === "") return "";
const n = Number(stateId);
if (n === 0) return "\u672a\u5f00\u59cb";
if (n === 1) return "\u5b66\u4e60\u4e2d";
if (n === 2) return "\u5f85\u8003\u8bd5";
if (n === 3) return "\u5df2\u5b8c\u6210";
return "\u72b6\u6001" + stateId;
}
const PRACTICE_PASS_SCORE = 100;
function courseStudyStateLabel(c) {
if (!c || typeof c !== "object") return "\u672a\u5b66\u4e60";
const raw = c.courseState;
if (raw == null || raw === "") return "\u672a\u5b66\u4e60";
if (typeof raw === "string" && /[\u4e00-\u9fa5]/.test(raw)) return raw;
const n = Number(raw);
if (n === 1) return "\u5b66\u4e60\u4e2d";
if (n === 2) return "\u5df2\u5b66\u5b8c";
if (n === 0) return "\u672a\u5b66\u4e60";
return "\u72b6\u6001" + raw;
}
function coursePracticeStateLabel(c) {
if (!c || typeof c !== "object") return "";
if (c.practiceNull === true || c.practiseNull === true) return "";
const score = c.practiseScore ?? c.practiceScore;
if (score == null || score === "") return "\u672a\u8003\u8bd5";
const n = Number(score);
if (Number.isFinite(n) && n >= PRACTICE_PASS_SCORE) return "\u8003\u8bd5\u901a\u8fc7";
if (Number.isFinite(n)) return "\u8003\u8bd5\u672a\u901a\u8fc7";
return "\u672a\u8003\u8bd5";
}
function courseStatusLabel(c) {
if (!c || typeof c !== "object") return "";
const study = courseStudyStateLabel(c);
const exam = coursePracticeStateLabel(c);
return exam ? study + " · " + exam : study;
}
function formatVideoProgress(cw) {
const total = Number(cw.videoTotalTime || 0);
const view = Number(cw.viewTime || 0);
if (cw && cw.isSegment && cw.segmentStart != null) {
const end = Number(cw.segmentEnd != null ? cw.segmentEnd : total);
if (view >= end && end > 0) return "\u5df2\u5b66\u5b8c";
if (view >= Number(cw.segmentStart)) {
return "\u5b66\u4e60\u4e2d · " + formatDuration(view);
}
return "\u672a\u5b66\u4e60 · " + formatDuration(cw.segmentStart);
}
if (total > 0) {
const pct = Math.min(100, Math.round((view / total) * 100));
return pct + "% · " + formatDuration(view) + "/" + formatDuration(total);
}
if (view > 0) return formatDuration(view);
return "\u672a\u5b66\u4e60";
}
function pickList(resp) {
const d = resp && resp.data;
if (Array.isArray(d)) return d;
if (d && typeof d === "object") {
for (const k of [
"courseVideoArr",
"coursewareList",
"courseWareList",
"list",
"records",
"rows",
"data",
"content",
"wares",
"coursewares",
]) {
if (Array.isArray(d[k])) return d[k];
}
}
return [];
}
function pickCoursewareName(cw) {
if (!cw || typeof cw !== "object") return "";
const keys = [
"name",
"coursewareName",
"courseWareName",
"wareName",
"videoName",
"cwName",
"chapterName",
"sectionName",
"title",
"label",
];
for (const k of keys) {
const v = cw[k];
if (v != null && String(v).trim()) return String(v).trim();
}
return "";
}
function isPlaceholderVideoName(name) {
return !name || /^\u89c6\u9891\s*\d+$/i.test(String(name).trim());
}
function pickName(item) {
if (!item || typeof item !== "object") return "";
return (
item.trainingName ||
item.projName ||
item.projectName ||
item.courseName ||
item.name ||
item.title ||
item.label ||
""
);
}
function pickId(item, keys) {
for (const k of keys) {
if (item[k] != null && item[k] !== "") return String(item[k]);
}
return "";
}
function gmRequest(method, url, options) {
options = options || {};
return new Promise((resolve, reject) => {
const req = {
method: method || "GET",
url,
headers: options.headers || {},
timeout: options.timeout || 60000,
onload(res) {
resolve({
status: res.status,
responseText: res.responseText || "",
finalUrl: res.finalUrl || url,
});
},
onerror: () => reject(new Error("network_error")),
ontimeout: () => reject(new Error("timeout")),
};
if (options.body != null) req.data = options.body;
if (typeof GM_xmlhttpRequest === "function") {
GM_xmlhttpRequest(req);
} else {
reject(new Error("GM_xmlhttpRequest unavailable"));
}
});
}
function authHeaders() {
const auth = loadAuth();
if (!auth.token) throw new Error("\u672a\u6355\u83b7\u767b\u5f55 Token\uff0c\u8bf7\u5148\u6b63\u5e38\u767b\u5f55\u5e76\u6253\u5f00\u8bfe\u7a0b\u9875\u89e6\u53d1\u4e00\u6b21 API \u8bf7\u6c42");
syncUserFromAuth(auth);
return {
Accept: "application/json, text/plain, */*",
Authorization: "Bearer " + auth.token.replace(/^Bearer\s+/i, "").trim(),
deviceinfo: auth.deviceinfo || buildDeviceInfo(state.uuid),
Origin: "https://www.yiboshi.com",
Referer: "https://www.yiboshi.com/",
};
}
async function apiJson(method, url, body, contentType) {
const headers = authHeaders();
if (contentType) headers["Content-Type"] = contentType;
const res = await gmRequest(method, url, { headers, body });
let data = null;
try {
data = JSON.parse(res.responseText || "{}");
} catch (_) {
throw new Error("\u54cd\u5e94\u975e JSON: " + res.responseText.slice(0, 120));
}
if (!isApiSuccess(data, res.status)) {
throw new Error(
(data && (data.msg || data.message)) ||
(data && data.status != null ? "status=" + data.status : "") ||
(data && data.code != null ? "code=" + data.code : "") ||
"HTTP " + res.status
);
}
return data;
}
async function apiGet(path, query) {
const qs =
query && typeof query === "object"
? "?" +
Object.keys(query)
.filter((k) => query[k] != null && query[k] !== "")
.map((k) => encodeURIComponent(k) + "=" + encodeURIComponent(query[k]))
.join("&")
: "";
return apiJson("GET", API_BASE + path + qs);
}
async function cloudGet(path, query) {
const qs =
query && typeof query === "object"
? "?" +
Object.keys(query)
.filter((k) => query[k] != null && query[k] !== "")
.map((k) => encodeURIComponent(k) + "=" + encodeURIComponent(query[k]))
.join("&")
: "";
return apiJson("GET", PLATFORM_CLOUD_BASE + path + qs);
}
async function cloudPostJson(path, payload) {
return apiJson("POST", PLATFORM_CLOUD_BASE + path, JSON.stringify(payload || {}), "application/json");
}
async function fetchCurrentUser() {
const data = await cloudGet("/openplatfrom-authserver/user/getCurrentUser");
const u = (data && data.data) || data;
if (u && u.id) state.userId = String(u.id);
if (u && u.userId) state.userId = String(u.userId);
if (u && u.uuid) state.uuid = String(u.uuid);
return u;
}
async function fetchTrainings() {
if (!state.userId) await fetchCurrentUser().catch(() => {});
const data = await apiGet("/api/study/student/listStudentTrainingApp", {
userId: state.userId,
excludeExpire: "true",
trainingWay: "1",
excludeRangeTran: "false",
});
state.trainings = pickList(data);
return state.trainings;
}
async function fetchProjectCourses(trainingId, projectId) {
const data = await apiGet("/api/study/project/getProjectCourse", {
trainingId,
projectId,
});
return pickList(data);
}
async function fetchMyProjectList(trainingId) {
const all = [];
let pageNum = 1;
let totalPage = 1;
do {
const data = await apiGet("/api/study/student/listStudentProjInfoAndStatus", {
userId: state.userId,
trainingId,
projType: "",
projState: "",
creType: "",
creValue: "",
projParam: "",
myProj: "true",
subId: "",
recommend: "false",
pageNum: String(pageNum),
pageSize: "10",
});
const meta = (data && data.data) || {};
const list = pickList(data).filter((p) => p.isOwn === 1 || p.isOwn == null);
all.push(...list);
totalPage = Math.max(1, Number(meta.totalPage || meta.totalPages || 1));
pageNum += 1;
} while (pageNum <= totalPage);
return all;
}
async function fetchCourseWareBundle(trainingId, projId, courseId) {
const data = await apiGet("/api/study/courseware/getCourseWareByCourse", {
userId: state.userId,
trainingId,
projId,
courseId,
});
const d = (data && data.data) || {};
const list = Array.isArray(d.courseVideoArr) ? d.courseVideoArr : pickList(data);
return {
list,
courseFieldId: String(d.courseFieldID || d.courseFieldId || d.fieldId || "").trim(),
practiseSwitch: d.practiseSwitch != null ? Number(d.practiseSwitch) : 1,
courseName: String(d.courseName || "").trim(),
};
}
async function fetchCourseWareRaw(trainingId, projId, courseId) {
const bundle = await fetchCourseWareBundle(trainingId, projId, courseId);
return bundle.list;
}
async function loadCourseVideos(trainingId, projId, courseId) {
let named = [];
let courseFieldId = "";
let practiseSwitch = 1;
try {
const bundle = await fetchCourseWareBundle(trainingId, projId, courseId);
courseFieldId = bundle.courseFieldId;
practiseSwitch = bundle.practiseSwitch;
named = bundle.list.map((cw, i) => {
const name = pickCoursewareName(cw);
return {
coursewareId: pickId(cw, ["coursewareId", "id", "wareId", "cwId"]),
name: name || "\u8bfe\u4ef6" + (i + 1),
videoTotalTime: Number(cw.videoTotalTime || cw.totalTime || cw.duration || cw.timeLength || 0),
viewTime: Number(cw.viewTime || cw.viewLocation || cw.watchTime || 0),
isSegment: false,
};
});
} catch (_) {}
let progressMap = {};
try {
const percent = await fetchCoursePercent(trainingId, projId, courseId);
coursewaresFromPercent(percent).forEach((cw) => {
progressMap[String(cw.coursewareId)] = cw;
});
} catch (_) {}
if (!named.length) {
named = Object.keys(progressMap).map((id, i) => {
const cw = progressMap[id];
return {
coursewareId: cw.coursewareId,
name: "\u8bfe\u4ef6" + (i + 1),
videoTotalTime: cw.videoTotalTime,
viewTime: cw.viewTime,
isSegment: false,
};
});
}
const videos = named
.filter((x) => x.coursewareId)
.map((x) => {
const hit = progressMap[String(x.coursewareId)];
return {
...x,
videoTotalTime: hit ? hit.videoTotalTime || x.videoTotalTime : x.videoTotalTime,
viewTime: hit != null ? hit.viewTime : x.viewTime,
};
});
videos.courseFieldId = courseFieldId;
videos.practiseSwitch = practiseSwitch;
return videos;
}
async function buildProjectTree(trainingId, projects) {
const tree = [];
for (const p of projects) {
const projId = pickId(p, ["projId", "projectId", "id"]);
const projectName = pickName(p) || "\u9879\u76ee " + projId;
const stateLabel = projStateLabel(p.projState);
let coursesRaw = [];
try {
coursesRaw = await fetchProjectCourses(trainingId, projId);
} catch (e) {
log("getProjectCourse \u5931\u8d25 " + projectName + ": " + e.message);
}
const courses = [];
coursesRaw.forEach((c) => {
const courseId = pickId(c, ["courseId", "id", "wareCourseId"]);
if (!courseId) return;
const courseName = pickName(c) || "\u8bfe\u7a0b " + courseId;
courses.push({
trainingId,
projId,
courseId,
courseName,
courseFieldId: String(c.courseFieldID || c.courseFieldId || c.fieldId || "").trim(),
practiseSwitch: c.practiseSwitch != null ? Number(c.practiseSwitch) : c.practiceNull === false ? 1 : null,
practiceNull: c.practiceNull === true || c.practiseNull === true,
courseState: c.courseState,
practiseScore: c.practiseScore ?? c.practiceScore ?? null,
statusLabel: courseStatusLabel(c),
percent: c.percent != null ? c.percent : c.studyPercent != null ? c.studyPercent : c.passPercent,
videos: null,
videosLoading: false,
expanded: false,
});
});
if (!courses.length) {
log("\u9879\u76ee\u65e0\u8bfe\u7a0b\u660e\u7ec6\uff0c\u5df2\u8df3\u8fc7\uff1a" + projectName);
continue;
}
tree.push({
trainingId,
projId,
projectName,
projState: p.projState,
stateLabel,
expanded: true,
courses,
});
}
return tree;
}
function flattenTreeToProjects(tree) {
const rows = [];
(tree || []).forEach((proj) => {
(proj.courses || []).forEach((c) => {
rows.push({
trainingId: proj.trainingId,
projId: proj.projId,
courseId: c.courseId,
projectName: proj.projectName,
title: proj.projectName + " · " + c.courseName,
stateLabel: c.statusLabel || proj.stateLabel,
percent: c.percent,
});
});
});
return rows;
}
async function fetchProjects(trainingId) {
const raw = await fetchMyProjectList(trainingId);
state.projectTree = await buildProjectTree(trainingId, raw);
state.projects = flattenTreeToProjects(state.projectTree);
return state.projects;
}
async function fetchCoursePercent(trainingId, projId, courseId) {
return cloudGet("/api-video/v2/video/getUserCoursePercent", {
trainingId,
projId,
courseId,
uuid: state.uuid || randomHex(32),
});
}
async function singleDeviceCheck(trainingId) {
return cloudGet("/api-video/v2/video/singleDevice/check", { trainingId });
}
async function syncCourseStatus(payload) {
return cloudPostJson("/api-video/v2/video/syncCourseStatus", payload);
}
async function isFinishVideo(trainingId, projId, courseId) {
return cloudGet("/api-video/v2/video/isFinishVideo", {
trainingId,
projId,
courseId,
uuid: state.uuid || randomHex(32),
});
}
function coursewaresFromPercent(data) {
const d = (data && data.data) || {};
const list = Array.isArray(d.coursewareList) ? d.coursewareList : [];
return list.map((cw) => ({
coursewareId: cw.coursewareId,
videoTotalTime: Number(cw.videoTotalTime || 0),
viewTime: Number(cw.viewTime || cw.viewLocation || 0),
viewLocation: Number(cw.viewLocation || cw.viewTime || 0),
}));
}
function syncModeUi() {
const el = document.getElementById("ybs-mode-text");
if (el) el.textContent = "\u81ea\u52a8\u5b66\u4e60";
const badge = document.getElementById("ybs-speed-badge");
if (badge) {
badge.textContent = "\u81ea\u52a8";
badge.className = "ybs-auth-badge ybs-badge-1x";
}
}
function snapshotCourseChecks() {
return Array.from(document.querySelectorAll(".ybs-course-cb:checked")).map(
(el) => el.dataset.projId + "|" + el.dataset.courseId
);
}
function applyCourseChecks(keys) {
const set = new Set(keys || []);
document.querySelectorAll(".ybs-course-cb").forEach((el) => {
el.checked = set.has(el.dataset.projId + "|" + el.dataset.courseId);
});
syncSelectAllCheckbox();
}
function patchCoursewareViewTime(courseId, coursewareId, viewTime, totalTime) {
for (const proj of state.projectTree || []) {
for (const c of proj.courses || []) {
if (String(c.courseId) !== String(courseId)) continue;
(c.videos || []).forEach((v) => {
if (String(v.coursewareId) !== String(coursewareId)) return;
v.viewTime = Number(viewTime) || 0;
if (totalTime > 0) v.videoTotalTime = Number(totalTime);
});
}
}
}
function refreshProgressUi() {
const box = document.getElementById("ybs-course-list");
const scrollTop = box ? box.scrollTop : 0;
const checks = snapshotCourseChecks();
renderProjectCourses();
applyCourseChecks(checks);
if (box) box.scrollTop = scrollTop;
syncChapterPreviewFromCache();
syncCurrentUi();
}
function syncChapterPreviewFromCache() {
const box = document.getElementById("ybs-chapter-preview");
if (!box || !box.querySelector(".ybs-chapter-course")) return;
const selected = selectedCourses();
if (!selected.length) return;
const blocks = [];
for (const item of selected) {
const hit = findCourseInTree(item.projId, item.courseId);
if (!hit || !Array.isArray(hit.course.videos)) continue;
const courseName = hit.course.courseName;
const projectName = hit.proj.projectName;
const listHtml = hit.course.videos
.map(
(v) =>
`
` +
`${escHtml(v.name || "\u89c6\u9891")}` +
`${escHtml(formatVideoProgress(v))}` +
`
`
)
.join("");
blocks.push(
`` +
`
${escHtml(projectName ? projectName + " · " + courseName : courseName)}
` +
`
${listHtml || '
\u6682\u65e0\u89c6\u9891\u660e\u7ec6
'}
` +
`
`
);
}
if (blocks.length) box.innerHTML = blocks.join("");
}
function getCloudApiBase() {
return DEFAULT_CLOUD_API_BASE;
}
function getLearningUserId() {
return String(state.userId || "").trim();
}
function formatLeaseExpireText(expSec) {
const ex = Number(expSec || 0);
if (!Number.isFinite(ex) || ex <= 0) return "—";
const d = new Date(ex * 1000);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString("zh-CN", { hour12: false });
}
function resolveLeaseExpireSec(data) {
const raw = data && (data.exp ?? data.expire_at ?? data.expires_at ?? data.lease_exp);
const n = Number(raw || 0);
if (Number.isFinite(n) && n > 1e12) return Math.floor(n / 1000);
return Number.isFinite(n) ? Math.floor(n) : 0;
}
function resolveProExpireSec(data) {
const raw = data && (data.pro_expires_at ?? data.proExpiresAt ?? data.pro_expire_at);
const n = Number(raw || 0);
if (Number.isFinite(n) && n > 1e12) return Math.floor(n / 1000);
return Number.isFinite(n) ? Math.floor(n) : 0;
}
function readCloudLeaseCache() {
try {
const raw = localStorage.getItem(CLOUD_LEASE_CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
const lease = String((parsed && parsed.lease) || "").trim();
const exp = Number((parsed && parsed.exp) || 0);
if (!lease || !Number.isFinite(exp) || exp <= 0) return null;
return {
lease,
exp,
tier: String((parsed && parsed.tier) || "").trim(),
freeVideoLimit: Number(parsed.freeVideoLimit ?? parsed.free_video_limit ?? 3),
freeUsedVideos: Number(parsed.freeUsedVideos ?? parsed.free_used_videos ?? 0),
proExpireAt: Number(parsed.proExpireAt ?? parsed.pro_expire_at ?? 0),
};
} catch (_) {
return null;
}
}
function writeCloudLeaseCache(lease, exp, extra) {
if (!lease || !exp) {
localStorage.removeItem(CLOUD_LEASE_CACHE_KEY);
return;
}
localStorage.setItem(CLOUD_LEASE_CACHE_KEY, JSON.stringify(Object.assign({ lease, exp }, extra || {})));
}
function writeCloudLastState(partial) {
try {
const prev = JSON.parse(localStorage.getItem(CLOUD_LAST_STATE_KEY) || "{}") || {};
localStorage.setItem(
CLOUD_LAST_STATE_KEY,
JSON.stringify(Object.assign({}, prev, partial || {}, { ts: Date.now() }))
);
} catch (_) {}
}
function writeCloudProExpireCache(token, exp) {
try {
const tk = String(token || "").trim();
if (!tk || !exp) {
localStorage.removeItem(CLOUD_PRO_EXPIRE_CACHE_KEY);
return;
}
localStorage.setItem(CLOUD_PRO_EXPIRE_CACHE_KEY, JSON.stringify({ token: tk, exp: Math.floor(exp) }));
} catch (_) {}
}
function syncCloudQuotaFromResponse(data) {
if (!data || typeof data !== "object") return;
const lim = data.free_video_limit ?? data.free_chapter_limit ?? data.freeVideoLimit;
const used = data.free_used_videos ?? data.free_used_chapters ?? data.freeUsedVideos;
if (lim != null) state.freeVideoLimit = Number(lim) || state.freeVideoLimit;
if (used != null) state.freeUsedVideos = Number(used) || 0;
if (data.tier) state.cloudTier = String(data.tier);
updateCloudPanelUI();
}
async function licenseGmRequest(url, method, headers, data) {
return new Promise((resolve, reject) => {
const req = {
method: method || "GET",
url,
headers: headers || {},
timeout: 30000,
onload: (res) => resolve({ status: res.status, text: res.responseText || "" }),
onerror: () => reject(new Error("\u4e91\u7aef\u7f51\u7edc\u9519\u8bef")),
ontimeout: () => reject(new Error("\u4e91\u7aef\u8bf7\u6c42\u8d85\u65f6")),
};
if (data != null) req.data = data;
if (typeof GM_xmlhttpRequest === "function") GM_xmlhttpRequest(req);
else reject(new Error("GM_xmlhttpRequest unavailable"));
});
}
async function _rq(path, method, payload) {
const base = getCloudApiBase();
const url = base + (path.startsWith("/") ? path : "/" + path);
const headers = { Accept: "application/json", "Content-Type": "application/json" };
const luid = getLearningUserId();
if (luid) headers["x-learning-user-id"] = luid;
if (state.cloudToken) headers.Authorization = "Bearer " + state.cloudToken;
let body = null;
if (method && method.toUpperCase() !== "GET") {
const reqPayload = Object.assign({}, payload || {});
if (path !== "/api/ybs/lease" && state.cloudLease && reqPayload.lease == null) {
reqPayload.lease = state.cloudLease;
}
body = JSON.stringify(reqPayload);
}
const res = await licenseGmRequest(url, method || "GET", headers, body);
let data = null;
try {
data = JSON.parse(res.text || "{}");
} catch (_) {
throw new Error("\u4e91\u7aef\u54cd\u5e94\u975e JSON");
}
if (res.status === 401 || res.status === 403) {
const msg = (data && (data.message || data.msg || data.detail)) || "unauthorized";
const err = new Error(String(msg));
err.status = res.status;
throw err;
}
if (res.status < 200 || res.status >= 300) {
throw new Error((data && (data.message || data.msg)) || "HTTP " + res.status);
}
if (data && data.ok === false && data.success === false) {
throw new Error(data.message || data.msg || "request_failed");
}
return data;
}
async function _el(forceRefresh) {
const now = Math.floor(Date.now() / 1000);
if (!forceRefresh && state.cloudLease && state.cloudLeaseExp - now > 60) return true;
if (!forceRefresh) {
const cached = readCloudLeaseCache();
if (cached && cached.exp - now > 60) {
state.cloudLease = cached.lease;
state.cloudLeaseExp = cached.exp;
state.cloudProExpireAt = Number(cached.proExpireAt || 0);
if (cached.tier) state.cloudTier = cached.tier;
if (cached.freeVideoLimit > 0) state.freeVideoLimit = cached.freeVideoLimit;
state.freeUsedVideos = Number(cached.freeUsedVideos || 0);
return true;
}
}
const luid = getLearningUserId();
if (!luid) throw new Error("\u672a\u767b\u5f55\u533b\u535a\u58eb\uff0c\u65e0\u6cd5\u83b7\u53d6\u4e91\u7aef\u6388\u6743\uff08\u8bf7\u5148\u6253\u5f00\u8bfe\u7a0b\u9875\uff09");
let data;
try {
state.cloudRevoked = false;
data = await _rq("/api/ybs/lease", "POST", { learning_user_id: luid });
} catch (e) {
const em = String((e && e.message) || e || "");
if (/revoked|invalid token|expired/i.test(em)) {
state.cloudRevoked = true;
state.cloudTier = "revoked";
state.cloudLease = "";
state.cloudLeaseExp = 0;
}
throw e;
}
const lease = String(data.lease || "");
const exp = resolveLeaseExpireSec(data);
let tier = String(data.tier || "").trim() || (state.cloudToken ? "pro" : "free");
const proExpireAt = resolveProExpireSec(data);
state.cloudLease = lease;
state.cloudLeaseExp = exp;
state.cloudProExpireAt = proExpireAt > 0 ? proExpireAt : 0;
state.cloudTier = tier;
state.freeVideoLimit = Number(
data.free_video_limit ?? data.free_chapter_limit ?? data.freeVideoLimit ?? state.freeVideoLimit ?? 3
);
state.freeUsedVideos = Number(data.free_used_videos ?? data.free_used_chapters ?? data.freeUsedVideos ?? 0);
writeCloudLeaseCache(lease, exp, {
tier: state.cloudTier,
freeVideoLimit: state.freeVideoLimit,
freeUsedVideos: state.freeUsedVideos,
proExpireAt: state.cloudProExpireAt,
});
writeCloudLastState({
tier: state.cloudTier,
freeVideoLimit: state.freeVideoLimit,
freeUsedVideos: state.freeUsedVideos,
});
if (tier === "pro" && state.cloudProExpireAt > 0) {
writeCloudProExpireCache(state.cloudToken, state.cloudProExpireAt);
}
updateCloudPanelUI();
return !!lease;
}
async function cloudVerifyToken() {
if (!state.cloudToken) return true;
const base = getCloudApiBase();
const url = base + "/api/license/verify";
const headers = { Accept: "application/json", Authorization: "Bearer " + state.cloudToken };
const res = await licenseGmRequest(url, "GET", headers, null);
let data = {};
try {
data = JSON.parse(res.text || "{}");
} catch (_) {}
if (res.status === 401 || res.status === 403) {
state.cloudRevoked = true;
state.cloudTier = "revoked";
throw new Error((data && (data.message || data.detail)) || "Token \u65e0\u6548");
}
if (res.status < 200 || res.status >= 300) {
throw new Error((data && data.message) || "verify HTTP " + res.status);
}
if (data.tier) state.cloudTier = String(data.tier);
syncCloudQuotaFromResponse(data);
return true;
}
async function ensureCloudReady() {
if (!getLearningUserId()) {
try {
await fetchCurrentUser();
} catch (_) {}
}
if (!getLearningUserId()) {
log("\u8bf7\u5148\u767b\u5f55\u533b\u535a\u58eb\u5e76\u6253\u5f00\u4efb\u610f\u8bfe\u7a0b\u9875");
return false;
}
try {
if (state.cloudToken) await cloudVerifyToken();
await _el(false);
} catch (err) {
log("\u6388\u6743\u5931\u8d25\uff0c\u8bf7\u5230\u8bbe\u7f6e\u91cc\u68c0\u67e5 Token");
return false;
}
const tier = String(state.cloudTier || "").toLowerCase();
if (tier === "pro") return true;
if (tier === "free") {
if (Number(state.freeUsedVideos) >= Number(state.freeVideoLimit)) {
log("\u514d\u8d39\u989d\u5ea6\u5df2\u7528\u5b8c\uff0c\u8bf7\u5347\u7ea7 Pro");
switchPanelTab("settings");
openProModal();
return false;
}
return true;
}
log("\u6388\u6743\u5f02\u5e38\uff0c\u8bf7\u5230\u8bbe\u7f6e\u91cc\u91cd\u65b0\u6821\u9a8c");
return false;
}
async function fetchPanelNotice() {
try {
const path = String(state.panelNoticePath || "/api/ybs/panel-notice");
const url = getCloudApiBase() + (path.startsWith("/") ? path : "/" + path);
const res = await licenseGmRequest(url, "GET", { Accept: "text/plain" }, null);
if (res.status >= 200 && res.status < 300) {
state.remotePanelNotice = String(res.text || "").trim() || PANEL_NOTICE_FALLBACK;
const el = document.querySelector("#ybs-ann-text");
if (el) el.textContent = state.remotePanelNotice;
}
} catch (_) {}
}
async function fetchClientConfig() {
try {
const res = await licenseGmRequest(
getCloudApiBase() + "/api/ybs/client-config",
"GET",
{ Accept: "application/json" },
null
);
if (res.status >= 200 && res.status < 300 && res.text) {
const j = JSON.parse(res.text);
if (j && j.panelNoticePath) state.panelNoticePath = String(j.panelNoticePath);
if (j && j.freeVideoLimit != null) state.freeVideoLimit = Number(j.freeVideoLimit) || 3;
if (j && j.proBuyUrl) state.proBuyUrl = String(j.proBuyUrl).trim() || PRO_BUY_URL;
}
} catch (_) {}
await fetchPanelNotice();
updateCloudPanelUI();
}
function switchPanelTab(name) {
document.querySelectorAll(".ybs-tab-btn").forEach((el) => {
el.classList.toggle("active", el.dataset.tab === name);
});
document.querySelectorAll(".ybs-pane").forEach((el) => {
el.classList.toggle("active", el.dataset.pane === name);
});
}
function formatCloudTierText(tier) {
if (state.cloudRevoked) return "\u5df2\u7981\u7528";
const t = String(tier || "").trim().toLowerCase();
if (t === "pro") return "Pro \u4f1a\u5458";
if (t === "free") return "\u514d\u8d39\u4f53\u9a8c";
if (t === "revoked") return "\u5df2\u7981\u7528";
if (t === "unknown") return "\u672a\u6821\u9a8c";
return tier ? String(tier) : "\u672a\u6821\u9a8c";
}
function updateCloudPanelUI() {
const tierEl = document.querySelector("#ybs-cloud-tier");
const freeEl = document.querySelector("#ybs-cloud-free");
const freeLabelEl = document.querySelector("#ybs-cloud-free-label");
const tokenInput = document.querySelector("#ybs-cloud-token");
if (tierEl) {
tierEl.textContent = formatCloudTierText(state.cloudTier);
tierEl.style.color =
state.cloudRevoked || String(state.cloudTier || "").toLowerCase() === "revoked" ? "#dc2626" : "#0f172a";
}
if (state.cloudRevoked) {
if (freeLabelEl) freeLabelEl.textContent = "\u6388\u6743\u72b6\u6001";
if (freeEl) freeEl.textContent = "Token \u5df2\u7981\u7528";
} else if (String(state.cloudTier || "").toLowerCase() === "pro") {
if (freeLabelEl) freeLabelEl.textContent = "Pro \u5230\u671f";
if (freeEl) freeEl.textContent = formatLeaseExpireText(state.cloudProExpireAt || state.cloudLeaseExp);
} else {
if (freeLabelEl) freeLabelEl.textContent = "\u514d\u8d39\u4f53\u9a8c\u89c6\u9891";
if (freeEl) freeEl.textContent = state.freeUsedVideos + "/" + state.freeVideoLimit;
}
if (tokenInput && tokenInput !== document.activeElement) tokenInput.value = state.cloudToken || "";
}
function openProModal() {
let modal = document.getElementById("ybs-pro-modal");
if (!modal) {
modal = document.createElement("div");
modal.id = "ybs-pro-modal";
modal.innerHTML =
'' +
'
\u5f00\u901a Pro
' +
'
\u514d\u8d39\u53ef\u4f53\u9a8c ' +
state.freeVideoLimit +
" \u4e2a\u89c6\u9891\u7ae0\u8282\uff1bPro \u4e0d\u9650\u6b21\u6570
" +
'
' +
'
' +
"
";
document.body.appendChild(modal);
modal.addEventListener("click", (e) => {
if (e.target === modal) modal.style.display = "none";
});
modal.querySelector("#ybs-pro-close").addEventListener("click", () => {
modal.style.display = "none";
});
modal.querySelector("#ybs-pro-buy-link").addEventListener("click", () => {
const u = String(state.proBuyUrl || PRO_BUY_URL);
if (typeof GM_openInTab === "function") GM_openInTab(u, { active: true });
else window.open(u, "_blank");
});
}
modal.style.display = "flex";
}
async function ybsEngineStart(kind, context, cfg) {
await _el(false);
const data = await _rq("/api/ybs/study/engine/start", "POST", {
kind: String(kind || "video"),
context: context || {},
config: cfg || {},
});
syncCloudQuotaFromResponse(data);
return data;
}
async function ybsEngineStep(sessionId, event, lastResult, contextPatch) {
await _el(false);
const data = await _rq("/api/ybs/study/engine/step", "POST", {
session_id: String(sessionId || ""),
event: String(event || "tick"),
last_result: lastResult == null ? null : lastResult,
context_patch: contextPatch == null ? null : contextPatch,
});
syncCloudQuotaFromResponse(data);
return data;
}
async function executeYbsCommand(cmd, ctx) {
const c = cmd || {};
const t = String(c.type || "");
if (t === "wait") {
await sleep(Math.max(500, Number(c.ms || 1000)));
return { event: "tick", lastResult: null };
}
if (t === "done") {
return { terminal: true, ok: !!c.success, skipped: !!c.skipped, msg: c.message || "\u5b8c\u6210" };
}
if (t === "failed") {
return { terminal: true, ok: false, msg: c.message || "failed" };
}
if (t === "ybs_device_check") {
try {
await singleDeviceCheck(c.trainingId || ctx.trainingId);
return { event: "submit_result", lastResult: { ok: true } };
} catch (e) {
return { event: "submit_result", lastResult: { ok: true, warn: String(e.message || e) } };
}
}
if (t === "ybs_sync") {
const payload = Object.assign({}, c.payload || {}, {
uuid: state.uuid || randomHex(32),
});
const nextPos = Number(c.next_pos != null ? c.next_pos : payload.currentLocationTime || 0);
const prog = c.progress || {};
const target = Number(prog.target || ctx.totalTime || 0);
try {
await syncCourseStatus(payload);
state.currentTask = formatDuration(nextPos) + "/" + formatDuration(target || nextPos);
syncCurrentUi();
patchCoursewareViewTime(ctx.courseId, ctx.coursewareId, nextPos, target);
refreshProgressUi();
return { event: "submit_result", lastResult: { ok: true, success: true } };
} catch (e) {
return {
event: "submit_result",
lastResult: { ok: false, success: false, error: String((e && e.message) || e) },
};
}
}
if (t === "ybs_finish") {
try {
await isFinishVideo(
c.trainingId || ctx.trainingId,
c.projId || ctx.projId,
c.courseId || ctx.courseId
);
} catch (_) {}
patchCoursewareViewTime(ctx.courseId, ctx.coursewareId, ctx.totalTime, ctx.totalTime);
refreshProgressUi();
return { event: "submit_result", lastResult: { ok: true } };
}
return { event: "tick", lastResult: null };
}
async function runYbsEngineLoop(startRes, ctx) {
let sessionId = String(startRes.session_id || "");
let cmd = startRes.command;
for (let i = 0; i < 5000; i += 1) {
if (!state.running || state.stopRequested) {
return { terminal: true, ok: false, msg: "\u5df2\u505c\u6b62" };
}
if (!cmd) break;
const exec = await executeYbsCommand(cmd, ctx);
if (exec.terminal) return exec;
const step = await ybsEngineStep(sessionId, exec.event || "tick", exec.lastResult, null);
if (step.session_id) sessionId = String(step.session_id);
cmd = step.command;
const ct = cmd && String(cmd.type || "");
if (ct === "done" || ct === "failed") {
return await executeYbsCommand(cmd, ctx);
}
}
return { terminal: true, ok: false, msg: "\u5b66\u4e60\u4e2d\u65ad\uff0c\u8bf7\u91cd\u8bd5" };
}
async function studyOneCourseware(ctx) {
const { trainingId, projId, courseId, coursewareId, totalTime, title } = ctx;
const pos = Math.max(0, Number(ctx.startPos || 0));
const target = Math.max(1, totalTime);
const name = String(title || coursewareId);
state.activeSpeed = FIXED_SPEED;
syncModeUi();
state.currentChapter = name;
state.currentTask = formatDuration(pos) + "/" + formatDuration(target);
syncCurrentUi();
patchCoursewareViewTime(courseId, coursewareId, pos, target);
refreshProgressUi();
log("\u5f00\u59cb\u5b66\u4e60" + name);
const startRes = await ybsEngineStart(
"video",
{
course_id: String(courseId),
courseware_id: Number(coursewareId),
proj_id: String(projId),
training_id: String(trainingId),
total_time: target,
start_pos: pos,
title: name,
uuid: state.uuid || randomHex(32),
},
{ free_video_limit: state.freeVideoLimit, step_sec: FIXED_STEP_SEC }
);
if (startRes.command && String(startRes.command.type) === "failed") {
const msg = startRes.command.message || startRes.log || "\u5b66\u4e60\u5931\u8d25";
if (/free_quota/i.test(msg) || /\u514d\u8d39\u989d\u5ea6/.test(String(startRes.log || ""))) {
switchPanelTab("settings");
openProModal();
throw new Error("\u514d\u8d39\u989d\u5ea6\u5df2\u7528\u5b8c\uff0c\u8bf7\u5347\u7ea7 Pro");
}
throw new Error("\u5b66\u4e60\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5");
}
const result = await runYbsEngineLoop(startRes, {
trainingId,
projId,
courseId,
coursewareId,
totalTime: target,
title: name,
});
if (!result.ok && !result.skipped) {
if (/free_quota|\u514d\u8d39\u989d\u5ea6/.test(String(result.msg || ""))) {
switchPanelTab("settings");
openProModal();
throw new Error("\u514d\u8d39\u989d\u5ea6\u5df2\u7528\u5b8c\uff0c\u8bf7\u5347\u7ea7 Pro");
}
throw new Error(result.msg === "\u5df2\u505c\u6b62" ? "\u5df2\u505c\u6b62" : name + "\u5b66\u4e60\u5931\u8d25");
}
if (result.skipped) {
} else log(name + "\u5b66\u4e60\u5b8c\u6210");
}
async function studyCourse(trainingId, projId, courseId, courseTitle) {
state.currentCourse = courseTitle || courseId;
syncCurrentUi();
const videos = await ensureCourseVideos(projId, courseId);
const percentResp = await fetchCoursePercent(trainingId, projId, courseId);
const progressList = coursewaresFromPercent(percentResp);
if (!progressList.length && !videos.length) {
throw new Error("\u672a\u83b7\u53d6\u5230 coursewareList\uff0c\u8bf7\u786e\u8ba4\u5df2\u5728\u8be5\u5e73\u53f0\u9009\u8fc7\u8be5\u8bfe\u7a0b");
}
const nameMap = {};
videos.forEach((v) => {
const id = String(v.coursewareId);
if (!nameMap[id]) nameMap[id] = v.name;
});
const list = progressList.length
? progressList
: Array.from(
videos.reduce((m, v) => {
const id = String(v.coursewareId);
if (!m.has(id)) {
m.set(id, {
coursewareId: v.coursewareId,
videoTotalTime: v.videoTotalTime,
viewTime: v.viewTime,
});
}
return m;
}, new Map()).values()
);
for (const cw of list) {
if (!state.running || state.stopRequested) break;
if (cw.viewTime >= cw.videoTotalTime && cw.videoTotalTime > 0) {
continue;
}
const videoTitle = nameMap[String(cw.coursewareId)] || courseTitle || String(cw.coursewareId);
await studyOneCourseware({
trainingId,
projId,
courseId,
coursewareId: cw.coursewareId,
totalTime: cw.videoTotalTime,
startPos: cw.viewTime,
title: videoTitle,
});
}
if (state.running && !state.stopRequested) {
const hit = findCourseInTree(projId, courseId);
const course = hit && hit.course;
try {
await takeCourseExam({
trainingId,
projId,
courseId,
courseTitle: (course && course.courseName) || courseTitle,
courseFieldId: (course && course.courseFieldId) || (videos && videos.courseFieldId) || "",
practiseSwitch: course && course.practiseSwitch,
});
} catch (_) {
log(((course && course.courseName) || courseTitle || "\u672c\u8bfe\u7a0b") + "\u8003\u8bd5\u5931\u8d25");
}
}
}
function selectedCourses() {
return Array.from(document.querySelectorAll(".ybs-course-cb:checked")).map((el) => ({
trainingId: el.dataset.trainingId,
projId: el.dataset.projId,
courseId: el.dataset.courseId,
title: el.dataset.title || el.dataset.courseId,
}));
}
function syncQueueUi() {
const doneEl = document.getElementById("ybs-queue-done");
const totalEl = document.getElementById("ybs-queue-total");
const pctEl = document.getElementById("ybs-queue-percent");
const bar = document.getElementById("ybs-queue-progress");
const footer = document.getElementById("ybs-queue-text");
const total = state.queueTotal || 0;
const done = state.queueDone || 0;
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
if (doneEl) doneEl.textContent = String(done);
if (totalEl) totalEl.textContent = String(total);
if (pctEl) pctEl.textContent = pct + "%";
if (bar) bar.style.width = pct + "%";
if (footer) {
footer.textContent = total
? `\u5df2\u9009 ${total} \u95e8 · \u5b8c\u6210 ${done}`
: "\u672a\u9009\u62e9\u8bfe\u7a0b";
}
}
function syncCurrentUi() {
const set = (id, v) => {
const el = document.getElementById(id);
if (el) {
el.textContent = v || "\u65e0";
el.title = v || "";
}
};
set("ybs-current-course", state.currentCourse);
set("ybs-current-chapter", state.currentChapter);
set("ybs-current-task", state.currentTask);
}
function setRunningUi(on) {
const status = document.getElementById("ybs-auto-status");
const btnStart = document.getElementById("ybs-start");
const btnStop = document.getElementById("ybs-stop");
if (status) status.textContent = on ? "\u8fd0\u884c\u4e2d" : "\u5df2\u505c\u6b62";
if (btnStart) {
btnStart.disabled = on;
btnStart.classList.toggle("ybs-btn-off", on);
}
if (btnStop) {
btnStop.disabled = !on;
btnStop.classList.toggle("ybs-btn-off", !on);
}
}
function renderLog() {
const el = document.getElementById("ybs-run-log");
if (!el) return;
el.innerHTML = state.logLines
.map((line) => `${escHtml(line)}
`)
.join("");
}
function renderTrainingOptions() {
const sel = document.getElementById("ybs-plan-select");
if (!sel) return;
sel.innerHTML = '';
state.trainings.forEach((t) => {
const id = pickId(t, ["trainingId", "id", "trainId"]);
const name = pickName(t) || "\u57f9\u8bad " + id;
const opt = document.createElement("option");
opt.value = id;
opt.textContent = name;
if (id === state.selectedTrainingId) opt.selected = true;
sel.appendChild(opt);
});
}
function pickDefaultTrainingId() {
const saved = String(localStorage.getItem(LAST_TRAINING_KEY) || "").trim();
const ids = state.trainings.map((t) => pickId(t, ["trainingId", "id", "trainId"])).filter(Boolean);
if (saved && ids.includes(saved)) return saved;
return ids[0] || "";
}
async function loadMyProjectsForTraining(trainingId, opts) {
opts = opts || {};
const id = String(trainingId || "").trim();
if (!id) {
if (!opts.silent) log("\u8bf7\u5148\u9009\u62e9\u57f9\u8bad");
return false;
}
state.selectedTrainingId = id;
localStorage.setItem(LAST_TRAINING_KEY, id);
const sel = document.getElementById("ybs-plan-select");
if (sel && sel.value !== id) sel.value = id;
const box = document.getElementById("ybs-course-list");
if (box) box.innerHTML = '\u6b63\u5728\u52a0\u8f7d\u6211\u7684\u9879\u76ee…
';
await fetchProjects(id);
renderProjectCourses();
if (!opts.silent) log("\u6211\u7684\u9879\u76ee\u5df2\u5237\u65b0\uff08" + state.projects.length + " \u95e8\u8bfe\uff09");
return true;
}
async function autoLoadTrainingsAndProjects(opts) {
opts = opts || {};
if (state.autoLoading) return;
state.autoLoading = true;
try {
const auth = loadAuth();
if (!auth.token) {
if (!opts.silent) log("\u7b49\u5f85\u767b\u5f55 Token…\u767b\u5f55\u5e76\u6253\u5f00\u8bfe\u7a0b\u9875\u540e\u5c06\u81ea\u52a8\u52a0\u8f7d");
return false;
}
syncUserFromAuth(auth);
await fetchTrainings();
renderTrainingOptions();
const trainingId = pickDefaultTrainingId();
if (!trainingId) {
log("\u672a\u83b7\u53d6\u5230\u57f9\u8bad\u5217\u8868");
return false;
}
const name =
pickName(state.trainings.find((t) => pickId(t, ["trainingId", "id", "trainId"]) === trainingId)) ||
trainingId;
log("\u5df2\u81ea\u52a8\u9009\u62e9\u57f9\u8bad\uff1a" + name);
await loadMyProjectsForTraining(trainingId, { silent: false });
return true;
} catch (e) {
log("\u81ea\u52a8\u52a0\u8f7d\u5931\u8d25\uff1a" + (e && e.message ? e.message : e));
return false;
} finally {
state.autoLoading = false;
}
}
function findCourseInTree(projId, courseId) {
for (const proj of state.projectTree || []) {
if (String(proj.projId) !== String(projId)) continue;
for (const c of proj.courses || []) {
if (String(c.courseId) === String(courseId)) return { proj, course: c };
}
}
return null;
}
async function ensureCourseVideos(projId, courseId, force) {
const hit = findCourseInTree(projId, courseId);
if (!hit) return [];
const { course } = hit;
const placeholder =
Array.isArray(course.videos) &&
course.videos.some(
(v) =>
isPlaceholderVideoName(v && v.name) ||
/^\u8bfe\u4ef6\s*\d+$/i.test(String((v && v.name) || ""))
);
if (Array.isArray(course.videos) && !force && !(placeholder && !course._cwArrTried)) {
return course.videos;
}
if (placeholder) course._cwArrTried = true;
if (course.videosLoading) return Array.isArray(course.videos) ? course.videos : [];
const prevView = {};
(course.videos || []).forEach((v) => {
if (v && v.coursewareId != null) prevView[String(v.coursewareId)] = Number(v.viewTime) || 0;
});
course.videosLoading = true;
try {
const videos = await loadCourseVideos(course.trainingId, course.projId, course.courseId);
if (videos.courseFieldId) course.courseFieldId = videos.courseFieldId;
if (videos.practiseSwitch != null) course.practiseSwitch = videos.practiseSwitch;
videos.forEach((v) => {
const old = prevView[String(v.coursewareId)];
if (old != null && old > (Number(v.viewTime) || 0)) v.viewTime = old;
});
course.videos = videos;
} catch (e) {
course.videos = [];
log("\u52a0\u8f7d\u89c6\u9891\u5217\u8868\u5931\u8d25");
} finally {
course.videosLoading = false;
}
return course.videos;
}
function formatExamStartTime(date) {
const d = date instanceof Date ? date : new Date();
const p = (n) => String(n).padStart(2, "0");
return (
d.getFullYear() +
"-" +
p(d.getMonth() + 1) +
"-" +
p(d.getDate()) +
" " +
p(d.getHours()) +
":" +
p(d.getMinutes()) +
":" +
p(d.getSeconds())
);
}
async function queryCoursePractices(trainingId, practiceCourseId) {
return cloudGet("/api-study/v4/practice/queryCoursePractices", {
trainingId,
courseId: practiceCourseId,
});
}
async function commitPracticeScore(payload) {
return cloudPostJson("/api-video/v2/video/commitPracticeScore", payload);
}
function gradePracticeQuestions(questions) {
const list = Array.isArray(questions) ? questions : [];
const questionNum = list.length;
let correct = 0;
list.forEach((q) => {
if (!q) return;
if (q.ans != null && String(q.ans).trim() !== "") {
correct += 1;
return;
}
const opts = Array.isArray(q.opts) ? q.opts : [];
if (opts.some((o) => o && o.isAns)) correct += 1;
});
const score = questionNum > 0 ? Math.round((correct / questionNum) * 100) : 0;
return { questionNum, correctQuestionNum: correct, score };
}
async function takeCourseExam(ctx) {
const { trainingId, projId, courseId, courseTitle, courseFieldId, practiseSwitch } = ctx;
if (practiseSwitch === 0) return;
const hit = findCourseInTree(projId, courseId);
const course = hit && hit.course;
if (course && course.practiceNull) return;
if (course && Number(course.practiseScore) >= PRACTICE_PASS_SCORE) return;
const name = String((course && course.courseName) || courseTitle || courseId || "\u672c\u8bfe\u7a0b");
const fieldId = courseFieldId || (course && course.courseFieldId) || "";
const tryIds = [];
if (fieldId) tryIds.push(String(fieldId));
if (courseId) tryIds.push(String(courseId));
const seen = {};
let questions = [];
for (let i = 0; i < tryIds.length; i += 1) {
const pid = tryIds[i];
if (!pid || seen[pid]) continue;
seen[pid] = true;
try {
const resp = await queryCoursePractices(trainingId, pid);
const list = resp && resp.data;
if (Array.isArray(list) && list.length) {
questions = list;
break;
}
} catch (_) {}
}
if (!questions.length) return;
log("\u5f00\u59cb\u8003\u8bd5" + name);
state.currentChapter = name;
state.currentTask = "\u8003\u8bd5\u4e2d";
syncCurrentUi();
const graded = gradePracticeQuestions(questions);
if (!graded.questionNum) return;
const score = PRACTICE_PASS_SCORE;
const correctQuestionNum = graded.questionNum;
await sleep(1500 + Math.floor(Math.random() * 1500));
await commitPracticeScore({
trainingId: String(trainingId),
projId: String(projId),
userId: Number(state.userId) || state.userId,
courseId: String(courseId),
score,
versionId: "3.1",
examStartTime: formatExamStartTime(new Date()),
questionNum: graded.questionNum,
correctQuestionNum,
uuid: state.uuid || randomHex(32),
});
if (course) {
course.practiseScore = score;
course.courseState = 2;
course.statusLabel = courseStatusLabel(course);
refreshProgressUi();
}
log(name + "\u8003\u8bd5\u901a\u8fc7");
}
function syncSelectAllCheckbox() {
const master = document.getElementById("ybs-select-all");
if (!master) return;
const boxes = Array.from(document.querySelectorAll(".ybs-course-cb"));
if (!boxes.length) {
master.checked = false;
master.indeterminate = false;
return;
}
const checked = boxes.filter((b) => b.checked).length;
master.checked = checked === boxes.length;
master.indeterminate = checked > 0 && checked < boxes.length;
}
function renderProjectCourses() {
const box = document.getElementById("ybs-course-list");
if (!box) return;
const tree = state.projectTree || [];
if (!tree.length) {
box.innerHTML = '\u300c\u6211\u7684\u9879\u76ee\u300d\u6682\u65e0\u8bfe\u7a0b\uff0c\u8bf7\u5148\u9009\u62e9\u57f9\u8bad
';
syncSelectAllCheckbox();
return;
}
box.innerHTML = tree
.map((proj) => {
const open = proj.expanded !== false;
const coursesHtml = (proj.courses || [])
.map((c) => {
const title = proj.projectName + " · " + c.courseName;
const tip = c.statusLabel || "";
const vidOpen = !!c.expanded;
const vids = Array.isArray(c.videos) ? c.videos : null;
let videoHtml = "";
if (vidOpen) {
if (c.videosLoading) {
videoHtml = '\u89c6\u9891\u52a0\u8f7d\u4e2d…
';
} else if (!vids || !vids.length) {
videoHtml = '\u6682\u65e0\u89c6\u9891\u660e\u7ec6
';
} else {
videoHtml = vids
.map(
(v) =>
`` +
`${escHtml(v.name || "\u89c6\u9891")}` +
`${escHtml(formatVideoProgress(v))}` +
`
`
)
.join("");
}
}
return (
`` +
`
` +
`` +
`
` +
(vidOpen ? `
${videoHtml}
` : "") +
`
`
);
})
.join("");
return (
`` +
`
` +
`` +
`${escHtml(proj.projectName)}` +
(proj.stateLabel ? `${escHtml(proj.stateLabel)}` : "") +
`${(proj.courses || []).length}\u8bfe` +
`
` +
(open ? `
${coursesHtml}
` : "") +
`
`
);
})
.join("");
box.querySelectorAll(".ybs-course-cb").forEach((cb) => {
cb.addEventListener("change", () => {
syncSelectAllCheckbox();
renderChapterPreview();
});
});
box.querySelectorAll(".ybs-proj-toggle").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.getAttribute("data-proj-id");
const proj = (state.projectTree || []).find((p) => String(p.projId) === String(id));
if (!proj) return;
proj.expanded = !(proj.expanded !== false);
renderProjectCourses();
});
});
box.querySelectorAll(".ybs-course-toggle").forEach((btn) => {
btn.addEventListener("click", async () => {
const projId = btn.getAttribute("data-proj-id");
const courseId = btn.getAttribute("data-course-id");
const hit = findCourseInTree(projId, courseId);
if (!hit) return;
hit.course.expanded = !hit.course.expanded;
renderProjectCourses();
if (hit.course.expanded && !Array.isArray(hit.course.videos)) {
await ensureCourseVideos(projId, courseId);
renderProjectCourses();
renderChapterPreview();
}
});
});
syncSelectAllCheckbox();
}
async function renderChapterPreview() {
const box = document.getElementById("ybs-chapter-preview");
if (!box) return;
const selected = selectedCourses();
if (!selected.length) {
box.innerHTML = '\u52fe\u9009\u8bfe\u7a0b\u540e\u53ef\u9884\u89c8\u89c6\u9891
';
return;
}
box.innerHTML = '\u6b63\u5728\u52a0\u8f7d\u89c6\u9891\u76ee\u5f55…
';
const blocks = [];
for (const item of selected) {
const hit = findCourseInTree(item.projId, item.courseId);
const courseName = hit ? hit.course.courseName : item.title;
const projectName = hit ? hit.proj.projectName : "";
const videos = await ensureCourseVideos(item.projId, item.courseId);
const listHtml = videos.length
? videos
.map(
(v) =>
`` +
`${escHtml(v.name || "\u89c6\u9891")}` +
`${escHtml(formatVideoProgress(v))}` +
`
`
)
.join("")
: '\u6682\u65e0\u89c6\u9891\u660e\u7ec6
';
blocks.push(
`` +
`
${escHtml(projectName ? projectName + " · " + courseName : courseName)}
` +
`
${listHtml}
` +
`
`
);
}
box.innerHTML = blocks.join("");
}
async function runStudyQueue(optQueue) {
if (state.running) return;
if (isPlayPage()) return;
const resume = readStudyRun();
let queue = Array.isArray(optQueue) ? optQueue.slice() : null;
let doneBase = 0;
let totalBase = 0;
let fullQueue = null;
if (queue && queue.length) {
fullQueue = queue.slice();
totalBase = queue.length;
doneBase = 0;
} else if (resume && resume.running) {
fullQueue = resume.queue.slice();
doneBase = Math.max(0, Number(resume.queueDone) || 0);
totalBase = Math.max(fullQueue.length, Number(resume.queueTotal) || fullQueue.length);
queue = fullQueue.slice(doneBase);
if (!queue.length) {
clearStudyRun();
return;
}
} else {
queue = selectedCourses();
if (!queue.length) {
log("\u8bf7\u52fe\u9009\u81f3\u5c11\u4e00\u95e8\u8bfe\u7a0b");
return;
}
fullQueue = queue.slice();
totalBase = queue.length;
doneBase = 0;
}
if (!(await ensureCloudReady())) {
switchPanelTab("settings");
return;
}
state.running = true;
state.stopRequested = false;
state.queueTotal = totalBase;
state.queueDone = doneBase;
state.activeSpeed = FIXED_SPEED;
setRunningUi(true);
syncModeUi();
syncQueueUi();
saveStudyRun({
running: true,
queue: fullQueue,
queueDone: state.queueDone,
queueTotal: state.queueTotal,
});
try {
for (const item of queue) {
if (state.stopRequested) break;
if (!(await ensureCloudReady())) {
switchPanelTab("settings");
break;
}
await studyCourse(item.trainingId, item.projId, item.courseId, item.title);
state.queueDone += 1;
syncQueueUi();
saveStudyRun({
running: true,
queue: fullQueue,
queueDone: state.queueDone,
queueTotal: state.queueTotal,
});
}
const stopped = state.stopRequested;
log(stopped ? "\u5df2\u505c\u6b62" : "\u5168\u90e8\u5b8c\u6210");
state.currentTask = stopped ? "\u5df2\u505c\u6b62" : "\u5168\u90e8\u5b8c\u6210";
syncCurrentUi();
if (!stopped) clearStudyRun();
} catch (e) {
const em = String((e && e.message) || e || "");
if (em && em !== "\u5df2\u505c\u6b62") log(em);
state.currentTask = em === "\u5df2\u505c\u6b62" ? "\u5df2\u505c\u6b62" : "\u5df2\u4e2d\u65ad";
syncCurrentUi();
saveStudyRun({
running: true,
queue: fullQueue,
queueDone: state.queueDone,
queueTotal: state.queueTotal,
});
} finally {
state.running = false;
state.activeSpeed = FIXED_SPEED;
setRunningUi(false);
syncModeUi();
updateCloudPanelUI();
if (state.stopRequested) clearStudyRun();
}
}
async function tryResumeStudy() {
if (isPlayPage() || state.running) return;
const run = readStudyRun();
if (!run) return;
if (!(await ensureCloudReady())) return;
log("\u7ee7\u7eed\u672a\u5b8c\u6210\u7684\u5b66\u4e60");
await runStudyQueue();
}
function readPanelPos() {
try {
return JSON.parse(localStorage.getItem(PANEL_POS_KEY) || "null");
} catch (_) {
return null;
}
}
function writePanelPos(left, top) {
localStorage.setItem(PANEL_POS_KEY, JSON.stringify({ left, top }));
}
function readPanelCollapsed() {
return localStorage.getItem(PANEL_COLLAPSED_KEY) === "1";
}
function writePanelCollapsed(v) {
localStorage.setItem(PANEL_COLLAPSED_KEY, v ? "1" : "0");
}
function applyPanelCollapsed(panel, collapsed) {
const btnMin = panel.querySelector("#ybs-btn-min");
const btnMax = panel.querySelector("#ybs-btn-max");
panel.classList.toggle("ybs-panel-min", !!collapsed);
panel.classList.toggle("ybs-panel-max", !collapsed);
if (btnMin) btnMin.style.display = collapsed ? "none" : "";
if (btnMax) btnMax.style.display = collapsed ? "" : "none";
}
function enablePanelDrag(panel) {
const header = panel.querySelector("#ybs-panel-header");
if (!header) return;
let dragging = false;
let startX = 0;
let startY = 0;
let startLeft = 0;
let startTop = 0;
header.addEventListener("mousedown", (e) => {
if (e.target?.closest("#ybs-panel-controls, .ybs-panel-ctl")) return;
dragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = panel.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
panel.style.right = "auto";
panel.style.bottom = "auto";
e.preventDefault();
});
document.addEventListener("mousemove", (e) => {
if (!dragging) return;
const left = Math.max(0, Math.min(window.innerWidth - panel.offsetWidth, startLeft + (e.clientX - startX)));
const top = Math.max(0, Math.min(window.innerHeight - panel.offsetHeight, startTop + (e.clientY - startY)));
panel.style.left = left + "px";
panel.style.top = top + "px";
});
document.addEventListener("mouseup", () => {
if (!dragging) return;
dragging = false;
const rect = panel.getBoundingClientRect();
writePanelPos(rect.left, rect.top);
});
}
function injectPanelStyles() {
const id = "ybs-panel-style-v1";
if (document.getElementById(id) || !document.head) return;
const st = document.createElement("style");
st.id = id;
st.textContent = `
#ybs-auto-panel{position:fixed;right:20px;top:80px;z-index:999999;width:412px;display:flex;flex-direction:column;background:#f4f7fb;border:1px solid #dbe4f0;border-radius:16px;box-shadow:0 16px 40px rgba(15,23,42,.17);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;font-size:12px;color:#0f172a;overflow:hidden;transition:max-height .22s ease,box-shadow .22s ease;}
#ybs-auto-panel.ybs-panel-max{max-height:min(92vh,780px);}
#ybs-auto-panel.ybs-panel-min{max-height:none;box-shadow:0 10px 28px rgba(15,23,42,.14);}
#ybs-auto-panel.ybs-panel-min #ybs-panel-header{border-bottom:none;}
#ybs-auto-panel.ybs-panel-min #ybs-panel-body,#ybs-auto-panel.ybs-panel-min #ybs-panel-footer,#ybs-auto-panel.ybs-panel-min .ybs-footer-extra{display:none !important;}
#ybs-panel-header{flex:0 0 auto;padding:8px 11px;background:linear-gradient(180deg,#f8ecd5,#f4e8cf);border-bottom:1px solid #e5dbc6;display:flex;justify-content:space-between;align-items:center;cursor:move;user-select:none;}
#ybs-panel-brand{display:flex;align-items:center;gap:9px;min-width:0;flex:1;}
#ybs-panel-logo{width:30px;height:30px;border-radius:9px;object-fit:cover;box-shadow:0 2px 9px rgba(15,23,42,.11);border:1px solid rgba(148,163,184,.45);background:#fff;flex:0 0 auto;}
#ybs-panel-title{font-size:13px;font-weight:900;color:#9a3412;line-height:1.26;display:flex;align-items:flex-start;gap:5px;flex-wrap:wrap;}
.ybs-panel-title-text{flex:1 1 12em;min-width:0;letter-spacing:-0.01em;}
#ybs-panel-sub{display:flex;flex-wrap:wrap;align-items:center;gap:3px 5px;margin-top:3px;line-height:1.32;}
.ybs-sub-chip{font-size:11px;color:#7c2d12;background:rgba(255,255,255,.62);padding:2px 7px;border-radius:999px;border:1px solid rgba(180,83,9,.18);font-weight:700;}
.ybs-sub-chip-em{color:#0f766e;background:rgba(236,253,245,.9);border-color:rgba(15,118,110,.28);}
.ybs-sub-dot{color:#d6d3d1;font-size:10px;}
.ybs-panel-version{font-size:11px;font-weight:900;color:#64748b;padding:2px 7px;border-radius:999px;background:#f1f5f9;border:1px solid #e2e8f0;}
#ybs-panel-controls{display:flex;align-items:center;gap:5px;flex:0 0 auto;}
.ybs-panel-ctl{border:none;background:#fff;color:#64748b;width:28px;height:28px;border-radius:999px;cursor:pointer;box-shadow:0 1px 2px rgba(15,23,42,.1);font-size:15px;line-height:1;font-weight:900;padding:0;}
.ybs-ctl-min{display:block;width:10px;height:2px;background:#64748b;border-radius:1px;margin:0 auto;}
.ybs-ctl-plus{font-size:16px;line-height:1;font-weight:700;color:#64748b;}
#ybs-panel-body{flex:1 1 auto;min-height:0;overflow-y:auto;padding:8px;}
.ybs-card{background:#fff;border:1px solid #d9e2ee;border-radius:12px;padding:7px 9px;margin-bottom:7px;}
.ybs-card-status{padding:6px 8px;margin-bottom:6px;}
.ybs-status-row{display:flex;justify-content:space-between;align-items:center;gap:7px;line-height:1.28;}
.ybs-status-label{font-size:11px;color:#64748b;font-weight:700;}
#ybs-auto-status{padding:2px 7px;border-radius:999px;font-weight:900;font-size:11px;background:#fff;border:1px solid #cbd5e1;}
.ybs-status-metrics{display:flex;align-items:center;gap:5px;font-size:12px;color:#475569;min-width:0;flex:1;}
.ybs-status-metrics em{font-style:normal;font-weight:900;color:#0f172a;}
.ybs-metric-div{color:#cbd5e1;}
.ybs-progress-pct{font-size:14px;font-weight:900;color:#0369a1;flex:0 0 auto;}
.ybs-progress-bar{height:5px;border-radius:999px;background:#e2e8f0;overflow:hidden;margin-top:4px;}
.ybs-progress-bar>span{display:block;height:100%;width:0;background:linear-gradient(90deg,#22d3ee,#2563eb);transition:width .2s ease;}
.ybs-tabbar{display:flex;gap:6px;margin-bottom:7px;}
.ybs-tab-btn{flex:1;border:1px solid #cbd5e1;background:#f8fafc;color:#475569;padding:4px;border-radius:9px;cursor:pointer;font-weight:700;font-size:11px;}
.ybs-tab-btn.active{background:linear-gradient(135deg,#1d4ed8,#0ea5e9);color:#fff;border-color:transparent;}
.ybs-pane{display:none;}.ybs-pane.active{display:block;}
.ybs-list-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;}
.ybs-list-title{font-size:12px;color:#64748b;font-weight:700;display:inline-flex;align-items:center;gap:6px;}
.ybs-select-all-wrap{display:inline-flex;align-items:center;gap:4px;cursor:pointer;user-select:none;}
.ybs-select-all-wrap input{margin:0;width:14px;height:14px;cursor:pointer;}
.ybs-list-tag{font-size:11px;color:#92400e;background:#ffedd5;border:1px solid #fdba74;border-radius:999px;padding:2px 7px;}
.ybs-plan-row{padding:0 2px 5px;display:flex;align-items:center;gap:8px;}
.ybs-plan-select{flex:1;min-width:0;width:auto;border:1px solid #cbd5e1;border-radius:8px;padding:6px 9px;font-size:12px;background:#fff;color:#0f172a;}
.ybs-list-head-actions{display:flex;align-items:center;gap:5px;}
.ybs-log-clear-btn{border:1px solid #cbd5e1;background:#fff;color:#64748b;padding:1px 7px;border-radius:999px;font-size:10px;font-weight:700;cursor:pointer;}
#ybs-course-list,#ybs-chapter-preview,#ybs-run-log{max-height:220px;overflow-y:auto;background:#f8fafc;border:1px solid #dbe4f0;border-radius:11px;padding:5px;}
.ybs-log-row{padding:4px 6px;border-bottom:1px dashed #d4deea;font-size:12px;line-height:1.42;}
.ybs-empty-state{padding:12px 7px;text-align:center;color:#94a3b8;font-size:12px;font-weight:900;}
.ybs-course-row{display:flex;align-items:flex-start;gap:6px;padding:4px 4px;cursor:pointer;font-size:12px;flex:1;min-width:0;}
.ybs-course-name{flex:1;min-width:0;line-height:1.35;}
.ybs-course-tip{flex:0 0 auto;color:#92400e;font-size:10px;font-weight:700;}
.ybs-proj-block{margin-bottom:7px;border:1px solid #dbe4f0;border-radius:9px;background:#fff;overflow:hidden;}
.ybs-proj-head{display:flex;align-items:center;gap:6px;padding:6px 8px;background:#eaf1ff;border-bottom:1px solid #dbe4f0;}
.ybs-proj-toggle,.ybs-course-toggle{border:none;background:transparent;color:#1d4ed8;cursor:pointer;font-size:12px;font-weight:900;padding:0 2px;line-height:1;flex:0 0 auto;}
.ybs-proj-name{flex:1;min-width:0;font-size:12px;font-weight:800;color:#1d4ed8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.ybs-proj-tip{flex:0 0 auto;font-size:10px;font-weight:700;color:#92400e;}
.ybs-proj-count{flex:0 0 auto;font-size:10px;color:#64748b;font-weight:700;}
.ybs-proj-body{padding:4px 6px 6px;}
.ybs-course-block{border-bottom:1px dashed #e2e8f0;padding:2px 0;}
.ybs-course-block:last-child{border-bottom:none;}
.ybs-course-line{display:flex;align-items:flex-start;gap:2px;}
.ybs-video-list{margin:2px 0 4px 22px;padding:4px 6px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;}
.ybs-video-row{display:flex;justify-content:space-between;gap:8px;padding:3px 2px;font-size:11px;line-height:1.35;border-bottom:1px dashed #e5e7eb;}
.ybs-video-row:last-child{border-bottom:none;}
.ybs-video-name{flex:1;min-width:0;color:#334155;}
.ybs-video-tip{flex:0 0 auto;color:#0369a1;font-weight:700;font-size:10px;}
.ybs-video-empty{padding:6px;color:#94a3b8;font-size:11px;text-align:center;}
.ybs-chapter-course{margin-bottom:7px;border:1px solid #dbe4f0;border-radius:9px;background:#fff;overflow:hidden;}
.ybs-chapter-title{padding:6px 9px;background:#eaf1ff;border-bottom:1px solid #dbe4f0;color:#1d4ed8;font-size:12px;font-weight:700;}
#ybs-course-list,#ybs-chapter-preview,#ybs-run-log{max-height:220px;overflow-y:auto;background:#f8fafc;border:1px solid #dbe4f0;border-radius:11px;padding:5px;}
.ybs-meta-row{display:flex;justify-content:space-between;gap:7px;font-size:12px;margin-bottom:5px;}
.ybs-meta-label{color:#64748b;}.ybs-meta-value{font-weight:700;text-align:right;}
.ybs-card-current{padding:5px 8px;margin-bottom:6px;}
.ybs-card-current .ybs-meta-row{font-size:11px;margin-bottom:2px;gap:5px;align-items:flex-start;}
.ybs-card-current .ybs-meta-label{flex:0 0 auto;font-size:10px;white-space:nowrap;}
.ybs-card-current .ybs-meta-value{flex:1 1 auto;min-width:0;font-size:11px;font-weight:600;text-align:right;word-break:break-all;}
.ybs-auth-badge{padding:2px 7px;border-radius:999px;border:1px solid #cbd5e1;font-size:11px;font-weight:900;}
.ybs-badge-2x{background:#ecfdf5;border-color:#6ee7b7;color:#047857;}
.ybs-badge-1x{background:#fff7ed;border-color:#fdba74;color:#c2410c;}
.ybs-mode-hint{margin-top:5px;padding:7px 9px;border-radius:9px;border:1px solid #bfdbfe;background:linear-gradient(135deg,#eff6ff,#f0f9ff);font-size:11px;line-height:1.42;color:#1e3a8a;}
.ybs-btn-row{display:flex;gap:7px;margin-bottom:0;flex-wrap:nowrap;}
.ybs-btn{flex:1;border:none;color:#fff;padding:7px 9px;border-radius:10px;cursor:pointer;font-weight:800;font-size:12px;min-width:68px;}
.ybs-btn-start{background:#16a34a;}.ybs-btn-stop{background:#ef4444;}.ybs-btn-refresh{background:#64748b;}
.ybs-btn-ghost{border:1px solid #cbd5e1;background:#fff;color:#0f172a;flex:0 0 auto;}
.ybs-btn-start.ybs-btn-off,.ybs-btn-start:disabled{background:#cbd5e1 !important;color:#64748b !important;cursor:not-allowed;}
.ybs-btn-stop.ybs-btn-off,.ybs-btn-stop:disabled{background:#e2e8f0 !important;color:#94a3b8 !important;cursor:not-allowed;}
.ybs-btn-ico{margin-right:5px;}
.ybs-footer-extra{flex:0 0 auto;padding:6px 9px;background:#f8fafc;border-top:1px solid #e2e8f0;}
.ybs-ann{position:relative;font-size:12px;color:#334155;line-height:1.42;background:linear-gradient(180deg,#fffdf5,#fff7e6);border:1px solid #fcd34d;border-radius:11px;padding:8px 9px 8px 11px;}
.ybs-ann::before{content:"";position:absolute;left:0;top:0;bottom:0;width:3px;background:linear-gradient(180deg,#f59e0b,#ef4444);border-radius:11px 0 0 11px;}
.ybs-qq-row{display:flex;align-items:center;justify-content:space-between;gap:9px;margin-top:5px;}
.ybs-qq-text{font-size:12px;color:#475569;}
.ybs-qq-title{font-size:13px;font-weight:800;color:#0f172a;}
.ybs-qq-btn{border:none;background:linear-gradient(135deg,#1d4ed8,#0ea5e9);color:#fff;padding:5px 12px;border-radius:999px;font-size:12px;font-weight:800;cursor:pointer;}
#ybs-panel-footer{flex:0 0 auto;padding:7px 11px;background:#eef2f7;border-top:1px solid #dbe4f0;font-size:12px;}
.ybs-btn-pro{border:none;border-radius:999px;padding:4px 10px;font-size:12px;font-weight:800;cursor:pointer;color:#fff;background:linear-gradient(135deg,#f59e0b,#ea580c);}
#ybs-pro-modal{display:none;position:fixed;inset:0;z-index:2147483646;background:rgba(15,23,42,.45);align-items:center;justify-content:center;}
.ybs-pro-card{width:min(360px,92vw);background:#fff;border-radius:16px;padding:16px;box-shadow:0 20px 50px rgba(15,23,42,.25);text-align:center;}
.ybs-pro-title{font-size:16px;font-weight:900;color:#9a3412;margin-bottom:6px;}
.ybs-pro-sub{font-size:12px;color:#64748b;line-height:1.5;margin-bottom:12px;}
`;
document.head.appendChild(st);
}
function createPanel() {
document.getElementById("ybs-auto-panel")?.remove();
document.getElementById("ybs-mvp-panel")?.remove();
injectPanelStyles();
const panel = document.createElement("div");
panel.id = "ybs-auto-panel";
panel.className = "ybs-panel-max";
panel.innerHTML = `
\u8fd0\u884c\u72b6\u6001
\u5df2\u505c\u6b62
0/0 \u8bfe\u7a0b\u5df2\u5b66\u4e60
·
\u72b6\u6001 \u81ea\u52a8
0%
\u6211\u7684\u9879\u76ee
\u8bfe\u7a0b\u5217\u8868
\u767b\u5f55\u540e\u5c06\u81ea\u52a8\u52a0\u8f7d\u57f9\u8bad
\u89c6\u9891\u5217\u8868
\u8bfe\u4ef6\u8fdb\u5ea6
\u52fe\u9009\u8bfe\u7a0b\u540e\u53ef\u9884\u89c8\u89c6\u9891
\u8fd0\u884c\u65e5\u5fd7
\u5b9e\u65f6\u65e5\u5fd7
\u6388\u6743\u4e0e\u9009\u9879\u8bbe\u7f6e
\u7528\u6237\u7c7b\u578b\u672a\u6821\u9a8c
\u514d\u8d39\u4f53\u9a8c\u89c6\u98910/3
Token
\u5f53\u524d\u8bfe\u7a0b\u65e0
\u5f53\u524d\u8bfe\u4ef6\u65e0
\u5f53\u524d\u4efb\u52a1\u70b9\u5f00\u59cb\u540e\u81ea\u52a8\u5b66\u4e60
`;
document.body.appendChild(panel);
const savedPos = readPanelPos();
if (savedPos && savedPos.left != null && savedPos.top != null) {
panel.style.right = "auto";
panel.style.left = savedPos.left + "px";
panel.style.top = savedPos.top + "px";
}
applyPanelCollapsed(panel, readPanelCollapsed());
enablePanelDrag(panel);
document.getElementById("ybs-btn-min")?.addEventListener("click", (e) => {
e.stopPropagation();
writePanelCollapsed(true);
applyPanelCollapsed(panel, true);
});
document.getElementById("ybs-btn-max")?.addEventListener("click", (e) => {
e.stopPropagation();
writePanelCollapsed(false);
applyPanelCollapsed(panel, false);
});
panel.querySelectorAll(".ybs-tab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
panel.querySelectorAll(".ybs-tab-btn").forEach((b) => b.classList.remove("active"));
panel.querySelectorAll(".ybs-pane").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
const pane = panel.querySelector(`.ybs-pane[data-pane="${btn.dataset.tab}"]`);
if (pane) pane.classList.add("active");
if (btn.dataset.tab === "chapter") renderChapterPreview();
});
});
document.getElementById("ybs-clear-log")?.addEventListener("click", () => {
state.logLines = [];
renderLog();
});
document.getElementById("ybs-join-qq")?.addEventListener("click", () => {
window.open(QQ_GROUP_LINK, "_blank");
});
document.getElementById("ybs-plan-select")?.addEventListener("change", async () => {
const trainingId = document.getElementById("ybs-plan-select")?.value || "";
if (!trainingId) return;
try {
await loadMyProjectsForTraining(trainingId);
renderChapterPreview();
} catch (e) {
log("\u5207\u6362\u57f9\u8bad\u5931\u8d25\uff1a" + e.message);
}
});
document.getElementById("ybs-select-all")?.addEventListener("change", (e) => {
const on = !!e.target.checked;
document.querySelectorAll(".ybs-course-cb").forEach((cb) => {
cb.checked = on;
});
e.target.indeterminate = false;
renderChapterPreview();
});
document.getElementById("ybs-start")?.addEventListener("click", () => {
clearStudyRun();
runStudyQueue(selectedCourses());
});
document.getElementById("ybs-stop")?.addEventListener("click", () => {
state.stopRequested = true;
clearStudyRun();
log("\u6b63\u5728\u505c\u6b62");
});
document.getElementById("ybs-cloud-save")?.addEventListener("click", async () => {
const input = document.getElementById("ybs-cloud-token");
const tk = String((input && input.value) || "").trim();
state.cloudToken = tk;
if (tk) localStorage.setItem(CLOUD_TOKEN_KEY, tk);
else localStorage.removeItem(CLOUD_TOKEN_KEY);
try {
if (tk) await cloudVerifyToken();
await _el(true);
log("\u6388\u6743\u5df2\u66f4\u65b0");
} catch (e) {
log("\u6388\u6743\u5931\u8d25\uff0c\u8bf7\u68c0\u67e5 Token");
}
updateCloudPanelUI();
});
document.getElementById("ybs-open-pro")?.addEventListener("click", () => openProModal());
syncModeUi();
syncQueueUi();
updateCloudPanelUI();
log("\u52a9\u624b\u5df2\u5c31\u7eea");
fetchClientConfig().catch(() => {});
scheduleAutoLoad();
}
let autoLoadScheduled = false;
function scheduleAutoLoad() {
if (autoLoadScheduled) return;
autoLoadScheduled = true;
let tries = 0;
const tick = async () => {
tries += 1;
const ok = await autoLoadTrainingsAndProjects({ silent: tries > 1 });
if (ok) return;
if (tries < 12) setTimeout(tick, 2500);
};
setTimeout(tick, 800);
}
function ensurePanelMounted() {
if (isPlayPage()) {
const panel = document.getElementById("ybs-auto-panel");
if (panel) panel.style.display = "none";
return false;
}
let panel = document.getElementById("ybs-auto-panel");
if (!panel) {
if (!document.body) return false;
createPanel();
panel = document.getElementById("ybs-auto-panel");
scheduleAutoLoad();
setTimeout(() => {
tryResumeStudy().catch(() => {});
}, 1800);
} else {
panel.style.display = "";
}
return !!panel;
}
function installRouteWatch() {
if (window.__ybsRouteWatch) return;
window.__ybsRouteWatch = true;
const onRoute = () => {
try {
ensurePanelMounted();
} catch (_) {}
};
window.addEventListener("hashchange", onRoute);
window.addEventListener("popstate", onRoute);
const wrap = (type) => {
const raw = history[type];
if (typeof raw !== "function") return;
history[type] = function () {
const ret = raw.apply(this, arguments);
setTimeout(onRoute, 0);
return ret;
};
};
wrap("pushState");
wrap("replaceState");
}
function boot() {
if (!document.body) {
setTimeout(boot, 200);
return;
}
installRouteWatch();
if (isPlayPage()) {
return;
}
createPanel();
scheduleAutoLoad();
setTimeout(() => {
tryResumeStudy().catch(() => {});
}, 1800);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})();