// ==UserScript==
// @name 南理工教务增强助手 V2
// @namespace http://tampermonkey.net/
// @version 2.0.1
// @description 在合适的地方显示课程大纲、选修课类别及选修课学分情况,并自动刷新登录状态。同时支持评教自动填分与批量提交。
// @match http://202.119.81.112/*
// @match http://bkjw.njust.edu.cn/*
// @match http://202.119.81.112:9080/*
// @match http://202.119.81.113:9080/*
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @connect jsdelivr.net
// @connect njust.wiki
// @author Light
// @license MIT
// @supportURL https://github.com/NJUST-OpenLib/NJUST-JWC-Enhance
// ==/UserScript==
// ================================================================
// 【模块一】核心增强功能模块
// 职责:数据抓取、课程信息增强(大纲/类别)、学分统计、登录保活
// 设计模式:单例对象(Singleton)组织各子系统
// ================================================================
(function () {
'use strict';
/**
* ── 数据源镜像配置 ─────────────────────────────────────────────
* 采用多节点冗余设计,解决部分节点在大规模访问时的稳定性问题。
* 优先级顺序:官方主站 > jsDelivr 加速 > jsDelivr 备用 > GitHub Raw。
*/
// 选修课类别数据源(JSON 格式)
const CATEGORY_URLS = [
'https://enhance.njust.wiki/data/xxk.json', // 官方主节点
'https://fastly.jsdelivr.net/gh/NJUST-OpenLib/NJUST-JWC-Enhance@latest/data/xxk.json', // jsDelivr 全球加速
'https://testingcf.jsdelivr.net/gh/NJUST-OpenLib/NJUST-JWC-Enhance@latest/data/xxk.json', // jsDelivr Cloudflare
'https://raw.githubusercontent.com/NJUST-OpenLib/NJUST-JWC-Enhance/refs/heads/main/data/xxk.json' // GitHub 原始文件(备用)
];
// 课程大纲索引数据源(包含课程代码到 jx02id 的映射)
const OUTLINE_URLS = [
'https://enhance.njust.wiki/data/kcdg.json', // 官方主节点
'https://fastly.jsdelivr.net/gh/NJUST-OpenLib/NJUST-JWC-Enhance@latest/data/kcdg.json', // jsDelivr 全球加速
'https://testingcf.jsdelivr.net/gh/NJUST-OpenLib/NJUST-JWC-Enhance@latest/data/kcdg.json', // jsDelivr Cloudflare
'https://raw.githubusercontent.com/NJUST-OpenLib/NJUST-JWC-Enhance/refs/heads/main/data/kcdg.json' // GitHub 原始文件(备用)
];
/**
* ── 全局配置选项 ───────────────────────────────────────────────
*/
const UI_CONFIG = {
showNotifications: true // 是否允许弹出系统级通知(目前主要通过 LogPanel 反馈)
};
/**
* ── 调试系统配置 ───────────────────────────────────────────────
* enabled: 开启后会向控制台和日志面板输出详细过程
* level: 4(DEBUG), 3(INFO), 2(WARN), 1(ERROR)
*/
const DEBUG_CONFIG = {
enabled: true,
level: 3,
showCache: true // 是否在日志中详细记录缓存的存取动作
};
/**
* ── 缓存系统配置 ───────────────────────────────────────────────
* 使用 localStorage 存储远程 JSON 数据,减少重复的网络请求,提升页面加载速度。
*/
const CACHE_CONFIG = {
enabled: true,
ttl: 86400, // 缓存有效期:(单位:秒)
prefix: 'njust_jwc_enhance_' // 本脚本专用的缓存键名前缀
};
/**
* ── 日志面板 UI 系统 ────────────────────────────────────────────
* 这是一个右下角的悬浮面板,用于实时展示脚本运行状态,取代了侵入式的 Toast 弹窗。
* 特性:
* 1. 队列化处理:确保在 DOM 准备好之前产生的日志不会丢失。
* 2. 标题栏滚动:标题栏会循环展示最新的日志摘要,5秒后自动复位。
* 3. 级别过滤:支持在展开状态下通过下拉菜单过滤日志级别。
*/
const LogPanelUI = {
container: null, // 外部容器 DOM
body: null, // 日志行容器 DOM
initialized: false, // 初始化状态标志
queue: [], // 预初始化消息队列
_statusQueue: [], // 标题栏待播放队列
_statusPlaying: false, // 标题栏是否正在播放动画
/**
* 初始化面板 DOM 并注入样式
*/
init() {
if (this.initialized || !document.body) return;
const style = document.createElement('style');
style.textContent = `
/* 面板主样式 */
#njust-enhance-log {
position: fixed; bottom: 0; right: 20px; width: 380px;
background: #fff; border: 1px solid #e2e8f0; border-bottom: none;
border-radius: 10px 10px 0 0; box-shadow: 0 -2px 15px rgba(0,0,0,0.08);
z-index: 10001; font-family: 'SFMono-Regular', Consolas, monospace;
display: flex; flex-direction: column; transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
#njust-enhance-log.minimized { transform: translateY(calc(100% - 38px)); }
/* 标题栏样式 */
#njust-enhance-log-hd {
padding: 10px 15px; background: #f7fafc; border-bottom: 1px solid #e2e8f0;
cursor: pointer; display: flex; align-items: center; justify-content: space-between;
border-radius: 10px 10px 0 0; user-select: none; gap: 10px;
}
#njust-enhance-log-hd b { font-size: 13px; color: #2d3748; display: flex; align-items: center; gap: 6px; flex: 1; min-width: 0; }
#nel-status-text { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; flex: 1; transition: color 0.2s; }
/* 日志列表样式 */
#njust-enhance-log-body {
height: 220px; overflow-y: auto; background: #fdfdfd; font-size: 11px;
padding: 4px 0; scroll-behavior: smooth;
}
.nel-btn { font-size: 11px; color: #718096; background: #edf2f7; padding: 2px 8px; border-radius: 4px; cursor: pointer; transition: all 0.2s; white-space: nowrap; }
.nel-btn:hover { background: #e2e8f0; color: #2d3748; }
.nel-clear { background: rgba(245, 101, 101, 0.05); color: #c53030; }
.nel-clear:hover { background: rgba(245, 101, 101, 0.15); color: #c53030; }
/* 日志行与分级颜色 */
.nel-line { padding: 3px 12px; border-bottom: 1px solid rgba(226, 232, 240, 0.4); display: flex; gap: 8px; align-items: flex-start; transition: background 0.1s; }
.nel-line:hover { background: #f7fafc; }
.nel-ts { color: #a0aec0; flex-shrink: 0; min-width: 55px; user-select: none; }
.nel-lvl { font-weight: bold; flex-shrink: 0; min-width: 42px; text-align: center; font-size: 10px; }
.nel-msg { color: #4a5568; word-break: break-all; flex: 1; line-height: 1.5; }
.nel-error { border-left: 3px solid #e53e3e; background: rgba(229, 62, 62, 0.02); } .nel-error .nel-lvl { color: #e53e3e; }
.nel-warn { border-left: 3px solid #dd6b20; background: rgba(221, 107, 32, 0.02); } .nel-warn .nel-lvl { color: #dd6b20; }
.nel-success { border-left: 3px solid #38a169; background: rgba(56, 161, 105, 0.02); } .nel-success .nel-lvl { color: #38a169; }
.nel-info { border-left: 3px solid #3182ce; } .nel-info .nel-lvl { color: #3182ce; }
.nel-debug { border-left: 3px solid #9f7aea; color: #718096; } .nel-debug .nel-lvl { color: #9f7aea; }
`;
document.head.appendChild(style);
this.container = document.createElement('div');
this.container.id = 'njust-enhance-log';
this.container.className = 'minimized';
this.container.innerHTML = `
南理工教务增强助手V2
清空
展开 ▴
`;
document.body.appendChild(this.container);
this.body = this.container.querySelector('#njust-enhance-log-body');
this.initialized = true;
// 绑定交互事件
this.container.querySelector('#njust-enhance-log-hd').onclick = (e) => {
if (e.target.id === 'nel-clear-btn') return;
const isMin = this.container.classList.toggle('minimized');
this.container.querySelector('#njust-log-toggle').textContent = isMin ? '展开 ▴' : '折叠 ▾';
};
this.container.querySelector('#nel-clear-btn').onclick = (e) => {
e.stopPropagation();
if (this.body) this.body.innerHTML = '';
const statusText = this.container.querySelector('#nel-status-text');
if (statusText) statusText.textContent = '日志已清空';
// 清空时也清空标题栏队列
this._statusQueue = [];
this._statusPlaying = false;
};
// 处理初始化前的缓存消息
if (this.queue.length > 0) {
this.queue.forEach(item => this.add(item.level, item.msg));
this.queue = [];
}
},
/**
* 向面板添加一条新日志
* @param {string} level - 级别: error, warn, success, info, debug
* @param {string} msg - 日志内容
*/
add(level, msg) {
if (!this.initialized) {
this.init();
if (!this.initialized) {
this.queue.push({ level, msg });
return;
}
}
if (!this.body) return;
const labels = { error: '[ERR]', warn: '[WRN]', success: '[OK ]', info: '[INF]', debug: '[DBG]' };
const lvlLabel = labels[level] || '[INF]';
const ts = new Date().toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
const line = document.createElement('div');
line.className = `nel-line nel-${level}`;
line.innerHTML = `[${ts}]${lvlLabel}${this.esc(msg)}`;
this.body.appendChild(line);
// 限制最大行数,防止页面过重
if (this.body.children.length > 200) this.body.removeChild(this.body.firstChild);
this.body.scrollTop = this.body.scrollHeight;
// 加入标题栏滚动显示队列
this._statusQueue.push({ msg, level });
if (!this._statusPlaying) {
this._playStatusQueue();
}
},
/**
* 标题栏消息队列播放器
*/
_playStatusQueue() {
if (this._statusQueue.length === 0) {
this._statusPlaying = false;
// 恢复默认状态文字
const statusText = this.container && this.container.querySelector('#nel-status-text');
if (statusText) {
statusText.textContent = '南理工教务增强助手V2';
statusText.style.color = '#2d3748';
statusText.style.opacity = '0.95';
}
return;
}
this._statusPlaying = true;
const { msg, level } = this._statusQueue.shift();
const statusText = this.container && this.container.querySelector('#nel-status-text');
if (statusText) {
const colors = { error: '#e53e3e', warn: '#dd6b20', success: '#38a169', info: '#3182ce', debug: '#718096' };
statusText.textContent = msg;
statusText.style.color = colors[level] || '#2d3748';
statusText.style.opacity = '0.5';
setTimeout(() => { statusText.style.opacity = '1'; }, 80);
}
// 每 200ms 切换下一条,形成滚动感
setTimeout(() => { this._playStatusQueue(); }, 200);
},
esc(s) { return String(s).replace(/&/g, '&').replace(//g, '>'); }
};
/**
* ── 调试系统 ────────────────────────────────────────────────────
* 统一的日志入口,负责格式化输出并同步到 LogPanelUI。
*/
const Logger = {
LEVELS: { ERROR: 1, WARN: 2, INFO: 3, DEBUG: 4 },
log(level, message, ...args) {
if (!DEBUG_CONFIG.enabled || level > DEBUG_CONFIG.level) return;
const timestamp = new Date().toLocaleTimeString();
const levelNames = ['', 'error', 'warn', 'info', 'debug'];
const lvlName = levelNames[level] || 'info';
// 控制台原生输出
console.log(`[${timestamp}] [南理工教务助手]`, message, ...args);
// 格式化对象参数,使其在 UI 面板中可见
let displayMessage = message;
if (args.length > 0) {
const formattedArgs = args.map(arg => {
if (typeof arg === 'object' && arg !== null) {
try {
return JSON.stringify(arg, null, 1)
.replace(/^{|}$/g, '')
.replace(/"/g, '')
.replace(/\n/g, ' ');
} catch (e) { return '[Object]'; }
}
return String(arg);
}).join(' ');
displayMessage += ' ' + formattedArgs;
}
// 同步到 UI 面板
LogPanelUI.add(lvlName, displayMessage);
},
error(message, ...args) { this.log(this.LEVELS.ERROR, message, ...args); },
warn(message, ...args) { this.log(this.LEVELS.WARN, message, ...args); },
info(message, ...args) { this.log(this.LEVELS.INFO, message, ...args); },
debug(message, ...args) { this.log(this.LEVELS.DEBUG, message, ...args); }
};
/**
* ── 缓存系统 ────────────────────────────────────────────────────
* 负责远程 JSON 数据在本地 localStorage 的存取、过期判断及统计。
*/
const CacheManager = {
/**
* 生成 URL 对应的哈希键名
*/
getKey(url) {
return CACHE_CONFIG.prefix + btoa(unescape(encodeURIComponent(url))).replace(/[^a-zA-Z0-9]/g, '');
},
/**
* 存入缓存
*/
set(url, data) {
if (!CACHE_CONFIG.enabled) return false;
try {
const cacheData = {
data,
timestamp: Date.now(),
ttl: CACHE_CONFIG.ttl * 1000,
url
};
localStorage.setItem(this.getKey(url), JSON.stringify(cacheData));
if (DEBUG_CONFIG.showCache) Logger.info(`💾 缓存已保存: ${url}`);
return true;
} catch (e) {
Logger.error('缓存保存失败: ', e);
return false;
}
},
/**
* 读取缓存(包含过期校验)
*/
get(url) {
if (!CACHE_CONFIG.enabled) return null;
try {
const key = this.getKey(url);
const cached = localStorage.getItem(key);
if (!cached) {
if (DEBUG_CONFIG.showCache) Logger.debug(`缓存未命中: ${url}`);
return null;
}
const cacheData = JSON.parse(cached);
const now = Date.now();
// 过期判断
if (now - cacheData.timestamp > cacheData.ttl) {
localStorage.removeItem(key);
if (DEBUG_CONFIG.showCache) Logger.warn(`⏰ 缓存已过期: ${url}`);
return null;
}
const age = ((now - cacheData.timestamp) / 1000).toFixed(1);
const remaining = ((cacheData.ttl - (now - cacheData.timestamp)) / 1000).toFixed(1);
if (DEBUG_CONFIG.showCache) Logger.info(`✅ 缓存命中: ${url} (已缓存${age}s,剩余${remaining}s)`);
return cacheData.data;
} catch (e) {
Logger.error('缓存读取失败: ', e);
return null;
}
},
/**
* 清空本脚本产生的所有缓存
*/
clear() {
try {
const keys = Object.keys(localStorage).filter(k => k.startsWith(CACHE_CONFIG.prefix));
keys.forEach(k => localStorage.removeItem(k));
Logger.info(`已清除 ${keys.length} 个缓存项`);
return keys.length;
} catch (e) {
Logger.error('清除缓存失败: ', e);
return 0;
}
},
/**
* 获取缓存占用情况统计
*/
getStats() {
try {
const keys = Object.keys(localStorage).filter(k => k.startsWith(CACHE_CONFIG.prefix));
let totalSize = 0, validCount = 0, expiredCount = 0;
const now = Date.now();
keys.forEach(k => {
try {
const cached = localStorage.getItem(k);
totalSize += cached.length;
const d = JSON.parse(cached);
(now - d.timestamp > d.ttl) ? expiredCount++ : validCount++;
} catch (e) { expiredCount++; }
});
return { total: keys.length, valid: validCount, expired: expiredCount, size: totalSize };
} catch (e) {
Logger.error('获取缓存统计失败: ', e);
return { total: 0, valid: 0, expired: 0, size: 0 };
}
}
};
// fix④: 彻底移除与 LogPanelUI 职责重叠的 StatusNotifier,
// 原代码中该系统的调用已被注释,直接删除其定义以消除冗余。
// ==================== 延迟初始化日志 ====================
function initializeLogging() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeLogging);
return;
}
setTimeout(() => {
try {
Logger.info('南理工教务增强助手已启动', {
debug: DEBUG_CONFIG.enabled ? `Level ${DEBUG_CONFIG.level}` : '关闭',
cache: CACHE_CONFIG.enabled ? `TTL ${CACHE_CONFIG.ttl}s` : '关闭'
});
if (DEBUG_CONFIG.enabled && DEBUG_CONFIG.showCache) {
const stats = CacheManager.getStats();
Logger.info(`缓存统计: 总${stats.total} 有效${stats.valid} 过期${stats.expired} ${(stats.size / 1024).toFixed(1)}KB`);
}
} catch (e) {
console.error('初始化日志失败: ', e);
}
}, 100);
}
initializeLogging();
let courseCategoryMap = {};
let courseOutlineMap = {};
// ==================== 统一弹窗 ====================
function createUnifiedModal(title, content, type = 'info') {
const existingModal = document.getElementById('njustAssistantModal');
if (existingModal) existingModal.remove();
const container = document.createElement('div');
container.id = 'njustAssistantModal';
const gradientColor = {
warning: 'linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%)',
success: 'linear-gradient(135deg, #28a745 0%, #20c997 100%)',
info: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
}[type] || 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
container.style.cssText = `
position: fixed; top: 50%; left: 50%;
transform: translate(-50%, -50%);
background: ${gradientColor};
border: none; border-radius: 15px; padding: 0;
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
z-index: 10000; min-width: 200px; max-width: 500px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
overflow: hidden; animation: njustFadeIn 0.3s ease-out;
`;
container.innerHTML = `
${content}
⚠️ 免责声明
本工具仅为学习交流使用,数据仅供参考。
请以教务处官网信息为准,使用本工具产生的任何后果均由用户自行承担。
`;
if (!document.getElementById('njustAssistantStyles')) {
const style = document.createElement('style');
style.id = 'njustAssistantStyles';
// fix②: 避免与 LogPanelUI 的动画同名冲突,改用前缀 njustFadeIn
style.textContent = `
@keyframes njustFadeIn {
from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
`;
document.head.appendChild(style);
}
addDragFunctionality(container);
document.body.appendChild(container);
return container;
}
// ==================== 拖动功能 ====================
// fix②: 使用 getBoundingClientRect 获取视觉位置,修复折叠动画后拖拽跳位问题
function addDragFunctionality(container) {
let isDragging = false;
let mouseStartX, mouseStartY, elemStartX, elemStartY;
const dragHandle = container.querySelector('#dragHandle');
function dragStart(e) {
const clientX = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX;
const clientY = e.type === 'touchstart' ? e.touches[0].clientY : e.clientY;
if (e.target === dragHandle || dragHandle.contains(e.target)) {
// 读取当前视觉位置(考虑 transform 的影响)
const rect = container.getBoundingClientRect();
elemStartX = rect.left;
elemStartY = rect.top;
mouseStartX = clientX;
mouseStartY = clientY;
isDragging = true;
e.preventDefault();
}
}
function drag(e) {
if (!isDragging) return;
e.preventDefault();
const clientX = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX;
const clientY = e.type === 'touchmove' ? e.touches[0].clientY : e.clientY;
const newLeft = elemStartX + (clientX - mouseStartX);
const newTop = elemStartY + (clientY - mouseStartY);
container.style.transform = 'none'; // 清除动画 transform
container.style.left = newLeft + 'px';
container.style.top = newTop + 'px';
// 若原来用 translate(-50%,-50%) 定位,拖动后改为绝对位置
container.style.margin = '0';
}
function dragEnd() { isDragging = false; }
dragHandle.addEventListener('mousedown', dragStart);
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', dragEnd);
dragHandle.addEventListener('touchstart', dragStart, { passive: false });
document.addEventListener('touchmove', drag, { passive: false });
document.addEventListener('touchend', dragEnd, { passive: false });
}
// ==================== 检测强智科技页面 ====================
function checkQiangzhiPage() {
try {
const pageTitle = document.title || '';
if (pageTitle.includes('强智科技教务系统概念版')) {
Logger.warn('检测到强智科技概念版页面,显示登录引导');
const content = `
💡 提示:
强智科技教务系统概念版是无法登陆的。
请使用上述链接跳转到正确的登录页面,
登录后可正常使用教务系统功能
验证码区分大小写,大部分情况下均为小写
`;
try { createUnifiedModal('南理工教务增强助手', content, 'warning'); }
catch (e) { Logger.error('创建强智科技页面提示弹窗失败:', e); }
return true;
}
return false;
} catch (e) {
Logger.error('检测强智科技页面失败:', e);
return false;
}
}
// ==================== 数据加载(智能切源)====================
// fix⑤: 无论第几个数据源成功,均进行缓存;缓存读取也遍历所有 URL
function loadJSONWithFallback(urls) {
return new Promise((resolve, reject) => {
const urlArray = Array.isArray(urls) ? urls : [urls];
const fileName = urlArray[0].includes('xxk') ? '选修课分类' : '课程大纲';
Logger.info(`开始加载 ${fileName},共 ${urlArray.length} 个数据源`);
// fix⑤: 先遍历所有 URL,尝试从缓存中命中任意一个
for (const url of urlArray) {
const cachedData = CacheManager.get(url);
if (cachedData) {
Logger.info(`从缓存读取 ${fileName} 成功 (${url})`);
resolve(cachedData);
return;
}
}
// 缓存全部未命中,依次请求网络
let currentIndex = 0;
function tryNextUrl() {
if (currentIndex >= urlArray.length) {
Logger.error(`${fileName} 所有数据源均不可用`);
reject(new Error(`所有数据源都不可用: ${fileName}`));
return;
}
const currentUrl = urlArray[currentIndex++];
Logger.info(`尝试数据源 ${currentIndex}/${urlArray.length}: ${currentUrl}`);
const startTime = Date.now();
GM_xmlhttpRequest({
method: 'GET',
url: currentUrl,
timeout: 10000,
onload(response) {
const loadTime = Date.now() - startTime;
try {
const json = JSON.parse(response.responseText);
// fix⑤: 无论哪个数据源成功,均以该 URL 为键进行缓存
const cached = CacheManager.set(currentUrl, json);
Logger.info(
`数据源 ${currentIndex} 请求成功 (${loadTime}ms, ${response.responseText.length}B, 缓存${cached ? '已保存' : '失败'})`
);
resolve(json);
} catch (e) {
Logger.error(`JSON 解析失败: ${currentUrl}`, e);
tryNextUrl();
}
},
onerror() {
Logger.warn(`数据源 ${currentIndex} 请求失败 (${Date.now() - startTime}ms): ${currentUrl}`);
tryNextUrl();
},
ontimeout() {
Logger.warn(`数据源 ${currentIndex} 请求超时: ${currentUrl}`);
tryNextUrl();
}
});
}
tryNextUrl();
});
}
function loadJSON(url) {
return loadJSONWithFallback(Array.isArray(url) ? url : [url]);
}
// ==================== 构建课程映射 ====================
function buildCourseMaps(categoryList, outlineList) {
try {
let categoryCount = 0, outlineCount = 0;
if (Array.isArray(categoryList)) {
categoryList.forEach(item => {
try {
if (item && item.course_code && item.category) {
courseCategoryMap[item.course_code.trim()] = item.category;
categoryCount++;
}
} catch (e) { Logger.warn('处理分类数据项时出错:', e); }
});
} else {
Logger.warn('分类数据不是数组格式:', typeof categoryList);
}
if (Array.isArray(outlineList)) {
outlineList.forEach(item => {
try {
if (item && item.course_code && item.id) {
courseOutlineMap[item.course_code.trim()] = item.id;
outlineCount++;
}
} catch (e) { Logger.warn('处理大纲数据项时出错:', e); }
});
} else {
Logger.warn('大纲数据不是数组格式:', typeof outlineList);
}
Logger.info(`课程映射构建完成: 选修课类别 ${categoryCount} 条,课程大纲 ${outlineCount} 条`);
} catch (e) {
Logger.error('构建课程映射表失败:', e);
if (typeof courseCategoryMap !== 'object') courseCategoryMap = {};
if (typeof courseOutlineMap !== 'object') courseOutlineMap = {};
}
}
// ==================== 学分统计悬浮窗 ====================
function createCreditSummaryWindow() {
try {
const container = document.createElement('div');
container.id = 'creditSummaryWindow';
container.style.cssText = `
position: fixed; top: 40px; right: 40px;
background: #fff; border: 1px solid #e0e0e0; border-radius: 14px;
padding: 0; box-shadow: 0 8px 32px rgba(0,0,0,0.13);
z-index: 9999; min-width: 420px; max-width: 520px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
overflow: hidden;
`;
container.innerHTML = `
对照个人培养方案核实具体修课要求
选修课类别统计仅包含已知分类的通识教育选修课
课程分类信息可能随时更新,请以教务处最新通知为准
`;
// fix②: 学分窗拖拽也使用修正后的 addDragFunctionality
// 注意:该容器的拖拽句柄 id 为 creditDragHandle,与 addDragFunctionality 查找 #dragHandle 不符,
// 故在此单独实现,逻辑与修正后的 addDragFunctionality 一致
let isDragging = false;
let mouseStartX, mouseStartY, elemStartX, elemStartY;
const dragHandle = container.querySelector('#creditDragHandle');
if (dragHandle) {
const dragStart = (e) => {
const clientX = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX;
const clientY = e.type === 'touchstart' ? e.touches[0].clientY : e.clientY;
if (e.target === dragHandle || dragHandle.contains(e.target)) {
const rect = container.getBoundingClientRect();
elemStartX = rect.left;
elemStartY = rect.top;
mouseStartX = clientX;
mouseStartY = clientY;
isDragging = true;
e.preventDefault();
}
};
const drag = (e) => {
if (!isDragging) return;
e.preventDefault();
const clientX = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX;
const clientY = e.type === 'touchmove' ? e.touches[0].clientY : e.clientY;
container.style.right = 'auto';
container.style.left = (elemStartX + clientX - mouseStartX) + 'px';
container.style.top = (elemStartY + clientY - mouseStartY) + 'px';
};
const dragEnd = () => { isDragging = false; };
dragHandle.addEventListener('mousedown', dragStart);
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', dragEnd);
dragHandle.addEventListener('touchstart', dragStart, { passive: false });
document.addEventListener('touchmove', drag, { passive: false });
document.addEventListener('touchend', dragEnd, { passive: false });
}
document.body.appendChild(container);
Logger.debug('学分统计弹窗创建完成');
return container;
} catch (e) {
Logger.error('创建学分统计弹窗失败:', e);
return null;
}
}
// ==================== 学分统计更新 ====================
function updateCreditSummary() {
try {
const creditSummaryDiv = document.getElementById('creditSummary');
if (!creditSummaryDiv) { Logger.warn('未找到学分统计容器'); return; }
const creditsByType = {};
const creditsByCategory = {};
const tables = document.querySelectorAll('table');
tables.forEach(table => {
table.querySelectorAll('tr').forEach(row => {
const tds = row.querySelectorAll('td');
if (tds.length < 11) return;
const courseCode = tds[2].textContent.trim();
const credit = parseFloat(tds[6].textContent) || 0;
const courseType = tds[10].textContent.trim();
const categoryDiv = tds[2].querySelector('[data-category-inserted]');
let category = null;
if (categoryDiv) {
category = categoryDiv.textContent.trim() || null;
}
if (courseType) {
if (!creditsByType[courseType]) creditsByType[courseType] = { credits: 0, count: 0 };
creditsByType[courseType].credits += credit;
creditsByType[courseType].count++;
}
if (category) {
if (!creditsByCategory[category]) creditsByCategory[category] = { credits: 0, count: 0 };
creditsByCategory[category].credits += credit;
creditsByCategory[category].count++;
}
});
});
const totalCreditsByType = Object.values(creditsByType).reduce((s, d) => s + d.credits, 0);
const totalCountByType = Object.values(creditsByType).reduce((s, d) => s + d.count, 0);
const totalCreditsByCategory = Object.values(creditsByCategory).reduce((s, d) => s + d.credits, 0);
const totalCountByCategory = Object.values(creditsByCategory).reduce((s, d) => s + d.count, 0);
let summaryHTML = ``;
summaryHTML += `
📊 按课程性质统计
`;
summaryHTML += `
总计
${totalCreditsByType.toFixed(1)} 学分
${totalCountByType} 门
`;
for (const [type, data] of Object.entries(creditsByType)) {
summaryHTML += `
${type}
${data.credits.toFixed(1)} 学分
${data.count} 门
`;
}
summaryHTML += `
`;
if (Object.keys(creditsByCategory).length > 0) {
summaryHTML += ``;
summaryHTML += `
🏷️ 按选修课类别统计
`;
summaryHTML += `
总计
${totalCreditsByCategory.toFixed(1)} 学分
${totalCountByCategory} 门
`;
for (const [category, data] of Object.entries(creditsByCategory)) {
summaryHTML += `
${category}
${data.credits.toFixed(1)} 学分
${data.count} 门
`;
}
summaryHTML += `
`;
}
creditSummaryDiv.innerHTML = summaryHTML || '暂无数据';
Logger.debug('学分统计更新完成');
} catch (e) {
Logger.error('更新学分统计失败:', e);
const el = document.getElementById('creditSummary');
if (el) el.innerHTML = '❌ 学分统计更新失败
';
}
}
/**
* ── 课程信息增强核心逻辑 ───────────────────────────────────────
* 负责解析教务系统的表格 DOM,并根据映射表插入大纲链接、选修课类别等信息。
*/
function processAllTables() {
try {
const tables = document.querySelectorAll('table');
// 页面类型识别
const isGradePage = window.location.pathname.includes('/njlgdx/kscj/cjcx_list');
const isSchedulePage = window.location.pathname.includes('xskb_list.do') &&
document.title.includes('学期理论课表');
const isSmartCampus = window.location.href.includes('bkjw.njust.edu.cn');
let processedTables = 0, processedRows = 0, enhancedCourses = 0;
tables.forEach(table => {
try {
// 课表页面只处理 id 为 dataList 的主表格
if (isSchedulePage && table.id !== 'dataList') return;
const rows = table.querySelectorAll('tr');
processedTables++;
rows.forEach(row => {
try {
const tds = row.querySelectorAll('td');
if (tds.length < 3) return; // 略过非数据行
processedRows++;
let courseCodeTd, courseCode;
// 不同页面的课程代码提取逻辑差异化处理
if (isGradePage) {
courseCodeTd = tds[2];
courseCode = courseCodeTd ? courseCodeTd.textContent.trim() : '';
} else if (isSchedulePage) {
courseCodeTd = tds[1];
courseCode = courseCodeTd ? courseCodeTd.textContent.trim() : '';
} else {
// 通用逻辑:提取形如 "课程名
课程代码" 的结构
courseCodeTd = tds[1];
if (courseCodeTd && courseCodeTd.innerHTML) {
const parts = courseCodeTd.innerHTML.split('
');
if (parts.length === 2) courseCode = parts[1].trim();
else return;
} else return;
}
if (!courseCode) return;
let courseEnhanced = false;
// 1. 插入选修课类别(如:人文素养、自然科学等)
try {
if (courseCodeTd && !courseCodeTd.querySelector('[data-category-inserted]')) {
const category = courseCategoryMap[courseCode];
if (category) {
const catDiv = document.createElement('div');
catDiv.setAttribute('data-category-inserted', '1');
catDiv.style.color = '#28a745';
catDiv.style.fontWeight = 'bold';
catDiv.style.marginTop = '4px';
catDiv.textContent = category;
courseCodeTd.appendChild(catDiv);
courseEnhanced = true;
}
}
} catch (e) { Logger.warn('添加课程类别时出错:', e); }
// 2. 插入老师说明(将 的 title 属性显性化)
try {
if (!isGradePage && !isSchedulePage && courseCodeTd &&
courseCodeTd.title && !courseCodeTd.querySelector('[data-title-inserted]')) {
const titleDiv = document.createElement('div');
titleDiv.setAttribute('data-title-inserted', '1');
titleDiv.style.color = '#666';
titleDiv.style.fontSize = '13px';
titleDiv.style.marginTop = '4px';
titleDiv.style.fontStyle = 'italic';
titleDiv.textContent = `📌 老师说明: ${courseCodeTd.title}`;
courseCodeTd.appendChild(titleDiv);
courseEnhanced = true;
}
} catch (e) { Logger.warn('添加老师说明时出错:', e); }
// 3. 插入教学大纲链接
try {
if (courseCodeTd && !courseCodeTd.querySelector('[data-outline-inserted]')) {
const outlineDiv = document.createElement('div');
outlineDiv.setAttribute('data-outline-inserted', '1');
outlineDiv.style.marginTop = '4px';
if (isSmartCampus) {
// 智慧理工平台因跨域和权限限制,无法直接预览官网大纲
outlineDiv.textContent = '⚠️ 课程大纲功能受限';
outlineDiv.style.color = '#ff9800';
outlineDiv.style.fontWeight = 'bold';
outlineDiv.style.cursor = 'pointer';
outlineDiv.title = '当前使用智慧理工平台,课程大纲功能受限。请访问教务处官网获取完整功能';
} else {
const realId = courseOutlineMap[courseCode];
if (realId) {
const link = document.createElement('a');
// 拼接教务处官网预览链接
link.href = `http://202.119.81.112:8080/kcxxAction.do?method=kcdgView&jx02id=${realId}&isentering=0`;
link.textContent = '📘 查看课程大纲';
link.target = '_blank';
link.style.color = '#0077cc';
outlineDiv.appendChild(link);
} else {
outlineDiv.textContent = '❌ 无大纲信息';
outlineDiv.style.color = 'gray';
}
}
courseCodeTd.appendChild(outlineDiv);
courseEnhanced = true;
}
} catch (e) { Logger.warn('添加课程大纲链接时出错:', e); }
if (courseEnhanced) enhancedCourses++;
} catch (e) { Logger.warn('处理表格行时出错:', e); }
});
} catch (e) { Logger.warn('处理表格时出错:', e); }
});
Logger.info(`表格处理完成: ${processedTables}个表格, ${processedRows}行, 增强${enhancedCourses}门课程`);
// 成绩页面额外执行学分实时汇总
if (isGradePage) updateCreditSummary();
} catch (e) {
Logger.error('处理页面表格失败:', e);
}
}
/**
* ── 登录保活与自动修复系统 ───────────────────────────────────────
* 解决教务系统频繁掉线、重复登录导致的报错页面。
*/
function checkLoginErrorAndRefresh() {
try {
const pageTitle = document.title || '';
const pageContent = document.body ? document.body.textContent : '';
// 匹配典型的教务处报错关键词
const isLoginError = pageTitle.includes('出错页面') &&
(pageContent.includes('您登录后过长时间没有操作') ||
pageContent.includes('您的用户名已经在别处登录') ||
pageContent.includes('请重新输入帐号,密码后,继续操作'));
if (isLoginError) {
Logger.warn('检测到登录超时或重复登录错误页面,正在自动刷新...');
performLoginRefresh(true);
return true;
}
return false;
} catch (e) {
Logger.error('检测登录错误页面失败:', e);
return false;
}
}
/**
* 执行静默登录刷新
* 原理:通过一个隐藏的 iframe 请求教务系统的一个轻量级页面,利用浏览器自动携带的 Cookie 维持/刷新 Session。
*/
function performLoginRefresh(forceRefresh = false) {
const currentUrl = window.location.href;
try {
let baseUrl;
if (currentUrl.includes('njlgdx/')) {
baseUrl = currentUrl.substring(0, currentUrl.indexOf('njlgdx/'));
} else {
const urlObj = new URL(currentUrl);
baseUrl = `${urlObj.protocol}//${urlObj.host}/`;
}
// 使用“课程大纲查询”作为刷新页面(权限要求低且加载快)
const refreshUrl = baseUrl + 'njlgdx/pyfa/kcdgxz';
Logger.info('使用隐藏 iframe 刷新登录状态:', refreshUrl);
const iframe = document.createElement('iframe');
iframe.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:1px;height:1px;opacity:0;visibility:hidden;border:none;';
iframe.src = refreshUrl;
iframe.onload = () => {
Logger.info('登录状态刷新请求完成');
setTimeout(() => { if (iframe.parentNode) iframe.parentNode.removeChild(iframe); }, 1000);
};
iframe.onerror = () => {
Logger.warn('登录状态刷新请求失败');
if (iframe.parentNode) iframe.parentNode.removeChild(iframe);
if (forceRefresh) Logger.error('登录状态刷新失败,请手动重新点击选课中心 - 课程总库');
};
document.body.appendChild(iframe);
// 10秒兜底清理,防止网络挂起
setTimeout(() => { if (iframe.parentNode) iframe.parentNode.removeChild(iframe); }, 10000);
} catch (e) {
Logger.error('自动刷新登录状态失败:', e);
}
}
/**
* 定时自动保活逻辑
* 策略:仅在主框架页面执行,且 5 分钟内不重复触发。
*/
function autoRefreshLoginStatus() {
try {
const currentUrl = window.location.href;
if (!currentUrl.includes('njlgdx/framework/main.jsp')) return;
const lastRefreshKey = 'njust_last_login_refresh';
const lastRefreshTime = localStorage.getItem(lastRefreshKey);
const now = Date.now();
const refreshInterval = 5 * 60 * 1000; // 5分钟间隔
if (lastRefreshTime && (now - parseInt(lastRefreshTime)) < refreshInterval) {
Logger.debug('距上次刷新不足5分钟,跳过');
return;
}
// 更新最后刷新时间戳
localStorage.setItem(lastRefreshKey, now.toString());
// fix⑦: 使用 BroadcastChannel 通知其他同源标签页同步刷新时间,避免多标签页并发请求
if (typeof BroadcastChannel !== 'undefined') {
const bc = new BroadcastChannel('njust_login_refresh');
bc.postMessage({ type: 'refreshing', ts: now });
bc.close();
}
Logger.info('检测到主框架页面,开始刷新登录状态');
performLoginRefresh(false);
} catch (e) {
Logger.error('自动刷新登录状态检查失败:', e);
}
}
/**
* ── 脚本入口初始化 ──────────────────────────────────────────────
*/
async function init() {
try {
Logger.info('开始执行主要逻辑');
// 强智概念版拦截
if (checkQiangzhiPage()) {
Logger.info('强智科技页面检测完成,脚本退出');
return;
}
const currentUrl = window.location.href;
const isSmartCampus = currentUrl.includes('bkjw.njust.edu.cn');
if (isSmartCampus) {
Logger.warn('检测到智慧理工平台,课程大纲功能将受限');
}
// 1. 登录保活与状态检查
autoRefreshLoginStatus();
checkLoginErrorAndRefresh();
// 2. 加载远程配置数据
Logger.info('开始加载数据');
const [categoryData, outlineData] = await Promise.all([
loadJSON(CATEGORY_URLS),
loadJSON(OUTLINE_URLS)
]);
// 3. 构建索引并执行初次处理
Logger.info('数据加载完成,构建映射表');
buildCourseMaps(categoryData, outlineData);
if (window.location.pathname.includes('/njlgdx/kscj/cjcx_list')) {
createCreditSummaryWindow();
}
processAllTables();
/**
* 4. 动态监听系统
* 解决教务系统通过 AJAX 异步切换页面(如点击菜单)导致脚本失效的问题。
*/
let isProcessing = false;
const observer = new MutationObserver((mutations) => {
try {
if (isProcessing) return;
const hasRelevantChanges = mutations.some(mutation => {
try {
if (mutation.type !== 'childList') return false;
for (const node of mutation.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
// 忽略脚本自己插入的 DOM
if (node.hasAttribute &&
(node.hasAttribute('data-category-inserted') ||
node.hasAttribute('data-title-inserted') ||
node.hasAttribute('data-outline-inserted'))) {
return false;
}
// 只关注表格类变更
if (node.tagName === 'TABLE' || node.tagName === 'TR' || node.tagName === 'TD') {
return true;
}
}
return false;
} catch (e) {
Logger.warn('检查页面变化时出错:', e);
return false;
}
});
if (hasRelevantChanges && !checkQiangzhiPage()) {
isProcessing = true;
try {
processAllTables();
} catch (e) {
Logger.error('重新处理表格失败:', e);
} finally {
// 节流处理,防止频繁触发
setTimeout(() => { isProcessing = false; }, 100);
}
}
} catch (e) {
Logger.error('MutationObserver 回调执行失败:', e);
isProcessing = false;
}
});
try {
observer.observe(document.body, { childList: true, subtree: true });
} catch (e) {
Logger.error('启动页面变化监听器失败:', e);
}
Logger.info('南理工教务增强助手加载成功!');
} catch (err) {
Logger.error('初始化失败:', err);
}
}
// 延迟 1 秒执行初始化,确保教务系统原始 JS 框架加载完成
setTimeout(init, 1000);
})();
// ================================================================
// 【模块二】自动评教助手 V1
// 功能:自动填分、批量保存、批量提交、分值实时预览
// 适用页面:学生评教入口 (xspj_find.do)、课程列表 (xspj_list.do)、评价详情 (xspj_edit.do)
// ================================================================
(function () {
'use strict';
// ── 常量定义 ─────────────────────────────────────────────────────
// 存储键名,用于跨页面同步状态
const KEY_STORE = 'njust_eval_v1_store'; // 核心存储:课程状态、评分选项等
const KEY_RUNNING = 'njust_eval_running'; // 全局标志:是否处于“开始评价并保存”流水线中
const KEY_BUSY = 'njust_eval_busy'; // 互斥锁:防止多个窗口同时执行保存操作
const KEY_QUEUE = 'njust_eval_queue'; // 队列:待处理的类别 URL 列表
const KEY_CURLIST = 'njust_eval_curlist'; // 当前正在处理的类别 URL
const KEY_LOG = 'njust_eval_log'; // 日志存储
const KEY_LOGLVL = 'njust_eval_loglvl'; // 日志显示等级过滤
const KEY_SUBQUEUE = 'njust_eval_subqueue'; // 待提交课程的 URL 队列
const KEY_SUBRUN = 'njust_eval_subrun'; // 全局标志:是否处于“提交已评课程”流水线中
const KEY_SUBBSY = 'njust_eval_subbsy'; // 互斥锁:防止多个窗口同时执行提交操作
// URL 参数,用于传递指令给详情页
const PARAM_AUTO = 'isAutoEval'; // 详情页接收后执行自动填分+保存
const PARAM_SUBMIT = 'isAutoSubmit'; // 详情页接收后执行自动提交
const MAX_LOG = 300; // 日志最大保留条数
// ── 日志系统 ─────────────────────────────────────────────────────
// 日志级别与 UI 映射
const LOG_LEVELS = { debug: 0, info: 1, success: 2, warn: 3, error: 4 };
const LOG_LABELS = { debug: 'DBG', info: 'INF', success: 'OK ', warn: 'WRN', error: 'ERR' };
const LOG_ICONS = { debug: '🔍', info: 'ℹ️', success: '✅', warn: '⚠️', error: '❌' };
// 日志持久化与读取
const loadLogs = () => JSON.parse(localStorage.getItem(KEY_LOG) || '[]');
const clearLogs = () => { localStorage.removeItem(KEY_LOG); renderLogPanel(); };
const getMinLevel = () => { const s = localStorage.getItem(KEY_LOGLVL); return (s && LOG_LEVELS[s] !== undefined) ? s : 'info'; };
const setMinLevel = (l) => { localStorage.setItem(KEY_LOGLVL, l); renderLogPanel(); };
/**
* 推送新日志并更新面板
*/
const pushLog = (msg, level = 'info') => {
const logs = loadLogs();
logs.push({ ts: new Date().toTimeString().slice(0, 8), msg, level });
if (logs.length > MAX_LOG) logs.splice(0, logs.length - MAX_LOG);
localStorage.setItem(KEY_LOG, JSON.stringify(logs));
renderLogPanel();
};
const logInfo = (m) => pushLog(m, 'info');
const logSuccess = (m) => pushLog(m, 'success');
const logError = (m) => pushLog(m, 'error');
/**
* 渲染控制面板底部的日志区域
*/
const renderLogPanel = () => {
const minP = LOG_LEVELS[getMinLevel()] ?? 1;
const lines = loadLogs().filter(l => (LOG_LEVELS[l.level] ?? 1) >= minP);
const html = lines.map(l => {
const level = l.level || 'info';
const icon = LOG_ICONS[level] || '•';
const label = LOG_LABELS[level] || 'INF';
return ` ` +
`${l.ts}` +
`${icon} ${label}` +
`${esc(l.msg)} `;
}).join('');
const el = document.getElementById('v80-log-content');
if (el) { el.innerHTML = html; el.scrollTop = el.scrollHeight; }
const sel = document.getElementById('log-level-sel');
if (sel) sel.value = getMinLevel();
};
// ── 通用工具函数 ──────────────────────────────────────────────────
// HTML 转义防止 XSS
const esc = (s) => String(s).replace(/&/g, '&').replace(//g, '>');
// 解析 URL 参数
const qp = (url, key) => {
try { return new URL(url, location.origin).searchParams.get(key) || ''; }
catch { return url.match(new RegExp(`[?&]${key}=([^&]+)`))?.[1] || ''; }
};
// 生成课程唯一 Key (课程ID + 教师ID)
const courseKey = (url) => { const cid = qp(url, 'jx02id'), tid = qp(url, 'jg0101id'); return cid && tid ? `${cid}__${tid}` : null; };
const appendParam = (url, key, val) => url + (url.includes('?') ? '&' : '?') + key + '=' + val;
const withAuto = (url, val) => appendParam(url, PARAM_AUTO, val);
const withSubmit = (url) => appendParam(url, PARAM_SUBMIT, 'true');
const roundFloat = (n) => Math.round(n * 1e9) / 1e9; // 解决浮点数精度误差
// 快捷访问 LocalStorage
const loadStore = () => JSON.parse(localStorage.getItem(KEY_STORE) || '{}');
const saveStore = (v) => localStorage.setItem(KEY_STORE, JSON.stringify(v));
const loadQueue = () => JSON.parse(localStorage.getItem(KEY_QUEUE) || '[]');
const saveQueue = (q) => localStorage.setItem(KEY_QUEUE, JSON.stringify(q));
const loadSubQueue = () => JSON.parse(localStorage.getItem(KEY_SUBQUEUE) || '[]');
const saveSubQueue = (q) => localStorage.setItem(KEY_SUBQUEUE, JSON.stringify(q));
/**
* 在控制面板预览当前存储的课程状态
*/
const renderStoragePanel = () => {
const el = document.getElementById('v80-storage-pre');
if (el) el.textContent = JSON.stringify(loadStore(), null, 2);
};
// ── 评价页面核心逻辑 ──────────────────────────────────────────────
/**
* 收集当前评价页面的所有评分组 (Radio) 及其对应的分值
*/
const collectGroups = () => {
const groups = {};
document.querySelectorAll('input[type="radio"]').forEach(r => {
if (!groups[r.name]) groups[r.name] = [];
const idx = r.id.split('_')[1];
// 查找隐藏域中的分值 (pj0601fz_...)
const fzEl = document.getElementsByName(`pj0601fz_${idx}_${r.value}`)[0];
groups[r.name].push({ el: r, score: fzEl ? parseFloat(fzEl.value) || 0 : 0 });
});
const gkeys = Object.keys(groups);
// 按分值从高到低排序,方便策略选择
gkeys.forEach(k => groups[k].sort((a, b) => b.score - a.score));
return { gkeys, groups };
};
/**
* 寻找最适合进行分值扰动的题目索引
* 原则:分值差距最小的题目,扰动对总分影响最小
*/
const findPerturbIdx = (gkeys, groups) => {
let minDelta = Infinity, perturbIdx = -1;
gkeys.forEach((k, i) => {
const opts = groups[k];
if (opts.length < 2) return;
const delta = roundFloat(opts[0].score - opts[1].score);
if (delta < minDelta) { minDelta = delta; perturbIdx = i; }
});
return perturbIdx;
};
/**
* 计算当前页面已勾选选项的总分
*/
const calcCurrentTotal = (gkeys, groups) => {
let total = 0;
gkeys.forEach(k => { const chosen = groups[k].find(o => o.el.checked); if (chosen) total += chosen.score; });
return roundFloat(total);
};
/**
* 在详情页每个选项后标注具体分值,方便人工微调
*/
const ensureValueFields = () => {
const { gkeys, groups } = collectGroups();
gkeys.forEach(k => {
groups[k].forEach(({ el, score }) => {
const idx = el.id.split('_')[1];
const fzEl = document.getElementsByName(`pj0601fz_${idx}_${el.value}`)[0];
if (!fzEl) return;
let next = fzEl.nextElementSibling;
if (next && next.classList && next.classList.contains('v80-value-chip')) return;
const chip = document.createElement('span');
chip.className = 'v80-value-chip';
chip.textContent = `[${score}分]`;
fzEl.insertAdjacentElement('afterend', chip);
});
});
};
/**
* 应用评分策略并勾选 Radio
* strategy: highest (最高分), high (次高), mid (中分), low (低分)
* 策略中包含“选项去重”逻辑:即在保证总分尽量高的前提下,在某一道题上选择非最高分,避免全满分被系统拦截
*/
const applyStrategy = (strategy, gkeys, groups) => {
const perturbIdx = findPerturbIdx(gkeys, groups);
let total = 0;
gkeys.forEach((k, i) => {
const opts = groups[k], len = opts.length;
let pick;
if (strategy === 'highest') {
// 最高分策略:在扰动项选次高,其余全选最高
pick = (i === perturbIdx && len >= 2) ? 1 : 0;
} else if (strategy === 'high') {
pick = len < 2 ? 0 : (i === perturbIdx) ? 0 : 1;
} else if (strategy === 'mid') {
const midIdx = Math.floor((len - 1) / 2);
pick = (i === perturbIdx && len >= 2) ? (midIdx > 0 ? midIdx - 1 : midIdx + 1) : midIdx;
} else if (strategy === 'low') {
pick = (i === perturbIdx && len >= 2) ? len - 2 : len - 1;
}
const chosen = opts[Math.min(pick, len - 1)];
if (chosen) { chosen.el.checked = true; total += chosen.score; }
});
return roundFloat(total);
};
// ── 样式注入 ──────────────────────────────────────────────────────
const injectCSS = () => {
if (document.getElementById('v80-style')) return;
const style = document.createElement('style');
style.id = 'v80-style';
style.textContent = `
/* 控制面板主容器 */
#v80-panel {
position: fixed; top: 20px; right: 20px; width: 490px;
background: #fff; border-radius: 10px;
box-shadow: 0 4px 24px rgba(0,0,0,0.10);
z-index: 99999; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex; flex-direction: column; border: 1px solid #e2e8f0;
max-height: 90vh; overflow: hidden;
transition: transform 0.25s ease; font-size: 13px; color: #2d3748;
}
#v80-panel.wide { width: 640px; }
#v80-header {
padding: 11px 14px; background: #f7fafc; border-bottom: 1px solid #e2e8f0;
cursor: move; display: flex; align-items: center; gap: 8px; user-select: none; flex-shrink: 0;
}
#v80-header b { flex: 1; font-size: 14px; color: #2d3748; }
#v80-min-btn {
width: 28px; height: 28px; border-radius: 6px; background: #edf2f7; color: #4a5568;
border: none; font-size: 16px; cursor: pointer;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
#v80-min-btn:hover { background: #e2e8f0; }
#v80-action-bar { padding: 10px 14px 8px; border-bottom: 1px solid #edf2f7; background: #fff; flex-shrink: 0; }
#v80-submit-hint { font-size: 11px; padding: 6px 10px; border-radius: 6px; margin-bottom: 8px; background: #f0fff4; color: #276749; border: 1px solid #c6f6d5; display: none; line-height: 1.6; }
#v80-submit-hint.visible { display: block; }
.btn-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 7px; }
#v80-body { padding: 10px 14px; overflow-y: auto; flex: 1; }
/* 列表项卡片样式 */
.entry-card, .ci { display: flex; align-items: center; gap: 8px; padding: 9px 12px; border-radius: 7px; border: 1px solid #e2e8f0; margin-bottom: 7px; background: #f7fafc; }
.ci { padding: 8px 10px; margin-bottom: 6px; border-color: #edf2f7; }
.entry-label, .ci-name { flex: 1; font-weight: 500; color: #2d3748; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ci-teacher { color: #718096; white-space: nowrap; }
.ci-zpf { color: #276749; font-size: 11px; background: #f0fff4; padding: 1px 7px; border-radius: 8px; border: 1px solid #c6f6d5; white-space: nowrap; }
/* 状态标签 */
.entry-st-done, .st-submitted { font-size: 11px; padding: 1px 8px; border-radius: 8px; background: #f0fff4; color: #276749; border: 1px solid #c6f6d5; white-space: nowrap; }
.entry-st-wait, .st-wait { font-size: 11px; padding: 1px 8px; border-radius: 8px; background: #fffaf0; color: #c05621; border: 1px solid #feebc8; white-space: nowrap; }
.entry-st-run { font-size: 11px; padding: 1px 8px; border-radius: 8px; background: #ebf4ff; color: #2b6cb0; border: 1px solid #bee3f8; }
.st-can-submit { font-size: 11px; padding: 1px 8px; border-radius: 8px; background: #fefcbf; color: #744210; border: 1px solid #f6e05e; white-space: nowrap; }
.st-none { font-size: 11px; padding: 1px 8px; border-radius: 8px; background: #edf2f7; color: #718096; border: 1px solid #e2e8f0; white-space: nowrap; }
/* 按钮 */
.vb { padding: 6px 13px; border-radius: 6px; border: none; font-size: 12px; font-weight: 600; cursor: pointer; transition: background 0.15s; white-space: nowrap; }
.vb-primary { background: #ebf4ff; color: #2b6cb0; border: 1px solid #bee3f8; }
.vb-green { background: #f0fff4; color: #276749; border: 1px solid #c6f6d5; }
.vb-yellow { background: #fefcbf; color: #744210; border: 1px solid #f6e05e; }
.vb-outline { background: #fff; color: #4a5568; border: 1px solid #cbd5e0; }
.vb-danger { background: #fff; color: #c53030; border: 1px solid #fed7d7; }
.vb-mini { padding: 3px 9px; font-size: 11px; }
.vb:disabled { opacity: 0.45; cursor: not-allowed; }
/* 可折叠区块样式 */
.v80-section { flex-shrink: 0; border-top: 1px solid #edf2f7; }
.v80-sec-hd { padding: 7px 14px; display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; background: #f7fafc; }
.v80-sec-hd .lbl { font-size: 11px; color: #4a5568; font-weight: 600; flex: 1; }
.v80-sec-hd .arr { font-size: 13px; color: #a0aec0; }
.v80-sec-body { display: none; }
.v80-sec-body.open { display: block; }
/* 日志行样式 */
#v80-log-content, #v80-storage-pre { max-height: 200px; overflow-y: auto; padding: 4px 0 10px; font-size: 11px; line-height: 1.6; font-family: 'SFMono-Regular', Consolas, monospace; background: #f7fafc; }
.log-line { padding: 3px 14px; border-bottom: 1px solid rgba(226, 232, 240, 0.4); display: flex; gap: 6px; align-items: flex-start; transition: background 0.1s; }
.log-line:hover { background: rgba(226, 232, 240, 0.6); }
.log-ts { color: #a0aec0; user-select: none; flex-shrink: 0; min-width: 54px; }
.log-lvl { font-weight: 700; flex-shrink: 0; min-width: 32px; text-align: center; border-radius: 3px; font-size: 10px; padding: 0 2px; }
.log-msg { color: #4a5568; word-break: break-all; flex: 1; }
.log-debug { background: rgba(159,122,234,0.05); } .log-debug .log-lvl { color: #9f7aea; background: rgba(159,122,234,0.1); }
.log-info { background: transparent; } .log-info .log-lvl { color: #3182ce; background: rgba(49,130,206,0.1); }
.log-success { background: rgba(72,187,120,0.05); } .log-success .log-lvl { color: #276749; background: rgba(72,187,120,0.1); }
.log-warn { background: rgba(237,137,54,0.05); } .log-warn .log-lvl { color: #c05621; background: rgba(237,137,54,0.1); }
.log-error { background: rgba(245,101,101,0.08); } .log-error .log-lvl { color: #c53030; background: rgba(245,101,101,0.15); }
.log-level-select { font-size: 11px; padding: 1px 5px; border-radius: 4px; background: #fff; color: #4a5568; border: 1px solid #cbd5e0; cursor: pointer; }
.minimized { transform: translateY(calc(100% - 44px)); }
.v80-value-chip { display: inline-block; margin-left: 6px; font-size: 11px; color: #4a5568; }
`;
document.head.appendChild(style);
};
// ── 面板构建 ──────────────────────────────────────────────────────
/**
* 创建并注入控制面板 DOM
*/
const buildPanel = (titleHtml, actionBarHtml, bodyHtml) => {
injectCSS();
const panel = document.createElement('div');
panel.id = 'v80-panel';
panel.innerHTML = `
${actionBarHtml}
${bodyHtml}
📋 运行日志
▴
`;
document.body.appendChild(panel);
// 最小化切换
document.getElementById('v80-min-btn').onclick = (e) => { e.stopPropagation(); panel.classList.toggle('minimized'); };
// 日志区块折叠
const logBody = document.getElementById('v80-log-content'), logArr = document.getElementById('log-arr');
document.getElementById('log-hd').onclick = () => { logBody.classList.toggle('open'); logArr.textContent = logBody.classList.contains('open') ? '▴' : '▾'; };
document.getElementById('log-level-sel').addEventListener('change', (e) => { e.stopPropagation(); setMinLevel(e.target.value); });
// 存储预览区块折叠
const storeBody = document.getElementById('store-body'), storeArr = document.getElementById('store-arr');
document.getElementById('store-hd').onclick = () => { storeBody.classList.toggle('open'); storeArr.textContent = storeBody.classList.contains('open') ? '▴' : '▾'; if (storeBody.classList.contains('open')) renderStoragePanel(); };
// 面板拖拽逻辑 (修正 transform 带来的坐标偏移问题)
let drag = false, mouseStartX = 0, mouseStartY = 0, elemStartX = 0, elemStartY = 0;
document.getElementById('v80-header').onmousedown = (e) => {
if (e.target.id === 'v80-min-btn') return;
const rect = panel.getBoundingClientRect();
elemStartX = rect.left; elemStartY = rect.top;
mouseStartX = e.clientX; mouseStartY = e.clientY;
drag = true;
};
document.onmousemove = (e) => {
if (!drag) return;
panel.style.left = (elemStartX + e.clientX - mouseStartX) + 'px';
panel.style.top = (elemStartY + e.clientY - mouseStartY) + 'px';
panel.style.right = 'auto';
};
document.onmouseup = () => { drag = false; };
renderLogPanel();
return panel;
};
// ── FIND 页面 (评教类别入口页) ────────────────────────────────────
if (location.href.includes('xspj_find.do')) {
/**
* 扫描页面中所有的评价入口(如理论课程、实验课程等)
*/
const scanEntries = () => {
const anchors = document.querySelectorAll('a[href*="xspj_list.do"]');
const found = [];
anchors.forEach(a => {
const href = a.getAttribute('href');
const label = a.textContent.trim() || a.title || href;
const abs = href.startsWith('http') ? href : location.origin + href;
found.push({ label, url: abs });
});
return found;
};
buildPanel(
'🎓 自动评教助手 V1',
`
新手使用指南
① 点击下方任一入口,进入该"类别"的课程列表页。
② 在课程列表页,勾选要自动处理的课程(默认全部勾选)。
③ 点击"开始评价并保存",系统会依次打开勾选课程的评价页,自动填分并保存。
④ 保存后课程显示"待提交",点击"提交已评课程"可批量提交。
⑤ "是否提交=是"的课程视为已完成,不会再进行任何自动操作。
重要:用户必须自行点击"确认"弹窗确认!
出现问题可使用“重置缓存(清除状态)”按钮重置进度
`,
``
);
// 初始化布局调整
(function(){const p=document.getElementById('v80-panel');if(p)p.classList.add('wide');const lg=document.getElementById('v80-log-content');const arr=document.getElementById('log-arr');if(lg)lg.classList.remove('open');if(arr)arr.textContent='▾';})();
/**
* 渲染各类别入口的卡片及完成进度
*/
const renderEntries = () => {
const entries = scanEntries(), store = loadStore();
const curList = localStorage.getItem(KEY_CURLIST) || '';
const running = localStorage.getItem(KEY_RUNNING) === 'true';
const box = document.getElementById('entry-list');
if (!box) return;
box.innerHTML = '';
entries.forEach(entry => {
const pj01 = qp(entry.url, 'pj01id');
// 从存储中筛选属于该类别的课程
const related = Object.values(store).filter(c => c.url && qp(c.url, 'pj01id') === pj01);
const doneN = related.filter(c => c.done).length;
const totalN = related.length;
const isCur = running && curList && entry.url.includes(qp(curList, 'pj01id'));
const allDone = totalN > 0 && doneN === totalN;
const card = document.createElement('div');
card.className = 'entry-card';
card.innerHTML = `${esc(entry.label)}` +
(totalN ? `${doneN}/${totalN}` : '') +
`${isCur ? '▶ 运行中' : allDone ? '✓ 已完成' : '等待中'}` +
``;
box.appendChild(card);
});
};
window.addEventListener('storage', () => { renderEntries(); renderLogPanel(); });
renderEntries();
}
// ── LIST 页面 (课程列表页) ────────────────────────────────────────
if (location.href.includes('xspj_list.do')) {
buildPanel(
'🎓 自动评教助手',
`
`,
``
);
/**
* 解析当前页面的课程表格,提取课程信息和状态
*/
const parseRows = () => {
const rows = document.querySelectorAll('#dataList tr:not(:first-child)'), result = [];
rows.forEach(row => {
if (row.cells.length < 7) return;
const a = row.querySelector('a[href*="openWindow"]');
if (!a) return;
const rawUrl = a.getAttribute('href').match(/'([^']+)'/)?.[1];
if (!rawUrl) return;
result.push({
key: courseKey(rawUrl), rawUrl,
name: row.cells[2]?.innerText.trim() || '',
teacher: row.cells[3]?.innerText.trim() || '',
zpf: qp(rawUrl, 'zpf'),
evaluated: row.cells[5]?.innerText.trim() === '是',
submitted: row.cells[6]?.innerText.trim() === '是'
});
});
return result;
};
/**
* 更新“批量提交”按钮的状态和提示文字
*/
const updateSubmitBtn = () => {
const btn = document.getElementById('submit-all-btn'), hint = document.getElementById('v80-submit-hint');
if (!btn) return;
const store = loadStore();
const canSubmit = parseRows().filter(c => {
const info = store[c.key];
return (c.evaluated || (info && info.done)) && !c.submitted && (info ? info.auto !== false : true);
});
if (canSubmit.length > 0) {
btn.disabled = false; hint.className = 'visible';
hint.innerHTML = `${canSubmit.length} 门课程可提交(已评价且未提交且选中):` + canSubmit.map(c => ` · ${esc(c.name)}`).join('');
} else { btn.disabled = true; hint.className = ''; hint.innerHTML = ''; }
};
/**
* 渲染课程列表,包含勾选框和实时状态标签
*/
const renderList = () => {
const store = loadStore(), courses = parseRows(), box = document.getElementById('course-list');
if (!box) return;
box.innerHTML = '';
courses.forEach(c => {
// 初始化存储项
if (!store[c.key]) store[c.key] = { auto: true, done: false, name: c.name, teacher: c.teacher, zpf: c.zpf, url: c.rawUrl, pj01id: qp(c.rawUrl, 'pj01id') };
if (c.submitted) store[c.key].done = true;
const info = store[c.key];
let stClass, stLabel;
if (c.submitted) { stClass = 'st-submitted'; stLabel = '已提交'; }
else if (info.auto !== false) { if (c.evaluated || info.done) { stClass = 'st-can-submit'; stLabel = '待提交'; } else { stClass = 'st-wait'; stLabel = '待评价'; } }
else { stClass = 'st-none'; stLabel = '不操作'; }
const el = document.createElement('div');
el.className = 'ci';
el.innerHTML = `` +
`${esc(c.name)}` +
`${esc(c.teacher)}` +
(c.zpf ? `${esc(c.zpf)}分` : '') +
`${stLabel}` +
``;
box.appendChild(el);
});
// 绑定勾选框事件,更新存储
document.querySelectorAll('.course-ck').forEach(ck => {
ck.onchange = (e) => { const k = e.target.getAttribute('data-key'); store[k].auto = e.target.checked; saveStore(store); updateSubmitBtn(); setTimeout(() => renderList(), 0); };
});
saveStore(store);
updateSubmitBtn();
};
/**
* “自动评价并保存”流水线的调度器
*/
const execNext = () => {
if (localStorage.getItem(KEY_RUNNING) !== 'true') return;
if (localStorage.getItem(KEY_BUSY) === 'true') return; // 等待当前窗口保存完毕
const store = loadStore(), curPj01 = qp(location.href, 'pj01id');
// 查找属于当前类别且未完成的待评价课程
const pending = Object.keys(store).filter(k => { const c = store[k]; return c.auto && !c.done && (!curPj01 || qp(c.url, 'pj01id') === curPj01); });
if (pending.length > 0) {
const c = store[pending[0]];
localStorage.setItem(KEY_BUSY, 'true'); // 上锁
logInfo(`▶ 正在保存:${c.name}`);
window.open(withAuto(c.url, 'true'), '_blank', 'width=1200,height=800');
} else {
// 当前类别处理完毕,检查跨类别队列
const queue = loadQueue();
if (queue.length > 0) { const next = queue.shift(); saveQueue(queue); localStorage.setItem(KEY_CURLIST, next); localStorage.setItem(KEY_BUSY, 'false'); setTimeout(() => { location.href = next; }, 800); }
else { localStorage.setItem(KEY_RUNNING, 'false'); localStorage.setItem(KEY_BUSY, 'false'); logSuccess('🎉 所有类别评价已全部完成!'); renderList(); alert('🎉全部评价已完成!'); }
}
};
/**
* “自动提交”流水线的调度器
*/
const execNextSubmit = () => {
if (localStorage.getItem(KEY_SUBRUN) !== 'true') return;
if (localStorage.getItem(KEY_SUBBSY) === 'true') return;
const queue = loadSubQueue();
if (queue.length === 0) { localStorage.setItem(KEY_SUBRUN, 'false'); localStorage.setItem(KEY_SUBBSY, 'false'); logSuccess('🎉 所有勾选课程提交完毕!'); setTimeout(() => location.reload(), 800); return; }
const nextUrl = queue.shift(); saveSubQueue(queue); localStorage.setItem(KEY_SUBBSY, 'true');
const submitStore = loadStore(), submitKey = courseKey(nextUrl), submitInfo = submitKey ? submitStore[submitKey] : null;
logInfo(`▶ 正在提交:${submitInfo ? submitInfo.name + '(' + submitInfo.teacher + ')' : nextUrl}`);
window.open(nextUrl, '_blank', 'width=1200,height=800');
};
// 按钮点击事件绑定
document.getElementById('start-btn').onclick = () => { localStorage.setItem(KEY_RUNNING, 'true'); localStorage.setItem(KEY_BUSY, 'false'); renderList(); execNext(); };
document.getElementById('submit-all-btn').onclick = () => {
const store = loadStore(), toSubmit = parseRows().filter(c => { const info = store[c.key]; return (c.evaluated || (info && info.done)) && !c.submitted && (info ? info.auto !== false : true); });
if (toSubmit.length === 0) return;
if (!confirm(`即将提交以下 ${toSubmit.length} 门课程:\n` + toSubmit.map(c => `· ${c.name}(${c.teacher})`).join('\n') + '\n\n确认继续?')) return;
const queue = toSubmit.map(c => withSubmit(c.rawUrl)); saveSubQueue(queue); localStorage.setItem(KEY_SUBRUN, 'true'); localStorage.setItem(KEY_SUBBSY, 'false'); execNextSubmit();
};
document.getElementById('reset-btn').onclick = () => { if (confirm('重置所有缓存?')) { [KEY_STORE, KEY_RUNNING, KEY_BUSY, KEY_QUEUE, KEY_CURLIST, KEY_SUBQUEUE, KEY_SUBRUN, KEY_SUBBSY].forEach(k => localStorage.removeItem(k)); location.reload(); } };
document.getElementById('clear-log-btn').onclick = () => clearLogs();
// 跨页面状态监听:当其他窗口修改了 busy 标志或完成状态时,本页面及时响应并触发下一步
window.addEventListener('storage', (e) => {
if ([KEY_STORE, KEY_BUSY, KEY_RUNNING].includes(e.key)) { renderList(); renderLogPanel(); if (e.key === KEY_BUSY && e.newValue === 'false' && localStorage.getItem(KEY_RUNNING) === 'true') setTimeout(execNext, 800); }
if (e.key === KEY_SUBBSY && e.newValue === 'false' && localStorage.getItem(KEY_SUBRUN) === 'true') setTimeout(execNextSubmit, 800);
});
renderList();
// 自动恢复执行:若页面刷新时流水线正在运行,则继续
if (localStorage.getItem(KEY_RUNNING) === 'true' && localStorage.getItem(KEY_BUSY) !== 'true') setTimeout(execNext, 1200);
if (localStorage.getItem(KEY_SUBRUN) === 'true' && localStorage.getItem(KEY_SUBBSY) !== 'true') setTimeout(execNextSubmit, 1200);
}
// ── EDIT 页面 (具体课程评价页) ────────────────────────────────────
if (location.href.includes('xspj_edit.do')) {
const params = new URLSearchParams(location.search);
const isAutoSave = params.get(PARAM_AUTO) === 'true';
const isAutoSub = params.get(PARAM_SUBMIT) === 'true';
const isManual = !isAutoSave && !isAutoSub;
// 手动模式:注入顶栏快捷填分工具
if (isManual) {
const initManual = () => {
injectCSS();
const { gkeys, groups } = collectGroups();
if (gkeys.length === 0) return;
ensureValueFields();
const bar = document.createElement('div');
bar.id = 'v80-manual-bar';
bar.style.cssText = 'position:sticky;top:0;left:0;width:100%;z-index:99999;box-sizing:border-box;background:#ebf8ff;border-bottom:2px solid #90cdf4;color:#2c5282;padding:10px 18px;font-family:sans-serif;box-shadow:0 2px 8px rgba(0,0,0,0.08);';
bar.innerHTML = `
🎓 评教助手 V1
手动模式
快捷填分:
未填写
已自动填写,请确认无误后手动点击页面上的「保存」或「提交」按钮。 `;
document.body.prepend(bar);
const scoreDisplay = document.getElementById('v8-score-display');
const manualHint = document.getElementById('v8-manual-hint');
const refreshScore = () => {
const { gkeys: gk2, groups: gr2 } = collectGroups();
const total = calcCurrentTotal(gk2, gr2);
const answered = gk2.filter(k => gr2[k].some(o => o.el.checked)).length;
scoreDisplay.textContent = answered === 0 ? '未填写' : `总分 ${total} (${answered}/${gk2.length}题)`;
scoreDisplay.style.color = '#276749';
};
const strategies = [
{ id: 'v8-fill-highest', s: 'highest', label: '最高分' },
{ id: 'v8-fill-high', s: 'high', label: '中高分' },
{ id: 'v8-fill-mid', s: 'mid', label: '中分' },
{ id: 'v8-fill-low', s: 'low', label: '低分' }
];
strategies.forEach(({ id, s, label }) => {
document.getElementById(id).addEventListener('click', () => {
const { gkeys: gk2, groups: gr2 } = collectGroups();
const total = applyStrategy(s, gk2, gr2);
scoreDisplay.textContent = `当前 ${total} 分(${label})`;
scoreDisplay.style.color = '#276749';
manualHint.style.display = 'block';
});
});
document.querySelectorAll('input[type="radio"]').forEach(r => r.addEventListener('change', refreshScore));
refreshScore();
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => setTimeout(initManual, 300));
else setTimeout(initManual, 300);
return;
}
// 自动模式(保存或提交)
injectCSS();
const bgColor = isAutoSub ? '#f0fff4' : '#ebf8ff';
const bdColor = isAutoSub ? '#9ae6b4' : '#90cdf4';
const textColor = isAutoSub ? '#276749' : '#2c5282';
const modeName = isAutoSub ? '✅ 提交模式' : '💾 保存模式';
const bar = document.createElement('div');
bar.style.cssText = `position:sticky;top:0;left:0;width:100%;z-index:99999;box-sizing:border-box;background:${bgColor};color:${textColor};border-bottom:2px solid ${bdColor};box-shadow:0 2px 8px rgba(0,0,0,0.08);font-family:sans-serif;`;
bar.innerHTML = `
🎓 评教助手 V1
${modeName}
初始化...
总分 0
请确认评分无误后,手动点击浏览器弹出的「确认」按钮 `;
document.body.prepend(bar);
const tag = document.getElementById('edit-tag');
const editLog = (msg, level = 'info') => { tag.textContent = msg; pushLog('[edit] ' + msg, level); };
let stopped = false;
document.getElementById('stop-btn').onclick = () => { stopped = true; editLog('已停止'); document.getElementById('stop-btn').style.display = 'none'; };
// 执行自动提交逻辑
if (isAutoSub) {
setTimeout(() => {
const key = courseKey(location.href), store = loadStore();
editLog('准备提交...');
if (stopped) return;
// 提交模式下也要计算并显示当前总分
const { gkeys, groups } = collectGroups();
const total = calcCurrentTotal(gkeys, groups);
const totalDisplay = document.getElementById('v8-total-display');
if (totalDisplay) totalDisplay.textContent = `总分 ${total}`;
ensureValueFields();
const doSubmit = () => {
const tj = document.getElementById('tj');
if (!tj) { localStorage.setItem(KEY_SUBBSY, 'false'); setTimeout(() => window.close(), 1000); return; }
try {
// 调用教务系统原生的提交函数
unsafeWindow.saveData(tj, '1');
if (key && store[key]) { store[key].done = true; saveStore(store); }
editLog('已提交!', 'success');
} catch (err) {
logError(err.message);
editLog('提交出错,请手动操作', 'error');
}
// 释放互斥锁并关闭窗口
setTimeout(() => { localStorage.setItem(KEY_SUBBSY, 'false'); setTimeout(() => window.close(), 300); }, 800);
};
let tries = 0;
const poll = setInterval(() => {
tries++;
if (document.getElementById('tj') || tries > 10) { clearInterval(poll); doSubmit(); }
}, 500);
}, 800);
} else {
// 执行自动填分并保存逻辑
setTimeout(() => {
const key = courseKey(location.href), store = loadStore();
const { gkeys, groups } = collectGroups();
ensureValueFields();
const total = applyStrategy('highest', gkeys, groups);
document.getElementById('v8-total-display').textContent = `总分 ${total}`;
if (key && store[key]) { store[key].done = true; saveStore(store); }
if (stopped) return;
editLog('填写完成,即将保存');
setTimeout(() => {
if (stopped) return;
const bc = document.getElementById('bc'); // 保存按钮
if (bc) try { unsafeWindow.saveData(bc, '0'); } catch (err) { logError(err.message); }
// 释放互斥锁并关闭窗口
setTimeout(() => { localStorage.setItem(KEY_BUSY, 'false'); setTimeout(() => window.close(), 300); }, 600);
}, 1000);
}, 800);
}
}
})();
|