翱翔教务功能加强
使用方法
- 安装 Tampermonkey
- 新建脚本,复制以下代码保存即可
点击查看代码
// ==UserScript==
// @name 翱翔教务功能加强(非官方)
// @namespace http://tampermonkey.net/
// @version 1.7.3
// @description 1.提供GPA分析报告;2. 导出课程成绩与教学班排名;3.更好的“学生画像”显示;4.选课助手;5.课程关注与后台同步;6.一键自动评教;7.人员信息检索
// @author 47
// @match https://jwxt.nwpu.edu.cn/*
// @match https://jwxt.nwpu.edu.cn/student/for-std/course-select/some-page*
// @match https://ecampus.nwpu.edu.cn/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant unsafeWindow
// @connect electronic-signature.nwpu.edu.cn
// @license MIT
// @icon https://www.google.com/s2/favicons?sz=64&domain=nwpu.edu.cn
// @run-at document-start
// @require https://cdnjs.cloudflare.com/ajax/libs/exceljs/4.3.0/exceljs.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// @homepage https://greasyfork.org/zh-CN/scripts/524099-%E7%BF%B1%E7%BF%94%E6%95%99%E5%8A%A1%E5%8A%9F%E8%83%BD%E5%8A%A0%E5%BC%BA
// @downloadURL https://update.greasyfork.org/scripts/524099/%E7%BF%B1%E7%BF%94%E6%95%99%E5%8A%A1%E5%8A%9F%E8%83%BD%E5%8A%A0%E5%BC%BA.user.js
// @updateURL https://update.greasyfork.org/scripts/524099/%E7%BF%B1%E7%BF%94%E6%95%99%E5%8A%A1%E5%8A%9F%E8%83%BD%E5%8A%A0%E5%BC%BA.meta.js
// ==/UserScript==
// ==================== 用户可配置区域 ====================
/**
* @description 中文等级制成绩到百分制分数的映射。
* @description 您可以根据需要修改这里的数值,例如将 '优秀' 改为 95。
*/
const GRADE_MAPPING_CONFIG = {
'优秀': 93,
'良好': 80,
'中等': 70,
'及格': 60,
'不及格': 0
};
// ============================================================
(function () {
'use strict';
// =============== 0.0 拦截浏览器的异常请求,优化网页加载速度 ===============
try {
const BAD_KEY = 'burp';
// 1. 劫持 HTMLImageElement 原型链上的 src 属性
const imageProto = HTMLImageElement.prototype;
const originalSrcDescriptor = Object.getOwnPropertyDescriptor(imageProto, 'src');
if (originalSrcDescriptor) {
Object.defineProperty(imageProto, 'src', {
get: function() {
return originalSrcDescriptor.get.call(this);
},
set: function(value) {
if (value && typeof value === 'string' && value.indexOf(BAD_KEY) !== -1) {
//console.log('[NWPU-Enhanced] 成功拦截底层图片请求:', value);
return;
}
originalSrcDescriptor.set.call(this, value);
},
configurable: true,
enumerable: true
});
}
// 2. 劫持 setAttribute 方法
const originalSetAttribute = Element.prototype.setAttribute;
Element.prototype.setAttribute = function(name, value) {
if (this instanceof HTMLImageElement && name === 'src' && value && value.indexOf(BAD_KEY) !== -1) {
//console.log('[NWPU-Enhanced] 成功拦截 setAttribute:', value);
return;
}
return originalSetAttribute.apply(this, arguments);
};
} catch (e) {
console.error('[NWPU-Enhanced] 拦截器初始化异常', e);
}
// =-=-=-=-=-=-=-=-=-=-=-=-= 0. 基础工具与日志系统 =-=-=-=-=-=-=-=-=-=-=-=-=
// --- 全局常量定义 ---
const CONSTANTS = {
CACHE_KEY: 'jwxtEnhancedDataCache',
FOLLOWED_COURSES_KEY: 'jwxt_followed_courses_list',
BACKGROUND_SYNC_KEY: 'jwxt_background_sync_data',
LAST_SYNC_TIME_KEY: 'jwxt_last_bg_sync_time',
HISTORY_STORAGE_KEY: 'course_enrollment_history_auto_sync',
SYNC_COOLDOWN_MS: 1 * 60 * 60 * 1000,
GRADES_SNAPSHOT_KEY: 'jwxt_grades_snapshot_v1',
// 性能优化常量
PAGINATION_LIMIT: 50,
PAGE_SIZE_1000: 1000,
DEBOUNCE_DELAY: 50,
OBSERVER_TIMEOUT: 3000,
RETRY_INTERVAL: 100,
MAX_RETRY_COUNT: 20,
SLEEP_SHORT: 500,
SLEEP_LONG: 2000,
// API 端点
API_STUDENT_INFO: 'https://jwxt.nwpu.edu.cn/student/for-std/student-portrait/getStdInfo',
API_GPA: 'https://jwxt.nwpu.edu.cn/student/for-std/student-portrait/getMyGpa',
API_GRADES: 'https://jwxt.nwpu.edu.cn/student/for-std/student-portrait/getMyGrades',
API_RANK: 'https://jwxt.nwpu.edu.cn/student/for-std/student-portrait/getMyGradesByProgram',
API_PERSONNEL: 'https://electronic-signature.nwpu.edu.cn/api/local-user/page',
API_MY_SCHEDULE: 'https://jwxt.nwpu.edu.cn/student/for-std/course-schedule/getData',
PAGE_COURSE_TABLE: 'https://jwxt.nwpu.edu.cn/student/for-std/course-table',
// GPA 预测
GPA_ESTIMATE_KEY: 'jwxt_gpa_estimate_data',
// 课表缓存
COURSE_TABLE_CACHE_KEY: 'jwxt_course_table_cache'
};
/**
* 统一日志输出工具
* @description 所有控制台输出统一带有 [NWPU-Enhanced] 前缀
*/
const Logger = {
_print: (module, msg, type = 'log', args = []) => {
const prefix = `%c[NWPU-Enhanced][${module}]`;
const css = 'color: #007bff; font-weight: bold;';
if (args.length > 0) {
console[type](prefix, css, msg, ...args);
} else {
console[type](prefix, css, msg);
}
},
log: (module, msg, ...args) => Logger._print(module, msg, 'log', args),
warn: (module, msg, ...args) => Logger._print(module, msg, 'warn', args),
error: (module, msg, ...args) => Logger._print(module, msg, 'error', args),
info: (module, msg, ...args) => Logger._print(module, msg, 'info', args)
};
/**
* 通用 DOM 工具库 - 减少重复 DOM 操作
*/
const DOMUtils = {
/**
* 缓存 DOM 查询结果
*/
cache: new Map(),
/**
* 带缓存的元素查询
*/
$(selector, context = document) {
const key = selector + (context === document ? '' : context.toString());
if (!DOMUtils.cache.has(key)) {
const el = context.querySelector(selector);
DOMUtils.cache.set(key, el);
return el;
}
const cached = DOMUtils.cache.get(key);
return cached && cached.isConnected ? cached : (DOMUtils.cache.delete(key), DOMUtils.$(selector, context));
},
/**
* 带缓存的元素列表查询
*/
$$(selector, context = document) {
const key = selector + '_all_' + (context === document ? '' : context.toString());
if (!DOMUtils.cache.has(key)) {
const els = Array.from(context.querySelectorAll(selector));
DOMUtils.cache.set(key, els);
return els;
}
const cached = DOMUtils.cache.get(key);
const valid = cached.filter(el => el.isConnected);
if (valid.length !== cached.length) {
DOMUtils.cache.delete(key);
return DOMUtils.$$(selector, context);
}
return valid;
},
/**
* 清除缓存
*/
clearCache(selector = null) {
if (selector) {
for (const key of DOMUtils.cache.keys()) {
if (key.startsWith(selector)) DOMUtils.cache.delete(key);
}
} else {
DOMUtils.cache.clear();
}
},
/**
* 创建样式元素(带防重)
*/
createStyle(id, css) {
if (document.getElementById(id)) return document.getElementById(id);
const style = document.createElement('style');
style.id = id;
style.textContent = css;
document.head.appendChild(style);
return style;
},
/**
* 防抖函数
*/
debounce(fn, delay = CONSTANTS.DEBOUNCE_DELAY) {
let timer = null;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
},
/**
* 创建带唯一 ID 的元素
*/
createElement(tag, props = {}, children = []) {
const el = document.createElement(tag);
Object.entries(props).forEach(([key, value]) => {
if (key === 'className') el.className = value;
else if (key === 'style' && typeof value === 'object') Object.assign(el.style, value);
else if (key.startsWith('on')) el.addEventListener(key.slice(2).toLowerCase(), value);
else el.setAttribute(key, value);
});
children.forEach(child => {
if (typeof child === 'string') el.appendChild(document.createTextNode(child));
else if (child instanceof Node) el.appendChild(child);
});
return el;
},
/**
* 等待元素出现
*/
waitForElement(selector, timeout = 5000) {
return new Promise(resolve => {
const el = document.querySelector(selector);
if (el) return resolve(el);
const observer = new MutationObserver(() => {
const found = document.querySelector(selector);
if (found) {
observer.disconnect();
resolve(found);
}
});
observer.observe(document.body, { childList: true, subtree: true });
setTimeout(() => { observer.disconnect(); resolve(null); }, timeout);
});
}
};
// 悬浮球 UI 变量
let floatBall = null;
let floatMenu = null;
let menuExportBtn = null;
let menuGpaBtn = null;
let menuSyncBtn = null;
let menuFollowBtn = null;
let menuHupanBtn = null;
// 功能UI变量
let semesterCheckboxContainer = null;
let isDataReady = false;
let isBackgroundSyncing = false;
// --- 配置管理 ---
const ConfigManager = {
get enableExport() { return true; }, // 基础功能始终开启
get enableGpaReport() { return true; }, // 基础功能始终开启
get enablePortraitEnhancement() { return GM_getValue('enablePortraitEnhancement', true); },
set enablePortraitEnhancement(val) { GM_setValue('enablePortraitEnhancement', val); },
get enableCourseWatch() { return GM_getValue('enableCourseWatch', true); },
set enableCourseWatch(val) { GM_setValue('enableCourseWatch', val); }
};
// --- 关注课程数据管理 ---
const FollowManager = {
getList() {
try {
return JSON.parse(GM_getValue(CONSTANTS.FOLLOWED_COURSES_KEY, '{}'));
} catch (e) {
console.error('[NWPU-Enhanced] 关注列表数据损坏,将返回空列表', e);
return {};
}
},
add(courseId, courseData) {
const list = this.getList();
list[courseId] = courseData;
GM_setValue(CONSTANTS.FOLLOWED_COURSES_KEY, JSON.stringify(list));
Logger.log('Follow', `关注课程成功: ${courseData.name}`);
},
remove(courseId) {
const list = this.getList();
delete list[courseId];
GM_setValue(CONSTANTS.FOLLOWED_COURSES_KEY, JSON.stringify(list));
Logger.log('Follow', `取消关注成功: ID ${courseId}`);
},
has(courseId) { return !!this.getList()[courseId]; }
};
// --- 基础数据获取与缓存 ---
/**
* 获取SessionStorage缓存的数据
*/
function getCachedData() {
const cachedData = sessionStorage.getItem(CONSTANTS.CACHE_KEY);
if (cachedData) {
try { return JSON.parse(cachedData); }
catch (error) { sessionStorage.removeItem(CONSTANTS.CACHE_KEY); return null; }
}
return null;
}
/**
* 写入数据到SessionStorage
*/
function setCachedData(data) {
try { sessionStorage.setItem(CONSTANTS.CACHE_KEY, JSON.stringify(data)); }
catch (error) { Logger.error('Core', "缓存写入失败", error); }
}
/**
* 获取学号
*/
async function getStudentId() {
Logger.log('Core', "正在通过 API 获取 StudentID...");
// 优先尝试读取本地缓存
const localId = localStorage.getItem('cs-course-select-student-id');
if (localId) {
// Logger.log('Core', "发现本地缓存 ID:", localId);
// return localId;
}
return new Promise((resolve) => {
const infoUrl = `${CONSTANTS.API_STUDENT_INFO}?bizTypeAssoc=2&cultivateTypeAssoc=1`;
GM_xmlhttpRequest({
method: "GET",
url: infoUrl,
onload: function(response) {
if (response.status === 200) {
try {
const data = JSON.parse(response.responseText);
if (data && data.student && data.student.id) {
const sid = data.student.id;
Logger.log('Core', `API 获取成功,StudentID: ${sid}`);
// 写入 localStorage,兼容选课助手功能
localStorage.setItem('cs-course-select-student-id', sid);
resolve(sid);
} else {
Logger.error('Core', "API 响应中未找到 student.id");
resolve(null);
}
} catch (e) {
Logger.error('Core', "API JSON 解析失败", e);
resolve(null);
}
} else {
Logger.error('Core', `API 请求失败,HTTP状态码: ${response.status}`);
resolve(null);
}
},
onerror: (err) => {
Logger.error('Core', "API 网络请求失败", err);
resolve(null);
}
});
});
}
/**
* 从后端抓取所有成绩数据并缓存
*/
async function fetchAllDataAndCache(retryCount = 0) {
Logger.log("Initial", "开始获取并缓存所有教务数据");
try {
const studentId = await getStudentId();
// 参数验证
if (!studentId) {
throw new Error("无法获取学生ID,请检查登录状态");
}
const [gpaRes, semRes, rankRes] = await Promise.all([
new Promise(r => GM_xmlhttpRequest({ method: "GET", url: `${CONSTANTS.API_GPA}?studentAssoc=${studentId}`, onload: r, onerror: () => r({status:500}) })),
new Promise(r => GM_xmlhttpRequest({ method: "GET", url: `${CONSTANTS.API_GRADES}?studentAssoc=${studentId}&semesterAssoc=`, onload: r, onerror: () => r({status:500}) })),
new Promise(r => GM_xmlhttpRequest({ method: "GET", url: `${CONSTANTS.API_RANK}?studentAssoc=${studentId}`, onload: r, onerror: () => r({status:500}) }))
]);
// --- 判断 ID 是否失效 ---
// 1. 检查 HTTP 状态码是否异常(通常 401, 403, 500 代表 ID 不匹配或过期)
// 2. 检查返回内容是否包含登录 HTML(说明 Session 失效重定向了)
const isInvalid = (res) => {
return res.status !== 200 ||
(typeof res.responseText === 'string' && res.responseText.includes('<!DOCTYPE html>'));
};
if (isInvalid(gpaRes) || isInvalid(semRes)) {
if (retryCount < 1) { // 仅允许重试一次,防止死循环
Logger.warn("Core", "检测到请求无效,准备重试...");
localStorage.removeItem('cs-course-select-student-id');
return await fetchAllDataAndCache(retryCount + 1);
} else {
throw new Error("多次请求均无效,请检查登录状态。");
}
}
const gpaData = JSON.parse(gpaRes.responseText);
const gpaRankData = gpaData.stdGpaRankDto || { rank: null, gpa: null };
const semesterData = JSON.parse(semRes.responseText);
const semesters = Array.isArray(semesterData.semesters) ? semesterData.semesters.sort((a, b) => b.id - a.id) : [];
const semesterIds = semesters.map(s => s.id);
const semesterNames = semesters.map(s => s.nameZh);
const classRankData = {};
if (rankRes.status === 200) {
try {
const data = JSON.parse(rankRes.responseText);
// 将 data?.courseItemMap 改为 data && data.courseItemMap
if (data && data.courseItemMap) {
for (const cid in data.courseItemMap) {
if (Object.prototype.hasOwnProperty.call(data.courseItemMap, cid)) {
const c = data.courseItemMap[cid];
// 确保数据存在再进行赋值
if (c && c.stdLessonRank != null) {
classRankData[cid] = c.stdLessonRank + "/" + c.stdCount;
}
}
}
}
} catch (e) {
console.error("[NWPU-Enhanced] 解析排名数据失败", e);
}
}
let allGrades = [];
const GRADE_API_BASE = 'https://jwxt.nwpu.edu.cn/student/for-std/grade/sheet/info';
if (semesterIds.length > 0) {
const gradePromises = semesterIds.map(semesterId =>
new Promise(resolve => {
GM_xmlhttpRequest({
method: "GET",
url: `${GRADE_API_BASE}/${studentId}?semester=${semesterId}`,
onload: response => {
if (response.status === 200) {
try {
const data = JSON.parse(response.responseText);
const grades = data?.semesterId2studentGrades?.[semesterId] || [];
resolve(grades);
} catch (parseErr) {
Logger.error('Core', `解析学期 ${semesterId} 成绩失败`, parseErr);
resolve([]);
}
} else {
resolve([]);
}
},
onerror: () => resolve([])
});
})
);
const allGradesArrays = await Promise.all(gradePromises);
allGradesArrays.forEach((grades, index) => {
// 边界检查
if (!Array.isArray(grades)) return;
const semesterName = semesterNames[index];
grades.forEach(grade => {
// 边界检查 - 确保必要字段存在
if (!grade?.course?.id || !grade?.course?.nameZh) return;
allGrades.push({
'课程ID': grade.course.id,
'课程代码': grade.course.code,
'课程名称': grade.course.nameZh,
'学分': grade.course.credits,
'成绩': grade.gaGrade,
'绩点': grade.gp,
'教学班排名': classRankData[grade.course.id] || "无数据",
'学期': semesterName,
'是否必修': grade.course.obligatory
});
});
});
}
checkForNewGrades(allGrades);
const finalData = { gpaRankData, allGrades, semesterNames };
setCachedData(finalData);
Logger.log('Initial', "数据获取完成,已写入缓存");
return finalData;
} catch (error) {
Logger.error("Initial", "数据获取错误", error);
throw error;
}
}
/**
* 检查是否有新成绩发布
* @param {Array} newGrades 本次抓取到的所有成绩数组
*/
function checkForNewGrades(newGrades) {
if (!newGrades || newGrades.length === 0) return;
// 1. 获取上次存储的成绩快照
const oldGradesRaw = GM_getValue(CONSTANTS.GRADES_SNAPSHOT_KEY, null);
// 2. 如果是第一次运行,直接保存当前数据,不弹窗(避免首次安装就弹窗)
if (!oldGradesRaw) {
GM_setValue(CONSTANTS.GRADES_SNAPSHOT_KEY, JSON.stringify(newGrades));
Logger.log('GradeCheck', '首次运行,建立成绩快照');
return;
}
let oldGrades = [];
try {
oldGrades = JSON.parse(oldGradesRaw);
} catch (e) {
GM_setValue(CONSTANTS.GRADES_SNAPSHOT_KEY, JSON.stringify(newGrades));
return;
}
// 3. 构建旧数据的映射表 (Key: 课程代码, Value: 成绩/绩点组合字符串)
// 使用组合字符串是为了检测成绩数值的变化
const oldMap = new Map();
oldGrades.forEach(g => {
oldMap.set(g['课程代码'], `${g['成绩']}-${g['绩点']}`);
});
// 4. 对比找出新成绩
const newUpdates = [];
newGrades.forEach(g => {
const code = g['课程代码'];
const currentSig = `${g['成绩']}-${g['绩点']}`;
// 情况A: 旧数据里没有这门课 (新出的课)
// 情况B: 旧数据里有这门课,但是成绩/绩点变了 (更新了成绩)
if (!oldMap.has(code) || oldMap.get(code) !== currentSig) {
// 排除掉可能是还没出成绩的数据
if (g['成绩'] && g['成绩'] !== '-') {
newUpdates.push(g);
}
}
});
// 5. 如果有更新
if (newUpdates.length > 0) {
Logger.log('GradeCheck', `发现 ${newUpdates.length} 门新成绩`);
// 更新本地存储
GM_setValue(CONSTANTS.GRADES_SNAPSHOT_KEY, JSON.stringify(newGrades));
// 显示通知
showGradeNotification(newUpdates);
} else {
Logger.log('GradeCheck', '未检测到成绩变化');
}
}
/**
* 在页面顶部指定位置悬浮显示新成绩通知
*/
function showGradeNotification(courses) {
// 防止重复插入
if (document.getElementById('gm-new-grade-banner')) return;
const style = document.createElement('style');
style.innerHTML = `
.gm-new-grade-banner {
position: fixed;
top: 110px;
left: 50%;
transform: translateX(-50%);
z-index: 999999;
background: linear-gradient(135deg, #e6f7ff 0%, #d1edff 100%); /* 浅蓝渐变背景 */
border: 1px solid #a6d4fa; /* 浅蓝边框 */
color: #004085; /* 深蓝色文字,对比度更高更清晰 */
box-shadow: 0 8px 20px rgba(0, 123, 255, 0.15); /* 蓝色的淡淡投影 */
padding: 15px 30px;
border-radius: 50px;
display: flex;
align-items: center;
gap: 15px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
min-width: 400px;
max-width: 80%;
animation: gmSlideIn 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes gmSlideIn {
from { opacity: 0; transform: translate(-50%, -20px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
.gm-ng-content { display: flex; align-items: center; flex: 1; }
.gm-ng-emoji { font-size: 24px; margin-right: 10px; }
.gm-ng-title { font-weight: bold; font-size: 16px; margin-right: 10px; color: #0056b3; /* 标题用亮一点的蓝 */ }
.gm-ng-list { font-size: 14px; color: #333; font-weight: 500; }
.gm-ng-tip { font-size: 12px; color: #6699cc; margin-left: 10px; /* 提示语用灰蓝色 */ }
.gm-ng-btn {
background: #fff;
border: 1px solid #a6d4fa;
color: #007bff; /* 按钮文字蓝 */
padding: 6px 15px;
border-radius: 20px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
margin-left: 15px;
white-space: nowrap;
}
.gm-ng-btn:hover {
background: #007bff; /* 鼠标悬停变蓝 */
color: #fff; /* 文字变白 */
border-color: #007bff;
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
}
`;
document.head.appendChild(style);
const banner = document.createElement('div');
banner.id = 'gm-new-grade-banner';
banner.className = 'gm-new-grade-banner';
// 构建课程列表字符串
const courseText = courses.map(c => `[${c['课程代码']}] ${c['课程名称']}`).join('、');
banner.innerHTML = `
<div class="gm-ng-content">
<div>
<span class="gm-ng-title">已检测到新成绩发布!</span>
<span class="gm-ng-list">${courseText}</span>
</div>
</div>
<button class="gm-ng-btn" onclick="this.parentElement.remove()">知道了</button>
`;
document.body.appendChild(banner);
}
// =-=-=-=-=-=-=-=-=-=-=-=-= 1. 主页初始化与诊断 =-=-=-=-=-=-=-=-=-=-=-=-=
/**
* 打印脚本初始化时的详细存储状态诊断报告
*/
function printStorageDiagnosis() {
// 辅助函数:计算字符串大小(KB)
const calcSize = (str) => str ? (new Blob([str]).size / 1024).toFixed(2) + ' KB' : '0 KB';
// 辅助函数:安全解析JSON
const safeParse = (key, isSession = false) => {
const raw = isSession ? sessionStorage.getItem(key) : GM_getValue(key);
try { return raw ? JSON.parse(raw) : null; } catch(e) { return null; }
};
try {
console.groupCollapsed('%c[NWPU-Enhanced]脚本环境诊断报告 (点击展开)', 'background:#007bff; color:#fff; padding:4px 8px; border-radius:4px;');
// --- 1. 基础环境与配置 ---
const studentId = localStorage.getItem('cs-course-select-student-id');
const configData = {
'脚本版本': GM_info.script.version,
'当前学号 (LocalStorage)': studentId || '❌ 未获取 (可能导致功能失效)',
'功能开关: 画像增强': ConfigManager.enablePortraitEnhancement ? '✅ 开启' : 'OFF',
'功能开关: 课程关注': ConfigManager.enableCourseWatch ? '✅ 开启' : 'OFF',
'浏览器 UserAgent': navigator.userAgent.substring(0, 50) + '...'
};
console.log('%c 1. 环境与配置', 'color: #007bff; font-weight: bold;');
console.table(configData);
// --- 2. 成绩缓存数据 (SessionStorage) ---
const cachedData = safeParse(CONSTANTS.CACHE_KEY, true);
const cacheRawSize = sessionStorage.getItem(CONSTANTS.CACHE_KEY);
console.log('%c 2. 成绩缓存数据 (SessionStorage)', 'color: #007bff; font-weight: bold;');
if (cachedData) {
const semesterCounts = {};
if (cachedData.allGrades) {
cachedData.allGrades.forEach(g => {
semesterCounts[g.学期] = (semesterCounts[g.学期] || 0) + 1;
});
}
console.log(`%c ✅ 数据有效 | 占用空间: ${calcSize(cacheRawSize)}`, 'color: green');
console.table({
'总课程数': cachedData.allGrades ? cachedData.allGrades.length : 0,
'包含学期数': cachedData.semesterNames ? cachedData.semesterNames.length : 0,
'GPA (Rank数据)': cachedData.gpaRankData ? cachedData.gpaRankData.gpa : '无',
'排名': cachedData.gpaRankData ? cachedData.gpaRankData.rank : '无'
});
if(Object.keys(semesterCounts).length > 0) {
console.log('▼ 各学期课程数量分布:');
console.table(semesterCounts);
}
} else {
console.log('%c ⚠️ 未检测到成绩缓存 (正常现象,稍后会自动抓取)', 'color: orange');
}
// --- 3. 关注课程数据 (LocalStorage) ---
const followed = FollowManager.getList();
const followedRaw = GM_getValue(CONSTANTS.FOLLOWED_COURSES_KEY);
console.log('%c 3. 关注课程列表', 'color: #007bff; font-weight: bold;');
if (Object.keys(followed).length > 0) {
const followStats = {
'关注总数': Object.keys(followed).length,
'数据大小': calcSize(followedRaw),
'最近添加': Object.values(followed).sort((a,b) => new Date(b.addedTime) - new Date(a.addedTime))[0]?.name || 'N/A'
};
console.table(followStats);
} else {
console.log('⚪ 关注列表为空');
}
// --- 4. 选课助手/后台同步数据 ---
const bgData = safeParse(CONSTANTS.BACKGROUND_SYNC_KEY);
const bgRaw = GM_getValue(CONSTANTS.BACKGROUND_SYNC_KEY);
const lastSyncTime = GM_getValue(CONSTANTS.LAST_SYNC_TIME_KEY, 0);
const historyData = safeParse(CONSTANTS.HISTORY_STORAGE_KEY);
const historyRaw = GM_getValue(CONSTANTS.HISTORY_STORAGE_KEY);
console.log('%c 4. 选课助手数据', 'color: #007bff; font-weight: bold;');
console.table({
'全校课表缓存 (条数)': bgData ? bgData.length : 0,
'全校课表占用': calcSize(bgRaw),
'上次全校同步时间': lastSyncTime ? new Date(lastSyncTime).toLocaleString() : '⚠️ 从未同步',
'历史余量记录 (课程数)': historyData ? Object.keys(historyData).length : 0,
'历史记录占用': calcSize(historyRaw)
});
console.groupEnd();
} catch (e) {
console.error('[NWPU-Enhanced] 诊断报告生成失败', e);
}
}
async function initializeHomePageFeatures() {
// 1. UI 初始化
printStorageDiagnosis();
createFloatingMenu();
initExportUI();
initScheduleWidget();
// 首次运行检测
const FIRST_RUN_KEY = 'jwxt_enhanced_v162_intro_shown';
if (!GM_getValue(FIRST_RUN_KEY, false)) {
setTimeout(() => handleHelpClick(), 1500);
GM_setValue(FIRST_RUN_KEY, true);
}
// 2. 设置按钮状态为“加载中”
updateMenuButtonsState(false);
// 3. 【延迟执行】定义繁重的数据加载任务
const runHeavyDataFetch = async () => {
let cachedData = getCachedData();
if (cachedData) {
updateMenuButtonsState(true);
isDataReady = true;
} else {
try {
await fetchAllDataAndCache();
updateMenuButtonsState(true);
isDataReady = true;
} catch (error) {
console.error("[NWPU-Enhanced] 后台数据加载失败", error);
}
}
};
// 4. 使用 requestIdleCallback 在浏览器空闲时执行
if ('requestIdleCallback' in window) {
// timeout: 3000 表示:如果浏览器一直很忙,最晚 3秒后强制执行,防止任务饿死
window.requestIdleCallback(() => {
runHeavyDataFetch();
}, { timeout: 3000 });
} else {
// 兼容不支持该 API 的浏览器
setTimeout(runHeavyDataFetch, 1000);
}
// 注册控制台调试命令
if (typeof unsafeWindow !== 'undefined') {
unsafeWindow.nwpuDiag = function() { printStorageDiagnosis(); return "✅ 诊断报告已生成"; };
Object.defineProperty(unsafeWindow, 'axjw_test', {
get: function() { printStorageDiagnosis(); return "✅ 正在生成报告..."; },
configurable: true
});
console.log("%c[NWPU-Enhanced]调试提示:在控制台输入 'axjw_test' 并按Enter键,可重新显示诊断报告。", "color: gray; font-style: italic;");
}
}
function createFloatingMenu() {
if (!document.getElementById('gm-float-menu-style')) {
const style = document.createElement('style');
style.id = 'gm-float-menu-style';
style.textContent = `
/* 悬浮球样式 */
.gm-float-ball {
position: fixed; top: 15%; right: 20px; width: 48px; height: 48px;
background-color: #007bff; color: white; border-radius: 50%;
box-shadow: 0 4px 12px rgba(0,123,255,0.4); z-index: 100001; cursor: pointer;
display: flex; align-items: center; justify-content: center; font-size: 26px;
user-select: none; transition: all 0.2s; touch-action: none;
}
.gm-float-ball:hover { transform: scale(1.08); background-color: #0056b3; }
/* 菜单容器 */
.gm-float-menu {
position: fixed; width: 230px !important; background-color: #fff; border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.15); z-index: 100000;
display: none; flex-direction: column; padding: 6px 0;
opacity: 0; transform: translateY(10px); transition: opacity 0.2s, transform 0.2s;
border: 1px solid #ebeef5; box-sizing: border-box !important;
max-height: 85vh; overflow-y: auto; /* 防止屏幕太小显示不全 */
}
.gm-float-menu.show { display: flex; opacity: 1; transform: translateY(0); }
/* 滚动条美化 */
.gm-float-menu::-webkit-scrollbar { width: 5px; }
.gm-float-menu::-webkit-scrollbar-thumb { background: #dcdfe6; border-radius: 3px; }
/* 分组标题 */
.gm-menu-group-title {
font-size: 12px; color: #909399; padding: 10px 18px 4px;
margin-top: 4px; border-top: 1px solid #f0f2f5;
font-weight: bold; pointer-events: none; letter-spacing: 1px;
}
.gm-menu-group-title:first-child { margin-top: 0; border-top: none; padding-top: 6px; }
/* 菜单项 */
.gm-menu-item {
padding: 10px 18px !important;
cursor: pointer; color: #444; font-size: 14px; text-align: left;
background: transparent !important; border: none !important;
border-radius: 0 !important; width: 100% !important; margin: 0 !important;
transition: background 0.15s, color 0.15s;
display: flex; align-items: center; gap: 10px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
box-sizing: border-box !important; line-height: 1.5 !important;
}
.gm-menu-item:hover:not(:disabled) { background-color: #f0f7ff !important; color: #007bff !important; }
.gm-menu-item:disabled { cursor: not-allowed; color: #c0c4cc !important; }
.gm-view-main { display: flex; flex-direction: column; width: 100%; }
.gm-badge { position: absolute; top: -2px; right: -2px; width: 10px; height: 10px; background: #ff4d4f; border-radius: 50%; display: none; border: 2px solid #fff;}
.gm-icon { width: 18px; text-align: center; display: inline-block; font-weight: bold; flex-shrink: 0; font-size: 15px; }
`;
document.head.appendChild(style);
}
floatBall = document.createElement('div');
floatBall.className = 'gm-float-ball';
floatBall.innerHTML = '⚙<div class="gm-badge"></div>';
floatBall.title = "翱翔教务功能增强设置";
document.body.appendChild(floatBall);
floatMenu = document.createElement('div');
floatMenu.className = 'gm-float-menu';
const mainView = document.createElement('div');
mainView.className = 'gm-view-main';
mainView.innerHTML = `
<div class="gm-menu-group-title">成绩与学业分析</div>
<button class="gm-menu-item" id="gm-btn-gpa" disabled><span class="gm-icon">∑</span> GPA综合分析</button>
<button class="gm-menu-item" id="gm-btn-gpa-estimate" disabled><span class="gm-icon">📊</span> GPA预测</button>
<button class="gm-menu-item" id="gm-btn-export" disabled><span class="gm-icon">⇩</span> 导出成绩与排名</button>
<div class="gm-menu-group-title">选课助手</div>
<button class="gm-menu-item" id="gm-btn-follow"><span class="gm-icon">❤</span> 课程关注列表</button>
<button class="gm-menu-item" id="gm-btn-sync-course"><span class="gm-icon">↻</span> 同步最新选课学期数据</button>
<div class="gm-menu-group-title">快捷工具</div>
<button class="gm-menu-item" id="gm-btn-eval-jump"><span class="gm-icon">✎</span> 一键自动评教</button>
<button class="gm-menu-item" id="gm-btn-person-search"><span class="gm-icon">搜</span> 人员信息检索</button>
<button class="gm-menu-item" id="gm-btn-hupan"><span class="gm-icon">➜</span> 跳转至湖畔资料</button>
<div class="gm-menu-group-title">偏好设置</div>
<button class="gm-menu-item" id="gm-chk-portrait-btn"><span class="gm-icon" id="icon-portrait"></span> 启用学生画像增强</button>
<button class="gm-menu-item" id="gm-chk-watch-btn"><span class="gm-icon" id="icon-watch"></span> 启用选课辅助功能</button>
<button class="gm-menu-item" id="gm-btn-help"><span class="gm-icon">◆</span> 脚本使用说明</button>
`;
floatMenu.appendChild(mainView);
document.body.appendChild(floatMenu);
menuExportBtn = document.getElementById('gm-btn-export');
menuGpaBtn = document.getElementById('gm-btn-gpa');
menuSyncBtn = document.getElementById('gm-btn-sync-course');
menuFollowBtn = document.getElementById('gm-btn-follow');
menuHupanBtn = document.getElementById('gm-btn-hupan');
const menuHelpBtn = document.getElementById('gm-btn-help');
document.addEventListener('click', (e) => { if (!floatMenu.contains(e.target) && !floatBall.contains(e.target)) hideMenu(); });
menuExportBtn.onclick = handleExportClick;
menuGpaBtn.onclick = handleGpaClick;
menuSyncBtn.onclick = handleSyncCourseClick;
menuFollowBtn.onclick = handleShowFollowedClick;
menuHelpBtn.onclick = () => handleHelpClick();
const gpaEstimateBtn = document.getElementById('gm-btn-gpa-estimate');
if (gpaEstimateBtn) {
gpaEstimateBtn.addEventListener('click', () => {
hideMenu();
// 立即显示弹窗,不要等待数据加载
handleGpaEstimateClickImmediate();
});
}
menuHupanBtn.onclick = () => {
hideMenu();
if(confirm("即将跳转至湖畔资料网站,请在校园网环境下访问,是否继续?")) {
window.open('http://nwpushare.fun', '_blank');
}
};
document.getElementById('gm-btn-person-search').onclick = () => {
hideMenu();
PersonnelSearch.openModal();
};
const updateToggleUI = () => {
const isPortrait = ConfigManager.enablePortraitEnhancement;
const isWatch = ConfigManager.enableCourseWatch;
document.getElementById('icon-portrait').textContent = isPortrait ? '☑' : '☐';
document.getElementById('icon-watch').textContent = isWatch ? '☑' : '☐';
document.getElementById('gm-chk-portrait-btn').style.color = isPortrait ? '#333' : '#999';
document.getElementById('gm-chk-watch-btn').style.color = isWatch ? '#333' : '#999';
};
document.getElementById('gm-chk-portrait-btn').onclick = () => {
ConfigManager.enablePortraitEnhancement = !ConfigManager.enablePortraitEnhancement;
updateToggleUI();
if(window.location.href.includes('student-portrait')) {
if(confirm("修改画像增强设置需要刷新页面生效,是否刷新?")) window.location.reload();
}
};
document.getElementById('gm-chk-watch-btn').onclick = () => {
ConfigManager.enableCourseWatch = !ConfigManager.enableCourseWatch;
updateToggleUI();
if(window.location.href.includes('lesson-search')) {
alert("课程关注设置已更新,将在下次进入页面或翻页时生效。");
}
};
document.getElementById('gm-btn-eval-jump').onclick = handleJumpToEvaluation;
updateToggleUI();
let isDragging = false, hasMoved = false, startX, startY, initialLeft, initialTop;
floatBall.addEventListener('mousedown', (e) => {
isDragging = true; hasMoved = false; startX = e.clientX; startY = e.clientY;
const rect = floatBall.getBoundingClientRect(); initialLeft = rect.left; initialTop = rect.top;
floatBall.style.transition = 'none';
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const deltaX = e.clientX - startX, deltaY = e.clientY - startY;
if (Math.abs(deltaX) > 3 || Math.abs(deltaY) > 3) { hasMoved = true; hideMenu(); }
floatBall.style.left = Math.min(Math.max(0, initialLeft + deltaX), window.innerWidth - 50) + 'px';
floatBall.style.top = Math.min(Math.max(0, initialTop + deltaY), window.innerHeight - 50) + 'px';
floatBall.style.bottom = 'auto'; floatBall.style.right = 'auto';
});
document.addEventListener('mouseup', () => { if (isDragging) { isDragging = false; floatBall.style.transition = 'all 0.2s'; } });
floatBall.addEventListener('click', (e) => {
e.stopPropagation(); if (hasMoved) return;
if (floatMenu.classList.contains('show')) hideMenu();
else {
const rect = floatBall.getBoundingClientRect();
let left = rect.left - 230;
if(left < 10) left = rect.right + 10;
floatMenu.style.left = left + 'px';
floatMenu.style.top = rect.top + 'px';
showMenu();
}
});
}
function showMenu() { floatMenu.style.display = 'flex'; floatMenu.offsetHeight; floatMenu.classList.add('show'); }
function hideMenu() { floatMenu.classList.remove('show'); setTimeout(() => { if(!floatMenu.classList.contains('show')) floatMenu.style.display = 'none'; }, 200); }
function updateMenuButtonsState(isReady) {
if (!menuExportBtn || !menuGpaBtn) return;
menuExportBtn.disabled = !isReady;
menuGpaBtn.disabled = !isReady;
const menuGpaEstimateBtn = document.getElementById('gm-btn-gpa-estimate');
if (menuGpaEstimateBtn) {
menuGpaEstimateBtn.disabled = !isReady;
}
const badge = floatBall.querySelector('.gm-badge');
if (badge) {
badge.style.display = (!isReady || isBackgroundSyncing) ? 'block' : 'none';
}
}
// ----------------- 功能处理函数 -----------------
/**
* 处理点击导出按钮`
*/
function handleExportClick() {
hideMenu();
if (semesterCheckboxContainer && semesterCheckboxContainer.style.display === "block") {
semesterCheckboxContainer.style.display = "none";
return;
}
const cachedData = getCachedData();
if (isDataReady && cachedData) {
if(typeof showSemesterCheckboxes === 'function') showSemesterCheckboxes(cachedData.semesterNames);
} else {
alert("成绩数据仍在后台加载中,请稍候...");
}
}
/**
* 处理点击GPA分析按钮
*/
function handleGpaClick() {
hideMenu();
const cachedData = getCachedData();
if (isDataReady && cachedData) {
if(typeof calculateAndDisplayGPA === 'function') calculateAndDisplayGPA(cachedData);
} else {
alert("成绩数据仍在后台加载中,请稍候...");
}
}
/**
* 处理点击同步数据按钮
*/
function handleSyncCourseClick() {
hideMenu();
// 提示文案明确界定功能范围
const confirmMsg = '【更新选课助手数据】\n\n' +
'此操作将跳转至“全校开课查询”页面,并自动执行数据更新。\n' +
'数据将用于选课页面的“历史余量”参考。\n' +
'建议每轮选课开始前执行一次。\n\n' +
'同步将花费几十秒,是否跳转并开始同步?';
if (confirm(confirmMsg)) {
sessionStorage.setItem('nwpu_course_sync_trigger', 'true');
// 尝试查找链接跳转
let courseLink = document.querySelector('a[onclick*="lesson-search"]') ||
document.querySelector('a[href*="/student/for-std/lesson-search"]') ||
document.querySelector('a[data-text="全校开课查询"]'); // 增加data-text匹配
// 尝试在顶层窗口查找
if (!courseLink && window.top !== window.self) {
try {
courseLink = window.top.document.querySelector('a[onclick*="lesson-search"]') ||
window.top.document.querySelector('a[href*="/student/for-std/lesson-search"]') ||
window.top.document.querySelector('a[data-text="全校开课查询"]');
} catch (e) { /* 忽略跨域错误 */ }
}
if (courseLink) {
courseLink.click();
} else {
// 强制跳转作为后备方案
window.location.href = 'https://jwxt.nwpu.edu.cn/student/for-std/lesson-search';
}
}
}
/**
* 处理点击帮助按钮 - 弹窗版操作指南
*/
function handleHelpClick() {
hideMenu(); // 关闭悬浮菜单
// 1. 注入弹窗专用样式
const styleId = 'gm-help-popup-style';
if (!document.getElementById(styleId)) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
/* 遮罩层 */
.gm-help-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.6);
z-index: 20000; /* 确保在最上层 */
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(3px);
animation: gmFadeIn 0.2s ease-out;
}
/* 弹窗主体 */
.gm-help-modal {
background: #fff;
width: 650px;
max-width: 90%;
max-height: 85vh;
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
display: flex; flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
overflow: hidden;
border: 1px solid #eee;
}
/* 标题栏 */
.gm-help-header {
padding: 16px 24px;
border-bottom: 1px solid #eee;
display: flex; justify-content: space-between; align-items: center;
background: #fcfcfc;
}
.gm-help-title { font-size: 18px; font-weight: bold; color: #333; display: flex; align-items: center; gap: 8px; }
.gm-help-close { border: none; background: transparent; font-size: 24px; color: #999; cursor: pointer; transition: color 0.2s; }
.gm-help-close:hover { color: #F56C6C; }
/* 内容区 */
.gm-help-body { padding: 0; overflow-y: auto; background-color: #fcfcfc; }
.gm-help-section {
background: #fff;
margin: 0 0 12px 0;
padding: 18px 24px;
border-bottom: 1px solid #f0f0f0;
}
.gm-help-sec-title {
font-size: 15px; font-weight: bold; color: #303133;
margin-bottom: 12px; padding-left: 10px;
border-left: 4px solid #409EFF;
display: flex; align-items: center; justify-content: space-between;
}
.gm-help-step {
font-size: 13.5px; color: #555; line-height: 1.7; margin-bottom: 8px;
position: relative; padding-left: 15px;
}
.gm-help-step::before {
content: "•"; position: absolute; left: 0; color: #bbb;
}
/* UI 标签模拟 */
.gm-tag {
display: inline-block; padding: 0 6px; border-radius: 4px;
font-size: 12px; font-family: monospace; margin: 0 2px;
}
.gm-tag-blue { background: #ecf5ff; color: #409EFF; border: 1px solid #d9ecff; }
.gm-tag-red { background: #fef0f0; color: #F56C6C; border: 1px solid #fde2e2; }
.gm-tag-gray { background: #f4f4f5; color: #909399; border: 1px solid #e9e9eb; }
/* 动画 */
@keyframes gmFadeIn { from { opacity: 0; } to { opacity: 1; } }
`;
document.head.appendChild(style);
}
// 2. 创建 DOM 结构
const overlay = document.createElement('div');
overlay.className = 'gm-help-overlay';
const modal = document.createElement('div');
modal.className = 'gm-help-modal';
// 3. 构建 HTML 内容
modal.innerHTML = `
<div class="gm-help-header">
<div class="gm-help-title">脚本使用说明</div>
<button class="gm-help-close" title="关闭">×</button>
</div>
<div class="gm-help-body">
<!-- 模块:成绩与画像 -->
<div class="gm-help-section">
<div class="gm-help-sec-title" style="border-color:#F56C6C;">
成绩与学业分析
</div>
<div class="gm-help-step">
点击悬浮球菜单 <span class="gm-tag gm-tag-gray">∑ GPA综合分析</span>:查看加权均分(标准/百分制)、专业排名、卡绩分析及“GPA计算器”。
</div>
<div class="gm-help-step">
点击悬浮球菜单 <span class="gm-tag gm-tag-gray">⇩ 导出成绩</span>:生成包含<b>教学班排名</b>的 Excel 成绩单。
</div>
</div>
<!-- 模块:选课助手 -->
<div class="gm-help-section">
<div class="gm-help-sec-title" style="border-color:#409EFF;">
选课助手
</div>
<div class="gm-help-step">
<b>第1步:历史选课数据同步</b><br>
在每一轮选课开始前,点击悬浮球菜单中的 <span class="gm-tag gm-tag-blue">↻ 同步最新选课学期数据</span>。脚本会自动跳转并后台抓取选课人数信息。完成同步后,该数据可在意愿值选课阶段显示课程内置情况/上一轮选课情况。
</div>
<div class="gm-help-step">
<b>第2步:课程关注与排课</b><br>
在“全校开课查询”页面,点击课程左侧的 <span class="gm-tag gm-tag-red">❤</span> 收藏课程。<br>
在“培养方案”页面,课程代码会自动高亮并显示最新学期的教学班信息,点击课程旁的 <span class="gm-tag gm-tag-red">❤</span> 收藏课程。<br>
然后打开悬浮球菜单 <span class="gm-tag gm-tag-blue">❤ 课程关注列表</span>,切换右上角到 <b>“课表视图”</b>,可直观查看当前已关注课程的课表情况。
</div>
<div class="gm-help-step">
<b>第3步:正式选课</b><br>
进入“选课”页面:<br>
- <b>意愿值选课:</b>显示上次同步时的“历史余量/上限”。<br>
- <b>直选选课:</b>自动显示“待释放名额”。<br>
- <b>关注课程高亮:</b>已关注的课程背景会高亮显示,方便用户定位。<br>
</div>
</div>
<!-- 模块:实用工具 -->
<div class="gm-help-section">
<div class="gm-help-sec-title" style="border-color:#67C23A;">
实用工具
</div>
<div class="gm-help-step">
<b>一键自动评教:</b>进入评教页面,点击右上角的 <span class="gm-tag gm-tag-blue">打开自动评教</span> 按钮。
按照操作可以任意给分评教或指定给分。
</div>
<div class="gm-help-step">
<b>人员检索:</b>悬浮球菜单点击 <span class="gm-tag gm-tag-gray">人员信息检索</span>,输入姓名/学号/工号可查询具体信息。
</div>
<div class="gm-help-step">
<b>跳转至湖畔资料:</b>悬浮球菜单点击 <span class="gm-tag gm-tag-gray">➜跳转至湖畔资料</span>,可在校园网环境下访问湖畔资料网站。
</div>
<div class="gm-help-step">
<b>学生画像增强:</b>进入“学生画像”页面,脚本会自动修正顶部卡片的平均分算法,并优化底部“计划外课程”的表格显示(增加教学班排名)。
</div>
</div>
<div style="text-align:center; padding:15px; color:#c0c4cc; font-size:12px;">
当前版本: ${GM_info.script.version} | 祝您学业进步
</div>
</div>
`;
// 4. 组装与事件绑定
overlay.appendChild(modal);
document.body.appendChild(overlay);
// 关闭逻辑
const closeFn = () => {
overlay.style.opacity = '0';
setTimeout(() => overlay.remove(), 200);
};
modal.querySelector('.gm-help-close').onclick = closeFn;
overlay.onclick = (e) => {
if (e.target === overlay) closeFn();
};
}
/**
* 处理跳转至评教界面
*/
function handleJumpToEvaluation() {
hideMenu(); // 确保 hideMenu 函数在此作用域内可见
if (confirm("即将跳转至“学生总结性评教”页面,是否继续?")) {
// ... (跳转逻辑)
let evalLink = document.querySelector('a[onclick*="evaluation-student"]') ||
document.querySelector('a[href*="evaluation-student"]') ||
document.querySelector('a[data-text="学生总结性评教"]');
// 尝试在顶层窗口查找(应对 iframe 情况)
if (!evalLink && window.top !== window.self) {
try {
evalLink = window.top.document.querySelector('a[onclick*="evaluation-student"]') ||
window.top.document.querySelector('a[data-text="学生总结性评教"]');
} catch (e) {}
}
if (evalLink) {
evalLink.click();
} else {
// 强制跳转作为后备方案
window.location.href = 'https://jwxt.nwpu.edu.cn/evaluation-student-frontend/#/byTask';
}
}
}
// =-=-=-=-=-=-=-=-=-=-=-=-= 2.1 课程关注列表 =-=-=-=-=-=-=-=-=-=-=-=-=
/**
* 展示已关注课程列表
*/
function handleShowFollowedClick() {
hideMenu();
Logger.log("2.1", "正在初始化课程关注列表...");
// 模块 1: 课程数据解析工具 (CourseParser)
const CourseParser = {
cnToNumber(str) {
const map = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9, '十': 10, '十一': 11, '十二': 12, '十三': 13, '十四': 14, '日': 7, '天': 7 };
const clean = str.replace(/[^\d一二三四五六七八九十日天]/g, '');
return map[clean] || parseInt(clean) || 0;
},
formatWeekSet(weekSet) {
if (!weekSet || weekSet.size === 0) return "";
const weeks = Array.from(weekSet).sort((a, b) => a - b);
const ranges = [];
let start = weeks[0], prev = weeks[0];
for (let i = 1; i < weeks.length; i++) {
if (weeks[i] === prev + 1) {
prev = weeks[i];
} else {
ranges.push(start === prev ? `${start}` : `${start}-${prev}`);
start = weeks[i];
prev = weeks[i];
}
}
ranges.push(start === prev ? `${start}` : `${start}-${prev}`);
return ranges.join(',') + "周";
},
parseActiveWeeks(weekStr) {
const activeWeeks = new Set();
if (!weekStr) return activeWeeks;
let content = weekStr.replace(/周/g, '').replace(/[\(\[\{(].*?[\)\]\})]/g, '');
const isOdd = weekStr.includes('单');
const isEven = weekStr.includes('双');
content.split(/[,,]/).forEach(part => {
const rangeMatch = part.match(/(\d+)\s*[-~~]\s*(\d+)/);
if (rangeMatch) {
const start = parseInt(rangeMatch[1]);
const end = parseInt(rangeMatch[2]);
for (let i = start; i <= end; i++) {
if (isOdd && i % 2 === 0) continue;
if (isEven && i % 2 !== 0) continue;
activeWeeks.add(i);
}
} else {
const single = parseInt(part);
if (!isNaN(single)) activeWeeks.add(single);
}
});
return activeWeeks;
},
parseTimeAndPlace(timeStr) {
if (!timeStr || timeStr === '-' || timeStr === '') return [];
const results = [];
const cleanStr = timeStr.replace(/<br\s*\/?>/gi, ';').replace(/\n/g, ';');
cleanStr.split(/[;;]/).forEach(seg => {
seg = seg.trim();
if (!seg) return;
const weekMatch = seg.match(/([\d,\-~~]+)周(?:\([单双]\))?/);
const activeWeeks = weekMatch ? this.parseActiveWeeks(weekMatch[0]) : null;
const dayMatch = seg.match(/[周星][期]?[一二三四五六日天1-7]/);
let day = 0;
if (dayMatch) day = this.cnToNumber(dayMatch[0].replace(/[周星期]/g, ''));
const nodeMatch = seg.match(/(?:第)?(\d+|[一二三四五六七八九十]+)(?:[节\s]*[-~~][第\s]*(\d+|[一二三四五六七八九十]+))?节/);
if (day > 0 && nodeMatch) {
const startNode = this.cnToNumber(nodeMatch[1]);
const endNode = nodeMatch[2] ? this.cnToNumber(nodeMatch[2]) : startNode;
let location = seg.replace(weekMatch ? weekMatch[0] : '', '').replace(dayMatch ? dayMatch[0] : '', '').replace(nodeMatch[0], '').trim().replace(/^[\s,,]+|[\s,,]+$/g, '');
if (startNode > 0 && startNode <= 14) {
results.push({ day, startNode, endNode, activeWeeks, location, rawInfo: seg });
}
}
});
return results;
}
};
// 模块 2: 视图渲染器 (CourseParser)
const ViewRenderer = {
getStyle(name) {
const palettes = [
{ bg: 'rgba(111, 176, 243, 0.2)', border: 'rgb(111, 176, 243)' },
{ bg: 'rgba(154, 166, 189, 0.2)', border: 'rgb(154, 166, 189)' },
{ bg: 'rgba(240, 200, 109, 0.2)', border: 'rgb(240, 200, 109)' },
{ bg: 'rgba(56, 200, 180, 0.2)', border: 'rgb(56, 200, 180)' },
{ bg: 'rgba(244, 144, 96, 0.2)', border: 'rgb(244, 144, 96)' },
{ bg: 'rgba(121, 150, 202, 0.2)', border: 'rgb(121, 150, 202)' },
{ bg: 'rgba(218, 196, 165, 0.2)', border: 'rgb(218, 196, 165)' },
{ bg: 'rgba(253, 171, 154, 0.2)', border: 'rgb(253, 171, 154)' },
{ bg: 'rgba(255, 117, 117, 0.2)', border: 'rgb(255, 117, 117)' },
{ bg: 'rgba(169, 206, 149, 0.2)', border: 'rgb(169, 206, 149)' }
];
let hash = 0;
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
return palettes[Math.abs(hash) % palettes.length];
},
renderList(courses, container) {
if (!courses.length) {
container.innerHTML = `<div class="gm-empty-state"><p>暂无相关课程</p></div>`;
return;
}
let html = `
<table class="gm-course-table">
<thead><tr><th width="120">代码</th><th>课程名称</th><th width="12%">学期</th><th width="15%">教师</th><th width="50" align="center">学分</th><th>时间/地点</th><th width="70" align="center">操作</th></tr></thead>
<tbody>
`;
courses.forEach(c => {
const tpStr = c.timeAndPlace ? c.timeAndPlace.replace(/;/g, '<br>') : '-';
html += `
<tr>
<td><span class="gm-code-badge">${c.code}</span></td>
<td>${c.name}</td>
<td style="color:#999;font-size:12px;">${c.semester || '历史'}</td>
<td>${c.teachers}</td>
<td align="center">${c.credits}</td>
<td style="font-size:12px;line-height:1.4;">${tpStr}</td>
<td align="center"><button class="gm-btn-unfollow" data-id="${c.id}">取消</button></td>
</tr>`;
});
html += `</tbody></table>`;
container.innerHTML = html;
},
renderTimetable(courses, container, targetWeek) {
const timeSlots = [
{ range: [1, 2] }, { range: [3, 4] }, { range: [5, 6] }, { range: [7, 8] }, { range: [9, 10] }, { range: [11, 12] }, { range: [13] }
];
let html = `<table class="gm-timetable"><thead><tr><th width="50" style="background:#f5f7fa;"></th><th width="13.5%">星期一</th><th width="13.5%">星期二</th><th width="13.5%">星期三</th><th width="13.5%">星期四</th><th width="13.5%">星期五</th><th width="13.5%">星期六</th><th width="13.5%">星期日</th></tr></thead><tbody>`;
timeSlots.forEach((slot, index) => {
const startNode = slot.range[0];
let slotBg = '#f9fafc'; // 默认颜色
if (startNode <= 4) {
slotBg = '#e6f7ff'; // 1-4节 (上午): 浅蓝
} else if (startNode <= 6) {
slotBg = '#fff7e6'; // 5-6节 (下午1): 浅橙
} else if (startNode <= 10) {
slotBg = '#f6ffed'; // 7-10节 (下午2+晚1): 浅绿
} else {
slotBg = '#f4f4f5'; // 11-13节 (晚2): 浅灰
}
let periodHtml = `<div class="gm-period-wrapper">`;
slot.range.forEach((num, idx) => {
const borderStyle = (idx < slot.range.length - 1) ? 'border-bottom: 1px solid rgba(0,0,0,0.06);' : '';
periodHtml += `<div class="gm-period-num" style="${borderStyle}">${num}</div>`;
});
periodHtml += `</div>`;
html += `<tr><td class="gm-tt-period" style="background:${slotBg}">${periodHtml}</td>`;
for (let day = 1; day <= 7; day++) {
const coursesInSlotMap = new Map();
courses.forEach(course => {
const segments = CourseParser.parseTimeAndPlace(course.timeAndPlace);
segments.forEach(seg => {
if (seg.day !== day) return;
if (targetWeek !== 'all') {
const weekNum = parseInt(targetWeek);
if (seg.activeWeeks && seg.activeWeeks.size > 0 && !seg.activeWeeks.has(weekNum)) return;
}
if (seg.startNode <= slot.range[slot.range.length-1] && seg.endNode >= slot.range[0]) {
const key = course.id;
if (!coursesInSlotMap.has(key)) {
coursesInSlotMap.set(key, {
...course,
mergedWeeks: new Set(),
segLocation: seg.location,
detailSegments: []
});
}
const existing = coursesInSlotMap.get(key);
if (seg.activeWeeks) seg.activeWeeks.forEach(w => existing.mergedWeeks.add(w));
existing.detailSegments.push(seg.rawInfo);
}
});
});
html += `<td style="vertical-align: top; padding: 2px;">`;
html += `<div class="gm-tt-cell-wrapper">`;
if (coursesInSlotMap.size > 0) {
coursesInSlotMap.forEach(item => {
const style = this.getStyle(item.name);
let weekInfoStr = CourseParser.formatWeekSet(item.mergedWeeks);
if (weekInfoStr === "") weekInfoStr = "未知周次";
const uniqueDetails = [...new Set(item.detailSegments)];
const tooltip = `${item.name}\n${item.teachers}\n----------------\n${uniqueDetails.join('\n')}`;
html += `
<div class="gm-tt-course-block"
style="background: ${style.bg}; border-left: 3px solid ${style.border};"
title="${tooltip}">
<div class="gm-tt-name">${item.name}</div>
<div class="gm-tt-info">@${item.segLocation}</div>
<div class="gm-tt-info" style="display:flex;justify-content:space-between;align-items:center;margin-top:2px;">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%;" title="${item.teachers}">${item.teachers}</span>
<span class="gm-tt-tag">${weekInfoStr}</span>
</div>
</div>
`;
});
}
html += `</div></td>`;
}
html += `</tr>`;
});
html += `</tbody></table>`;
const weekText = targetWeek === 'all' ? '全部周次' : `第 ${targetWeek} 周`;
if(courses.length > 0) {
html += `<div class="gm-tt-footer">当前展示:${weekText}</div>`;
} else {
html = `<div class="gm-empty-state"><p>${weekText} 暂无课程</p></div>`;
}
container.innerHTML = html;
}
};
// 模块 3: CSS 样式注入 - 保持不变
const styleId = 'gm-followed-modal-style';
if (!document.getElementById(styleId)) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
.gm-modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.6); z-index: 10005; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(2px); }
.gm-modal-content { background-color: #fff; border-radius: 6px; width: 95%; max-width: 1200px; height: 90vh; max-height: 950px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); display: flex; flex-direction: column; overflow: hidden; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; animation: gmFadeIn 0.2s ease-out; }
@keyframes gmFadeIn { from { opacity: 0; transform: scale(0.99); } to { opacity: 1; transform: scale(1); } }
.gm-modal-header { padding: 0 20px; border-bottom: 1px solid #eee; background: #f8f9fa; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; height: 50px; }
.gm-modal-title { font-size: 16px; font-weight: bold; color: #333; display: flex; align-items: center; gap: 8px; }
.gm-modal-close { border: none; background: none; font-size: 24px; color: #999; cursor: pointer; padding: 0 10px; display:flex; align-items:center; }
.gm-tabs { display: flex; gap: 20px; margin-left: 30px; height: 100%; }
.gm-tab-item { display: flex; align-items: center; height: 100%; font-size: 14px; color: #666; cursor: pointer; border-bottom: 3px solid transparent; transition: all 0.2s; padding: 0 5px; font-weight: 500; }
.gm-tab-item.active { color: #007bff; border-bottom-color: #007bff; }
.gm-filter-bar { padding: 10px 20px; background: #fff; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; gap: 15px;}
.gm-filter-group { display: flex; align-items: center; gap: 10px; }
.gm-filter-label { font-size: 13px; color: #606266; }
.gm-filter-select { padding: 5px 10px; font-size: 13px; border: 1px solid #dcdfe6; border-radius: 3px; color: #606266; outline: none; background-color: white; }
.gm-btn-clear-all { padding: 5px 12px; font-size: 13px; border-radius: 3px; border: 1px solid #f56c6c; color: #f56c6c; background: #fff; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; gap: 5px; }
.gm-btn-clear-all:hover { background: #f56c6c; color: #fff; }
.gm-modal-body { flex: 1; overflow: hidden; position: relative; display: flex; flex-direction: column; background: #fff; }
.gm-view-container { flex: 1; overflow-y: auto; padding: 0 20px 20px 20px; display: none; height: 100%; }
.gm-view-container.active { display: block; }
.gm-course-table { width: 100%; border-collapse: separate; border-spacing: 0; margin-top: 10px; }
.gm-course-table th { position: sticky; top: 0; background: #fff; z-index: 10; padding: 12px 10px; text-align: left; font-size: 13px; color: #909399; font-weight: bold; border-bottom: 2px solid #f0f0f0; }
.gm-course-table td { padding: 10px; font-size: 13px; color: #606266; border-bottom: 1px solid #ebeef5; vertical-align: middle; line-height: 1.4; }
.gm-code-badge { background: #f4f4f5; color: #909399; padding: 2px 6px; border-radius: 3px; font-size: 12px; }
.gm-btn-unfollow { padding: 4px 10px; font-size: 12px; border-radius: 3px; border: 1px solid #fab6b6; color: #f56c6c; background: #fef0f0; cursor: pointer; }
.gm-timetable { width: 100%; border-collapse: collapse; table-layout: fixed; margin-top: 10px; border: 1px solid #e0e0e0; font-size: 12px; }
.gm-timetable th { background: #f8f9fa; color: #333; font-weight: bold; padding: 8px; text-align: center; border: 1px solid #ddd; height: 36px; }
.gm-timetable td { border: 1px solid #ddd; height: auto; }
.gm-tt-period { position: relative; padding: 0 !important; vertical-align: top; width: 50px; height: 1px; }
.gm-period-wrapper { position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; }
.gm-period-num { flex: 1; display: flex; align-items: center; justify-content: center; font-size: 13px; color: #555; font-weight: bold; width: 100%; }
.gm-tt-cell-wrapper { width: 100%; min-height: 60px; display: flex; flex-direction: column; gap: 4px; padding: 4px; box-sizing: border-box; }
.gm-tt-course-block { padding: 6px; font-size: 12px; line-height: 1.35; color: #333; cursor: pointer; border-radius: 0; overflow: hidden; }
.gm-tt-course-block:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.15); transform: scale(1.01); z-index: 5; transition: all 0.1s; }
.gm-tt-name { font-weight: bold; color: #000; margin-bottom: 3px; font-size: 13px; }
.gm-tt-info { color: #555; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.gm-tt-tag { opacity: 0.9; font-size: 11px; color: #333; background: rgba(255,255,255,0.6); padding: 1px 4px; border-radius: 3px; flex-shrink: 0; }
.gm-tt-footer { margin-top:10px; font-size:12px; color:#606266; text-align:right; padding-right:10px;}
.gm-empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: #909399; text-align: center; padding-top: 60px;}
`;
document.head.appendChild(style);
}
// 模块 4: 初始化
const followedCourses = FollowManager.getList();
let courseList = Object.values(followedCourses);
const allSemesters = [...new Set(courseList.map(c => c.semester || '历史关注'))].sort().reverse();
let semesterOptions = `<option value="all">显示全部学期</option>`;
allSemesters.forEach(sem => { semesterOptions += `<option value="${sem}">${sem}</option>`; });
let weekOptions = `<option value="all">显示全部周次</option>`;
for(let i=1; i<=20; i++) weekOptions += `<option value="${i}">第 ${i} 周</option>`;
const modalHTML = `
<div class="gm-modal-overlay" id="gm-modal-overlay">
<div class="gm-modal-content">
<div class="gm-modal-header">
<div style="display:flex; align-items:center;">
<div class="gm-modal-title">❤ 课程关注列表</div>
<div class="gm-tabs">
<div class="gm-tab-item active" data-tab="list">列表视图</div>
<div class="gm-tab-item" data-tab="timetable">课表视图</div>
</div>
</div>
<button class="gm-modal-close" id="gm-modal-close">×</button>
</div>
<div class="gm-modal-body">
<div class="gm-filter-bar">
<div class="gm-filter-group">
<span class="gm-filter-label">学期:</span>
<select id="gm-semester-select" class="gm-filter-select" style="min-width:140px;">${semesterOptions}</select>
<div id="gm-week-filter-container" style="display:none; align-items:center; margin-left:15px;">
<span class="gm-filter-label">周次:</span>
<select id="gm-week-select" class="gm-filter-select" style="min-width:100px; margin-left:5px;">${weekOptions}</select>
</div>
</div>
<div class="gm-right-actions" style="display:flex; align-items:center; gap:15px;">
<span style="font-size:13px; color:#606266; font-weight:bold;">
总学分: <span id="gm-total-credits" style="color:#409EFF">0</span>
</span>
<button id="gm-btn-clear-all" class="gm-btn-clear-all">清空当前</button>
</div>
</div>
<div class="gm-view-container active" id="gm-view-list"><div id="gm-table-wrapper"></div></div>
<div class="gm-view-container" id="gm-view-timetable"><div id="gm-timetable-wrapper"></div></div>
</div>
</div>
</div>
`;
const existingOverlay = document.getElementById('gm-modal-overlay');
if (existingOverlay) existingOverlay.remove();
const wrapper = document.createElement('div');
wrapper.innerHTML = modalHTML;
document.body.appendChild(wrapper.firstElementChild);
// 状态管理
const state = { semester: 'all', week: 'all', currentTab: 'list' };
const refreshView = () => {
const filtered = courseList.filter(c => state.semester === 'all' || (c.semester || '历史关注') === state.semester);
filtered.sort((a, b) => {
const semA = a.semester || ''; const semB = b.semester || '';
if (semA !== semB) return semB.localeCompare(semA);
return a.code.localeCompare(b.code);
});
// 【修改点】 计算并更新总学分
let totalCredits = 0;
filtered.forEach(c => {
const credit = parseFloat(c.credits);
if (!isNaN(credit)) {
totalCredits += credit;
}
});
const creditSpan = document.getElementById('gm-total-credits');
if (creditSpan) {
creditSpan.innerText = totalCredits % 1 === 0 ? totalCredits : totalCredits.toFixed(1);
}
if (state.currentTab === 'list') {
ViewRenderer.renderList(filtered, document.getElementById('gm-table-wrapper'));
} else {
ViewRenderer.renderTimetable(filtered, document.getElementById('gm-timetable-wrapper'), state.week);
}
const clearBtn = document.getElementById('gm-btn-clear-all');
const btnText = state.semester === 'all' ? '清空全部' : '清空当前学期';
clearBtn.innerHTML = `<span style="margin-left:4px">${btnText}</span>`;
clearBtn.style.opacity = filtered.length === 0 ? '0.5' : '1';
clearBtn.style.pointerEvents = filtered.length === 0 ? 'none' : 'auto';
};
// 事件绑定
const tabs = document.querySelectorAll('.gm-tab-item');
const views = document.querySelectorAll('.gm-view-container');
const weekFilterContainer = document.getElementById('gm-week-filter-container');
tabs.forEach(tab => {
tab.onclick = () => {
tabs.forEach(t => t.classList.remove('active'));
views.forEach(v => v.classList.remove('active'));
tab.classList.add('active');
document.getElementById(`gm-view-${tab.dataset.tab}`).classList.add('active');
state.currentTab = tab.dataset.tab;
weekFilterContainer.style.display = (state.currentTab === 'timetable') ? 'flex' : 'none';
refreshView();
};
});
document.getElementById('gm-semester-select').onchange = (e) => { state.semester = e.target.value; refreshView(); };
document.getElementById('gm-week-select').onchange = (e) => { state.week = e.target.value; refreshView(); };
const closeModal = () => document.getElementById('gm-modal-overlay').remove();
document.getElementById('gm-modal-close').onclick = closeModal;
document.getElementById('gm-modal-overlay').onclick = (e) => { if (e.target.id === 'gm-modal-overlay') closeModal(); };
document.getElementById('gm-btn-clear-all').onclick = () => {
const targetName = state.semester === 'all' ? '所有' : state.semester;
if (confirm(`⚠️ 确定要取消关注【${targetName}】下的所有课程吗?`)) {
const idsToRemove = courseList
.filter(c => state.semester === 'all' || (c.semester || '历史关注') === state.semester)
.map(c => c.id);
idsToRemove.forEach(id => FollowManager.remove(id));
courseList = courseList.filter(c => !idsToRemove.includes(c.id));
if (courseList.length === 0) { closeModal(); handleShowFollowedClick(); }
else refreshView();
}
};
const btnContainer = document.querySelector('.gm-modal-body');
btnContainer.addEventListener('click', (e) => {
if (e.target.classList.contains('gm-btn-unfollow')) {
const id = e.target.dataset.id;
if(confirm('确定不再关注此课程吗?')) {
// 1. 从存储中移除
FollowManager.remove(id);
// 2. 重新从存储读取最新全量列表
courseList = Object.values(FollowManager.getList());
// 3. 刷新视图 (ViewRenderer 会自动处理空列表的情况)
refreshView();
}
}
});
refreshView();
}
// ----------------- 2.2 导出成绩 -----------------
function initExportUI() {
if (!document.getElementById('export-ui-styles')) {
const style = document.createElement("style");
style.id = 'export-ui-styles';
style.textContent = `
.semester-bg-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.4); backdrop-filter: blur(4px); z-index: 10001; display: none; transition: all 0.3s; }
.semester-checkbox-container { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 10002; background-color: #ffffff; padding: 24px; border-radius: 12px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15); display: none; width: 450px; border: 1px solid #f0f0f0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.semester-checkbox-container h3 { margin: 0 0 20px 0; font-size: 18px; color: #1f1f1f; display: flex; align-items: center; gap: 8px; font-weight: 600; }
.gm-semester-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; max-height: 320px; overflow-y: auto; padding: 4px; margin-bottom: 20px; }
.gm-semester-item { display: flex; align-items: center; padding: 10px 12px; background: #f5f7fa; border-radius: 8px; cursor: pointer; transition: all 0.2s; border: 1px solid transparent; font-size: 14px; color: #444; user-select: none; }
.gm-semester-item:hover { background: #eef5fe; border-color: #b3d8ff; color: #007bff; }
.gm-semester-item input[type='checkbox'] { margin-right: 10px; width: 16px; height: 16px; cursor: pointer; }
.button-container { display: flex; justify-content: flex-end; gap: 12px; border-top: 1px solid #f0f0f0; padding-top: 20px; }
.semester-checkbox-container button { padding: 8px 18px; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.2s; border: none; }
.confirm-export-button { background-color: #007bff; color: white; }
.confirm-export-button:hover { background-color: #0069d9; transform: translateY(-1px); }
.select-all-button { background-color: #f0f2f5; color: #595959; border: 1px solid #d9d9d9 !important; }
.cancel-button { background-color: transparent; color: #8c8c8c; }
.gm-semester-list::-webkit-scrollbar { width: 6px; }
.gm-semester-list::-webkit-scrollbar-thumb { background: #dcdfe6; border-radius: 3px; }
`;
document.head.appendChild(style);
}
let bgOverlay = document.createElement("div"); bgOverlay.className = "semester-bg-overlay"; document.body.appendChild(bgOverlay);
semesterCheckboxContainer = document.createElement("div"); semesterCheckboxContainer.className = "semester-checkbox-container"; document.body.appendChild(semesterCheckboxContainer);
bgOverlay.addEventListener('click', () => { semesterCheckboxContainer.style.display = "none"; });
Object.defineProperty(semesterCheckboxContainer.style, 'display', { set: function(val) { bgOverlay.style.display = val; this.setProperty('display', val); }, get: function() { return this.getPropertyValue('display'); } });
}
function showSemesterCheckboxes(semesterNames) {
if (!document.getElementById('export-ui-styles')) {
injectExportStyles(); // 封装样式注入逻辑
}
Logger.log("2.2", "开始导出成绩...");
semesterCheckboxContainer.innerHTML = "";
const title = document.createElement("h3");
title.textContent = "选择要导出的学期";
semesterCheckboxContainer.appendChild(title);
const listDiv = document.createElement("div");
listDiv.className = "gm-semester-list";
semesterNames.forEach((semesterName) => {
const label = document.createElement("label");
label.className = "gm-semester-item";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.value = semesterName;
checkbox.checked = true;
label.appendChild(checkbox);
label.appendChild(document.createTextNode(semesterName));
listDiv.appendChild(label);
});
semesterCheckboxContainer.appendChild(listDiv);
const buttonContainer = document.createElement("div");
buttonContainer.className = "button-container";
const selectAllButton = document.createElement("button");
selectAllButton.textContent = "全选/反选";
selectAllButton.className = "select-all-button";
selectAllButton.onclick = () => {
const checkboxes = semesterCheckboxContainer.querySelectorAll("input[type='checkbox']");
const isAllChecked = Array.from(checkboxes).every(c => c.checked);
checkboxes.forEach(c => { c.checked = !isAllChecked; });
};
const cancelButton = document.createElement("button");
cancelButton.textContent = "取消";
cancelButton.className = "cancel-button";
cancelButton.onclick = () => { semesterCheckboxContainer.style.display = "none"; };
const confirmExportButton = document.createElement("button");
confirmExportButton.textContent = "导出至 Excel";
confirmExportButton.className = "confirm-export-button";
confirmExportButton.onclick = () => {
const selectedSemesters = Array.from(semesterCheckboxContainer.querySelectorAll("input[type='checkbox']:checked")).map(c => c.value);
const cachedData = getCachedData();
if (cachedData) {
const filteredGrades = cachedData.allGrades.filter(grade => selectedSemesters.includes(grade.学期));
exportToExcel(filteredGrades);
}
semesterCheckboxContainer.style.display = "none";
};
buttonContainer.appendChild(selectAllButton);
buttonContainer.appendChild(cancelButton);
buttonContainer.appendChild(confirmExportButton);
semesterCheckboxContainer.appendChild(buttonContainer);
semesterCheckboxContainer.style.display = "block";
}
async function exportToExcel(filteredGrades) {
if (!filteredGrades || filteredGrades.length === 0) { alert("没有选中任何成绩数据,已取消导出。"); return; }
try {
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet('课程成绩与排名');
worksheet.addRow(["注意:由于教务系统将同一分数视为同一排名,故您的实际教学班排名可能会低于本数据。"]);
worksheet.mergeCells('A1:H1');
worksheet.getRow(1).font = { bold: true, color: { argb: 'FFFF0000' } };
worksheet.getRow(1).alignment = { horizontal: 'left', vertical: 'middle' };
const header = ['课程ID', '课程代码', '课程名称', '学分', '成绩', '绩点', '教学班排名', '学期'];
worksheet.addRow(header);
worksheet.getRow(2).font = { bold: true };
worksheet.getRow(2).alignment = { horizontal: 'center', vertical: 'middle' };
filteredGrades.forEach((grade) => {
worksheet.addRow([ grade['课程ID'], grade['课程代码'], grade['课程名称'], grade['学分'], grade['成绩'], grade['绩点'], grade['教学班排名'], grade['学期'] ]);
});
worksheet.columns = [ { width: 10 }, { width: 12 }, { width: 35 }, { width: 7 }, { width: 7 }, { width: 7 }, { width: 12 }, { width: 22 } ];
for (let i = 3; i <= worksheet.rowCount; i++) { worksheet.getRow(i).alignment = { horizontal: 'center', vertical: 'middle' }; }
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
saveAs(blob, '课程成绩与排名.xlsx');
} catch (error) { Logger.error('2.2', 'Excel生成失败', error); alert("导出Excel文件时发生错误,请查看控制台了解详情。"); }
}
// ----------------- 2.3 GPA分析 -----------------
/**
* GPA 分析报告计算
* @param {Object} data - 包含 allGrades 和 gpaRankData 的数据对象
* @param {Array} data.allGrades - 成绩数组
* @param {Object} data.gpaRankData - 排名数据
*/
function calculateAndDisplayGPA(data) {
Logger.log("2.3", "开始进行GPA及加权成绩分析...");
const { allGrades, gpaRankData } = data;
if (!allGrades || allGrades.length === 0) { alert("没有可供分析的成绩数据。"); return; }
// 中文等级制成绩映射到 GPA
const chineseGradeMap = { '优秀': 4.0, '良好': 3.0, '中等': 2.0, '及格': 1.3, '不及格': 0.0, '通过': null, '不通过': 0.0 };
// 卡绩分数映射 (分数 -> 提升后的 GPA)
const stuckGradesMap = { 94: 4.1, 89: 3.9, 84: 3.7, 80: 3.3, 77: 2.7, 74: 2.3, 71: 2.0, 67: 2.0, 63: 1.7, 59: 1.3 };
const validGradesForGpa = [];
let totalScoreCreditsNumericOnly = 0, totalCreditsNumericOnly = 0;
let totalScoreCreditsWithMapping = 0, totalCreditsWithMapping = 0;
// 过滤有效成绩并计算加权分
allGrades.forEach(grade => {
const credits = parseFloat(grade['学分']);
const score = grade['成绩'];
let gp = parseFloat(grade['绩点']);
// 边界检查:学分和绩点有效性验证
if (isNaN(credits) || credits <= 0 || grade['绩点'] === null || isNaN(gp)) return;
let finalGp = gp;
// 处理中文等级制成绩
if (typeof score === 'string' && chineseGradeMap.hasOwnProperty(score)) {
const mappedGp = chineseGradeMap[score];
if (mappedGp === null) return; // 跳过 P/NP 类型
finalGp = mappedGp;
}
validGradesForGpa.push({ ...grade, '学分': credits, '成绩': score, '绩点': finalGp });
const numericScore = parseFloat(score);
// 百分制成绩计算
if (!isNaN(numericScore)) {
totalScoreCreditsNumericOnly += numericScore * credits;
totalCreditsNumericOnly += credits;
totalScoreCreditsWithMapping += numericScore * credits;
totalCreditsWithMapping += credits;
} else if (typeof score === 'string' && GRADE_MAPPING_CONFIG.hasOwnProperty(score)) {
// 使用配置的中文等级制映射
totalScoreCreditsWithMapping += GRADE_MAPPING_CONFIG[score] * credits;
totalCreditsWithMapping += credits;
}
});
const weightedScoreNumeric = totalCreditsNumericOnly > 0 ? (totalScoreCreditsNumericOnly / totalCreditsNumericOnly) : 0;
const weightedScoreWithMapping = totalCreditsWithMapping > 0 ? (totalScoreCreditsWithMapping / totalCreditsWithMapping) : 0;
if (validGradesForGpa.length === 0) { alert("未找到可用于计算GPA的有效课程成绩。"); return; }
// 计算总学分绩点和 GPA
const totalCreditPoints = validGradesForGpa.reduce((sum, g) => sum + (g['绩点'] * g['学分']), 0);
const totalCredits = validGradesForGpa.reduce((sum, g) => sum + g['学分'], 0);
const gpa = totalCredits > 0 ? (totalCreditPoints / totalCredits) : 0;
// 卡绩分析
const stuckCourses = validGradesForGpa.filter(g => stuckGradesMap.hasOwnProperty(parseFloat(g['成绩'])));
let reportData = {
gpa: gpa.toFixed(4),
totalCredits: totalCredits.toFixed(2),
totalCreditPoints: totalCreditPoints.toFixed(4),
courseCount: validGradesForGpa.length,
hasStuckCourses: stuckCourses.length > 0,
weightedScoreNumeric: weightedScoreNumeric.toFixed(4),
weightedScoreWithMapping: weightedScoreWithMapping.toFixed(4),
gpaRankData: gpaRankData
};
if (reportData.hasStuckCourses) {
const stuckCoursesCredits = stuckCourses.reduce((sum, c) => sum + c['学分'], 0);
let hypotheticalTotalCreditPoints = validGradesForGpa.reduce((sum, g) => {
const scoreNum = parseFloat(g['成绩']);
return sum + ((stuckGradesMap[scoreNum] || g['绩点']) * g['学分']);
}, 0);
const hypotheticalGpa = totalCredits > 0 ? (hypotheticalTotalCreditPoints / totalCredits) : 0;
Object.assign(reportData, {
stuckCoursesCount: stuckCourses.length,
stuckCoursesCredits: stuckCoursesCredits.toFixed(2),
stuckCoursesList: stuckCourses,
hypotheticalGpa: hypotheticalGpa.toFixed(4),
hypotheticalTotalCreditPoints: hypotheticalTotalCreditPoints.toFixed(4)
});
}
showGpaReportModal(reportData, allGrades);
}
function showGpaReportModal(reportData, allGrades) {
const existingOverlay = document.querySelector('.gpa-report-overlay'); if (existingOverlay) existingOverlay.remove();
const styleId = 'gpa-report-modal-styles';
if (!document.getElementById(styleId)) {
const style = document.createElement("style"); style.id = styleId;
style.textContent = `
.gpa-report-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.6); z-index: 10005; display: flex; align-items: center; justify-content: center; }
.gpa-report-modal { background-color: #fff; border-radius: 8px; padding: 25px; width: 90%; max-width: 700px; box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; max-height: 85vh; overflow-y: auto; position: relative; }
.gpa-report-modal h2 { margin-top: 0; font-size: 22px; color: #333; border-bottom: 1px solid #eee; padding-bottom: 15px; }
.gpa-report-modal h3 { margin-top: 20px; font-size: 18px; color: #007bff; margin-bottom: 10px; border-left: 4px solid #007bff; padding-left: 8px; }
.gpa-report-modal p, .gpa-report-modal li { font-size: 15px; line-height: 1.8; color: #000 !important; }
.gpa-report-modal strong { color: #000; }
.gpa-report-modal .close-btn { position: absolute; top: 15px; right: 20px; font-size: 28px; color: #aaa; background: none; border: none; cursor: pointer; line-height: 1; padding: 0; }
.gpa-report-modal .close-btn:hover { color: #000; }
.gpa-report-modal .disclaimer { font-size: 12px; color: #999; margin-top: 20px; border-top: 1px solid #eee; padding-top: 10px; }
.gpa-report-modal ul { padding-left: 20px; margin: 10px 0; }
.gpa-report-modal .prediction-module { padding-top: 15px; }
.gpa-report-modal .input-group { display: flex; align-items: center; margin-bottom: 10px; }
.gpa-report-modal .input-group label { width: 180px; font-size: 14px; flex-shrink: 0; }
.gpa-report-modal .input-group input { flex-grow: 1; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px;}
.gpa-report-modal .calculate-btn { width: 100%; padding: 10px; background-color: #28a745; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 15px; margin-top: 5px; }
.gpa-report-modal .calculate-btn:hover { background-color: #218838; }
.gpa-report-modal .prediction-result { margin-top: 12px; font-weight: bold; text-align: center; font-size: 16px; min-height: 24px; }
.gpa-report-modal details { border: 1px solid #eee; border-radius: 4px; margin-top: 20px; background-color: #f9f9f9; }
.gpa-report-modal summary { padding: 12px 15px; font-weight: bold; font-size: 18px; color: #555; cursor: pointer; list-style: none; position: relative; outline: none; }
.gpa-report-modal details.stuck-analysis-section > summary { color: #555; }
.gpa-report-modal summary::-webkit-details-marker { display: none; }
.gpa-report-modal summary::before { content: '▶'; margin-right: 10px; font-size: 14px; display: inline-block; transition: transform 0.2s; }
.gpa-report-modal details[open] > summary::before { transform: rotate(90deg); }
.gpa-report-modal .details-content { padding: 0 15px 15px 15px; border-top: 1px solid #eee; }
.gpa-report-modal .tooltip-q { display: inline-block; width: 16px; height: 16px; border-radius: 50%; background-color: #a0a0a0; color: white; text-align: center; font-size: 12px; line-height: 16px; font-weight: bold; cursor: help; margin-left: 5px; vertical-align: middle; position: relative; }
.gpa-report-modal .tooltip-q:hover::after { content: attr(data-gm-tooltip); position: absolute; left: 50%; bottom: 120%; transform: translateX(-50%); background-color: #333; color: #fff; padding: 8px 12px; border-radius: 5px; font-size: 13px; font-weight: normal; white-space: pre-line; z-index: 10; box-shadow: 0 2px 5px rgba(0,0,0,0.2); width: max-content; max-width: 280px; }
`;
document.head.appendChild(style);
}
const mappingConfigString = Object.entries(GRADE_MAPPING_CONFIG).map(([key, value]) => `${key}: ${value}`).join(', ');
const tooltipTextWithMapping = `使用百分制成绩和中文等级制分数进行计算\n您可以在脚本最上面配置参数,当前参数:\n${mappingConfigString}。`;
const overlay = document.createElement('div'); overlay.className = 'gpa-report-overlay';
const modal = document.createElement('div'); modal.className = 'gpa-report-modal';
let contentHTML = `<button class="close-btn" title="关闭">×</button><h2>GPA综合分析报告</h2><div class="current-gpa-module"><h3>当前学业总览</h3><p><strong>GPA:</strong> <strong>${reportData.gpa}</strong><br><strong>专业排名:</strong> ${reportData.gpaRankData.rank ?? '无数据'}<br><strong>前一名GPA:</strong> ${reportData.gpaRankData.beforeRankGpa ?? '无数据'}<br><strong>后一名GPA:</strong> ${reportData.gpaRankData.afterRankGpa ?? '无数据'}<br><strong>纳入GPA计算课程数:</strong> ${reportData.courseCount} 门<br><strong>总学分:</strong> ${reportData.totalCredits}<br><strong>总学分绩点:</strong> ${reportData.totalCreditPoints}<br><strong>加权百分制成绩:</strong> <strong>${reportData.weightedScoreNumeric}</strong> <span class="tooltip-q" data-gm-tooltip="仅计算百分制成绩,不含中文等级制成绩和PNP课程。">?</span><br><strong>加权百分制成绩 (含中文等级制成绩):</strong> <strong>${reportData.weightedScoreWithMapping}</strong> <span class="tooltip-q" data-gm-tooltip="${tooltipTextWithMapping}">?</span></p></div><details><summary>预测GPA计算</summary><div class="prediction-module details-content"><div class="input-group"><label for="next-credits-a">下学期课程总学分:</label><input type="number" id="next-credits-a" placeholder="例如: 25"></div><div class="input-group"><label for="next-gpa-a">下学期预期平均GPA:</label><input type="number" id="next-gpa-a" step="0.01" placeholder="1.0 ~ 4.1"></div><button id="calculate-prediction-btn-a" class="calculate-btn">计算</button><p id="predicted-gpa-result-a" class="prediction-result"></p></div></details><details><summary>达成目标GPA所需均绩计算</summary><div class="prediction-module details-content"><div class="input-group"><label for="target-gpa-b">期望达到的总GPA:</label><input type="number" id="target-gpa-b" step="0.01" placeholder="例如: 3.80"></div><div class="input-group"><label for="next-credits-b">下学期课程总学分:</label><input type="number" id="next-credits-b" placeholder="例如: 20"></div><button id="calculate-target-btn-b" class="calculate-btn">计算</button><p id="target-gpa-result-b" class="prediction-result"></p></div></details><details class="stuck-analysis-section"><summary>卡绩分析</summary><div class="details-content">`;
if (reportData.hasStuckCourses) {
let stuckCoursesListHTML = '<ul>';
reportData.stuckCoursesList.forEach(course => { stuckCoursesListHTML += `<li>${course['课程名称']} (成绩: ${course['成绩']}, 绩点: ${course['绩点']})</li>`; });
stuckCoursesListHTML += '</ul>';
contentHTML += `<p>发现 <strong>${reportData.stuckCoursesCount} 门</strong>卡绩科目,共计 <strong>${reportData.stuckCoursesCredits}</strong> 学分。</p>${stuckCoursesListHTML}<p>如果这些科目绩点均提高一个等级,您的GPA结果如下:</p><p><strong>总学分绩点:</strong> ${reportData.hypotheticalTotalCreditPoints}<br><strong>加权平均GPA:</strong> <strong style="color: #28a745;">${reportData.hypotheticalGpa}</strong></p>`;
} else { contentHTML += `<p>恭喜您!当前未发现卡绩科目。</p>`; }
contentHTML += `</div></details><p class="disclaimer">注意:此结果仅供参考,基于所有已获取的成绩数据计算,并非教务系统官方排名所用GPA。</p>`;
modal.innerHTML = contentHTML;
overlay.appendChild(modal); document.body.appendChild(overlay);
const close = () => document.body.removeChild(overlay); overlay.querySelector('.close-btn').onclick = close; overlay.onclick = (e) => { if (e.target === overlay) close(); };
const calculateBtnA = document.getElementById('calculate-prediction-btn-a');
const nextCreditsInputA = document.getElementById('next-credits-a');
const nextGpaInputA = document.getElementById('next-gpa-a');
const resultDisplayA = document.getElementById('predicted-gpa-result-a');
calculateBtnA.addEventListener('click', () => {
const nextCredits = parseFloat(nextCreditsInputA.value); const nextGpa = parseFloat(nextGpaInputA.value);
if (isNaN(nextCredits) || nextCredits <= 0 || isNaN(nextGpa) || nextGpa < 1.0 || nextGpa > 4.1) { resultDisplayA.textContent = '请输入有效的学分与GPA,且GPA应在1.0-4.1之间。'; return; }
const currentTotalCredits = parseFloat(reportData.totalCredits); const currentTotalCreditPoints = parseFloat(reportData.totalCreditPoints);
const predictedOverallGPA = (currentTotalCreditPoints + (nextCredits * nextGpa)) / (currentTotalCredits + nextCredits);
resultDisplayA.innerHTML = `预测总GPA为: <span style="color: green; font-size: 18px;">${predictedOverallGPA.toFixed(4)}</span>`;
});
const calculateBtnB = document.getElementById('calculate-target-btn-b');
const targetGpaInputB = document.getElementById('target-gpa-b');
const nextCreditsInputB = document.getElementById('next-credits-b');
const resultDisplayB = document.getElementById('target-gpa-result-b');
calculateBtnB.addEventListener('click', () => {
const targetGpa = parseFloat(targetGpaInputB.value); const nextCredits = parseFloat(nextCreditsInputB.value);
if (isNaN(targetGpa) || targetGpa < 1.0 || targetGpa > 4.1 || isNaN(nextCredits) || nextCredits <= 0) { resultDisplayB.textContent = '请输入有效的学分与期望GPA。'; resultDisplayB.style.color = 'red'; return; }
const currentTotalCredits = parseFloat(reportData.totalCredits); const currentTotalCreditPoints = parseFloat(reportData.totalCreditPoints);
const requiredCreditPointsNext = (targetGpa * (currentTotalCredits + nextCredits)) - currentTotalCreditPoints;
const requiredGpaNext = requiredCreditPointsNext / nextCredits;
let resultHTML = `下学期需达到均绩: <span style="font-size: 18px; color: ${requiredGpaNext > 4.1 ? 'red' : 'green'};">${requiredGpaNext.toFixed(4)}</span>`;
if (requiredGpaNext > 4.1) { resultHTML += '<br><span style="color: red; font-size: 13px;">(目标过高,无法实现)</span>'; } else if (requiredGpaNext < 1.0) { resultHTML += '<br><span style="color: #6c757d; font-size: 13px;">(目标低于最低绩点要求)</span>'; }
resultDisplayB.innerHTML = resultHTML;
});
}
/**
* 创建加载提示弹窗
*/
function createLoadingOverlay(message) {
const overlay = document.createElement('div');
overlay.className = 'gpa-report-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;z-index:10001;';
overlay.innerHTML = `
<div style="background:#fff;padding:30px 50px;border-radius:8px;text-align:center;box-shadow:0 2px 12px rgba(0,0,0,0.15);">
<div style="font-size:16px;color:#333;">${message}</div>
</div>
`;
return overlay;
}
/**
* 跳转到课表页面获取最新课表数据
* 会自动跳转到"我的课表 -> 全部课程"页面,脚本在那个页面会自动解析并缓存课表数据
*/
function navigateToCourseTablePage() {
// 检查当前页面(或 iframe)是否已经在课表页面
const courseTableUrl = CONSTANTS.PAGE_COURSE_TABLE;
const isAlreadyOnCourseTable = window.location.href.includes('/student/for-std/course-table');
// 检查 iframe 是否已经在课表页面
let iframeOnCourseTable = false;
if (!isAlreadyOnCourseTable) {
try {
const iframes = document.querySelectorAll('iframe');
for (const iframe of iframes) {
if (iframe.contentWindow && iframe.contentWindow.location.href.includes('/student/for-std/course-table')) {
iframeOnCourseTable = true;
// 直接在 iframe 中执行"全部课程"切换
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
const allTargets = iframeDoc.querySelectorAll('a, button, [role="tab"], li, span');
for (const el of allTargets) {
const text = (el.textContent || '').trim();
if (text === '全部课程' || text === '课程列表') {
Logger.log('课表获取', `已在课表页面,直接点击"${text}"`);
el.click();
// 关闭 GPA 预测弹窗
const overlay = document.querySelector('.gpa-report-overlay');
if (overlay) overlay.remove();
return;
}
}
break;
}
}
} catch (e) {
// 跨域 iframe 访问可能失败,忽略
Logger.log('课表获取', 'iframe 跨域访问失败,将使用跳转方式');
}
}
// 如果当前窗口本身就在课表页面(iframe 内运行的情况)
if (isAlreadyOnCourseTable) {
const allTargets = document.querySelectorAll('a, button, [role="tab"], li, span');
for (const el of allTargets) {
const text = (el.textContent || '').trim();
if (text === '全部课程' || text === '课程列表') {
Logger.log('课表获取', `已在课表页面,直接点击"${text}"`);
el.click();
const overlay = document.querySelector('.gpa-report-overlay');
if (overlay) overlay.remove();
return;
}
}
}
// 不在课表页面,执行跳转
GM_setValue('jwxt_auto_fetch_course_table', Date.now());
Logger.log('课表获取', '正在跳转到课表页面...');
// 关闭 GPA 预测弹窗
const overlay = document.querySelector('.gpa-report-overlay');
if (overlay) overlay.remove();
// 策略1:查找导航菜单中的"我的课表"链接并点击(保留教务系统的 iframe 框架和菜单栏)
let courseTableLink = document.querySelector('a[onclick*="course-table"]') ||
document.querySelector('a[href*="/student/for-std/course-table"]') ||
document.querySelector('a[data-text="我的课表"]');
// 如果当前在 iframe 中,尝试在顶层窗口查找菜单链接
if (!courseTableLink && window.top !== window.self) {
try {
courseTableLink = window.top.document.querySelector('a[onclick*="course-table"]') ||
window.top.document.querySelector('a[href*="/student/for-std/course-table"]') ||
window.top.document.querySelector('a[data-text="我的课表"]');
} catch (e) { /* 忽略跨域错误 */ }
}
if (courseTableLink) {
Logger.log('课表获取', '找到菜单链接,通过点击导航跳转');
courseTableLink.click();
} else {
// 策略2:查找内容 iframe,仅修改其 src(不破坏顶层页面)
let contentIframe = null;
try {
const iframes = document.querySelectorAll('iframe');
for (const f of iframes) {
// 忽略插件自己创建的 iframe
if (f.id && (f.id.startsWith('gm') || f.id.startsWith('gm_'))) continue;
if (f.offsetParent !== null && f.offsetHeight > 300 && f.offsetWidth > 300) {
contentIframe = f;
break;
}
}
} catch (e) { /* 忽略 */ }
if (contentIframe) {
Logger.log('课表获取', '通过修改内容 iframe src 跳转');
contentIframe.src = courseTableUrl;
} else {
// 策略3:最终兜底 - 直接修改当前窗口 URL
Logger.warn('课表获取', '未找到导航菜单或内容 iframe,直接跳转(菜单栏可能消失)');
window.location.href = courseTableUrl;
}
}
}
/**
* 立即显示 GPA 预测弹窗(带加载状态)
*/
function handleGpaEstimateClickImmediate() {
// 移除旧弹窗
const existingOverlay = document.querySelector('.gpa-report-overlay');
if (existingOverlay) existingOverlay.remove();
// 创建弹窗框架(立即显示)
const overlay = document.createElement('div');
overlay.className = 'gpa-report-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;z-index:10001;';
const modal = document.createElement('div');
modal.className = 'gpa-report-modal';
modal.style.cssText = 'background:#fff;border-radius:8px;max-width:700px;width:90%;max-height:85vh;overflow-y:auto;box-shadow:0 4px 20px rgba(0,0,0,0.15);';
modal.innerHTML = `
<div style="padding:20px;border-bottom:1px solid #ebeef5;display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;font-size:18px;color:#303133;">📊 GPA 预测</h3>
<button id="gm-estimate-close" style="background:none;border:none;font-size:24px;cursor:pointer;color:#909399;">×</button>
</div>
<div id="gm-estimate-content" style="padding:20px;text-align:center;">
<div style="color:#909399;padding:40px;">正在加载数据...</div>
</div>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
// 关闭按钮
modal.querySelector('#gm-estimate-close').onclick = () => overlay.remove();
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
// 异步加载数据
setTimeout(() => {
handleGpaEstimateClickLoad(modal.querySelector('#gm-estimate-content'), overlay);
}, 10);
}
/**
* 加载 GPA 预测数据并填充到弹窗
*/
async function handleGpaEstimateClickLoad(contentDiv, overlay) {
const cachedData = getCachedData();
if (!cachedData || !cachedData.allGrades || cachedData.allGrades.length === 0) {
contentDiv.innerHTML = '<div style="color:#f56c6c;padding:40px;">暂无成绩数据,请先获取成绩数据后再使用此功能。</div>';
return;
}
const allGrades = cachedData.allGrades;
const semesterNames = cachedData.semesterNames || [];
const gpaRankData = cachedData.gpaRankData;
// 使用官方 GPA 数据
const currentGPA = gpaRankData?.gpa || 'N/A';
// P/NP 课程的成绩标识
const pnPGrades = ['通过', 'P', '不通过', 'NP'];
const estimateData = JSON.parse(GM_getValue(CONSTANTS.GPA_ESTIMATE_KEY, '{}'));
// 已出成绩的课程及其成绩映射(用于判断是否已出分)
const gradedCourseMap = new Map();
// 所有课程的学分映射(用于在课表缓存无学分时做后备查询)
const creditLookupMap = new Map();
allGrades.forEach(g => {
if (g['课程代码'] && g['成绩']) {
gradedCourseMap.set(g['课程代码'], {
'成绩': g['成绩'],
'绩点': g['绩点'],
'学分': g['学分'],
'课程名称': g['课程名称'],
'学期': g['学期']
});
}
// 记录所有课程的学分(无论是否有成绩)
if (g['课程代码'] && g['学分']) {
creditLookupMap.set(g['课程代码'], g['学分']);
}
});
// 收集当前学期的所有课程(无论是否出分)
const currentSemesterCourses = [];
const seenCourseCodes = new Set();
// 获取课表缓存
const courseTableCache = GM_getValue(CONSTANTS.COURSE_TABLE_CACHE_KEY, null);
let currentSemester = null;
let parsedCourseCache = null;
let cacheTimestamp = 0;
// 解析课表缓存(只解析一次)
if (courseTableCache) {
try {
parsedCourseCache = JSON.parse(courseTableCache);
currentSemester = parsedCourseCache.semester;
cacheTimestamp = parsedCourseCache.timestamp || 0;
Logger.log('GPA 预测', `课表缓存学期: ${currentSemester}`);
} catch (e) {
Logger.error('GPA 预测', '解析课表缓存失败', e);
parsedCourseCache = null;
}
}
Logger.log('GPA 预测', `目标学期: ${currentSemester || '未知'}`);
// P/NP 课程关键词(用于过滤)
const pnpKeywords = ['通过', '不通过', 'Pass', 'NP', 'P/NP'];
// === 核心:从课表缓存获取课程列表(这才是用户当前选的课)===
if (parsedCourseCache) {
try {
const cacheData = parsedCourseCache;
if (cacheData.courses && Array.isArray(cacheData.courses)) {
Logger.log('GPA 预测', `课表缓存中有 ${cacheData.courses.length} 门课程`);
cacheData.courses.forEach(course => {
const code = course.code;
const name = course.name;
const credits = course.credits || '';
if (!code || !name) return;
if (seenCourseCodes.has(code)) return;
// 过滤 P/NP 课程
const isPnp = pnpKeywords.some(kw => name.includes(kw));
if (isPnp) {
Logger.log('GPA 预测', `跳过 P/NP: ${name}`);
return;
}
seenCourseCodes.add(code);
// 检查成绩数据中是否有这门课的成绩
const gradedInfo = gradedCourseMap.get(code);
const hasScore = gradedInfo && gradedInfo['成绩'] && gradedInfo['成绩'] !== '待发布' && gradedInfo['成绩'] !== '';
// 学分优先级:已出分成绩的学分 > 课表缓存学分 > 成绩数据中的学分
let finalCredits = '';
if (hasScore && gradedInfo['学分']) {
finalCredits = gradedInfo['学分'];
} else if (credits) {
finalCredits = credits;
} else if (creditLookupMap.has(code)) {
finalCredits = creditLookupMap.get(code);
}
currentSemesterCourses.push({
'课程代码': code,
'课程名称': name,
'学分': finalCredits,
'学期': currentSemester,
'已出分': hasScore,
'成绩': hasScore ? gradedInfo['成绩'] : null,
'绩点': hasScore ? gradedInfo['绩点'] : null,
'来源': '课表'
});
});
}
} catch (e) {
Logger.error('GPA 预测', '读取课表缓存失败', e);
}
}
// 如果没有课表缓存数据,不再从成绩数据获取(避免错误加载上学期课程)
if (currentSemesterCourses.length === 0 && !parsedCourseCache) {
Logger.log('GPA 预测', '无课表缓存,提示用户打开课表页面');
}
Logger.log('GPA 预测', `当前学期共 ${currentSemesterCourses.length} 门课程`);
// 检测学分缺失情况:如果用户只打开了"我的课表"但未进入"全部课程",学分可能无法获取
const coursesWithoutCredits = currentSemesterCourses.filter(c => {
const credit = parseFloat(c['学分']);
return isNaN(credit) || credit <= 0;
});
const hasMissingCredits = coursesWithoutCredits.length > 0;
if (hasMissingCredits) {
Logger.warn('GPA 预测', `有 ${coursesWithoutCredits.length} 门课程缺少学分信息: ${coursesWithoutCredits.map(c => c['课程名称']).join(', ')}`);
}
// 计算缓存时间信息
const cacheAgeMs = cacheTimestamp ? (Date.now() - cacheTimestamp) : 0;
const cacheAgeHours = cacheAgeMs / 1000 / 60 / 60;
const cacheAgeText = cacheTimestamp ? formatCacheAge(cacheAgeMs) : '';
const isCacheStale = cacheAgeHours > 24; // 超过24小时视为可能过期
// 构建表格 HTML
let tableHTML = '';
if (currentSemesterCourses.length === 0) {
tableHTML = `<div style="text-align:center;padding:40px;color:#888;font-size:15px;">
<p>暂无当前学期课程数据</p>
<p style="margin-top:15px;font-size:13px;line-height:1.8;">
点击下方按钮将跳转到课表页面,自动获取全部课程信息:
</p>
<button id="gm-fetch-course-btn" style="margin-top:12px;padding:10px 28px;background:#409EFF;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:14px;">前往课表页面获取数据</button>
<p style="margin-top:12px;font-size:12px;color:#bbb;">将自动跳转到「我的课表 → 全部课程」页面完成数据缓存,<br>之后回到此页面即可使用 GPA 预测功能。</p>
</div>`;
} else if (hasMissingCredits) {
// 有课程但学分信息不完整(通常是只查看了"我的课表"而没有进入"全部课程")
tableHTML = `<div style="text-align:center;padding:40px;color:#888;font-size:15px;">
<div style="margin-bottom:18px;padding:14px;background:#FDF6EC;border:1px solid #E6A23C;border-radius:6px;text-align:left;font-size:13px;color:#E6A23C;line-height:1.8;">
<b style="font-size:14px;">学分信息不完整</b><br>
已获取到课程信息,但部分课程缺少学分数据,无法进行 GPA 预测。
</div>
<p style="font-size:13px;line-height:1.8;color:#666;">
这通常是因为仅查看了「我的课表」页面,该页面不包含学分信息。<br>
请点击下方按钮跳转到课表页面,并切换到「全部课程」视图以获取完整数据:
</p>
<button id="gm-fetch-course-btn" style="margin-top:12px;padding:10px 28px;background:#E6A23C;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:14px;">前往课表页面补全学分数据</button>
<p style="margin-top:12px;font-size:12px;color:#bbb;">将自动跳转到「我的课表 → 全部课程」页面完成数据缓存,<br>之后回到此页面即可使用 GPA 预测功能。</p>
</div>`;
} else {
// 统计已出分和未出分数量
const gradedCount = currentSemesterCourses.filter(c => c['已出分']).length;
const pendingCount = currentSemesterCourses.filter(c => !c['已出分']).length;
// 缓存过期警告
const cacheWarningHTML = isCacheStale
? `<div style="margin-bottom:12px;padding:10px;background:#FDF6EC;border:1px solid #E6A23C;border-radius:4px;font-size:13px;color:#E6A23C;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;">
<span>⚠️ 课表缓存已超过 ${cacheAgeText},选课如有变动请刷新。</span>
<button id="gm-refresh-course-btn" title="如果选退课有变动,请点此刷新以获取最新课表" style="padding:5px 14px;background:#E6A23C;color:#fff;border:none;border-radius:3px;cursor:pointer;font-size:12px;white-space:nowrap;">🔄 刷新课表</button>
</div>`
: (cacheAgeText ? `<div style="margin-bottom:8px;font-size:12px;color:#bbb;display:flex;align-items:center;gap:6px;">
<span>课表数据更新于 ${cacheAgeText}前</span>
<button id="gm-refresh-course-btn" title="如果选退课有变动,请点此刷新以获取最新课表" style="padding:2px 10px;background:none;color:#409EFF;border:1px solid #409EFF;border-radius:3px;cursor:pointer;font-size:11px;">刷新</button>
</div>` : '');
tableHTML = `
${cacheWarningHTML}
<div style="margin-bottom:15px;padding:10px;background:#f5f7fa;border-radius:4px;font-size:13px;">
<span>当前官方 GPA: <b style="color:#409EFF;font-size:16px;">${currentGPA}</b></span>
<span style="margin-left:20px;">已出分: <b style="color:#67C23A;">${gradedCount}</b> 门</span>
<span style="margin-left:10px;">未出分: <b style="color:#E6A23C;">${pendingCount}</b> 门</span>
<span style="margin-left:20px;color:#909399;">学期: ${currentSemester || '未知'}</span>
</div>
<table style="width:100%;border-collapse:collapse;margin:15px 0;">
<thead>
<tr style="background:#f5f7fa;">
<th style="padding:10px;border:1px solid #ebeef5;text-align:left;">课程名称</th>
<th style="padding:10px;border:1px solid #ebeef5;text-align:center;width:60px;">学分</th>
<th style="padding:10px;border:1px solid #ebeef5;text-align:center;width:100px;">GPA</th>
</tr>
</thead>
<tbody>
`;
// GPA选项(最后一个为自定义)
const gpaOptions = [4.1, 3.9, 3.7, 3.3, 3.0, 2.7, 2.3, 2.0, 1.7, 1.3, 0];
currentSemesterCourses.forEach((course, idx) => {
const sourceTag = course['来源'] === '课表' ? '<span style="font-size:11px;color:#909399;">[课表]</span>' : '';
// 学分显示(学分一定从课表/成绩数据中获得,无需手动输入)
const creditDisplay = `<span>${course['学分'] || '-'}</span><input type="hidden" data-code="${course['课程代码']}" data-field="credits" value="${course['学分'] || 0}">`;
// 成绩/GPA显示:已出分固定显示,未出分可输入
let gpaCell = '';
if (course['已出分']) {
// 已出分:GPA 为主显示,成绩为辅
const scoreColor = course['绩点'] >= 3.7 ? '#67C23A' : (course['绩点'] >= 2.0 ? '#E6A23C' : '#F56C6C');
gpaCell = `<span style="color:${scoreColor};font-weight:bold;font-size:15px;">${course['绩点']}</span>
<br><small style="color:#909399;">${course['成绩']}</small>
<input type="hidden" data-code="${course['课程代码']}" data-field="gpa" value="${course['绩点']}" data-graded="true">`;
} else {
// 未出分:显示下拉选择框
const savedGpa = estimateData[course['课程代码']] || '';
const isCustomGpa = savedGpa && !gpaOptions.includes(parseFloat(savedGpa));
const gpaSelectId = `gpa-select-${idx}`;
const gpaCustomId = `gpa-custom-${idx}`;
gpaCell = `<select id="${gpaSelectId}" data-code="${course['课程代码']}" data-field="gpa" class="gpa-predict-select" style="width:80px;padding:5px 6px;border:1px solid #c0c4cc;border-radius:4px;text-align:center;font-size:13px;color:#606266;background:#fff;cursor:pointer;outline:none;appearance:auto;">
<option value="" style="color:#c0c4cc;">--</option>
${gpaOptions.map(g => `<option value="${g}" ${savedGpa !== '' && String(savedGpa) === String(g) && !isCustomGpa ? 'selected' : ''}>${g}</option>`).join('')}
<option value="custom" ${isCustomGpa ? 'selected' : ''}>自定义</option>
</select>
<input type="number" step="0.01" min="0" max="4.3" id="${gpaCustomId}" data-code="${course['课程代码']}" data-field="gpa-custom" value="${isCustomGpa ? savedGpa : ''}" placeholder="0-4.3" style="width:62px;padding:4px 6px;border:1px solid #c0c4cc;border-radius:4px;text-align:center;font-size:13px;color:#606266;margin-left:4px;outline:none;${isCustomGpa ? '' : 'display:none;'}">`;
}
// 已出分:绿色左边框 + 微灰背景;未出分:浅灰左边框 + 极浅灰背景
const rowStyle = course['已出分']
? 'background:#fafafa;border-left:3px solid #67C23A;'
: 'background:#fdfdfd;border-left:3px solid #dcdfe6;';
const rowTag = course['已出分']
? '<span style="display:inline-block;width:7px;height:7px;background:#67C23A;border-radius:50%;vertical-align:middle;margin-right:4px;"></span>'
: '<span style="display:inline-block;width:7px;height:7px;border:2px solid #E6A23C;border-radius:50%;vertical-align:middle;margin-right:4px;box-sizing:border-box;"></span>';
tableHTML += `
<tr data-code="${course['课程代码']}" style="${rowStyle}">
<td style="padding:10px;border:1px solid #ebeef5;">
${rowTag} ${course['课程名称']} ${sourceTag}
<br><small style="color:#909399;">${course['课程代码']}</small>
</td>
<td style="padding:10px;border:1px solid #ebeef5;text-align:center;">
${creditDisplay}
</td>
<td style="padding:10px;border:1px solid #ebeef5;text-align:center;">
${gpaCell}
</td>
</tr>
`;
});
tableHTML += `
</tbody>
</table>
<div style="text-align:center;margin-top:15px;">
<button id="gm-estimate-calc" style="padding:10px 30px;background:#409EFF;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:14px;">预测GPA</button>
</div>
<div id="gm-estimate-result" style="margin-top:15px;padding:15px;background:#f5f7fa;border-radius:4px;display:none;">
<div id="gm-result-a" style="font-size:14px;margin-bottom:8px;"></div>
<div id="gm-result-b" style="font-size:14px;"></div>
</div>
`;
}
contentDiv.innerHTML = tableHTML;
// 自动保存函数
const autoSaveGPA = (courseCode, rowElement) => {
const estimateData = JSON.parse(GM_getValue(CONSTANTS.GPA_ESTIMATE_KEY, '{}'));
// 获取 GPA 值
const gpaSelect = rowElement.querySelector(`select[data-code="${courseCode}"]`);
const gpaCustomInput = rowElement.querySelector(`input[data-field="gpa-custom"][data-code="${courseCode}"]`);
let gpaValue = '';
if (gpaSelect && gpaSelect.value) {
if (gpaSelect.value === 'custom') {
gpaValue = gpaCustomInput?.value || '';
} else {
gpaValue = gpaSelect.value;
}
}
if (gpaValue !== '') {
estimateData[courseCode] = gpaValue;
GM_setValue(CONSTANTS.GPA_ESTIMATE_KEY, JSON.stringify(estimateData));
Logger.log('GPA 预测', `自动保存: ${courseCode} = ${gpaValue}`);
}
};
// 为所有 GPA 下拉框绑定事件
contentDiv.querySelectorAll('select[data-field="gpa"]').forEach(select => {
const courseCode = select.dataset.code;
const customInputId = select.id.replace('gpa-select-', 'gpa-custom-');
const customInput = document.getElementById(customInputId);
select.addEventListener('change', () => {
if (select.value === 'custom') {
customInput.style.display = 'inline-block';
customInput.focus();
} else {
customInput.style.display = 'none';
autoSaveGPA(courseCode, select.closest('tr'));
}
});
});
// 为所有自定义 GPA 输入框绑定事件
contentDiv.querySelectorAll('input[data-field="gpa-custom"]').forEach(input => {
const courseCode = input.dataset.code;
input.addEventListener('change', () => {
autoSaveGPA(courseCode, input.closest('tr'));
});
});
// 绑定计算按钮事件
const calcBtn = document.getElementById('gm-estimate-calc');
if (calcBtn) {
calcBtn.onclick = () => {
calculatePredictedGPA(contentDiv, allGrades, currentSemesterCourses, currentGPA, gpaRankData, currentSemester);
};
}
// 绑定「前往课表页面获取」或「刷新课表」按钮事件
const fetchBtn = contentDiv.querySelector('#gm-fetch-course-btn') || contentDiv.querySelector('#gm-refresh-course-btn');
if (fetchBtn) {
fetchBtn.addEventListener('click', () => {
navigateToCourseTablePage();
});
}
}
/**
* 计算预测 GPA
*/
function calculatePredictedGPA(contentDiv, allGrades, currentSemesterCourses, currentGPA, gpaRankData, currentSemester) {
// 中文等级制成绩映射
const chineseGradeMap = { '优秀': 4.0, '良好': 3.0, '中等': 2.0, '及格': 1.3, '不及格': 0.0 };
const pnPGrades = ['通过', 'P', '不通过', 'NP'];
// === 预检查:检测未出分课程是否都已选择GPA ===
const missingItems = [];
currentSemesterCourses.forEach(course => {
if (course['已出分']) return; // 跳过已出分的
const row = contentDiv.querySelector(`tr[data-code="${course['课程代码']}"]`);
if (!row) return;
// 检查GPA
const gpaSelect = row.querySelector('select[data-field="gpa"]');
const gpaCustomInput = row.querySelector('input[data-field="gpa-custom"]');
let gpaValue = '';
if (gpaSelect?.value) {
if (gpaSelect.value === 'custom') {
gpaValue = gpaCustomInput?.value || '';
} else {
gpaValue = gpaSelect.value;
}
}
const hasGpa = gpaValue && !isNaN(parseFloat(gpaValue));
if (!hasGpa) {
missingItems.push(course['课程名称']);
}
});
// 如果有未填写的,显示提醒
if (missingItems.length > 0) {
const resultDiv = document.getElementById('gm-estimate-result');
const resultA = document.getElementById('gm-result-a');
const resultB = document.getElementById('gm-result-b');
resultDiv.style.display = 'block';
resultA.innerHTML = `<span style="color:#E6A23C;">⚠️ 请先为以下课程选择预估 GPA:</span>
<ul style="margin:8px 0;padding-left:20px;font-size:13px;">
${missingItems.map(item => `<li>${item}</li>`).join('')}
</ul>`;
resultB.innerHTML = '';
return;
}
Logger.log('GPA 预测', `当前学期: ${currentSemester || '未知'}`);
// 1. 计算本学期开始前的成绩(之前学期的成绩)
let previousCredits = 0;
let previousPoints = 0;
let previousCount = 0;
allGrades.forEach(g => {
const credits = parseFloat(g['学分']);
const score = g['成绩'];
const semester = g['学期'];
let gp = parseFloat(g['绩点']);
if (isNaN(credits) || credits <= 0) return;
if (gp === null || isNaN(gp)) return;
if (pnPGrades.includes(score)) return;
// 处理中文等级制成绩
if (typeof score === 'string' && chineseGradeMap.hasOwnProperty(score)) {
gp = chineseGradeMap[score];
}
// 只计算本学期开始前的成绩
if (semester !== currentSemester) {
previousCredits += credits;
previousPoints += credits * gp;
previousCount++;
}
});
Logger.log('GPA 预测', `本学期开始前: ${previousCount} 门, 学分 ${previousCredits.toFixed(1)}, 绩点 ${previousPoints.toFixed(2)}`);
// 2. 从当前学期课程表格中收集数据(包括已出分和未出分)
let currentSemCredits = 0;
let currentSemPoints = 0;
let gradedCount = 0;
let estimatedCount = 0;
currentSemesterCourses.forEach(course => {
const row = contentDiv.querySelector(`tr[data-code="${course['课程代码']}"]`);
if (!row) return;
// 学分:可能是 input 或 hidden input
const creditInput = row.querySelector('input[data-field="credits"]');
let credits = 0;
if (creditInput && creditInput.value) {
credits = parseFloat(creditInput.value);
} else if (course['学分']) {
credits = parseFloat(course['学分']);
}
if (isNaN(credits) || credits <= 0) {
Logger.log('GPA 预测', `课程 ${course['课程名称']}: 学分无效,跳过`);
return;
}
// GPA:已出分从hidden input获取,未出分从select获取
let gpa = NaN;
const gpaHiddenInput = row.querySelector('input[data-field="gpa"][data-graded="true"]');
if (gpaHiddenInput) {
// 已出分的课程
gpa = parseFloat(gpaHiddenInput.value);
if (!isNaN(gpa)) {
gradedCount++;
Logger.log('GPA 预测', `课程 ${course['课程名称']}: 已出分, 学分=${credits}, GPA=${gpa}`);
}
} else {
// 未出分的课程,从下拉框获取
const gpaSelect = row.querySelector('select[data-field="gpa"]');
const gpaCustomInput = row.querySelector('input[data-field="gpa-custom"]');
if (gpaSelect && gpaSelect.value) {
if (gpaSelect.value === 'custom') {
if (gpaCustomInput && gpaCustomInput.value) {
gpa = parseFloat(gpaCustomInput.value);
}
} else {
gpa = parseFloat(gpaSelect.value);
}
}
if (!isNaN(gpa) && gpa >= 0 && gpa <= 4.3) {
estimatedCount++;
Logger.log('GPA 预测', `课程 ${course['课程名称']}: 预估, 学分=${credits}, GPA=${gpa}`);
}
}
// 累加有效数据
if (!isNaN(gpa) && gpa >= 0 && gpa <= 4.3) {
currentSemCredits += credits;
currentSemPoints += credits * gpa;
}
});
Logger.log('GPA 预测', `本学期: 已出分 ${gradedCount} 门, 预估 ${estimatedCount} 门, 总学分 ${currentSemCredits.toFixed(1)}, 总绩点 ${currentSemPoints.toFixed(2)}`);
// 显示结果
const resultDiv = document.getElementById('gm-estimate-result');
const resultA = document.getElementById('gm-result-a');
const resultB = document.getElementById('gm-result-b');
// 计算各项 GPA
const previousGPA = previousCredits > 0 ? previousPoints / previousCredits : 0;
const currentSemGPA = currentSemCredits > 0 ? currentSemPoints / currentSemCredits : 0;
const totalAllCredits = previousCredits + currentSemCredits;
const totalAllPoints = previousPoints + currentSemPoints;
const totalAllGPA = totalAllCredits > 0 ? totalAllPoints / totalAllCredits : 0;
resultDiv.style.display = 'block';
resultA.innerHTML = `
<table style="width:100%;border-collapse:collapse;font-size:14px;">
<tr style="background:#f5f7fa;">
<th style="padding:8px;border:1px solid #ebeef5;text-align:center;">项目</th>
<th style="padding:8px;border:1px solid #ebeef5;text-align:center;">学分</th>
<th style="padding:8px;border:1px solid #ebeef5;text-align:center;">GPA</th>
</tr>
<tr>
<td style="padding:8px;border:1px solid #ebeef5;text-align:center;">本学期开始前</td>
<td style="padding:8px;border:1px solid #ebeef5;text-align:center;">${previousCredits.toFixed(1)}</td>
<td style="padding:8px;border:1px solid #ebeef5;text-align:center;font-weight:bold;">${previousGPA.toFixed(4)}</td>
</tr>
<tr>
<td style="padding:8px;border:1px solid #ebeef5;text-align:center;">本学期</td>
<td style="padding:8px;border:1px solid #ebeef5;text-align:center;">${currentSemCredits.toFixed(1)}</td>
<td style="padding:8px;border:1px solid #ebeef5;text-align:center;font-weight:bold;">${currentSemGPA.toFixed(4)}</td>
</tr>
<tr style="background:#ecf5ff;">
<td style="padding:8px;border:1px solid #ebeef5;text-align:center;font-weight:bold;color:#409EFF;">预测总 GPA</td>
<td style="padding:8px;border:1px solid #ebeef5;text-align:center;font-weight:bold;color:#409EFF;">${totalAllCredits.toFixed(1)}</td>
<td style="padding:8px;border:1px solid #ebeef5;text-align:center;font-weight:bold;color:#409EFF;font-size:16px;">${totalAllGPA.toFixed(4)}</td>
</tr>
</table>
`;
resultB.innerHTML = `<small style="color:#909399;">本学期: 已出分 ${gradedCount} 门 + 预估 ${estimatedCount} 门</small>`;
}
/**
* 格式化缓存时间差为可读文本
*/
function formatCacheAge(ms) {
const minutes = Math.floor(ms / 1000 / 60);
if (minutes < 1) return '不到 1 分钟';
if (minutes < 60) return `${minutes} 分钟`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} 小时`;
const days = Math.floor(hours / 24);
return `${days} 天`;
}
// 旧版 handleGpaEstimateClick 已废弃,统一使用 handleGpaEstimateClickImmediate
// ----------------- 2.4 学生画像增强 -----------------
function precomputeAllWeightedScores(allGrades) {
const scoresBySemester = {}; const gradesBySemester = {};
allGrades.forEach(grade => { const semester = grade['学期']; if (!gradesBySemester[semester]) gradesBySemester[semester] = []; gradesBySemester[semester].push(grade); });
const calculate = (grades) => {
let totalScoreCredits = 0, totalCredits = 0;
grades.forEach(grade => {
const credits = parseFloat(grade['学分']); if (isNaN(credits) || credits <= 0) return;
const numericScore = parseFloat(grade['成绩']);
if (!isNaN(numericScore)) { totalScoreCredits += numericScore * credits; totalCredits += credits; }
});
return totalCredits > 0 ? (totalScoreCredits / totalCredits).toFixed(4) : 'N/A';
};
for (const semesterName in gradesBySemester) { scoresBySemester[semesterName] = { weightedScore: calculate(gradesBySemester[semesterName]), tooltipText: `当前学期加权百分制成绩\n(不含PNP和中文等级制成绩)` }; }
scoresBySemester['全部'] = { weightedScore: calculate(allGrades), tooltipText: `所有学期加权百分制成绩\n(不含PNP和中文等级制成绩)` };
return scoresBySemester;
}
function setupSemesterChangeObserver(weightedScores) {
const targetNode = document.querySelector('.myScore .el-select .el-input__inner');
if (!targetNode || targetNode.dataset.gmListenerAttached) return;
let lastValue = targetNode.value;
setInterval(() => {
if (!ConfigManager.enablePortraitEnhancement || !document.body.contains(targetNode)) return;
const currentValue = targetNode.value;
if (currentValue !== lastValue) {
lastValue = currentValue;
const scoreTile = document.getElementById('gm-weighted-score-tile');
if (scoreTile) {
const semesterKey = currentValue || "全部";
const scoreData = weightedScores[semesterKey] || { weightedScore: 'N/A' };
const scoreSpan = scoreTile.querySelector('.score');
if (scoreSpan) scoreSpan.textContent = scoreData.weightedScore;
}
}
}, 200);
targetNode.dataset.gmListenerAttached = 'true';
}
function injectTooltipStylesForPortrait() {
const styleId = 'gm-tooltip-styles-portrait'; if (document.getElementById(styleId)) return;
const style = document.createElement('style'); style.id = styleId;
style.textContent = `
.gm-tooltip-trigger { position: relative; cursor: help; font-family: "iconfont" !important; font-size: 14px; font-style: normal; }
.gm-tooltip-trigger:hover::after { content: attr(data-gm-tooltip); position: absolute; bottom: 125%; left: 50%; transform: translateX(-50%); background-color: #303133; color: #fff; padding: 8px 12px; border-radius: 4px; font-size: 12px; line-height: 1.4; white-space: pre-line; z-index: 10001; display: inline-block; width: max-content; max-width: 280px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); pointer-events: none; }
`;
document.head.appendChild(style);
}
function updateSummaryTilesForPortrait(data, scoreContentElement, weightedScores) {
if (!scoreContentElement) return;
const infoDivs = Array.from(scoreContentElement.querySelectorAll('.info'));
const avgScoreLabel = infoDivs.find(el => el.textContent.includes("平均分") || el.textContent.includes("加权分") || el.dataset.originalHtml);
const majorRankTileId = 'gm-major-rank-tile';
const majorRankTile = document.getElementById(majorRankTileId);
if (!ConfigManager.enablePortraitEnhancement) {
if (avgScoreLabel && avgScoreLabel.dataset.originalHtml) {
avgScoreLabel.innerHTML = avgScoreLabel.dataset.originalHtml;
delete avgScoreLabel.dataset.originalHtml;
const avgScoreTile = avgScoreLabel.closest('.score-item');
if (avgScoreTile) avgScoreTile.removeAttribute('id');
}
if (majorRankTile) majorRankTile.remove();
scoreContentElement.removeAttribute('data-gm-enhanced-summary');
return;
}
if (!avgScoreLabel || (scoreContentElement.dataset.gmEnhancedSummary === 'true' && document.getElementById(majorRankTileId))) return;
const { gpaRankData } = data;
const avgScoreTile = avgScoreLabel.closest('.score-item');
if (avgScoreTile) {
avgScoreTile.id = 'gm-weighted-score-tile';
if (!avgScoreLabel.dataset.originalHtml) avgScoreLabel.dataset.originalHtml = avgScoreLabel.innerHTML;
const initialScoreData = weightedScores['全部'] || { weightedScore: 'N/A', tooltipText: '' };
avgScoreLabel.innerHTML = `加权百分制分数 <i class="iconfont icon-bangzhu gm-tooltip-trigger" data-gm-tooltip="${initialScoreData.tooltipText}"></i>`;
const scoreValDiv = avgScoreTile.querySelector('.score');
if (scoreValDiv) scoreValDiv.textContent = initialScoreData.weightedScore;
}
if (!document.getElementById(majorRankTileId)) {
const rankValue = gpaRankData?.rank ?? '无数据';
const rankDiv = document.createElement('li');
rankDiv.id = majorRankTileId;
rankDiv.className = 'score-item';
rankDiv.style.background = '#17a2b8';
rankDiv.innerHTML = `<div class="icon-img"><i class="iconfont icon-paiming2"></i></div><div class="score-info"><div class="score">${rankValue}</div><div class="info">专业排名 <i class="iconfont icon-bangzhu gm-tooltip-trigger" data-gm-tooltip="排名数据来自教务系统\n若无则显示'无数据'"></i></div>`;
scoreContentElement.appendChild(rankDiv);
}
scoreContentElement.dataset.gmEnhancedSummary = 'true';
}
function getPassStatus(score) {
const passingGrades = ['优秀', '良好', '中等', '及格', '通过', 'P'];
const failingGrades = ['不及格', '不通过'];
if (passingGrades.includes(score)) return '<span class="value">通过</span>';
if (failingGrades.includes(score)) return '<span class="value" style="color: #F56C6C">不通过</span>';
const numericScore = parseFloat(score);
if (!isNaN(numericScore)) return numericScore >= 60 ? '<span class="value">通过</span>' : '<span class="value" style="color: #F56C6C">不通过</span>';
return '';
}
function createEnhancedOutOfPlanTableForPortrait(data, originalTableContainer) {
const enhancedId = 'gm-enhanced-table-wrapper';
let enhancedContainer = document.getElementById(enhancedId);
if (!ConfigManager.enablePortraitEnhancement) {
if (enhancedContainer) enhancedContainer.remove();
originalTableContainer.style.display = '';
originalTableContainer.removeAttribute('data-gm-enhanced');
return;
}
if (originalTableContainer.dataset.gmEnhanced === 'true' && enhancedContainer) return;
const outOfPlanCourseCodes = new Set();
const rows = originalTableContainer.querySelectorAll('.el-table__body-wrapper tbody tr');
const headerCells = Array.from(originalTableContainer.querySelectorAll('.el-table__header-wrapper th'));
let codeIndex = headerCells.findIndex(th => th.textContent.trim().includes('课程代码'));
if (codeIndex === -1) return;
rows.forEach(row => {
const cells = row.querySelectorAll('td');
if (cells[codeIndex]) outOfPlanCourseCodes.add(cells[codeIndex].textContent.trim());
});
if (outOfPlanCourseCodes.size === 0) return;
const outOfPlanGrades = data.allGrades.filter(grade => outOfPlanCourseCodes.has(grade['课程代码']));
const classRankMap = new Map(data.allGrades.map(g => [g['课程代码'], g['教学班排名']]));
const totalCredits = outOfPlanGrades.reduce((sum, g) => sum + parseFloat(g['学分'] || 0), 0);
const passedCredits = outOfPlanGrades.reduce((sum, g) => {
const statusHtml = getPassStatus(g['成绩']);
return (statusHtml.includes('通过') && !statusHtml.includes('不')) ? sum + parseFloat(g['学分'] || 0) : sum;
}, 0);
const failedCredits = totalCredits - passedCredits;
const originalHandler = originalTableContainer.querySelector('.node-handler');
let paddingLeft = '20px';
if (originalHandler && originalHandler.style.paddingLeft) paddingLeft = originalHandler.style.paddingLeft;
if (!enhancedContainer) {
enhancedContainer = document.createElement('div');
enhancedContainer.id = enhancedId;
enhancedContainer.className = 'node-wrapper courseTreeNode marginBottom';
originalTableContainer.insertAdjacentElement('afterend', enhancedContainer);
}
const colGroupHTML = `<colgroup><col width="48"><col width="200"><col width="100"><col width="120"><col width="80"><col width="60"><col width="60"><col width="60"><col width="100"><col width="80"></colgroup>`;
const headerHTML = `<div class="el-table__header-wrapper"><table cellspacing="0" cellpadding="0" border="0" class="el-table__header" style="width: 100%;">${colGroupHTML}<thead class="has-gutter"><tr class="table-header"><th class="is-leaf" width="50"><div class="cell">序号</div></th><th class="is-leaf"><div class="cell">课程名称</div></th><th class="is-leaf" width="100"><div class="cell">课程代码</div></th><th class="is-leaf" width="120"><div class="cell">学年学期</div></th><th class="is-leaf" width="80"><div class="cell">是否必修</div></th><th class="is-leaf" width="60"><div class="cell">学分</div></th><th class="is-leaf" width="60"><div class="cell">成绩</div></th><th class="is-leaf" width="60"><div class="cell">绩点</div></th><th class="is-leaf" width="100"><div class="cell">教学班排名</div></th><th class="is-leaf" width="80"><div class="cell">是否通过</div></th></tr></thead></table></div>`;
const tableBodyRows = outOfPlanGrades.map((grade, index) => {
const score = grade['成绩'];
const isFail = parseFloat(score) < 60 && !isNaN(parseFloat(score));
const scoreStyle = isFail ? 'color: #F56C6C; font-weight: bold;' : '';
const passStatus = getPassStatus(score);
return `<tr class="el-table__row"><td class="cell-style"><div class="cell">${index + 1}</div></td><td class="cell-style"><div class="cell el-tooltip"><span class="value">${grade['课程名称'] || ''}</span></div></td><td class="cell-style"><div class="cell el-tooltip">${grade['课程代码'] || ''}</div></td><td class="cell-style"><div class="cell el-tooltip">${grade['学期'] || ''}</div></td><td class="cell-style"><div class="cell el-tooltip"><span class="value">${grade['是否必修'] ? '是' : '否'}</span></div></td><td class="cell-style"><div class="cell el-tooltip">${grade['学分'] || ''}</div></td><td class="cell-style"><div class="cell el-tooltip" style="${scoreStyle}">${grade['成绩'] || ''}</div></td><td class="cell-style"><div class="cell el-tooltip">${grade['绩点'] ?? ''}</div></td><td class="cell-style"><div class="cell el-tooltip"><span class="value">${classRankMap.get(grade['课程代码']) || '-'}</span></div></td><td class="cell-style"><div class="cell el-tooltip">${passStatus}</div></td></tr>`;
}).join('');//`
const bodyHTML = `<div class="el-table__body-wrapper is-scrolling-left"><table cellspacing="0" cellpadding="0" border="0" class="el-table__body" style="width: 100%;">${colGroupHTML}<tbody>${tableBodyRows}</tbody></table></div>`;
enhancedContainer.innerHTML = `<div class="node-handler background" style="padding-left: ${paddingLeft}; cursor: pointer;"><div class="arrow"></div><div class="title"><div class="course-name">计划外课程</div><div class="require-item"><span class="score">学分:</span><span class="con">共 ${totalCredits} | 已通过 ${passedCredits} | 未通过 </span><span class="unpassed">${failedCredits}</span></div></div></div><div class="node-child-wrapper none"><div class="node-child"><div class="child"><div class="el-table el-table--fit el-table--enable-row-hover el-table--enable-row-transition el-table--small" style="width: 100%;">${headerHTML}${bodyHTML}</div></div></div></div>`;
const handler = enhancedContainer.querySelector('.node-handler');
const wrapper = enhancedContainer.querySelector('.node-child-wrapper');
const arrow = enhancedContainer.querySelector('.arrow');
handler.addEventListener('click', () => {
if (wrapper.classList.contains('none')) { wrapper.classList.remove('none'); arrow.classList.add('up'); }
else { wrapper.classList.add('none'); arrow.classList.remove('up'); }
});
originalTableContainer.style.display = 'none';
originalTableContainer.dataset.gmEnhanced = 'true';
}
function applyPortraitSettings() {
if (!window.location.href.includes('student-portrait')) return;
const cachedData = getCachedData();
if (!cachedData) return;
const weightedScores = precomputeAllWeightedScores(cachedData.allGrades);
const scoreContent = document.querySelector(".score-content");
if (scoreContent) updateSummaryTilesForPortrait(cachedData, scoreContent, weightedScores);
const outOfPlanTable = document.querySelector('.outPlanTable');
if (outOfPlanTable) createEnhancedOutOfPlanTableForPortrait(cachedData, outOfPlanTable);
}
async function enhancePortraitPage() {
while (!document.body || !document.querySelector(".score-content")) { await new Promise(resolve => setTimeout(resolve, 50)); }
Logger.log("2.4", "脚本已在学生画像页激活");
injectTooltipStylesForPortrait();
let data = getCachedData();
if (!data) {
try { data = await fetchAllDataAndCache(); }
catch (err) { Logger.error("2.4", "获取数据失败:", err); return; }
}
const weightedScores = precomputeAllWeightedScores(data.allGrades);
applyPortraitSettings();
const observer = new MutationObserver((mutations, obs) => {
const scoreContent = document.querySelector(".score-content");
const outOfPlanTable = document.querySelector('.outPlanTable');
const isEnabled = ConfigManager.enablePortraitEnhancement;
if (scoreContent) {
const isEnhanced = scoreContent.hasAttribute('data-gm-enhanced-summary');
if ((isEnabled && !isEnhanced) || (!isEnabled && isEnhanced)) {
updateSummaryTilesForPortrait(data, scoreContent, weightedScores);
if (isEnabled) setupSemesterChangeObserver(weightedScores);
}
}
if (outOfPlanTable) {
const isTableEnhanced = outOfPlanTable.hasAttribute('data-gm-enhanced');
if (outOfPlanTable.querySelector('.el-table__body-wrapper tbody tr')) {
if ((isEnabled && !isTableEnhanced) || (!isEnabled && isTableEnhanced)) {
createEnhancedOutOfPlanTableForPortrait(data, outOfPlanTable);
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
// =-=-=-=-=-=-=-=-=-=-=-=-= 2.5 全校开课查询页选课记录 =-=-=-=-=-=-=-=-=-=-=-=-=
/**
* 全校开课查询页面增强模块
* 包含:历史记录显示、控制面板UI、自动翻页同步逻辑
*/
const LessonSearchEnhancer = {
// 配置常量
CONFIG: {
HISTORY_KEY: 'course_enrollment_history_auto_sync',
PAGE_SIZE_BTN: '.page-config .dropdown-toggle',
PAGE_SIZE_1000: '.page-config .dropdown-menu a[value="1000"]',
NEXT_BTN: '.semi-auto-table-paginator .fa-angle-right',
LOADER: 'td.dataTables_empty',
TABLE_ROWS: '#table tbody tr'
},
init() {
// 1. 路径检查
if (!window.location.href.includes('/student/for-std/lesson-search')) return;
// 2. 强制等待分页栏(.page-config)出现
// 如果页面核心组件没加载出来,每300ms重试一次,直到出现为止
if (!document.querySelector('.page-config') || !document.querySelector('#table')) {
setTimeout(() => this.init(), 300);
return;
}
Logger.log("2.5", "初始化选课记录模块...");
// 3. 初始化UI
this.injectControlPanel();
this.renderHistoryTags();
// 4. 自动同步触发逻辑
// 必须在页面完全就绪后才消耗掉 sessionStorage 的标记
if (sessionStorage.getItem('nwpu_course_sync_trigger') === 'true') {
// 检查:如果表格还在转圈加载中(dataTables_empty),则继续等待,暂不执行
if (document.querySelector('td.dataTables_empty')) {
setTimeout(() => this.init(), 500);
return;
}
console.log("[NWPU-Enhanced] 页面就绪,准备执行自动同步...");
sessionStorage.removeItem('nwpu_course_sync_trigger'); // 消耗标记
// 延迟 1秒 确保视觉上页面稳定,然后启动
setTimeout(() => {
this.startSyncProcess(true);
}, 1000);
}
// 5. 启动观察者
const observer = new MutationObserver(() => this.renderHistoryTags());
const target = document.querySelector('#table') || document.body;
observer.observe(target, { childList: true, subtree: true });
},
// --- 1. UI: 注入右侧控制面板 ---
injectControlPanel() {
if (document.getElementById('gm-lesson-helper-panel')) return;
const panel = document.createElement('div');
panel.id = 'gm-lesson-helper-panel';
panel.innerHTML = `
<div style="background:#f8f9fa; border-bottom:1px solid #dee2e6; padding:10px; border-radius:8px 8px 0 0; font-weight:bold; position:relative; cursor:move; user-select:none;" id="gm-panel-header">
选课助手
<span id="gm-panel-close" style="position:absolute; right:10px; color:#999; cursor:pointer; font-size:18px; line-height:1; font-weight:bold;" title="关闭面板 (刷新页面可恢复)">×</span>
</div>
<div style="padding:15px;">
<button id="gm-btn-sync-start" style="width:100%; padding:8px; background:#007bff; color:white; border:none; border-radius:4px; cursor:pointer; font-weight:bold; transition: background 0.2s;">存储当前学期课程信息</button>
<button id="gm-btn-clear-hist" style="width:100%; padding:8px; background:#dc3545; color:white; border:none; border-radius:4px; cursor:pointer; margin-top:10px; transition: background 0.2s;">清除所有记录</button>
<div style="margin-top:12px; font-size:12px; color:#666; line-height:1.5;">
建议在每轮选课开始前执行一次。
</div>
</div>
`;
panel.style.cssText = `position:fixed; top:120px; right:30px; z-index:99999; background:white; border:1px solid #ccc; border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,0.15); width:240px; font-size:14px; font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;`;
document.body.appendChild(panel);
// 绑定事件
const btnSync = document.getElementById('gm-btn-sync-start');
const btnClear = document.getElementById('gm-btn-clear-hist');
const btnClose = document.getElementById('gm-panel-close'); // 获取关闭按钮
// 关闭功能
btnClose.onclick = () => {
panel.style.display = 'none';
};
btnSync.onclick = () => this.startSyncProcess(false);
btnSync.onmouseover = () => btnSync.style.background = '#0056b3';
btnSync.onmouseout = () => btnSync.style.background = '#007bff';
btnClear.onclick = () => {
if(confirm('确定清空所有本地存储的课程历史数据吗?')) {
GM_setValue(this.CONFIG.HISTORY_KEY, '{}');
alert('已清空。');
this.renderHistoryTags();
}
};
btnClear.onmouseover = () => btnClear.style.background = '#c82333';
btnClear.onmouseout = () => btnClear.style.background = '#dc3545';
// 拖拽
const header = document.getElementById('gm-panel-header');
let isDragging = false, startX, startY, initialLeft, initialTop;
header.onmousedown = (e) => {
if(e.target === btnClose) return; // 点击关闭时不触发拖拽
isDragging = true; startX = e.clientX; startY = e.clientY;
const rect = panel.getBoundingClientRect(); initialLeft = rect.left; initialTop = rect.top;
};
document.onmousemove = (e) => {
if(!isDragging) return;
e.preventDefault();
panel.style.left = (initialLeft + e.clientX - startX) + 'px';
panel.style.top = (initialTop + e.clientY - startY) + 'px';
panel.style.right = 'auto';
};
document.onmouseup = () => isDragging = false;
},
// --- 2. Core: 同步逻辑 ---
async startSyncProcess(isAuto) {
if (!isAuto && !confirm('即将自动操作并开始执行抓取。\n过程可能需要几十秒,请勿关闭页面。')) return;
const overlay = this.showOverlay();
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const waitForLoad = async () => {
let limit = 0;
while(!document.querySelector(this.CONFIG.LOADER) && limit < 20) { await sleep(100); limit++; }
limit = 0;
while(document.querySelector(this.CONFIG.LOADER) && limit < 300) { await sleep(100); limit++; }
await sleep(300);
};
try {
const sizeBtn = document.querySelector(this.CONFIG.PAGE_SIZE_BTN);
if(sizeBtn) {
if(!sizeBtn.innerText.includes('1000')) {
this.updateOverlayStatus("正在切换每页显示数量...");
sizeBtn.click();
await sleep(500);
const maxOpt = document.querySelector(this.CONFIG.PAGE_SIZE_1000);
if(maxOpt) {
maxOpt.click();
await sleep(500);
await waitForLoad();
}
}
}
let page = 1;
let totalScraped = 0;
this.updateOverlayStatus(`准备开始抓取...`);
while(true) {
const count = this.scrapeCurrentPage();
totalScraped += count;
this.updateOverlay(totalScraped);
const nextIcon = document.querySelector(this.CONFIG.NEXT_BTN);
const nextBtn = nextIcon ? nextIcon.closest('button') : null;
if (!nextBtn || nextBtn.disabled || nextBtn.classList.contains('disabled')) break;
nextBtn.click();
page++;
await sleep(500);
await waitForLoad();
}
alert(`同步完成!\n\n共存储 ${totalScraped} 条课程数据。\n页面即将刷新以更新状态。`);
window.location.reload();
} catch(e) {
console.error(e);
alert('同步中断: ' + e.message);
overlay.remove();
}
},
// --- 3. Helper: 抓取与存储 ---
scrapeCurrentPage() {
const rows = document.querySelectorAll(this.CONFIG.TABLE_ROWS);
const data = [];
const timestamp = new Date().toLocaleString('zh-CN', { hour12: false });
rows.forEach(row => {
if(row.querySelector(this.CONFIG.LOADER)) return;
const idInput = row.querySelector('input[name="model_id"]');
if(!idInput) return;
const id = idInput.value;
const codeEl = row.querySelector('.lesson-code');
const nameEl = row.querySelector('.course-name');
const countSpan = row.querySelector('span[data-original-title="实际/上限人数"]');
if(countSpan) {
const match = countSpan.innerText.match(/(\d+)\/(\d+)/);
if(match) {
data.push({
id: id,
code: codeEl ? codeEl.innerText.trim() : 'N/A',
name: nameEl ? nameEl.innerText.trim() : 'N/A',
stdCount: parseInt(match[1]),
limitCount: parseInt(match[2]),
time: timestamp
});
}
}
});
if(data.length > 0) this.saveToHistory(data);
return data.length;
},
saveToHistory(courseData) {
let history = {};
try {
// 尝试解析旧数据
history = JSON.parse(GM_getValue(this.CONFIG.HISTORY_KEY, '{}'));
} catch (e) {
console.warn('[NWPU-Enhanced] 写入时发现历史数据损坏,已自动重置为空');
history = {}; // 解析失败则重置,防止阻碍新数据写入
}
courseData.forEach(c => {
if(!history[c.id]) history[c.id] = [];
const records = history[c.id];
const last = records[records.length-1];
// 只有当人数发生变化时才记录,节省空间
if(!last || last.stdCount !== c.stdCount || last.limitCount !== c.limitCount) {
records.push(c);
} else {
last.time = c.time; // 更新最后检测时间
}
});
// 保存回本地
GM_setValue(this.CONFIG.HISTORY_KEY, JSON.stringify(history));
// 刷新界面显示
this.renderHistoryTags();
},
// --- 4. UI: 渲染历史标签 ---
renderHistoryTags() {
let history = {};
try {
history = JSON.parse(GM_getValue(this.CONFIG.HISTORY_KEY, '{}'));
} catch (e) {
console.error('[NWPU-Enhanced] 读取历史记录失败(数据格式错误),已跳过渲染', e);
GM_setValue(this.CONFIG.HISTORY_KEY, '{}');
return;
}
const rows = document.querySelectorAll(this.CONFIG.TABLE_ROWS);
rows.forEach(row => {
if(row.dataset.gmProcessed) return;
const idInput = row.querySelector('input[name="model_id"]');
if(!idInput) return;
const records = history[idInput.value];
if(records && records.length > 0) {
const last = records[records.length-1];
const countSpan = Array.from(row.querySelectorAll('span')).find(s => s.getAttribute('data-original-title') === '实际/上限人数');
if(countSpan && !countSpan.parentNode.querySelector('.gm-hist-tag')) {
const tag = document.createElement('span');
tag.className = 'gm-hist-tag';
const isFull = last.stdCount >= last.limitCount;
const bgColor = isFull ? '#fff0f0' : '#e6ffec';
const textColor = isFull ? '#d32f2f' : '#1e7e34';
tag.style.cssText = `font-size:12px; color:${textColor}; background:${bgColor}; padding:1px 5px; border-radius:3px; margin-left:8px; border:1px solid ${textColor}40;`;
tag.innerText = `记录:${last.stdCount}/${last.limitCount}`;
tag.title = `上次同步时间: ${last.time}`;
countSpan.parentNode.appendChild(tag);
}
}
row.dataset.gmProcessed = 'true';
});
},
showOverlay() {
const div = document.createElement('div');
div.id = 'gm-sync-overlay';
div.style.cssText = 'position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.7); z-index:100000; color:white; display:flex; flex-direction:column; align-items:center; justify-content:center;';
div.innerHTML = `
<div style="font-size:24px; font-weight:bold; margin-bottom:15px;">正在同步课程数据...</div>
<div id="gm-overlay-status" style="font-size:16px; margin-bottom:10px; color:#ddd;">正在初始化...</div>
<div style="font-size:18px;">已抓取: <span id="gm-sync-count" style="color:#4facfe; font-weight:bold;">0</span> 条</div>
<div style="margin-top:30px; color:#aaa; font-size:14px;">请勿关闭页面,程序正在自动操作</div>
`;
document.body.appendChild(div);
return div;
},
updateOverlay(count) {
const el = document.getElementById('gm-sync-count');
if(el) el.innerText = count;
},
updateOverlayStatus(text) {
const el = document.getElementById('gm-overlay-status');
if(el) el.innerText = text;
}
};
if (window.location.href.includes('/student/for-std/lesson-search')) {
if (document.readyState === 'loading') {
window.addEventListener('DOMContentLoaded', () => LessonSearchEnhancer.init());
} else {
LessonSearchEnhancer.init();
}
}
// =-=-=-=-=-=-=-=-=-=-=-=-= 2.6 课程关注 =-=-=-=-=-=-=-=-=-=-=-=-=
/**
* 在开课查询页面的表格中注入关注按钮
*/
function injectFollowButtons() {
if (!ConfigManager.enableCourseWatch) return;
// --- 1. 初始化弹窗样式 (Toast) ---
if (!document.getElementById('gm-toast-style')) {
const style = document.createElement('style');
style.id = 'gm-toast-style';
style.textContent = `
.gm-toast {
position: fixed; top: 30px; left: 50%; transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8); color: #fff; padding: 12px 24px;
border-radius: 6px; font-size: 14px; z-index: 99999; font-weight: 500;
box-shadow: 0 4px 15px rgba(0,0,0,0.2); pointer-events: none;
opacity: 0; transition: opacity 0.3s, transform 0.3s;
display: flex; align-items: center; letter-spacing: 0.5px;
}
.gm-toast.show { opacity: 1; transform: translateX(-50%) translateY(10px); }
.gm-toast-icon { margin-right: 10px; font-size: 16px; font-weight: bold; }
`;
document.head.appendChild(style);
}
const showToast = (message, type = 'success') => {
const existing = document.querySelector('.gm-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = 'gm-toast';
const icon = type === 'success' ? '✔' : '✖';
const iconColor = type === 'success' ? '#67C23A' : '#F56C6C';
toast.innerHTML = `<span class="gm-toast-icon" style="color:${iconColor}">${icon}</span><span>${message}</span>`;
document.body.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('show'));
setTimeout(() => { toast.classList.remove('show'); setTimeout(() => toast.remove(), 300); }, 2000);
};
// --- 2. 获取学期信息 ---
let currentSemester = "未知学期";
try {
const semesterEl = document.querySelector('.selectize-control.semester .item') ||
document.querySelector('.semester-name') ||
document.querySelector('.selectize-input .item');
if (semesterEl) {
currentSemester = semesterEl.innerText.trim();
}
} catch(e) { console.warn("无法自动获取学期名称", e); }
// --- 3. 获取表格容器 ---
const scrollBodyTable = document.querySelector('.dataTables_scrollBody table#table');
if (!scrollBodyTable) return;
const rows = scrollBodyTable.querySelectorAll('tbody tr');
rows.forEach(row => {
if (row.querySelector('.dataTables_empty')) return;
const firstTd = row.querySelector('td:first-child');
const checkbox = row.querySelector('input[name="model_id"]');
if (!firstTd || !checkbox) return;
if (firstTd.querySelector('.gm-follow-btn')) return;
// --- 数据提取 ---
const storageId = checkbox.value;
const lessonCodeDiv = row.querySelector('.lesson-code');
const displayCode = lessonCodeDiv ? lessonCodeDiv.innerText.trim() : '未知编号';
const nameEl = row.querySelector('.course-name a');
const name = nameEl ? nameEl.innerText.trim() : '未知课程';
const teacherEl = row.querySelector('.course-teacher');
const teachers = teacherEl ? teacherEl.innerText.trim() : '';
const creditEl = row.children[3];
const credits = creditEl ? creditEl.innerText.trim() : '';
const placeEl = row.querySelector('.course-datetime-place');
const timeAndPlace = placeEl ? placeEl.innerText.trim() : '';
// --- 样式布局 ---
firstTd.style.display = 'flex';
firstTd.style.flexDirection = 'column';
firstTd.style.alignItems = 'center';
firstTd.style.justifyContent = 'center';
firstTd.style.padding = '8px 0';
firstTd.style.height = '100%';
checkbox.style.margin = '0';
// --- 创建按钮 ---
const btn = document.createElement('div');
btn.className = 'gm-follow-btn';
btn.innerHTML = '❤';
btn.style.cssText = `cursor: pointer; font-size: 20px; margin-top: 4px; line-height: 1; user-select: none; transition: all 0.2s; font-family: sans-serif;`;
const updateState = () => {
if (FollowManager.has(storageId)) {
btn.title = '点击取消关注';
btn.style.color = '#f56c6c';
btn.style.textShadow = '0 2px 5px rgba(245, 108, 108, 0.3)';
btn.style.opacity = '1';
btn.style.transform = 'scale(1.1)';
} else {
btn.title = '点击关注课程';
btn.style.color = '#dcdfe6';
btn.style.textShadow = 'none';
btn.style.opacity = '1';
btn.style.transform = 'scale(1)';
}
};
updateState();
btn.onmouseenter = () => { if (!FollowManager.has(storageId)) { btn.style.color = '#fbc4c4'; btn.style.transform = 'scale(1.1)'; } };
btn.onmouseleave = () => updateState();
btn.onclick = (e) => {
e.stopPropagation(); e.preventDefault();
btn.style.transform = 'scale(0.8)';
setTimeout(() => updateState(), 150);
if (FollowManager.has(storageId)) {
FollowManager.remove(storageId);
showToast(`已取消关注 ${displayCode}`, 'cancel');
} else {
FollowManager.add(storageId, {
id: storageId,
code: displayCode,
name, teachers, credits, timeAndPlace,
semester: currentSemester, // 【新增】保存当前学期
addedTime: new Date().toLocaleString()
});
showToast(`已加入关注列表 ${displayCode}`, 'success');
}
};
firstTd.appendChild(btn);
});
}
/**
* 启动开课查询页面的监听器
*/
function initLessonSearchPage() {
if (!ConfigManager.enableCourseWatch) return;
Logger.log("2.6", "已进入全校开课查询页面 (Iframe)");
// 初始执行一次
injectFollowButtons();
// 使用 MutationObserver 监听表格变化(翻页、搜索时触发)
const observer = new MutationObserver((mutations) => {
// 简单的防抖,避免频繁触发
injectFollowButtons();
});
const tableContainer = document.getElementById('e-content-area') || document.body;
observer.observe(tableContainer, {
childList: true,
subtree: true
});
}
// =-=-=-=-=-=-=-=-=-=-=-=-= 2.7 选课助手 =-=-=-=-=-=-=-=-=-=-=-=-=
if (window.location.href.includes('/course-selection')) {
(function() {
'use strict';
if (unsafeWindow.courseHelperInitialized) return;
unsafeWindow.courseHelperInitialized = true;
// ==============================================================================
// [1. 配置与核心变量]
// ==============================================================================
const API_URL_TEMPLATE = "https://jwxt.nwpu.edu.cn/student/for-std-lessons/info/";
const TARGET_CELL_SELECTOR = "td div.el-progress";
const UI_ELEMENT_CLASS = 'course-helper-ui-element';
const HISTORY_STORAGE_KEY = 'course_enrollment_history_auto_sync';
let courseCodeToLessonIdMap = null;
const originalFetch = unsafeWindow.fetch;
const originalXhrSend = unsafeWindow.XMLHttpRequest.prototype.send;
const originalXhrOpen = unsafeWindow.XMLHttpRequest.prototype.open;
// ==============================================================================
// [2. 网络拦截与数据解析]
// ==============================================================================
function cleanupAndReset() {
// 数据重置时,清空映射表
courseCodeToLessonIdMap = null;
}
function forceUpdateUI() {
if (!courseCodeToLessonIdMap) return;
// console.log('[选课助手] 数据更新,刷新UI...');
const tables = document.querySelectorAll('.el-table__body');
const currentMode = getSelectionMode();
tables.forEach(tableBody => {
if (tableBody.rows.length > 0) {
tableBody.querySelectorAll('tr.el-table__row').forEach(row => {
processRowWithCode(row, currentMode);
});
}
});
}
function processApiResponse(responseText) {
cleanupAndReset();
try {
const data = JSON.parse(responseText);
if (data && data.data && data.data.lessons) {
courseCodeToLessonIdMap = new Map(data.data.lessons.map(lesson => [lesson.code, lesson.id]));
}
} catch (e) {
console.error('[选课助手] 解析课程列表JSON时出错:', e);
}
// 数据准备好后,通知 UI 刷新
setTimeout(forceUpdateUI, 500);
}
// --- 1. 拦截 Fetch ---
unsafeWindow.fetch = function(...args) {
let [resource, config] = args;
// 兼容 resource 是 Request 对象的情况
const requestUrl = resource instanceof Request ? resource.url : resource;
// 检查是否是查询请求
if (requestUrl && requestUrl.includes('/query-lesson/')) {
// A. 尝试修改请求参数
if (config && config.body && typeof config.body === 'string') {
try {
const data = JSON.parse(config.body);
if (data.limit || data.pageSize) {
const TARGET = 100; // 设定目标数量
if(data.limit) data.limit = TARGET;
if(data.pageSize) data.pageSize = TARGET;
config.body = JSON.stringify(data);
}
} catch (e) {}
}
// B. 监听响应
return originalFetch.apply(this, args).then(response => {
const cloned = response.clone();
cloned.text().then(text => processApiResponse(text));
return response;
});
}
return originalFetch.apply(this, args);
};
// --- 2. 拦截 XHR ---
unsafeWindow.XMLHttpRequest.prototype.open = function(method, url) {
this._gm_url = url; // 保存 URL 供 send 使用
return originalXhrOpen.apply(this, arguments);
};
// 拦截 Send 修改分页参数
unsafeWindow.XMLHttpRequest.prototype.send = function(data) {
this.addEventListener('load', function() {
if (this.responseURL && this.responseURL.includes('/query-lesson/')) {
processApiResponse(this.responseText);
}
}, { once: true });
if (this._gm_url && this._gm_url.includes('/query-lesson/')) {
try {
if (typeof data === 'string') {
let jsonData = JSON.parse(data);
// 强制修改 limit / pageSize
if (jsonData.hasOwnProperty('limit') || jsonData.hasOwnProperty('pageSize')) {
const TARGET_LIMIT = 50;
if (jsonData.limit) jsonData.limit = TARGET_LIMIT;
if (jsonData.pageSize) jsonData.pageSize = TARGET_LIMIT;
// 重新打包数据
data = JSON.stringify(jsonData);
}
}
} catch (e) {
// 静默失败
}
}
return originalXhrSend.apply(this, [data]);
};
// ==============================================================================
// [3. 辅助功能函数]
// ==============================================================================
function getSelectionMode() {
const semesterSpan = document.querySelector('div.course-select-semester > span');
if (!semesterSpan) return 'unknown';
const modeText = semesterSpan.textContent || '';
if (modeText.includes('直选')) return 'direct';
else return 'wishlist';
}
function injectDirectSelectionUI(row, lessonId) {
fetch(`${API_URL_TEMPLATE}${lessonId}`)
.then(response => response.ok ? response.text() : Promise.reject(`HTTP error! status: ${response.status}`))
.then(htmlString => {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, "text/html");
let releaseCount = -1;
const ths = doc.querySelectorAll('th');
for (const th of ths) {
if (th.textContent.trim() === '待释放保留人数') {
const td = th.nextElementSibling;
if (td) { releaseCount = parseInt(td.textContent.trim(), 10) || 0; }
break;
}
}
const targetCell = row.querySelector(TARGET_CELL_SELECTOR);
if (targetCell && targetCell.parentElement) {
const existingElement = targetCell.parentElement.querySelector(`.${UI_ELEMENT_CLASS}`);
if (existingElement) existingElement.remove();
if (releaseCount > 0) {
const displayElement = document.createElement('div');
displayElement.className = UI_ELEMENT_CLASS;
displayElement.textContent = `待释放保留人数: ${releaseCount}`;
Object.assign(displayElement.style, {
color: '#E65100', fontWeight: 'bold', fontSize: '13px',
marginTop: '6px', textShadow: '0 0 5px rgba(255, 193, 7, 0.5)'
});
targetCell.parentElement.appendChild(displayElement);
}
}
})
.catch(error => { /* 静默 */ });
}
async function injectWishlistUI(row, lessonId) {
// 读取存储的历史数据
const historyJSON = await GM_getValue(HISTORY_STORAGE_KEY, '{}');
let history = {};
try {
history = JSON.parse(historyJSON);
} catch(e) { history = {}; }
const courseHistory = history[lessonId];
const targetContainer = row.querySelector('td:nth-child(5) > .cell'); // 适配 ElementUI 表格列
if (targetContainer) {
const existingElement = targetContainer.querySelector(`.${UI_ELEMENT_CLASS}`);
if (existingElement) existingElement.remove();
if (courseHistory && courseHistory.length > 0) {
const latestRecord = courseHistory[courseHistory.length - 1];
const { stdCount, limitCount, time } = latestRecord;
const isFull = stdCount >= limitCount;
const displayElement = document.createElement('span');
displayElement.className = UI_ELEMENT_CLASS;
displayElement.textContent = ` (上次记录: ${stdCount}/${limitCount})`;
displayElement.title = `同步于 ${time}`;
Object.assign(displayElement.style, {
color: isFull ? '#dc3545' : '#28a745',
fontWeight: 'bold', fontSize: '12px', marginLeft: '5px'
});
targetContainer.appendChild(displayElement);
}
}
}
function injectCollapseControl() {
const cols = document.querySelectorAll('.el-col.el-col-24');
let targetContainer = null;
for (const col of cols) {
if (col.innerText.includes('【主修】')) {
targetContainer = col;
break;
}
}
if (!targetContainer) return; // 未找到目标
if (document.getElementById('gm-collapse-toggle-btn')) return; // 防止重复
const btn = document.createElement('button');
btn.id = 'gm-collapse-toggle-btn';
// 样式调整:
btn.className = 'el-button el-button--primary el-button--small';
btn.innerHTML = '<i class="el-icon-s-operation"></i> 全部展开/折叠';
// CSS调整:右浮动 + 阴影 + 字体加粗
btn.style.cssText = `
float: right; /* 靠最右侧 */
margin-right: 5px; /* 右侧留一点缝隙 */
margin-top: 5px; /* 微调垂直位置,使其垂直居中 */
font-weight: bold; /* 字体加粗 */
font-size: 14px; /* 字体加大 */
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.5); /* 添加蓝色光晕阴影,增加显眼度 */
transition: all 0.3s;
`;
// 4. 绑定点击逻辑
let isExpanded = true;
btn.onclick = (e) => {
e.stopPropagation();
isExpanded = !isExpanded;
// 添加点击动画效果
btn.style.transform = 'scale(0.95)';
setTimeout(() => btn.style.transform = 'scale(1)', 150);
document.querySelectorAll('.course-module').forEach(mod => {
const icon = mod.querySelector('i');
if (!icon) return;
const isOpen = icon.classList.contains('el-icon-caret-bottom');
if ((isExpanded && !isOpen) || (!isExpanded && isOpen)) {
mod.click();
}
});
btn.innerHTML = isExpanded ? '<i class="el-icon-folder-opened"></i> 全部折叠' : '<i class="el-icon-folder"></i> 全部展开';
btn.blur();
};
// 5. 插入 DOM
targetContainer.appendChild(btn);
}
// ==============================================================================
// [4. 核心逻辑]
// ==============================================================================
function processRowWithCode(row, mode) {
let courseCode = null;
// 1. 尝试获取课程代码
const accurateCodeElement = row.querySelector('div.lesson-code > a.link-url');
if (accurateCodeElement) {
courseCode = accurateCodeElement.textContent.trim();
} else {
const fallbackCodeElement = row.querySelector('td:first-child span.el-tooltip');
if (fallbackCodeElement) courseCode = fallbackCodeElement.textContent.trim();
}
// 2. [Diff 检查]:防止重复渲染导致的闪烁
// 如果当前行已经标记了代码,且代码未变,说明是同一行,仅更新状态颜色,不重绘 DOM
if (row.dataset.gmCurrentCode === courseCode) {
const existingBtn = row.querySelector('.gm-follow-btn');
// 如果按钮存在且挂载了 updateState 方法,直接调用更新颜色
if (existingBtn && existingBtn.updateState) {
existingBtn.updateState();
}
return;
}
// 3. [清理旧状态]:如果代码变了(说明翻页了,DOM 被复用),清除旧的样式和元素
if (row.dataset.gmCurrentCode) {
row.style.backgroundColor = '';
row.style.boxShadow = '';
row.style.transition = '';
const nameEl = row.querySelector('.course-name');
if (nameEl) {
nameEl.style.fontWeight = '';
nameEl.style.color = '';
}
row.querySelectorAll('.gm-follow-btn, .course-helper-ui-element').forEach(el => el.remove());
delete row.dataset.gmCurrentCode;
}
// 4. [注入新状态]
if (courseCode && courseCodeToLessonIdMap && courseCodeToLessonIdMap.has(courseCode)) {
// 标记当前行归属
row.dataset.gmCurrentCode = courseCode;
const lessonId = courseCodeToLessonIdMap.get(courseCode);
const nameEl = row.querySelector('.course-name');
// --- 注入交互式关注按钮 ---
if (nameEl) {
// 补全样式
if (!document.getElementById('gm-toast-style')) {
const style = document.createElement('style');
style.id = 'gm-toast-style';
style.textContent = `.gm-toast{position:fixed;top:30px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.8);color:#fff;padding:12px 24px;border-radius:6px;font-size:14px;z-index:99999;font-weight:500;box-shadow:0 4px 15px rgba(0,0,0,0.2);pointer-events:none;opacity:0;transition:opacity 0.3s,transform 0.3s;display:flex;align-items:center;}.gm-toast.show{opacity:1;transform:translateX(-50%) translateY(10px);}.gm-toast-icon{margin-right:10px;font-size:16px;font-weight:bold;}`;
document.head.appendChild(style);
}
const showToast = (message, type = 'success') => {
const existing = document.querySelector('.gm-toast'); if (existing) existing.remove();
const toast = document.createElement('div'); toast.className = 'gm-toast';
const iconColor = type === 'success' ? '#67C23A' : '#F56C6C';
toast.innerHTML = `<span class="gm-toast-icon" style="color:${iconColor}">${type === 'success' ? '✔' : '✖'}</span><span>${message}</span>`;
document.body.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('show'));
setTimeout(() => { toast.classList.remove('show'); setTimeout(() => toast.remove(), 300); }, 2000);
};
const btn = document.createElement('span');
btn.className = 'gm-follow-btn';
btn.innerHTML = '❤';
btn.style.cssText = `cursor: pointer; font-size: 18px; margin-left: 8px; line-height: 1; user-select: none; transition: all 0.2s; display: inline-block; vertical-align: middle;`;
btn.title = "点击关注课程";
// 挂载状态更新函数
btn.updateState = () => {
if (typeof FollowManager !== 'undefined' && FollowManager.has(lessonId)) {
// 已关注样式:深红、加粗、粉背景、内阴影
btn.title = '点击取消关注';
btn.style.color = '#f56c6c';
btn.style.textShadow = '0 0 8px rgba(245, 108, 108, 0.4)';
btn.style.transform = 'scale(1.2)';
nameEl.style.fontWeight = 'bold';
nameEl.style.color = '#d93025';
row.style.backgroundColor = '#ffebeb';
row.style.boxShadow = 'inset 5px 0 0 #f56c6c';
} else {
// 未关注样式:浅灰
btn.title = '点击关注课程';
btn.style.color = '#e4e7ed';
btn.style.textShadow = 'none';
btn.style.transform = 'scale(1)';
nameEl.style.fontWeight = '';
nameEl.style.color = '';
row.style.backgroundColor = '';
row.style.boxShadow = '';
}
};
btn.updateState(); // 初始化调用
// 绑定点击事件
btn.onclick = (e) => {
e.stopPropagation();
if (typeof FollowManager === 'undefined') { alert('功能未加载'); return; }
if (FollowManager.has(lessonId)) {
FollowManager.remove(lessonId);
showToast('已取消关注', 'cancel');
} else {
// --- 数据抓取 ---
let teachers = '待定', credits = '-', timeAndPlace = '-';
try {
// 教师:第3列
const teacherEl = row.querySelector('td:nth-child(3) .course-teacher');
if (teacherEl) teachers = teacherEl.innerText.replace(/[\r\n]+/g, ' ').trim();
// 时间地点:第4列
const placeEl = row.querySelector('td:nth-child(4) .dateTimePlace');
if (placeEl) {
const tooltipDiv = placeEl.querySelector('.tooltip-dateTimePlace span');
timeAndPlace = (tooltipDiv ? tooltipDiv.innerText : placeEl.innerText).replace(/[\r\n]+/g, '; ').trim();
}
// 学分:第1列下方
const infoEl = row.querySelector('td:nth-child(1) .text-color-6');
if (infoEl) {
const creditMatch = infoEl.innerText.match(/([\d\.]+)学分/);
if (creditMatch) credits = creditMatch[1];
}
} catch(err) {}
// --- 学期提取 (从页面标题) ---
let targetSemester = '选课页面关注';
try {
const semesterEl = document.querySelector('span[title*="选课"]');
if (semesterEl) {
const rawText = semesterEl.getAttribute('title') || semesterEl.innerText;
const match = rawText.match(/(\d{4}-\d{4}[春夏秋冬])/);
if (match) targetSemester = match[1];
}
} catch (e) {}
FollowManager.add(lessonId, {
id: lessonId, code: courseCode, name: nameEl.innerText.replace('❤', '').trim(),
teachers, credits, timeAndPlace, semester: targetSemester, addedTime: new Date().toLocaleString()
});
showToast(`已关注 ${courseCode}`, 'success');
}
btn.updateState();
};
btn.onmouseenter = () => { if(!FollowManager.has(lessonId)) btn.style.color = '#fbc4c4'; };
btn.onmouseleave = () => { if(!FollowManager.has(lessonId)) btn.style.color = '#e4e7ed'; };
nameEl.appendChild(btn);
}
// --- 注入其他辅助信息 ---
if (mode === 'direct') {
injectDirectSelectionUI(row, lessonId);
} else {
injectWishlistUI(row, lessonId);
}
}
}
// ==============================================================================
// [5. 初始化]
// ==============================================================================
function main() {
let debounceTimer = null;
const mainObserver = new MutationObserver(() => {
if (debounceTimer) clearTimeout(debounceTimer);
// 50ms 防抖,检测到 DOM 变动停止后执行 UI 更新
debounceTimer = setTimeout(() => {
if(courseCodeToLessonIdMap) forceUpdateUI();
injectCollapseControl();
}, 50);
});
const container = document.getElementById('app-content') || document.body;
mainObserver.observe(container, { childList: true, subtree: true });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', main, { once: true });
} else {
main();
}
})();
}
// =-=-=-=-=-=-=-=-=-=-=-=-= 2.8 后台静默数据同步 =-=-=-=-=-=-=-=-=-=-=-=-=
const BackgroundSyncSystem = {
WORKER_NAME: 'gm_bg_sync_worker_frame',
// 主控逻辑
initController() {
const lastSync = GM_getValue(CONSTANTS.LAST_SYNC_TIME_KEY, 0);
const now = Date.now();
if (now - lastSync < CONSTANTS.SYNC_COOLDOWN_MS) {
const remainingMs = CONSTANTS.SYNC_COOLDOWN_MS - (now - lastSync);
const remainingMins = Math.ceil(remainingMs / 1000 / 60);
Logger.log("2.8", `处于冷却期,下次自动同步需等待 ${remainingMins} 分钟`);
return;
}
Logger.log("2.8", "准备创建后台 Iframe...");
isBackgroundSyncing = true;
updateMenuButtonsState(isDataReady);
const oldFrame = document.getElementById('gm_bg_sync_frame');
if (oldFrame) oldFrame.remove();
const iframe = document.createElement('iframe');
iframe.id = 'gm_bg_sync_frame';
iframe.name = this.WORKER_NAME;
iframe.src = `https://jwxt.nwpu.edu.cn/student/for-std/lesson-search`;
iframe.style.cssText = `position: fixed; top: 0; left: -15000px; width: 1440px; height: 900px; border: none; visibility: visible; z-index: -100;`;
document.body.appendChild(iframe);
const messageHandler = (event) => {
if (event.data && event.data.type === 'GM_BG_SYNC_COMPLETE') {
Logger.log("2.8", `后台同步完成。抓取: ${event.data.count}`);
if (event.data.count > 0) GM_setValue(CONSTANTS.LAST_SYNC_TIME_KEY, Date.now());
isBackgroundSyncing = false;
updateMenuButtonsState(isDataReady);
setTimeout(() => {
const frame = document.getElementById('gm_bg_sync_frame');
if (frame) frame.remove();
}, 2000);
window.removeEventListener('message', messageHandler);
}
};
window.addEventListener('message', messageHandler);
},
// Worker 逻辑
startWorker() {
Logger.info("Sync-Worker", "启动");
let allCourseData = [];
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// 等待Loading遮罩消失
const waitForLoading = async () => {
let limit = 0;
while(!document.querySelector('td.dataTables_empty') && limit < 5) { await sleep(100); limit++; }
limit = 0;
while(document.querySelector('td.dataTables_empty') && limit < 200) { await sleep(100); limit++; }
await sleep(500);
};
// 解析当前页面的表格行
const scrapeCurrentPage = (currentSemester) => {
const rows = document.querySelectorAll('#table tbody tr');
const pageData = [];
rows.forEach(row => {
try {
const idInput = row.querySelector('input[name="model_id"]');
if (!idInput) return;
const id = idInput.value;
const codeEl = row.querySelector('.lesson-code');
const code = codeEl ? codeEl.innerText.trim() : '';
const nameEl = row.querySelector('.course-name');
const name = nameEl ? nameEl.innerText.trim() : '';
const teacherEl = row.querySelector('.course-teacher');
const teachers = teacherEl ? teacherEl.innerText.trim() : '待定';
const creditEl = row.children[3];
const credits = creditEl ? creditEl.innerText.trim() : '';
const placeEl = row.querySelector('.course-datetime-place');
let timeAndPlace = placeEl ? placeEl.innerText.replace(/\n/g, '; ').trim() : '详见课表';
const countSpan = row.querySelector('span[data-original-title="实际/上限人数"]');
let stdCount = 0;
let limitCount = 0;
if (countSpan) {
const match = countSpan.innerText.trim().match(/(\d+)\/(\d+)/);
if (match) {
stdCount = parseInt(match[1], 10);
limitCount = parseInt(match[2], 10);
}
}
pageData.push({
id, code, name, teachers, credits, timeAndPlace, stdCount, limitCount,
semester: currentSemester,
updateTime: Date.now()
});
} catch (e) {
console.error("行解析错误:", e);
}
});
return pageData;
};
// 自动化执行流程
const runAutomation = async () => {
try {
let maxRetries = 60;
while (maxRetries > 0) {
if (document.querySelector('.page-config .dropdown-toggle')) break;
await sleep(500); maxRetries--;
}
if (maxRetries <= 0) throw new Error("页面加载超时");
// ================== 1. 切换到最新学期 ==================
let activeSemesterName = "未知学期";
const semesterInput = document.querySelector('.selectize-control.semester .selectize-input');
if (semesterInput) {
semesterInput.click();
await sleep(500);
const firstOption = document.querySelector('.selectize-dropdown-content .option:first-child');
if (firstOption) {
const targetSemester = firstOption.innerText.trim();
const currentSemester = semesterInput.innerText.trim();
if (targetSemester !== currentSemester && !currentSemester.startsWith(targetSemester)) {
firstOption.click();
await sleep(500);
await waitForLoading();
activeSemesterName = targetSemester;
} else {
activeSemesterName = currentSemester.split('\n')[0];
document.body.click();
}
}
}
Logger.log("2.8", `锁定抓取学期: ${activeSemesterName}`);
// ================== 2. 切换到 1000 条/页 ==================
const pageSizeBtn = document.querySelector('.page-config .dropdown-toggle');
if (pageSizeBtn && !pageSizeBtn.innerText.includes('1000')) {
pageSizeBtn.click(); await sleep(500);
const maxOption = document.querySelector('.page-config .dropdown-menu a[value="1000"]');
if (maxOption) {
maxOption.click();
await waitForLoading();
}
}
// ================== 3. 翻页抓取循环 ==================
let pageIndex = 1;
while (true) {
await waitForLoading();
const pageData = scrapeCurrentPage(activeSemesterName);
allCourseData = allCourseData.concat(pageData);
const nextIcon = document.querySelector('.semi-auto-table-paginator .fa-angle-right');
const nextBtn = nextIcon ? nextIcon.closest('button') : null;
if (!nextBtn || nextBtn.disabled || nextBtn.classList.contains('disabled')) {
break;
}
nextBtn.click();
pageIndex++;
await sleep(2000);
}
Logger.log("2.8", `全部完成! 存储 ${allCourseData.length} 条。`);
GM_setValue(CONSTANTS.BACKGROUND_SYNC_KEY, JSON.stringify(allCourseData));
window.top.postMessage({ type: 'GM_BG_SYNC_COMPLETE', count: allCourseData.length }, '*');
} catch (err) {
console.error("[Worker] 异常:", err);
window.top.postMessage({ type: 'GM_BG_SYNC_COMPLETE', count: 0 }, '*');
}
};
setTimeout(runAutomation, 1500);
}
};
// =-=-=-=-=-=-=-=-=-=-=-=-= 2.9 培养方案课程代码智能预览 =-=-=-=-=-=-=-=-=-=-=-=-=
function initProgramPageEnhancement() {
// 检查功能开关
if (!ConfigManager.enableCourseWatch) {
return;
}
console.log("[NWPU-Enhanced] 初始化培养方案课程预览");
// 1. 数据准备
const bgDataStr = GM_getValue('jwxt_background_sync_data'); // 使用硬编码Key
if (!bgDataStr) return;
let courseDB;
try { courseDB = JSON.parse(bgDataStr); } catch(e) { return; }
if (!courseDB || courseDB.length === 0) return;
// 构建索引 (Parent Code -> List of Courses)
const courseMap = new Map();
courseDB.forEach(c => {
if (!c.code) return;
// 提取课程代码前缀 (例如 U14M11003.01 -> U14M11003)
const parentCode = c.code.trim().split('.')[0];
if (!courseMap.has(parentCode)) courseMap.set(parentCode, []);
courseMap.get(parentCode).push(c);
});
// 定义高清 SVG 图标
const svgs = {
book: `<svg viewBox="0 0 1024 1024" width="18" height="18" style="vertical-align:-4px;fill:#409EFF"><path d="M832 160H256c-52.9 0-96 43.1-96 96v576c0 52.9 43.1 96 96 96h576c17.7 0 32-14.3 32-32V192c0-17.7-14.3-32-32-32zm-40 640H256c-17.7 0-32-14.3-32-32s14.3-32 32-32h536v64zM256 224h536v320H256V224z"></path></svg>`,
user: `<svg viewBox="0 0 1024 1024" width="14" height="14" style="fill:#909399;margin-right:6px;"><path d="M512 512c141.4 0 256-114.6 256-256S653.4 0 512 0 256 114.6 256 256s114.6 256 256 256zm0 64c-170.7 0-512 85.3-512 256v64c0 17.7 14.3 32 32 32h960c17.7 0 32-14.3 32-32v-64c0-170.7-341.3-256-512-256z"></path></svg>`,
pin: `<svg viewBox="0 0 1024 1024" width="14" height="14" style="fill:#909399;margin-right:6px;"><path d="M512 0C323.8 0 170.7 153.1 170.7 341.3c0 176.3 194.2 460.5 285.4 584.2 24.3 32.9 73.5 32.9 97.8 0 91.2-123.7 285.4-407.9 285.4-584.2C853.3 153.1 700.2 0 512 0zm0 512c-94.3 0-170.7-76.4-170.7-170.7S417.7 170.7 512 170.7 682.7 247.1 682.7 341.3 606.3 512 512 512z"></path></svg>`
};
// 2. 注入美化后的 CSS
if (!document.getElementById('gm-program-tooltip-style')) {
const style = document.createElement('style');
style.id = 'gm-program-tooltip-style';
style.textContent = `
/* 课程代码高亮样式 */
.gm-course-code-highlight {
border-bottom: 2px dashed #409EFF;
color: #409EFF;
font-weight: 600;
cursor: pointer;
background-color: rgba(64, 158, 255, 0.08);
padding: 1px 4px;
border-radius: 4px;
transition: all 0.2s;
}
.gm-course-code-highlight:hover {
background-color: rgba(64, 158, 255, 0.2);
color: #0056b3;
}
/* 弹窗容器 - 磨砂玻璃质感 */
.gm-program-tooltip {
position: fixed; z-index: 100001;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(10px);
border: 1px solid rgba(0,0,0,0.06);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
border-radius: 12px; padding: 0;
width: 440px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
display: none; opacity: 0; transition: opacity 0.2s ease, transform 0.2s ease;
pointer-events: auto;
transform: translateY(5px);
}
.gm-program-tooltip.show { display: block; opacity: 1; transform: translateY(0); }
/* 头部样式 */
.gm-pt-header {
background: linear-gradient(to right, #f9fafc, #ffffff);
padding: 14px 20px;
border-bottom: 1px solid #ebeef5;
font-weight: 700; color: #303133; font-size: 15px;
display:flex; justify-content:space-between; align-items: center;
border-radius: 12px 12px 0 0;
letter-spacing: 0.5px;
}
.gm-pt-badge {
font-weight:normal; color:#409EFF; font-size:12px;
background:rgba(64, 158, 255, 0.1);
padding:4px 10px; border-radius:20px;
}
/* 列表区域 */
.gm-pt-list { max-height: 420px; overflow-y: auto; padding: 0; }
/* 滚动条美化 */
.gm-pt-list::-webkit-scrollbar { width: 6px; }
.gm-pt-list::-webkit-scrollbar-track { background: transparent; }
.gm-pt-list::-webkit-scrollbar-thumb { background-color: #dcdfe6; border-radius: 3px; }
/* 单个课程卡片 */
.gm-pt-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #f2f4f7;
transition: background-color 0.2s;
}
.gm-pt-item:last-child { border-bottom: none; }
.gm-pt-item:hover { background-color: #f0f7ff; }
/* 左侧信息区 */
.gm-pt-info { flex: 1; min-width: 0; padding-right: 15px; }
.gm-pt-title {
font-weight: 600; font-size: 15px; color: #303133;
margin-bottom: 6px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.gm-pt-code { font-size: 12px; color: #909399; font-family: Consolas, monospace; margin-bottom: 8px; }
.gm-pt-meta { display: flex; flex-direction: column; gap: 4px; color: #606266; font-size: 13px; }
.gm-pt-row { display: flex; align-items: center; }
/* 右侧操作区 */
.gm-pt-action {
display: flex; flex-direction: column; align-items: flex-end; gap: 8px; flex-shrink: 0;
}
/* 人数胶囊标签 */
.gm-pt-stat {
font-family: Consolas, monospace; font-size: 13px; font-weight: bold;
padding: 3px 8px; border-radius: 4px;
}
.gm-tag-full { color: #F56C6C; background: #fef0f0; border: 1px solid #fde2e2; }
.gm-tag-avail { color: #67C23A; background: #f0f9eb; border: 1px solid #e1f3d8; }
/* 关注按钮 */
.gm-pt-btn {
cursor: pointer; font-size: 22px; color: #dcdfe6; line-height: 1;
transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
display: flex; align-items: center; justify-content: center;
width: 32px; height: 32px; border-radius: 50%;
}
.gm-pt-btn:hover { transform: scale(1.15); background-color: #fff; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.gm-pt-btn.is-active { color: #f56c6c !important; text-shadow: 0 2px 5px rgba(245, 108, 108, 0.3); }
`;
document.head.appendChild(style);
}
let tooltip = document.querySelector('.gm-program-tooltip');
if(!tooltip) {
tooltip = document.createElement('div');
tooltip.className = 'gm-program-tooltip';
document.body.appendChild(tooltip);
}
// 3. 全局事件委托 (处理悬停和点击)
let hideTimer = null;
document.body.addEventListener('mouseover', function(e) {
if (e.target.classList.contains('gm-course-code-highlight')) {
if (hideTimer) clearTimeout(hideTimer);
const code = e.target.getAttribute('data-code');
showTooltip(e.target, code);
}
else if (e.target.closest('.gm-program-tooltip')) {
if (hideTimer) clearTimeout(hideTimer);
}
});
document.body.addEventListener('mouseout', function(e) {
if (e.target.classList.contains('gm-course-code-highlight') || e.target.closest('.gm-program-tooltip')) {
hideTimer = setTimeout(() => { tooltip.classList.remove('show'); }, 300);
}
});
document.body.addEventListener('dblclick', function(e) {
// 检查是否点击了高亮的代码块
if (e.target.classList.contains('gm-course-code-highlight')) {
const code = e.target.getAttribute('data-code');
// 使用剪贴板 API
navigator.clipboard.writeText(code).then(() => {
// 视觉反馈:变为绿色并闪烁一下
const originalTransition = e.target.style.transition;
const originalBg = e.target.style.backgroundColor;
const originalColor = e.target.style.color;
e.target.style.transition = 'all 0.1s';
e.target.style.backgroundColor = '#f0f9eb';
e.target.style.color = '#67C23A';
e.target.textContent = '已复制!'; // 临时改变文字提示
setTimeout(() => {
e.target.textContent = code; // 恢复文字
e.target.style.backgroundColor = originalBg;
e.target.style.color = originalColor;
e.target.style.transition = originalTransition;
}, 800);
}).catch(err => {
console.error('复制失败:', err);
alert('复制失败,请手动复制');
});
// 阻止选中文本的默认行为
e.preventDefault();
window.getSelection().removeAllRanges();
}
});
// 处理关注按钮点击
document.body.addEventListener('click', function(e) {
const btn = e.target.closest('.gm-pt-btn');
if (btn) {
e.stopPropagation();
handleFollowClick(btn);
}
});
function handleFollowClick(btn) {
const id = btn.dataset.id.toString();
const semester = btn.dataset.semester && btn.dataset.semester !== "undefined"
? btn.dataset.semester
: '从培养方案页关注';
const data = {
id: id,
code: btn.dataset.code,
name: btn.dataset.name,
semester: semester,
teachers: btn.dataset.teachers,
credits: btn.dataset.credits || '-',
timeAndPlace: btn.dataset.place,
addedTime: new Date().toLocaleString()
};
if (FollowManager.has(id)) {
FollowManager.remove(id);
btn.classList.remove('is-active');
btn.style.color = '#dcdfe6';
} else {
FollowManager.add(id, data);
btn.classList.add('is-active');
btn.style.color = '#f56c6c';
}
}
// 4. DOM 扫描 (将普通文本转换为高亮节点)
function processCells() {
const cells = document.querySelectorAll('td');
cells.forEach(td => {
if (td.dataset.gmProcessed) return;
const rawText = td.textContent;
if (!rawText) return;
const text = rawText.trim();
// 简单的正则匹配课程代码 (大写字母开头,包含数字,长度适中)
if (text.length >= 5 && text.length <= 15 && /^[A-Z][A-Z0-9]+$/.test(text)) {
if (courseMap.has(text)) {
td.dataset.gmProcessed = "true";
td.innerHTML = `<span class="gm-course-code-highlight" data-code="${text}" title="双击复制课程代码">${text}</span>`;
}
}
});
}
// 5. 显示浮层 (生成HTML)
function showTooltip(targetEl, code) {
const courses = courseMap.get(code) || [];
const rect = targetEl.getBoundingClientRect();
let contentHTML = '';
if (courses.length === 0) {
contentHTML = '<div style="padding:30px;text-align:center;color:#909399;font-size:13px;">本学期暂无开课记录</div>';
} else {
contentHTML = `<div class="gm-pt-list">`;
courses.forEach(c => {
const isFull = c.stdCount >= c.limitCount;
const countClass = isFull ? 'gm-tag-full' : 'gm-tag-avail';
const isFollowed = FollowManager.has(c.id);
const activeClass = isFollowed ? 'is-active' : '';
const initColor = isFollowed ? '#f56c6c' : '#dcdfe6';
const teacherText = c.teachers || '待定';
const placeText = c.timeAndPlace || '详见课表';
contentHTML += `
<div class="gm-pt-item">
<div class="gm-pt-info">
<div class="gm-pt-title" title="${c.name}">${c.name}</div>
<div class="gm-pt-code">${c.code}</div>
<div class="gm-pt-meta">
<div class="gm-pt-row">${svgs.user} <span>${teacherText}</span></div>
<div class="gm-pt-row">${svgs.pin} <span>${placeText}</span></div>
</div>
</div>
<div class="gm-pt-action">
<div class="gm-pt-stat ${countClass}">
${c.stdCount}/${c.limitCount}
</div>
<div class="gm-pt-btn ${activeClass}" style="color:${initColor}"
data-id="${c.id}" data-code="${c.code}" data-name="${c.name}"
data-teachers="${teacherText}" data-place="${placeText}"
data-credits="${c.credits || ''}"
data-semester="${c.semester}"
title="${isFollowed ? '取消关注' : '关注此班级'}">❤</div>
</div>
</div>
`;
});
contentHTML += `</div>`;
}
tooltip.innerHTML = `
<div class="gm-pt-header">
<span style="display:flex;align-items:center;gap:8px">${svgs.book} <span style="font-family:Consolas, monospace;font-size:16px;">${code}</span></span>
<span class="gm-pt-badge">本学期 ${courses.length} 个班级</span>
</div>
${contentHTML}
`;
// 智能定位
const viewportHeight = window.innerHeight;
const tooltipHeight = Math.min(500, courses.length * 90 + 125); // 估算高度
let top = rect.bottom + 8;
// 如果底部放不下,就放上面
if (rect.bottom + tooltipHeight > viewportHeight) {
top = rect.top - tooltipHeight - 10;
if(top < 10) top = 10; // 防止溢出顶部
}
// 水平定位
let left = rect.left + 80;
if (left + 440 > window.innerWidth) {
left = window.innerWidth - 450;
}
tooltip.style.left = left + 'px';
tooltip.style.top = top + 'px';
tooltip.classList.add('show');
}
// 观察页面变化,动态处理新加载的内容
const observer = new MutationObserver(() => {
if(window.gm_program_timer) clearTimeout(window.gm_program_timer);
window.gm_program_timer = setTimeout(processCells, 200);
});
const targetNode = document.querySelector('.main-content') || document.body;
observer.observe(targetNode, { childList: true, subtree: true });
// 初始执行
setTimeout(processCells, 500);
setTimeout(processCells, 1500);
}
// =-=-=-=-=-=-=-=-=-=-=-=-= 2.10 选课时间提醒 =-=-=-=-=-=-=-=-=-=-=-=-=
function initScheduleWidget() {
// ================= 配置区域 (维护请修改此处) =================
const SCHEDULE_CONFIG = {
// 插件整体失效时间 (超过此时间不再显示)
EXPIRATION_DATE: '2026-03-07T00:00:00',
// 本地存储Key (用于不再提醒)
STORAGE_KEY: 'jwxt_schedule_table_closed_2026_spring',
// 选课地址
COURSE_URL: 'https://jwxt.nwpu.edu.cn/student/for-std/course-select',
// 提前N小时提示同步数据
PRE_NOTIFY_HOURS: 16,
// 选课阶段配置 (支持自动生成表格)
// type: 'positive' (正选) | 'makeup' (补选/其他) -> 用于判断是否触发考前数据同步提示
GROUPS: [
{
groupName: '正选', // 表格第一列名称
phases: [
{ name: '第一轮', type: 'positive', start: '2026-01-12T14:00:00', end: '2026-01-15T12:00:00', method: '意愿值选课', scope: '主修专业课' },
{ name: '第二轮', type: 'positive', start: '2026-01-19T14:00:00', end: '2026-01-21T12:00:00', method: '意愿值选课', scope: '学期教学计划全部课程' },
{ name: '第三轮', type: 'positive', start: '2026-01-23T08:00:00', end: '2026-01-25T12:00:00', method: '直选选课', scope: '学期教学计划全部课程' }
]
},
{
groupName: '补选',
phases: [
{ name: '补选阶段', type: 'makeup', start: '2026-03-02T09:00:00', end: '2026-03-06T16:00:00', method: '系统中申请', scope: '学期开设的全部课程' },
{ name: '本研共选', type: 'makeup', start: '2026-03-02T09:00:00', end: '2026-03-06T16:00:00', method: '直选选课', scope: '学期开设的本研共选课程' }
]
}
]
};
// ===========================================================
const showWidget = () => {
if (GM_getValue(SCHEDULE_CONFIG.STORAGE_KEY, false) === true) return;
// 避免单次页面刷新内重复关闭后弹出
if (window.gm_schedule_manually_closed) return;
const now = Date.now();
const expirationTime = new Date(SCHEDULE_CONFIG.EXPIRATION_DATE).getTime();
if (now > expirationTime) return;
if (document.querySelector('.gm-schedule-box')) return;
// --- 1. 计算当前状态 & 构建表格行 ---
let statusHtml = '<span style="color: #909399;">当前未处于选课时段</span>';
let showPreSyncLink = false; // 是否显示同步链接
let tableRowsHtml = '';
// 扁平化遍历所有阶段以检查时间
let activePhaseFound = false;
SCHEDULE_CONFIG.GROUPS.forEach((group, gIndex) => {
group.phases.forEach((phase, pIndex) => {
const startTime = new Date(phase.start).getTime();
const endTime = new Date(phase.end).getTime();
const preStartTime = startTime - (SCHEDULE_CONFIG.PRE_NOTIFY_HOURS * 60 * 60 * 1000);
// A. 检查状态: 进行中
if (!activePhaseFound && now >= startTime && now <= endTime) {
statusHtml = `当前处于 <span style="color: #f56c6c; font-weight: bold; border-bottom: 2px solid #f56c6c;">${group.groupName} - ${phase.name}</span>`;
activePhaseFound = true;
}
// B. 检查状态: 即将开始 (正选前N小时提示)
else if (!activePhaseFound && phase.type === 'positive' && now >= preStartTime && now < startTime) {
const hoursLeft = Math.ceil((startTime - now) / 3600000);
statusHtml = `<span style="color: #E65100; font-weight:bold;">${group.groupName}${phase.name}</span> 将于 ${hoursLeft} 小时后开始。` +
`<span id="gm-sch-pre-sync" style="color:#409EFF; cursor:pointer; text-decoration:underline; font-weight:bold; margin-left:10px;">[建议您点击此处记录课程内置情况]</span>`;
showPreSyncLink = true;
activePhaseFound = true;
}
// C. 构建表格行
// 格式化时间显示 (移除年份,保留 月-日 时:分)
const formatTime = (isoStr) => {
const d = new Date(isoStr);
return `${d.getMonth() + 1}月${d.getDate()}日 ${d.getHours()}点`;
};
const timeStr = `${formatTime(phase.start)} 至 ${formatTime(phase.end)}`;
tableRowsHtml += `<tr>`;
// 处理第一列的 Rowspan (合并单元格)
if (pIndex === 0) {
const borderStyle = gIndex > 0 ? 'border-top:2px solid #ebeef5;' : '';
tableRowsHtml += `<td rowspan="${group.phases.length}" style="font-weight:bold; ${borderStyle}">${group.groupName}</td>`;
}
// 高亮选课方式
const methodClass = phase.method.includes('意愿值') || phase.method.includes('直选') ? 'gm-sch-highlight' : '';
tableRowsHtml += `
<td>${phase.name}</td>
<td>${timeStr}</td>
<td class="${methodClass}">${phase.method}</td>
<td>${phase.scope}</td>
</tr>`;
});
});
// --- 2. 注入样式 ---
if (!document.getElementById('gm-schedule-table-style')) {
const style = document.createElement('style');
style.id = 'gm-schedule-table-style';
style.textContent = `
.gm-schedule-box {
position: fixed; left: 20px; bottom: 20px; z-index: 9999;
background: #fff; padding: 12px; border-radius: 8px;
box-shadow: 0 4px 25px rgba(0,0,0,0.15);
border: 1px solid #dcdfe6;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
animation: gmSlideUp 0.5s ease-out;
width: auto; max-width: 680px;
}
.gm-status-bar {
text-align: center; background: #fdf6ec; padding: 8px;
border-radius: 4px; margin-bottom: 10px; font-size: 13px;
border: 1px inset #faecd8; line-height: 1.5;
}
.gm-sch-table {
width: 100%; border-collapse: collapse; font-size: 12px; color: #333;
margin-bottom: 10px; border: 1px solid #ebeef5;
}
.gm-sch-table th, .gm-sch-table td {
border: 1px solid #ebeef5; padding: 6px 8px; text-align: center; vertical-align: middle;
}
.gm-sch-table th { background-color: #f5f7fa; font-weight: bold; color: #606266; }
.gm-sch-highlight { color: #409EFF; font-weight: bold; }
.gm-schedule-footer {
display: flex; justify-content: space-between; align-items: center;
font-size: 12px; color: #909399; margin-top: 8px;
}
.gm-sch-btn-group { display: flex; gap: 10px; }
.gm-schedule-btn {
border: none; padding: 5px 12px; border-radius: 4px; cursor: pointer;
font-size: 12px; transition: opacity 0.2s; color: white;
}
.gm-btn-close { background: #f56c6c; }
.gm-btn-go { background: #409EFF; }
.gm-schedule-btn:hover { opacity: 0.8; }
#gm-sch-pre-sync:hover { color: #66b1ff; }
@keyframes gmSlideUp { from { transform: translateY(30px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
`;
document.head.appendChild(style);
}
// --- 3. 构建容器 HTML ---
const div = document.createElement('div');
div.className = 'gm-schedule-box';
div.innerHTML = `
<div style="font-weight:bold; font-size:14px; margin-bottom:8px; color:#303133; text-align:center;">
选课时间安排表
</div>
<div class="gm-status-bar">${statusHtml}</div>
<table class="gm-sch-table">
<thead>
<tr><th>选课阶段</th><th>选课轮次</th><th>时间安排</th><th>选课方式</th><th>课程范围</th></tr>
</thead>
<tbody>
${tableRowsHtml}
</tbody>
</table>
<div class="gm-schedule-footer">
<label style="cursor:pointer; display:flex; align-items:center; user-select:none;">
<input type="checkbox" id="gm-schedule-check" style="margin-right:6px;">
不再显示此安排
</label>
<div class="gm-sch-btn-group">
<button class="gm-schedule-btn gm-btn-go" id="gm-schedule-go-btn">进入选课</button>
<button class="gm-schedule-btn gm-btn-close" id="gm-schedule-close-btn">关闭</button>
</div>
</div>
`;
document.body.appendChild(div);
// --- 4. 事件绑定 ---
// 绑定同步数据的点击事件
if (showPreSyncLink) {
const syncLink = document.getElementById('gm-sch-pre-sync');
if (syncLink) {
syncLink.onclick = () => {
// 调用全局定义的同步函数
if (typeof handleSyncCourseClick === 'function') {
handleSyncCourseClick();
} else {
alert("同步功能初始化中,请稍后再试。");
}
};
}
}
// 跳转选课页面
document.getElementById('gm-schedule-go-btn').onclick = () => {
window.location.href = SCHEDULE_CONFIG.COURSE_URL;
};
// 关闭
document.getElementById('gm-schedule-close-btn').onclick = () => {
if (document.getElementById('gm-schedule-check').checked) {
GM_setValue(SCHEDULE_CONFIG.STORAGE_KEY, true);
}
window.gm_schedule_manually_closed = true;
div.remove();
};
};
const hideWidget = () => {
const box = document.querySelector('.gm-schedule-box');
if (box) box.remove();
};
// 监控页面变化
setInterval(() => {
const iframes = document.querySelectorAll('iframe');
let hasActiveSubPage = false;
for (let f of iframes) {
// 忽略插件自己创建的 iframe
if (f.id && (f.id.startsWith('gm_') || f.style.visibility === 'hidden')) continue;
// 检测是否有可见的大型iframe覆盖
if (f.offsetParent !== null && f.offsetHeight > 300 && f.offsetWidth > 300) {
hasActiveSubPage = true;
break;
}
}
if (window.location.href.includes('/student/home') && !hasActiveSubPage) {
showWidget();
} else {
hideWidget();
}
}, 1000); // 稍微放宽检查间隔
// 首次立即检查
if (window.location.href.includes('/student/home')) showWidget();
}
// =-=-=-=-=-=-=-=-=-=-=-=-= 2.11 自动评教模块 =-=-=-=-=-=-=-=-=-=-=-=-=
function initEvaluationHelper() {
const IS_TEST_MODE = false; // 正式使用请设为 false
if (window.gm_eval_observer_started) return;
window.gm_eval_observer_started = true;
// --- 基础工具 ---
const waitForElement = (selector, timeout = 5000) => {
return new Promise((resolve) => {
if (document.querySelector(selector)) return resolve(document.querySelector(selector));
const observer = new MutationObserver(() => {
if (document.querySelector(selector)) {
observer.disconnect();
resolve(document.querySelector(selector));
}
});
observer.observe(document.body, { childList: true, subtree: true });
setTimeout(() => { observer.disconnect(); resolve(null); }, timeout);
});
};
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// 模拟输入事件,确保Vue响应
const triggerInputEvent = (element, value) => {
if (!element) return;
element.focus();
element.value = value;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
element.blur();
};
// --- 1. 注入 CSS ---
if (!document.getElementById('gm-eval-style')) {
const style = document.createElement('style');
style.id = 'gm-eval-style';
style.textContent = `
.gm-modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 20000; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); }
.gm-modal-content { background: #fff; border-radius: 12px; width: 720px; max-height: 85vh; display: flex; flex-direction: column; box-shadow: 0 12px 40px rgba(0,0,0,0.25); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; animation: gmFadeIn 0.25s ease-out; border: 1px solid #ebeef5; }
.gm-modal-header { padding: 18px 24px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; background: #fff; border-radius: 12px 12px 0 0; }
.gm-modal-title { font-size: 18px; font-weight: 700; color: #303133; letter-spacing: 0.5px; }
.gm-close-btn { width: 30px; height: 30px; border-radius: 50%; border: none; background: transparent; display: flex; align-items: center; justify-content: center; cursor: pointer; color: #909399; transition: all 0.2s; }
.gm-close-btn:hover { background-color: #f56c6c; color: #fff; transform: rotate(90deg); }
.gm-close-btn svg { width: 16px; height: 16px; fill: currentColor; }
.gm-eval-body { padding: 20px; overflow-y: auto; flex: 1; background: #f5f7fa; }
.gm-course-group { background: #fff; border: 1px solid #ebeef5; border-radius: 8px; margin-bottom: 15px; box-shadow: 0 2px 8px rgba(0,0,0,0.03); overflow: hidden; }
.gm-course-header { background: #eef5fe; padding: 10px 15px; border-bottom: 1px solid #ebeef5; font-weight: bold; color: #409EFF; font-size: 14px; display: flex; align-items: center; gap: 8px; justify-content: space-between;}
.gm-course-status-tag { font-size: 12px; font-weight: normal; padding: 2px 8px; border-radius: 10px; }
.gm-tag-done { background: #f0f9eb; color: #67C23A; border: 1px solid #e1f3d8; }
.gm-tag-todo { background: #fdf6ec; color: #E6A23C; border: 1px solid #faecd8; }
.gm-teacher-row { display: flex; align-items: center; padding: 12px 15px; border-bottom: 1px solid #f2f2f2; transition: background 0.2s; }
.gm-teacher-row:last-child { border-bottom: none; }
.gm-teacher-row:hover { background: #fafafa; }
.gm-teacher-row.gm-row-done { background: #fcfcfc; color: #999; }
.gm-t-name { flex: 1; font-size: 14px; color: #606266; margin-left: 10px; font-weight: 500; display: flex; align-items: center; gap: 5px; }
.gm-row-done .gm-t-name { color: #a8abb2; text-decoration: line-through; }
.gm-done-badge { font-size: 12px; color: #67C23A; border: 1px solid #67C23A; padding: 0 4px; border-radius: 3px; transform: scale(0.9); text-decoration: none; display: inline-block;}
.gm-score-input { width: 80px; padding: 6px 8px; border: 1px solid #dcdfe6; border-radius: 4px; text-align: center; font-family: Consolas, monospace; transition: 0.2s; margin-right: 15px; }
.gm-score-input:focus { border-color: #409EFF; outline: none; box-shadow: 0 0 0 2px rgba(64,158,255,0.2); }
.gm-score-input:disabled { background: #f5f7fa; color: #c0c4cc; cursor: not-allowed; border-color: #e4e7ed; }
.gm-checkbox { cursor: pointer; width: 16px; height: 16px; accent-color: #409EFF; }
.gm-checkbox:disabled { cursor: not-allowed; opacity: 0.5; }
.gm-status-box { width: 70px; text-align: right; font-size: 12px; }
.gm-modal-footer { padding: 16px 24px; border-top: 1px solid #eee; background: #fff; border-radius: 0 0 12px 12px; display: flex; justify-content: space-between; align-items: center; gap: 15px; }
.gm-btn { padding: 9px 20px; border-radius: 6px; border: none; font-size: 14px; cursor: pointer; font-weight: 500; transition: 0.2s; display: inline-flex; align-items: center; gap: 6px; }
.gm-btn-primary { background: #409EFF; color: white; }
.gm-btn-primary:hover { background: #66b1ff; }
.gm-btn-warning { background: #E6A23C; color: white; }
.gm-btn-warning:hover { background: #ebb563; }
.gm-btn:disabled { opacity: 0.6; cursor: not-allowed; background: #e4e7ed; color: #909399; }
.gm-status-pending { color: #909399; }
.gm-status-running { color: #409EFF; font-weight: bold; }
.gm-status-success { color: #67C23A; font-weight: bold; }
.gm-status-error { color: #F56C6C; }
@keyframes gmFadeIn { from { opacity: 0; transform: scale(0.98); } to { opacity: 1; transform: scale(1); } }
`;
document.head.appendChild(style);
}
// --- 2. 抓取任务 ---
function scrapeTasks() {
const tasks = [];
let idCounter = 0;
const rows = document.querySelectorAll('.el-table__body-wrapper tbody tr');
rows.forEach(row => {
const courseNameEl = row.querySelector('.coursename .name') || row.querySelector('td:nth-child(2)');
const courseName = courseNameEl ? courseNameEl.innerText.replace(/\s+/g, ' ').trim() : '未知课程';
const successTag = row.querySelector('.el-tag--success');
const isRowComplete = successTag && successTag.innerText.includes('已完成');
const links = row.querySelectorAll('a');
links.forEach(link => {
const isSubmitted = link.innerText.includes('已评') || link.classList.contains('submitted');
const isDisabled = link.classList.contains('is-disabled');
if (link.innerText.length > 1 && !isDisabled) {
tasks.push({
id: ++idCounter,
course: courseName,
teacher: link.innerText.trim(),
element: link,
isDone: isSubmitted,
courseIsDone: isRowComplete,
status: isSubmitted ? 'done' : 'pending'
});
}
});
});
return tasks;
}
// --- 3. 显示主面板 ---
const showEvalModal = () => {
if (document.getElementById('gm-eval-modal')) return;
const taskList = scrapeTasks();
const courseGroups = {};
taskList.forEach(task => {
if (!courseGroups[task.course]) courseGroups[task.course] = [];
courseGroups[task.course].push(task);
});
const overlay = document.createElement('div');
overlay.id = 'gm-eval-modal';
overlay.className = 'gm-modal-overlay';
const pendingCount = taskList.filter(t => !t.isDone).length;
overlay.innerHTML = `
<div class="gm-modal-content">
<div class="gm-modal-header">
<div class="gm-modal-title">自动评教功能 <span style="font-size:12px;font-weight:normal;color:#999;margin-left:10px;">待评任务: ${pendingCount}</span></div>
<button class="gm-close-btn" id="gm-eval-close" title="关闭">
<svg viewBox="0 0 1024 1024"><path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm165.4 618.2l-66-.3L512 563.4l-99.3 118.4-66.1.3c-4.4 0-8-3.5-8-8 0-1.9.7-3.7 1.9-5.2l130.1-155L340.5 359a8.32 8.32 0 0 1-1.9-5.2c0-4.4 3.6-8 8-8l66.1.3L512 464.6l99.3-118.4 66-.3c4.4 0 8 3.5 8 8 0 1.9-.7 3.7-1.9 5.2L553.5 514l130 155c1.2 1.5 1.9 3.3 1.9 5.2 0 4.4-3.6 8-8 8z"></path></svg>
</button>
</div>
<div class="gm-eval-body" id="gm-eval-container">
<div style="margin-bottom:10px;display:flex;justify-content:flex-end;">
<label style="font-size:13px;color:#606266;cursor:pointer;display:flex;align-items:center;">
<input type="checkbox" id="gm-check-all-available" style="margin-right:5px;"> 全选所有待评任务
</label>
</div>
</div>
<div class="gm-modal-footer">
<div style="flex:1;"></div>
<div style="display:flex; gap:10px;">
<button id="gm-btn-min-eval" class="gm-btn gm-btn-warning" title="跳过已完成课程,未完成课程只评第一个">
⚡ 自动完成最低评教
</button>
<button id="gm-btn-run-selected" class="gm-btn gm-btn-primary">
▶ 开始评教
</button>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
const container = document.getElementById('gm-eval-container');
if (Object.keys(courseGroups).length === 0) {
container.innerHTML = '<div style="text-align:center;padding:50px;color:#999;">当前没有评教任务</div>';
document.getElementById('gm-btn-min-eval').disabled = true;
document.getElementById('gm-btn-run-selected').disabled = true;
} else {
for (const [courseName, teachers] of Object.entries(courseGroups)) {
const hasDone = teachers.some(t => t.isDone) || (teachers.length > 0 && teachers[0].courseIsDone);
const statusTag = hasDone
? `<span class="gm-course-status-tag gm-tag-done">最低要求已达成</span>`
: `<span class="gm-course-status-tag gm-tag-todo">未完成</span>`;
const groupDiv = document.createElement('div');
groupDiv.className = 'gm-course-group';
let teachersHtml = '';
teachers.forEach(t => {
const rowClass = t.isDone ? 'gm-teacher-row gm-row-done' : 'gm-teacher-row';
const nameBadge = t.isDone ? '<span class="gm-done-badge">已完成</span>' : '';
const statusText = t.isDone ? '<span class="gm-status-success">已提交</span>' : '<span class="gm-status-pending">待评</span>';
const disabledAttr = t.isDone ? 'disabled' : '';
const inputPlaceholder = t.isDone ? '-' : '分数';
teachersHtml += `
<div class="${rowClass}">
<input type="checkbox" class="gm-item-check gm-checkbox" data-id="${t.id}" ${disabledAttr}>
<div class="gm-t-name">${t.teacher} ${nameBadge}</div>
<input type="number" class="gm-score-input" data-id="${t.id}" id="score-${t.id}" placeholder="${inputPlaceholder}" min="0" max="100" ${disabledAttr}>
<div class="gm-status-box"><span id="status-${t.id}">${statusText}</span></div>
</div>
`;
});
groupDiv.innerHTML = `
<div class="gm-course-header">
<span>${courseName}</span>
${statusTag}
</div>
<div class="gm-teacher-list">
${teachersHtml}
</div>
`;
container.appendChild(groupDiv);
}
}
const btnMin = document.getElementById('gm-btn-min-eval');
const btnRun = document.getElementById('gm-btn-run-selected');
const checkAll = document.getElementById('gm-check-all-available');
document.getElementById('gm-eval-close').onclick = () => overlay.remove();
checkAll.onchange = (e) => {
document.querySelectorAll('.gm-item-check:not(:disabled)').forEach(cb => cb.checked = e.target.checked);
};
document.querySelectorAll('.gm-score-input:not(:disabled)').forEach(input => {
input.oninput = function() {
const id = this.getAttribute('data-id');
const cb = document.querySelector(`.gm-item-check[data-id="${id}"]`);
if (cb) cb.checked = true;
};
});
const fillFormExact = (targetScore) => {
const groups = document.querySelectorAll('.el-radio-group');
const questions = [];
let maxTotalScore = 0;
// 1. 扫描题目结构
groups.forEach((group, index) => {
const options = group.querySelectorAll('.el-radio');
if (options.length === 0) return;
const text = options[0].innerText || "";
let maxPoints = 5;
let step = 1;
if (text.includes("10分")) {
maxPoints = 10;
step = 2;
}
maxTotalScore += maxPoints;
questions.push({
domOptions: options,
maxPoints: maxPoints,
step: step,
currentIdx: 0
});
});
if (targetScore > maxTotalScore) targetScore = maxTotalScore;
if (targetScore < 0) targetScore = 0;
let pointsToLose = maxTotalScore - targetScore;
// 2. 算法扣分
// Phase A: 扣除奇数分 (找5分题)
if (pointsToLose % 2 !== 0) {
const q5 = questions.find(q => q.step === 1);
if (q5) {
q5.currentIdx = 1;
pointsToLose -= 1;
}
}
// Phase B: 扣除偶数分 (优先10分题)
for (let q of questions) {
if (pointsToLose <= 0) break;
const remainingSteps = (q.domOptions.length - 1) - q.currentIdx;
const maxDeductable = remainingSteps * q.step;
if (maxDeductable > 0) {
let deduct = Math.min(pointsToLose, maxDeductable);
const stepsToMove = deduct / q.step;
q.currentIdx += stepsToMove;
pointsToLose -= deduct;
}
}
// 3.深度点击执行
questions.forEach(q => {
const targetOption = q.domOptions[q.currentIdx] || q.domOptions[q.domOptions.length - 1];
if (targetOption) {
// 尝试找到内部真正的 input 元素
const internalInput = targetOption.querySelector('input.el-radio__original');
if (internalInput) {
internalInput.click(); // 原生点击
// 双重保险:手动派发变更事件,确保 Vue Model 更新
internalInput.checked = true;
internalInput.dispatchEvent(new Event('change', { bubbles: true }));
} else {
// 降级:点击 Label
targetOption.click();
}
}
});
// 4. 填星星
document.querySelectorAll('.el-rate').forEach(group => {
const stars = group.querySelectorAll('.el-rate__item');
let starIdx = stars.length - 1;
if (targetScore < 90) starIdx = Math.max(0, stars.length - 2);
if (stars[starIdx]) stars[starIdx].click();
});
// 5. 填评语
const comments = ["老师授课认真,重点突出。", "教学严谨,对学生负责。", "课堂氛围好,讲解生动。", "深入浅出,受益匪浅。", "理论联系实际,收获很大。"];
document.querySelectorAll('textarea').forEach(area => {
const randomComment = comments[Math.floor(Math.random() * comments.length)];
triggerInputEvent(area, randomComment);
});
};
// --- 核心执行函数 ---
const executeTasks = async (tasksToRun) => {
if (tasksToRun.length === 0) {
alert("没有选中任何任务!");
return;
}
btnMin.disabled = true;
btnRun.disabled = true;
document.querySelectorAll('input').forEach(i => i.disabled = true);
let downgradedCourses = [];
for (let i = 0; i < tasksToRun.length; i++) {
const task = tasksToRun[i];
if (task.isDone) continue;
const statusEl = document.getElementById(`status-${task.id}`);
const inputVal = document.getElementById(`score-${task.id}`).value;
let scoreVal = inputVal ? parseInt(inputVal) : 95;
statusEl.className = 'gm-status-running';
statusEl.innerText = '准备进入...';
const rowEl = statusEl.closest('.gm-teacher-row');
if(rowEl) rowEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
try {
// ★ 重新寻找DOM (Fix Stale Element)
let activeLink = null;
const allRows = document.querySelectorAll('.el-table__body-wrapper tbody tr');
for (let tr of allRows) {
const courseText = tr.innerText;
if (courseText.includes(task.course) && courseText.includes(task.teacher)) {
const links = tr.querySelectorAll('a');
for (let link of links) {
if (link.innerText.includes(task.teacher) && !link.classList.contains('is-disabled')) {
activeLink = link;
break;
}
}
}
if (activeLink) break;
}
if (!activeLink) activeLink = task.element;
activeLink.click();
statusEl.innerText = '加载表单...';
const formReady = await waitForElement('.el-radio-group', 15000);
if (!formReady) {
// 重试点击
activeLink.click();
const retryReady = await waitForElement('.el-radio-group', 10000);
if (!retryReady) throw new Error("表单加载超时");
}
await sleep(1000);
// 1. 填表
statusEl.innerText = '正在填表...';
fillFormExact(scoreVal);
await sleep(1500);
// 2. 提交
let submitBtn = null;
const btnGroup = document.getElementById('btn-group');
if (btnGroup) {
const btns = btnGroup.querySelectorAll('button');
for (let btn of btns) {
if (btn.textContent.includes('提交') && !btn.textContent.includes('匿名')) {
submitBtn = btn;
break;
}
}
}
if (submitBtn) {
// 如果按钮还禁用,重试填表
if (submitBtn.disabled || submitBtn.classList.contains('is-disabled')) {
fillFormExact(scoreVal);
await sleep(1000);
}
statusEl.innerText = '提交中...';
submitBtn.click();
const msgBox = await waitForElement('.el-message-box', 5000);
// 检查是否有错误提示 (500 Error 会弹 toast 或 message-box)
const errorToast = document.querySelector('.el-message--error');
if (errorToast) {
throw new Error("服务器返回错误(500),可能是提交过快");
}
if (msgBox) {
const text = msgBox.innerText || "";
const confirmBtn = msgBox.querySelector('.el-button--primary');
// 场景 A:20% 限制
if (text.includes('20%') || text.includes('不得超过') || text.includes('优秀')) {
statusEl.innerText = '限制触发, 降分...';
if (confirmBtn) confirmBtn.click();
await sleep(1000);
scoreVal = 89;
downgradedCourses.push(`${task.course}`);
fillFormExact(89);
await sleep(1500);
if (!submitBtn.disabled) {
submitBtn.click();
const confirmBox2 = await waitForElement('.el-message-box__btns', 5000);
if (confirmBox2) {
const finalOk = confirmBox2.querySelector('.el-button--primary');
if (finalOk) finalOk.click();
}
}
}
// 场景 B:普通确认
else {
if (confirmBtn) confirmBtn.click();
}
}
// 等待返回列表
statusEl.innerText = '等待返回...';
await waitForElement('.el-table__body-wrapper', 15000);
await sleep(1500);
statusEl.className = 'gm-status-success';
statusEl.innerText = `完成(${scoreVal})`;
} else {
throw new Error("未找到提交按钮");
}
} catch (e) {
console.error(e);
statusEl.className = 'gm-status-error';
statusEl.innerText = '失败';
const backBtn = Array.from(document.querySelectorAll('button')).find(b => b.innerText.includes('取消') || b.innerText.includes('返回'));
if (backBtn) {
backBtn.click();
await waitForElement('.el-table__body-wrapper', 5000);
}
await sleep(2000);
}
}
btnMin.innerText = "流程结束";
btnRun.innerText = "流程结束";
let finishMsg = "所有任务处理完成!";
if (downgradedCourses.length > 0) {
finishMsg += `\n\n⚠️ 检测到优秀率限制,以下课程已自动降为 89 分:\n` + downgradedCourses.join('\n');
}
finishMsg += "\n\n建议刷新页面更新状态。是否刷新?";
if (confirm(finishMsg)) {
window.location.reload();
}
};
// --- 功能 A: 自动完成最低评教 ---
btnMin.onclick = () => {
document.querySelectorAll('.gm-item-check').forEach(c => c.checked = false);
document.querySelectorAll('.gm-score-input').forEach(i => {
if(!i.disabled) i.value = '';
});
const itemsToRun = [];
let skippedCourses = 0;
for (const [courseName, teachers] of Object.entries(courseGroups)) {
const alreadyDone = teachers.some(t => t.isDone) || (teachers.length > 0 && teachers[0].courseIsDone);
if (alreadyDone) {
skippedCourses++;
continue;
}
if (teachers.length > 0) {
const target = teachers[0];
if (target.isDone) continue;
const checkbox = document.querySelector(`.gm-item-check[data-id="${target.id}"]`);
const scoreInput = document.getElementById(`score-${target.id}`);
if (checkbox && scoreInput && !checkbox.disabled) {
checkbox.checked = true;
// 随机 80 - 89 分
scoreInput.value = Math.floor(Math.random() * 10) + 80;
itemsToRun.push(target);
}
}
}
if (itemsToRun.length === 0) {
alert(`没有待处理的最低评教任务。\n\n已跳过 ${skippedCourses} 门已完成(或部分完成)的课程。`);
return;
}
if (confirm(`即将对 ${itemsToRun.length} 门课程进行最低标准评教(每门课评1人,随机80-89分)。\n\n是否开始?`)) {
executeTasks(itemsToRun);
}
};
// --- 功能 B: 开始评教---
btnRun.onclick = () => {
const selectedIds = Array.from(document.querySelectorAll('.gm-item-check:checked'))
.filter(cb => !cb.disabled)
.map(cb => parseInt(cb.dataset.id));
const itemsToRun = taskList.filter(t => selectedIds.includes(t.id));
if (itemsToRun.length === 0) {
alert("请至少勾选一个待评任务!");
return;
}
let hasEmptyScore = false;
itemsToRun.forEach(t => {
const val = document.getElementById(`score-${t.id}`).value;
if (!val) hasEmptyScore = true;
});
let msg = `即将对 ${itemsToRun.length} 位教师进行评教。`;
if (hasEmptyScore) msg += `\n\n⚠️ 注意:部分未填分,默认按 95分 (优秀) 处理。`;
msg += `\n\n是否开始?`;
if (confirm(msg)) {
executeTasks(itemsToRun);
}
};
};
// --- 4. 入口按钮 ---
const injectPageButton = () => {
const targetContainer = document.querySelector('.el-tab-pane .el-select') || document.querySelector('.el-form');
if (!targetContainer || document.getElementById('gm-page-eval-btn')) return;
const btn = document.createElement('button');
btn.id = 'gm-page-eval-btn';
btn.className = 'el-button el-button--primary el-button--small';
btn.innerHTML = `<i class="el-icon-s-cooperation"></i> 打开自动评教`;
btn.style.cssText = 'margin-left: 15px; vertical-align: top; height: 32px; font-weight: bold; box-shadow: 0 2px 6px rgba(64,158,255, 0.3);';
if (targetContainer.parentNode) targetContainer.parentNode.insertBefore(btn, targetContainer.nextSibling);
else targetContainer.appendChild(btn);
btn.onclick = showEvalModal;
};
const startObserve = () => {
let debounceTimer = null;
const observer = new MutationObserver(() => {
// 使用防抖,避免频繁触发
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
if (location.href.includes('evaluation-student-frontend')) injectPageButton();
}, CONSTANTS.DEBOUNCE_DELAY);
});
observer.observe(document.body, { childList: true, subtree: true });
injectPageButton();
};
if (document.body) startObserve();
else window.addEventListener('load', startObserve);
}
// =-=-=-=-=-=-=-=-=-=-=-=-= 2.12 人员信息检索模块 =-=-=-=-=-=-=-=-=-=-=-=-=
const PersonnelSearch = {
STORAGE_KEY: "nwpu_synced_token",
API_BASE: CONSTANTS.API_PERSONNEL,
state: { page: 1, loading: false, hasMore: true, keyword: "" },
// 1. Token 同步逻辑 (运行在 ecampus 域名下)
syncToken() {
if (location.host !== 'ecampus.nwpu.edu.cn') return;
const checkAndSave = () => {
const token = localStorage.getItem('token');
if (token) {
// 只要获取到token,就强制更新存储,确保是最新的
GM_setValue(this.STORAGE_KEY, token);
}
};
// 立即执行一次
checkAndSave();
// 稍微延时再执行一次,确保 iframe 加载完全
setTimeout(checkAndSave, 500);
setTimeout(checkAndSave, 2000);
},
// 2. 打开界面的主入口
openModal() {
Logger.log('2.12', "初始化人员信息检索");
// 先检查本地是否有 Token
const token = GM_getValue(this.STORAGE_KEY);
// === 分支 A: 有 Token,直接打开界面 ===
if (token) {
if (document.getElementById('gm-person-search-overlay')) return;
this.injectStyles();
this.createUI();
this.resetState();
return;
}
// === 分支 B: 无 Token,启动后台静默同步 ===
this._startSilentSync();
},
// 内部方法:执行静默同步
_startSilentSync() {
// 1. 显示提示
this._showToast("正在后台获取授权,请稍候...");
// 2. 创建隐形 iframe
const iframe = document.createElement('iframe');
iframe.src = 'https://ecampus.nwpu.edu.cn'; // 目标地址
iframe.style.display = 'none';
iframe.id = 'gm-sync-iframe-worker';
document.body.appendChild(iframe);
// 3. 轮询检测 Token 是否到位
let attempts = 0;
const maxAttempts = 15; // 约 7.5 秒超时
const timer = setInterval(() => {
const newToken = GM_getValue(this.STORAGE_KEY);
if (newToken) {
// [成功] 拿到 Token 了!
clearInterval(timer);
this._cleanupSync();
this._showToast("授权成功!正在打开界面...", 1000);
setTimeout(() => this.openModal(), 500); // 递归调用打开界面
} else {
// [等待] 还没拿到...
attempts++;
if (attempts >= maxAttempts) {
// [超时] 可能是没登录,或者网络太慢
clearInterval(timer);
this._cleanupSync();
this._removeToast();
if(confirm("后台自动同步超时(可能是您未登录翱翔门户)。\n\n是否打开新窗口手动登录?")) {
window.open('https://ecampus.nwpu.edu.cn', '_blank');
}
}
}
}, 500); // 每 500ms 检查一次
},
// 辅助:清理同步用的临时元素
_cleanupSync() {
const frame = document.getElementById('gm-sync-iframe-worker');
if (frame) frame.remove();
},
// 辅助:显示 Toast 提示
_showToast(msg, duration = 0) {
let toast = document.getElementById('gm-search-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'gm-search-toast';
toast.style.cssText = 'position:fixed; top:20%; left:50%; transform:translateX(-50%); background:rgba(0,0,0,0.75); color:white; padding:12px 24px; border-radius:30px; font-size:14px; z-index:100020; transition:opacity 0.3s; box-shadow:0 4px 15px rgba(0,0,0,0.2); pointer-events:none;';
document.body.appendChild(toast);
}
toast.innerText = msg;
toast.style.opacity = '1';
if (duration > 0) {
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, duration);
}
},
_removeToast() {
const toast = document.getElementById('gm-search-toast');
if(toast) toast.remove();
},
// 3. 注入样式 (含黑白大号学号样式)
injectStyles() {
if (document.getElementById('gm-person-search-style')) return;
const style = document.createElement('style');
style.id = 'gm-person-search-style';
style.textContent = `
.gm-modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.6); z-index: 10005; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(2px); }
.gm-modal-content { background-color: #fff; border-radius: 6px; width: 95%; max-width: 1200px; height: 90vh; max-height: 950px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); display: flex; flex-direction: column; overflow: hidden; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; animation: gmFadeIn 0.2s ease-out; }
@keyframes gmFadeIn { from { opacity: 0; transform: scale(0.99); } to { opacity: 1; transform: scale(1); } }
.gm-modal-header { padding: 0 20px; border-bottom: 1px solid #eee; background: #f8f9fa; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; height: 50px; }
.gm-modal-title { font-size: 16px; font-weight: bold; color: #333; display: flex; align-items: center; gap: 8px; }
.gm-modal-close { border: none; background: none; font-size: 24px; color: #999; cursor: pointer; padding: 0 10px; display:flex; align-items:center; }
.gm-ps-body { display: flex; flex-direction: column; height: 100%; overflow: hidden; padding: 0 !important; }
.gm-ps-search-bar { padding: 15px 20px; background: #fff; border-bottom: 1px solid #ebeef5; display: flex; gap: 10px; flex-shrink: 0; }
.gm-ps-input { flex: 1; padding: 8px 12px; border: 1px solid #dcdfe6; border-radius: 4px; outline: none; font-size: 14px; transition: border-color 0.2s; color: #606266; }
.gm-ps-input:focus { border-color: #409EFF; }
.gm-ps-btn { padding: 8px 20px; background: #409EFF; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: 500; transition: background 0.2s; }
.gm-ps-btn:hover { background: #66b1ff; }
.gm-ps-list-container { flex: 1; overflow-y: auto; padding: 0; position: relative; }
.gm-ps-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.gm-ps-table th { position: sticky; top: 0; background: #f8f9fa; color: #606266; font-weight: bold; padding: 12px 15px; text-align: left; border-bottom: 1px solid #ebeef5; z-index: 10; }
.gm-ps-table td { padding: 12px 15px; border-bottom: 1px solid #ebeef5; color: #606266; vertical-align: middle; }
.gm-ps-table tr:hover { background-color: #f5f7fa; }
/* 学号样式:黑白、加大、加粗 */
.gm-ps-tag {
background: #f0f0f0;
color: #000000;
border: 1px solid #bbb;
padding: 6px 10px;
border-radius: 4px;
font-family: Consolas, monospace;
font-size: 15px;
font-weight: bold;
letter-spacing: 0.5px;
}
.gm-ps-loader { padding: 20px; text-align: center; color: #909399; font-size: 13px; }
`;
document.head.appendChild(style);
},
createUI() {
const overlay = document.createElement('div');
overlay.id = 'gm-person-search-overlay';
overlay.className = 'gm-modal-overlay';
overlay.innerHTML = `
<div class="gm-modal-content" style="width: 650px; height: 70vh; max-height: 800px;">
<div class="gm-modal-header">
<div class="gm-modal-title">
<span style="font-size:18px; margin-right:5px; font-weight:bold;">人员信息检索
</div>
<button class="gm-modal-close" id="gm-ps-close">×</button>
</div>
<div class="gm-modal-body gm-ps-body">
<div class="gm-ps-search-bar">
<input type="text" id="gm-ps-input" class="gm-ps-input" placeholder="输入姓名、学号或工号">
<button id="gm-ps-btn" class="gm-ps-btn">搜索</button>
</div>
<div class="gm-ps-list-container" id="gm-ps-scroll-area">
<table class="gm-ps-table">
<thead>
<tr>
<th width="30%">姓名</th>
<th width="35%">学号/工号</th>
<th>学院/单位</th>
</tr>
</thead>
<tbody id="gm-ps-tbody"></tbody>
</table>
<div id="gm-ps-loader" class="gm-ps-loader">请输入关键词开始搜索</div>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
const closeFn = () => overlay.remove();
document.getElementById('gm-ps-close').onclick = closeFn;
overlay.onclick = (e) => { if(e.target === overlay) closeFn(); };
const doSearch = () => {
const val = document.getElementById('gm-ps-input').value.trim();
if(val) {
this.state.keyword = val;
this.resetState();
this.fetchData();
}
};
document.getElementById('gm-ps-btn').onclick = doSearch;
document.getElementById('gm-ps-input').onkeypress = (e) => { if(e.key === 'Enter') doSearch(); };
const scrollArea = document.getElementById('gm-ps-scroll-area');
scrollArea.onscroll = () => {
if (scrollArea.scrollTop + scrollArea.clientHeight >= scrollArea.scrollHeight - 30) {
if (!this.state.loading && this.state.hasMore) this.fetchData();
}
};
},
resetState() {
this.state = { page: 1, loading: false, hasMore: true, keyword: this.state.keyword };
const tbody = document.getElementById('gm-ps-tbody');
if(tbody) tbody.innerHTML = '';
const loader = document.getElementById('gm-ps-loader');
if(loader) {
loader.style.display = 'block';
loader.innerText = this.state.keyword ? '正在搜索...' : '请输入关键词';
}
},
fetchData() {
const token = GM_getValue(this.STORAGE_KEY);
if(!token || !this.state.keyword) return;
this.state.loading = true;
const loader = document.getElementById('gm-ps-loader');
if(loader) loader.innerText = "加载中...";
GM_xmlhttpRequest({
method: "GET",
url: `${this.API_BASE}?current=${this.state.page}&size=20&keyword=${encodeURIComponent(this.state.keyword)}`,
headers: { "X-Id-Token": token, "X-Requested-With": "XMLHttpRequest" },
onload: (res) => {
this.state.loading = false;
try {
const resp = JSON.parse(res.responseText);
if (resp.success && resp.data.records) {
this.renderRows(resp.data.records);
const total = resp.data.total;
if (resp.data.records.length < 20 || this.state.page * 20 >= total) {
this.state.hasMore = false;
if(loader) loader.innerText = `— 已显示全部 ${total} 条结果 —`;
} else {
this.state.page++;
if(loader) loader.innerText = "向下滚动加载更多...";
}
if (total === 0 && this.state.page === 1) {
if(loader) loader.innerText = "未找到相关人员";
}
} else {
// Token失效时,清空存储并重新触发静默同步
if(loader) loader.innerText = "授权过期,正在自动刷新...";
GM_setValue(this.STORAGE_KEY, "");
setTimeout(() => this._startSilentSync(), 1000);
}
} catch (e) {
if(loader) loader.innerText = "解析数据失败";
}
},
onerror: () => {
this.state.loading = false;
if(loader) loader.innerText = "网络请求失败";
}
});
},
renderRows(items) {
const tbody = document.getElementById('gm-ps-tbody');
if(!tbody) return;
items.forEach(item => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><span style="color:#303133;font-weight:600">${item.xm || '-'}</span></td>
<td><span class="gm-ps-tag">${item.gh || '-'}</span></td>
<td>${item.yxmc || '-'}</td>
`;
tbody.appendChild(tr);
});
}
};
/**
* 自动点击"全部课程"标签并滚动到底部(从 GPA 预测页面跳转过来时使用)
*/
function autoClickAllCoursesAndScroll() {
const MAX_WAIT = 15000; // 最多等 15 秒
const CHECK_INTERVAL = 500;
let elapsed = 0;
const tryClick = () => {
if (elapsed >= MAX_WAIT) {
Logger.warn('课表自动操作', '等待超时,页面可能未完全加载');
return;
}
elapsed += CHECK_INTERVAL;
// 查找所有可能的"全部课程"按钮/标签
const allClickTargets = document.querySelectorAll('a, button, [role="tab"], li, span');
let clicked = false;
for (const el of allClickTargets) {
const text = (el.textContent || '').trim();
if (text === '全部课程' || text === '课程列表') {
Logger.log('课表自动操作', `找到并点击: "${text}"`);
el.click();
clicked = true;
break;
}
}
if (clicked) {
// 点击后等待列表渲染,然后滚动到底部
setTimeout(() => {
scrollToBottom();
}, 2000);
} else {
// 还没找到按钮,继续等待
setTimeout(tryClick, CHECK_INTERVAL);
}
};
// 等待页面初始加载
const startAutoClick = () => {
setTimeout(tryClick, 1500);
};
if (document.readyState === 'complete') {
startAutoClick();
} else {
window.addEventListener('load', startAutoClick);
}
}
/**
* 滚动到页面底部
*/
function scrollToBottom() {
// 尝试找到课表内容容器
const containers = [
document.querySelector('.course-table-container'),
document.querySelector('.main-content'),
document.querySelector('#courseTableForm'),
document.querySelector('.content-wrapper'),
document.documentElement
].filter(Boolean);
for (const container of containers) {
container.scrollTop = container.scrollHeight;
}
// 同时也滚动窗口
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
Logger.log('课表自动操作', '已滚动到页面底部');
}
/**
* 显示自动获取成功的 Toast 提示
* @param {number} count 获取到的课程数量
*/
function showAutoFetchSuccessToast(count) {
const toast = document.createElement('div');
toast.style.cssText = `
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
background: #67C23A; color: #fff; padding: 16px 32px; border-radius: 8px;
font-size: 15px; z-index: 99999; box-shadow: 0 4px 16px rgba(0,0,0,0.15);
display: flex; align-items: center; gap: 10px; animation: gm-toast-in 0.3s ease;
`;
toast.innerHTML = `
<span style="font-size:22px;">✅</span>
<div>
<div style="font-weight:bold;">课表数据已自动缓存</div>
<div style="font-size:13px;margin-top:4px;opacity:0.9;">共获取 ${count} 门课程,可返回使用 GPA 预测功能</div>
</div>
`;
// 添加动画样式
if (!document.getElementById('gm-toast-style')) {
const style = document.createElement('style');
style.id = 'gm-toast-style';
style.textContent = `
@keyframes gm-toast-in { from { opacity: 0; transform: translateX(-50%) translateY(-20px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
@keyframes gm-toast-out { from { opacity: 1; transform: translateX(-50%) translateY(0); } to { opacity: 0; transform: translateX(-50%) translateY(-20px); } }
`;
document.head.appendChild(style);
}
document.body.appendChild(toast);
// 5秒后自动消失
setTimeout(() => {
toast.style.animation = 'gm-toast-out 0.3s ease forwards';
setTimeout(() => toast.remove(), 300);
}, 5000);
}
// --- 课表页面缓存功能 ---
function cacheCourseTableData() {
Logger.log('课表缓存', '开始解析课表页面...');
let courses = [];
let semester = '当前学期';
const seenCodes = new Set();
// 获取学期信息
const semesterSelect = document.querySelector('select[id*="semester"], select[name*="semester"]');
if (semesterSelect) {
semester = semesterSelect.selectedOptions[0]?.text || semester;
Logger.log('课表缓存', `学期选择器找到: ${semester}`);
}
// 方法1: 从"全部课程"列表视图解析(优先)
// 结构: tr.lessonInfo > td.courseInfo[data-course="课程名[课程代码]"] > span.span-gap > "学分(X)"
const lessonRows = document.querySelectorAll('tr.lessonInfo');
Logger.log('课表缓存', `找到 ${lessonRows.length} 行 lessonInfo`);
if (lessonRows.length > 0) {
// 建立学期映射
const semesterMap = new Map();
const semesterRows = document.querySelectorAll('tr.semester_tr');
semesterRows.forEach(row => {
const semId = row.getAttribute('data-semester');
const semName = row.querySelector('td')?.textContent?.trim() || '';
if (semId && semName) {
semesterMap.set(semId, semName);
}
});
lessonRows.forEach(row => {
const courseInfoTd = row.querySelector('td.courseInfo');
if (!courseInfoTd) return;
// 从 data-course 属性获取课程名和代码,格式: "课程名[代码]"
const dataCourse = courseInfoTd.getAttribute('data-course');
if (!dataCourse) return;
const match = dataCourse.match(/^(.+?)\[(.+?)\]$/);
if (!match) return;
const name = match[1].trim();
const code = match[2].trim();
// 从 span.span-gap 提取学分,支持多种格式
let credits = '';
const creditSpan = courseInfoTd.querySelector('span.span-gap');
if (creditSpan) {
const spanText = creditSpan.textContent;
// 尝试多种格式匹配
const patterns = [
/学分\(([0-9.]+)\)/, // 学分(4)
/\(([0-9.]+)学分\)/, // (4学分)
/学分[::]\s*([0-9.]+)/, // 学分:4 或 学分:4
/([0-9.]+)\s*学分/, // 4学分 或 4.0 学分
/学分\s*([0-9.]+)/, // 学分4 或 学分 4
];
for (const pattern of patterns) {
const match = spanText.match(pattern);
if (match) {
credits = match[1];
Logger.log('课表缓存', `从span-gap解析学分: ${credits} (文本: ${spanText})`);
break;
}
}
}
// 如果 span.span-gap 没找到学分,尝试从整个单元格文本中提取
const cellText = courseInfoTd.textContent;
if (!credits) {
const patterns = [
/学分\(([0-9.]+)\)/,
/\(([0-9.]+)学分\)/,
/学分[::]\s*([0-9.]+)/,
/([0-9.]+)\s*学分/,
/学分\s*([0-9.]+)/,
];
for (const pattern of patterns) {
const match = cellText.match(pattern);
if (match) {
credits = match[1];
Logger.log('课表缓存', `从单元格文本解析学分: ${credits}`);
break;
}
}
}
// 最后尝试:查找单元格中所有数字,取最后一个作为学分(课表页常见格式)
if (!credits) {
const allNumbers = cellText.match(/[0-9.]+/g);
if (allNumbers && allNumbers.length > 0) {
// 假设最后一个数字是学分(课程代码通常在前)
const lastNum = allNumbers[allNumbers.length - 1];
// 学分通常在0.5-10之间
const numVal = parseFloat(lastNum);
if (numVal >= 0.5 && numVal <= 10) {
credits = lastNum;
Logger.log('课表缓存', `从数字推断学分: ${credits}`);
}
}
}
// 获取学期
const semId = row.getAttribute('data-semester');
const rowSemester = semesterMap.get(semId) || semester;
if (!code || !name) return;
if (seenCodes.has(code)) return;
seenCodes.add(code);
Logger.log('课表缓存', `课程: ${name} | 代码: ${code} | 学分: ${credits || '(未找到)'} | 单元格文本: ${cellText.substring(0, 100)}...`);
courses.push({
code,
name,
credits,
semester: rowSemester,
source: '课表'
});
});
if (courses.length > 0) {
Logger.log('课表缓存', `从列表视图解析到 ${courses.length} 门课程`);
}
}
// 方法2: 如果方法1没找到,尝试从格子视图解析
if (courses.length === 0) {
const tables = document.querySelectorAll('table');
let courseTable = null;
for (const table of tables) {
const headerText = table.textContent.slice(0, 50);
if (headerText.includes('星期') || headerText.includes('周一')) {
courseTable = table;
break;
}
}
if (courseTable) {
Logger.log('课表缓存', '尝试从格子视图解析');
const cells = courseTable.querySelectorAll('td');
cells.forEach(td => {
const text = td.textContent.trim();
if (text.length < 10) return;
// 提取课程代码
const codeMatch = text.match(/([A-Z]\d{2}[A-Z]?\d{4,})/);
if (!codeMatch) return;
const code = codeMatch[1];
// 提取学分,支持多种格式
let credits = '';
const creditPatterns = [
/学分\(([0-9.]+)\)/,
/\(([0-9.]+)学分\)/,
/学分[::]\s*([0-9.]+)/,
/([0-9.]+)\s*学分/,
];
for (const pattern of creditPatterns) {
const creditMatch = text.match(pattern);
if (creditMatch) {
credits = creditMatch[1];
break;
}
}
// 提取课程名称
const codeIndex = text.indexOf(code);
const beforeCode = text.slice(0, codeIndex);
const name = beforeCode.replace(/^[本选必修考]+/, '').trim();
if (!code || !name) return;
if (seenCodes.has(code)) return;
seenCodes.add(code);
courses.push({
code,
name,
credits,
semester,
source: '课表'
});
});
}
}
if (courses.length > 0) {
const withCredits = courses.filter(c => c.credits).length;
// 保护机制:如果本次解析的数据缺少学分信息(通常来自"我的课表"格子视图),
// 且已有缓存包含完整学分信息,则不覆盖已有缓存
if (withCredits === 0) {
try {
const existingRaw = GM_getValue(CONSTANTS.COURSE_TABLE_CACHE_KEY, null);
if (existingRaw) {
const existing = JSON.parse(existingRaw);
const existingWithCredits = (existing.courses || []).filter(c => c.credits).length;
if (existingWithCredits > 0) {
Logger.log('课表缓存', `本次解析无学分信息,已有缓存包含 ${existingWithCredits} 门有学分课程,跳过覆盖`);
return;
}
}
} catch (e) { /* 解析失败则继续写入 */ }
}
const cacheData = {
timestamp: Date.now(),
semester,
courses
};
GM_setValue(CONSTANTS.COURSE_TABLE_CACHE_KEY, JSON.stringify(cacheData));
Logger.log('课表缓存', `已缓存 ${courses.length} 门课程,其中 ${withCredits} 门有学分信息`);
} else {
Logger.warn('课表缓存', '未解析到任何课程');
}
}
// =-=-=-=-=-=-=-=-=-=-=-=-= 2.13 我的课表教材信息显示 =-=-=-=-=-=-=-=-=-=-=-=-=
const TextbookInfoModule = {
init() {
if (!window.location.href.includes('/student/for-std/course-table')) return;
Logger.log('2.13', '课表教材信息模块初始化');
this.injectStyles();
this.interceptNetwork();
},
// 1. 拦截课表数据的请求
interceptNetwork() {
const _send = unsafeWindow.XMLHttpRequest.prototype.send;
const _open = unsafeWindow.XMLHttpRequest.prototype.open;
const that = this;
// 劫持 open 获取 URL
unsafeWindow.XMLHttpRequest.prototype.open = function(method, url) {
this._gm_textbook_url = url;
return _open.apply(this, arguments);
};
// 劫持 send 监听响应
unsafeWindow.XMLHttpRequest.prototype.send = function(data) {
this.addEventListener('load', function() {
if (this._gm_textbook_url && this._gm_textbook_url.includes('/print-data/')) {
try {
const responseJson = JSON.parse(this.responseText);
that.processData(responseJson);
} catch (e) {
Logger.error('2.13', '解析课表 print-data 失败', e);
}
}
}, { once: true });
return _send.apply(this, arguments);
};
},
// 2. 递归提取课程信息
processData(jsonData) {
const courseMap = new Map();
const findCourses = (obj) => {
if (Array.isArray(obj)) {
obj.forEach(item => findCourses(item));
} else if (obj !== null && typeof obj === 'object') {
if (obj.course && obj.course.id && obj.course.nameZh) {
// key: id, value: nameZh
courseMap.set(obj.course.id, obj.course.nameZh);
}
for (let key in obj) {
findCourses(obj[key]);
}
}
};
findCourses(jsonData);
if (courseMap.size > 0) {
Logger.log('2.13', `提取到 ${courseMap.size} 门课程,准备获取教材信息`);
this.fetchTextbooks(courseMap);
}
},
// 3. 并发获取教材详情页面并解析
async fetchTextbooks(courseMap) {
this.renderContainer('正在努力获取全本学期课程的教材信息,请稍候...');
const allTextbooks = [];
const promises = [];
for (const [courseId, courseName] of courseMap.entries()) {
const p = fetch(`https://jwxt.nwpu.edu.cn/student/for-std/lesson-search/info/${courseId}`)
.then(res => res.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const rows = doc.querySelectorAll('.textbook-table tbody tr');
rows.forEach(row => {
const tds = row.querySelectorAll('td');
if (tds.length === 0) return;
// 处理存在 rowspan 的列差情况 (一般有8列,如果没有类型列就是7列)
const offset = tds.length >= 8 ? 2 : 1;
// 防止越界报错
if (tds.length < 6) return;
allTextbooks.push({
courseId: courseId, // 关键:保存 courseId 用于跳转
courseName: courseName,
name: tds[offset] ? tds[offset].innerText.trim() : '-',
author: tds[offset + 1] ? tds[offset + 1].innerText.trim() : '-',
isbn: tds[offset + 2] ? tds[offset + 2].innerText.trim() : '-',
publisher: tds[offset + 3] ? tds[offset + 3].innerText.trim() : '-',
edition: tds[offset + 4] ? tds[offset + 4].innerText.trim() : '-',
pubDate: tds[offset + 5] ? tds[offset + 5].innerText.trim() : '-'
});
});
})
.catch(err => {
Logger.warn('2.13', `获取 ${courseName} 教材失败`, err);
});
promises.push(p);
}
await Promise.allSettled(promises);
this.renderTable(allTextbooks);
},
// 4. 注入UI样式
injectStyles() {
if (document.getElementById('gm-textbook-style')) return;
const style = document.createElement('style');
style.id = 'gm-textbook-style';
style.textContent = `
.gm-textbook-wrapper {
margin: 20px; padding: 20px; background: #fff;
border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.gm-textbook-title {
font-size: 16px; font-weight: bold; color: #303133; margin-bottom: 15px;
border-left: 4px solid #409EFF; padding-left: 10px;
}
.gm-textbook-table {
width: 100%; border-collapse: collapse; font-size: 13px;
}
.gm-textbook-table th, .gm-textbook-table td {
border: 1px solid #ebeef5; padding: 10px 15px; text-align: center; vertical-align: middle;color: #606266;
}
.gm-textbook-table th {
background: #f5f7fa; font-weight: bold; color: #333;
}
.gm-textbook-table tr:hover { background-color: #f5f7fa; }
.gm-textbook-empty { text-align: center; color: #909399; padding: 30px; }
.gm-textbook-course { font-weight: bold; color: #409EFF; }
.gm-textbook-course a { color: #409EFF; text-decoration: none; transition: color 0.2s; }
.gm-textbook-course a:hover { color: #66b1ff; text-decoration: underline; }
`;
document.head.appendChild(style);
},
// 5. 渲染基础容器
renderContainer(msg) {
let container = document.getElementById('gm-textbook-container');
if (!container) {
container = document.createElement('div');
container.id = 'gm-textbook-container';
container.className = 'gm-textbook-wrapper';
// 尝试将其挂载到页面的主内容区底部
const target = document.querySelector('.main-content') || document.querySelector('#app') || document.body;
target.appendChild(container);
}
container.innerHTML = `
<div class="gm-textbook-title">本学期课程教材清单</div>
<div class="gm-textbook-empty">${msg}</div>
`;
},
// 6. 渲染最终表格
renderTable(dataList) {
const container = document.getElementById('gm-textbook-container');
if (!container) return;
if (dataList.length === 0) {
container.innerHTML = `
<div class="gm-textbook-title">本学期课程教材清单</div>
<div class="gm-textbook-empty">本学期的所有课程目前均未在教务系统中登记教材信息。</div>
`;
return;
}
// 去重
const uniqueKeys = new Set();
const finalData = [];
dataList.forEach(item => {
const key = `${item.courseName}-${item.isbn}-${item.name}`;
if (!uniqueKeys.has(key)) {
uniqueKeys.add(key);
finalData.push(item);
}
});
// 按课程名排序,确保同一课程排在一起
finalData.sort((a, b) => a.courseName.localeCompare(b.courseName));
// 统计每门课程有多少本教材,用于 rowspan 合并单元格
const courseCountMap = {};
finalData.forEach(item => {
courseCountMap[item.courseName] = (courseCountMap[item.courseName] || 0) + 1;
});
let rowsHtml = '';
let currentCourse = '';
finalData.forEach(tb => {
rowsHtml += `<tr>`;
// 如果是该课程的第一本书,输出带有 rowspan 的课程名单元格
if (tb.courseName !== currentCourse) {
currentCourse = tb.courseName;
const courseUrl = `https://jwxt.nwpu.edu.cn/student/for-std/lesson-search/info/${tb.courseId}`;
rowsHtml += `<td rowspan="${courseCountMap[currentCourse]}" class="gm-textbook-course">
<a href="${courseUrl}" target="_blank" title="在新标签页中查看课程详情">${tb.courseName}</a>
</td>`;
}
rowsHtml += `
<td>${tb.name}</td>
<td>${tb.author}</td>
<td>${tb.publisher}</td>
<td>${tb.isbn}</td>
<td>${tb.edition}</td>
<td>${tb.pubDate}</td>
</tr>
`;
});
container.innerHTML = `
<div class="gm-textbook-title">本学期课程教材清单</div>
<table class="gm-textbook-table">
<thead>
<tr>
<th width="20%">课程名称</th>
<th width="20%">教材名称</th>
<th width="17%">作者</th>
<th width="15%">出版社</th>
<th width="10%">ISBN/编号</th>
<th width="5%">版次</th>
<th width="8%">出版年月</th>
</tr>
</thead>
<tbody>
${rowsHtml}
</tbody>
</table>
`;
}
};
// --- 3. 脚本主入口 (路由分发) ---
function runMainFeatures() {
const href = window.location.href;
// 0. 【最高优先级】后台 Worker
if (window.name === BackgroundSyncSystem.WORKER_NAME) {
BackgroundSyncSystem.startWorker();
return;
}
if (window.frameElement && window.frameElement.id === 'gm-id-fetcher-patch') {
return;
}
// 门户(ecampus) Token同步
// 如果在门户网站,只运行Token同步,不运行其他教务逻辑
if (location.host === 'ecampus.nwpu.edu.cn') {
PersonnelSearch.syncToken();
return;
}
// 1. 评教页面检测
if (href.includes('evaluation-student-frontend')) {
window.addEventListener('load', initEvaluationHelper);
window.addEventListener('hashchange', () => {
if(window.location.hash.includes('byTask')) initEvaluationHelper();
});
setTimeout(initEvaluationHelper, 2000); // 兜底
}
// 2. 开课查询页面
else if (href.includes('/student/for-std/lesson-search')) {
if(document.body) initLessonSearchPage();
}
// 3. 学生画像页面
else if (href.includes('/student/for-std/student-portrait')) {
if (ConfigManager.enablePortraitEnhancement) {
enhancePortraitPage(); // 功能3
}
}
// 4. 培养方案页面
else if (href.includes('/student/for-std/program/info/') ||
href.includes('/student/for-std/program-completion-preview/info/') ||
href.includes('/student/for-std/majorPrograms/info/')) {
initProgramPageEnhancement(); // 功能8
}
// 5. 课表页面 - 缓存课表数据 & 教材信息显示
else if (href.includes('/student/for-std/course-table')) {
// 检查是否是从 GPA 预测页面自动跳转过来的
const autoFetchFlag = GM_getValue('jwxt_auto_fetch_course_table', 0);
const isAutoFetch = autoFetchFlag && (Date.now() - autoFetchFlag < 30000); // 30秒内有效
if (isAutoFetch) {
GM_setValue('jwxt_auto_fetch_course_table', 0); // 清除标记
Logger.log('课表缓存', '检测到自动获取标记,将自动展开全部课程并缓存');
}
// 等待页面加载完成后解析
const parseAndCache = () => {
setTimeout(() => {
cacheCourseTableData();
}, 1500);
};
if (document.readyState === 'complete') {
parseAndCache();
} else {
window.addEventListener('load', parseAndCache);
}
// 监听学期切换
setTimeout(() => {
const semesterSelect = document.querySelector('select[id*="semester"], select[name*="semester"]');
if (semesterSelect) {
semesterSelect.addEventListener('change', () => {
setTimeout(cacheCourseTableData, 1000);
});
}
}, 2000);
// 使用 MutationObserver 监听"全部课程"列表的出现
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
// 检查是否有 lessonInfo 元素出现
const lessonRows = document.querySelectorAll('tr.lessonInfo');
if (lessonRows.length > 0) {
Logger.log('课表缓存', '检测到课程列表出现,开始缓存');
cacheCourseTableData();
// 如果是自动跳转过来的,缓存完成后显示成功提示
if (isAutoFetch) {
showAutoFetchSuccessToast(lessonRows.length);
}
break;
}
}
}
});
// 延迟启动 observer,等页面准备好
setTimeout(() => {
observer.observe(document.body, {
childList: true,
subtree: true
});
// 60秒后停止观察
setTimeout(() => observer.disconnect(), 60000);
}, 2000);
// 监听所有点击事件,当用户点击可能的"我的课表"、"全部课程"按钮时触发缓存
document.addEventListener('click', (e) => {
const target = e.target;
const text = target.textContent || target.innerText || '';
if (text.includes('我的课表') || text.includes('全部课程') || text.includes('课程列表')) {
Logger.log('课表缓存', `检测到"${text}"按钮点击`);
setTimeout(cacheCourseTableData, 1500);
}
});
// 如果是自动跳转,自动点击"全部课程"标签并滚动到底部
if (isAutoFetch) {
autoClickAllCoursesAndScroll();
}
// 教材信息显示(官方功能 2.13)
TextbookInfoModule.init();
// 兜底:如果课表页面意外成为顶层窗口(如旧版跳转导致框架被破坏),
// 也要创建悬浮球,避免插件图标消失
if (window.top === window.self) {
createFloatingMenu();
}
}
// 6. 顶层主页
else if (window.top === window.self) {
initializeHomePageFeatures();
// 延迟启动后台控制器
setTimeout(() => {
BackgroundSyncSystem.initController();
}, 5000);
}
}
if (document.readyState === 'loading') {
window.addEventListener('DOMContentLoaded', runMainFeatures);
} else {
runMainFeatures();
}
})();

浙公网安备 33010602011771号