// ==UserScript== // @name 医博士继续医学教育在线学习助手 // @namespace https://card.wlxy.live/details/050752C8 // @version 1.1 // @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 = "免费体验 3 个视频章节,升级 Pro 不限"; /** 固定刷课参数:每步 20s(云端引擎编排) */ 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: "点开始后自动学习", 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(`[医博士刷课] ${msg}`); renderLog(); } /** 只取平台业务原文;没有文案则空(不写 HTTP/路径/code) */ function platformTipText(data) { if (!data || typeof data !== "object") return ""; if (data.msg != null && String(data.msg).trim() !== "") return String(data.msg).trim(); if (data.message != null && String(data.message).trim() !== "") return String(data.message).trim(); return ""; } /** 抛错用:优先业务原文;无原文时给短状态,不带接口路径包装 */ function platformReplyText(data, res) { const tip = platformTipText(data); if (tip) return tip; const status = res && res.status != null ? Number(res.status) : 0; if (status === 404) return "接口不存在(404)"; if (status === 403) return "接口拒绝(403)"; if (status === 502 || status === 503) return "网关异常(" + status + ")"; const raw = String((res && res.responseText) || "") .replace(/\s+/g, " ") .trim(); if (/\s*\|?\s*code=\d+/i.test(t)) return false; if (/^code=\d+$/i.test(t)) return false; return true; } function logPlatformFeedback(tip) { if (!shouldLogPlatformFeedback(tip)) return; log("平台反馈:" + tip); } async function sleep(ms) { const end = Date.now() + Math.max(0, Number(ms) || 0); while (Date.now() < end) { if (state.stopRequested) return; const left = end - Date.now(); await new Promise((r) => setTimeout(r, Math.min(200, Math.max(0, left)))); } } function formatDuration(sec) { const s = Math.max(0, Number(sec) || 0); const m = Math.round((s / 60) * 10) / 10; if (m <= 0) return "0分钟"; const text = Number.isInteger(m) ? String(m) : m.toFixed(1); return text + "分钟"; } function escHtml(s) { return String(s || "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function isViolationError(err) { const t = String((err && err.message) || err || ""); return /违规|重新进入|异常学习/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; } /** 视频播放页:不挂面板、不启动学习(鉴权钩子仍可捕获 Token) */ 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: "中国大陆", 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; } /** * 项目 projState(来自 listStudentProjInfoAndStatus) * 抓包:1=学习中,3=已完成;0 未映射时会显示成「状态0」 */ function projStateLabel(stateId) { if (stateId == null || stateId === "") return ""; const n = Number(stateId); if (n === 0) return "未开始"; if (n === 1) return "学习中"; if (n === 2) return "待考试"; if (n === 3) return "已完成"; return "状态" + stateId; } /** 考试满分才算通过 */ const PRACTICE_PASS_SCORE = 100; /** 课程学习状态:courseState 1学习中 / null未学习 / 2已学完 */ function courseStudyStateLabel(c) { if (!c || typeof c !== "object") return "未学习"; const raw = c.courseState; if (raw == null || raw === "") return "未学习"; if (typeof raw === "string" && /[\u4e00-\u9fa5]/.test(raw)) return raw; const n = Number(raw); if (n === 1) return "学习中"; if (n === 2) return "已学完"; if (n === 0) return "未学习"; return "状态" + raw; } /** 考试状态:practiseScore null未考试 / 100考试通过 */ 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 "未考试"; const n = Number(score); if (Number.isFinite(n) && n >= PRACTICE_PASS_SCORE) return "考试通过"; if (Number.isFinite(n)) return "考试未通过"; return "未考试"; } /** 课程列表右侧:学习中 · 未考试 */ 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 "已学完"; if (view >= Number(cw.segmentStart)) { return "学习中 · " + formatDuration(view); } return "未学习 · " + 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 "未学习"; } 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 || /^视频\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("未捕获登录 Token,请先正常登录并打开课程页触发一次 API 请求"); 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(platformReplyText(null, res)); } if (!isApiSuccess(data, res.status)) { throw new Error(platformReplyText(data, res)); } 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 || "课件" + (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: "课件" + (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) || "项目 " + projId; const stateLabel = projStateLabel(p.projState); let coursesRaw = []; try { coursesRaw = await fetchProjectCourses(trainingId, projId); } catch (e) { log("getProjectCourse 失败 " + projectName + ": " + e.message); } const courses = []; coursesRaw.forEach((c) => { const courseId = pickId(c, ["courseId", "id", "wareCourseId"]); if (!courseId) return; const courseName = pickName(c) || "课程 " + 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("项目无课程明细,已跳过:" + 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 cloudRequestRaw(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 (_) { return { ok: false, data: null, res, parseError: true }; } return { ok: isApiSuccess(data, res.status), data, res, parseError: false }; } async function singleDeviceSwitch(trainingId) { const qs = "?trainingId=" + encodeURIComponent(String(trainingId || "")); const url = PLATFORM_CLOUD_BASE + "/api-video/v2/video/singleDevice/switch" + qs; return cloudRequestRaw("GET", url); } async function singleDeviceCheck(trainingId) { const qs = "?trainingId=" + encodeURIComponent(String(trainingId || "")); const url = PLATFORM_CLOUD_BASE + "/api-video/v2/video/singleDevice/check" + qs; const raw = await cloudRequestRaw("GET", url); if (raw.ok) return raw.data; const code = Number(raw.data && raw.data.code); // code=2:账号在其他设备 — 打原文提示后抢占到当前设备 if (code === 2) { logPlatformFeedback(platformTipText(raw.data)); const sw = await singleDeviceSwitch(trainingId); if (sw.ok || Number(sw.data && sw.data.code) === 0) return sw.data || raw.data; return raw.data; } throw new Error(platformReplyText(raw.data, raw.res)); } async function pagePostJson(url, payload) { const headers = authHeaders(); headers["Content-Type"] = "application/json"; const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload || {}), credentials: "include", }); const responseText = await res.text(); let data = null; try { data = JSON.parse(responseText || "{}"); } catch (_) { throw new Error(platformReplyText(null, { status: res.status, responseText })); } if (!isApiSuccess(data, res.status)) { throw new Error(platformReplyText(data, { status: res.status, responseText })); } return data; } async function syncCourseStatus(payload) { const url = PLATFORM_CLOUD_BASE + "/api-video/v2/video/syncCourseStatus"; const body = JSON.stringify(payload || {}); let lastErr = null; for (let attempt = 1; attempt <= 3; attempt += 1) { if (state.stopRequested) throw new Error("已停止"); try { return await apiJson("POST", url, body, "application/json"); } catch (e) { lastErr = e; const tip = String((e && e.message) || e || ""); // 只对网关类错误重试;业务违规等直接抛出 if (!/404|502|503|Not Found|Bad Gateway|network_error|timeout/i.test(tip)) throw e; if (attempt < 3) { log("平台反馈:apicloud 暂不可用,重试 " + attempt + "/3"); await sleep(1000 * attempt); } } } // GM 连续网关失败时,改用页面 fetch(同源策略下更接近官方播放器) try { log("平台反馈:改用页面通道重试 apicloud 上报"); return await pagePostJson(url, payload); } catch (e2) { throw lastErr || e2; } } 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 = "自动学习"; const badge = document.getElementById("ybs-speed-badge"); if (badge) { badge.textContent = "自动"; 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(); } /** 仅用已缓存的 videos 刷新预览进度,避免反复请求 */ 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 || "视频")}` + `${escHtml(formatVideoProgress(v))}` + `
` ) .join(""); blocks.push( `
` + `
${escHtml(projectName ? projectName + " · " + courseName : courseName)}
` + `
${listHtml || '
暂无视频明细
'}
` + `
` ); } 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("云端网络错误")), ontimeout: () => reject(new Error("云端请求超时")), }; 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("云端响应非 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("未登录医博士,无法获取云端授权(请先打开课程页)"); 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 无效"); } 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("请先登录医博士并打开任意课程页"); return false; } try { if (state.cloudToken) await cloudVerifyToken(); await _el(false); } catch (err) { log("授权失败,请到设置里检查 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("免费额度已用完,请升级 Pro"); switchPanelTab("settings"); openProModal(); return false; } return true; } log("授权异常,请到设置里重新校验"); 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 "已禁用"; const t = String(tier || "").trim().toLowerCase(); if (t === "pro") return "Pro 会员"; if (t === "free") return "免费体验"; if (t === "revoked") return "已禁用"; if (t === "unknown") return "未校验"; return tier ? String(tier) : "未校验"; } 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 = "授权状态"; if (freeEl) freeEl.textContent = "Token 已禁用"; } else if (String(state.cloudTier || "").toLowerCase() === "pro") { if (freeLabelEl) freeLabelEl.textContent = "Pro 到期"; if (freeEl) freeEl.textContent = formatLeaseExpireText(state.cloudProExpireAt || state.cloudLeaseExp); } else { if (freeLabelEl) freeLabelEl.textContent = "免费体验视频"; 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 = '
' + '
开通 Pro
' + '
免费可体验 ' + state.freeVideoLimit + " 个视频章节;Pro 不限次数
" + '' + '' + "
"; 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") { if (state.stopRequested) return { terminal: true, ok: false, msg: "已停止" }; await sleep(Math.max(500, Number(c.ms || 1000))); if (state.stopRequested) return { terminal: true, ok: false, msg: "已停止" }; return { event: "tick", lastResult: null }; } if (t === "done") { return { terminal: true, ok: !!c.success, skipped: !!c.skipped, msg: c.message || "完成" }; } if (t === "failed") { const msg = c.message || "failed"; logPlatformFeedback(msg); return { terminal: true, ok: false, msg }; } if (t === "ybs_device_check") { try { await singleDeviceCheck(c.trainingId || ctx.trainingId); return { event: "submit_result", lastResult: { ok: true } }; } catch (e) { const warn = String((e && e.message) || e || ""); logPlatformFeedback(warn); return { event: "submit_result", lastResult: { ok: true, warn } }; } } 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) { const err = String((e && e.message) || e || "sync_failed"); logPlatformFeedback(err); return { event: "submit_result", lastResult: { ok: false, success: false, error: err, message: err, text: err }, }; } } 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: "已停止" }; } 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: "学习中断,请重试" }; } 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("开始学习" + 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 || "学习失败"; if (/free_quota/i.test(msg) || /免费额度/.test(String(startRes.log || ""))) { switchPanelTab("settings"); openProModal(); throw new Error("免费额度已用完,请升级 Pro"); } throw new Error(name + "学习失败:" + String(msg).slice(0, 160)); } const result = await runYbsEngineLoop(startRes, { trainingId, projId, courseId, coursewareId, totalTime: target, title: name, }); if (!result.ok && !result.skipped) { const detail = String(result.msg || "").trim(); if (/free_quota|免费额度/.test(detail)) { switchPanelTab("settings"); openProModal(); throw new Error("免费额度已用完,请升级 Pro"); } if (detail === "已停止") throw new Error("已停止"); throw new Error(name + "学习失败" + (detail ? ":" + detail.slice(0, 160) : "")); } if (result.skipped) { /* 已学完不刷屏 */ } else log(name + "学习完成"); } 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("未获取到 coursewareList,请确认已在该平台选过该课程"); } 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 (e) { const tip = String((e && e.message) || e || "").trim(); log( ((course && course.courseName) || courseTitle || "本课程") + "考试失败" + (tip ? ":" + tip.slice(0, 160) : "") ); if (tip) logPlatformFeedback(tip); } } } 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 ? `已选 ${total} 门 · 完成 ${done}` : "未选择课程"; } } function syncCurrentUi() { const set = (id, v) => { const el = document.getElementById(id); if (el) { el.textContent = v || "无"; 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 ? "运行中" : "已停止"; 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) || "培训 " + 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("请先选择培训"); 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 = '
正在加载我的项目…
'; await fetchProjects(id); renderProjectCourses(); if (!opts.silent) log("我的项目已刷新(" + state.projects.length + " 门课)"); 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("等待登录 Token…登录并打开课程页后将自动加载"); return false; } syncUserFromAuth(auth); await fetchTrainings(); renderTrainingOptions(); const trainingId = pickDefaultTrainingId(); if (!trainingId) { log("未获取到培训列表"); return false; } const name = pickName(state.trainings.find((t) => pickId(t, ["trainingId", "id", "trainId"]) === trainingId)) || trainingId; log("已自动选择培训:" + name); await loadMyProjectsForTraining(trainingId, { silent: false }); return true; } catch (e) { log("自动加载失败:" + (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) || /^课件\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("加载视频列表失败"); } 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); } /** 题目里自带 ans / isAns,交卷只需汇总分数 */ 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 || "本课程"); 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("开始考试" + name); state.currentChapter = name; state.currentTask = "考试中"; syncCurrentUi(); const graded = gradePracticeQuestions(questions); if (!graded.questionNum) return; // 平台要求 100 分通过;答案在题目里,按满分提交 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 + "考试通过"); } 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 = '
「我的项目」暂无课程,请先选择培训
'; 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 = '
视频加载中…
'; } else if (!vids || !vids.length) { videoHtml = '
暂无视频明细
'; } else { videoHtml = vids .map( (v) => `
` + `${escHtml(v.name || "视频")}` + `${escHtml(formatVideoProgress(v))}` + `
` ) .join(""); } } return ( `
` + `
` + `` + `
` + (vidOpen ? `
${videoHtml}
` : "") + `
` ); }) .join(""); return ( `
` + `
` + `` + `${escHtml(proj.projectName)}` + (proj.stateLabel ? `${escHtml(proj.stateLabel)}` : "") + `${(proj.courses || []).length}课` + `
` + (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 = '
勾选课程后可预览视频
'; return; } box.innerHTML = '
正在加载视频目录…
'; 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 || "视频")}` + `${escHtml(formatVideoProgress(v))}` + `
` ) .join("") : '
暂无视频明细
'; 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("请勾选至少一门课程"); 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 ? "已停止" : "全部完成"); state.currentTask = stopped ? "已停止" : "全部完成"; syncCurrentUi(); if (!stopped) clearStudyRun(); } catch (e) { const em = String((e && e.message) || e || ""); if (em && em !== "已停止") log(em); state.currentTask = em === "已停止" ? "已停止" : "已中断"; 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("继续未完成的学习"); 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 = `
医博士继续医学教育自动学习助手 v${SCRIPT_VERSION}
免费体验 3 节 · Pro 不限 · 我的项目
运行状态 已停止
0/0 课程已学习 · 状态 自动
0%
我的项目 课程列表
登录后将自动加载培训
视频列表 课件进度
勾选课程后可预览视频
运行日志
实时日志
授权与选项设置
用户类型未校验
免费体验视频0/3
Token
当前课程
当前课件
当前任务点开始后自动学习
`; 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("切换培训失败:" + 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; state.running = false; clearStudyRun(); setRunningUi(false); state.currentTask = "已停止"; syncCurrentUi(); log("已停止"); }); 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("授权已更新"); } catch (e) { log("授权失败,请检查 Token"); } updateCloudPanelUI(); }); document.getElementById("ybs-open-pro")?.addEventListener("click", () => openProModal()); syncModeUi(); syncQueueUi(); updateCloudPanelUI(); log("助手已就绪"); 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(); } })();