// ==UserScript== // @name 山东省教师教育网-教师远程研修课程助手 // @namespace seventeen_17.qlteacher-helper // @version 0.2.2 // @author seventeen_17(修改维护) // @description 山东省教师教育网教师培训、远程研修学习辅助:课程队列、章节进度、2至8倍进度提交尝试及本地测验题库填入。 // @match *://*.qlteacher.com/* // @match *://qlteacher.com/* // @connect *.qlteacher.com // @connect qlteacher.com // @grant GM_xmlhttpRequest // @grant GM_info // @grant GM_openInTab // @grant GM_getValue // @grant GM_setValue // @run-at document-start // @tag 山东省教师教育网 // @tag 齐鲁教师 // @tag 教师培训 // @tag 学习辅助 // @tag 远程研修 // @tag 继续教育 // @tag 课程助手 // @license All Rights Reserved // ==/UserScript== (function () { "use strict"; const SCRIPT_VERSION = (() => { try { return String(GM_info?.script?.version || "1.1.2"); } catch (_) { return "1.1.2"; } })(); if (window.__qltYxjcHelper) return; window.__qltYxjcHelper = true; const PRODUCT_NAME = "山东省教师教育网-教师远程研修课程助手"; const PANEL_POS_KEY = "qlt_panel_pos_v1"; const PANEL_COLLAPSED_KEY = "qlt_panel_collapsed_v1"; const LOG_KEY = "qlt_local_run_log_v2"; const QUEUE_KEY = "qlt_local_lesson_queue_v2"; const SPEED_KEY = "qlt_speed_mode_v1"; const AUTH_TOKEN_KEY = "qlt_captured_bearer_v1"; const PANEL_LOGO_URL = ""; const PANEL_NOTICE_FALLBACK = "seventeen_17:勾选课程后开始学习"; const ACCOUNT_API = "https://account.qlteacher.com"; const YXJC_API = "https://yxjc.qlteacher.com"; const PLAYER_API = "https://player.qlteacher.com"; const SPEED_MODES = { oneone: { id: "oneone", label: "1:1\u5e38\u901f\u6a21\u5f0f" }, eff: { id: "eff", label: "自适应提速(从 2 倍开始)", maxRate: 2 }, eff3: { id: "eff3", label: "自适应提速(从 3 倍开始)", maxRate: 3 }, eff4: { id: "eff4", label: "自适应提速(从 4 倍开始)", maxRate: 4 }, eff5: { id: "eff5", label: "自适应提速(从 5 倍开始)", maxRate: 5 }, eff6: { id: "eff6", label: "自适应提速(从 6 倍开始)", maxRate: 6 }, eff7: { id: "eff7", label: "自适应提速(从 7 倍开始)", maxRate: 7 }, eff8: { id: "eff8", label: "自适应提速(从 8 倍开始)", maxRate: 8 }, }; const state = { user: null, projectId: "", projectName: "", projects: [], lessons: [], selectedIds: loadQueue(), running: false, stopFlag: false, lastAction: "\u5c31\u7eea", currentLessonName: "-", currentTitle: "-", progressPct: 0, queueDone: 0, queueTotal: 0, logs: [], bearer: String(GM_getValue?.(AUTH_TOKEN_KEY, "") || localStorage.getItem(AUTH_TOKEN_KEY) || "").trim(), panelNoticePath: "/api/qlt/panel-notice", remotePanelNotice: "", speedMode: (() => { const raw = localStorage.getItem(SPEED_KEY) || "eff"; if (raw === "normal" || raw === "fine" || raw === "platform" || raw === "oneone") return "oneone"; if (raw === "soft" || raw === "eff" || /^(x2|x3|x5|turbo)$/.test(raw)) return "eff"; return SPEED_MODES[raw] ? raw : "eff"; })(), authReady: false, lastLoggedPctBucket: -1, previewByLesson: {}, _previewLoadSeq: 0, }; try { state.logs = JSON.parse(localStorage.getItem(LOG_KEY) || "[]") || []; } catch (_) { state.logs = []; } function loadQueue() { try { const arr = JSON.parse(localStorage.getItem(QUEUE_KEY) || "[]"); return Array.isArray(arr) ? arr.map(String) : []; } catch (_) { return []; } } function saveQueue(ids) { state.selectedIds = [...new Set((ids || []).map(String))]; try { localStorage.setItem(QUEUE_KEY, JSON.stringify(state.selectedIds)); } catch (_) {} } function escapeHtml(s) { return String(s == null ? "" : s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } function log(msg) { const line = `[${new Date().toLocaleTimeString()}] ${msg}`; state.logs.unshift(line); if (state.logs.length > 50) state.logs.length = 50; try { localStorage.setItem(LOG_KEY, JSON.stringify(state.logs.slice(0, 30))); } catch (_) {} state.lastAction = msg; const el = document.getElementById("qlt-run-log"); if (el) { el.innerHTML = state.logs .slice(0, 30) .map((s) => `
${escapeHtml(s)}
`) .join(""); } } function clearRunLog() { state.logs = []; try { localStorage.removeItem(LOG_KEY); } catch (_) {} const el = document.getElementById("qlt-run-log"); if (el) el.innerHTML = ""; } function logProgress(_title, pct) { updateProgressUi(pct); } function logSessionDoneOnce(title) { const key = String(title || "").trim(); if (!key) return; if (!state._doneLogKeys) state._doneLogKeys = Object.create(null); if (state._doneLogKeys[key]) return; state._doneLogKeys[key] = 1; log(`\u5df2\u5b8c\u6210\uff1a${key}`); } function currentSpeedMode() { return SPEED_MODES[state.speedMode] || SPEED_MODES.eff; } function sessionGapMs() { return 5000; } function effWaitCapMs() { return 2000; } function resolveWaitMs(rawMs) { let ms = Math.max(200, Number(rawMs) || 1000); if (currentSpeedMode().id === "eff") { ms = Math.min(ms, effWaitCapMs()); } return ms; } function isTimeMismatchError(err) { const msg = String(err?.message || err || ""); return /\u65f6\u95f4\u4e0d\u7b26|\u4e0d\u8981\u5411\u540e\u62d6\u52a8|\u8fdb\u5ea6\u4fdd\u5b58\u5931\u8d25|\u5b9e\u9645\u7ecf\u8fc7\u65f6\u95f4/i.test(msg); } function isNeedFinishSignalError(err) { const msg = String(err?.message || err || ""); return /\u8d85\u8fc7\u5b66\u4e60\u6807\u51c6|\u72b6\u6001\u6ca1\u6709\u5b8c\u6210|\u6ca1\u6709\u5b8c\u6210/i.test(msg); } function readCookie(name) { const m = document.cookie.match( new RegExp("(?:^|; )" + name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "=([^;]*)") ); return m ? decodeURIComponent(m[1]) : ""; } function getLearningUserId() { return String(readCookie("qlt_uid") || state.user?.id || "").trim(); } function detectProjectId() { const path = String(location.pathname || ""); let m = path.match(/\/(?:project|learning)\/([^/]+)/i); if (m) return m[1]; m = String(location.href).match(/[?&]projectId=([^&]+)/i); if (m) return decodeURIComponent(m[1]); const sel = document.getElementById("qlt-project-select"); if (sel && sel.value) return String(sel.value); if (state.projectId) return state.projectId; try { const cached = localStorage.getItem("qlt_last_project_id"); if (cached) return cached; } catch (_) {} return ""; } function rememberProjectId(pid, name) { const id = String(pid || "").trim(); if (!id) return; state.projectId = id; if (name) state.projectName = String(name); else { const hit = (state.projects || []).find((p) => String(p.id) === id); if (hit?.name) state.projectName = String(hit.name); } try { localStorage.setItem("qlt_last_project_id", id); } catch (_) {} syncProjectSelect(); } function syncProjectSelect() { const sel = document.getElementById("qlt-project-select"); if (!sel) return; const cur = state.projectId || ""; const opts = (state.projects || []) .map((p) => { const id = String(p.id || ""); const name = String(p.name || id); return ``; }) .join(""); sel.innerHTML = `` + opts; if (cur) sel.value = cur; } function rememberBearer(token) { const t = String(token || "").replace(/^Bearer\s+/i, "").trim(); if (!t || t.length < 20) return; if (state.bearer === t) return; const prev = state.bearer; state.bearer = t; state.authReady = true; try { localStorage.setItem(AUTH_TOKEN_KEY, t); if (typeof GM_setValue === "function") GM_setValue(AUTH_TOKEN_KEY, t); } catch (_) {} } function extractBearerFromHeaders(headers) { if (!headers) return ""; try { if (typeof headers.get === "function") { return String(headers.get("Authorization") || headers.get("authorization") || ""); } if (Array.isArray(headers)) { for (const pair of headers) { if (String(pair?.[0] || "").toLowerCase() === "authorization") { return String(pair[1] || ""); } } } if (typeof headers === "object") { for (const k of Object.keys(headers)) { if (k.toLowerCase() === "authorization") return String(headers[k] || ""); } } } catch (_) {} return ""; } function hookAuthCapture() { try { const ofetch = window.fetch; if (typeof ofetch === "function" && !ofetch.__qltHooked) { const wrapped = function (input, init) { try { const h = init?.headers || (input && input.headers); const auth = extractBearerFromHeaders(h); if (/^Bearer\s+/i.test(auth)) rememberBearer(auth); } catch (_) {} return ofetch.apply(this, arguments); }; wrapped.__qltHooked = true; window.fetch = wrapped; } } catch (_) {} try { const XO = XMLHttpRequest.prototype.open; const XS = XMLHttpRequest.prototype.setRequestHeader; if (!XO.__qltHooked) { XMLHttpRequest.prototype.open = function () { this.__qltHeaders = {}; return XO.apply(this, arguments); }; XMLHttpRequest.prototype.open.__qltHooked = true; XMLHttpRequest.prototype.setRequestHeader = function (k, v) { try { this.__qltHeaders[String(k).toLowerCase()] = String(v); if (String(k).toLowerCase() === "authorization" && /^Bearer\s+/i.test(String(v))) { rememberBearer(v); } } catch (_) {} return XS.apply(this, arguments); }; } } catch (_) {} try { for (const store of [localStorage, sessionStorage]) { for (let i = 0; i < store.length; i++) { const key = store.key(i); const val = String(store.getItem(key) || ""); if (/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\./.test(val)) { const m = val.match(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/); if (m) rememberBearer(m[0]); } } } } catch (_) {} } hookAuthCapture(); function gmRequest(method, url, body, headers) { return new Promise((resolve, reject) => { const g = typeof GM_xmlhttpRequest === "function" ? GM_xmlhttpRequest : typeof GM !== "undefined" && GM.xmlHttpRequest ? GM.xmlHttpRequest.bind(GM) : null; if (!g) { fetch(url, { method, headers: headers || {}, body: body == null ? undefined : body, credentials: "include", }) .then(async (r) => { const text = await r.text(); resolve({ status: r.status, text, headers: {} }); }) .catch(reject); return; } g({ method, url, headers: headers || {}, data: body == null ? undefined : body, anonymous: false, onload: (res) => resolve({ status: Number(res.status || 0), text: String(res.responseText || ""), headers: res.responseHeaders || "", }), onerror: () => reject(new Error("network_error")), ontimeout: () => reject(new Error("timeout")), }); }); } async function apiJson(base, path, method, bodyObj) { const url = `${base.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`; const headers = { Accept: "application/json, text/plain, */*", "Content-Type": "application/json", }; if (state.bearer) headers.Authorization = `Bearer ${state.bearer}`; const body = method === "GET" || method === "HEAD" ? null : bodyObj === undefined ? "{}" : JSON.stringify(bodyObj == null ? {} : bodyObj); const res = await gmRequest(method || "GET", url, body, headers); let data = null; const text = String(res.text || "").trim(); if (text) { try { data = JSON.parse(text); } catch (_) { data = { raw: text }; } } return { status: res.status, data, text }; } function assertOk(payload, tip) { const code = Number(payload?.data?.code); if (payload.status < 200 || payload.status >= 300) { throw new Error(tip || `http_${payload.status}`); } if (payload?.data && code && code !== 200) { throw new Error(String(payload.data.msg || payload.data.message || `code_${code}`)); } return payload?.data?.data; } async function ensureLocalReady() { if (!state.user) await refreshUser(); return !!state.user; } function canStudyNextFreeLesson() { return true; } function rememberFreeStudiedLesson() {} async function backfillFreeConsumes() {} function notifyQuotaExhausted(message) { log(message || "平台拒绝了请求,请查看日志"); } function markProgressComplete() { state.progressPct = 100; updateProgressUi(100); } async function waitForBearer(timeoutMs) { const deadline = Date.now() + (timeoutMs || 8000); while (Date.now() < deadline) { if (state.bearer) return state.bearer; hookAuthCapture(); await sleep(300); } return state.bearer; } async function refreshUser() { await waitForBearer(5000); if (!state.bearer) throw new Error("\u672a\u6355\u83b7\u5230\u767b\u5f55 Token\uff0c\u8bf7\u5237\u65b0\u9875\u9762\u6216\u70b9\u5f00\u4efb\u610f\u8bfe\u7a0b\u540e\u518d\u8bd5"); const payload = await apiJson(ACCOUNT_API, "/api/me", "GET"); const data = assertOk(payload, "\u83b7\u53d6\u7528\u6237\u4fe1\u606f\u5931\u8d25"); state.user = data || null; updateUserUI(); return state.user; } function flattenLessons(topicList) { const out = []; const seen = new Set(); for (const topic of topicList || []) { const topicName = String(topic?.name || "\u4e13\u9898"); const lessons = Array.isArray(topic?.lessons) ? topic.lessons : []; for (const les of lessons) { const id = String(les?.id || ""); if (!id || seen.has(id)) continue; seen.add(id); const st = les?.lessonLearnStatus || {}; out.push({ id, name: String(les?.name || id), topicName, topicId: String(topic?.id || ""), courseId: String(les?.courseId || ""), mustLearn: !!les?.mustLearn, learnHour: Number(les?.learnHour || 0), allowLearn: les?.allowLearn !== false, status: st.status == null || st.status === "" ? -1 : Number(st.status), lastLearnActId: st.lastLearnActId || null, raw: les, }); } } return out; } function setLessonStatusLocal(lessonId, status) { const id = String(lessonId || ""); if (!id) return; const les = state.lessons.find((l) => String(l.id) === id); if (!les) return; const next = Number(status); if (Number(les.status) === next) { renderLessonList(); return; } les.status = next; renderLessonList(); updateQueueSummary(); } async function loadProjects() { await waitForBearer(5000); if (!state.bearer) throw new Error("\u672a\u6355\u83b7\u5230\u767b\u5f55 Token\uff0c\u8bf7\u5237\u65b0\u9875\u9762\u540e\u518d\u8bd5"); const payload = await apiJson( YXJC_API, "/api/training-project/training-projects/progress/my?clean=0", "GET" ); let list = payload?.data; if (list && !Array.isArray(list) && Array.isArray(list.data)) list = list.data; if (!Array.isArray(list)) { if (payload?.data?.code === 200 && Array.isArray(payload.data.data)) { list = payload.data.data; } else if (Array.isArray(payload?.data?.data)) { list = payload.data.data; } else { list = []; } } if (!list.length && Array.isArray(payload?.data) === false) { try { const raw = JSON.parse(payload.text || "null"); if (Array.isArray(raw)) list = raw; } catch (_) {} } state.projects = (list || []) .map((p) => ({ id: String(p?.id || ""), name: String(p?.name || p?.id || ""), status: Number(p?.status || 0), raw: p, })) .filter((p) => p.id); syncProjectSelect(); return state.projects; } async function resolveProjectId() { let pid = detectProjectId(); if (!state.projects.length) { try { await loadProjects(); } catch (_) {} } if (!pid && state.projects.length === 1) { pid = state.projects[0].id; } if (!pid && state.projects.length > 1) { try { const cached = localStorage.getItem("qlt_last_project_id"); if (cached && state.projects.some((p) => p.id === cached)) pid = cached; } catch (_) {} } return String(pid || "").trim(); } async function loadLessons(opts) { const quiet = !!(opts && opts.quiet); await refreshUser(); const projects = await loadProjects(); const pid = await resolveProjectId(); if (!pid) { syncProjectSelect(); state.lessons = []; renderLessonList(); if (!projects.length) { throw new Error("\u672a\u627e\u5230\u7814\u4fee\u9879\u76ee\uff0c\u8bf7\u786e\u8ba4\u5df2\u62a5\u540d\u5e76\u767b\u5f55"); } throw new Error("\u8bf7\u5148\u5728\u9762\u677f\u4e2d\u9009\u62e9\u7814\u4fee\u9879\u76ee\uff08\u9996\u9875\u4e5f\u53ef\u9009\uff0c\u4e0d\u5fc5\u8fdb\u9879\u76ee\u9875\uff09"); } rememberProjectId(pid); try { const proj = await apiJson( YXJC_API, `/api/training-project/training-projects/${encodeURIComponent(pid)}`, "GET" ); const pdata = proj?.data?.data; if (pdata?.name) state.projectName = String(pdata.name); } catch (_) {} let payload = await apiJson( YXJC_API, `/api/lesson/lessons/my?projectId=${encodeURIComponent(pid)}`, "GET" ); if (Number(payload?.data?.code) !== 200) { payload = await apiJson( YXJC_API, `/api//lesson/lessons/my?projectId=${encodeURIComponent(pid)}`, "GET" ); } const list = assertOk(payload, "\u83b7\u53d6\u8bfe\u7a0b\u5217\u8868\u5931\u8d25"); state.lessons = flattenLessons(Array.isArray(list) ? list : []); renderLessonList(); updateQueueSummary(); if (!quiet) { log(`\u5df2\u52a0\u8f7d ${state.lessons.length} \u95e8\u8bfe · ${state.projectName || pid}`); } return state.lessons; } async function refreshLessonListAfterStudy() { await loadLessons({ quiet: true }); updateQueueSummary(); void refreshChapterPreview(); } async function resolveLearningId(lessonId) { let payload = await apiJson( YXJC_API, `/api/lesson/learning/url?projectId=${encodeURIComponent(state.projectId)}&lessonId=${encodeURIComponent(lessonId)}`, "GET" ); if (Number(payload?.data?.code) !== 200) { payload = await apiJson( YXJC_API, `/api//lesson/learning/url?projectId=${encodeURIComponent(state.projectId)}&lessonId=${encodeURIComponent(lessonId)}`, "GET" ); } const url = assertOk(payload, "\u83b7\u53d6\u5b66\u4e60\u5730\u5740\u5931\u8d25"); const m = String(url || "").match(/\/learning\/([A-Za-z0-9]+)/); if (!m) throw new Error("\u5b66\u4e60\u5730\u5740\u65e0\u6cd5\u89e3\u6790 learningId"); return m[1]; } async function fetchCourse(learningId) { const payload = await apiJson( PLAYER_API, `/api/learning/${encodeURIComponent(learningId)}/course`, "GET" ); return assertOk(payload, "\u83b7\u53d6\u8bfe\u4ef6\u76ee\u5f55\u5931\u8d25"); } async function fetchActivity(learningId, activityId) { const payload = await apiJson( PLAYER_API, `/api/learning/${encodeURIComponent(learningId)}/activity/${encodeURIComponent(activityId)}`, "GET" ); return assertOk(payload, "\u83b7\u53d6\u6d3b\u52a8\u8be6\u60c5\u5931\u8d25"); } async function putDuration(learningId, sessionType, activityId, actToken, seconds) { const type = String(sessionType || "video").toLowerCase(); const path = `/api/learning/${encodeURIComponent(learningId)}/duration/${encodeURIComponent(type)}/${encodeURIComponent(activityId)}/${encodeURIComponent(actToken)}/${encodeURIComponent(String(seconds))}?_ignore_error=true`; const payload = await apiJson(PLAYER_API, path, "PUT", {}); const code = Number(payload?.data?.code); if (code && code !== 200) { const err = new Error(String(payload?.data?.msg || `code_${code}`)); err.code = code; throw err; } return payload?.data?.data; } function isVideoSessionType(t) { return String(t || "").toLowerCase() === "video"; } function isExamSessionType(t) { const s = String(t || "").toLowerCase(); return s === "exam" || s === "test" || s === "question" || s === "quiz"; } function isExamSession(sess) { if (!sess) return false; if (isExamSessionType(sess.sessionType)) return true; return /\u6d4b\u8bd5\u9898|\u6d4b\u9a8c|\u8003\u8bd5/.test(String(sess.title || "")); } function isActFinishedInCourse(courseData, actId) { const id = String(actId || ""); if (!id || !courseData) return false; const finished = courseData.finishedActs || {}; if (Array.isArray(finished)) return finished.map(String).includes(id); const value = finished[id]; return value != null && value !== false && value !== 0 && value !== "0"; } function listSessions(courseData, lessonOpt) { const sections = courseData?.sections || []; const actDuration = courseData?.actDuration || {}; const previewInherit = !!lessonOpt?.previewInherit; const lessonDone = Number(lessonOpt?.status) === 1; const courseDone = Number(courseData?.status) === 1; const out = []; for (const sec of sections) { if (sec?.extendedResource) continue; for (const sess of sec?.courseSessions || []) { const id = String(sess.id || ""); if (!id) continue; const must = Number(sess.mustLearn) === 1 || !!sess.mustLearn; const ext = sess.ext || {}; const title = String(sess.title || id); const alias = String(sess.sessionAlias || ""); const tags = Array.isArray(sess.tags) ? sess.tags.map(String).join(" ") : ""; let sessionType = String(sess.sessionType || "").toLowerCase(); const looksExam = isExamSessionType(sessionType) || /\u6d4b\u8bd5\u9898|\u6d4b\u9a8c|\u8003\u8bd5/.test(title) || /\u6d4b\u8bd5|\u6d4b\u9a8c|\u8003\u8bd5/.test(alias) || /\u6d4b\u8bd5|\u6d4b\u9a8c|\u8003\u8bd5/.test(tags); if (!sessionType) sessionType = looksExam ? "exam" : "video"; else if (looksExam && !isExamSessionType(sessionType)) sessionType = "exam"; const knownTarget = Number( ext.studyTime || ext.duration || sess.studyTime || 0 ); const targetSec = Math.max( 1, knownTarget || (looksExam ? 60 : 600) ); const learnedSec = Math.max( 0, Number(actDuration[id] || sess.learnedSec || sess.duration || 0) ); const statusDone = Number(sess.status) === 1 || sess.status === "1" || Number(sess.learnStatus) === 1; const inFinished = isActFinishedInCourse(courseData, id); const inheritLessonDone = previewInherit && (lessonDone || courseDone) && must; out.push({ id, title, sessionType, mustLearn: must, finished: !!(statusDone || inFinished || inheritLessonDone), learnedSec, targetSec, knownTarget: knownTarget > 0 ? knownTarget : 0, chapterTitle: String(sec.title || ""), extended: false, raw: sess, }); } } return out; } function resolveTargetSec(activityData, fallbackType) { const act = activityData?.activity || {}; const ext = act.ext || {}; const studyTime = Number(ext.studyTime || 0); const videoDur = Math.ceil(Number(ext.duration || 0)); const current = activityData?.duration == null || activityData?.duration === "" ? 0 : Number(activityData.duration); const stype = String(act.sessionType || fallbackType || "video"); let target = studyTime > 0 ? studyTime : videoDur > 0 ? videoDur : 0; if (!target) target = isVideoSessionType(stype) ? 600 : 60; if (current > target) target = current; const actToken = String( activityData?.actToken || act.actToken || activityData?.token || act.token || "" ); return { target: Math.max(10, Math.floor(target)), current: Math.max(0, Math.floor(current)), interval: Math.max(30, Number(activityData?.updateDurationInterval || 180)), actToken, sessionType: stype, status: Number(activityData?.status || act.status || 0), }; } async function tryPutDuration(learningId, sessionType, activityId, actToken, seconds) { const types = []; const t = String(sessionType || "video").toLowerCase(); types.push(t); if (t !== "video") types.push("video"); if (t === "doc" || t === "document" || t === "pdf") { types.push("doc", "document", "file", "pdf"); } if (t === "resource") types.push("resource", "doc", "file"); const uniq = [...new Set(types)]; let lastErr = null; for (const typ of uniq) { try { return await putDuration(learningId, typ, activityId, actToken, seconds); } catch (e) { lastErr = e; if (Number(e?.code) === 20110) throw e; } } throw lastErr || new Error("duration_failed"); } function engineConfig() { const id = currentSpeedMode().id; return { speed_mode: id, mode: id, speedMode: id, }; } async function resyncSessionCtx(learningId, session, ctx) { await sleep(1200); const activity = await fetchActivity(learningId, session.id); ctx.meta = resolveTargetSec(activity, session.sessionType); const course = await fetchCourse(learningId); const finished = !!( course?.finishedActs && course.finishedActs[session.id] ); ctx.cur = ctx.meta.current; return { current_sec: ctx.meta.current, target_sec: ctx.meta.target, act_token: ctx.meta.actToken, finished: finished || ctx.meta.status === 1, }; } async function executeQltCommand(cmd, ctx) { const c = cmd || {}; const t = String(c.type || ""); const learningId = ctx.learningId; const session = ctx.session; const meta = ctx.meta || {}; if (t === "wait") { await sleep(resolveWaitMs(c.ms)); return { event: "tick", lastResult: null }; } if (t === "report_duration") { const seconds = Math.max(0, Math.floor(Number(c.seconds || 0))); const sessionType = c.session_type || meta.sessionType || session.sessionType; const activityId = c.activity_id || session.id; const actToken = c.act_token || meta.actToken; const target = Math.max(1, meta.target || 600); let afterSec = seconds; let progressSource = "submitted"; let ok = true; let timeMismatch = false; let actStatus = 0; try { const putRes = await tryPutDuration( learningId, sessionType, activityId, actToken, seconds ); if (putRes && putRes.duration != null && putRes.duration !== "" && Number.isFinite(Number(putRes.duration)) && Number(putRes.duration) >= 0) { progressSource = "response"; afterSec = Math.floor(Number(putRes.duration)); } actStatus = Number(putRes?.actStatus); } catch (e) { ok = false; if (Number(e?.code) === 20110) { const lr = await resyncSessionCtx(learningId, session, ctx); return { event: "resync_result", lastResult: lr }; } if (isTimeMismatchError(e)) { timeMismatch = true; afterSec = Math.max(ctx.cur || 0, meta.current || 0); } else if (isNeedFinishSignalError(e)) { return executeQltCommand( { type: "finish_duration", session_type: sessionType, activity_id: activityId, act_token: actToken, }, ctx ); } else { return { event: "duration_report_result", lastResult: { ok: false, seconds: afterSec, current_sec: afterSec }, }; } } if (actStatus === 1) { return executeQltCommand( { type: "finish_duration", session_type: sessionType, activity_id: activityId, act_token: actToken, }, ctx ); } ctx.cur = Math.max(ctx.cur || 0, afterSec, meta.current || 0); ctx.meta = Object.assign({}, meta, { current: ctx.cur }); const pct = Math.min(100, (ctx.cur / target) * 100); state.progressPct = pct; updateProgressUi(pct); logProgress(session.title, pct); touchPreviewSession(learningId, session.id, ctx.cur, target, false); return { event: "duration_report_result", lastResult: { ok, seconds: afterSec, current_sec: ctx.cur, after_sec: afterSec, progress_source: progressSource, time_mismatch: timeMismatch, }, }; } if (t === "finish_duration") { const sessionType = c.session_type || meta.sessionType || session.sessionType; const activityId = c.activity_id || session.id; const actToken = c.act_token || meta.actToken; const target = Math.max(1, meta.target || 600); let done = false; let actStatus = 0; try { const putRes = await tryPutDuration( learningId, sessionType, activityId, actToken, -1 ); actStatus = Number(putRes?.actStatus); done = actStatus === 1; } catch (e) { if (Number(e?.code) === 20110) { const lr = await resyncSessionCtx(learningId, session, ctx); return { event: "resync_result", lastResult: lr }; } if (isTimeMismatchError(e)) { return { event: "finish_duration_result", lastResult: { ok: false, done: false, finished: false, time_mismatch: true, }, }; } return { event: "finish_duration_result", lastResult: { ok: false, done: false, finished: false }, }; } await sleep(200); const course = await fetchCourse(learningId); const best = Math.max( ctx.cur || 0, Number((course?.actDuration || {})[session.id] || 0) ); done = done || isActFinishedInCourse(course, session.id); ctx.cur = done ? Math.max(best, target) : best; if (done) { markProgressComplete(); logSessionDoneOnce(session.title); touchPreviewSession(learningId, session.id, target, target, true); return { terminal: true, ok: true, done: true, finished: true, event: "finish_duration_result", lastResult: { ok: true, done: true, finished: true, success: true, actStatus, current_sec: ctx.cur, }, }; } return { event: "finish_duration_result", lastResult: { ok: false, done: false, finished: false, success: false, actStatus, current_sec: ctx.cur, }, }; } if (t === "resync") { const lr = await resyncSessionCtx(learningId, session, ctx); return { event: "resync_result", lastResult: lr }; } if (t === "exam_submit") { throw new Error("本地版不处理考试或自动答题"); } if (t === "done") { if (c.success && !c.skipped && session?.title) { markProgressComplete(); logSessionDoneOnce(session.title); } return { terminal: true, ok: !!c.success, skipped: !!c.skipped, done: !!c.success, msg: c.message || "", }; } if (t === "failed" || t === "quota") { return { terminal: true, ok: false, quota: t === "quota", msg: c.message || "", }; } await sleep(400); return { event: "tick", lastResult: null }; } async function studyOneSession(learningId, session, courseData) { if (isExamSession(session)) return { skipped: true, done: false }; if (isActFinishedInCourse(courseData, session.id)) return { skipped: true, done: true }; state.currentTitle = session.title; syncCurrentUi(); const activity = await fetchActivity(learningId, session.id); const meta = resolveTargetSec(activity, session.sessionType); if (!meta.actToken) meta.actToken = String(session.id); log(`开始课件:${session.title};平台进度 ${meta.current}/${meta.target} 秒`); const ctx = { learningId, session, courseData, meta, cur: meta.current }; let started = Date.now(); let initial = meta.current; const maximum = currentSpeedMode().maxRate || 1; let rate = Math.min(maximum, state.localRateCap || maximum); let slow = rate === 1; let stalls = 0; let errors = 0; let finishTries = 0; log(`调度倍率尝试:${rate} 倍;以平台实际返回的进度为准`); // 小步提交,平台拒绝后逐级降低倍率,并以已确认进度重新计时。 const reduceRate = () => { if (rate <= 1) return false; rate = Math.max(1, rate - 1); state.localRateCap = rate; slow = rate === 1; initial = ctx.cur; started = Date.now(); stalls = 0; log(`平台未接受加速提交,降至 ${rate} 倍:${session.title}`); return true; }; while (!state.stopFlag) { if (ctx.cur >= meta.target - 1) { const result = await executeQltCommand({ type: "finish_duration" }, ctx); const fresh = await fetchCourse(learningId); if (isActFinishedInCourse(fresh, session.id)) { markProgressComplete(); logSessionDoneOnce(session.title); return { done: true }; } if (++finishTries >= 3) { log(`平台尚未确认完成:${session.title},请在网页中检查`); return { done: false }; } if (result.lastResult?.time_mismatch) reduceRate(); await sleep(5000); continue; } const before = ctx.cur; const elapsed = Math.floor((Date.now() - started) / 1000); if (elapsed < 5) { await sleep(1000); continue; } const seconds = Math.min(meta.target, Math.floor(initial + elapsed * rate)); if (seconds <= before) { await sleep(1000); continue; } const result = await executeQltCommand({ type: "report_duration", seconds }, ctx); if (result.terminal && result.done) { const fresh = await fetchCourse(learningId); return { done: isActFinishedInCourse(fresh, session.id) }; } const lr = result.lastResult || {}; if (lr.time_mismatch) { if (reduceRate()) { await sleep(5000); continue; } } errors = lr.ok === false && !lr.time_mismatch ? errors + 1 : 0; stalls = ctx.cur <= before ? stalls + 1 : 0; if (stalls >= 2 && rate > 1 && reduceRate()) { await sleep(5000); continue; } if (errors >= 3 || stalls >= 12) { log(`进度未被平台接受,停止本课件:${session.title}`); return { done: false }; } if (ctx.cur > before && !lr.time_mismatch) { const realSeconds = Math.max(1, Math.floor((Date.now() - started) / 1000)); const accepted = Math.max(0, ctx.cur - initial); if (!ctx.lastRateLogAt || Date.now() - ctx.lastRateLogAt >= 60000) { const sourceText = lr.progress_source === "response" ? "接口返回时长" : "提交进度(接口未返回时长,待刷新确认)"; log(`${sourceText}:${ctx.cur}/${meta.target} 秒;本段增加 ${accepted} 秒,用时 ${realSeconds} 秒(约 ${(accepted / realSeconds).toFixed(2)} 倍)`); ctx.lastRateLogAt = Date.now(); } } await sleep(slow ? 10000 : 5000); } return { stopped: true }; } async function studyOneLesson(lesson) { state.currentLessonName = lesson.name; state.currentTitle = "-"; syncCurrentUi(); const learningId = await resolveLearningId(lesson.id); let courseData = await fetchCourse(learningId); updatePreviewFromCourse(lesson, courseData); const supported = s => !isExamSession(s) && ["video", "audio", "doc", "document", "pdf", "resource"].includes(String(s.sessionType).toLowerCase()); const pending = listSessions(courseData).filter(s => supported(s) && !isActFinishedInCourse(courseData, s.id)); for (const session of pending) { if (state.stopFlag) return { stopped: true }; try { const result = await studyOneSession(learningId, session, courseData); if (result.stopped) return result; } catch (error) { log(`课件处理失败:${session.title};${String(error.message || error).slice(0, 120)}`); } courseData = await fetchCourse(learningId); updatePreviewFromCourse(lesson, courseData); if (!state.stopFlag) await sleep(sessionGapMs()); } courseData = await fetchCourse(learningId); updatePreviewFromCourse(lesson, courseData); const sessions = listSessions(courseData); const left = sessions.filter(s => s.mustLearn && !isActFinishedInCourse(courseData, s.id)); const exams = left.filter(isExamSession); if (exams.length) log(`课程【${lesson.name}】课件已处理;还有 ${exams.length} 个测验,请自行完成`); // 空列表或未识别的课程结构不能作为完成依据。 const done = sessions.length > 0 && left.length === 0; if (done) setLessonStatusLocal(lesson.id, 1); return { done }; } async function runStudy() { if (state.running) return; state.running = true; state.stopFlag = false; state._doneLogKeys = Object.create(null); const completedIds = []; setStatus("\u8fd0\u884c\u4e2d"); syncRunButtons(); try { if (!state.lessons.length) await loadLessons(); if (!(await ensureLocalReady())) return; try { await backfillFreeConsumes(); } catch (_) {} let queue = state.lessons.filter((l) => state.selectedIds.includes(l.id)); if (!queue.length) { log("\u8bf7\u5148\u52fe\u9009\u8bfe\u7a0b"); return; } state.localRateCap = currentSpeedMode().maxRate || 1; log(`本轮开始:${queue.length} 门课程;${currentSpeedMode().label}。运行期间请勿更改勾选。`); state.queueTotal = queue.length; state.queueDone = 0; updateQueueSummary(); for (const lesson of queue) { if (state.stopFlag) break; if (!canStudyNextFreeLesson()) { notifyQuotaExhausted(); break; } const r = await studyOneLesson(lesson); state.queueDone += 1; updateQueueSummary(); if (r?.done) { completedIds.push(lesson.id); setLessonStatusLocal(lesson.id, 1); rememberFreeStudiedLesson(lesson.id); } if (r?.quota) { notifyQuotaExhausted(r.msg); break; } if (r?.stopped) break; } log(state.stopFlag ? "\u5df2\u505c\u6b62" : "\u961f\u5217\u7ed3\u675f"); await refreshLessonListAfterStudy(completedIds); } catch (e) { log(`\u8fd0\u884c\u51fa\u9519\uff1a${String(e?.message || e).slice(0, 140)}`); console.debug("[\u9f50\u9c81\u7814\u4fee] run", e); try { await refreshLessonListAfterStudy(completedIds); } catch (_) {} } finally { state.running = false; setStatus("\u5df2\u505c\u6b62"); syncRunButtons(); } } function stopStudy() { state.stopFlag = true; setStatus("\u6b63\u5728\u505c\u6b62…"); syncRunButtons(); } function updatePreviewFromCourse(lesson, courseData) { const lid = String(lesson?.id || ""); if (!lid) return; const sessions = listSessions(courseData || {}, { status: lesson?.status, previewInherit: true, }); state.previewByLesson[lid] = { name: String(lesson.name || lid), sessions: sessions.map((s) => { const target = Math.max(1, s.targetSec || 600); let tag = "\u672a\u5b66"; if (s.finished) tag = "\u5df2\u5b8c\u6210"; else if (s.learnedSec > 0) { tag = `${Math.min(99, Math.floor((s.learnedSec / target) * 100))}%`; } return { id: s.id, title: s.title, finished: !!s.finished, tag, chapterTitle: s.chapterTitle, }; }), }; renderChapterPreview(); } function touchPreviewSession(learningId, actId, cur, target, finished) { for (const lid of Object.keys(state.previewByLesson)) { const row = state.previewByLesson[lid]; if (!row?.sessions) continue; for (const s of row.sessions) { if (String(s.id) === String(actId)) { if (finished) { s.finished = true; s.tag = "\u5df2\u5b8c\u6210"; } else { const pct = Math.min(99, Math.floor((cur / Math.max(target, 1)) * 100)); s.tag = `${pct}%`; } } } } renderChapterPreview(); } function renderChapterPreview() { const box = document.getElementById("qlt-chapter-preview"); if (!box) return; const ids = state.selectedIds; if (!ids.length) { box.innerHTML = `
\u8bf7\u52fe\u9009\u8bfe\u7a0b
`; return; } const parts = []; for (const id of ids) { const les = state.lessons.find((l) => String(l.id) === String(id)); const prev = state.previewByLesson[id]; const title = prev?.name || les?.name || id; parts.push( `
${escapeHtml( title )}
` ); if (!prev || !prev.sessions?.length) { parts.push(`
\u52a0\u8f7d\u4e2d…
`); } else { for (const s of prev.sessions) { const tag = s.finished || s.tag === "\u5df2\u5b8c\u6210" ? "\u5df2\u5b8c\u6210" : s.tag || "\u672a\u5b66"; parts.push( `
${escapeHtml( s.title )}${escapeHtml(tag)}
` ); } } parts.push(`
`); } box.innerHTML = parts.join(""); } async function refreshChapterPreview() { const seq = ++state._previewLoadSeq; try { await waitForBearer(4000); } catch (_) {} for (const id of state.selectedIds) { if (seq !== state._previewLoadSeq) break; const les = state.lessons.find((l) => String(l.id) === String(id)); if (!les) continue; try { if (!state.bearer) { if (Number(les.status) === 1) { state.previewByLesson[id] = { name: String(les.name || id), sessions: [ { id: "_lesson_done", title: les.name || "\u672c\u8bfe", finished: true, tag: "\u5df2\u5b8c\u6210", }, ], }; } continue; } const learningId = await resolveLearningId(les.id); const course = await fetchCourse(learningId); if (seq !== state._previewLoadSeq) break; updatePreviewFromCourse(les, course); } catch (e) { console.debug("[\u9f50\u9c81\u7814\u4fee] preview", id, e); if (Number(les.status) === 1) { state.previewByLesson[id] = { name: String(les.name || id), sessions: [ { id: "_lesson_done", title: les.name || "\u672c\u8bfe", finished: true, tag: "\u5df2\u5b8c\u6210", }, ], }; } } } renderChapterPreview(); } function setStatus(text) { const el = document.getElementById("qlt-auto-status"); if (el) el.textContent = text; } function updateProgressUi(pct) { state.progressPct = Math.max(0, Math.min(100, Number(pct) || 0)); const num = document.getElementById("qlt-progress-num"); if (num) num.innerHTML = `${Math.floor(state.progressPct)}%`; } function syncCurrentUi() { const a = document.getElementById("qlt-cur-course"); const b = document.getElementById("qlt-cur-doc"); if (a) a.textContent = state.currentLessonName || "-"; if (b) b.textContent = state.currentTitle || "-"; } function updateUserUI() {} function syncRunButtons() { setStatus(state.running ? (state.stopFlag ? "正在停止…" : "运行中") : "已停止"); for (const selector of ["#qlt-project-select", "#qlt-btn-refresh", "#qlt-btn-select-pending", "#qlt-speed", "#qlt-course-list input[type=checkbox]"]) { document.querySelectorAll(selector).forEach(el => { el.disabled = state.running; }); } syncCurrentUi(); updateProgressUi(state.progressPct); const start = document.getElementById("qlt-btn-start"); const stop = document.getElementById("qlt-btn-stop"); if (start) { start.disabled = state.running; start.classList.toggle("qlt-btn-off", state.running); } if (stop) { stop.disabled = !state.running; stop.classList.toggle("qlt-btn-off", !state.running); } } function switchPanelTab(name) { document.querySelectorAll(".qlt-tab-btn").forEach((el) => { el.classList.toggle("active", el.dataset.tab === name); }); document.querySelectorAll(".qlt-pane").forEach((el) => { el.classList.toggle("active", el.dataset.pane === name); }); if (name === "chapter") void refreshChapterPreview(); } function updateQueueSummary() { const q = state.selectedIds.length; const doneEl = document.getElementById("qlt-queue-done"); const totalEl = document.getElementById("qlt-queue-total"); const textEl = document.getElementById("qlt-queue-text"); const barEl = document.getElementById("qlt-queue-progress"); const pctEl = document.getElementById("qlt-queue-percent"); const done = state.queueDone; const total = state.running ? state.queueTotal : q; if (doneEl) doneEl.textContent = String(done); if (totalEl) totalEl.textContent = String(total); const pct = total > 0 ? Math.round((done / total) * 100) : 0; if (pctEl) pctEl.textContent = `${pct}%`; if (barEl) barEl.style.width = `${pct}%`; if (textEl) { textEl.textContent = q ? `\u5df2\u9009 ${q} \u95e8 · \u961f\u5217 ${done}/${total || q}` : "\u672a\u9009\u62e9"; } const sel = document.getElementById("qlt-sel-count"); if (sel) sel.textContent = String(q); } function renderLessonList() { const box = document.getElementById("qlt-course-list"); if (!box) return; box.innerHTML = ""; if (!state.lessons.length) { box.innerHTML = `
\u6682\u65e0\u8bfe\u7a0b\uff0c\u8bf7\u9009\u62e9\u9879\u76ee\u540e\u70b9\u5237\u65b0
`; return; } const selected = new Set(state.selectedIds); for (const les of state.lessons) { const row = document.createElement("label"); row.className = "qlt-course-item"; const checked = selected.has(les.id) ? "checked" : ""; const tag = les.mustLearn ? "\u5fc5\u4fee" : "\u9009\u4fee"; const st = formatLessonStatus(les.status); row.innerHTML = `
${escapeHtml(les.name)}
${escapeHtml(les.topicName)} · ${tag} · ${escapeHtml(st)} · ${les.learnHour || 0}\u5b66\u65f6
`; const input = row.querySelector("input"); input.addEventListener("change", () => { const ids = new Set(state.selectedIds); if (input.checked) ids.add(les.id); else ids.delete(les.id); saveQueue([...ids]); updateQueueSummary(); renderChapterPreview(); void refreshChapterPreview(); }); box.appendChild(row); } } function formatLessonStatus(status) { const s = Number(status); if (s === 1) return "\u5df2\u5b8c\u6210"; if (s === 0) return "\u5b66\u4e60\u4e2d"; if (s === -1) return "\u672a\u5b66"; return "\u672a\u77e5"; } function isLessonFinished(les) { return Number(les?.status) === 1; } function isLessonPending(les) { return !isLessonFinished(les); } function selectByFilter(fn) { const ids = state.lessons.filter(fn).map((l) => l.id); saveQueue(ids); renderLessonList(); updateQueueSummary(); } function injectPanelStyles() { if (document.getElementById("qlt-panel-style")) return; const st = document.createElement("style"); st.id = "qlt-panel-style"; st.textContent = ` #qlt-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;max-height:min(92vh,780px);} #qlt-auto-panel.qlt-min #qlt-panel-body,#qlt-auto-panel.qlt-min #qlt-panel-footer,#qlt-auto-panel.qlt-min .qlt-footer-extra{display:none !important;} #qlt-panel-header{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;} #qlt-panel-brand{display:flex;align-items:center;gap:9px;min-width:0;flex:1;} #qlt-panel-logo{width:30px;height:30px;border-radius:9px;object-fit:cover;border:1px solid rgba(148,163,184,.45);background:#fff;} #qlt-panel-title{font-size:13px;font-weight:900;color:#9a3412;line-height:1.26;display:flex;align-items:center;gap:5px;flex-wrap:wrap;} .qlt-panel-version{font-size:11px;font-weight:900;color:#64748b;padding:2px 7px;border-radius:999px;background:#f1f5f9;border:1px solid #e2e8f0;} #qlt-panel-sub{display:flex;flex-wrap:wrap;gap:4px;margin-top:3px;} .qlt-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;} .qlt-sub-chip-em{color:#9a3412;border-color:rgba(154,52,18,.28);background:rgba(255,247,237,.9);} .qlt-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-weight:900;} #qlt-panel-body{flex:1;overflow-y:auto;padding:8px;min-height:0;} .qlt-card{background:#fff;border:1px solid #d9e2ee;border-radius:12px;padding:7px 9px;margin-bottom:7px;} .qlt-status-row{display:flex;justify-content:space-between;align-items:center;gap:7px;} .qlt-status-label{font-size:11px;color:#64748b;font-weight:700;} #qlt-auto-status{padding:2px 7px;border-radius:999px;font-weight:900;font-size:11px;background:#fff;border:1px solid #cbd5e1;} .qlt-status-metrics{display:flex;align-items:center;gap:5px;font-size:12px;color:#475569;flex:1;margin-top:4px;} .qlt-status-metrics em{font-style:normal;font-weight:900;color:#0f172a;} .qlt-progress-pct{font-size:14px;font-weight:900;color:#0369a1;} .qlt-progress-bar{height:5px;border-radius:999px;background:#e2e8f0;overflow:hidden;margin-top:4px;} .qlt-progress-bar>span{display:block;height:100%;width:0;background:linear-gradient(90deg,#22d3ee,#2563eb);transition:width .2s;} .qlt-tabbar{display:flex;gap:6px;margin-bottom:7px;} .qlt-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;} .qlt-tab-btn.active{background:linear-gradient(135deg,#1d4ed8,#0ea5e9);color:#fff;border-color:transparent;} .qlt-pane{display:none;}.qlt-pane.active{display:block;} .qlt-list-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;gap:6px;} .qlt-list-head-actions{display:flex;align-items:center;gap:5px;flex-shrink:0;} .qlt-list-title{font-size:12px;color:#64748b;font-weight:700;} .qlt-list-tag{font-size:11px;color:#92400e;background:#ffedd5;border:1px solid #fdba74;border-radius:999px;padding:2px 7px;} .qlt-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;} #qlt-course-list,#qlt-chapter-preview,#qlt-run-log{max-height:188px;overflow-y:auto;background:#f8fafc;border:1px solid #dbe4f0;border-radius:11px;padding:5px;} .qlt-course-item{display:flex;gap:7px;align-items:flex-start;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:7px;margin-bottom:6px;cursor:pointer;} .qlt-course-name{display:block;font-size:12px;color:#0f172a;line-height:1.38;} .qlt-course-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px;} .qlt-course-tag{font-style:normal;font-size:10px;font-weight:800;padding:1px 6px;border-radius:999px;border:1px solid transparent;line-height:1.5;} .qlt-tag-required{color:#9a3412;background:#ffedd5;border-color:#fdba74;} .qlt-tag-elective{color:#1d4ed8;background:#dbeafe;border-color:#93c5fd;} .qlt-tag-other{color:#334155;background:#e2e8f0;border-color:#cbd5e1;} .qlt-tag-period{color:#065f46;background:#d1fae5;border-color:#6ee7b7;} .qlt-tag-done{color:#64748b;background:#f1f5f9;border-color:#cbd5e1;} .qlt-course-item.qlt-course-done{opacity:.72;cursor:default;background:#f8fafc;} .qlt-course-item input:disabled{cursor:not-allowed;} .qlt-course-meta{display:block;font-size:11px;color:#64748b;margin-top:2px;} .qlt-chapter-course{margin-bottom:7px;border:1px solid #dbe4f0;border-radius:9px;background:#fff;} .qlt-chapter-title{padding:5px 8px;background:#eaf1ff;border-bottom:1px solid #dbe4f0;color:#1d4ed8;font-size:11px;font-weight:700;} .qlt-chapter-item{padding:4px 8px;font-size:11px;line-height:1.35;display:flex;justify-content:space-between;gap:6px;} .qlt-chapter-item span:first-child{flex:1;min-width:0;word-break:break-all;} .qlt-chapter-item em{font-style:normal;color:#0369a1;font-weight:800;font-size:10px;flex-shrink:0;} .qlt-log-row{padding:4px 6px;border-bottom:1px dashed #d4deea;font-size:12px;line-height:1.42;} .qlt-empty-state{padding:12px 7px;text-align:center;color:#94a3b8;font-size:12px;font-weight:900;} .qlt-meta-row{display:flex;justify-content:space-between;gap:7px;font-size:12px;margin-bottom:5px;align-items:center;} .qlt-meta-label{color:#64748b;}.qlt-meta-value{font-weight:700;text-align:right;word-break:break-all;} .qlt-auth-badge{padding:2px 7px;border-radius:999px;border:1px solid #cbd5e1;font-size:11px;font-weight:900;} .qlt-btn-row{display:flex;gap:7px;} .qlt-btn{flex:1;border:none;color:#fff;padding:7px 9px;border-radius:10px;cursor:pointer;font-weight:800;font-size:12px;} .qlt-btn-start{background:#16a34a;}.qlt-btn-stop{background:#ef4444;}.qlt-btn-ghost{border:1px solid #cbd5e1;background:#fff;color:#0f172a;flex:0 0 auto;} .qlt-btn-off,.qlt-btn:disabled{background:#cbd5e1 !important;color:#64748b !important;cursor:not-allowed;} .qlt-btn-pro{flex:0 0 auto;background:linear-gradient(135deg,#f59e0b,#ef4444);color:#fff;border:none;padding:4px 8px;font-size:12px;border-radius:10px;cursor:pointer;font-weight:800;} .qlt-hidden{display:none !important;} .qlt-footer-extra{padding:6px 9px;background:#f8fafc;border-top:1px solid #e2e8f0;} .qlt-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;} .qlt-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;} .qlt-qq-row{display:flex;align-items:center;justify-content:space-between;gap:9px;margin-top:5px;} .qlt-qq-text{font-size:12px;color:#475569;} .qlt-qq-title{font-size:13px;font-weight:800;color:#0f172a;} .qlt-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;} #qlt-panel-footer{padding:7px 11px;background:#eef2f7;border-top:1px solid #dbe4f0;font-size:12px;color:#64748b;} #qlt-pro-modal{position:fixed;inset:0;background:rgba(15,23,42,.45);z-index:1000001;display:none;align-items:center;justify-content:center;padding:20px;} .qlt-pro-card{width:min(560px,92vw);max-height:88vh;overflow:auto;background:#fff;border-radius:18px;border:1px solid #dbe4f0;box-shadow:0 18px 46px rgba(15,23,42,.25);padding:16px;} .qlt-pro-title{font-size:24px;font-weight:900;}.qlt-pro-sub{font-size:13px;color:#64748b;margin-top:4px;} .qlt-pro-sec{margin-top:12px;border:1px solid #dbe4f0;border-radius:12px;padding:12px;background:#f8fafc;} .qlt-pro-sec h4{margin:0 0 8px;font-size:15px;}.qlt-pro-sec p,.qlt-pro-sec li{font-size:13px;color:#334155;line-height:1.5;} .qlt-pro-buy{display:flex;flex-direction:column;gap:10px;margin-top:8px;} .qlt-pro-muted{color:#64748b;font-size:12px;} .qlt-pro-buy-btn{background:linear-gradient(135deg,#1d4ed8,#0ea5e9);color:#fff;padding:10px 16px;border-radius:12px;font-size:14px;font-weight:800;border:none;cursor:pointer;align-self:flex-start;} .qlt-pro-actions{margin-top:14px;display:flex;justify-content:flex-end;} .qlt-pro-close{border:none;background:linear-gradient(135deg,#1d4ed8,#0ea5e9);color:#fff;padding:10px 18px;border-radius:12px;font-size:14px;font-weight:800;cursor:pointer;} .qlt-progress-num{font-size:18px;font-weight:900;} .qlt-progress-num small{font-size:12px;color:#64748b;} select.qlt-input{width:100%;box-sizing:border-box;border:1px solid #cbd5e1;border-radius:8px;padding:4px 7px;font-size:12px;background:#fff;}`; (document.head || document.documentElement).appendChild(st); } function enableDrag(panel) { const header = panel.querySelector("#qlt-panel-header"); if (!header) return; let sx = 0; let sy = 0; let ox = 0; let oy = 0; let dragging = false; header.addEventListener("mousedown", (e) => { if (e.target.closest("button")) return; dragging = true; sx = e.clientX; sy = e.clientY; const rect = panel.getBoundingClientRect(); ox = rect.left; oy = rect.top; e.preventDefault(); }); window.addEventListener("mousemove", (e) => { if (!dragging) return; const nx = Math.max(0, ox + e.clientX - sx); const ny = Math.max(0, oy + e.clientY - sy); panel.style.left = nx + "px"; panel.style.top = ny + "px"; panel.style.right = "auto"; }); window.addEventListener("mouseup", () => { if (!dragging) return; dragging = false; try { localStorage.setItem( PANEL_POS_KEY, JSON.stringify({ left: panel.style.left, top: panel.style.top }) ); } catch (_) {} }); try { const pos = JSON.parse(localStorage.getItem(PANEL_POS_KEY) || "null"); if (pos?.left && pos?.top) { panel.style.left = pos.left; panel.style.top = pos.top; panel.style.right = "auto"; } } catch (_) {} } function createPanel() { if (document.getElementById("qlt-auto-panel")) return; injectPanelStyles(); const panel = document.createElement("div"); panel.id = "qlt-auto-panel"; panel.innerHTML = `
${PRODUCT_NAME} v${escapeHtml(SCRIPT_VERSION)}
\u52fe\u9009\u8bfe\u7a0b\u5b66\u4e60 \u5b66\u5b8c\u81ea\u52a8\u7ee7\u7eed \u7701\u65f6\u7701\u5fc3
\u8fd0\u884c\u72b6\u6001 \u5df2\u505c\u6b62
0/0 \u8bfe\u7a0b\u5df2\u5b66\u4e60
0%
\u8bfe\u7a0b\u9009\u62e9
\u767b\u5f55\u540e\u5c06\u81ea\u52a8\u52a0\u8f7d\u8bfe\u7a0b
\u7ae0\u8282\u9884\u89c8 \u5b9e\u65f6\u8fdb\u5ea6
\u8bf7\u52fe\u9009\u8bfe\u7a0b
\u8fd0\u884c\u65e5\u5fd7
本地调度实验版

无需作者会员或云端 Token。可选 2 至 8 倍起始倍率;平台拒绝时按 8→7→6→5→4→3→2→1 逐级降速。降速后本轮后续课件沿用较低倍率;测验请在左下角使用本地题库助手填入,并核对后提交。

进度以平台返回的完成状态为准。失败详情请查看日志。

\u5f53\u524d\u8bfe\u7a0b-
\u5f53\u524d\u8bfe\u4ef6-
\u8bfe\u4ef6\u8fdb\u5ea60%
\u5b66\u4e60\u6a21\u5f0f
`; document.documentElement.appendChild(panel); enableDrag(panel); if (localStorage.getItem(PANEL_COLLAPSED_KEY) === "1") { panel.classList.add("qlt-min"); } panel.querySelector("#qlt-btn-min")?.addEventListener("click", () => { panel.classList.toggle("qlt-min"); localStorage.setItem( PANEL_COLLAPSED_KEY, panel.classList.contains("qlt-min") ? "1" : "0" ); }); panel.querySelectorAll(".qlt-tab-btn").forEach((btn) => { btn.addEventListener("click", () => switchPanelTab(btn.dataset.tab)); }); panel.querySelector("#qlt-project-select")?.addEventListener("change", async (e) => { const pid = String(e.target.value || "").trim(); if (!pid) return; rememberProjectId(pid); try { setStatus("\u5237\u65b0\u4e2d"); await loadLessons(); setStatus("\u5df2\u505c\u6b62"); } catch (err) { log(String(err?.message || err)); setStatus("\u5df2\u505c\u6b62"); } }); panel.querySelector("#qlt-btn-refresh")?.addEventListener("click", async () => { try { setStatus("\u5237\u65b0\u4e2d"); await loadLessons(); setStatus("\u5df2\u505c\u6b62"); void refreshChapterPreview(); } catch (e) { log(String(e?.message || e)); setStatus("\u5df2\u505c\u6b62"); } }); panel.querySelector("#qlt-btn-select-pending")?.addEventListener("click", () => { selectByFilter(isLessonPending); }); panel.querySelector("#qlt-btn-start")?.addEventListener("click", () => runStudy()); panel.querySelector("#qlt-btn-stop")?.addEventListener("click", () => stopStudy()); panel.querySelector("#qlt-clear-log")?.addEventListener("click", () => clearRunLog()); panel.querySelector("#qlt-speed")?.addEventListener("change", (e) => { state.speedMode = Object.hasOwn(SPEED_MODES, e.target.value) ? e.target.value : "eff"; localStorage.setItem(SPEED_KEY, state.speedMode); }); const logEl = panel.querySelector("#qlt-run-log"); if (logEl) { logEl.innerHTML = state.logs .slice(0, 30) .map((s) => `
${escapeHtml(s)}
`) .join(""); } updateQueueSummary(); syncRunButtons(); } function isPlayerHost(host) { const h = String(host || location.hostname || "").toLowerCase(); return h === "player.qlteacher.com" || h.endsWith(".player.qlteacher.com"); } function showPlayerPageTip() { if (state.playerTipDismissed || document.getElementById("qlt-player-tip")) return; const tip = document.createElement("div"); tip.id = "qlt-player-tip"; tip.innerHTML = `
${escapeHtml(PRODUCT_NAME)}
\u5f53\u524d\u662f\u64ad\u653e\u5668\u9875\u9762\uff0c\u8bf7\u5230\u8bfe\u7a0b\u76ee\u5f55\u9875\u8fd0\u884c\u52a9\u624b\uff08\u9996\u9875 / \u7814\u4fee\u7ad9\u9009\u8bfe\u540e\u518d\u5f00\u59cb\uff09\u3002
\u6253\u5f00\u9996\u9875 \u6253\u5f00\u7814\u4fee\u7ad9
`; const st = document.createElement("style"); st.textContent = ` #qlt-player-tip{position:fixed;right:18px;bottom:18px;z-index:999999;max-width:360px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;} .qlt-player-tip-card{background:#fff;border:1px solid #dbe4f0;border-radius:14px;box-shadow:0 14px 36px rgba(15,23,42,.18);padding:12px 14px;color:#0f172a;} .qlt-player-tip-title{font-size:13px;font-weight:900;color:#9a3412;margin-bottom:6px;} .qlt-player-tip-text{font-size:12px;line-height:1.5;color:#334155;} .qlt-player-tip-actions{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px;} .qlt-player-tip-btn{display:inline-block;text-decoration:none;background:linear-gradient(135deg,#1d4ed8,#0ea5e9);color:#fff;padding:5px 10px;border-radius:999px;font-size:12px;font-weight:800;} .qlt-player-tip-btn2{background:linear-gradient(135deg,#059669,#10b981);} .qlt-player-tip-close{border:1px solid #cbd5e1;background:#f8fafc;color:#475569;padding:5px 10px;border-radius:999px;font-size:12px;font-weight:700;cursor:pointer;} `; (document.head || document.documentElement).appendChild(st); document.documentElement.appendChild(tip); tip.querySelector("#qlt-player-tip-close")?.addEventListener("click", () => { state.playerTipDismissed = true; tip.remove(); }); } async function boot() { const host = String(location.hostname || ""); if (!/\.?qlteacher\.com$/i.test(host) && host !== "qlteacher.com") return; if (isPlayerHost(host)) { const show = () => { if (!document.documentElement) return false; showPlayerPageTip(); return true; }; if (!show()) { const t = setInterval(() => { if (show()) clearInterval(t); }, 300); } setInterval(show, 2000); return; } const mount = () => { if (!document.documentElement) return false; createPanel(); return true; }; if (!mount()) { const t = setInterval(() => { if (mount()) clearInterval(t); }, 300); } setInterval(mount, 2000); setTimeout(async () => { try { await waitForBearer(10000); if (state.bearer) { try { await refreshUser(); } catch (_) {} } try { await loadProjects(); const pid = await resolveProjectId(); if (pid) { rememberProjectId(pid); await loadLessons(); } else if (state.projects.length) { log(`\u5df2\u627e\u5230 ${state.projects.length} \u4e2a\u9879\u76ee\uff0c\u8bf7\u5728\u9762\u677f\u4e0b\u62c9\u6846\u4e2d\u9009\u62e9`); } } catch (e) { log(String(e?.message || e).slice(0, 120)); } } catch (_) {} }, 800); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", boot); } else { boot(); } })(); /* 本地题库助手:按题干与选项文字匹配,由用户点击填入,提交使用网站原按钮。 */ (function () { "use strict"; const BANK = [ {id: "mud", tokens: ["在主题活动泥土中", "主要目标之一"], kind: "single", answers: ["让学生通过动手实验和艺术创作,理解泥土的多样性和用途"], basis: "根据题意推断"}, {id: "electricity", tokens: ["玩玩小电珠", "不是该活动的有效教学策略"], kind: "single", answers: ["教学活动中完全不提及电的安全注意事项"], basis: "根据题意推断"}, {id: "great-wall", tokens: ["长城", "如何有效评估学生对长城历史和文化的理解"], kind: "single", answers: ["观察学生在课堂讨论中的参与度和表达的观点,结合书面作业进行分析"], basis: "根据题意推断"}, {id: "report-harm", tokens: ["未成年人保护法", "发现学生疑似遭受侵害或者面临其他危险情形时"], kind: "single", answers: ["立即向公安、民政、教育等有关部门报告,并采取必要保护措施"], basis: "未成年人保护法第十一条"}, {id: "bullying", tokens: ["未成年人保护法", "学校处理学生欺凌行为时"], kind: "single", answers: ["学校应当立即制止,通知相关学生监护人参与认定和处理;严重欺凌应及时报告"], basis: "未成年人保护法第三十九条"}, {id: "absence", tokens: ["预防未成年人犯罪法", "下列哪一项属于不良行为"], kind: "single", answers: ["多次旷课、逃学"], basis: "预防未成年人犯罪法第二十八条"}, {id: "education-measures", tokens: ["预防未成年人犯罪法", "学校对有不良行为的未成年学生可以采取的管理教育措施是"], kind: "single", answers: ["要求参加特定专题教育、校内服务活动,或接受心理辅导和行为干预"], basis: "预防未成年人犯罪法第三十一条"}, {id: "fish-is-fish", tokens: ["鱼就是鱼", "这个小故事对我们阅读的启示是"], kind: "multiple", answers: ["阅读是我们借助他人的眼睛看世界", "阅读是对我们原有认知图式的挑战与拓展", "阅读的最终目的是在广阔视野中更好地认识自己"], basis: "根据题意推断,需结合课程核对"}, {id: "reading-week", tokens: ["国函2025101号", "全民阅读活动周自", "年起正式设立"], kind: "single", answers: ["2026年"], basis: "国务院国函〔2025〕101号"}, {id: "reading-connections", tokens: ["深度阅读中的关联主要在", "层面展开"], kind: "multiple", answers: ["文本内部的贯通", "文本之间的互文", "文本与自我的联结", "文本与世界的对接"], basis: "根据题意推断,需结合课程核对"}, {id: "read-create", tokens: ["文中提到的读创结合理念", "在中学阶段的实施重点是"], kind: "single", answers: ["开展整本书阅读,撰写人物小传,组织辩论,培养批判性思维"], basis: "根据题意推断,需结合课程核对"}, ]; function normalize(value) { return String(value || "").normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{N}]/gu, ""); } function optionKey(value) { return normalize(String(value || "").trim().replace(/^[A-H](?:[\s.、::.]+|(?=[\u3400-\u9fff\d]))/i, "")); } function matchQuestion(text) { const normalized = normalize(text); const hits = BANK.filter(row => row.tokens.every(token => normalized.includes(normalize(token)))); return hits.length === 1 ? hits[0] : null; } function planAnswers(entry, options, type) { if (!entry || (entry.kind === "multiple" ? type !== "checkbox" : type !== "radio")) return null; const indexes = entry.answers.map(answer => { const hits = options.flatMap((option, index) => optionKey(option) === optionKey(answer) ? [index] : []); return hits.length === 1 ? hits[0] : -1; }); return indexes.includes(-1) || new Set(indexes).size !== indexes.length ? null : indexes; } function optionText(input, container) { let element = input.closest("label") || input.parentElement; while (element && element !== container) { const text = element.innerText || element.textContent || ""; if (element.querySelectorAll('input[type="radio"],input[type="checkbox"]').length === 1 && normalize(text).length > 1) return text.trim(); element = element.parentElement; } return ""; } function findQuestions(doc) { const found = new Map(); for (const input of doc.querySelectorAll('input[type="radio"],input[type="checkbox"]')) { if (input.closest("#qlt-auto-panel,#qlt-local-quiz-helper")) continue; let container = input.parentElement; while (container && container !== doc.body && container !== doc.documentElement) { const inputs = [...container.querySelectorAll('input[type="radio"],input[type="checkbox"]')]; if (inputs.length >= 4) { const entry = matchQuestion(container.innerText || container.textContent); if (entry) { found.set(container, {container, entry, inputs}); break; } } container = container.parentElement; } } // 父级容器包含已识别的子题目时,保留较小的题目容器。 const rows = [...found.values()]; return rows.filter(row => !rows.some(other => other !== row && row.container.contains(other.container))); } function fill(doc) { const rows = findQuestions(doc); let filled = 0; const messages = []; for (const {container, entry, inputs} of rows) { const options = inputs.map(input => optionText(input, container)); const type = inputs[0]?.type; const indexes = inputs.every(input => input.type === type) ? planAnswers(entry, options, type) : null; if (!indexes || inputs.some(input => input.disabled)) { messages.push(`${entry.id}:选项或题型未完整匹配,跳过`); continue; } // 所有答案与选项完成匹配后才更改本题;多选题同时清除不在答案内的选项。 inputs.forEach((input, index) => { const wanted = indexes.includes(index); if (input.type === "checkbox" ? input.checked !== wanted : wanted && !input.checked) input.click(); }); const success = inputs.every((input, index) => input.checked === indexes.includes(index)); if (success) filled++; messages.push(`${entry.id}:${success ? "已填入" : "勾选未生效,请手动处理"}(${entry.basis})`); } return {filled, matched: rows.length, messages}; } function mount() { if (!document.body || document.getElementById("qlt-local-quiz-helper")) return; const panel = document.createElement("div"); panel.id = "qlt-local-quiz-helper"; panel.style.cssText = "position:fixed;left:16px;bottom:16px;z-index:2147483646;background:white;color:#172554;border:1px solid #93c5fd;border-radius:12px;padding:12px;width:290px;font:13px/1.5 sans-serif;box-shadow:0 4px 18px #0002"; const title = document.createElement("strong"); title.textContent = `本地测验题库(${BANK.length} 题)`; const status = document.createElement("div"); status.textContent = "打开测验后点击填入。课程题含推断答案,请核对后使用网站的提交按钮。"; const button = document.createElement("button"); button.type = "button"; button.textContent = "匹配题库并填入答案"; button.style.cssText = "margin-top:8px;background:#2563eb;color:white;border:0;border-radius:6px;padding:8px;cursor:pointer"; button.addEventListener("click", () => { try { const result = fill(document); status.textContent = result.matched ? `识别 ${result.matched} 题,填入 ${result.filled} 题。请核对后提交。` : "未匹配到题目。可能题库未收录、选项不是标准控件,或测验在另一框架内。"; panel.querySelector("details")?.remove(); if (result.messages.length) { const details = document.createElement("details"); const summary = document.createElement("summary"); summary.textContent = "匹配详情"; const text = document.createElement("div"); text.textContent = result.messages.join(";"); details.append(summary, text); panel.appendChild(details); } } catch (error) { status.textContent = `识别失败:${String(error.message || error)}`; } }); panel.append(title, status, button); document.body.appendChild(panel); } if (typeof module === "object" && module.exports) { module.exports = {BANK, normalize, optionKey, matchQuestion, planAnswers, findQuestions, fill}; } else { if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", mount, {once: true}); else mount(); setInterval(mount, 2000); } })();