// ==UserScript==
// @name Enhanced GPA Calculator
// @namespace http://tampermonkey.net/
// @version 2.1.1
// @description 现代化设计的GPA计算器,带有动画效果、深色模式和更多功能,支持全屏自由拖动,正确显示课程名称
// @author Toony (Enhanced by Claude)
// @match https://jw.ahu.edu.cn/student/home
// @grant GM_setValue
// @grant GM_getValue
// ==/UserScript==
(function() {
'use strict';
// 配置选项
const CONFIG = {
defaultExcludedCourses: ['GG18002', 'GG82001'],
animationDuration: 300,
storageKeys: {
position: 'gpaCalculatorPosition',
darkMode: 'gpaCalculatorDarkMode',
excludedCourses: 'gpaCalculatorExcludedCourses',
history: 'gpaCalculatorHistory'
}
};
// 缓存DOM元素的引用
const DOM = {
container: null,
gpaValue: null,
creditsValue: null,
pointsValue: null,
excludedCoursesList: null,
historyList: null
};
// 状态管理
const state = {
isDragging: false,
currentX: 0,
currentY: 0,
initialX: 0,
initialY: 0,
xOffset: 0,
yOffset: 0,
isDarkMode: false,
excludedCourses: [...CONFIG.defaultExcludedCourses],
calculationHistory: [],
currentTab: 'stats' // 'stats', 'courses', 'history'
};
/**
* 计算GPA的核心功能
* @param {Document} doc - 包含成绩表的文档对象
* @returns {Object|null} - 计算结果或null
*/
function calculateGPA(doc) {
try {
const tables = doc.querySelectorAll('.student-grade-table');
if (tables.length === 0) return null;
let totalGPA = 0;
let totalCredits = 0;
let totalGradePoints = 0;
let courseDetails = [];
tables.forEach(table => {
const rows = table.querySelectorAll('tbody tr');
rows.forEach(row => {
try {
const courseCodeElement = row.querySelector('td div.text-color-6.one-line-nowarp span[data-original-title="课程代码"]');
let courseCode = courseCodeElement ? courseCodeElement.textContent.trim() : '';
// 检查课程是否被排除
if (state.excludedCourses.includes(courseCode)) return;
const cells = row.cells;
if (cells.length < 3) return;
// 获取课程名称 - 修复选择器,课程名称在div.course-name中
const courseNameElement = row.querySelector('td div.course-name');
let courseName = courseNameElement ? courseNameElement.textContent.trim() : '未知课程';
const creditsCell = cells[1];
const gpaCell = cells[2];
if (creditsCell && gpaCell) {
const credits = parseFloat(creditsCell.textContent.trim());
const gpa = parseFloat(gpaCell.textContent.trim());
if (!isNaN(credits) && !isNaN(gpa)) {
totalGPA += credits * gpa;
totalCredits += credits;
totalGradePoints += credits * gpa;
// 保存课程详情
courseDetails.push({
code: courseCode,
name: courseName,
credits: credits,
gpa: gpa,
points: credits * gpa
});
}
}
} catch (rowError) {
console.error('处理课程行时出错:', rowError);
}
});
});
if (totalCredits === 0) return null;
// 排序课程详情
courseDetails.sort((a, b) => b.gpa - a.gpa);
const result = {
gpa: totalGPA / totalCredits,
totalCredits: totalCredits,
totalGradePoints: totalGradePoints,
courses: courseDetails,
timestamp: new Date().toISOString()
};
// 保存到历史记录
saveToHistory(result);
return result;
} catch (error) {
console.error('计算GPA时出错:', error);
return null;
}
}
/**
* 保存计算结果到历史记录
* @param {Object} result - GPA计算结果
*/
function saveToHistory(result) {
// 只保存主要数据到历史记录
const historyEntry = {
gpa: result.gpa,
totalCredits: result.totalCredits,
totalGradePoints: result.totalGradePoints,
timestamp: result.timestamp,
excludedCourses: [...state.excludedCourses]
};
// 限制历史记录最多保存10条
state.calculationHistory.unshift(historyEntry);
if (state.calculationHistory.length > 10) {
state.calculationHistory.pop();
}
// 保存到存储
GM_setValue(CONFIG.storageKeys.history, JSON.stringify(state.calculationHistory));
}
/**
* 从存储加载数据
*/
function loadSavedData() {
try {
// 加载位置
const savedPosition = GM_getValue(CONFIG.storageKeys.position);
if (savedPosition) {
const position = JSON.parse(savedPosition);
state.xOffset = position.x || 0;
state.yOffset = position.y || 0;
}
// 加载主题
const savedDarkMode = GM_getValue(CONFIG.storageKeys.darkMode);
if (savedDarkMode !== undefined) {
state.isDarkMode = savedDarkMode === 'true';
}
// 加载排除课程
const savedExcludedCourses = GM_getValue(CONFIG.storageKeys.excludedCourses);
if (savedExcludedCourses) {
state.excludedCourses = JSON.parse(savedExcludedCourses);
}
// 加载历史记录
const savedHistory = GM_getValue(CONFIG.storageKeys.history);
if (savedHistory) {
state.calculationHistory = JSON.parse(savedHistory);
}
} catch (error) {
console.error('加载保存的数据时出错:', error);
// 出错时使用默认值
}
}
/**
* 保存位置信息
*/
function savePosition() {
const position = { x: state.xOffset, y: state.yOffset };
GM_setValue(CONFIG.storageKeys.position, JSON.stringify(position));
}
/**
* 保存深色模式设置
*/
function saveDarkMode() {
GM_setValue(CONFIG.storageKeys.darkMode, state.isDarkMode.toString());
}
/**
* 保存排除课程列表
*/
function saveExcludedCourses() {
GM_setValue(CONFIG.storageKeys.excludedCourses, JSON.stringify(state.excludedCourses));
}
/**
* 创建样式
*/
function injectStyles() {
const style = document.createElement('style');
style.textContent = `
.gpa-calculator {
position: fixed;
top: 0;
left: 0;
width: 320px;
background: linear-gradient(145deg, #ffffff, #f5f5f5);
border-radius: 20px;
padding: 20px;
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
transition: all 0.3s ease, transform 0.1s ease;
z-index: 9999;
max-height: 80vh;
display: flex;
flex-direction: column;
transform: translate(20px, 20px);
}
.gpa-calculator.dark-mode {
background: linear-gradient(145deg, #2d2d2d, #1a1a1a);
color: #ffffff;
box-shadow: 0 10px 20px rgba(0,0,0,0.3);
}
.gpa-calculator:hover {
box-shadow: 0 15px 30px rgba(0,0,0,0.15);
}
.gpa-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #eee;
}
.dark-mode .gpa-header {
border-bottom-color: #444;
}
.gpa-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0;
user-select: none;
}
.dark-mode .gpa-title {
color: #fff;
}
.gpa-controls {
display: flex;
gap: 10px;
}
.gpa-button {
background: #4CAF50;
color: white;
border: none;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 5px;
}
.gpa-button:hover {
background: #45a049;
transform: translateY(-2px);
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.gpa-button:active {
transform: translateY(0);
box-shadow: none;
}
.gpa-button.secondary {
background: #2196F3;
}
.gpa-button.secondary:hover {
background: #1E88E5;
}
.gpa-button.danger {
background: #F44336;
}
.gpa-button.danger:hover {
background: #E53935;
}
.gpa-button.small {
padding: 4px 8px;
font-size: 12px;
}
.gpa-theme-toggle {
background: none;
border: none;
cursor: pointer;
padding: 5px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s ease;
}
.gpa-theme-toggle:hover {
transform: rotate(30deg);
}
.gpa-tabs {
display: flex;
gap: 5px;
margin-bottom: 15px;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
.dark-mode .gpa-tabs {
border-bottom-color: #444;
}
.gpa-tab {
padding: 5px 10px;
cursor: pointer;
border-radius: 5px;
transition: all 0.2s ease;
font-size: 14px;
user-select: none;
}
.gpa-tab:hover {
background: rgba(0, 0, 0, 0.05);
}
.dark-mode .gpa-tab:hover {
background: rgba(255, 255, 255, 0.1);
}
.gpa-tab.active {
background: #4CAF50;
color: white;
}
.tab-content {
display: none;
animation: fadeIn 0.3s ease-out;
overflow-y: auto;
max-height: 300px;
scrollbar-width: thin;
}
.tab-content.active {
display: block;
}
.gpa-content {
display: grid;
gap: 15px;
}
.gpa-stat {
background: rgba(255, 255, 255, 0.9);
padding: 15px;
border-radius: 12px;
display: flex;
flex-direction: column;
gap: 5px;
transition: all 0.2s ease;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}
.dark-mode .gpa-stat {
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}
.gpa-stat:hover {
transform: translateX(5px);
}
.gpa-stat-label {
font-size: 14px;
color: #666;
}
.dark-mode .gpa-stat-label {
color: #aaa;
}
.gpa-stat-value {
font-size: 24px;
font-weight: 600;
color: #2196F3;
}
.dark-mode .gpa-stat-value {
color: #64B5F6;
}
.gpa-error {
color: #f44336;
font-size: 14px;
text-align: center;
padding: 15px;
}
.dark-mode .gpa-error {
color: #ef9a9a;
}
.move-handle {
cursor: move;
padding: 5px;
margin: -5px;
user-select: none;
}
.course-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.course-item {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(255, 255, 255, 0.9);
padding: 10px;
border-radius: 8px;
transition: all 0.2s ease;
}
.dark-mode .course-item {
background: rgba(255, 255, 255, 0.1);
}
.course-info {
flex: 1;
}
.course-code {
font-size: 12px;
color: #666;
}
.dark-mode .course-code {
color: #aaa;
}
.course-name {
font-weight: 500;
}
.course-gpa {
font-weight: 600;
color: #2196F3;
}
.dark-mode .course-gpa {
color: #64B5F6;
}
.excluded-courses {
display: flex;
flex-direction: column;
gap: 10px;
}
.excluded-item {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(255, 255, 255, 0.9);
padding: 10px;
border-radius: 8px;
}
.dark-mode .excluded-item {
background: rgba(255, 255, 255, 0.1);
}
.add-excluded {
display: flex;
gap: 5px;
margin-top: 10px;
}
.add-excluded input {
flex: 1;
padding: 8px;
border-radius: 5px;
border: 1px solid #ddd;
background: #fff;
}
.dark-mode .add-excluded input {
background: #333;
border-color: #555;
color: #fff;
}
.history-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.history-item {
background: rgba(255, 255, 255, 0.9);
padding: 15px;
border-radius: 12px;
transition: all 0.2s ease;
}
.dark-mode .history-item {
background: rgba(255, 255, 255, 0.1);
}
.history-date {
font-size: 12px;
color: #666;
margin-bottom: 5px;
}
.dark-mode .history-date {
color: #aaa;
}
.history-value {
font-size: 18px;
font-weight: 600;
color: #2196F3;
}
.dark-mode .history-value {
color: #64B5F6;
}
.history-details {
font-size: 14px;
color: #666;
margin-top: 5px;
}
.dark-mode .history-details {
color: #aaa;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* 滚动条样式 */
.tab-content::-webkit-scrollbar {
width: 6px;
}
.tab-content::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
border-radius: 10px;
}
.tab-content::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 10px;
}
.dark-mode .tab-content::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
.dark-mode .tab-content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
`;
document.head.appendChild(style);
}
/**
* 创建UI界面
* @returns {HTMLElement} 计算器容器元素
*/
function createGPADisplay() {
const container = document.createElement('div');
container.className = 'gpa-calculator';
if (state.isDarkMode) {
container.classList.add('dark-mode');
}
container.innerHTML = `
<div class="gpa-header">
<div class="move-handle">
<div class="gpa-title">GPA 计算器</div>
</div>
<div class="gpa-controls">
<button class="gpa-theme-toggle" title="切换主题">${state.isDarkMode ? '🌞' : '🌙'}</button>
<button class="gpa-button" id="calculate-gpa">
<span>计算</span>
<span class="calculation-icon">📊</span>
</button>
</div>
</div>
<div class="gpa-tabs">
<div class="gpa-tab active" data-tab="stats">统计</div>
<div class="gpa-tab" data-tab="courses">课程</div>
<div class="gpa-tab" data-tab="history">历史</div>
</div>
<div id="stats-tab" class="tab-content active">
<div class="gpa-content">
<div class="gpa-stat">
<span class="gpa-stat-label">总平均 GPA</span>
<span class="gpa-stat-value" id="gpa-value">-</span>
</div>
<div class="gpa-stat">
<span class="gpa-stat-label">总学分</span>
<span class="gpa-stat-value" id="credits-value">-</span>
</div>
<div class="gpa-stat">
<span class="gpa-stat-label">总绩点</span>
<span class="gpa-stat-value" id="points-value">-</span>
</div>
</div>
</div>
<div id="courses-tab" class="tab-content">
<h3>排除的课程</h3>
<div class="excluded-courses" id="excluded-courses-list">
${state.excludedCourses.map(code => `
<div class="excluded-item" data-code="${code}">
<span>${code}</span>
<button class="gpa-button small danger remove-excluded">移除</button>
</div>
`).join('')}
</div>
<div class="add-excluded">
<input type="text" id="new-excluded-course" placeholder="输入课程代码">
<button class="gpa-button small" id="add-excluded-btn">添加</button>
</div>
<h3>课程列表</h3>
<div class="course-list" id="course-list">
<div class="gpa-error">请先计算GPA以查看课程列表</div>
</div>
</div>
<div id="history-tab" class="tab-content">
<div class="history-list" id="history-list">
${state.calculationHistory.length === 0 ?
'<div class="gpa-error">暂无历史记录</div>' :
state.calculationHistory.map(entry => {
const date = new Date(entry.timestamp);
return `
<div class="history-item">
<div class="history-date">${date.toLocaleString()}</div>
<div class="history-value">GPA: ${entry.gpa.toFixed(4)}</div>
<div class="history-details">
学分: ${entry.totalCredits.toFixed(1)} |
总绩点: ${entry.totalGradePoints.toFixed(4)}
</div>
</div>
`;
}).join('')
}
</div>
</div>
`;
return container;
}
/**
* 使元素可拖动
* @param {HTMLElement} element - 需要拖动的元素
*/
function makeDraggable(element) {
const handle = element.querySelector('.move-handle');
handle.addEventListener('mousedown', dragStart);
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', dragEnd);
function dragStart(e) {
if (e.button !== 0) return; // 只响应左键
state.initialX = e.clientX - state.xOffset;
state.initialY = e.clientY - state.yOffset;
if (e.target === handle || handle.contains(e.target)) {
state.isDragging = true;
element.style.transition = 'none';
}
}
function drag(e) {
if (state.isDragging) {
e.preventDefault();
state.currentX = e.clientX - state.initialX;
state.currentY = e.clientY - state.initialY;
// 边界检查
const rect = element.getBoundingClientRect();
const maxX = window.innerWidth - rect.width;
const maxY = window.innerHeight - rect.height;
state.xOffset = Math.min(Math.max(0, state.currentX), maxX);
state.yOffset = Math.min(Math.max(0, state.currentY), maxY);
updateElementPosition(element);
}
}
function dragEnd() {
if (state.isDragging) {
state.isDragging = false;
element.style.transition = 'all 0.3s ease, transform 0.1s ease';
savePosition();
}
}
}
/**
* 更新元素位置
* @param {HTMLElement} element - 需要更新位置的元素
*/
function updateElementPosition(element) {
element.style.transform = `translate(${state.xOffset}px, ${state.yOffset}px)`;
}
/**
* 更新GPA显示
* @param {Object|null} gpaData - GPA计算结果
*/
function updateGPADisplay(gpaData) {
if (gpaData === null) {
const statsContent = DOM.container.querySelector('.gpa-content');
statsContent.innerHTML = `
<div class="gpa-error">
未找到成绩表格或有效数据
</div>
`;
// 清空课程列表
const courseList = DOM.container.querySelector('#course-list');
courseList.innerHTML = '<div class="gpa-error">无可用数据</div>';
return;
}
// 更新统计数据
DOM.gpaValue.textContent = gpaData.gpa.toFixed(4);
DOM.creditsValue.textContent = gpaData.totalCredits.toFixed(1);
DOM.pointsValue.textContent = gpaData.totalGradePoints.toFixed(4);
// 添加动画效果
[DOM.gpaValue, DOM.creditsValue, DOM.pointsValue].forEach(el => {
el.style.animation = 'none';
el.offsetHeight; // 触发重绘
el.style.animation = 'fadeIn 0.5s ease-out';
});
// 更新课程列表
const courseList = DOM.container.querySelector('#course-list');
if (gpaData.courses && gpaData.courses.length > 0) {
courseList.innerHTML = gpaData.courses.map(course => `
<div class="course-item">
<div class="course-info">
<div class="course-code">${course.code}</div>
<div class="course-name">${course.name}</div>
</div>
<div class="course-gpa">${course.gpa.toFixed(2)}</div>
</div>
`).join('');
} else {
courseList.innerHTML = '<div class="gpa-error">无课程数据</div>';
}
// 更新历史记录列表
updateHistoryList();
}
/**
* 更新历史记录列表
*/
function updateHistoryList() {
if (!DOM.historyList) return;
if (state.calculationHistory.length === 0) {
DOM.historyList.innerHTML = '<div class="gpa-error">暂无历史记录</div>';
return;
}
DOM.historyList.innerHTML = state.calculationHistory.map(entry => {
const date = new Date(entry.timestamp);
return `
<div class="history-item">
<div class="history-date">${date.toLocaleString()}</div>
<div class="history-value">GPA: ${entry.gpa.toFixed(4)}</div>
<div class="history-details">
学分: ${entry.totalCredits.toFixed(1)} |
总绩点: ${entry.totalGradePoints.toFixed(4)}
</div>
</div>
`;
}).join('');
}
/**
* 更新排除课程列表
*/
function updateExcludedCoursesList() {
if (!DOM.excludedCoursesList) return;
DOM.excludedCoursesList.innerHTML = state.excludedCourses.map(code => `
<div class="excluded-item" data-code="${code}">
<span>${code}</span>
<button class="gpa-button small danger remove-excluded">移除</button>
</div>
`).join('');
// 重新绑定移除按钮事件
DOM.excludedCoursesList.querySelectorAll('.remove-excluded').forEach(btn => {
btn.addEventListener('click', function() {
const code = this.parentElement.dataset.code;
const index = state.excludedCourses.indexOf(code);
if (index > -1) {
state.excludedCourses.splice(index, 1);
updateExcludedCoursesList();
saveExcludedCourses();
}
});
});
}
/**
* 初始化标签页切换功能
*/
function initTabs() {
const tabs = DOM.container.querySelectorAll('.gpa-tab');
const tabContents = DOM.container.querySelectorAll('.tab-content');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
// 移除所有活动标签
tabs.forEach(t => t.classList.remove('active'));
tabContents.forEach(c => c.classList.remove('active'));
// 激活当前标签
tab.classList.add('active');
const tabId = tab.dataset.tab;
DOM.container.querySelector(`#${tabId}-tab`).classList.add('active');
state.currentTab = tabId;
});
});
}
/**
* 计算GPA并更新显示
*/
function calculateAndUpdate() {
const iframe = document.querySelector('iframe');
if (iframe) {
try {
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
if (iframeDoc) {
const gpaData = calculateGPA(iframeDoc);
updateGPADisplay(gpaData);
} else {
updateGPADisplay(null);
}
} catch (error) {
console.error('访问iframe内容时出错:', error);
updateGPADisplay(null);
}
} else {
updateGPADisplay(null);
}
}
/**
* 绑定事件
*/
function bindEvents() {
// 计算按钮事件
const calculateButton = DOM.container.querySelector('#calculate-gpa');
calculateButton.addEventListener('click', calculateAndUpdate);
// 主题切换
const themeToggle = DOM.container.querySelector('.gpa-theme-toggle');
themeToggle.addEventListener('click', () => {
state.isDarkMode = !state.isDarkMode;
DOM.container.classList.toggle('dark-mode');
themeToggle.innerHTML = state.isDarkMode ? '🌞' : '🌙';
saveDarkMode();
});
// 添加排除课程
const addExcludedBtn = DOM.container.querySelector('#add-excluded-btn');
const newExcludedInput = DOM.container.querySelector('#new-excluded-course');
if (addExcludedBtn && newExcludedInput) {
addExcludedBtn.addEventListener('click', () => {
const code = newExcludedInput.value.trim();
if (code && !state.excludedCourses.includes(code)) {
state.excludedCourses.push(code);
updateExcludedCoursesList();
saveExcludedCourses();
newExcludedInput.value = '';
}
});
newExcludedInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
addExcludedBtn.click();
}
});
}
// 监听窗口大小变化,确保计算器不会超出界面
window.addEventListener('resize', () => {
const rect = DOM.container.getBoundingClientRect();
const maxX = window.innerWidth - rect.width;
const maxY = window.innerHeight - rect.height;
if (state.xOffset > maxX || state.yOffset > maxY) {
state.xOffset = Math.min(state.xOffset, maxX);
state.yOffset = Math.min(state.yOffset, maxY);
updateElementPosition(DOM.container);
savePosition();
}
});
}
/**
* 缓存DOM引用
*/
function cacheDOMReferences() {
DOM.container = document.querySelector('.gpa-calculator');
DOM.gpaValue = DOM.container.querySelector('#gpa-value');
DOM.creditsValue = DOM.container.querySelector('#credits-value');
DOM.pointsValue = DOM.container.querySelector('#points-value');
DOM.excludedCoursesList = DOM.container.querySelector('#excluded-courses-list');
DOM.historyList = DOM.container.querySelector('#history-list');
}
/**
* 初始化应用
*/
function init() {
// 加载保存的数据
loadSavedData();
// 创建UI
injectStyles();
DOM.container = createGPADisplay();
document.body.appendChild(DOM.container);
// 缓存DOM引用
cacheDOMReferences();
// 初始化功能
updateElementPosition(DOM.container);
makeDraggable(DOM.container);
initTabs();
bindEvents();
// 应用深色模式
if (state.isDarkMode) {
DOM.container.classList.add('dark-mode');
}
}
// 启动应用
init();
})();