// ==UserScript==
// @name GXELA (广西干部网络学院)自动选课 + 自动学习
// @namespace http://tampermonkey.net/
// @version 3.600
// @description GXELA 自动学习助手|个人自用,为爱发电,完全免费,无付费、无会员、无引流
// @author underdestiny
// @match https://www.gxela.gov.cn/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @license 仅供个人学习使用,禁止未经许可的商业化分发
// ==/UserScript==
(function () {
'use strict';
/********************************************************************
* GXELA 自动选课 + 自动学习
* Version: V3.6
********************************************************************/
/* ================================================================
* 配置
* ================================================================ */
const FALLBACK_CLIENT_ID = 'e5cd7e4891bf95d1d19206ce24a7b32e';
const PAGE_SIZE = 100;
const SELECT_DELAY_MS = 600;
const CANCEL_DELAY_MS = 400;
const SELECT_MAX_RETRIES = 2;
const CANCEL_MAX_RETRIES = 2;
const LOG_MAX_LINES = 1500;
// video 查找
const VIDEO_DETECT_RETRY_MS = 5000;
const VIDEO_DETECT_MAX_RETRIES = 12;
// 卡顿检测
const VIDEO_STUCK_CHECK_MS = 15000;
const VIDEO_STUCK_THRESHOLD = 3;
// 页面重载次数
const VIDEO_MAX_RELOADS = 2;
// 自动播放重试
const PLAY_RETRY_INTERVAL = 2500;
const PLAY_MAX_RETRIES = 8;
// 失焦恢复
const BLUR_RECOVER_DELAY = 800;
// 页面点击消除弹窗
const DIALOG_DISMISS_DELAY = 800;
/* ================================================================
* 持久化
* ================================================================ */
function saveState(key, value) {
try {
GM_setValue(key, value);
} catch (e) {
console.warn('[GXELA] saveState error:', e);
}
}
function loadState(key, def) {
try {
const value = GM_getValue(key);
return value === undefined ? def : value;
} catch (e) {
return def;
}
}
/* ================================================================
* 运行状态
* ================================================================ */
let logs = loadState('gxela_logs', []);
let logFilter = loadState('gxela_logFilter', 'all');
let targetHours = loadState(
'gxela_targets',
{
compulsory: 0,
elective: 0
}
);
let pendingIdsSet = new Set(
loadState('gxela_pendingIds', [])
);
let clientIdCurrent =
loadState('gxela_clientid', null);
let clientIdSource =
loadState('gxela_clientid_source', null);
let currentYear =
loadState('gxela_year', new Date().getFullYear());
let videoPlayRetryCount = 0;
let videoMonitorStarted = false;
let currentVideo = null;
let pageClickTimer = null;
let classSyncState = loadState('gxela_classSyncState', {
active: false,
phase: null,
pending: [],
failed: [],
current: null,
results: []
});
/* ================================================================
* 日志
* ================================================================ */
function nowTime() {
return new Date().toLocaleTimeString();
}
function appendLog(text) {
const line =
`[${nowTime()}] ${text}`;
logs.push(line);
if (logs.length > LOG_MAX_LINES) {
logs = logs.slice(-LOG_MAX_LINES);
}
saveState('gxela_logs', logs);
renderLogs(true);
console.log(line);
}
function renderLogs(scrollToEnd = false) {
const box = document.getElementById('gxela-logbox');
if (!box) return;
const visible = logs.filter(line => {
if (logFilter === 'warning') return /⚠|❌|失败|异常|卡顿|跳过/.test(line);
if (logFilter === 'success') return /✅|🎉|成功|完成|已恢复/.test(line);
return true;
});
box.textContent = visible.join('\n');
if (scrollToEnd) box.scrollTop = box.scrollHeight;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/* ================================================================
* Cookie Authorization
* ================================================================ */
function getAuthFromCookie() {
const match =
document.cookie.match(/GXELA-Token=([^;]+)/);
return match
? `Bearer ${match[1]}`
: '';
}
/* ================================================================
* clientid
* ================================================================ */
function saveClientId(cid, source) {
if (!cid) return;
cid = String(cid).trim();
clientIdCurrent = cid;
clientIdSource =
source || 'auto';
saveState(
'gxela_clientid',
cid
);
saveState(
'gxela_clientid_source',
clientIdSource
);
appendLog(
`clientid 已保存(来源:${clientIdSource})`
);
const input =
document.getElementById(
'gxela-clientid-input'
);
const status =
document.getElementById(
'gxela-clientid-status'
);
if (input) {
input.value = cid;
}
if (status) {
status.textContent =
`已设置 · ${cid}`;
}
updateStartButton();
}
function clearClientId() {
clientIdCurrent = null;
clientIdSource = null;
saveState(
'gxela_clientid',
null
);
saveState(
'gxela_clientid_source',
null
);
const status =
document.getElementById(
'gxela-clientid-status'
);
if (status) {
status.textContent =
'尚未设置 clientid';
}
updateStartButton();
appendLog(
'clientid 已清除'
);
}
function updateStartButton() {
const button =
document.getElementById(
'gxela-startBtn'
);
if (button) {
button.disabled =
!clientIdCurrent;
}
}
function getClientIdForHeader() {
return clientIdCurrent || null;
}
/* ================================================================
* GM 网络请求
* ================================================================ */
function gmRequestRaw(options) {
return new Promise((resolve, reject) => {
try {
options =
Object.assign({}, options);
options.headers =
Object.assign(
{},
options.headers || {}
);
const cid =
getClientIdForHeader();
if (
cid &&
!options.headers.clientid
) {
options.headers.clientid =
cid;
}
const auth =
getAuthFromCookie();
if (
auth &&
!options.headers.Authorization
) {
options.headers.Authorization =
auth;
}
GM_xmlhttpRequest(
Object.assign(
{},
options,
{
onload(res) {
resolve(res);
},
onerror(err) {
reject(err);
}
}
)
);
} catch (e) {
reject(e);
}
});
}
/* ================================================================
* clientid 页面拦截
* ================================================================ */
function injectClientIdInterceptorToPage() {
try {
const scriptContent = `
(function () {
try {
const originalFetch = window.fetch;
window.fetch = function (input, init) {
try {
let headers =
init && init.headers
? init.headers
: null;
let cid = null;
if (headers) {
if (
typeof headers.get === 'function'
) {
cid =
headers.get('clientid') ||
headers.get('ClientId');
}
else if (
Array.isArray(headers)
) {
for (
const h of headers
) {
if (
h[0] &&
h[0].toLowerCase() === 'clientid'
) {
cid = h[1];
break;
}
}
}
else if (
typeof headers === 'object'
) {
for (
const key in headers
) {
if (
key &&
key.toLowerCase() === 'clientid'
) {
cid =
headers[key];
break;
}
}
}
}
if (cid) {
window.postMessage(
{
type:
'GXELA_CLIENTID',
clientid:
cid
},
'*'
);
}
} catch (e) {}
return originalFetch.apply(
this,
arguments
);
};
const originalSetHeader =
XMLHttpRequest
.prototype
.setRequestHeader;
XMLHttpRequest
.prototype
.setRequestHeader =
function (
header,
value
) {
try {
if (
header &&
header.toLowerCase() ===
'clientid'
) {
window.postMessage(
{
type:
'GXELA_CLIENTID',
clientid:
value
},
'*'
);
}
} catch (e) {}
return originalSetHeader.apply(
this,
arguments
);
};
try {
if (window.clientid) {
window.postMessage(
{
type:
'GXELA_CLIENTID',
clientid:
window.clientid
},
'*'
);
}
if (window.__CLIENTID__) {
window.postMessage(
{
type:
'GXELA_CLIENTID',
clientid:
window.__CLIENTID__
},
'*'
);
}
} catch (e) {}
} catch (e) {}
})();
`;
const script =
document.createElement('script');
script.type =
'text/javascript';
script.textContent =
scriptContent;
(
document.head ||
document.documentElement
).appendChild(script);
script.remove();
appendLog(
'已注入 clientid 页面拦截器'
);
} catch (e) {
appendLog(
'clientid 拦截器注入失败:' +
e
);
}
}
window.addEventListener(
'message',
function (event) {
try {
const data =
event.data;
if (
data &&
data.type ===
'GXELA_CLIENTID' &&
data.clientid
) {
const cid =
String(
data.clientid
).trim();
if (cid.length >= 8) {
saveClientId(
cid,
'auto'
);
}
}
} catch (e) {}
}
);
function scanPageForClientId() {
try {
const metas =
document.querySelectorAll(
'meta'
);
for (
const meta of metas
) {
const name =
(
meta.getAttribute('name') ||
''
).toLowerCase();
const content =
(
meta.getAttribute('content') ||
''
).trim();
if (
name.includes('clientid') &&
content
) {
return content;
}
const match =
content.match(
/clientid\s*[:=]\s*['"]?([a-z0-9_-]{8,64})['"]?/i
);
if (match) {
return match[1];
}
}
const scripts =
document.getElementsByTagName(
'script'
);
const regex =
/clientid\s*[:=]\s*['"]?([a-z0-9_-]{8,64})['"]?/i;
for (
const script of scripts
) {
const text =
script.textContent || '';
const match =
text.match(regex);
if (
match &&
match[1]
) {
return match[1];
}
}
} catch (e) {
console.warn(e);
}
return null;
}
/* ================================================================
* UI 样式
* ================================================================ */
GM_addStyle(`
#gxela-panel {
position: fixed;
top: 18px;
right: 18px;
width: 540px;
max-height: calc(100vh - 36px);
z-index: 2147483647;
display: flex;
flex-direction: column;
background: #f5f7fa;
border: 1px solid #dfe3e8;
border-radius: 16px;
box-shadow:
0 12px 40px rgba(0,0,0,.16);
overflow: hidden;
font-family:
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"Microsoft YaHei",
Arial,
sans-serif;
color: #202124;
font-size: 13px;
}
#gxela-header {
flex-shrink: 0;
padding: 16px 18px;
color: white;
background:
linear-gradient(
135deg,
#2563eb,
#4f46e5
);
display: flex;
justify-content:
space-between;
align-items: center;
cursor: pointer;
user-select: none;
}
#gxela-header-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 700;
}
#gxela-header-sub {
margin-top: 3px;
opacity: .78;
font-size: 11px;
}
#gxela-header-arrow {
font-size: 18px;
opacity: .85;
}
#gxela-body {
overflow-y: auto;
padding: 12px;
}
#gxela-body.collapsed {
display: none;
}
.gxela-card {
margin-bottom: 10px;
background: white;
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow: hidden;
}
.gxela-card:last-child {
margin-bottom: 0;
}
.gxela-card-title {
min-height: 42px;
padding: 0 13px;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
cursor: pointer;
user-select: none;
font-weight: 700;
}
.gxela-card-title:hover {
background: #f8fafc;
}
.gxela-card-title-left {
display: flex;
align-items: center;
gap: 8px;
}
.gxela-card-icon {
width: 25px;
height: 25px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 7px;
background: #eef2ff;
}
.gxela-card-arrow {
color: #9ca3af;
}
.gxela-card-content {
padding: 12px;
border-top: 1px solid #f0f1f3;
}
.gxela-card-content.collapsed {
display: none;
}
/* 目标学时 */
.gxela-target-grid {
display: grid;
grid-template-columns:
1fr 1fr;
gap: 10px;
}
.gxela-target-box {
padding: 14px;
border-radius: 11px;
background:
linear-gradient(
135deg,
#eff6ff,
#eef2ff
);
border: 1px solid #dbeafe;
}
.gxela-target-label {
font-size: 12px;
color: #64748b;
}
.gxela-target-input {
width: 100%;
box-sizing: border-box;
margin-top: 6px;
padding: 7px 8px;
border: 1px solid #cbd5e1;
border-radius: 7px;
font-size: 18px;
font-weight: 700;
background: white;
}
.gxela-stat-grid {
display: grid;
grid-template-columns:
repeat(4, 1fr);
gap: 7px;
margin-top: 10px;
}
.gxela-stat {
padding: 9px 5px;
text-align: center;
border-radius: 9px;
background: #f8fafc;
border: 1px solid #eef0f2;
}
.gxela-stat-value {
font-size: 16px;
font-weight: 700;
}
.gxela-stat-label {
margin-top: 2px;
font-size: 10px;
color: #64748b;
}
.gxela-remaining {
margin-top: 10px;
padding: 10px;
border-radius: 9px;
background: #fefce8;
border: 1px solid #fef08a;
}
.gxela-remaining-row {
display: flex;
justify-content:
space-between;
padding: 2px 0;
}
/* 按钮 */
.gxela-buttons {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.gxela-btn {
border: 0;
border-radius: 7px;
padding: 7px 11px;
cursor: pointer;
background: #f1f5f9;
color: #334155;
font-size: 12px;
transition: .15s;
}
.gxela-btn:hover {
background: #e2e8f0;
}
.gxela-btn.primary {
color: white;
background:
linear-gradient(
135deg,
#2563eb,
#4f46e5
);
}
.gxela-btn.danger {
color: #b91c1c;
background: #fef2f2;
}
.gxela-btn:disabled {
opacity: .45;
cursor: not-allowed;
}
/* 当前播放 */
.gxela-playing {
padding: 12px;
border-radius: 10px;
background:
linear-gradient(
135deg,
#eff6ff,
#eef2ff
);
border: 1px solid #dbeafe;
}
.gxela-playing-name {
font-size: 14px;
font-weight: 700;
line-height: 1.5;
}
.gxela-playing-meta {
margin-top: 5px;
color: #64748b;
font-size: 11px;
}
.gxela-next {
margin-top: 8px;
color: #64748b;
font-size: 11px;
}
/* 课程列表 */
#gxela-course-list {
max-height: 390px;
overflow-y: auto;
padding-right: 2px;
}
.gxela-course-item {
display: grid;
grid-template-columns:
34px 1fr auto;
gap: 8px;
align-items: center;
padding: 9px 6px;
border-bottom:
1px solid #f1f5f9;
}
.gxela-course-item:last-child {
border-bottom: 0;
}
.gxela-course-index {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 7px;
background: #f1f5f9;
color: #64748b;
font-size: 11px;
font-weight: 700;
}
.gxela-course-main {
min-width: 0;
}
.gxela-course-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-weight: 600;
}
.gxela-course-meta {
margin-top: 3px;
color: #94a3b8;
font-size: 10px;
}
.gxela-course-play {
padding: 5px 9px;
border: 0;
border-radius: 6px;
color: white;
background: #2563eb;
cursor: pointer;
font-size: 11px;
}
.gxela-course-play:hover {
background: #1d4ed8;
}
.gxela-course-current {
background: #eff6ff;
}
.gxela-course-current
.gxela-course-index {
color: white;
background: #2563eb;
}
.gxela-empty {
padding: 30px 10px;
text-align: center;
color: #94a3b8;
}
/* clientid */
.gxela-client-status {
margin-bottom: 8px;
padding: 8px;
border-radius: 7px;
background: #f8fafc;
color: #64748b;
font-size: 11px;
word-break: break-all;
}
.gxela-client-input {
width: 100%;
box-sizing: border-box;
padding: 7px 9px;
border: 1px solid #cbd5e1;
border-radius: 7px;
margin-bottom: 7px;
}
/* 日志 */
#gxela-logbox {
height: 190px;
overflow: auto;
padding: 8px;
border-radius: 8px;
background: #111827;
color: #d1d5db;
font-family:
Consolas,
"Courier New",
monospace;
font-size: 10px;
line-height: 1.55;
white-space: pre-wrap;
}
/* 状态 */
#gxela-status {
margin-bottom: 10px;
padding: 9px 10px;
border-radius: 8px;
background: #f8fafc;
color: #475569;
font-size: 11px;
font-weight: 600;
}
.gxela-year-row {
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 10px;
}
.gxela-year-input {
width: 100px;
padding: 6px 8px;
border: 1px solid #cbd5e1;
border-radius: 7px;
}
@media (max-width: 700px) {
#gxela-panel {
width: calc(100vw - 20px);
right: 10px;
top: 10px;
}
}
`);
/* V3.6:强化信息层级、班级同步、队列操作与移动端排版。 */
GM_addStyle(`
#gxela-panel { width: 570px; border: 1px solid #dbe4f0; background: #f8fafc; }
#gxela-header { background: linear-gradient(135deg, #0f4c81, #2563eb 62%, #7c3aed); }
#gxela-body { padding: 10px; scrollbar-gutter: stable; }
.gxela-card { border: 1px solid #e2e8f0; border-radius: 12px; box-shadow: 0 2px 8px rgba(15, 23, 42, .04); overflow: hidden; }
.gxela-card-title { min-height: 42px; background: #fff; }
.gxela-card-title:hover { background: #f8fafc; }
.gxela-card-content { background: #fff; }
.gxela-target-card { border: 2px solid #93c5fd; box-shadow: 0 8px 22px rgba(37, 99, 235, .13); }
.gxela-target-card .gxela-card-title { background: linear-gradient(90deg, #eff6ff, #fff); font-size: 15px; }
.gxela-target-input { font-size: 22px; font-weight: 750; color: #1d4ed8; background: #f8fbff; }
.gxela-target-box { padding: 12px; border-radius: 10px; background: #f8fafc; }
.gxela-target-hint { margin: 10px 0 2px; color: #64748b; font-size: 11px; }
#gxela-course-list { max-height: min(48vh, 520px); overflow-y: auto; overscroll-behavior: contain; padding: 2px 5px 2px 1px; }
#gxela-course-list::-webkit-scrollbar { width: 9px; }
#gxela-course-list::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 99px; border: 2px solid #fff; }
.gxela-course-item { margin-bottom: 7px; border-radius: 9px; }
.gxela-course-play { white-space: nowrap; }
.gxela-course-actions { display: flex; gap: 4px; align-items: center; }
.gxela-course-action { border: 1px solid #cbd5e1; background: #fff; color: #475569; border-radius: 6px; padding: 4px 6px; font-size: 11px; cursor: pointer; }
.gxela-course-action:hover { border-color: #2563eb; color: #1d4ed8; background: #eff6ff; }
.gxela-queue-controls { margin: 0 0 9px; display: flex; gap: 7px; flex-wrap: wrap; }
.gxela-log-tools { display: flex; gap: 7px; margin: 0 0 8px; }
#gxela-logFilter { flex: 1; border: 1px solid #cbd5e1; border-radius: 7px; color: #334155; padding: 5px 7px; }
.gxela-queue-hint { margin: 0 0 9px; color: #64748b; font-size: 11px; }
@media (max-width: 700px) { #gxela-panel { width: calc(100vw - 16px); right: 8px; top: 8px; } #gxela-course-list { max-height: 40vh; } }
`);
/* ================================================================
* UI 创建
* ================================================================ */
function createPanel() {
if (
document.getElementById(
'gxela-panel'
)
) {
return;
}
const panel =
document.createElement('div');
panel.id =
'gxela-panel';
panel.innerHTML = `
[状态] 等待操作
全部待学习课程均显示在此处;列表较长时可在卡片内滚动,点击“播放”可直接跳转。
个人自学完成后,按班级入口逐门刷新完成状态;不会改动个人选课。
[状态] 尚未执行
暂无同步记录
`;
document.body.appendChild(panel);
/* ============================================================
* 折叠卡片
* ============================================================ */
panel
.querySelectorAll(
'.gxela-card-title'
)
.forEach(title => {
title.addEventListener(
'click',
() => {
const targetId =
title.dataset.target;
const content =
document.getElementById(
targetId
);
if (!content) {
return;
}
const arrow =
title.querySelector(
'.gxela-card-arrow'
);
content.classList.toggle(
'collapsed'
);
if (
content.classList.contains(
'collapsed'
)
) {
if (arrow) {
arrow.textContent = '▶';
}
} else {
if (arrow) {
arrow.textContent = '▼';
}
}
}
);
});
/* ============================================================
* 主面板折叠
* ============================================================ */
document
.getElementById(
'gxela-header'
)
.addEventListener(
'click',
() => {
const body =
document.getElementById(
'gxela-body'
);
const arrow =
document.getElementById(
'gxela-header-arrow'
);
body.classList.toggle(
'collapsed'
);
arrow.textContent =
body.classList.contains(
'collapsed'
)
? '▲'
: '▼';
}
);
/* ============================================================
* clientid
* ============================================================ */
const cidInput =
document.getElementById(
'gxela-clientid-input'
);
const cidStatus =
document.getElementById(
'gxela-clientid-status'
);
if (clientIdCurrent) {
cidInput.value =
clientIdCurrent;
cidStatus.textContent =
`已设置 · ${clientIdCurrent}`;
} else {
cidInput.value =
FALLBACK_CLIENT_ID;
cidStatus.textContent =
'尚未保存 clientid · 输入框已预填备用值';
}
document
.getElementById(
'gxela-saveClientBtn'
)
.addEventListener(
'click',
() => {
const value =
cidInput.value.trim();
if (!value) {
alert(
'请输入 clientid'
);
return;
}
saveClientId(
value,
'manual'
);
}
);
document
.getElementById(
'gxela-clearClientBtn'
)
.addEventListener(
'click',
clearClientId
);
document
.getElementById(
'gxela-detectClientBtn'
)
.addEventListener(
'click',
() => {
appendLog(
'开始手动检测 clientid...'
);
const found =
scanPageForClientId();
if (found) {
saveClientId(
found,
'scan'
);
setStatus(
'[状态] clientid 检测成功'
);
return;
}
injectClientIdInterceptorToPage();
appendLog(
'未找到静态 clientid,等待页面请求捕获'
);
}
);
/* ============================================================
* 目标学时
* ============================================================ */
const targetC =
document.getElementById(
'gxela-targetCompulsory'
);
const targetE =
document.getElementById(
'gxela-targetElective'
);
const yearInput =
document.getElementById(
'gxela-year'
);
targetC.addEventListener(
'input',
() => {
targetHours.compulsory =
parseFloat(
targetC.value
) || 0;
saveState(
'gxela_targets',
targetHours
);
appendLog(
`保存必修目标:${targetHours.compulsory}`
);
updateRemainingDisplay();
}
);
targetE.addEventListener(
'input',
() => {
targetHours.elective =
parseFloat(
targetE.value
) || 0;
saveState(
'gxela_targets',
targetHours
);
appendLog(
`保存选修目标:${targetHours.elective}`
);
updateRemainingDisplay();
}
);
yearInput.addEventListener(
'change',
() => {
currentYear =
parseInt(
yearInput.value
) ||
new Date().getFullYear();
saveState(
'gxela_year',
currentYear
);
appendLog(
`学习年份设置为 ${currentYear}`
);
}
);
/* ============================================================
* 按钮
* ============================================================ */
document
.getElementById(
'gxela-startBtn'
)
.addEventListener(
'click',
onStartClicked
);
document
.getElementById(
'gxela-stopBtn'
)
.addEventListener(
'click',
onStopClicked
);
document
.getElementById(
'gxela-resumeBtn'
)
.addEventListener(
'click',
resumeLastSession
);
document
.getElementById(
'gxela-refreshBtn'
)
.addEventListener(
'click',
async () => {
setStatus(
'正在刷新数据...'
);
await refreshLearnedAndPending(
currentYear
);
await refreshCourseList();
setStatus(
'[状态] 数据刷新完成'
);
}
);
document
.getElementById(
'gxela-cancelCompBtn'
)
.addEventListener(
'click',
async () => {
await cancelSelectedByType(3);
}
);
document
.getElementById(
'gxela-cancelElectBtn'
)
.addEventListener(
'click',
async () => {
await cancelSelectedByType(2);
}
);
document.getElementById('gxela-skipCurrentBtn').addEventListener(
'click',
skipCurrentCourse
);
document.getElementById('gxela-clearQueueBtn').addEventListener(
'click',
() => {
const queue = loadState('g_selectedQueue', []);
if (!queue.length) return;
if (!confirm(`清空本地播放队列中的 ${queue.length} 门课程?\n不会取消网站选课,也不会删除学习记录。`)) return;
saveState('g_selectedQueue', []);
saveState('g_currentIndex', 0);
saveState('gxela_isLearning', false);
persistPlaybackSession('queue-cleared');
renderCourseList();
setCurrentNext('-', '-');
setStatus('[状态] 本地队列已清空');
appendLog('用户清空本地播放队列');
}
);
document.getElementById('gxela-classSyncStartBtn').addEventListener(
'click',
() => startClassSync(false)
);
document.getElementById('gxela-classSyncRetryBtn').addEventListener(
'click',
() => startClassSync(true)
);
document.getElementById('gxela-classSyncStopBtn').addEventListener(
'click',
stopClassSync
);
document
.getElementById(
'gxela-exportBtn'
)
.addEventListener(
'click',
exportLogs
);
document
.getElementById(
'gxela-clearLogBtn'
)
.addEventListener(
'click',
() => {
logs = [];
saveState(
'gxela_logs',
[]
);
const box =
document.getElementById(
'gxela-logbox'
);
if (box) {
box.textContent = '';
}
appendLog(
'日志已清空'
);
}
);
const logBox =
document.getElementById(
'gxela-logbox'
);
if (logBox) {
document.getElementById('gxela-logFilter').value = logFilter;
renderLogs(true);
}
document.getElementById('gxela-logFilter').addEventListener('change', event => {
logFilter = event.target.value;
saveState('gxela_logFilter', logFilter);
renderLogs(true);
});
updateStartButton();
restorePlaybackDisplay();
saveClassSyncState();
}
/* ================================================================
* 状态
* ================================================================ */
function setStatus(text) {
const element =
document.getElementById(
'gxela-status'
);
if (element) {
element.textContent =
text;
}
}
/* ================================================================
* 课程显示
* ================================================================ */
function formatCourseLabel(
item,
actualSec
) {
if (!item) {
return '-';
}
const name =
item.name ||
`lessonId:${item.lessonId}`;
const classHour =
Number(item.classHour) || 0;
const predictedMin =
item.letime
? Number(item.letime)
: classHour
? Math.round(
classHour * 60
)
: null;
const predicted =
predictedMin
? `${predictedMin} 分`
: '-';
let actual = '';
if (
actualSec &&
!isNaN(actualSec) &&
actualSec > 0
) {
const minutes =
Math.floor(
actualSec / 60
);
const seconds =
Math.round(
actualSec % 60
);
actual =
` · 实际 ${minutes}分${seconds}秒`;
}
return (
`${name} · ${classHour} 学时 · 预计 ${predicted}${actual}`
);
}
function setCurrentNext(
current,
next
) {
const currentElement =
document.getElementById(
'gxela-currentCourse'
);
const nextElement =
document.getElementById(
'gxela-nextCourse'
);
if (currentElement) {
currentElement.textContent =
current || '-';
}
if (nextElement) {
nextElement.textContent =
next || '-';
}
}
function restorePlaybackDisplay() {
try {
const queue =
loadState(
'g_selectedQueue',
[]
);
const index =
loadState(
'g_currentIndex',
0
) || 0;
if (
Array.isArray(queue) &&
queue.length
) {
const current =
queue[index] ||
null;
const next =
queue[index + 1] ||
null;
setCurrentNext(
formatCourseLabel(
current,
null
),
formatCourseLabel(
next,
null
)
);
renderCourseList();
const session = loadState('gxela_playbackSession', null);
if (session && session.updatedAt) {
setStatus(`[状态] 已恢复队列:第 ${index + 1}/${queue.length} 门(${new Date(session.updatedAt).toLocaleString()})`);
}
}
} catch (e) {}
}
/* 保存可恢复的播放上下文,供刷新页面或重新打开浏览器后继续使用。 */
function persistPlaybackSession(reason = '') {
const queue = loadState('g_selectedQueue', []);
const index = loadState('g_currentIndex', 0) || 0;
const current = Array.isArray(queue) ? queue[index] : null;
saveState('gxela_playbackSession', {
isLearning: loadState('gxela_isLearning', false),
currentIndex: index,
currentLessonId: current ? String(current.lessonId) : null,
queueLength: Array.isArray(queue) ? queue.length : 0,
updatedAt: new Date().toISOString(),
reason
});
}
/* ================================================================
* 剩余学时
* ================================================================ */
function updateRemainingDisplay() {
const learnedComp =
parseFloat(
document.getElementById(
'gxela-learnedCompulsory'
)?.textContent || 0
) || 0;
const learnedElect =
parseFloat(
document.getElementById(
'gxela-learnedElective'
)?.textContent || 0
) || 0;
const pendingComp =
parseFloat(
document.getElementById(
'gxela-pendingCompulsory'
)?.textContent || 0
) || 0;
const pendingElect =
parseFloat(
document.getElementById(
'gxela-pendingElective'
)?.textContent || 0
) || 0;
const remainComp =
Math.max(
0,
targetHours.compulsory -
learnedComp -
pendingComp
);
const remainElect =
Math.max(
0,
targetHours.elective -
learnedElect -
pendingElect
);
const comp =
document.getElementById(
'gxela-remainingComp'
);
const elect =
document.getElementById(
'gxela-remainingElect'
);
if (comp) {
comp.textContent =
remainComp.toFixed(2);
}
if (elect) {
elect.textContent =
remainElect.toFixed(2);
}
}
/* ================================================================
* /my
* ================================================================ */
async function fetchMyCourses(
lessonType,
passStatus,
year
) {
const all = [];
let page = 1;
try {
while (true) {
const url =
`https://www.gxela.gov.cn/gateway/auth/course/lesson/page/my?pageNum=${page}&pageSize=${PAGE_SIZE}&queryLessonType=${lessonType}&passStatus=${passStatus}&year=${year}`;
const response =
await gmRequestRaw({
method: 'GET',
url,
headers: {
platform: 'web',
Accept:
'application/json, text/plain, */*'
}
});
if (
response.status !== 200
) {
appendLog(
`/my HTTP ${response.status}`
);
break;
}
let data = {};
try {
data =
JSON.parse(
response.responseText ||
'{}'
);
} catch (e) {}
if (
!data.rows ||
!data.rows.length
) {
break;
}
all.push(
...data.rows
);
const total =
data.total ||
all.length;
appendLog(
`/my type=${lessonType} status=${passStatus} page=${page} rows=${data.rows.length} (${all.length}/${total})`
);
page++;
if (
all.length >= total
) {
break;
}
}
} catch (e) {
appendLog(
`/my 请求异常:${e}`
);
}
return all;
}
/* ================================================================
* 刷新学时
* ================================================================ */
async function refreshLearnedAndPending(
year
) {
appendLog(
'刷新已学/已选未完成课程...'
);
try {
const mustDone =
await fetchMyCourses(
3,
1,
year
);
const electDone =
await fetchMyCourses(
2,
1,
year
);
const mustPending =
await fetchMyCourses(
3,
2,
year
);
const electPending =
await fetchMyCourses(
2,
2,
year
);
const learnedComp =
mustDone.reduce(
(sum, item) =>
sum +
(
Number(
item.classHour
) || 0
),
0
);
const learnedElect =
electDone.reduce(
(sum, item) =>
sum +
(
Number(
item.classHour
) || 0
),
0
);
const pendingComp =
mustPending.reduce(
(sum, item) =>
sum +
(
Number(
item.classHour
) || 0
),
0
);
const pendingElect =
electPending.reduce(
(sum, item) =>
sum +
(
Number(
item.classHour
) || 0
),
0
);
document.getElementById(
'gxela-learnedCompulsory'
).textContent =
learnedComp.toFixed(2);
document.getElementById(
'gxela-learnedElective'
).textContent =
learnedElect.toFixed(2);
document.getElementById(
'gxela-pendingCompulsory'
).textContent =
pendingComp.toFixed(2);
document.getElementById(
'gxela-pendingElective'
).textContent =
pendingElect.toFixed(2);
updateRemainingDisplay();
pendingIdsSet =
new Set();
mustPending.forEach(
item =>
pendingIdsSet.add(
String(
item.lessonId
)
)
);
electPending.forEach(
item =>
pendingIdsSet.add(
String(
item.lessonId
)
)
);
saveState(
'gxela_pendingIds',
Array.from(
pendingIdsSet
)
);
appendLog(
`学时刷新完成:必修已学 ${learnedComp},选修已学 ${learnedElect}`
);
return {
learnedComp,
learnedElect,
pendingComp,
pendingElect
};
} catch (e) {
appendLog(
'刷新学时失败:' +
e
);
return null;
}
}
/* ================================================================
* 候选课程
* ================================================================ */
async function fetchAllCandidates(
lessonType,
year
) {
const all = [];
let page = 1;
try {
while (true) {
const url =
'https://www.gxela.gov.cn/gateway/auth/course/lesson/page';
const body = {
pageNum: page,
pageSize: PAGE_SIZE,
queryLessonType:
lessonType,
passStatus: 2,
year: year,
lessonName: '',
teachers: '',
teacherPosition: '',
teacherUnit: '',
idList: [],
orderType: '',
sortType: ''
};
const response =
await gmRequestRaw({
method: 'POST',
url,
data:
JSON.stringify(body),
headers: {
'Content-Type':
'application/json;charset=UTF-8',
Accept:
'application/json, text/plain, */*'
}
});
if (
response.status !== 200
) {
break;
}
let data = {};
try {
data =
JSON.parse(
response.responseText ||
'{}'
);
} catch (e) {}
if (
!data.rows ||
!data.rows.length
) {
break;
}
all.push(
...data.rows
);
const total =
data.total ||
all.length;
appendLog(
`候选库 type=${lessonType} page=${page} ${all.length}/${total}`
);
page++;
if (
all.length >= total
) {
break;
}
}
} catch (e) {
appendLog(
'候选库请求异常:' +
e
);
}
return all;
}
/* ================================================================
* 贪心选课
* ================================================================ */
function greedySelect(
candidates,
target,
alreadySelectedIds
) {
const filtered =
candidates.filter(
item => {
const isSelect =
item.isSelect === undefined
? 0
: Number(
item.isSelect
);
return (
isSelect === 0 &&
!alreadySelectedIds.has(
String(
item.lessonId
)
)
);
}
);
filtered.forEach(
item => {
const hour =
Number(
item.classHour
) || 0;
const minutes =
item.letime
? Number(
item.letime
)
: hour * 60;
item._letimeMin =
minutes > 0
? minutes
: 1;
item._eff =
hour /
item._letimeMin;
}
);
filtered.sort(
(a, b) => {
if (
b._eff !==
a._eff
) {
return (
b._eff -
a._eff
);
}
return (
a._letimeMin -
b._letimeMin
);
}
);
const selected = [];
let accumulated = 0;
for (
const item of filtered
) {
if (
accumulated >=
target
) {
break;
}
selected.push(
item
);
accumulated +=
Number(
item.classHour
) || 0;
}
return selected;
}
/* ================================================================
* 选课
* ================================================================ */
async function selectLessonsBatch(
list
) {
const success = [];
for (
const item of list
) {
let ok = false;
for (
let attempt = 0;
attempt <= SELECT_MAX_RETRIES;
attempt++
) {
try {
const url =
`https://www.gxela.gov.cn/gateway/auth/user/study/lesson/selection?lessonId=${item.lessonId}&tcLessonId=0`;
const response =
await gmRequestRaw({
method: 'POST',
url,
data: '',
headers: {
Accept:
'application/json, text/plain, */*'
}
});
if (
response.status === 200
) {
let body = {};
try {
body =
JSON.parse(
response.responseText ||
'{}'
);
} catch (e) {}
appendLog(
`选课 ${item.name} → ${body.msg || body.code || ''}`
);
if (
Number(
body.code
) === 200
) {
ok = true;
break;
}
}
} catch (e) {
appendLog(
`选课异常:${item.name} ${e}`
);
}
if (
attempt <
SELECT_MAX_RETRIES
) {
await sleep(
800 +
attempt * 400
);
}
}
if (ok) {
success.push(
item
);
pendingIdsSet.add(
String(
item.lessonId
)
);
saveState(
'gxela_pendingIds',
Array.from(
pendingIdsSet
)
);
}
await sleep(
SELECT_DELAY_MS
);
}
await refreshLearnedAndPending(
currentYear
);
return success;
}
/* ================================================================
* 取消课程
* ================================================================ */
async function cancelLessonsBatch(
list
) {
const success = [];
for (
const item of list
) {
let ok = false;
for (
let attempt = 0;
attempt <= CANCEL_MAX_RETRIES;
attempt++
) {
try {
const url =
`https://www.gxela.gov.cn/gateway/auth/user/study/lesson/selection/cancel?lessonId=${item.lessonId}`;
const response =
await gmRequestRaw({
method: 'POST',
url,
data: '',
headers: {
Accept:
'application/json, text/plain, */*'
}
});
if (
response.status === 200
) {
let body = {};
try {
body =
JSON.parse(
response.responseText ||
'{}'
);
} catch (e) {}
appendLog(
`取消 ${item.name} → ${body.msg || body.code || ''}`
);
if (
Number(
body.code
) === 200
) {
ok = true;
break;
}
}
} catch (e) {
appendLog(
`取消异常:${item.name} ${e}`
);
}
if (
attempt <
CANCEL_MAX_RETRIES
) {
await sleep(
600 +
attempt * 400
);
}
}
if (ok) {
success.push(
item
);
pendingIdsSet.delete(
String(
item.lessonId
)
);
}
await sleep(
CANCEL_DELAY_MS
);
}
saveState(
'gxela_pendingIds',
Array.from(
pendingIdsSet
)
);
await refreshLearnedAndPending(
currentYear
);
return success;
}
async function cancelSelectedByType(
lessonType
) {
setStatus(
'正在获取已选课程...'
);
const list =
await fetchMyCourses(
lessonType,
2,
currentYear
);
if (!list.length) {
setStatus(
'[状态] 没有可取消课程'
);
return;
}
const typeName = lessonType === 3 ? '必修' : '选修';
const totalHours = list.reduce(
(sum, item) => sum + (Number(item.classHour) || 0),
0
);
const preview = list
.slice(0, 8)
.map((item, index) => `${index + 1}. ${item.name || item.lessonId}(${Number(item.classHour || 0).toFixed(2)} 学时)`)
.join('\n');
const remainder = list.length > 8 ? `\n…另有 ${list.length - 8} 门课程` : '';
if (!confirm(
`请确认取消以下 ${typeName} 已选课程:\n\n共 ${list.length} 门,${totalHours.toFixed(2)} 学时\n${preview}${remainder}\n\n此操作会实际取消网站选课,确定继续吗?`
)) {
setStatus('[状态] 已取消本次取消课程操作');
appendLog(`用户取消了批量取消${typeName}课程操作`);
return;
}
appendLog(
`准备取消 ${list.length} 门课程`
);
const success =
await cancelLessonsBatch(
list
);
setStatus(
`[状态] 取消完成 ${success.length}/${list.length}`
);
}
/* ================================================================
* 播放队列
* ================================================================ */
async function fetchPlayQueue(
year
) {
const mustPending =
await fetchMyCourses(
3,
2,
year
);
const electPending =
await fetchMyCourses(
2,
2,
year
);
const all = [
...mustPending,
...electPending
];
all.sort(
(a, b) => {
const typeA =
Number(
a.lessonType ||
(
a.isMust
? 3
: 2
)
);
const typeB =
Number(
b.lessonType ||
(
b.isMust
? 3
: 2
)
);
if (
typeA !==
typeB
) {
return (
typeB -
typeA
);
}
const progressA =
Number(
a.learnProgress
) || 0;
const progressB =
Number(
b.learnProgress
) || 0;
if (
progressA !==
progressB
) {
return (
progressB -
progressA
);
}
const remainA =
(
Number(
a.classHour
) || 0
) *
(
1 -
progressA / 100
);
const remainB =
(
Number(
b.classHour
) || 0
) *
(
1 -
progressB / 100
);
return (
remainA -
remainB
);
}
);
const seen =
new Set();
const result = [];
for (
const item of all
) {
const id =
String(
item.lessonId
);
if (
seen.has(id)
) {
continue;
}
seen.add(id);
result.push(item);
}
saveState(
'g_selectedQueue',
result
);
saveState(
'g_currentIndex',
0
);
persistPlaybackSession('queue-generated');
return result;
}
/* ================================================================
* 课程列表 UI
* ================================================================ */
function renderCourseList() {
const listElement =
document.getElementById(
'gxela-course-list'
);
const countElement =
document.getElementById(
'gxela-course-count'
);
if (!listElement) {
return;
}
const queue =
loadState(
'g_selectedQueue',
[]
);
const currentIndex =
loadState(
'g_currentIndex',
0
) || 0;
if (
countElement
) {
countElement.textContent =
`${queue.length} 门`;
}
if (
!Array.isArray(queue) ||
!queue.length
) {
listElement.innerHTML =
`
暂无待学习课程
`;
return;
}
listElement.innerHTML =
queue
.map(
(
item,
index
) => {
const type =
Number(
item.lessonType ||
(
item.isMust
? 3
: 2
)
);
const typeText =
type === 3
? '必修'
: '选修';
const progress =
Number(
item.learnProgress
) || 0;
const current =
index ===
currentIndex;
return `
${index + 1}
${escapeHtml(item.name || `课程 ${item.lessonId}`)}
${typeText}
·
${Number(item.classHour || 0).toFixed(2)}
学时
·
进度 ${progress.toFixed(1)}%
`;
}
)
.join('');
listElement
.querySelectorAll(
'.gxela-course-play'
)
.forEach(
button => {
button.addEventListener(
'click',
event => {
event.stopPropagation();
const lessonId =
button.dataset.lessonId;
playSpecificCourse(
lessonId
);
}
);
}
);
listElement.querySelectorAll('.gxela-course-top').forEach(button => {
button.addEventListener('click', event => {
event.stopPropagation();
moveCourseToTop(button.dataset.lessonId);
});
});
listElement.querySelectorAll('.gxela-course-remove').forEach(button => {
button.addEventListener('click', event => {
event.stopPropagation();
removeCourseFromQueue(button.dataset.lessonId);
});
});
}
function escapeHtml(
text
) {
return String(text)
.replace(
/&/g,
'&'
)
.replace(
//g,
'>'
)
.replace(
/"/g,
'"'
)
.replace(
/'/g,
'''
);
}
/* ================================================================
* 播放队列操作(仅修改本地队列,不影响网站上的选课记录)
* ================================================================ */
function moveCourseToTop(lessonId) {
const queue = loadState('g_selectedQueue', []);
const index = queue.findIndex(item => String(item.lessonId) === String(lessonId));
if (index < 0) return;
const item = queue.splice(index, 1)[0];
queue.unshift(item);
saveState('g_selectedQueue', queue);
saveState('g_currentIndex', 0);
persistPlaybackSession('course-moved-to-top');
renderCourseList();
restorePlaybackDisplay();
appendLog(`已将课程置顶:${item.name || item.lessonId}`);
}
function removeCourseFromQueue(lessonId) {
const queue = loadState('g_selectedQueue', []);
const index = queue.findIndex(item => String(item.lessonId) === String(lessonId));
if (index < 0) return;
const item = queue[index];
if (!confirm(`仅从本地播放列表移除“${item.name || item.lessonId}”?\n这不会取消网站中的选课。`)) return;
queue.splice(index, 1);
const currentIndex = Math.min(loadState('g_currentIndex', 0) || 0, Math.max(0, queue.length - 1));
saveState('g_selectedQueue', queue);
saveState('g_currentIndex', currentIndex);
persistPlaybackSession('course-removed-from-queue');
renderCourseList();
restorePlaybackDisplay();
appendLog(`已从本地队列移除:${item.name || item.lessonId}`);
}
function skipCurrentCourse() {
const queue = loadState('g_selectedQueue', []);
const index = loadState('g_currentIndex', 0) || 0;
const item = queue[index];
if (!item) {
setStatus('[状态] 没有可跳过的课程');
return;
}
if (!confirm(`跳过“${item.name || item.lessonId}”?\n课程仍保留在网站已选列表中,仅从本地播放队列移除。`)) return;
queue.splice(index, 1);
saveState('g_selectedQueue', queue);
saveState('g_currentIndex', Math.min(index, Math.max(0, queue.length - 1)));
persistPlaybackSession('current-course-skipped');
renderCourseList();
appendLog(`已跳过当前课程:${item.name || item.lessonId}`);
if (loadState('gxela_isLearning', false)) autoPlayQueue();
}
/* ================================================================
* 手动播放某课程
* ================================================================ */
function playSpecificCourse(
lessonId
) {
const queue =
loadState(
'g_selectedQueue',
[]
);
const index =
queue.findIndex(
item =>
String(
item.lessonId
) ===
String(
lessonId
)
);
if (index >= 0) {
saveState(
'g_currentIndex',
index
);
}
saveState(
'gxela_isLearning',
true
);
persistPlaybackSession('manual-play-selected');
appendLog(
`手动播放课程 lessonId=${lessonId}`
);
jumpToLesson(
lessonId
);
}
/* ================================================================
* 跳转播放
* ================================================================ */
function jumpToLesson(
lessonId
) {
const url =
`https://www.gxela.gov.cn/watch?lessonId=${lessonId}&tcLessonId=0&lessonOrigin=selflearn`;
window.location.href =
url;
}
function autoPlayQueue() {
if (
!loadState(
'gxela_isLearning',
false
)
) {
setStatus(
'[状态] 已停止'
);
return;
}
const queue =
loadState(
'g_selectedQueue',
[]
);
const index =
loadState(
'g_currentIndex',
0
) || 0;
if (
!Array.isArray(queue) ||
!queue.length
) {
setStatus(
'[状态] 队列为空'
);
saveState(
'gxela_isLearning',
false
);
return;
}
if (
index >= queue.length
) {
appendLog(
'🎉 所有课程播放完成'
);
saveState(
'gxela_isLearning',
false
);
setStatus(
'[状态] 全部完成'
);
appendLog('个人学习队列完成,准备自动检查班级必修同步任务');
setTimeout(() => startClassSync(false), 1000);
return;
}
const current =
queue[index];
const next =
queue[index + 1] ||
null;
setCurrentNext(
formatCourseLabel(
current,
null
),
formatCourseLabel(
next,
null
)
);
renderCourseList();
persistPlaybackSession('auto-play-next');
appendLog(
`跳转播放 ${index + 1}/${queue.length}:${current.name || current.lessonId}`
);
jumpToLesson(
current.lessonId
);
}
function resumeLastSession() {
const session = loadState('gxela_playbackSession', null);
const queue = loadState('g_selectedQueue', []);
if (!session || !Array.isArray(queue) || !queue.length) {
setStatus('[状态] 没有可恢复的播放队列');
return;
}
const savedIndex = Math.min(
Math.max(0, Number(session.currentIndex) || 0),
queue.length - 1
);
saveState('g_currentIndex', savedIndex);
saveState('gxela_isLearning', true);
persistPlaybackSession('user-resumed-session');
appendLog(`恢复上次学习:第 ${savedIndex + 1}/${queue.length} 门`);
setStatus('[状态] 正在恢复上次学习');
autoPlayQueue();
}
/* ================================================================
* 开始
* ================================================================ */
async function onStartClicked() {
if (!clientIdCurrent) {
alert(
'请先设置 clientid'
);
return;
}
try {
currentYear =
parseInt(
document.getElementById(
'gxela-year'
).value
) ||
new Date().getFullYear();
targetHours.compulsory =
parseFloat(
document.getElementById(
'gxela-targetCompulsory'
).value
) || 0;
targetHours.elective =
parseFloat(
document.getElementById(
'gxela-targetElective'
).value
) || 0;
saveState(
'gxela_targets',
targetHours
);
saveState(
'gxela_year',
currentYear
);
saveState(
'gxela_isLearning',
true
);
setStatus(
'正在初始化...'
);
const data =
await refreshLearnedAndPending(
currentYear
);
if (!data) {
saveState(
'gxela_isLearning',
false
);
setStatus(
'[状态] 初始化失败'
);
return;
}
const remainingComp =
Math.max(
0,
targetHours.compulsory -
data.learnedComp -
data.pendingComp
);
const remainingElect =
Math.max(
0,
targetHours.elective -
data.learnedElect -
data.pendingElect
);
if (
remainingComp > 0
) {
setStatus(
'正在选择必修课程...'
);
const candidates =
await fetchAllCandidates(
3,
currentYear
);
const selected =
greedySelect(
candidates,
remainingComp,
pendingIdsSet
);
appendLog(
`必修拟选 ${selected.length} 门`
);
await selectLessonsBatch(
selected
);
}
if (
remainingElect > 0
) {
setStatus(
'正在选择选修课程...'
);
const candidates =
await fetchAllCandidates(
2,
currentYear
);
const selected =
greedySelect(
candidates,
remainingElect,
pendingIdsSet
);
appendLog(
`选修拟选 ${selected.length} 门`
);
await selectLessonsBatch(
selected
);
}
setStatus(
'正在生成播放队列...'
);
const queue =
await fetchPlayQueue(
currentYear
);
renderCourseList();
if (!queue.length) {
saveState(
'gxela_isLearning',
false
);
setStatus(
'[状态] 没有待学习课程'
);
return;
}
setStatus(
`准备播放,共 ${queue.length} 门`
);
autoPlayQueue();
} catch (e) {
appendLog(
'开始流程异常:' +
e
);
setStatus(
'[状态] 异常'
);
}
}
/* ================================================================
* 停止
* ================================================================ */
function onStopClicked() {
saveState(
'gxela_isLearning',
false
);
persistPlaybackSession('user-stopped');
appendLog(
'用户停止自动学习'
);
setStatus(
'[状态] 已停止'
);
}
/* ================================================================
* 页面点击
*
* 播放后点击空白区域:
* 用于消除网站可能出现的 MessageBox
* ================================================================ */
function clickBlankArea() {
try {
const panel =
document.getElementById(
'gxela-panel'
);
const x =
Math.max(
5,
Math.floor(
window.innerWidth *
0.45
)
);
const y =
Math.max(
5,
Math.floor(
window.innerHeight *
0.08
)
);
/*
* 不点击 GXELA 面板,
* 尽量选择页面空白区域。
*/
if (
panel &&
x >= panel.offsetLeft &&
x <=
panel.offsetLeft +
panel.offsetWidth
) {
document.body.click();
} else {
const element =
document.elementFromPoint(
x,
y
);
if (element) {
element.dispatchEvent(
new MouseEvent(
'click',
{
bubbles: true,
cancelable: true,
view: window
}
)
);
}
}
appendLog(
'已执行一次页面空白区域点击'
);
} catch (e) {
console.warn(
'blank click error',
e
);
}
}
function scheduleBlankClick() {
clearTimeout(
pageClickTimer
);
pageClickTimer =
setTimeout(
() => {
clickBlankArea();
},
DIALOG_DISMISS_DELAY
);
}
/* ================================================================
* 永久静音
* ================================================================ */
function forceMute(
video
) {
if (!video) {
return;
}
try {
video.muted = true;
video.volume = 0;
} catch (e) {}
/*
* 防止网站再次修改 muted / volume
*/
if (
!video.__gxelaMuteBound
) {
video.__gxelaMuteBound =
true;
video.addEventListener(
'volumechange',
() => {
try {
if (
!video.muted
) {
video.muted =
true;
}
if (
video.volume !== 0
) {
video.volume = 0;
}
} catch (e) {}
}
);
}
}
/* ================================================================
* 主动播放
* ================================================================ */
async function forcePlayVideo(
video
) {
if (!video) {
return false;
}
currentVideo =
video;
forceMute(
video
);
/*
* 如果视频已经在播放,
* 不重复调用 play。
*/
if (
!video.paused &&
!video.ended
) {
appendLog(
`🎬 video 当前正在播放,保持播放。currentTime=${video.currentTime.toFixed(2)}`
);
videoPlayRetryCount = 0;
// V3.6:即使网站已先行播放,也执行一次空白点击以关闭可能的遮罩/对话框。
scheduleBlankClick();
return true;
}
/*
* paused 时主动尝试播放。
*/
try {
appendLog(
`▶ video 当前 paused,主动尝试播放。currentTime=${Number(video.currentTime || 0).toFixed(2)}`
);
const result =
video.play();
if (
result &&
typeof result.then ===
'function'
) {
await result;
}
forceMute(
video
);
videoPlayRetryCount = 0;
appendLog(
'▶ video.play() 成功'
);
scheduleBlankClick();
return true;
} catch (e) {
videoPlayRetryCount++;
appendLog(
`⚠ video.play() 失败 (${videoPlayRetryCount}/${PLAY_MAX_RETRIES}):${e}`
);
if (
videoPlayRetryCount <
PLAY_MAX_RETRIES
) {
setTimeout(
() => {
if (
loadState(
'gxela_isLearning',
false
)
) {
forcePlayVideo(
video
);
}
},
PLAY_RETRY_INTERVAL
);
}
return false;
}
}
/* ================================================================
* 视频查找
* ================================================================ */
function findBestVideo() {
const videos =
Array.from(
document.querySelectorAll(
'video'
)
);
if (!videos.length) {
return null;
}
/*
* 优先选择有 src / currentSrc 的 video
*/
videos.sort(
(a, b) => {
const score =
video => {
let value = 0;
if (
video.currentSrc
) {
value += 10;
}
if (
video.src
) {
value += 5;
}
if (
video.duration &&
isFinite(
video.duration
)
) {
value += 5;
}
if (
!video.paused
) {
value += 20;
}
return value;
};
return (
score(b) -
score(a)
);
}
);
return videos[0];
}
/* 轮询后台状态,只有确认课程完成(或已不在未完成列表)才允许切换下一节。 */
async function waitForLessonCompletion(lessonId) {
const id = String(lessonId || '');
for (let attempt = 1; attempt <= 6; attempt++) {
const completed = [
...(await fetchMyCourses(3, 1, currentYear)),
...(await fetchMyCourses(2, 1, currentYear))
];
if (completed.some(item => String(item.lessonId) === id)) return true;
const pending = [
...(await fetchMyCourses(3, 2, currentYear)),
...(await fetchMyCourses(2, 2, currentYear))
];
const stillPending = pending.some(item => String(item.lessonId) === id);
setStatus(`[状态] 等待平台确认完成(${attempt}/6)`);
appendLog(
stillPending
? `课程尚未同步为完成,${attempt}/6 次确认后重试`
: `未在完成列表找到课程,继续确认以避免网络异常误切换(${attempt}/6)`
);
await sleep(5000);
}
return false;
}
/* ================================================================
* 视频完成
* ================================================================ */
async function handleVideoEnded(
video
) {
appendLog(
'✅ 当前课程 video ended'
);
const endedLessonId =
new URL(window.location.href)
.searchParams
.get('lessonId');
const confirmed = await waitForLessonCompletion(endedLessonId);
if (!confirmed) {
saveState('gxela_isLearning', false);
persistPlaybackSession('completion-not-confirmed');
setStatus('[状态] 未确认完成,已停止切换;请稍后刷新后重试');
appendLog('❌ 未获得平台完成确认,未切换下一门课程');
return;
}
/*
* 这里非常重要:
*
* 不提前切换课程。
*
* 只有 video 真正 ended 后,
* 才推进队列。
*/
try {
await refreshLearnedAndPending(
currentYear
);
/*
* V3.6:每门课程结束后重新从服务器生成队列。
* 这样已完成课程会消失,下一门的进度、学时与列表高亮均是最新数据。
*/
const refreshedQueue =
await fetchPlayQueue(
currentYear
);
// 后端进度有时会延迟写入;本页已经触发 ended 的课程不应再次播放。
const nextQueue =
refreshedQueue.filter(
item =>
String(item.lessonId) !==
String(endedLessonId)
);
saveState(
'g_selectedQueue',
nextQueue
);
saveState(
'g_currentIndex',
0
);
persistPlaybackSession('completed-course-queue-refreshed');
renderCourseList();
if (!nextQueue.length) {
saveState(
'gxela_isLearning',
false
);
setStatus(
'[状态] 全部课程完成'
);
setCurrentNext(
'-',
'-'
);
appendLog(
'🎉 播放列表已刷新,所有课程均已完成'
);
return;
}
appendLog(
`播放列表已刷新,剩余 ${nextQueue.length} 门;即将进入下一门`
);
setTimeout(
autoPlayQueue,
800
);
return;
} catch (e) {
appendLog(
'结束后刷新播放列表异常:' +
e
);
return;
}
const queue =
loadState(
'g_selectedQueue',
[]
);
let index =
loadState(
'g_currentIndex',
0
) || 0;
index++;
saveState(
'g_currentIndex',
index
);
renderCourseList();
appendLog(
`当前课程完成,队列进入第 ${index + 1}/${queue.length} 门`
);
if (
index >= queue.length
) {
saveState(
'gxela_isLearning',
false
);
setStatus(
'[状态] 全部课程完成'
);
setCurrentNext(
'-',
'-'
);
return;
}
setTimeout(
() => {
autoPlayQueue();
},
800
);
}
/* ================================================================
* 视频卡顿监控
* ================================================================ */
function startVideoStuckMonitor(
video
) {
if (
video.__gxelaMonitor
) {
return;
}
video.__gxelaMonitor =
true;
let lastTime =
Number(
video.currentTime
) || 0;
let stuckCount = 0;
const lessonId =
new URL(
window.location.href
)
.searchParams
.get(
'lessonId'
) || '';
const reloadKey =
`gxela_reloadAttempts_${lessonId}`;
const interval =
setInterval(
async () => {
try {
if (
!loadState(
'gxela_isLearning',
false
)
) {
clearInterval(
interval
);
return;
}
forceMute(
video
);
if (
video.ended
) {
clearInterval(
interval
);
return;
}
/*
* 视频正在播放:
* 检查 currentTime 是否增长。
*/
if (
!video.paused
) {
const current =
Number(
video.currentTime
) || 0;
if (
current >
lastTime +
0.5
) {
stuckCount = 0;
lastTime =
current;
return;
}
stuckCount++;
appendLog(
`⚠ 播放疑似卡顿 count=${stuckCount} currentTime=${current.toFixed(2)}`
);
if (
stuckCount >=
VIDEO_STUCK_THRESHOLD
) {
appendLog(
'尝试恢复视频播放'
);
await forcePlayVideo(
video
);
await sleep(
3000
);
const current2 =
Number(
video.currentTime
) || 0;
if (
current2 >
lastTime +
0.5
) {
appendLog(
'▶ 视频已恢复'
);
stuckCount = 0;
lastTime =
current2;
return;
}
let reloadAttempts =
parseInt(
loadState(
reloadKey,
0
)
) || 0;
if (
reloadAttempts <
VIDEO_MAX_RELOADS
) {
reloadAttempts++;
saveState(
reloadKey,
reloadAttempts
);
appendLog(
`页面重载恢复,第 ${reloadAttempts}/${VIDEO_MAX_RELOADS} 次`
);
location.reload();
return;
}
/*
* 达到页面重载上限:
* 跳过当前课程。
*/
appendLog(
'重载次数达到上限,跳过当前课程'
);
clearInterval(
interval
);
const index =
(
loadState(
'g_currentIndex',
0
) || 0
) + 1;
saveState(
'g_currentIndex',
index
);
saveState(
reloadKey,
0
);
setTimeout(
autoPlayQueue,
500
);
}
}
} catch (e) {
console.warn(
'GXELA stuck monitor:',
e
);
}
},
VIDEO_STUCK_CHECK_MS
);
}
/* ================================================================
* 焦点恢复
*
* 网站切换焦点时可能:
*
* blur
* ↓
* 网站暂停 video
* ↓
* 网站自己的逻辑再次 play
*
* 我们不在 blur 时强行操作,
* 而是在 pause / focus 时检查并恢复。
* ================================================================ */
function bindFocusRecovery(
video
) {
if (
video.__gxelaFocusBound
) {
return;
}
video.__gxelaFocusBound =
true;
video.addEventListener(
'pause',
() => {
if (
!loadState(
'gxela_isLearning',
false
)
) {
return;
}
/*
* ended 是正常结束,
* 不处理。
*/
if (
video.ended
) {
return;
}
appendLog(
`⏸ video 触发 pause,currentTime=${Number(video.currentTime || 0).toFixed(2)}`
);
/*
* 不在 pause 事件瞬间反复 play。
*
* 给网站自己的焦点切换逻辑
* 一个短暂的处理时间。
*/
setTimeout(
() => {
if (
!video.ended &&
video.paused &&
loadState(
'gxela_isLearning',
false
)
) {
appendLog(
'⚠ video 仍处于暂停,主动恢复播放'
);
forcePlayVideo(
video
);
}
},
BLUR_RECOVER_DELAY
);
}
);
document.addEventListener(
'visibilitychange',
() => {
if (
document.visibilityState !==
'visible'
) {
return;
}
if (
!loadState(
'gxela_isLearning',
false
)
) {
return;
}
setTimeout(
() => {
if (
!video.ended &&
video.paused
) {
appendLog(
'👁 页面恢复可见,video 仍暂停,恢复播放'
);
forcePlayVideo(
video
);
}
},
BLUR_RECOVER_DELAY
);
}
);
window.addEventListener(
'focus',
() => {
if (
!loadState(
'gxela_isLearning',
false
)
) {
return;
}
setTimeout(
() => {
if (
video &&
!video.ended &&
video.paused
) {
appendLog(
'🖥 窗口重新获得焦点,恢复视频播放'
);
forcePlayVideo(
video
);
}
},
BLUR_RECOVER_DELAY
);
}
);
}
/* ================================================================
* video 初始化
* ================================================================ */
async function setupVideo(
video
) {
if (!video) {
return;
}
currentVideo =
video;
forceMute(
video
);
/*
* 设置 ended
*/
if (
!video.__gxelaEndedBound
) {
video.__gxelaEndedBound =
true;
video.addEventListener(
'ended',
() => {
handleVideoEnded(
video
);
}
);
}
/*
* 焦点恢复
*/
bindFocusRecovery(
video
);
/*
* 视频加载后再次静音
*/
video.addEventListener(
'loadedmetadata',
() => {
forceMute(
video
);
}
);
video.addEventListener(
'canplay',
() => {
forceMute(
video
);
}
);
/*
* 播放状态
*/
const duration =
Number(
video.duration
);
const queue =
loadState(
'g_selectedQueue',
[]
);
const index =
loadState(
'g_currentIndex',
0
) || 0;
const current =
queue[index] ||
null;
const next =
queue[index + 1] ||
null;
setCurrentNext(
formatCourseLabel(
current,
isFinite(duration) &&
duration > 0
? duration
: null
),
formatCourseLabel(
next,
null
)
);
setStatus(
`[状态] 当前课程播放中`
);
startVideoStuckMonitor(
video
);
/*
* 核心:
* 无论网站是否自动播放,
* 首次进入发现 video 后,
* 都主动尝试 play。
*/
await forcePlayVideo(
video
);
scheduleBlankClick();
}
/* ================================================================
* 班级必修状态同步
* ================================================================ */
function saveClassSyncState() {
saveState('gxela_classSyncState', classSyncState);
const status = document.getElementById('gxela-classSyncStatus');
const result = document.getElementById('gxela-classSyncResult');
if (status) status.textContent = classSyncState.active
? `[状态] ${classSyncState.phase === 'quick' ? '快速同步' : '自然播放兜底'}:剩余 ${classSyncState.pending.length} 门`
: '[状态] 未在同步';
if (result) result.textContent = `已成功 ${classSyncState.results.length} 门;待处理/失败 ${classSyncState.failed.length} 门`;
}
async function classApi(url, method = 'GET', data = null) {
const response = await gmRequestRaw({
method,
url: `https://www.gxela.gov.cn${url}`,
data: data ? JSON.stringify(data) : undefined,
headers: {
platform: 'web',
Accept: 'application/json, text/plain, */*',
...(data ? { 'Content-Type': 'application/json;charset=UTF-8' } : {})
}
});
if (response.status !== 200) throw new Error(`班级接口 HTTP ${response.status}`);
const body = JSON.parse(response.responseText || '{}');
if (Number(body.code) !== 200) throw new Error(body.msg || '班级接口返回失败');
return body;
}
async function fetchAllOpenTrainClasses() {
const all = [];
for (let page = 1; ; page++) {
const body = await classApi(`/gateway/auth/trainclass/myTrainclasspage?pageNum=${page}&pageSize=100&trainQueryStatus=1`);
const rows = body.rows || [];
all.push(...rows);
if (!rows.length || all.length >= Number(body.total || 0)) break;
}
return all;
}
async function fetchClassCategories(trainClassId) {
const body = await classApi(`/gateway/auth/trainclass/top/${trainClassId}`);
return (body.data || []).filter(item => item.isLeaf);
}
async function fetchClassLessons(trainClassId, categoryId) {
const all = [];
for (let page = 1; ; page++) {
const body = await classApi('/gateway/auth/trainclass/category/lessonpage', 'POST', {
trainClassId: String(trainClassId), isMust: 1, pageNum: page,
pageSize: PAGE_SIZE, categoryIds: [String(categoryId)]
});
const rows = body.rows || [];
all.push(...rows);
if (!rows.length || all.length >= Number(body.total || 0)) break;
}
return all;
}
async function buildClassSyncTasks() {
const personalDone = await fetchMyCourses(3, 1, currentYear);
const doneIds = new Set(personalDone.map(item => String(item.lessonId)));
const classes = await fetchAllOpenTrainClasses();
const tasks = [];
for (const trainClass of classes) {
const categories = await fetchClassCategories(trainClass.trainClassId);
for (const category of categories) {
const lessons = await fetchClassLessons(trainClass.trainClassId, category.categoryId);
for (const lesson of lessons) {
if (doneIds.has(String(lesson.lessonId)) && Number(lesson.passed) !== 1) {
tasks.push({ ...lesson, trainClassName: trainClass.name, categoryName: category.name });
}
}
}
}
const seen = new Set();
return tasks.filter(item => {
const key = `${item.trainClassId}:${item.tcLessonId}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function classWatchUrl(task) {
return `https://www.gxela.gov.cn/watch?lessonId=${task.lessonId}&tcLessonId=${task.tcLessonId}&lessonOrigin=trainclass`;
}
async function startClassSync(retryOnly) {
try {
let tasks;
if (retryOnly) {
tasks = classSyncState.failed || [];
if (!tasks.length) {
setStatus('[状态] 没有失败项可重试');
return;
}
} else {
setStatus('正在读取班级必修课程...');
tasks = await buildClassSyncTasks();
}
classSyncState = { active: true, phase: 'quick', pending: tasks, failed: [], current: null, results: [] };
appendLog(`班级同步任务已生成:${tasks.length} 门`);
saveClassSyncState();
processNextClassTask();
} catch (e) {
appendLog(`班级同步初始化失败:${e}`);
setStatus('[状态] 班级同步初始化失败');
}
}
function stopClassSync() {
classSyncState.active = false;
classSyncState.current = null;
saveClassSyncState();
appendLog('用户停止班级必修同步');
}
function processNextClassTask() {
if (!classSyncState.active) return;
if (!classSyncState.pending.length) {
if (classSyncState.phase === 'quick' && classSyncState.failed.length) {
classSyncState.phase = 'fallback';
classSyncState.pending = [...classSyncState.failed];
classSyncState.failed = [];
appendLog(`快速同步完成,开始自然播放兜底:${classSyncState.pending.length} 门`);
} else {
classSyncState.active = false;
classSyncState.current = null;
appendLog(`班级同步结束:成功 ${classSyncState.results.length},失败 ${classSyncState.failed.length}`);
saveClassSyncState();
return;
}
}
classSyncState.current = classSyncState.pending.shift();
saveClassSyncState();
appendLog(`班级同步 ${classSyncState.phase}:${classSyncState.current.name}`);
window.location.href = classWatchUrl(classSyncState.current);
}
async function isCurrentClassTaskPassed() {
const task = classSyncState.current;
if (!task) return false;
const lessons = await fetchClassLessons(task.trainClassId, task.categoryId);
return lessons.some(item => String(item.tcLessonId) === String(task.tcLessonId) && Number(item.passed) === 1);
}
async function completeClassTask(success, reason) {
const task = classSyncState.current;
if (!task) return;
if (success) classSyncState.results.push({ ...task, reason });
else classSyncState.failed.push({ ...task, reason });
appendLog(`${success ? '✅' : '⚠'} 班级同步${success ? '成功' : '待重试'}:${task.name}(${reason})`);
classSyncState.current = null;
classSyncState.awaitingExitVerification = false;
saveClassSyncState();
setTimeout(processNextClassTask, 800);
}
function findExitCourseButton() {
return Array.from(document.querySelectorAll('button')).find(button =>
button.offsetParent !== null && button.textContent.trim() === '退出课程'
);
}
async function clickExitAndConfirm() {
const exit = findExitCourseButton();
if (!exit) throw new Error('未找到“退出课程”按钮');
exit.click();
for (let i = 0; i < 20; i++) {
const box = document.querySelector('.el-message-box');
if (box && box.offsetParent !== null && /确定退出当前课程吗?/.test(box.textContent)) {
const confirmBtn = Array.from(box.querySelectorAll('button')).find(button => button.textContent.trim() === '确定');
if (!confirmBtn) throw new Error('退出确认框中未找到“确定”按钮');
confirmBtn.click();
return;
}
await sleep(250);
}
throw new Error('未出现退出课程确认对话框');
}
async function setupClassSyncVideo(video) {
forceMute(video);
const task = classSyncState.current;
if (!task) return;
const started = await forcePlayVideo(video);
if (!started) return completeClassTask(false, '视频未能开始播放');
const startTime = Number(video.currentTime) || 0;
await sleep(1200);
if (video.paused || (Number(video.currentTime) || 0) <= startTime + 0.1) {
return completeClassTask(false, '未确认视频实际开始播放');
}
if (classSyncState.phase === 'quick') {
try {
classSyncState.awaitingExitVerification = true;
saveClassSyncState();
await clickExitAndConfirm();
await sleep(1200);
await completeClassTask(await isCurrentClassTaskPassed(), '快速播放并退出后核验');
} catch (e) {
await completeClassTask(false, `快速同步异常:${e.message || e}`);
}
return;
}
video.addEventListener('ended', async () => {
await sleep(1200);
try { await completeClassTask(await isCurrentClassTaskPassed(), '自然播放结束后核验'); }
catch (e) { await completeClassTask(false, `兜底核验异常:${e.message || e}`); }
}, { once: true });
}
async function resumeClassSyncAfterExit() {
if (!classSyncState.active || !classSyncState.current || !classSyncState.awaitingExitVerification) return;
await sleep(1200);
try {
await completeClassTask(await isCurrentClassTaskPassed(), '退出课程后的页面跳转核验');
} catch (e) {
await completeClassTask(false, `退出后核验异常:${e.message || e}`);
}
}
/* ================================================================
* watch 页面视频检测
* ================================================================ */
async function handleWatchPage() {
const isClassSyncWatch =
classSyncState.active &&
classSyncState.current &&
new URL(window.location.href).searchParams.get('lessonOrigin') === 'trainclass';
if (
!isClassSyncWatch &&
!loadState('gxela_isLearning', false)
) {
appendLog(
'watch 页面:当前没有处于自动学习状态'
);
return;
}
appendLog(
'进入 watch 页面,开始寻找 video'
);
let attempts = 0;
const detect = async () => {
if (
!loadState(
'gxela_isLearning',
false
)
) {
return;
}
const video =
findBestVideo();
if (video) {
appendLog(
`🎬 找到 video:paused=${video.paused} currentTime=${Number(video.currentTime || 0).toFixed(2)}`
);
if (isClassSyncWatch) await setupClassSyncVideo(video);
else await setupVideo(video);
return;
}
attempts++;
if (
attempts >=
VIDEO_DETECT_MAX_RETRIES
) {
appendLog(
`❌ ${VIDEO_DETECT_MAX_RETRIES} 次检测仍未找到 video`
);
setStatus(
'[状态] 未找到视频'
);
return;
}
appendLog(
`未找到 video,${VIDEO_DETECT_RETRY_MS / 1000} 秒后重试 ${attempts}/${VIDEO_DETECT_MAX_RETRIES}`
);
setTimeout(
detect,
VIDEO_DETECT_RETRY_MS
);
};
setTimeout(
detect,
800
);
/*
* 网站可能动态创建 video。
* MutationObserver 可以提前发现。
*/
const observer =
new MutationObserver(
() => {
if (
currentVideo
) {
return;
}
const video =
findBestVideo();
if (video) {
observer.disconnect();
if (isClassSyncWatch) setupClassSyncVideo(video);
else setupVideo(video);
}
}
);
observer.observe(
document.documentElement,
{
childList: true,
subtree: true
}
);
}
/* ================================================================
* 导出日志
* ================================================================ */
function exportLogs() {
try {
const blob =
new Blob(
[
logs.join('\n')
],
{
type:
'text/plain;charset=utf-8'
}
);
const url =
URL.createObjectURL(
blob
);
const a =
document.createElement(
'a'
);
a.href = url;
a.download =
`gxela_logs_${new Date()
.toISOString()
.replace(
/[:.]/g,
'-'
)}.txt`;
document.body.appendChild(
a
);
a.click();
a.remove();
URL.revokeObjectURL(
url
);
appendLog(
'日志已导出'
);
} catch (e) {
appendLog(
'日志导出失败:' +
e
);
}
}
/* ================================================================
* 刷新课程列表
* ================================================================ */
async function refreshCourseList() {
try {
const queue =
await fetchPlayQueue(
currentYear
);
renderCourseList();
appendLog(
`课程列表刷新完成,共 ${queue.length} 门`
);
} catch (e) {
appendLog(
'课程列表刷新失败:' +
e
);
}
}
/* ================================================================
* 初始化
* ================================================================ */
(async function init() {
/*
* 等 DOM
*/
if (
document.readyState ===
'loading'
) {
await new Promise(
resolve => {
document.addEventListener(
'DOMContentLoaded',
resolve,
{
once: true
}
);
}
);
}
createPanel();
/*
* clientid
*/
injectClientIdInterceptorToPage();
const scanned =
scanPageForClientId();
if (scanned) {
saveClientId(
scanned,
'scan'
);
} else {
appendLog(
'未检测到页面静态 clientid,继续监听网络请求'
);
}
/*
* 恢复目标设置
*/
const targetInputC =
document.getElementById(
'gxela-targetCompulsory'
);
const targetInputE =
document.getElementById(
'gxela-targetElective'
);
const yearInput =
document.getElementById(
'gxela-year'
);
if (targetInputC) {
targetInputC.value =
targetHours.compulsory ||
0;
}
if (targetInputE) {
targetInputE.value =
targetHours.elective ||
0;
}
if (yearInput) {
yearInput.value =
currentYear;
}
/*
* 恢复日志
*/
const logBox =
document.getElementById(
'gxela-logbox'
);
if (logBox) {
logBox.textContent =
logs.join('\n');
logBox.scrollTop =
logBox.scrollHeight;
}
restorePlaybackDisplay();
/*
* 自动刷新学时
*/
try {
await refreshLearnedAndPending(
currentYear
);
} catch (e) {
appendLog(
'初始化学时刷新失败:' +
e
);
}
/*
* 如果是 watch 页面,
* 接管视频。
*/
if (
window.location.pathname ===
'/watch' &&
window.location.search.includes(
'lessonId='
)
) {
await handleWatchPage();
}
/*
* 首页则刷新课程列表。
*/
else if (
window.location.pathname ===
'/study/index'
) {
try {
const learning =
loadState(
'gxela_isLearning',
false
);
if (learning) {
await refreshCourseList();
}
} catch (e) {
appendLog(
'首页课程列表恢复失败:' +
e
);
}
await resumeClassSyncAfterExit();
}
else {
await resumeClassSyncAfterExit();
}
appendLog(
'GXELA V3.6 面板初始化完成'
);
})();
})();