// ==UserScript==
// @name 荟智学园学习助手
// @namespace https://scriptcat.org/users/令狐不太冲
// @version 1.2.4
// @description 荟智学园增强:自动播放、1~16倍速、自动下一节;秒过本节/整课/我的班级全部课程;「我的考试」批量自动作答交卷
// @author 令狐不太冲
// @license MIT
// @icon https://gips3.baidu.com/it/u=2085307030,2248755990&fm=3030&app=3030&size=re3,2&q=75&n=0&g=4n&f=JPEG&fmt=auto&maxorilen2heic=2000000?s=E0332572F0E506B149B7A5C7020070A2
// @match https://hz.baosteel.com/*
// @match https://examhz.baosteel.com/*
// @run-at document-idle
// @grant none
// ==/UserScript==
(function () {
'use strict';
if (window.__HZ_HELPER_LOADED__) return;
window.__HZ_HELPER_LOADED__ = true;
/* ============================ 配置 ============================ */
const API_BASE = 'https://apihz.baosteel.com';
const CFG_KEY = 'hz_helper_cfg_v1';
const LOG_MAX = 200;
const DEFAULT_CFG = {
autoPlay: true, // 自动播放(静音绕过浏览器自动播放限制)
autoNext: true, // 播完自动下一节
instant: false, // 秒过模式:进入某节即上报完成并切下一节
rate: 2, // 倍速
sync: true // 按播放位置周期性同步进度(让倍速真正生效)
};
let CFG = Object.assign({}, DEFAULT_CFG);
try { Object.assign(CFG, JSON.parse(localStorage.getItem(CFG_KEY) || '{}') || {}); } catch (e) { /* ignore */ }
function saveCfg() {
try { localStorage.setItem(CFG_KEY, JSON.stringify(CFG)); } catch (e) { /* ignore */ }
}
/* ============================ 状态 ============================ */
const ST = {
courseId: null,
trainId: null,
courseName: '',
resId: null,
res: null,
list: [],
idx: -1,
record: null,
video: null,
busy: false,
bulkRunning: false,
handledResId: '',
retryCount: 0,
lastNavAt: 0,
rateTimer: null,
syncTimer: null,
keepTimer: null,
statusTimer: null,
panel: null,
logEl: null,
statusEl: null,
logLines: []
};
/* ============================ 工具 ============================ */
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function nowStr() {
const d = new Date();
const p = (n) => String(n).padStart(2, '0');
return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
}
function fmtTime(sec) {
sec = Math.max(0, Math.round(Number(sec) || 0));
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
const p = (n) => String(n).padStart(2, '0');
return (h > 0 ? h + ':' : '') + p(m) + ':' + p(s);
}
function hashQuery(key) {
const h = location.hash || '';
const m = h.match(new RegExp('[?&]' + key + '=([^&]*)'));
return m ? decodeURIComponent(m[1]) : null;
}
function isResourcePage() {
return (location.hash || '').indexOf('/resourceInfo') > -1 && !!hashQuery('id');
}
/* ============================ 日志 / 面板 ============================ */
const COLORS = { info: '#9aa4b2', ok: '#4ade80', warn: '#fbbf24', err: '#f87171', run: '#60a5fa' };
function log(msg, type) {
type = type || 'info';
const text = '[' + nowStr() + '] ' + msg;
try {
ST.logLines.push(text);
if (ST.logLines.length > 600) ST.logLines.splice(0, ST.logLines.length - 600);
} catch (e) { /* ignore */ }
const line = document.createElement('div');
line.style.cssText = 'color:' + (COLORS[type] || COLORS.info) + ';line-height:1.55;word-break:break-all;';
line.textContent = text;
if (ST.logEl) {
ST.logEl.appendChild(line);
while (ST.logEl.children.length > LOG_MAX) ST.logEl.removeChild(ST.logEl.firstChild);
ST.logEl.scrollTop = ST.logEl.scrollHeight;
}
console.log('%c[荟智助手] ' + msg, 'color:' + (COLORS[type] || '#888'));
}
async function copyText(text) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch (e) { /* 回退到 execCommand */ }
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return ok;
} catch (e) { return false; }
}
function buildPanel() {
if (ST.panel) return;
const root = document.createElement('div');
root.id = 'hz-helper-panel';
root.style.cssText = [
'position:fixed', 'right:16px', 'bottom:16px', 'width:302px', 'z-index:2147483000',
'background:#111827', 'border:1px solid #2b3547', 'border-radius:10px',
'box-shadow:0 10px 34px rgba(0,0,0,.45)', 'color:#d1d5db',
'font:13px/1.5 "Microsoft YaHei",system-ui,sans-serif', 'overflow:hidden', 'user-select:none'
].join(';');
const hd = document.createElement('div');
hd.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:9px 12px;cursor:move;background:linear-gradient(90deg,#1f2937,#111827);border-bottom:1px solid #2b3547;';
hd.innerHTML = '荟智学园助手 v1.2.4' +
'' +
'—' +
'';
const bd = document.createElement('div');
bd.id = 'hz-body';
bd.style.cssText = 'padding:10px 12px 12px;';
const rowStyle = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:7px;';
const chkStyle = 'width:16px;height:16px;accent-color:#3b82f6;cursor:pointer;';
function makeRow(labelText, checked, onChange, id) {
const row = document.createElement('div');
row.style.cssText = rowStyle;
const lab = document.createElement('label');
lab.textContent = labelText;
lab.style.cssText = 'color:#cbd5e1;cursor:pointer;';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.id = id;
cb.checked = !!checked;
cb.style.cssText = chkStyle;
cb.addEventListener('change', () => onChange(cb.checked));
lab.addEventListener('click', () => { cb.checked = !cb.checked; onChange(cb.checked); });
row.appendChild(lab);
row.appendChild(cb);
bd.appendChild(row);
return cb;
}
makeRow('自动播放(静音)', CFG.autoPlay, (v) => { CFG.autoPlay = v; saveCfg(); if (v) tryPlay(); }, 'hz-auto-play');
makeRow('播完自动下一节', CFG.autoNext, (v) => { CFG.autoNext = v; saveCfg(); }, 'hz-auto-next');
makeRow('秒过模式(进入即完成)', CFG.instant, (v) => {
CFG.instant = v;
saveCfg();
if (v) {
ST.handledResId = '';
if (!ST.res) loadContext();
else scheduleInstant();
}
}, 'hz-instant');
makeRow('按播放位置同步进度', CFG.sync, (v) => { CFG.sync = v; saveCfg(); }, 'hz-sync');
// 倍速
const rateRow = document.createElement('div');
rateRow.style.cssText = rowStyle;
rateRow.innerHTML = '';
const sel = document.createElement('select');
sel.id = 'hz-rate';
sel.style.cssText = 'background:#1f2937;color:#e5e7eb;border:1px solid #374151;border-radius:5px;padding:2px 6px;font-size:12px;outline:none;cursor:pointer;';
[1, 1.25, 1.5, 2, 3, 4, 8, 16].forEach((r) => {
const o = document.createElement('option');
o.value = String(r);
o.textContent = '×' + r;
if (Number(CFG.rate) === r) o.selected = true;
sel.appendChild(o);
});
sel.addEventListener('change', () => {
CFG.rate = Number(sel.value) || 1;
saveCfg();
applyRate(true);
log('倍速设为 ×' + CFG.rate, 'run');
});
rateRow.appendChild(sel);
bd.appendChild(rateRow);
// 按钮
const btns = document.createElement('div');
btns.style.cssText = 'display:flex;gap:8px;margin:10px 0 8px;';
const btnStyle = 'flex:1;padding:7px 0;border:none;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;color:#fff;';
const bPass = document.createElement('button');
bPass.id = 'hz-pass';
bPass.textContent = '秒过本节';
bPass.style.cssText = btnStyle + 'background:#2563eb;';
bPass.addEventListener('click', () => runPassCurrent(true));
const bAll = document.createElement('button');
bAll.id = 'hz-pass-all';
bAll.textContent = '秒过整课';
bAll.style.cssText = btnStyle + 'background:#7c3aed;';
bAll.addEventListener('click', () => runPassCourse());
btns.appendChild(bPass);
btns.appendChild(bAll);
bd.appendChild(btns);
// 全账号秒过
const row2 = document.createElement('div');
row2.style.cssText = 'display:flex;gap:8px;margin:0 0 8px;';
const bAllAcc = document.createElement('button');
bAllAcc.id = 'hz-pass-all-account';
bAllAcc.textContent = '秒过所有';
bAllAcc.title = '仅处理「我的班级」下所有未完成的课程,逐门秒过,无需打开学习页';
bAllAcc.style.cssText = btnStyle + 'flex:2;background:#dc2626;';
bAllAcc.addEventListener('click', () => runPassAll());
const bStopAll = document.createElement('button');
bStopAll.id = 'hz-pass-stop';
bStopAll.textContent = '停止';
bStopAll.style.cssText = btnStyle + 'flex:1;background:#4b5563;opacity:.5;';
bStopAll.disabled = true;
bStopAll.addEventListener('click', () => { ALL.stop = true; log('已发出停止指令…', 'warn'); });
row2.appendChild(bAllAcc);
row2.appendChild(bStopAll);
bd.appendChild(row2);
// 我的考试(批量自动作答)
const row3 = document.createElement('div');
row3.style.cssText = 'display:flex;gap:8px;margin:0 0 8px;';
const bExam = document.createElement('button');
bExam.id = 'hz-my-exam';
bExam.textContent = '我的考试(自动作答)';
bExam.title = '读取「我的学习 → 我的考试」,勾选后逐场自动作答并交卷';
bExam.style.cssText = btnStyle + 'flex:1;background:#0d9488;';
bExam.addEventListener('click', () => openExamModal());
const bDiag = document.createElement('button');
bDiag.id = 'hz-diag';
bDiag.textContent = '诊断';
bDiag.title = '把当前课程的资源与平台记录打印到日志,排查「上报了但未判定完成」';
bDiag.style.cssText = btnStyle + 'flex:0 0 72px;background:#475569;';
bDiag.addEventListener('click', () => runDiagnose());
row3.appendChild(bExam);
row3.appendChild(bDiag);
bd.appendChild(row3);
// 状态
const status = document.createElement('div');
status.id = 'hz-status';
status.style.cssText = 'background:#0b1220;border:1px solid #1f2937;border-radius:6px;padding:7px 9px;font-size:12px;color:#9ca3af;line-height:1.7;margin-bottom:8px;white-space:pre-line;user-select:text;-webkit-user-select:text;';
status.textContent = '等待进入具体学习页…';
bd.appendChild(status);
// 日志工具条
const logBar = document.createElement('div');
logBar.style.cssText = 'display:flex;gap:6px;justify-content:flex-end;margin:0 0 5px;';
const tinyBtn = 'padding:3px 8px;border:none;border-radius:5px;font-size:11px;font-weight:600;cursor:pointer;color:#e5e7eb;background:#334155;';
const bCopy = document.createElement('button');
bCopy.id = 'hz-copy-log';
bCopy.type = 'button';
bCopy.textContent = '复制日志';
bCopy.title = '把日志区内容一键复制到剪贴板';
bCopy.style.cssText = tinyBtn;
bCopy.addEventListener('click', async () => {
const txt = (ST.logLines || []).join('\n') || '(暂无日志)';
const ok = await copyText(txt);
bCopy.textContent = ok ? '已复制' : '复制失败';
setTimeout(() => { bCopy.textContent = '复制日志'; }, 1500);
});
const bClear = document.createElement('button');
bClear.id = 'hz-clear-log';
bClear.type = 'button';
bClear.textContent = '清空';
bClear.title = '清空日志区';
bClear.style.cssText = tinyBtn + 'background:#1f2937;';
bClear.addEventListener('click', () => {
ST.logLines = [];
if (ST.logEl) ST.logEl.innerHTML = '';
bClear.textContent = '已清空';
setTimeout(() => { bClear.textContent = '清空'; }, 1200);
});
logBar.appendChild(bCopy);
logBar.appendChild(bClear);
bd.appendChild(logBar);
// 日志
const lg = document.createElement('div');
lg.id = 'hz-log';
lg.style.cssText = 'height:98px;overflow-y:auto;background:#0b1220;border:1px solid #1f2937;border-radius:6px;padding:6px 8px;font-size:11.5px;user-select:text;-webkit-user-select:text;cursor:text;';
bd.appendChild(lg);
root.appendChild(hd);
root.appendChild(bd);
document.body.appendChild(root);
ST.panel = root;
ST.logEl = lg;
ST.statusEl = status;
// 折叠
let folded = false;
hd.querySelector('#hz-fold').addEventListener('click', (ev) => {
ev.stopPropagation();
folded = !folded;
bd.style.display = folded ? 'none' : 'block';
hd.querySelector('#hz-fold').textContent = folded ? '+' : '—';
});
// 拖动
let dragging = false, sx = 0, sy = 0, ox = 0, oy = 0;
hd.addEventListener('mousedown', (ev) => {
if (ev.target.id === 'hz-fold') return;
dragging = true;
const rect = root.getBoundingClientRect();
sx = ev.clientX; sy = ev.clientY; ox = rect.left; oy = rect.top;
ev.preventDefault();
});
document.addEventListener('mousemove', (ev) => {
if (!dragging) return;
const w = root.offsetWidth, h = root.offsetHeight;
let left = ox + ev.clientX - sx;
let top = oy + ev.clientY - sy;
left = Math.max(4, Math.min(window.innerWidth - w - 4, left));
top = Math.max(4, Math.min(window.innerHeight - h - 4, top));
root.style.left = left + 'px';
root.style.top = top + 'px';
root.style.right = 'auto';
root.style.bottom = 'auto';
});
document.addEventListener('mouseup', () => { dragging = false; });
}
function render() {
if (!ST.statusEl) return;
const r = ST.res;
const v = ST.video;
if (!r) {
const tk = getToken();
ST.statusEl.textContent = isResourcePage()
? '正在读取资源信息…' + (tk ? '' : '\n登录态未获取到,正在重试')
: '可直接点「秒过所有」批量处理账号下的课程';
return;
}
const total = Math.round(Number(r.duration) || (v && isFinite(v.duration) ? v.duration : 0) || 0);
const pos = v ? Math.floor(v.currentTime || 0) : 0;
const pct = total > 0 ? Math.min(100, Math.round((pos / total) * 100)) : 0;
const typeMap = { '.mp4': '视频', '.pdf': 'PDF', html: 'H5图文' };
const recItem = ST.record && Array.isArray(ST.record.resUserRecords)
? ST.record.resUserRecords.find((x) => x.courseResourceId === r.id)
: null;
const lines = [];
if (ST.courseName) lines.push('课程:' + ST.courseName);
lines.push('本节:' + (ST.idx >= 0 ? (ST.idx + 1) + ' / ' + ST.list.length : '-') + ' ' + (r.name || ''));
lines.push('类型:' + (typeMap[r.fileType] || r.resType || r.fileType || '未知') + ' 时长:' + (total ? fmtTime(total) : '未知'));
if (v) lines.push('播放:' + (v.paused ? '已暂停' : '播放中') + ' 进度 ' + fmtTime(pos) + '(' + pct + '%)×' + (v.playbackRate || 1));
if (recItem) lines.push('平台已记录:' + fmtTime(recItem.resLearnDuration || 0) + (recItem.isComplete ? '(已完成)' : '(未完成)'));
ST.statusEl.textContent = lines.join('\n');
}
/* ============================ 接口 ============================ */
/* 该平台的登录态保存在 Cookie(js-cookie 写入),不是 localStorage */
const TOKEN_COOKIE = 'CMS-Public-Token';
let CAPTURED_TOKEN = '';
let TOKEN_SOURCE = '';
function readCookie(name) {
try {
const safe = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const m = document.cookie.match(new RegExp('(?:^|;\\s*)' + safe + '=([^;]*)'));
return m ? decodeURIComponent(m[1]) : '';
} catch (e) { return ''; }
}
function readVueToken() {
try {
const el = document.getElementById('app');
const vm = el && el.__vue__;
if (!vm) return '';
const store = vm.$store || (vm.$root && vm.$root.$store);
const t = store && store.getters ? store.getters.token : '';
return typeof t === 'string' ? t : '';
} catch (e) { return ''; }
}
function getToken() {
const cookie = String(readCookie(TOKEN_COOKIE) || '').trim();
if (cookie) { TOKEN_SOURCE = 'Cookie'; return cookie; }
const vueToken = String(readVueToken() || '').trim();
if (vueToken) { TOKEN_SOURCE = '页面内存'; return vueToken; }
if (CAPTURED_TOKEN) { TOKEN_SOURCE = '请求抓取'; return CAPTURED_TOKEN; }
try {
const keys = ['CMS-Public-Token', 'token', 'access_token', 'Access-Token'];
for (let i = 0; i < keys.length; i++) {
let v = localStorage.getItem(keys[i]);
if (!v) continue;
v = v.trim();
if (v.charAt(0) === '"') { try { v = JSON.parse(v); } catch (e) { /* ignore */ } }
if (v) { TOKEN_SOURCE = 'localStorage'; return String(v); }
}
} catch (e) { /* ignore */ }
TOKEN_SOURCE = '';
return '';
}
/* 兜底:从页面自身的请求里抓 Authorization,防止取不到 Cookie */
function installTokenSniffer() {
try {
if (typeof window.fetch === 'function' && !window.fetch.__hzPatched) {
const originFetch = window.fetch;
const patched = function (input, init) {
try {
const url = typeof input === 'string' ? input : ((input && input.url) || '');
if (url && url.indexOf('apihz.baosteel.com') > -1 && init && init.headers) {
const h = init.headers;
let auth = '';
if (typeof h.get === 'function') auth = h.get('Authorization') || h.get('authorization') || '';
else auth = h.Authorization || h.authorization || '';
if (auth) CAPTURED_TOKEN = String(auth).replace(/^Bearer\s+/i, '');
}
} catch (e) { /* ignore */ }
return originFetch.apply(this, arguments);
};
patched.__hzPatched = true;
window.fetch = patched;
}
const xo = XMLHttpRequest.prototype.setRequestHeader;
if (xo && !xo.__hzPatched) {
const patchedSet = function (k, v) {
try {
if (String(k).toLowerCase() === 'authorization' && v) {
CAPTURED_TOKEN = String(v).replace(/^Bearer\s+/i, '');
}
} catch (e) { /* ignore */ }
return xo.apply(this, arguments);
};
patchedSet.__hzPatched = true;
XMLHttpRequest.prototype.setRequestHeader = patchedSet;
}
} catch (e) { /* ignore */ }
}
const ENC_KEY = 'hz_helper_enc';
let ENCODING = 'json';
try { if (localStorage.getItem(ENC_KEY) === 'form') ENCODING = 'form'; } catch (e) { /* ignore */ }
function setEncoding(enc) {
if (ENCODING === enc) return;
ENCODING = enc;
try { localStorage.setItem(ENC_KEY, enc); } catch (e) { /* ignore */ }
log('接口提交方式已切换为:' + (enc === 'form' ? '表单' : 'JSON'), 'run');
}
function encodeBody(obj, enc) {
if (enc === 'form') {
const form = new URLSearchParams();
Object.keys(obj).forEach((k) => {
const val = obj[k];
if (val === null || val === undefined) return;
form.append(k, typeof val === 'object' ? JSON.stringify(val) : String(val));
});
return { contentType: 'application/x-www-form-urlencoded;charset=UTF-8', body: form.toString() };
}
return { contentType: 'application/json;charset=UTF-8', body: JSON.stringify(obj) };
}
async function api(path, opt) {
opt = opt || {};
const token = getToken();
if (!token) throw new Error('未获取到登录凭证(Cookie / 页面内存 / 请求头均为空),请刷新页面后重试');
const enc = opt.encoding || ENCODING;
const headers = { 'Accept-Language': 'zh-Hans', 'Authorization': 'Bearer ' + token };
let body;
if (opt.body !== undefined) {
const packed = encodeBody(opt.body, enc);
headers['Content-Type'] = packed.contentType;
body = packed.body;
}
const reqOpt = {
method: opt.method || 'GET',
headers,
body,
mode: 'cors',
cache: 'no-store',
credentials: 'omit'
};
let resp = await fetch(API_BASE + path, reqOpt);
// 后端绑定方式不匹配时,换另一种编码再试一次
if (!resp.ok && opt.body !== undefined && (resp.status === 400 || resp.status === 405 || resp.status === 415)) {
const alt = enc === 'json' ? 'form' : 'json';
const packed2 = encodeBody(opt.body, alt);
const retry = await fetch(API_BASE + path, Object.assign({}, reqOpt, {
headers: Object.assign({}, headers, { 'Content-Type': packed2.contentType }),
body: packed2.body
}));
if (retry.ok) {
setEncoding(alt);
resp = retry;
}
}
const text = await resp.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch (e) { data = text; }
if (!resp.ok) {
let msg = 'HTTP ' + resp.status;
if (data && data.error && data.error.message) msg += ' ' + data.error.message;
else if (typeof data === 'string' && data) msg += ' ' + data.slice(0, 120);
throw new Error(msg);
}
return data;
}
const apiResource = (id) => api('/api/cms/public/courses/resource/' + encodeURIComponent(id));
const apiCourse = (id) => api('/api/cms/public/courses/' + encodeURIComponent(id));
function apiRecord() {
const params = new URLSearchParams();
if (ST.courseId) params.append('courseId', ST.courseId);
if (ST.trainId) params.append('trainId', ST.trainId);
return api('/api/cms/public/courses/user-record?' + params.toString());
}
function clean(obj) {
const out = {};
Object.keys(obj).forEach((k) => {
if (obj[k] !== null && obj[k] !== undefined) out[k] = obj[k];
});
return out;
}
const apiUpdateRes = (payload, enc) => api('/api/cms/public/courses/user-record/update-res', { method: 'POST', body: clean(payload), encoding: enc });
const apiUpdateCourse = (payload) => api('/api/cms/public/courses/user-record/update-course', { method: 'POST', body: clean(payload) });
/* ============================ 资源列表 ============================ */
function flattenResources(list) {
const out = [];
const walk = (arr) => {
(arr || []).forEach((d) => {
if (!d) return;
if (Array.isArray(d.resources) && d.resources.length) out.push.apply(out, d.resources);
if (Array.isArray(d.directories) && d.directories.length) walk(d.directories);
});
};
walk(list);
return out;
}
/* ============================ 上下文加载 ============================ */
let loadSeq = 0;
async function loadContext() {
ST.res = null;
ST.video = null;
ST.idx = -1;
if (!isResourcePage()) {
render();
return;
}
const seq = ++loadSeq;
const resId = hashQuery('id');
const courseId = hashQuery('courseId');
const trainId = hashQuery('trainId');
ST.resId = resId;
ST.courseId = courseId || ST.courseId;
ST.trainId = trainId || null;
try {
const jobs = [apiResource(resId)];
jobs.push(ST.courseId ? apiCourse(ST.courseId) : Promise.resolve(null));
const [res, course] = await Promise.all(jobs);
if (seq !== loadSeq) return;
ST.res = res;
if (course) {
ST.courseName = course.name || '';
ST.list = flattenResources(course.directoryResources || course.directories);
}
if (!ST.list.length) ST.list = [res];
ST.idx = ST.list.findIndex((x) => x.id === resId);
try { ST.record = await apiRecord(); } catch (e) { ST.record = null; }
if (seq !== loadSeq) return;
ST.retryCount = 0;
log('已进入:' + (res.name || '资源') + '(' + (ST.idx + 1) + '/' + ST.list.length + ')', 'ok');
render();
if (CFG.instant) {
scheduleInstant();
} else {
setupVideo();
}
} catch (e) {
if (seq !== loadSeq) return;
log('加载资源失败:' + e.message, 'err');
render();
// 接口失败不影响播放器控制:倍速与自动播放照常生效
setupVideo();
if (ST.retryCount < 3) {
ST.retryCount = (ST.retryCount || 0) + 1;
setTimeout(loadContext, 2500);
} else {
log('资源信息连续读取失败,可刷新页面或重新登录后再试', 'err');
}
}
}
/* ============================ 播放器控制 ============================ */
function findVideo() {
return document.querySelector('#myPlayer_html5_api')
|| document.querySelector('.videoBox video')
|| document.querySelector('video');
}
async function tryPlay() {
const v = ST.video;
if (!v) return;
try {
v.muted = true;
await v.play();
} catch (e) {
const btn = document.querySelector('.vjs-big-play-button')
|| document.querySelector('.videoBox .vjs-big-play-button')
|| document.querySelector('#myPlayer .prism-play-btn');
if (btn) {
try { btn.click(); } catch (_) { /* ignore */ }
await sleep(600);
}
try { await v.play(); } catch (_) { /* 仍需用户点击一次,浏览器策略限制 */ }
}
}
function applyRate(force) {
const v = ST.video;
if (!v) return;
const rate = Number(CFG.rate) || 1;
if (rate === 1 && !force) return;
if (Math.abs((v.playbackRate || 1) - rate) > 0.01) {
try {
v.playbackRate = rate;
v.defaultPlaybackRate = rate;
} catch (e) { /* ignore */ }
}
}
function hookVideo(v) {
if (!v) return;
ST.video = v;
if (!v.__hzHooked) {
v.__hzHooked = true;
v.addEventListener('ended', onVideoEnd);
v.addEventListener('loadedmetadata', () => { applyRate(true); render(); });
v.addEventListener('play', () => { applyRate(); render(); });
}
applyRate(true);
if (CFG.autoPlay) tryPlay();
render();
if (!ST.rateTimer) ST.rateTimer = setInterval(() => applyRate(), 1500);
if (!ST.syncTimer) ST.syncTimer = setInterval(syncProgress, 20000);
if (!ST.keepTimer) {
ST.keepTimer = setInterval(() => {
if (!CFG.autoPlay || ST.busy || ST.bulkRunning) return;
const vid = ST.video;
if (!vid || !vid.__hzHooked) return;
if (document.visibilityState === 'hidden') return;
if (vid.paused && !vid.ended) tryPlay();
}, 4000);
}
}
function setupVideo() {
let tries = 0;
const timer = setInterval(() => {
const v = findVideo();
if (v) {
clearInterval(timer);
hookVideo(v);
return;
}
if (++tries > 75) {
clearInterval(timer);
log('本节未检测到视频元素(可能是 PDF / H5 资源),可用「秒过本节」', 'warn');
}
}, 400);
}
async function onVideoEnd() {
if (ST.busy || ST.bulkRunning) return;
const res = ST.res;
if (!res) return;
log('本节播放结束:' + (res.name || ''), 'ok');
try {
const total = Math.round(Number(res.duration) || (ST.video && isFinite(ST.video.duration) ? ST.video.duration : 0) || 0);
if (total > 0) {
await apiUpdateRes({
trainId: ST.trainId || null,
courseId: ST.courseId,
courseResourceId: res.id,
isVideo: true,
resTotalDuration: total,
resLearnDuration: total,
lastPlayProgress: total,
lastLearnProgress: 100
});
log('已按完整时长上报完成', 'ok');
}
} catch (e) {
log('完成上报失败:' + e.message, 'err');
}
if (CFG.autoNext) {
await sleep(800);
goNext('播放结束');
}
}
function syncProgress() {
if (!CFG.sync || ST.busy || ST.bulkRunning) return;
const v = ST.video;
const res = ST.res;
if (!v || !res || v.readyState < 1 || v.paused) return;
const total = Math.round(Number(res.duration) || (isFinite(v.duration) ? v.duration : 0) || 0);
const pos = Math.floor(v.currentTime || 0);
if (!total || pos <= 0) return;
apiUpdateRes({
trainId: ST.trainId || null,
courseId: ST.courseId,
courseResourceId: res.id,
isVideo: true,
resTotalDuration: total,
resLearnDuration: pos,
lastPlayProgress: pos,
lastLearnProgress: Math.min(100, Math.round((pos / total) * 100))
}).catch(() => { /* 静默失败,下次再同步 */ });
}
/* ============================ 秒过 ============================ */
async function verifyDone(resId, total) {
try {
const rec = await apiRecord();
ST.record = rec;
const item = (rec && Array.isArray(rec.resUserRecords) ? rec.resUserRecords : [])
.find((x) => x.courseResourceId === resId);
if (!item) return false;
if (item.isComplete === true) return true;
return total > 0 && Number(item.resLearnDuration || 0) >= total * 0.98;
} catch (e) {
return false;
}
}
async function passOne(res) {
const total = Math.round(Number(res.duration) || 0);
if (!total) {
if (!ST.courseId) {
log('该节没有时长信息且缺少 courseId,无法秒过:' + (res.name || res.id), 'warn');
return false;
}
const r0 = await reportResource(ST.courseId, ST.trainId, res);
log((r0.done ? '秒过成功(图文/无时长):' : '秒过未判定(图文/无时长):') + (res.name || ''), r0.done ? 'ok' : 'warn');
return r0.done;
}
const isVideo = String(res.fileType || '').toLowerCase() === '.mp4';
const payload = {
trainId: ST.trainId || null,
courseId: ST.courseId,
courseResourceId: res.id,
isVideo: isVideo,
resTotalDuration: total,
resLearnDuration: total,
lastPlayProgress: total,
lastLearnProgress: 100
};
const order = ENCODING === 'json' ? ['json', 'form'] : ['form', 'json'];
let ok = false;
let usedEnc = null;
let lastErr = null;
for (let i = 0; i < order.length && !ok; i++) {
try {
await apiUpdateRes(payload, order[i]);
await sleep(400);
if (await verifyDone(res.id, total)) {
ok = true;
usedEnc = order[i];
}
} catch (e) {
lastErr = e;
}
}
if (ok) {
if (usedEnc) setEncoding(usedEnc);
log('秒过成功:' + (res.name || ''), 'ok');
} else if (lastErr) {
log('秒过失败:' + (res.name || '') + ' ' + lastErr.message, 'err');
} else {
log('已提交,但平台暂未判定完成:' + (res.name || '') + '(可在课程目录核对)', 'warn');
}
return ok;
}
async function runPassCurrent(withNext) {
if (!ST.res) { log('当前没有可处理的资源', 'warn'); return false; }
if (ST.busy || ST.bulkRunning) return false;
ST.busy = true;
let ok = false;
try {
ok = await passOne(ST.res);
if (ok && withNext && CFG.autoNext !== false) {
await sleep(500);
await goNext('秒过');
}
render();
} catch (e) {
log('秒过失败:' + e.message, 'err');
} finally {
ST.busy = false;
}
return ok;
}
function scheduleInstant() {
if (!ST.res || !CFG.instant) return;
if (ST.handledResId === ST.res.id) return;
ST.handledResId = ST.res.id;
setTimeout(async () => {
if (!CFG.instant || !ST.res) return;
const ok = await runPassCurrent(false);
if (ok && CFG.autoNext) {
await sleep(450);
goNext('秒过');
}
}, 500);
}
async function runPassCourse() {
if (ST.bulkRunning) { log('整课秒过正在进行中…', 'warn'); return; }
if (!ST.list.length && ST.courseId) {
try {
const course = await apiCourse(ST.courseId);
ST.courseName = course.name || ST.courseName;
ST.list = flattenResources(course.directoryResources || course.directories);
} catch (e) {
log('获取课程资源列表失败:' + e.message, 'err');
return;
}
}
if (!ST.list.length) { log('没有取到课程资源列表,请确认已进入课程内的学习页', 'err'); return; }
ST.bulkRunning = true;
ST.busy = true;
let ok = 0, skip = 0, fail = 0;
log('开始整课秒过,共 ' + ST.list.length + ' 节', 'run');
try {
for (let i = 0; i < ST.list.length; i++) {
const res = ST.list[i];
if (!res || !res.id) { continue; }
const total = Math.round(Number(res.duration) || 0);
if (!total) {
if (!ST.courseId) { skip++; log('跳过(无时长且无 courseId):' + (res.name || res.id), 'warn'); continue; }
const rr = await reportResource(ST.courseId, ST.trainId, res);
if (rr.done) { ok++; log('完成(图文/无时长):' + (res.name || res.id), 'ok'); }
else { skip++; log('未判定(图文/无时长):' + (res.name || res.id), 'warn'); }
await sleep(300);
continue;
}
try {
const isVideo = String(res.fileType || '').toLowerCase() === '.mp4';
await apiUpdateRes({
trainId: ST.trainId || null,
courseId: ST.courseId,
courseResourceId: res.id,
isVideo: isVideo,
resTotalDuration: total,
resLearnDuration: total,
lastPlayProgress: total,
lastLearnProgress: 100
});
ok++;
log('进度上报 ' + (i + 1) + '/' + ST.list.length + ':' + (res.name || ''), 'ok');
} catch (e) {
fail++;
log('上报失败 ' + (i + 1) + ':' + e.message, 'err');
}
await sleep(500 + Math.floor(Math.random() * 500));
}
// 记录最后学习的资源
try {
const lastRes = ST.list[ST.list.length - 1];
await apiUpdateCourse({ trainId: ST.trainId || null, courseId: ST.courseId, lastLearnResourceId: lastRes.id });
} catch (e) { /* 非关键 */ }
log('整课秒过结束:成功 ' + ok + ' 节,跳过 ' + skip + ' 节,失败 ' + fail + ' 节', ok ? 'ok' : 'warn');
// 复核一遍
try {
const rec = await apiRecord();
ST.record = rec;
const items = (rec && rec.resUserRecords) || [];
const doneCount = items.filter((x) => x.isComplete === true).length;
log('平台当前记录:已完成 ' + doneCount + ' / ' + ST.list.length + ' 节', 'run');
} catch (e) { /* ignore */ }
render();
if (ok > 0) {
log('2 秒后自动刷新页面以同步状态', 'run');
setTimeout(() => location.reload(), 2000);
}
} finally {
ST.bulkRunning = false;
ST.busy = false;
}
}
/* ============================ 切节 ============================ */
async function goNext(reason) {
if (!ST.list.length || ST.idx < 0) {
log('无法确定下一节,请手动切换', 'warn');
return;
}
const next = ST.list[ST.idx + 1];
if (!next) {
log('已经是最后一节' + (reason ? '(' + reason + ')' : '') + ',已停止', 'warn');
if (CFG.instant) {
CFG.instant = false;
saveCfg();
const cb = document.getElementById('hz-instant');
if (cb) cb.checked = false;
log('秒过模式已自动关闭', 'run');
}
return;
}
const now = Date.now();
if (now - ST.lastNavAt < 1500) return;
ST.lastNavAt = now;
log('切换到下一节:' + (next.name || ''), 'run');
try {
await apiUpdateCourse({ trainId: ST.trainId || null, courseId: ST.courseId, lastLearnResourceId: next.id });
} catch (e) { /* 非关键 */ }
let hash = '#/resourceInfo?id=' + next.id;
if (ST.courseId) hash += '&courseId=' + ST.courseId;
if (ST.trainId) hash += '&trainId=' + ST.trainId;
location.hash = hash;
}
/* ============================ 全账号秒过 ============================ */
const ALL = { running: false, stop: false, done: 0, total: 0 };
function qs(obj) {
const p = new URLSearchParams();
Object.keys(obj).forEach((k) => {
const v = obj[k];
if (v !== null && v !== undefined && v !== '') p.append(k, v);
});
return p.toString();
}
function setAllBtnState(running) {
const b1 = document.getElementById('hz-pass-all-account');
const b2 = document.getElementById('hz-pass-stop');
if (b1) {
b1.disabled = running;
b1.style.opacity = running ? '.55' : '1';
b1.textContent = running ? '秒过进行中…' : '秒过所有';
}
if (b2) {
b2.disabled = !running;
b2.style.opacity = running ? '1' : '.5';
}
}
async function listMyTrains() {
const out = [];
let skip = 0;
for (let g = 0; g < 20; g++) {
const data = await api('/api/cms/public/trains/my-trains?' + qs({ SkipCount: skip, MaxResultCount: 50 }));
const items = (data && data.items) || [];
out.push.apply(out, items);
const total = (data && data.totalCount) || 0;
if (!items.length || out.length >= total) break;
skip += items.length;
}
return out;
}
async function getCourseRecord(courseId, trainId) {
try {
return await api('/api/cms/public/courses/user-record?' + qs({ courseId: courseId, trainId: trainId || null }));
} catch (e) {
return null;
}
}
function flattenDir(list) {
const out = [];
const walk = (arr) => {
(arr || []).forEach((d) => {
if (!d) return;
if (Array.isArray(d.resources) && d.resources.length) out.push.apply(out, d.resources);
if (Array.isArray(d.directories) && d.directories.length) walk(d.directories);
});
};
walk(list);
return out;
}
/* 上报方式变体:按资源类型分组记忆,视频用一组、图文/PDF/无时长用另一组 */
const RES_V_KEY = 'hz_helper_res_v2';
let RES_V = { video: '', other: '' };
try {
const raw = localStorage.getItem(RES_V_KEY);
if (raw) {
const o = JSON.parse(raw) || {};
RES_V.video = o.video || '';
RES_V.other = o.other || '';
}
} catch (e) { /* ignore */ }
function resCategory(r) {
const ft = String(r.fileType || '').toLowerCase();
const total = Math.round(Number(r.duration) || 0);
return ft === '.mp4' && total > 0 ? 'video' : 'other';
}
function saveResVariant(cat, id) {
if (RES_V[cat] === id) return;
RES_V[cat] = id;
try { localStorage.setItem(RES_V_KEY, JSON.stringify(RES_V)); } catch (e) { /* ignore */ }
log('上报方式已记录:' + (cat === 'video' ? '视频' : '图文/PDF') + ' → ' + id, 'info');
}
function buildVariants(cat, total) {
if (cat === 'video') {
const base = { isVideo: true, resTotalDuration: total, resLearnDuration: total, lastPlayProgress: total };
return [
{ id: 'A', body: Object.assign({}, base, { lastLearnProgress: 100 }) },
{ id: 'B', body: Object.assign({}, base, { lastLearnProgress: 100, learnDetail: JSON.stringify({ learnTime: total, progress: 100 }) }) },
{ id: 'C', body: Object.assign({}, base) }
];
}
// 图文 / PDF / 时长为 0 的节:平台自己的阅读器上报的学时很短,duration 常为 0
const t = total > 0 ? total : 0;
const learned = t > 0 ? t : 60;
return [
{ id: 'P1', body: { isVideo: false, resTotalDuration: t, resLearnDuration: learned, lastPlayProgress: 0, lastLearnProgress: 100 } },
{ id: 'P2', body: { isVideo: false, resTotalDuration: t, resLearnDuration: learned, lastPlayProgress: 0, lastLearnProgress: 100, learnDetail: JSON.stringify({ learnTime: learned, progress: 100 }) } },
{ id: 'P3', body: { isVideo: false, resTotalDuration: t || 1, resLearnDuration: t || 1, lastPlayProgress: 0, lastLearnProgress: 100 } },
{ id: 'P4', body: { isVideo: false, resTotalDuration: t, resLearnDuration: 0, lastPlayProgress: 0, lastLearnProgress: 100 } },
{ id: 'P5', body: { isVideo: false, resLearnDuration: learned, lastPlayProgress: 0, lastLearnProgress: 100 } }
];
}
async function isResDone(courseId, trainId, resId) {
try {
const rec = await getCourseRecord(courseId, trainId);
const item = (rec && Array.isArray(rec.resUserRecords) ? rec.resUserRecords : []).find((x) => x.courseResourceId === resId);
return !!(item && item.isComplete === true);
} catch (e) { return false; }
}
async function waitResDone(courseId, trainId, resId, delays) {
for (let i = 0; i < delays.length; i++) {
if (delays[i]) await sleep(delays[i]);
if (await isResDone(courseId, trainId, resId)) return true;
}
return false;
}
/* 像用户手动点开那一节一样,在隐藏 iframe 里把资源页加载一次:
平台的页面自己会走定时器 / H5 回传,把这一节学完并上报,这是最贴近真实操作的方式 */
async function openResPageUntilDone(courseId, trainId, resId) {
let fr = null;
try {
fr = document.createElement('iframe');
fr.id = 'hz-res-frame';
fr.style.cssText = 'position:fixed;left:-10000px;top:0;width:900px;height:700px;opacity:0;pointer-events:none;border:0;z-index:-1;';
const url = location.origin + '/#/resourceInfo?id=' + encodeURIComponent(resId) +
'&courseId=' + encodeURIComponent(courseId) +
(trainId ? '&trainId=' + encodeURIComponent(trainId) : '');
log(' 打开该节页面(隐藏标签),让平台自己上报…', 'warn');
fr.src = url;
document.body.appendChild(fr);
const t0 = Date.now();
while (Date.now() - t0 < 16000) {
await sleep(1500);
if (await isResDone(courseId, trainId, resId)) return true;
}
return false;
} catch (e) {
log(' 打开该节页面失败:' + e.message, 'err');
return false;
} finally {
if (fr) { try { fr.remove(); } catch (e2) { /* ignore */ } }
}
}
async function reportResource(courseId, trainId, r) {
const cat = resCategory(r);
const total = Math.round(Number(r.duration) || 0);
const pref = RES_V[cat];
if (!courseId) return { done: false, variant: '' };
// 上一次是靠「打开页面」过的,这类资源直接走页面
if (cat !== 'video' && pref === 'page') {
if (await openResPageUntilDone(courseId, trainId, r.id)) return { done: true, variant: 'page' };
}
const variants = buildVariants(cat, total);
variants.sort((a, b) => (a.id === pref ? -1 : (b.id === pref ? 1 : 0)));
const delays = cat === 'video' ? [450, 1300] : [600, 1500, 2600];
for (let i = 0; i < variants.length; i++) {
const v = variants[i];
try {
await apiUpdateRes(Object.assign({ trainId: trainId || null, courseId: courseId, courseResourceId: r.id }, v.body));
} catch (e) {
log(' 上报失败(' + v.id + '):' + e.message, 'err');
continue;
}
if (await waitResDone(courseId, trainId, r.id, delays)) {
saveResVariant(cat, v.id);
return { done: true, variant: v.id };
}
}
// 图文/PDF 兜底:直接上报不被判定时,改成把该节页面加载一次,让平台自己走完
if (cat !== 'video') {
if (await openResPageUntilDone(courseId, trainId, r.id)) {
saveResVariant(cat, 'page');
return { done: true, variant: 'page' };
}
}
return { done: false, variant: '' };
}
async function waitCourseComplete(courseId, trainId, delays) {
let last = null;
for (let i = 0; i < delays.length; i++) {
if (delays[i]) await sleep(delays[i]);
try {
last = await getCourseRecord(courseId, trainId);
if (last && last.isComplete === true) return last;
} catch (e) { /* ignore */ }
}
return last;
}
/* 诊断:把当前课程的资源、平台记录、班级记录打到日志里,用于排查「上报了但不判定完成」 */
async function runDiagnose() {
log('=== 诊断开始 ===', 'run');
log('路由:' + location.hash, 'info');
log('courseId=' + (ST.courseId || '-') + ' trainId=' + (ST.trainId || '-') + ' resId=' + (ST.resId || '-'), 'info');
if (!ST.courseId) {
log('当前不在课程页,先进入某门课程再点诊断', 'warn');
log('=== 诊断结束 ===', 'run');
return;
}
try {
const course = await apiCourse(ST.courseId);
const list = flattenResources(course.directoryResources || course.directories);
log('课程:' + (course.name || '') + ' 资源 ' + list.length + ' 节 freeModel=' + course.freeModel, 'info');
list.slice(0, 15).forEach((r, i) => {
log(' ' + (i + 1) + '. ' + (r.name || '') + ' id=' + r.id + ' fileType=' + r.fileType + ' resType=' + r.resType + ' duration=' + r.duration, 'info');
});
} catch (e) {
log('读取课程失败:' + e.message, 'err');
}
try {
const rec = await apiRecord();
ST.record = rec;
log('课程记录:isComplete=' + (rec && rec.isComplete) + ' learnPass=' + (rec && rec.learnPass) + ' id=' + (rec && rec.id), 'info');
const items = (rec && rec.resUserRecords) || [];
items.slice(0, 15).forEach((x) => {
log(' 节 ' + x.courseResourceId + ' isComplete=' + x.isComplete + ' learned=' + x.resLearnDuration + ' lastProgress=' + x.lastLearnProgress, 'info');
});
} catch (e) {
log('读取课程记录失败:' + e.message, 'err');
}
if (ST.trainId) {
try {
const d = await api('/api/cms/public/trains/detail?trainId=' + encodeURIComponent(ST.trainId));
const cs = (d && d.trainCourses) || [];
const me = cs.find((c) => c && c.courseId === ST.courseId);
log('班级记录:课程 ' + cs.length + ' 门 本课程 isComplete=' + (me && me.recordInfo ? me.recordInfo.isComplete : '无记录'), 'info');
} catch (e) {
log('读取班级记录失败:' + e.message, 'err');
}
}
log('=== 诊断结束:点上方「复制日志」即可整段发给作者 ===', 'run');
}
async function passCourse(courseId, courseName, trainId) {
if (ALL.stop) return false;
const rec = await getCourseRecord(courseId, trainId);
if (rec && rec.isComplete === true) {
log('已完成,跳过:' + courseName, 'info');
return true;
}
let detail;
try {
detail = await api('/api/cms/public/courses/' + encodeURIComponent(courseId));
} catch (e) {
log('读取课程失败:' + courseName + ' ' + e.message, 'err');
return false;
}
const resources = flattenDir(detail.directoryResources || detail.directories)
.filter((r) => r && r.id);
if (!resources.length) {
log('该课程没有可上报的资源,跳过:' + courseName, 'warn');
return false;
}
try {
await apiUpdateCourse({ trainId: trainId || null, courseId: courseId, lastLearnResourceId: resources[0].id });
} catch (e) { /* 建立记录失败时仍继续尝试上报 */ }
let ok = 0;
const notDone = [];
for (let i = 0; i < resources.length; i++) {
if (ALL.stop) { log('已停止:' + courseName, 'warn'); return false; }
const r = resources[i];
const pre = (rec && Array.isArray(rec.resUserRecords) ? rec.resUserRecords : []).find((x) => x.courseResourceId === r.id);
if (pre && pre.isComplete === true) {
ok++;
log(' 已完成,跳过:' + (r.name || r.id), 'info');
continue;
}
const res = await reportResource(courseId, trainId, r);
if (res.done) ok++;
else notDone.push((r.name || r.id) + '(' + r.fileType + '/' + r.resType + ')');
if (i % 5 === 4 || i === resources.length - 1) {
log(' ' + courseName + ' ' + (i + 1) + '/' + resources.length + '(已判定完成 ' + ok + ' 节)', 'info');
}
await sleep(220 + Math.random() * 200);
}
const after = await waitCourseComplete(courseId, trainId, [0, 1200, 2600]);
const done = !!(after && after.isComplete === true);
if (!done && notDone.length) {
log(' 未判定完成的节:' + notDone.slice(0, 3).join('、') + (notDone.length > 3 ? ' 等 ' + notDone.length + ' 节' : '') + '(可点「诊断」查看平台记录)', 'warn');
}
log((done ? '课程完成:' : '已上报(平台暂未判定):') + courseName + '(' + ok + '/' + resources.length + ' 节)', done ? 'ok' : 'warn');
return done;
}
async function runPassAll() {
if (ALL.running) { log('秒过所有正在进行中…', 'warn'); return; }
ALL.running = true;
ALL.stop = false;
setAllBtnState(true);
log('开始扫描「我的班级」…', 'run');
try {
const tasks = [];
let trains = [];
try {
trains = (await listMyTrains()).filter((t) => t && (t.trainState === 0 || t.trainState === 1));
log('我的班级(未结束):' + trains.length + ' 个', 'run');
} catch (e) {
log('读取我的班级失败:' + e.message, 'err');
}
for (let i = 0; i < trains.length; i++) {
if (ALL.stop) break;
const tr = trains[i];
let detail;
try {
detail = await api('/api/cms/public/trains/detail?trainId=' + encodeURIComponent(tr.id));
} catch (e) {
log('读取班级失败:' + (tr.name || tr.id) + ' ' + e.message, 'err');
continue;
}
const list = (detail && detail.trainCourses) || [];
log('班级「' + (tr.name || tr.id) + '」:' + list.length + ' 门课程', 'run');
list.forEach((c) => {
if (c && c.courseId && !c.isExpire) {
tasks.push({ courseId: c.courseId, name: (tr.name || '班级') + ' / ' + (c.courseName || c.courseId), trainId: tr.id });
}
});
}
// 同一门课程可能出现在多个班级里,按 班级+课程 去重
const uniq = [];
const seen = {};
tasks.forEach((t) => {
const key = String(t.trainId) + '|' + String(t.courseId);
if (seen[key]) return;
seen[key] = 1;
uniq.push(t);
});
ALL.total = uniq.length;
if (!uniq.length) { log('没有找到可处理的课程', 'warn'); return; }
log('合计 ' + uniq.length + ' 门课程,开始逐门秒过', 'run');
for (let i = 0; i < uniq.length; i++) {
if (ALL.stop) { log('已手动停止', 'warn'); break; }
ALL.done = i + 1;
log('【' + (i + 1) + '/' + uniq.length + '】' + uniq[i].name, 'run');
await passCourse(uniq[i].courseId, uniq[i].name, uniq[i].trainId);
await sleep(250);
}
// 班级完成度小结:课程都过了但班级仍显示未完成时,通常还缺考试/签到
if (trains.length && !ALL.stop) {
log('—— 班级完成情况 ——', 'run');
for (let i = 0; i < trains.length; i++) {
const tr = trains[i];
try {
const d2 = await api('/api/cms/public/trains/detail?trainId=' + encodeURIComponent(tr.id));
const cs = (d2 && d2.trainCourses) || [];
const doneN = cs.filter((c) => c && c.recordInfo && c.recordInfo.isComplete).length;
log('班级「' + (tr.name || tr.id) + '」:课程完成 ' + doneN + '/' + cs.length, cs.length && doneN === cs.length ? 'ok' : 'warn');
} catch (e) {
log('读取班级完成情况失败:' + (tr.name || tr.id), 'err');
}
}
log('若班级仍显示未完成,通常还需完成该班级的考试或签到;考试可在「我的考试」里批量处理', 'info');
}
log(ALL.stop ? '秒过所有已停止' : '秒过所有执行完毕', ALL.stop ? 'warn' : 'ok');
} catch (e) {
log('秒过所有失败:' + e.message, 'err');
} finally {
ALL.running = false;
setAllBtnState(false);
}
}
/* ============================ 我的考试(批量自动作答) ============================ */
const EXLIST = { items: [], selected: {}, result: {}, running: false, stop: false, cancel: null, frame: null };
async function fetchMyExams() {
const out = [];
let skip = 0;
for (let g = 0; g < 10; g++) {
const data = await api('/api/exams/student/exams?' + qs({ IsSubmit: 0, SkipCount: skip, MaxResultCount: 50 }));
const items = (data && data.items) || [];
out.push.apply(out, items);
const total = (data && data.totalCount) || 0;
if (!items.length || out.length >= total) break;
skip += items.length;
}
return out;
}
function examStatusText(e) {
const s = Number(e.status);
if (s === 1) return '未开始';
if (s === 2) return '进行中';
if (s === 3) return '已结束';
return '状态' + s;
}
function examAutomatable(e) {
return Number(e.status) === 2 && !e.examUrl;
}
function examMeta(e) {
const parts = [];
parts.push('考核时间 ' + (e.startDate || '?') + ' 至 ' + (e.endDate || '?'));
if (e.timeLong) parts.push('时长 ' + e.timeLong + ' 分钟');
if (Number(e.submitLimitTime) > 0) parts.push('开考 ' + e.submitLimitTime + ' 分钟后可交卷');
if (Number(e.submitTimes) > 0) parts.push('已交卷 ' + e.submitTimes + ' 次');
return parts.join(' ');
}
function buildExamUrl(e) {
const token = getToken();
if (!token) throw new Error('未获取到登录凭证,请刷新页面后重试');
const returnUrl = location.origin + '/#/examcenter';
const inner = '/start?examId=' + (e.examinationId || '')
+ '&starttime=' + (e.startDate || '')
+ '&submitLimitTime=' + (e.submitLimitTime == null ? 0 : e.submitLimitTime)
+ '&examName=' + encodeURIComponent(e.name || '')
+ '&timeLong=' + (e.timeLong || 0)
+ '&returnUrl=' + encodeURIComponent(returnUrl);
return 'https://examhz.baosteel.com/#/auth-redirect?redirect=' + encodeURIComponent(inner) + '&token=' + encodeURIComponent(token);
}
function updateExamCount() {
const bRun = document.getElementById('hz-exam-run');
const bStop = document.getElementById('hz-exam-stop');
const bAll = document.getElementById('hz-exam-selectall');
const n = EXLIST.items.filter((e) => EXLIST.selected[e.examinationId] && examAutomatable(e)).length;
if (bRun) {
bRun.textContent = EXLIST.running ? '自动完成中…' : '开始自动完成(' + n + ')';
bRun.disabled = EXLIST.running || n === 0;
bRun.style.opacity = EXLIST.running || n === 0 ? '.55' : '1';
}
if (bStop) {
bStop.disabled = !EXLIST.running;
bStop.style.opacity = EXLIST.running ? '1' : '.5';
}
if (bAll) bAll.disabled = EXLIST.running;
}
function renderExamList() {
const list = document.getElementById('hz-exam-list');
if (!list) return;
list.innerHTML = '';
if (!EXLIST.items.length) {
const empty = document.createElement('div');
empty.textContent = '没有未提交的考试(或接口未返回数据)。';
empty.style.cssText = 'color:#94a3b8;padding:20px 4px;';
list.appendChild(empty);
updateExamCount();
return;
}
EXLIST.items.forEach((e) => {
const row = document.createElement('div');
row.style.cssText = 'display:flex;gap:10px;align-items:flex-start;padding:9px 4px;border-bottom:1px solid #1b2637;';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.style.cssText = 'margin-top:4px;width:15px;height:15px;accent-color:#3b82f6;cursor:pointer;';
cb.checked = !!EXLIST.selected[e.examinationId];
cb.disabled = !examAutomatable(e) || EXLIST.running;
cb.addEventListener('change', () => {
if (cb.checked) EXLIST.selected[e.examinationId] = true;
else delete EXLIST.selected[e.examinationId];
updateExamCount();
});
const col = document.createElement('div');
col.style.cssText = 'flex:1;min-width:0;cursor:pointer;';
col.addEventListener('click', () => {
if (cb.disabled) return;
cb.checked = !cb.checked;
cb.dispatchEvent(new Event('change'));
});
const title = document.createElement('div');
title.style.cssText = 'color:#e5e7eb;font-weight:600;';
title.textContent = e.name || e.examinationId;
const meta = document.createElement('div');
meta.style.cssText = 'color:#94a3b8;font-size:12px;';
meta.textContent = examMeta(e);
col.appendChild(title);
col.appendChild(meta);
const tag = document.createElement('span');
const auto = examAutomatable(e);
tag.textContent = auto ? '可自动' : (e.examUrl ? '外部网页考试' : examStatusText(e));
tag.style.cssText = 'font-size:11px;padding:1px 7px;border-radius:10px;white-space:nowrap;margin-top:2px;'
+ (auto ? 'background:#164e63;color:#67e8f9;' : 'background:#334155;color:#cbd5e1;');
const mark = document.createElement('span');
const st = EXLIST.result[e.examinationId];
if (st) {
mark.textContent = st;
mark.style.cssText = 'font-size:11px;padding:1px 6px;border-radius:10px;white-space:nowrap;margin-top:2px;'
+ (st === '已交卷' ? 'background:#14532d;color:#86efac;' : (st === '进行中…' ? 'background:#1e3a8a;color:#93c5fd;' : 'background:#7f1d1d;color:#fca5a5;'));
}
row.appendChild(cb);
row.appendChild(col);
row.appendChild(tag);
if (st) row.appendChild(mark);
list.appendChild(row);
});
updateExamCount();
}
async function loadExams() {
const tip = document.getElementById('hz-exam-tip');
if (tip) tip.textContent = '正在读取「我的考试」…';
try {
EXLIST.items = await fetchMyExams();
EXLIST.selected = {};
renderExamList();
const auto = EXLIST.items.filter(examAutomatable).length;
if (tip) tip.textContent = '共 ' + EXLIST.items.length + ' 场未提交考试,其中 ' + auto + ' 场可自动完成(外部网页考试需手动完成,未开始的需等开考)。';
log('我的考试(未提交):' + EXLIST.items.length + ' 场,可自动 ' + auto + ' 场', 'run');
} catch (e) {
if (tip) tip.textContent = '读取考试列表失败:' + e.message;
log('读取考试列表失败:' + e.message, 'err');
}
}
function openExamModal() {
if (document.getElementById('hz-exam-overlay')) return;
const ov = document.createElement('div');
ov.id = 'hz-exam-overlay';
ov.style.cssText = 'position:fixed;inset:0;z-index:2147483002;background:rgba(3,7,18,.62);display:flex;align-items:center;justify-content:center;';
const card = document.createElement('div');
card.style.cssText = 'width:min(780px,94vw);max-height:88vh;display:flex;flex-direction:column;background:#0f172a;border:1px solid #243043;border-radius:12px;box-shadow:0 18px 48px rgba(0,0,0,.55);color:#d1d5db;font:13px/1.6 "Microsoft YaHei",system-ui,sans-serif;overflow:hidden;';
const hd = document.createElement('div');
hd.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:11px 14px;border-bottom:1px solid #243043;background:linear-gradient(90deg,#132033,#0f172a);';
hd.innerHTML = '我的考试 · 自动作答';
const closeBtn = document.createElement('span');
closeBtn.textContent = '✕';
closeBtn.title = '关闭';
closeBtn.style.cssText = 'cursor:pointer;color:#94a3b8;font-size:15px;padding:0 4px;';
closeBtn.addEventListener('click', () => {
if (EXLIST.running) { log('正在自动完成考试,请先点「停止」', 'warn'); return; }
ov.remove();
});
hd.appendChild(closeBtn);
const body = document.createElement('div');
body.style.cssText = 'padding:10px 14px 4px;overflow:auto;flex:1;';
const list = document.createElement('div');
list.id = 'hz-exam-list';
list.style.cssText = 'min-height:120px;';
const stage = document.createElement('div');
stage.id = 'hz-exam-stage';
stage.style.cssText = 'display:none;height:66vh;border:1px solid #243043;border-radius:8px;overflow:hidden;background:#fff;';
body.appendChild(list);
body.appendChild(stage);
const tip = document.createElement('div');
tip.id = 'hz-exam-tip';
tip.style.cssText = 'color:#94a3b8;font-size:12px;margin:8px 14px 10px;white-space:pre-line;';
const ft = document.createElement('div');
ft.style.cssText = 'display:flex;align-items:center;gap:8px;padding:10px 14px;border-top:1px solid #243043;background:#0b1220;';
const btnStyle = 'padding:7px 12px;border:none;border-radius:6px;color:#fff;font-size:12px;font-weight:600;cursor:pointer;';
const bAll = document.createElement('button');
bAll.id = 'hz-exam-selectall';
bAll.textContent = '全选可自动';
bAll.style.cssText = btnStyle + 'background:#475569;';
bAll.addEventListener('click', () => {
EXLIST.items.forEach((e) => { if (examAutomatable(e)) EXLIST.selected[e.examinationId] = true; });
renderExamList();
});
const bRun = document.createElement('button');
bRun.id = 'hz-exam-run';
bRun.textContent = '开始自动完成(0)';
bRun.style.cssText = btnStyle + 'background:#0d9488;';
bRun.disabled = true;
bRun.addEventListener('click', () => runSelectedExams());
const bStop = document.createElement('button');
bStop.id = 'hz-exam-stop';
bStop.textContent = '停止';
bStop.style.cssText = btnStyle + 'background:#b91c1c;opacity:.5;';
bStop.disabled = true;
bStop.addEventListener('click', () => {
EXLIST.stop = true;
if (EXLIST.cancel) EXLIST.cancel();
log('已发出停止指令…', 'warn');
});
ft.appendChild(bAll);
ft.appendChild(bRun);
ft.appendChild(bStop);
card.appendChild(hd);
card.appendChild(body);
card.appendChild(tip);
card.appendChild(ft);
ov.appendChild(card);
document.body.appendChild(ov);
loadExams();
}
function runOneExam(e) {
return new Promise((resolve) => {
const stage = document.getElementById('hz-exam-stage');
if (!stage) { resolve({ ok: false, detail: '面板未打开' }); return; }
let url;
try {
url = buildExamUrl(e);
} catch (err) {
resolve({ ok: false, detail: err.message });
return;
}
stage.innerHTML = '';
const frame = document.createElement('iframe');
frame.style.cssText = 'width:100%;height:100%;border:0;display:block;';
stage.appendChild(frame);
EXLIST.frame = frame;
let settled = false;
let timer = null;
const cleanup = () => {
window.removeEventListener('message', onMsg);
if (timer) clearTimeout(timer);
EXLIST.cancel = null;
try { frame.src = 'about:blank'; } catch (err) { /* ignore */ }
setTimeout(() => { if (frame.parentNode) frame.parentNode.removeChild(frame); }, 300);
if (EXLIST.frame === frame) EXLIST.frame = null;
};
const finish = (ok, detail) => {
if (settled) return;
settled = true;
cleanup();
resolve({ ok: ok, detail: detail || '' });
};
const onMsg = (ev) => {
if (ev.origin !== 'https://examhz.baosteel.com') return;
const d = ev.data;
if (!d || d.source !== 'hz-exam-helper') return;
if (d.type === 'done') finish(true, d.detail || '交卷成功');
else if (d.type === 'fail') finish(false, d.detail || '页面报告失败');
else if (d.type === 'progress') {
const tip = document.getElementById('hz-exam-tip');
if (tip) tip.textContent = tip.textContent.split('\n')[0] + '\n' + (d.detail || '');
}
};
window.addEventListener('message', onMsg);
EXLIST.cancel = () => finish(false, '已手动停止');
timer = setTimeout(() => finish(false, '超时未完成(可能题目未加载或被交卷时间限制卡住)'), 180000);
frame.src = url;
});
}
async function runSelectedExams() {
if (EXLIST.running) return;
const picked = EXLIST.items.filter((e) => EXLIST.selected[e.examinationId] && examAutomatable(e));
if (!picked.length) { log('请先勾选要自动完成的考试', 'warn'); return; }
const list = document.getElementById('hz-exam-list');
const stage = document.getElementById('hz-exam-stage');
const tip = document.getElementById('hz-exam-tip');
EXLIST.running = true;
EXLIST.stop = false;
EXLIST.result = {};
if (list) list.style.display = 'none';
if (stage) stage.style.display = 'block';
renderExamList();
log('开始自动完成 ' + picked.length + ' 场考试', 'run');
let okCount = 0;
try {
for (let i = 0; i < picked.length; i++) {
if (EXLIST.stop) break;
const e = picked[i];
EXLIST.result[e.examinationId] = '进行中…';
if (tip) tip.textContent = '正在处理 ' + (i + 1) + '/' + picked.length + ':' + (e.name || e.examinationId) + '\n等待考试页加载并自动作答…';
log('【' + (i + 1) + '/' + picked.length + '】' + (e.name || e.examinationId), 'run');
const r = await runOneExam(e);
EXLIST.result[e.examinationId] = r.ok ? '已交卷' : ('失败:' + r.detail);
if (r.ok) okCount++;
log((r.ok ? '已完成:' : '未完成:') + (e.name || e.examinationId) + (r.ok ? '' : ' ' + r.detail), r.ok ? 'ok' : 'warn');
await sleep(900 + Math.random() * 600);
}
} finally {
EXLIST.running = false;
if (stage) { stage.style.display = 'none'; stage.innerHTML = ''; }
if (list) list.style.display = 'block';
renderExamList();
if (tip) tip.textContent = (EXLIST.stop ? '已停止。' : '批量完成结束。') + '成功 ' + okCount + ' / ' + picked.length + ' 场,正在刷新列表…';
setTimeout(loadExams, 1500);
}
}
/* ============================ 考试站(examhz) ============================ */
const EXAM = { autoFill: true, autoSubmit: true, captured: '', filledOnce: false, submitted: false, started: false };
function examCfgLoad() {
try {
const c = JSON.parse(localStorage.getItem('hz_exam_cfg_v1') || '{}') || {};
if (typeof c.autoFill === 'boolean') EXAM.autoFill = c.autoFill;
if (typeof c.autoSubmit === 'boolean') EXAM.autoSubmit = c.autoSubmit;
} catch (e) { /* ignore */ }
}
function examCfgSave() {
try {
localStorage.setItem('hz_exam_cfg_v1', JSON.stringify({ autoFill: EXAM.autoFill, autoSubmit: EXAM.autoSubmit }));
} catch (e) { /* ignore */ }
}
function installCanvasHook() {
try {
const proto = window.CanvasRenderingContext2D && CanvasRenderingContext2D.prototype;
if (!proto || proto.__hzHooked) return;
const origFill = proto.fillText;
proto.fillText = function (text, x, y, mw) {
try {
if (this.canvas && this.canvas.id === 'mycanvas') EXAM.captured += String(text);
} catch (e) { /* ignore */ }
return arguments.length > 3 ? origFill.call(this, text, x, y, mw) : origFill.call(this, text, x, y);
};
const origClear = proto.clearRect;
proto.clearRect = function () {
try { if (this.canvas && this.canvas.id === 'mycanvas') EXAM.captured = ''; } catch (e) { /* ignore */ }
return origClear.apply(this, arguments);
};
proto.__hzHooked = true;
} catch (e) { /* ignore */ }
}
function setInputValue(el, val) {
try {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
setter.call(el, val);
} catch (e) {
el.value = val;
}
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
function clickStartIfAny() {
if (EXAM.started) return;
// 先勾选「已阅读考试须知」,否则「进入考试」按钮一直是禁用的
const accept = document.querySelector('.isaccept');
if (accept && !accept.classList.contains('van-checkbox--checked') && !accept.classList.contains('van-checkbox--disabled')) {
accept.click();
return;
}
const nodes = Array.prototype.slice.call(document.querySelectorAll('button, .van-button, a'));
const target = nodes.find((b) => /进入考试|开始考试|立即考试|继续考试/.test((b.textContent || '').replace(/\s+/g, '')) && !b.disabled);
if (target) {
EXAM.started = true;
log('已自动点击「' + (target.textContent || '').trim() + '」', 'run');
target.click();
}
}
function fillAnswers() {
let singleCount = 0;
let multiCount = 0;
document.querySelectorAll('.van-radio-group').forEach((g) => {
const opts = g.querySelectorAll('.van-radio');
if (!opts.length) return;
if (g.querySelector('.van-radio--checked')) return;
opts[0].click();
singleCount++;
});
document.querySelectorAll('.van-checkbox-group').forEach((g) => {
const opts = g.querySelectorAll('.van-checkbox');
opts.forEach((o) => {
if (!o.classList.contains('van-checkbox--checked')) o.click();
multiCount++;
});
});
return { singleCount: singleCount, multiCount: multiCount };
}
async function autoSubmit() {
if (EXAM.submitted) return false;
const openBtn = document.querySelector('.submitPage-btn');
if (!openBtn) { log('未找到「提交试卷」按钮', 'warn'); return false; }
EXAM.submitted = true;
EXAM.captured = '';
openBtn.click();
await sleep(1500);
const code = String(EXAM.captured || '').trim();
if (!/^\d{4}$/.test(code)) {
log('未捕获到验证码,请手动输入后交卷', 'warn');
EXAM.submitted = false;
return false;
}
const input = document.querySelector('input.codetxt') || document.querySelector('.codetxt input');
if (!input) { log('未找到验证码输入框', 'warn'); EXAM.submitted = false; return false; }
setInputValue(input, code);
await sleep(450);
const btn = document.querySelector('.code-opr .submit_btn');
if (!btn) { log('未找到确认交卷按钮', 'warn'); EXAM.submitted = false; return false; }
if (btn.disabled) { log('交卷按钮仍为禁用(验证码未同步),请手动确认', 'warn'); EXAM.submitted = false; return false; }
btn.click();
log('已自动交卷(验证码 ' + code + ')', 'ok');
return true;
}
/* 找到试卷页的 Vue 组件实例(用于在交卷按钮被时间限制锁住时调用其自身交卷方法) */
function findExamVM() {
try {
const el = document.getElementById('app');
const rootVm = el && el.__vue__;
if (!rootVm) return null;
let found = null;
const walk = (vm, depth) => {
if (!vm || found || depth > 14) return;
if (typeof vm.handleData === 'function' && vm.exam) { found = vm; return; }
(vm.$children || []).forEach((c) => walk(c, depth + 1));
};
walk(rootVm, 0);
return found;
} catch (e) { return null; }
}
function readExamId() {
try {
const q = location.hash.split('?')[1] || '';
return new URLSearchParams(q).get('examId') || '';
} catch (e) { return ''; }
}
/* 只在被批量考试任务嵌入 iframe 时向父页面回报 */
function reportToParent(type, detail) {
if (window.top === window.self) return;
try {
window.parent.postMessage({ source: 'hz-exam-helper', type: type, detail: detail || '', examId: readExamId() }, '*');
} catch (e) { /* ignore */ }
}
async function autoSubmitAuto() {
if (EXAM.submitted) return false;
const openBtn = document.querySelector('.submitPage-btn');
if (openBtn && !openBtn.disabled) return await autoSubmit();
const vm = findExamVM();
if (!vm) { log('未找到试卷组件,无法交卷', 'warn'); return false; }
EXAM.submitted = true;
try {
vm.handleData(1);
log('交卷按钮受「开考 N 分钟后才可交卷」限制,已直接调用页面自身的交卷方法', 'warn');
return true;
} catch (e) {
EXAM.submitted = false;
log('交卷失败:' + e.message, 'err');
return false;
}
}
/* 监听页面上的交卷结果提示,用于向批量任务回报成功/失败 */
function watchSubmitResult() {
if (window.top === window.self) return;
let reported = false;
const startedAt = Date.now();
setInterval(() => {
if (reported) return;
const txt = (document.body && document.body.innerText) || '';
if (txt.indexOf('交卷成功') > -1) {
reported = true;
reportToParent('done', '交卷成功');
return;
}
const bad = txt.match(/交卷失败[^\n]{0,30}|提交失败[^\n]{0,30}|未到交卷时间[^\n]{0,30}|禁止进入[^\n]{0,20}/);
if (bad) {
reported = true;
reportToParent('fail', bad[0]);
return;
}
// 25 秒还停在须知页 / 倒计时页,说明进不了试卷,直接回报失败,避免白等
if (Date.now() - startedAt > 25000) {
const hasQ = document.querySelectorAll('.van-radio-group, .van-checkbox-group').length > 0
|| document.querySelector('.test-item-div');
if (!hasQ) {
if (txt.indexOf('距离开考时间') > -1) {
reported = true;
reportToParent('fail', '未到开考时间,需等开考后再进');
} else if (txt.indexOf('考试须知') > -1) {
reported = true;
reportToParent('fail', '卡在考试须知页,未能进入试卷');
}
}
}
}, 700);
}
function buildExamPanel() {
if (ST.panel) return;
const root = document.createElement('div');
root.style.cssText = ['position:fixed', 'right:16px', 'bottom:16px', 'width:290px', 'z-index:2147483000',
'background:#111827', 'border:1px solid #2b3547', 'border-radius:10px',
'box-shadow:0 10px 34px rgba(0,0,0,.45)', 'color:#d1d5db',
'font:13px/1.5 "Microsoft YaHei",system-ui,sans-serif', 'overflow:hidden'].join(';');
const hd = document.createElement('div');
hd.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:9px 12px;background:linear-gradient(90deg,#1f2937,#111827);border-bottom:1px solid #2b3547;';
hd.innerHTML = '考试助手 v1.2.4';
const fold = document.createElement('span');
fold.textContent = '—';
fold.style.cssText = 'cursor:pointer;color:#9ca3af;font-size:15px;padding:0 3px;';
hd.appendChild(fold);
const bd = document.createElement('div');
bd.style.cssText = 'padding:10px 12px 12px;';
const chkStyle = 'width:16px;height:16px;accent-color:#3b82f6;cursor:pointer;';
function row(label, checked, onChange) {
const r = document.createElement('div');
r.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:7px;';
const l = document.createElement('label');
l.textContent = label;
l.style.cssText = 'color:#cbd5e1;cursor:pointer;';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = !!checked;
cb.style.cssText = chkStyle;
cb.addEventListener('change', () => onChange(cb.checked));
l.addEventListener('click', () => { cb.checked = !cb.checked; onChange(cb.checked); });
r.appendChild(l);
r.appendChild(cb);
bd.appendChild(r);
}
row('自动作答(单选A / 多选全选)', EXAM.autoFill, (v) => { EXAM.autoFill = v; examCfgSave(); if (v) EXAM.filledOnce = false; });
row('自动交卷(自动识别验证码)', EXAM.autoSubmit, (v) => { EXAM.autoSubmit = v; examCfgSave(); });
const btns = document.createElement('div');
btns.style.cssText = 'display:flex;gap:8px;margin:10px 0 8px;';
const bStyle = 'flex:1;padding:7px 0;border:none;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;color:#fff;';
const bRefill = document.createElement('button');
bRefill.textContent = '重新作答';
bRefill.style.cssText = bStyle + 'background:#2563eb;';
bRefill.addEventListener('click', () => {
const n = fillAnswers();
log('已重新作答:单选/判断 ' + n.singleCount + ' 题,多选选项 ' + n.multiCount + ' 个', 'ok');
});
const bSubmit = document.createElement('button');
bSubmit.textContent = '立即交卷';
bSubmit.style.cssText = bStyle + 'background:#dc2626;';
bSubmit.addEventListener('click', () => { EXAM.submitted = false; autoSubmitAuto(); });
btns.appendChild(bRefill);
btns.appendChild(bSubmit);
bd.appendChild(btns);
const lg = document.createElement('div');
lg.style.cssText = 'height:110px;overflow-y:auto;background:#0b1220;border:1px solid #1f2937;border-radius:6px;padding:6px 8px;font-size:11.5px;user-select:text;-webkit-user-select:text;cursor:text;';
bd.appendChild(lg);
root.appendChild(hd);
root.appendChild(bd);
document.body.appendChild(root);
ST.panel = root;
ST.logEl = lg;
let folded = false;
fold.addEventListener('click', () => {
folded = !folded;
bd.style.display = folded ? 'none' : 'block';
fold.textContent = folded ? '+' : '—';
});
}
function startExamLoop() {
setInterval(() => {
clickStartIfAny();
if (!EXAM.autoFill || EXAM.filledOnce) return;
const groups = document.querySelectorAll('.van-radio-group, .van-checkbox-group').length;
const loading = document.querySelector('.q-loading');
if (groups > 0 && !loading) {
EXAM.filledOnce = true;
setTimeout(() => {
const n = fillAnswers();
const total = n.singleCount + n.multiCount;
log('已自动作答:单选/判断 ' + n.singleCount + ' 题,多选选项 ' + n.multiCount + ' 个', total ? 'ok' : 'warn');
if (!total) { EXAM.filledOnce = false; return; }
reportToParent('progress', '已作答单选/判断 ' + n.singleCount + ' 题、多选 ' + n.multiCount + ' 个选项,准备交卷');
if (EXAM.autoSubmit) setTimeout(() => { autoSubmitAuto(); }, 1600);
}, 900);
}
}, 1000);
}
function bootExam() {
examCfgLoad();
installCanvasHook();
const inFrame = window.top !== window.self;
if (inFrame) {
// 由「我的考试」批量任务打开的考试窗口:不显示面板,直接作答、交卷并回报结果
EXAM.autoFill = true;
EXAM.autoSubmit = true;
startExamLoop();
watchSubmitResult();
log('批量考试模式:自动作答并交卷', 'run');
return;
}
buildExamPanel();
log('考试助手已启动(单选选A、多选全选、判断选正确)', 'run');
startExamLoop();
}
/* ============================ 生命周期 ============================ */
function boot() {
buildPanel();
installTokenSniffer();
const tk = getToken();
log('助手已启动 v1.2.4(' + (tk ? '登录态已获取,来源:' + TOKEN_SOURCE : '暂未获取登录态') + ')', 'run');
loadContext();
if (!ST.statusTimer) ST.statusTimer = setInterval(render, 1000);
let lastHash = location.hash;
setInterval(() => {
if (location.hash !== lastHash) {
lastHash = location.hash;
ST.handledResId = '';
ST.record = null;
ST.retryCount = 0;
log('页面切换,重新加载…', 'info');
setTimeout(loadContext, 650);
}
}, 500);
}
// 考试站:只做自动作答,不加载课程相关逻辑
if (/examhz\.baosteel\.com/.test(location.hostname)) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(bootExam, 600));
} else {
setTimeout(bootExam, 600);
}
return;
}
// 主站只在顶层窗口运行,避免同源 iframe 重复注入
if (window.top !== window.self) return;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(boot, 800));
} else {
setTimeout(boot, 800);
}
})();