// ==UserScript==
// @name 东南大学选课助手
// @namespace http://tampermonkey.net/
// @version 3.5.1
// @description 批量添加课程,按间隔提交,跟踪队列最终结果
// @author july, nada
// @license MIT
// @match https://newxk.urp.seu.edu.cn/xsxk/elective/grablessons*
// @run-at document-idle
// @grant none
// ==/UserScript==
(async function () {
'use strict';
// Pure protocol/state logic. The userscript build embeds this factory unchanged.
function createSeuCore() {
const codeOf = value => String(value ?? '');
const terminal = state => ['success', 'failed', 'cancelled'].includes(state);
const keyOf = course => `${course.courseBatch}:${course.classID}`;
const pending = state => ['submitting', 'queued', 'unknown'].includes(state);
function groupSize(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.max(1, Math.min(Number.MAX_SAFE_INTEGER, Math.trunc(parsed))) : 3;
}
function intervalMs(value, grouped = false) {
const parsed = Number(value);
const minimum = grouped ? 1000 : 1;
return Number.isFinite(parsed) ? Math.max(minimum, Math.min(60000, Math.trunc(parsed))) : 1000;
}
function parseCodes(value) {
return [...new Set(String(value || '').toUpperCase().split(/[\s,,;;]+/).filter(Boolean))];
}
function findCourse(code, rows, type, batch) {
for (const course of rows || []) {
const classes = Array.isArray(course.tcList) ? course.tcList : [course];
for (const teacher of classes) {
if (`${course.KCH ?? teacher.KCH}${teacher.KXH ?? ''}`.toUpperCase() !== code) continue;
if (!teacher.JXBID || !teacher.secretVal) return null;
return {
courseBatch: String(batch), classID: String(teacher.JXBID), courseType: type,
secretVal: teacher.secretVal, courseName: course.KCM || teacher.KCM || code,
teacherName: teacher.SKJS || '', department: teacher.KKDW || course.KKDW || '',
location: teacher.YPSJDD || '', selectedCount: teacher.YXRS ?? teacher.numberOfSelected ?? '',
totalCapacity: teacher.KRL ?? teacher.classCapacity ?? '',
courseNature: teacher.KCXZ || course.KCXZ || '', courseCategory: teacher.KCLB || course.KCLB || '',
status: 'ready', message: '',
};
}
}
return null;
}
function containsClass(rows, classID) {
if (!Array.isArray(rows)) throw new Error('已选课程响应结构变化,请在官网核实结果');
return rows.some(row => String(row.JXBID ?? '') === String(classID) ||
(Array.isArray(row.tcList) && containsClass(row.tcList, classID)));
}
class Ledger {
constructor(onChange = () => {}) { this.records = new Map(); this.onChange = onChange; }
start(course) {
const key = keyOf(course);
const old = this.records.get(key);
if (old && pending(old.state)) return null;
const record = { course, state: 'submitting', message: '正在提交', listeners: new Set() };
this.records.set(key, record);
this.change(record, 'submitting', record.message);
return record;
}
restore(course) {
const record = { course, state: 'unknown', message: '上次提交结果待核实', listeners: new Set() };
this.records.set(keyOf(course), record);
return record;
}
change(record, state, message) {
// A WebSocket result can arrive before the HTTP acknowledgement.
if (terminal(record.state)) return;
record.state = state;
record.message = message || '';
this.onChange(record);
for (const fn of record.listeners) fn();
}
receive(packet, batch) {
if (!packet || packet.code == null || packet.data === 'heart' || codeOf(packet.data?.operationType) !== '1') return false;
const data = packet.data;
if (data.batchId != null && String(data.batchId) !== String(batch)) return false;
const record = this.records.get(`${batch}:${data.clazzId}`);
if (!record || !pending(record.state)) return false;
this.change(record, codeOf(packet.code) === '200' ? 'success' : 'failed', packet.msg || '服务器返回选课处理结果');
return true;
}
wait(record, timeoutMs, signal) {
if (terminal(record.state) || signal?.aborted) return Promise.resolve(record.state);
return new Promise(resolve => {
const done = () => {
clearTimeout(timer);
record.listeners.delete(check);
signal?.removeEventListener('abort', done);
resolve(record.state);
};
const check = () => { if (terminal(record.state)) done(); };
const timer = setTimeout(done, timeoutMs);
record.listeners.add(check);
signal?.addEventListener('abort', done, { once: true });
if (signal?.aborted) done();
});
}
}
async function attempt({ course, ledger, submit, confirm, verify, signal, timeoutMs = 20000, onResponse = () => {} }) {
if (signal?.aborted) return 'cancelled';
const record = ledger.start(course);
if (!record) return 'unknown'; // Never replay an unresolved submission.
try {
try {
let response = await submit({ clazzType: course.courseType, clazzId: course.classID, secretVal: course.secretVal });
if (terminal(record.state)) return record.state;
if (codeOf(response?.code) === '301') {
const confirmed = signal?.aborted ? false : await untilStopped(confirm(response.msg || '此课程需要再次确认'), signal);
if (!confirmed) {
ledger.change(record, 'cancelled', '已取消二次确认');
return record.state;
}
if (signal?.aborted) {
ledger.change(record, 'cancelled', '已停止');
return record.state;
}
response = await submit({ clazzType: course.courseType, clazzId: course.classID, secretVal: course.secretVal, isConfirm: 1 });
}
if (terminal(record.state)) return record.state;
if (codeOf(response?.code) !== '200') {
ledger.change(record, 'failed', response?.msg || '服务器拒绝提交');
return record.state;
}
ledger.change(record, 'queued', '已入队,等待最终结果');
} finally { onResponse(); }
await ledger.wait(record, timeoutMs, signal);
if (terminal(record.state)) return record.state;
if (!signal?.aborted && await verify(course)) {
ledger.change(record, 'success', '已在官方已选课程记录中确认');
return record.state;
}
ledger.change(record, 'unknown', '结果待核实:停止重复提交,请查看官网已选课程');
} catch (error) {
// Timeout/abort/invalid JSON after POST does not prove that the server rejected it.
if (!terminal(record.state)) ledger.change(record, error?.definitive ? 'failed' : 'unknown', error?.message || '网络中断,提交结果待核实');
}
return record.state;
}
function dispatch(options) {
let responded;
const response = new Promise(resolve => { responded = resolve; });
const result = attempt({ ...options, onResponse: responded });
// Early cancellation or an already-pending record may return before submitting.
result.then(responded, responded);
return { response, result };
}
function delay(ms, signal) {
if (signal?.aborted) return Promise.resolve();
return new Promise(resolve => {
const finish = () => { clearTimeout(timer); signal?.removeEventListener('abort', finish); resolve(); };
const timer = setTimeout(finish, Math.max(0, ms));
signal?.addEventListener('abort', finish, { once: true });
if (signal?.aborted) finish();
});
}
function untilStopped(promise, signal) {
if (signal?.aborted) return Promise.resolve(false);
return new Promise((resolve, reject) => {
const stop = () => { cleanup(); resolve(false); };
const cleanup = () => signal?.removeEventListener('abort', stop);
signal?.addEventListener('abort', stop, { once: true });
Promise.resolve(promise).then(value => { cleanup(); resolve(value); }, error => { cleanup(); reject(error); });
if (signal?.aborted) stop();
});
}
async function run({ getCourses, send, mode, interval, signal, sleep = delay, waitForPending }) {
const active = new Map();
let rounds = 0;
let failed = false, failure;
const width = mode.isGrouped ? groupSize(mode.groupSize) : 1;
const pauseMs = intervalMs(interval, mode.isGrouped);
const limit = mode.isCyclic ? (mode.cycleCount > 0 ? mode.cycleCount : Infinity) : 1;
try {
while (!signal.aborted && !failed && rounds < limit) {
const courses = getCourses().filter(c => !pending(c.status) && !['cancelled', 'success'].includes(c.status));
if (!courses.length) {
if (waitForPending && await waitForPending()) continue;
break;
}
rounds++;
for (let i = 0; i < courses.length && !signal.aborted && !failed; i += width) {
const batch = [];
for (const course of courses.slice(i, i + width)) {
if (signal.aborted || failed) break;
const key = keyOf(course);
if (active.has(key)) continue;
// Observe async failures immediately, even while the scheduler sleeps.
const task = Promise.resolve().then(() => signal.aborted || failed ? undefined : send(course))
.catch(error => { if (!failed) failure = error; failed = true; })
.finally(() => active.delete(key));
active.set(key, task);
batch.push(task);
}
if (!mode.isAsync) await Promise.all(batch);
if (!signal.aborted && !failed) await sleep(pauseMs, signal);
}
// Finish HTTP exchanges before the next cycle; queue results are tracked separately.
await Promise.all(active.values());
}
} finally {
await Promise.allSettled(active.values());
}
if (failed) throw failure;
}
function serializable(enrollDict, settings) {
const cleanSettings = JSON.parse(JSON.stringify(settings));
delete cleanSettings.token;
const cleanCourses = {};
for (const [key, course] of Object.entries(enrollDict)) {
const clean = { ...course };
delete clean.secretVal;
delete clean.token;
clean.status = pending(clean.status) ? 'unknown' : 'ready';
clean.message = ''; // Do not persist server messages.
cleanCourses[key] = clean;
}
return { version: 350, enrollDict: cleanCourses, settings: cleanSettings };
}
return { codeOf, pending, keyOf, groupSize, intervalMs, parseCodes, findCourse, containsClass, Ledger, attempt, dispatch, delay, run, serializable };
}
const core = createSeuCore();
const started = Date.now();
while (!(window.grablessonsVue?.lcParam?.currentBatch?.code && window.axios && document.getElementById('xsxkapp'))) {
if (Date.now() - started > 30000) { console.warn('[东大选课助手] 页面未就绪,请进入课程列表后刷新'); return; }
await core.delay(200);
}
if (document.getElementById('seu-helper-root')) return;
const grablessonsVue = window.grablessonsVue;
const tip = options => grablessonsVue.$message(options);
const version = [3,5,1];
let isRunning = false, shouldStop = false;
let enrollDict = {};
const defaultSettings = {
savedCourseCodes: '',
mode: {isAsync: false, isCyclic: true, isGrouped: false, groupSize: 3, cycleCount: -1, enableSearch: true},
interval: {sync: {single: 300, group: 1000}, async: {single: 350, group: 1000}},
search: {pageSize: 20, pageDelay: 500}, announcement: {hasRead: false}
};
let settings = JSON.parse(JSON.stringify(defaultSettings));
const Components = {};
const app = document.createElement('div');
app.id = 'seu-helper-root';
document.body.appendChild(app);
// 组件生成
((self) => {
// 生成组件
self.mount = () => {
self.createTag();
self.createPanel();
self.createMask();
self.addEnrollButton();
};
// 生成节点
self.createNode = ({ tagName, text, HTML, obj, ev, children }) => {
let node = document.createElement(tagName);
if (obj) {
for (let key of Object.keys(obj)) {
node.setAttribute(key, obj[key]);
}
}
if (text) {
node.textContent = String(text);
}
if (HTML) {
node.innerHTML = HTML;
}
if (ev) {
for (let key of Object.keys(ev)) {
node.addEventListener(key, ev[key]);
}
}
if (children) {
children.map((x) => node.appendChild(x));
}
return node;
};
// 生成打开和关闭面板的按钮
self.createTag = () => {
let node = self.createNode({
tagName: "div",
obj: {
class: "slideMenu",
style: `
position: fixed;
top: 250px;
left:30px;width:
40px;z-index: 1314;
`,
},
children: [
self.createNode({
tagName: "div",
obj: {
class: "centre-btn item el-icon-date",
style: `background-color: #2b2b2b`,
},
ev: {
mousedown: (e) => {
methods.drag(e, node);
},
},
}),
],
});
app.appendChild(node);
};
// 生成面板
self.createPanel = () => {
app.appendChild(
self.createNode({
tagName: "div",
obj: {
id: "seu-panel",
style: `
position: fixed;
right: 0;
top:0 ;
z-index: 520;
width: 350px;
height: 100%;
background-color: rgba(61,72,105,0.8);
display: block;
`,
},
children: [
self.createNode({ tagName: "hr" }),
self.createNode({
tagName: "h1",
text: "东大抢课脚本",
obj: {
style: "color: #c7e6e6; text-align: center",
},
}),
self.createNode({ tagName: "hr" }),
self.createNode({
tagName: "input",
obj: {
id: "seu-input-box",
class: "el-input__inner",
style: `
width: 96%;
margin-left: 2%;
height: 30px
`,
placeholder: "输入课程代码(不区分大小写),按回车确定",
},
ev: {
keydown: methods.enter,
},
}),
self.createNode({
tagName: "div",
obj: {
id: "seu-list-wrap",
style: `
overflow: auto;
margin: 10px;
border:1px solid white;
height: 75%
`,
},
}),
self.createNode({
tagName: "button",
obj: {
id: "seu-enroll-button",
class: "el-button el-button--primary el-button--small is-round",
style: `
margin: 20px;
position: absolute;
right:50%;
bottom:5%
`,
},
text: "一键抢课",
ev: {
click: async () => {
if (shouldStop) {
tip({
type: "error",
message: "请稍候,正在终止上一个抢课进程",
duration: 1000,
});
return;
}
if (isRunning) {
return;
}
isRunning = true;
methods.updateUIState();
methods.enroll();
},
},
}),
self.createNode({
tagName: "button",
obj: {
id: "seu-settings-stop-button",
class: `el-button el-button--${
isRunning ? "danger" : "info"
} el-button--small is-round`,
style: `
margin: 20px;
position: absolute;
right:20%;
bottom:5%
`,
},
text: isRunning ? "停止抢课" : "更多设置",
ev: {
click: async () => {
if (isRunning) {
await methods.stopEnrolling();
} else {
document.getElementById("seu-mask").style.display = "block";
self.updatePopup(settings.mode.enableSearch, settings);
}
},
},
}),
self.createNode({
tagName: "div",
obj: {
style: `
margin: 20px;
position: absolute;
right:2%;
bottom:1%;
color: white;
float: right
`,
},
text: "ver" + version.join("."),
}),
],
})
);
self.reloadList();
};
// 生成抢课表格
self.reloadList = () => {
let list_wrap = document.querySelector("#seu-panel #seu-list-wrap");
list_wrap.innerHTML = "";
if (JSON.stringify(enrollDict) === "{}") {
list_wrap.innerHTML =
"
还未选择课程
";
} else {
list_wrap.appendChild(
self.createNode({
tagName: "table",
obj: {
width: "100%",
border: "1",
style: `
background-color: rgba(0,0,0,0);
color: lightblue
`,
},
children: [
self.createNode({
tagName: "tr",
obj: {
style: `
height: 30px;
background-color: #255e95
`,
},
HTML: `
课程 |
教师 |
操作 |
`,
}),
...Object.keys(enrollDict)
.filter(
(key) =>
enrollDict[key].courseBatch ===
grablessonsVue.lcParam.currentBatch.code
)
.map((key) => {
return self.createNode({
tagName: "tr",
obj: {
style: `height: 30px`,
},
children: [
self.createNode({
tagName: "td",
obj: {
style: `text-align: center`,
},
text: methods.displayCourse(enrollDict[key]),
}),
self.createNode({
tagName: "td",
obj: {
style: `text-align: center`,
},
text: enrollDict[key].teacherName,
}),
self.createNode({
tagName: "td",
obj: {
style: `text-align: center`,
},
children: [
self.createNode({
tagName: "button",
text: "删除",
obj: {
class: "delete-button",
style: `
color: red;
background: transparent;
border: 1px solid red;
border-radius: 6px;
text-align: center;
cursor: pointer;
text-decoration: none;
margin-right: 2px
`,
},
ev: {
click: () => {
const course = enrollDict[key];
if (core.pending(course.status)) {
if (!window.confirm('本课程的服务器处理结果尚未确认。删除只移除本地记录,不会取消服务器队列;重新添加后可能再次提交。请先核实官网结果,确认仍要删除本地记录吗?')) return;
ledger.records.delete(core.keyOf(course));
}
delete enrollDict[key];
methods.saveData();
tip({
type: "success",
message: `${course.teacherName} 的 ${course.courseName} 已删除`,
duration: 1000,
});
self.reloadList();
},
},
}),
self.createNode({
tagName: "button",
text: "更多",
obj: {
style: `
color: orange;
background: transparent;
border: 1px solid orange;
border-radius: 6px;
text-align: center;
cursor: pointer;
text-decoration: none;
margin-left: 2px
`,
},
ev: {
click: () => {
document.getElementById("seu-mask").style.display =
"block";
self.createPopUp(
"详细信息",
self.showCourseDetails(enrollDict[key]),
null,
40,
50
);
},
},
}),
],
}),
],
});
}),
],
})
);
}
};
// 生成遮罩
self.createMask = () => {
let node = self.createNode({
tagName: "div",
obj: {
id: "seu-mask",
style: `
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 2002;
background-color: rgba(66, 66, 66, 0.6);
display: none
`,
},
ev: {
click: () => {
node.style.display = "none";
document.querySelectorAll(".seu-temp").forEach((el) => {
if (el.parentNode) el.parentNode.removeChild(el);
});
},
},
});
app.appendChild(node);
};
// 生成弹出窗
self.createPopUp = (title, node, onConfirm, width, height, onExtend) => {
const popupNode = self.createNode({
tagName: "div",
obj: {
class: "seu-temp",
style: `
position: fixed;
left: ${width ? 50 - 0.5 * width : 30}%;
top: ${height ? 50 - 0.5 * height : 30}%;
width: ${width || 40}%;
height: ${height || 40}%;
z-index: 2021;
background-color: white;
border-radius: 30px;
overflow: auto;
`,
},
children: [
self.createNode({
tagName: "h1",
obj: {
style: `
margin: 20px 0;
width: 100%;
text-align: center;
`,
},
text: title,
}),
node,
self.createNode({
tagName: "div",
obj: {
style: `
position: absolute;
width: 80%;
left: 10%;
bottom: 10%;
display: flex;
justify-content: space-between;
align-items: center;
`,
},
children: [
// 左侧按钮组
self.createNode({
tagName: "div",
obj: {
style: "display: flex; gap: 10%;",
},
children: [
...(onExtend
? [
self.createNode({
tagName: "button",
obj: {
class:
"el-button el-button--primary el-button--large is-round",
},
text: "更多",
ev: { click: onExtend },
}),
]
: []),
// 如果存在其他temp类元素,说明当前不是第一层弹窗
...(document.querySelectorAll(".seu-temp").length > 0
? [
self.createNode({
tagName: "button",
obj: {
class:
"el-button el-button--warning el-button--large is-round",
},
text: "返回",
ev: {
click: () => {
// 移除当前弹窗
if (popupNode.parentNode) {
popupNode.parentNode.removeChild(popupNode);
}
},
},
}),
]
: []),
],
}),
// 右侧确认按钮
self.createNode({
tagName: "button",
obj: {
class:
"el-button el-button--default el-button--large is-round",
},
text: "确定",
ev: {
click: () => {
if (onConfirm) onConfirm();
if (document.querySelectorAll(".seu-temp").length > 1) {
if (popupNode.parentNode) {
popupNode.parentNode.removeChild(popupNode);
}
} else {
// 否则清除所有弹窗
document.getElementById("seu-mask").style.display = "none";
document.querySelectorAll(".seu-temp").forEach((el) => {
if (el.parentNode) el.parentNode.removeChild(el);
});
}
},
},
}),
],
}),
],
ev: {
// 阻止默认的表单提交行为
submit: (e) => e.preventDefault(),
// 阻止回车冒泡
keydown: (e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
}
},
},
});
app.appendChild(popupNode);
return popupNode;
};
self.currentPopup = null;
// 更新弹窗
self.updatePopup = (enableSearch, tempSettings) => {
// 如果存在旧弹窗,先移除
if (self.currentPopup && self.currentPopup.parentNode) {
self.currentPopup.parentNode.removeChild(self.currentPopup);
}
const mainSettings = self.showSettings(tempSettings);
// 创建新弹窗
self.currentPopup = self.createPopUp(
"设置",
mainSettings.node,
() => {
Object.assign(settings, mainSettings.tempSettings);
methods.saveData();
tip({
type: "success",
message: "设置已保存",
duration: 1000,
});
},
30,
45,
() => {
const searchSettings = self.showSearchSettings(mainSettings.tempSettings);
self.createPopUp(
"更多设置",
searchSettings.node,
() => {
mainSettings.tempSettings.mode.groupSize = searchSettings.tempSettings.mode.groupSize;
mainSettings.tempSettings.search = searchSettings.tempSettings.search;
Object.assign(settings, JSON.parse(JSON.stringify(mainSettings.tempSettings)));
methods.saveData();
tip({
type: "success",
message: "设置已保存",
duration: 1000,
});
},
30,
45
);
}
);
};
// 数值输入框
self.createNumberInput = (options) => {
const {
value, // 初始值
min, // 最小值
max, // 最大值
step, // 步进值
style, // 样式
onSave, // 保存回调
updateValue, // 值更新回调(可选)
} = options;
let inputElement = null;
let currentBaseValue = value;
// 自动保存函数
const autoSave = () => {
if (!inputElement) return;
const parsed = Number(inputElement.value);
const candidate = inputElement.value.trim() && Number.isFinite(parsed) ? Math.trunc(parsed) : currentBaseValue;
const currentValue = Math.max(Number(inputElement.min), Math.min(Number(inputElement.max), candidate));
inputElement.value = currentValue;
onSave(currentValue);
currentBaseValue = currentValue;
};
// 创建输入框
inputElement = self.createNode({
tagName: "input",
obj: {
class: "el-input__inner",
type: "number",
value: value,
min: min,
max: max,
step: step,
style:
style ||
"width: 60%; margin-left: 2%; margin-right: 2%; height: 30px",
},
ev: {
blur: autoSave, // 失去焦点时自动保存
wheel: (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -parseInt(step) : parseInt(step);
const newValue = Math.max(
parseInt(inputElement.min),
Math.min(parseInt(inputElement.max), parseInt(e.target.value) + delta)
);
e.target.value = newValue;
autoSave(); // 滚轮修改后立即保存
if (updateValue) updateValue(newValue);
},
keydown: (e) => {
if (e.key === "Enter") {
e.preventDefault();
autoSave(); // 按回车时自动保存
e.target.blur(); // 失去焦点
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
const delta =
e.key === "ArrowUp" ? parseInt(step) : -parseInt(step);
const newValue = Math.max(
parseInt(inputElement.min),
Math.min(parseInt(inputElement.max), parseInt(e.target.value) + delta)
);
e.target.value = newValue;
autoSave(); // 方向键修改后立即保存
if (updateValue) updateValue(newValue);
}
},
},
});
return {
input: inputElement,
};
};
// 设置
self.showSettings = (tempSettings) => {
tempSettings = tempSettings
? JSON.parse(JSON.stringify(tempSettings))
: JSON.parse(JSON.stringify(settings));
const intervalInput = self.createNumberInput({
value: methods.getCurrentInterval(tempSettings),
min: tempSettings.mode.isGrouped ? "1000" : "1",
max: "60000",
step: "1",
onSave: (value) => {
const mode = tempSettings.mode.isAsync ? "async" : "sync";
const type = tempSettings.mode.isGrouped ? "group" : "single";
tempSettings.interval[mode][type] = value;
},
});
intervalInput.input.id = "seu-interval";
intervalInput.input.setAttribute("aria-label", "发送间隔(毫秒)");
const settingsNode = self.createNode({
tagName: "div",
obj: {
style: "margin-left: 10%",
},
children: [
// 抢课方式选择
self.createNode({
tagName: "div",
obj: {
style: "margin-bottom: 5%",
},
children: [
self.createNode({
tagName: "label",
text: "抢课方式:",
obj: {
style: "margin-right: 10%",
},
}),
self.createNode({
tagName: "input",
obj: {
type: "radio",
name: "cycle-mode",
id: "single-cycle",
},
ev: {
change: (e) => {
tempSettings.mode.isCyclic = !e.target.checked;
},
},
}),
self.createNode({
tagName: "label",
text: " 单次抢课",
obj: {
for: "single-cycle",
style: "margin-right: 10%",
},
}),
self.createNode({
tagName: "input",
obj: {
type: "radio",
name: "cycle-mode",
id: "multi-cycle",
},
ev: {
change: (e) => {
tempSettings.mode.isCyclic = e.target.checked;
},
},
}),
self.createNode({
tagName: "label",
text: " 循环抢课",
obj: {
for: "multi-cycle",
},
}),
],
}),
// 发送模式选择
self.createNode({
tagName: "div",
obj: {
style: "margin-bottom: 5%",
},
children: [
self.createNode({
tagName: "label",
text: "发送模式:",
obj: {
style: "margin-right: 10%",
},
}),
self.createNode({
tagName: "input",
obj: {
type: "radio",
name: "send-mode",
id: "sync-mode",
},
ev: {
change: (e) => {
tempSettings.mode.isAsync = !e.target.checked;
const newValue = methods.getCurrentInterval(tempSettings);
intervalInput.input.value = newValue;
intervalInput.input.min = tempSettings.mode.isGrouped ? "1000" : "1";
},
},
}),
self.createNode({
tagName: "label",
text: " 同步模式",
obj: {
for: "sync-mode",
style: "margin-right: 10%",
},
}),
self.createNode({
tagName: "input",
obj: {
type: "radio",
name: "send-mode",
id: "async-mode",
},
ev: {
change: (e) => {
tempSettings.mode.isAsync = e.target.checked;
const newValue = methods.getCurrentInterval(tempSettings);
intervalInput.input.value = newValue;
intervalInput.input.min = tempSettings.mode.isGrouped ? "1000" : "1";
},
},
}),
self.createNode({
tagName: "label",
text: " 异步模式",
obj: {
for: "async-mode",
},
}),
],
}),
// 发送方式选择
self.createNode({
tagName: "div",
obj: {
style: "margin-bottom: 5%",
},
children: [
self.createNode({
tagName: "label",
text: "发送方式:",
obj: {
style: "margin-right: 10%",
},
}),
self.createNode({
tagName: "input",
obj: {
type: "radio",
name: "group-mode",
id: "single-send",
},
ev: {
change: (e) => {
tempSettings.mode.isGrouped = !e.target.checked;
const newValue = methods.getCurrentInterval(tempSettings);
intervalInput.input.value = newValue;
intervalInput.input.min = tempSettings.mode.isGrouped ? "1000" : "1";
},
},
}),
self.createNode({
tagName: "label",
text: " 单个发送",
obj: {
for: "single-send",
style: "margin-right: 10%",
},
}),
self.createNode({
tagName: "input",
obj: {
type: "radio",
name: "group-mode",
id: "group-send",
},
ev: {
change: (e) => {
tempSettings.mode.isGrouped = e.target.checked;
const newValue = methods.getCurrentInterval(tempSettings);
intervalInput.input.value = newValue;
intervalInput.input.min = tempSettings.mode.isGrouped ? "1000" : "1";
},
},
}),
self.createNode({
tagName: "label",
text: " 分组发送",
obj: {
for: "group-send",
},
}),
],
}),
// 时间间隔设置
self.createNode({
tagName: "div",
obj: {
style: "display: flex; align-items: center",
},
children: [
self.createNode({
tagName: "label",
text: "时间间隔(ms):",
obj: {
style: "margin-right: 10px",
},
}),
intervalInput.input,
],
}),
// 搜索功能启用开关
self.createNode({
tagName: "div",
obj: {
style: "margin-top: 5%",
},
children: [
self.createNode({
tagName: "label",
text: "启用搜索功能:",
obj: {
style: "margin-right: 5%",
},
}),
self.createNode({
tagName: "input",
obj: {
type: "checkbox",
id: "enable-search",
},
ev: {
change: (e) => {
tempSettings.mode.enableSearch = e.target.checked;
self.updatePopup(e.target.checked, tempSettings);
},
},
}),
],
}),
],
});
// 创建完成后进行初始化
setTimeout(() => {
// 获取所有需要初始化的单选按钮
const singleCycleInput = settingsNode.querySelector("#single-cycle");
const multiCycleInput = settingsNode.querySelector("#multi-cycle");
const syncModeInput = settingsNode.querySelector("#sync-mode");
const asyncModeInput = settingsNode.querySelector("#async-mode");
const singleSendInput = settingsNode.querySelector("#single-send");
const groupSendInput = settingsNode.querySelector("#group-send");
const enableSearchInput = settingsNode.querySelector("#enable-search");
// 根据设置初始化抢课方式
if (tempSettings.mode.isCyclic) {
multiCycleInput.checked = true;
} else {
singleCycleInput.checked = true;
}
// 根据设置初始化发送模式
if (tempSettings.mode.isAsync) {
asyncModeInput.checked = true;
} else {
syncModeInput.checked = true;
}
// 根据设置初始化发送方式
if (tempSettings.mode.isGrouped) {
groupSendInput.checked = true;
} else {
singleSendInput.checked = true;
}
// 根据设置初始化搜索功能开关
if (tempSettings.mode.enableSearch) {
enableSearchInput.checked = true;
}
}, 0);
return {
node: settingsNode,
tempSettings: tempSettings,
};
};
// 搜索设置
self.showSearchSettings = (source = settings) => {
// 创建临时设置对象和引用变量
const tempSettings = JSON.parse(JSON.stringify(source));
const groupSizeInput = self.createNumberInput({
value: tempSettings.mode.groupSize,
min: "1", max: String(Number.MAX_SAFE_INTEGER), step: "1",
onSave: value => { tempSettings.mode.groupSize = core.groupSize(value); },
});
groupSizeInput.input.id = "seu-group-size";
const pageSizeInput = self.createNumberInput({
value: tempSettings.search.pageSize,
min: "10",
max: "100",
step: "10",
onSave: (value) => {
tempSettings.search.pageSize = value;
},
});
const pageDelayInput = self.createNumberInput({
value: tempSettings.search.pageDelay,
min: "100",
max: "60000",
step: "100",
onSave: (value) => {
tempSettings.search.pageDelay = value;
},
});
const settingsNode = self.createNode({
tagName: "div",
obj: { style: "margin: 10%" },
children: [
self.createNode({
tagName: "div",
obj: { style: "margin-bottom: 5%" },
children: [
self.createNode({ tagName: "label", text: "每组课程数:", obj: { for: "seu-group-size", style: "margin-right: 10%" } }),
groupSizeInput.input,
],
}),
// 每页数量设置
self.createNode({
tagName: "div",
obj: { style: "margin-bottom: 5%" },
children: [
self.createNode({
tagName: "label",
text: "每页数量:",
obj: { style: "margin-right: 10%" },
}),
pageSizeInput.input,
],
}),
// 翻页延迟设置
self.createNode({
tagName: "div",
obj: { style: "margin-bottom: 5%" },
children: [
self.createNode({
tagName: "label",
text: "翻页延迟:",
obj: { style: "margin-right: 10%" },
}),
pageDelayInput.input,
],
}),
],
});
return {
node: settingsNode,
tempSettings: tempSettings,
};
};
// 生成课程详情信息
self.showCourseDetails = (course) => {
return self.createNode({
tagName: "div",
obj: {
style: `margin:5%`,
},
children: [
self.createNode({
tagName: "table",
obj: {
width: "80%",
border: "1",
style: `
background-color: rgba(0,0,0,0);
color: black;
margin: 0 auto;
`,
},
children: [
// 表头
self.createNode({
tagName: "tr",
obj: {
style: `
height: 30px;
background-color: #255e95;
color: lightblue;
`,
},
HTML: `
属性 |
值 |
`,
}),
// 课程号和课程名
self.createNode({
tagName: "tr",
obj: {
style: `height: 30px`,
},
children: [
self.createNode({
tagName: "td",
obj: { style: `text-align: center` },
text: "课程信息",
}),
self.createNode({
tagName: "td",
obj: { style: `text-align: center` },
text: `${methods.displayCourse(course)} ${course.message || ""}`,
}),
],
}),
// 学院和教师
self.createNode({
tagName: "tr",
obj: {
style: `height: 30px`,
},
children: [
self.createNode({
tagName: "td",
obj: { style: `text-align: center` },
text: "开课单位/教师",
}),
self.createNode({
tagName: "td",
obj: { style: `text-align: center` },
text: `${course.department} ${course.teacherName}`,
}),
],
}),
// 授课地点
self.createNode({
tagName: "tr",
obj: {
style: `height: 30px`,
},
children: [
self.createNode({
tagName: "td",
obj: { style: `text-align: center` },
text: "授课地点",
}),
self.createNode({
tagName: "td",
obj: { style: `text-align: center` },
text: course.location || "待定",
}),
],
}),
// 课程性质和类别
self.createNode({
tagName: "tr",
obj: {
style: `height: 30px`,
},
children: [
self.createNode({
tagName: "td",
obj: { style: `text-align: center` },
text: "课程属性",
}),
self.createNode({
tagName: "td",
obj: { style: `text-align: center` },
text: `${course.courseNature} ${course.courseCategory}`,
}),
],
}),
// 选课人数
self.createNode({
tagName: "tr",
obj: {
style: `height: 30px`,
},
children: [
self.createNode({
tagName: "td",
obj: { style: `text-align: center` },
text: "选课人数",
}),
self.createNode({
tagName: "td",
obj: { style: `text-align: center` },
text: `${course.selectedCount}/${course.totalCapacity}`,
}),
],
}),
],
}),
],
});
};
// 显示公告弹窗
self.showAnnouncement = () => {
// 显示弹窗时立即标记为已读
settings.announcement.hasRead = true;
methods.saveData();
const announcementNode = self.createNode({
tagName: "div",
obj: {
style: `
margin: 5% 10%;
padding: 20px;
background-color: #fff3cd;
border: 2px solid #ffc107;
border-radius: 10px;
`,
},
children: [
self.createNode({
tagName: "p",
obj: {
style: `
color: #333;
line-height: 1.8;
text-indent: 2em;
margin-bottom: 10px;
`,
},
text: "请各位同学秉持诚信原则参与选课,严禁使用脚本、代码等任何手段(无论有意或无意)干扰、破坏选课秩序;学校将对选课数据进行后台异常监测,一经查实违规行为,将直接判定选课结果无效,并根据情节轻重依规给予相应纪律处分。",
}),
self.createNode({
tagName: "p",
obj: {
style: `
color: #333;
line-height: 1.8;
text-indent: 2em;
`,
},
text: "在此提醒大家,自觉抵制各类非法课程资源交易。若发现网上存在非法售卖本校课程资源、诱导同学参与违规交易等情况,请第一时间向学院或教务处提供线索。让我们携手监督、共同行动,以实际行动维护公平、诚信、有序的选课氛围,筑牢校园学术诚信防线。",
}),
],
});
// 关闭公告弹窗的函数(需要在使用前定义)
const closeAnnouncement = () => {
const mask = document.getElementById("seu-mask");
if (mask) mask.style.display = "none";
if (popupNode && popupNode.parentNode) {
popupNode.parentNode.removeChild(popupNode);
}
};
const popupNode = self.createNode({
tagName: "div",
obj: {
class: "announcement-popup",
style: `
position: fixed;
left: 25%;
top: 20%;
width: 50%;
max-height: 60%;
z-index: 2021;
background-color: white;
border-radius: 30px;
overflow: auto;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
`,
},
children: [
self.createNode({
tagName: "h1",
obj: {
style: `
margin: 20px 0;
width: 100%;
text-align: center;
color: #d9534f;
`,
},
text: "特别提醒(转)",
}),
announcementNode,
self.createNode({
tagName: "div",
obj: {
style: `
position: relative;
width: 80%;
left: 10%;
margin: 20px 0;
display: flex;
justify-content: space-between;
align-items: center;
`,
},
children: [
self.createNode({
tagName: "div",
obj: {
style: "display: flex; align-items: center;",
},
children: [
self.createNode({
tagName: "input",
obj: {
type: "checkbox",
id: "dont-show-again",
checked: true,
style: "margin-right: 8px; cursor: pointer;",
},
ev: {
change: (e) => {
settings.announcement.hasRead = e.target.checked;
methods.saveData();
},
},
}),
self.createNode({
tagName: "label",
text: "不再弹出",
obj: {
for: "dont-show-again",
style: "cursor: pointer; user-select: none;",
},
}),
],
}),
self.createNode({
tagName: "button",
obj: {
class:
"el-button el-button--primary el-button--large is-round",
},
text: "我知道了",
ev: {
click: closeAnnouncement,
},
}),
],
}),
],
});
// 显示遮罩,并设置点击事件
const mask = document.getElementById("seu-mask");
if (mask) {
mask.style.display = "block";
// 为遮罩添加一次性点击事件监听器
const maskClickHandler = (e) => {
if (e.target === mask) {
closeAnnouncement();
mask.removeEventListener("click", maskClickHandler);
}
};
mask.addEventListener("click", maskClickHandler);
}
app.appendChild(popupNode);
};
})(Components);
// Embedded inside the userscript, after the retained v3 UI definitions.
let searching = false;
let runController = null;
let runPromise = null;
let attachedSocket = null;
const controllers = new Set();
const labels = { ready: '', submitting: '提交中', queued: '排队中', unknown: '待核实', failed: '失败', cancelled: '已取消' };
const ledger = new core.Ledger(record => {
const entry = Object.entries(enrollDict).find(([, course]) => core.keyOf(course) === core.keyOf(record.course));
if (!entry) return;
const [key, course] = entry;
course.status = record.state;
course.message = record.message;
if (record.state === 'success') {
delete enrollDict[key];
tip({ type: 'success', message: `${course.courseName}:已确认选课成功`, duration: 3000 });
} else if (['failed', 'unknown'].includes(record.state)) {
tip({ type: 'warning', message: `${course.courseName}:${record.message}`, duration: 4000 });
}
methods.saveData();
Components.reloadList();
methods.updateUIState();
});
function batchId() { return String(grablessonsVue.lcParam.currentBatch.code); }
function availableTypes() {
const allowed = ['TJKC', 'FANKC', 'FAWKC', 'TYKC', 'XGKC'];
const shown = (grablessonsVue.menuData?.menuList || []).map(item => item.teachingClassType);
return allowed.filter(type => shown.includes(type));
}
function attachSocket() {
const socket = grablessonsVue.sock;
if (!socket || socket === attachedSocket) return;
if (attachedSocket) attachedSocket.removeEventListener('message', onSocketMessage);
attachedSocket = socket;
socket.addEventListener('message', onSocketMessage);
}
function onSocketMessage(event) {
try { ledger.receive(JSON.parse(event.data), batchId()); } catch { /* Ignore heartbeat/non-JSON frames. */ }
}
function clamp(value, min, max, fallback) {
return Number.isFinite(Number(value)) ? Math.max(min, Math.min(max, Math.trunc(Number(value)))) : fallback;
}
function definitiveError(message) {
runController?.abort();
return Object.assign(new Error(message), { definitive: true });
}
function normalizeSettings() {
for (const mode of ['sync', 'async']) {
for (const kind of ['single', 'group']) settings.interval[mode][kind] = core.intervalMs(settings.interval[mode][kind], kind === 'group');
}
settings.mode.groupSize = core.groupSize(settings.mode.groupSize);
settings.search.pageSize = clamp(settings.search.pageSize, 10, 100, 20);
settings.search.pageDelay = clamp(settings.search.pageDelay, 100, 60000, 500);
settings.mode.cycleCount = clamp(settings.mode.cycleCount, -1, 10000, -1) || 1;
}
async function post(path, data, { json = false, batch = batchId() } = {}) {
const base = new URL(window.axios.defaults.baseURL, location.href);
if (base.origin !== location.origin || base.pathname.replace(/\/$/, '') !== '/xsxk') throw definitiveError('官网接口地址变化,已停止提交');
const authKey = window.axiosKey || 'Authorization';
const auth = window.axios.defaults.headers[authKey] || window.axios.defaults.headers.common?.[authKey];
if (!auth) throw definitiveError('请先在官网登录');
const controller = new AbortController();
controllers.add(controller);
const timer = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch(`${base.origin}/xsxk${path}`, {
method: 'POST', credentials: 'same-origin', signal: controller.signal,
headers: { [authKey]: auth, batchId: batch, 'Content-Type': json ? 'application/json' : 'application/x-www-form-urlencoded' },
body: json ? JSON.stringify(data) : new URLSearchParams(data).toString(),
});
if (!response.ok) {
if ([401, 403, 429].includes(response.status)) throw definitiveError(`官网拒绝请求(HTTP ${response.status}),已停止,请检查登录状态`);
throw new Error(`官网请求返回 HTTP ${response.status},请检查登录和网络`);
}
let body;
try { body = await response.json(); } catch { throw new Error('官网返回了非 JSON 内容,请检查 VPN 或重新登录'); }
if (['401', '402', '403', '429'].includes(core.codeOf(body?.code))) {
throw definitiveError('登录失效或请求受限,已停止,请回官网检查');
}
if (!body || body.code == null) throw new Error('官网响应结构变化,已停止');
return body;
} finally {
clearTimeout(timer);
controllers.delete(controller);
}
}
async function selectedRows() {
const result = await post('/elective/select', {});
if (core.codeOf(result.code) !== '200' || !Array.isArray(result.data)) throw new Error('无法核对已选课程,请在官网检查');
return result.data;
}
let methods = {
async init() {
let raw;
try { raw = JSON.parse(localStorage.getItem('july') || 'null'); }
catch { tip({ type: 'warning', message: '旧配置无法读取,使用默认设置', duration: 3000 }); }
if (raw?.settings && typeof raw.settings === 'object') {
const merge = (target, source) => {
for (const key of Object.keys(target)) {
if (source?.[key] === undefined) continue;
if (target[key] && typeof target[key] === 'object') merge(target[key], source[key]);
else if (typeof target[key] === typeof source[key]) target[key] = source[key];
}
};
merge(settings, raw.settings);
}
normalizeSettings();
let restoreCodes = [];
if (raw?.enrollDict && typeof raw.enrollDict === 'object') {
for (const [code, saved] of Object.entries(raw.enrollDict)) {
if (!saved || typeof saved !== 'object') continue;
const { secretVal, token, ...course } = saved;
course.courseBatch = String(course.courseBatch || '');
if (!/^[A-Z0-9]+$/i.test(code) || !course.classID) continue;
course.status = core.pending(course.status) ? 'unknown' : 'ready';
course.message = course.status === 'unknown' ? '上次提交结果待核实' : '需要重新获取当前会话课程信息';
enrollDict[code] = course;
if (course.status === 'unknown') ledger.restore(course);
if (course.courseBatch === batchId()) restoreCodes.push(code);
}
}
attachSocket();
const watch = setInterval(attachSocket, 500);
window.addEventListener('pagehide', () => {
clearInterval(watch);
runController?.abort();
for (const controller of controllers) controller.abort();
attachedSocket?.removeEventListener('message', onSocketMessage);
}, { once: true });
Components.reloadList();
methods.updateUIState();
const input = document.getElementById('seu-input-box');
input.value = core.parseCodes([settings.savedCourseCodes || '', ...restoreCodes].join(' ')).join(' ');
// Restored course credentials always come from the current session.
if (input.value) await methods.resolveCodes(input.value, true);
methods.saveData();
if (!settings.announcement.hasRead) Components.showAnnouncement();
},
saveData() {
normalizeSettings();
try { localStorage.setItem('july', JSON.stringify(core.serializable(enrollDict, settings))); }
catch { tip({ type: 'warning', message: '本地设置未能保存,请勿在结果未明时刷新重试', duration: 3000 }); }
},
togglePanel() {
const panel = document.getElementById('seu-panel');
const open = panel.style.display !== 'block';
panel.style.display = open ? 'block' : 'none';
document.getElementById('seu-helper-toggle')?.setAttribute('aria-expanded', String(open));
},
drag(e, node) {
if (e.button !== 0) return;
let moved = false;
const x = e.pageX - node.offsetLeft, y = e.pageY - node.offsetTop;
document.onmousemove = event => { node.style.left = `${event.pageX - x}px`; node.style.top = `${event.pageY - y}px`; moved = true; };
document.onmouseup = () => {
document.onmousemove = document.onmouseup = null;
if (!moved) methods.togglePanel();
};
},
updateUIState() {
const busy = isRunning || searching || shouldStop;
for (const node of document.querySelectorAll('#seu-input-box, #seu-enroll-button, button.delete-button, button.add-course-button')) {
node.disabled = busy;
node.style.opacity = busy ? '0.5' : '1';
node.style.cursor = busy ? 'not-allowed' : 'pointer';
}
const stop = document.getElementById('seu-settings-stop-button');
if (stop) {
stop.disabled = searching;
stop.className = `el-button el-button--${isRunning ? 'danger' : 'info'} el-button--small is-round`;
stop.textContent = shouldStop ? '正在停止' : isRunning ? '停止抢课' : '扩展设置';
}
},
async enter(event) {
if (event.key !== 'Enter' || isRunning || searching) return;
event.preventDefault();
await methods.resolveCodes(document.getElementById('seu-input-box').value);
},
async resolveCodes(value, restoring = false) {
searching = true;
methods.updateUIState();
let remaining = core.parseCodes(value);
try {
remaining = methods.addEnrollDict(remaining.join(' '), null, null, !restoring, restoring);
if (remaining.length && settings.mode.enableSearch) {
for (const type of availableTypes()) {
let page = 1;
const seen = new Set();
for (let pages = 0; remaining.length && pages < 500; pages++) {
await core.delay(settings.search.pageDelay);
const { courseList, total } = await methods.searchCourse(type, page, settings.search.pageSize);
if (!courseList.length) break;
const signature = JSON.stringify(courseList.map(c => [c.KCH, c.JXBID, c.tcList?.map(t => t.JXBID)]));
if (seen.has(signature)) throw new Error('查询重复返回同一页,已停止翻页,请在官网查找剩余课程');
seen.add(signature);
remaining = methods.addEnrollDict(remaining.join(' '), type, courseList, false, restoring);
if ((page - 1) * settings.search.pageSize + courseList.length >= total) break;
// The official client caches up to three pages per response.
page += Math.max(1, Math.ceil(courseList.length / settings.search.pageSize));
}
if (!remaining.length) break;
}
}
} catch (error) { tip({ type: 'warning', message: error.message, duration: 4000 }); }
finally {
document.getElementById('seu-input-box').value = remaining.join(' ');
settings.savedCourseCodes = remaining.join(' ');
searching = false;
methods.saveData();
Components.reloadList();
methods.updateUIState();
}
},
addEnrollDict(value, type = null, rows = null, showTip = true, restoring = false) {
type ||= grablessonsVue.teachingClassType;
rows ||= grablessonsVue.courseList;
const remaining = [];
for (const code of core.parseCodes(value)) {
const old = enrollDict[code];
if (old?.secretVal && old.courseBatch === batchId() && !restoring) continue;
const found = availableTypes().includes(type) ? core.findCourse(code, rows, type, batchId()) : null;
if (!found) { remaining.push(code); continue; }
if (old && core.pending(old.status) && core.keyOf(old) === core.keyOf(found)) {
Object.assign(old, found, { status: old.status, message: old.message });
} else enrollDict[code] = found;
if (showTip) tip({ type: 'success', message: `${found.courseName} 已添加到本地待选列表`, duration: 1500 });
}
methods.saveData();
Components.reloadList();
methods.updateUIState();
return remaining;
},
addSingleCourse(code) {
if (!isRunning && !searching) methods.addEnrollDict(code);
},
getCurrentInterval(value) {
return core.intervalMs(value.interval[value.mode.isAsync ? 'async' : 'sync'][value.mode.isGrouped ? 'group' : 'single'], value.mode.isGrouped);
},
async enroll() {
if (runPromise || searching) return;
isRunning = true;
shouldStop = false;
runController = new AbortController();
const signal = runController.signal;
const originalBatch = batchId();
const results = new Set();
methods.updateUIState();
runPromise = (async () => {
const courses = Object.values(enrollDict).filter(c => c.courseBatch === originalBatch);
const selected = courses.some(c => core.pending(c.status)) ? await selectedRows() : [];
for (const [key, course] of Object.entries(enrollDict)) {
if (course.courseBatch !== originalBatch) continue;
if (core.containsClass(selected, course.classID)) {
delete enrollDict[key];
ledger.records.delete(core.keyOf(course));
}
else if (course.status === 'cancelled') course.status = 'ready';
}
methods.saveData();
Components.reloadList();
await core.run({
signal, mode: { ...settings.mode }, interval: methods.getCurrentInterval(settings),
waitForPending: async () => {
if (!results.size) return false;
await Promise.race(results);
return true;
},
getCourses: () => Object.values(enrollDict).filter(c => c.courseBatch === originalBatch),
send: async course => {
if (signal.aborted) return;
if (batchId() !== originalBatch) { runController.abort(); throw new Error('选课轮次已变化,请重新添加课程'); }
if (!course.secretVal) { course.status = 'cancelled'; course.message = '请重新搜索本课程,获取当前会话信息'; return; }
attachSocket();
const job = core.dispatch({ course, ledger, signal,
submit: data => post('/elective/clazz/add', data, { batch: originalBatch }),
confirm: async message => {
if (signal.aborted) return false;
try { await grablessonsVue.$confirm(`${message},确认选择这门课程吗?`, '提醒', { type: 'warning', closeOnClickModal: false }); return !signal.aborted; }
catch { return false; }
},
verify: async target => core.containsClass(await selectedRows(), target.classID),
});
const result = job.result.catch(error => {
runController.abort();
tip({ type: 'warning', message: error.message || '运行中断', duration: 4000 });
}).finally(() => results.delete(result));
results.add(result);
await job.response;
},
});
await Promise.allSettled(results);
const unresolved = Object.values(enrollDict).filter(c => c.courseBatch === originalBatch && core.pending(c.status));
if (unresolved.length) tip({ type: 'warning', message: `${unresolved.length} 门课程结果待核实,已保留并暂停重复提交`, duration: 5000 });
})();
try { await runPromise; }
catch (error) { tip({ type: 'warning', message: error.message || '运行中断,请检查官网', duration: 4000 }); }
finally {
runController.abort();
for (const controller of controllers) controller.abort();
await Promise.allSettled(results);
isRunning = false; shouldStop = false; runPromise = null;
methods.saveData(); Components.reloadList(); methods.updateUIState();
}
},
async stopEnrolling() {
shouldStop = true;
runController?.abort();
for (const controller of controllers) controller.abort();
methods.updateUIState();
// Stopping cannot undo a request already accepted by the server.
if (runPromise) await runPromise.catch(() => {});
},
async searchCourse(type, pageNumber, pageSize) {
const result = await post('/elective/clazz/list', {
teachingClassType: type, pageNumber, pageSize, orderBy: '', campus: grablessonsVue.currentCampus.code,
}, { json: true });
if (core.codeOf(result.code) !== '200' || !Array.isArray(result.data?.rows) || !Number.isFinite(Number(result.data.total))) throw new Error(result.msg || '课程查询响应结构变化');
return { courseList: result.data.rows, total: Number(result.data.total) };
},
displayCourse(course) { return `${course.courseName}${labels[course.status] ? ` [${labels[course.status]}]` : ''}`; },
};
// Keep original page handlers intact. Add one helper button per teaching-class card.
Components.addEnrollButton = () => {
let scheduled = false;
const scan = () => {
scheduled = false;
const retained = new Set();
const supported = availableTypes().includes(grablessonsVue.teachingClassType);
for (const select of document.querySelectorAll('button.el-button')) {
if (!supported || select.closest('#seu-helper-root') || select.classList.contains('add-course-button') || !/^选择$/.test(select.textContent.trim())) continue;
const parent = select.parentElement;
if (!parent) continue;
const card = select.closest('.el-card__body') || select.closest('tr');
if (!card) continue;
const container = select.closest('.el-collapse-item') || select.closest('td.el-table__expanded-cell')?.parentElement?.previousElementSibling || card;
const title = container.textContent || '';
const text = card.textContent || '';
const matches = [];
for (const course of grablessonsVue.courseList || []) {
if (!title.includes(course.KCH)) continue;
for (const teacher of course.tcList || [course]) {
const sequence = String(teacher.KXH || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (sequence && new RegExp(`\\[\\s*${sequence}\\s*\\]`).test(text)) matches.push(`${course.KCH}${teacher.KXH}`);
}
}
if (matches.length !== 1) continue;
let button = parent.querySelector('.add-course-button');
if (!button) {
button = document.createElement('button');
button.type = 'button';
button.className = 'el-button el-button--primary el-button--mini is-round add-course-button';
button.textContent = '添加';
button.addEventListener('click', event => { event.stopPropagation(); methods.addSingleCourse(button.dataset.seuCourseCode); });
parent.appendChild(button);
}
button.dataset.seuCourseCode = matches[0];
button.disabled = isRunning || searching;
retained.add(button);
}
for (const button of document.querySelectorAll('button.add-course-button')) if (!retained.has(button)) button.remove();
};
const observer = new MutationObserver(() => { if (!scheduled) { scheduled = true; setTimeout(scan, 100); } });
observer.observe(document.getElementById('xsxkapp'), { childList: true, characterData: true, subtree: true });
scan();
window.addEventListener('pagehide', () => observer.disconnect(), { once: true });
};
// The helper lives outside #xsxkapp: do not depend on host-scoped launcher CSS
// or Element UI's icon font for the only way to reopen the panel.
Components.createTag = () => {
const node = document.createElement('button');
node.id = 'seu-helper-toggle';
node.type = 'button';
node.title = '打开或关闭选课助手(可拖动)';
node.setAttribute('aria-label', '打开或关闭选课助手');
node.setAttribute('aria-controls', 'seu-panel');
node.setAttribute('aria-expanded', 'true');
node.style.cssText = `position:fixed;top:250px;left:30px;z-index:1314;
display:flex;align-items:center;justify-content:center;box-sizing:border-box;
width:40px;height:40px;min-width:40px;min-height:40px;padding:0;margin:0;
border:0;border-radius:50%;background:#2b2b2b;color:#fff;
cursor:pointer;user-select:none;box-shadow:0 2px 8px rgba(0,0,0,.25);`;
node.innerHTML = ``;
node.addEventListener('mousedown', event => methods.drag(event, node));
// Mouse activation is handled on mouseup, preserving the original drag/click
// distinction. Native keyboard activation has no mousedown/mouseup pair.
node.addEventListener('click', event => { if (event.detail === 0) methods.togglePanel(); });
app.appendChild(node);
};
Components.mount();
methods.init().catch(error => tip({ type: 'warning', message: error.message || '助手初始化失败', duration: 5000 }));
})();