// ==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 = `
\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
`;
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);
}
})();