您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds a category-aware grade predictor with goal seeking, alerts, and visualizations, plus an "All Grades" dashboard with trend charts.
// ==UserScript== // @name Moodle Grade Dashboard & Predictor // @namespace Violentmonkey Scripts // @match https://moodle.colby.edu/grade/report/user/index.php* // @match https://moodle.colby.edu/my/ // @grant GM_addStyle // @license MIT // @version 4.1 // @author - // @description Adds a category-aware grade predictor with goal seeking, alerts, and visualizations, plus an "All Grades" dashboard with trend charts. // @description:fix v4.1 - Fixed a critical syntax error that occurred when adding a new hypothetical assignment row. // ==/UserScript== (function() { 'use strict'; // --- SHARED UTILITIES --- const STORAGE_KEY = 'moodleGradeTracker'; const getStoredData = () => JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; const setStoredData = (data) => localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); // --- STYLES --- GM_addStyle(` /* --- General Predictor Styles --- */ #gradePredictorContainer { margin-top: 40px; padding: 25px; background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; } #gradePredictorContainer h2 { margin-top: 0; margin-bottom: 20px; border-bottom: 2px solid #007bff; padding-bottom: 10px; color: #333; } .predictor-body { display: flex; gap: 30px; flex-wrap: wrap; } .predictor-assignments { flex: 3; min-width: 450px; } .predictor-sidebar { flex: 2; min-width: 300px; display: flex; flex-direction: column; gap: 20px; } .predictor-module { padding: 20px; background-color: #fff; border-radius: 5px; border: 1px solid #ddd; text-align: center; } .predictor-module h4 { margin-top: 0; color: #007bff; border-bottom: 1px solid #eee; padding-bottom: 10px; margin-bottom: 15px; } .grade-category h4 { margin: 15px 0 10px 0; color: #555; font-size: 16px; border-bottom: 1px solid #ccc; padding-bottom: 5px; } .assignment-row { display: flex; justify-content: space-between; margin-bottom: 12px; align-items: center; padding: 8px; border-radius: 4px; background-color: #fff; border: 1px solid #eee; } .assignment-row input { padding: 6px; border: 1px solid #ccc; border-radius: 4px; } .assignment-name { flex-grow: 1; font-size: 14px; margin-right: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } input.hypothetical-name { width: 40%; margin-right: 10px; } input.hypothetical-grade, input.hypothetical-max-grade { width: 60px; } input.hypothetical-weight { width: 70px; margin-left: 10px; } #addAssignmentBtn { margin-top: 20px; padding: 10px 15px; width: 100%; background-color: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; } #predictedGrade { font-size: 48px; font-weight: bold; color: #0056b3; margin: 10px 0; } #predictedLetterGrade { font-size: 24px; font-weight: bold; color: #495057; } #totalWeightInfo { font-size: 12px; color: #6c757d; margin-top: 15px; } /* --- Goal Seeker Styles --- */ #goalSeeker .goal-inputs { display: flex; gap: 10px; justify-content: center; margin-bottom: 15px; } #goalSeeker input { width: 100px; text-align: center; padding: 5px; } #goalSeeker button { padding: 8px 12px; background-color: #17a2b8; color: white; border: none; border-radius: 4px; cursor: pointer; } #goalSeekerResult { font-size: 18px; font-weight: bold; margin-top: 10px; min-height: 25px; } /* --- Alerts Styles --- */ #gradeAlerts .alert { padding: 12px; margin-bottom: 10px; border-radius: 5px; text-align: left; } #gradeAlerts .alert-critical { background-color: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; } #gradeAlerts .alert-warning { background-color: #fff3cd; border: 1px solid #ffeeba; color: #856404; } #gradeAlerts .alert-ok { background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724; } /* --- Chart Styles --- */ #categoryWeightChartContainer { position: relative; } #categoryWeightChart { display: block; margin: 10px auto; } #chartLegend { list-style: none; padding: 0; margin-top: 15px; display: flex; flex-wrap: wrap; justify-content: center; gap: 15px; } #chartLegend li { display: flex; align-items: center; font-size: 12px; } #chartLegend span { width: 12px; height: 12px; border-radius: 50%; margin-right: 6px; } .trend-chart-container { margin-top: 10px; padding: 10px; background: #f9f9f9; border-radius: 4px; } /* --- Dashboard Modal Styles --- */ #allGradesBtn { background-color: #007bff; color: white; border: none; padding: 10px 20px; font-size: 16px; border-radius: 5px; cursor: pointer; margin: 15px 0; } #gradesModal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.4); } .modal-content { background-color: #fefefe; margin: 8% auto; padding: 20px; border: 1px solid #888; width: 80%; max-width: 800px; border-radius: 8px; } .modal-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #ddd; padding-bottom: 10px; } .modal-header h2 { margin: 0; } .close-btn { color: #aaa; font-size: 28px; font-weight: bold; cursor: pointer; } .course-grade-summary { display: grid; grid-template-columns: 2fr 1fr 1fr 80px; gap: 15px; padding: 15px 10px; border-bottom: 1px solid #eee; align-items: center; } .course-name { font-weight: bold; } .course-last-updated { font-size: 12px; color: #888; } .course-final-grade { font-size: 20px; font-weight: bold; text-align: center; } .course-trend { font-size: 16px; text-align: center; } .trend-up { color: #28a745; } .trend-down { color: #dc3545; } .trend-stable { color: #6c757d; } .show-trend-btn { font-size: 12px; padding: 4px 8px; cursor: pointer; border: 1px solid #ccc; border-radius: 4px; background: #efefef; } `); // --- ROUTER --- if (window.location.pathname.includes('/grade/report/user/index.php')) { runGradePredictorPage(); } else if (window.location.pathname.includes('/my/')) { runDashboardPage(); } // --- GRADE PREDICTOR PAGE --- function runGradePredictorPage() { let categories = {}; let allAssignments = []; let courseId = new URLSearchParams(window.location.search).get('id'); let courseName = document.querySelector('h1').textContent; const predictorHTML = ` <div id="gradePredictorContainer"> <h2>Grade Dashboard for ${courseName}</h2> <div class="predictor-body"> <div class="predictor-assignments"> <div id="gradeCategories"></div> <div id="hypotheticalAssignments" class="grade-category"><h4>Add Future Assignments</h4></div> <button id="addAssignmentBtn">+ Add Assignment</button> </div> <div class="predictor-sidebar"> <div class="predictor-module" id="gradeSummary"> <h4>Predicted Grade</h4> <div id="predictedGrade">--%</div> <div id="predictedLetterGrade"></div> <div id="totalWeightInfo"></div> </div> <div class="predictor-module" id="categoryWeightContainer"> <h4>Category Weights</h4> <div id="categoryWeightChartContainer"></div> <ul id="chartLegend"></ul> </div> <div class="predictor-module" id="goalSeeker"> <h4>Goal Seeker</h4> <p style="font-size: 13px; color: #666;">What average grade do I need on my remaining assignments to get a final grade of...</p> <div class="goal-inputs"> <input type="number" id="targetGradeInput" placeholder="e.g., 90"> <input type="number" id="remainingWeightInput" placeholder="Remaining %"> </div> <div id="goalSeekerResult"></div> </div> <div class="predictor-module" id="gradeAlertsContainer"> <h4>Suggestions & Alerts</h4> <div id="gradeAlerts"></div> </div> </div> </div> </div> `; document.getElementById('region-main').insertAdjacentHTML('beforeend', predictorHTML); function parseGrades() { const gradeTable = document.querySelector('.user-grade'); if (!gradeTable) return; categories = {}; allAssignments = []; let currentCategory = 'Uncategorized'; const rows = gradeTable.querySelectorAll('tbody > tr:not(.spacer)'); rows.forEach(row => { if (row.classList.contains('category')) { const catNameElem = row.querySelector('a + span'); if (catNameElem) currentCategory = catNameElem.textContent.trim(); } else if (row.querySelector('.column-itemname .gradeitemheader')) { const nameElem = row.querySelector('.column-itemname .gradeitemheader'); let weight = parseFloat(row.querySelector('.column-weight').textContent.replace('%', '')) / 100; let grade = parseFloat(row.querySelector('.column-grade').textContent); let maxGrade = parseFloat(row.querySelector('.column-range').textContent.split('–')[1]); if (!isNaN(weight) && !isNaN(grade) && !isNaN(maxGrade) && weight > 0) { const assignment = { name: nameElem.textContent.trim(), grade, maxGrade, weight, isHypothetical: false }; if (!categories[currentCategory]) categories[currentCategory] = []; categories[currentCategory].push(assignment); allAssignments.push(assignment); } } }); renderCategories(); runAllCalculations(); } function renderCategories() { const container = document.getElementById('gradeCategories'); container.innerHTML = ''; for (const catName in categories) { if (categories[catName].every(a => a.isHypothetical)) continue; const catDiv = document.createElement('div'); catDiv.className = 'grade-category'; catDiv.innerHTML = `<h4>${catName}</h4>`; categories[catName].filter(a => !a.isHypothetical).forEach(a => { const row = document.createElement('div'); row.className = 'assignment-row'; row.innerHTML = `<span class="assignment-name" title="${a.name}">${a.name}</span><span><strong>${(a.weight * 100).toFixed(1)}%</strong></span><span>${a.grade.toFixed(2)}/${a.maxGrade.toFixed(2)}</span>`; catDiv.appendChild(row); }); container.appendChild(catDiv); } } function runAllCalculations() { const { finalGrade } = calculateGrade(); storeGrade(finalGrade); runAlertsAndSuggestions(); renderCategoryWeightChart(); setupGoalSeeker(); } function calculateGrade() { let totalWeightedGrade = 0; let totalWeight = 0; allAssignments.forEach(a => { if (a.weight > 0 && a.maxGrade > 0) { totalWeightedGrade += (a.grade / a.maxGrade) * a.weight; totalWeight += a.weight; } }); const finalGrade = totalWeight > 0 ? (totalWeightedGrade / totalWeight) * 100 : 0; document.getElementById('predictedGrade').textContent = `${finalGrade.toFixed(2)}%`; document.getElementById('totalWeightInfo').textContent = `Based on ${(totalWeight * 100).toFixed(0)}% of course weight.`; return { finalGrade, totalWeight, totalWeightedGrade }; } function storeGrade(finalGrade) { const data = getStoredData(); if (!data[courseId]) data[courseId] = { name: courseName, history: [] }; const history = data[courseId].history; if (history.length === 0 || history[history.length - 1].grade !== finalGrade.toFixed(2)) { history.push({ date: new Date().toISOString().split('T')[0], grade: finalGrade.toFixed(2) }); if (history.length > 10) history.shift(); } data[courseId].name = courseName; setStoredData(data); } function runAlertsAndSuggestions() { const container = document.getElementById('gradeAlerts'); container.innerHTML = ''; let hasAlerts = false; allAssignments.filter(a => !a.isHypothetical).forEach(a => { if (a.grade === 0) { hasAlerts = true; container.innerHTML += `<div class="alert alert-critical"><strong>CRITICAL:</strong> "${a.name}" has a score of 0. This is significantly impacting your grade.</div>`; } if ((a.grade / a.maxGrade) < 0.75 && a.weight >= 0.10) { hasAlerts = true; container.innerHTML += `<div class="alert alert-warning"><strong>Warning:</strong> Your score on "${a.name}" is below 75% on an item worth ${(a.weight * 100).toFixed(0)}% of your grade.</div>`; } }); if (!hasAlerts) { container.innerHTML = `<div class="alert alert-ok"><strong>All Good!</strong> No immediate issues detected. Keep up the great work!</div>`; } } function renderCategoryWeightChart() { const container = document.getElementById('categoryWeightChartContainer'); const legend = document.getElementById('chartLegend'); container.innerHTML = ''; legend.innerHTML = ''; const categoryWeights = {}; let totalChartWeight = 0; Object.keys(categories).forEach(catName => { const catWeight = categories[catName].reduce((sum, a) => sum + a.weight, 0); if (catWeight > 0) { categoryWeights[catName] = catWeight; totalChartWeight += catWeight; } }); const colors = ['#007bff', '#28a745', '#dc3545', '#ffc107', '#17a2b8', '#6f42c1']; let cumulativePercent = 0; const radius = 50; const circumference = 2 * Math.PI * radius; let svg = `<svg id="categoryWeightChart" width="120" height="120" viewBox="0 0 120 120">`; let colorIndex = 0; for (const catName in categoryWeights) { const percent = (categoryWeights[catName] / totalChartWeight) * 100; const color = colors[colorIndex % colors.length]; const offset = circumference - (cumulativePercent / 100 * circumference); svg += `<circle r="${radius}" cx="60" cy="60" fill="transparent" stroke="${color}" stroke-width="20" stroke-dasharray="${(percent/100)*circumference} ${circumference}" transform="rotate(-90 60 60)" style="stroke-dashoffset: ${offset};"></circle>`; legend.innerHTML += `<li><span style="background-color: ${color};"></span>${catName} (${(percent).toFixed(0)}%)</li>`; cumulativePercent += percent; colorIndex++; } svg += `</svg>`; container.innerHTML = svg; } function setupGoalSeeker() { const targetInput = document.getElementById('targetGradeInput'); const weightInput = document.getElementById('remainingWeightInput'); const resultDiv = document.getElementById('goalSeekerResult'); const calculateGoal = () => { const targetGrade = parseFloat(targetInput.value) / 100; const remainingWeight = parseFloat(weightInput.value) / 100; if (isNaN(targetGrade) || isNaN(remainingWeight) || remainingWeight <= 0) { resultDiv.textContent = ''; return; } const { totalWeight, totalWeightedGrade } = calculateGrade(); if (totalWeight + remainingWeight > 1.01) { // Allow for rounding errors resultDiv.textContent = 'Error: Weights exceed 100%'; return; } const currentScore = totalWeightedGrade; const targetScore = targetGrade * (totalWeight + remainingWeight); const neededScore = targetScore - currentScore; const requiredAvg = (neededScore / remainingWeight) * 100; if (requiredAvg > 100) { resultDiv.innerHTML = `<span style="color: #dc3545;">Need: ${requiredAvg.toFixed(2)}% (High)</span>`; } else if (requiredAvg < 0) { resultDiv.innerHTML = `<span style="color: #28a745;">Goal is already met!</span>`; } else { resultDiv.innerHTML = `<span style="color: #0056b3;">Need: ${requiredAvg.toFixed(2)}%</span>`; } }; targetInput.oninput = calculateGoal; weightInput.oninput = calculateGoal; } document.getElementById('addAssignmentBtn').addEventListener('click', () => { const hypoCategory = 'Hypothetical'; if (!categories[hypoCategory]) categories[hypoCategory] = []; const newAssignment = { name: 'New Assignment', grade: 0, maxGrade: 100, weight: 0.1, isHypothetical: true }; categories[hypoCategory].push(newAssignment); allAssignments.push(newAssignment); const index = categories[hypoCategory].length - 1; const container = document.getElementById('hypotheticalAssignments'); const row = document.createElement('div'); row.className = 'assignment-row'; // *** FIX WAS APPLIED HERE *** The last input tag was missing its closing '>' row.innerHTML = `<input type="text" value="${newAssignment.name}" class="hypo-name" data-index="${index}"><span><input type="number" value="${newAssignment.grade}" class="hypo-grade" data-index="${index}">/<input type="number" value="${newAssignment.maxGrade}" class="hypo-max" data-index="${index}"></span><input type="number" value="${(newAssignment.weight*100).toFixed(0)}" class="hypo-weight" data-index="${index}" placeholder="%">`; container.appendChild(row); row.querySelectorAll('input').forEach(input => input.addEventListener('input', (e) => { const idx = parseInt(e.target.dataset.index); const assignment = categories[hypoCategory][idx]; if (e.target.classList.contains('hypo-name')) assignment.name = e.target.value; else if (e.target.classList.contains('hypo-grade')) assignment.grade = parseFloat(e.target.value) || 0; else if (e.target.classList.contains('hypo-max')) assignment.maxGrade = parseFloat(e.target.value) || 100; else if (e.target.classList.contains('hypo-weight')) assignment.weight = parseFloat(e.target.value) / 100 || 0; runAllCalculations(); })); runAllCalculations(); }); parseGrades(); } // --- DASHBOARD PAGE --- function runDashboardPage() { const dashboardHeader = document.querySelector('#page-header .page-header-headings'); if (!dashboardHeader) return; const button = document.createElement('button'); button.id = 'allGradesBtn'; button.textContent = 'View All Grades Summary'; dashboardHeader.parentNode.insertBefore(button, dashboardHeader.nextSibling); const modalHTML = `<div id="gradesModal"><div class="modal-content"><div class="modal-header"><h2>All Grades Summary</h2><span class="close-btn">×</span></div><div id="modalBody"></div></div></div>`; document.body.insertAdjacentHTML('beforeend', modalHTML); const modal = document.getElementById('gradesModal'); button.onclick = () => { renderModalContent(); modal.style.display = 'block'; }; document.querySelector('.close-btn').onclick = () => modal.style.display = 'none'; window.onclick = (event) => { if (event.target == modal) modal.style.display = 'none'; }; function renderModalContent() { const modalBody = document.getElementById('modalBody'); const data = getStoredData(); if (Object.keys(data).length === 0) { modalBody.innerHTML = `<p>No grade data found. Visit a course's grade page first to populate this summary.</p>`; return; } let content = ''; for (const courseId in data) { const course = data[courseId]; if (!course.history || course.history.length === 0) continue; const latest = course.history[course.history.length - 1]; let trendHTML = '<span class="trend-stable">—</span>'; if (course.history.length > 1) { const prev = course.history[course.history.length - 2]; const diff = parseFloat(latest.grade) - parseFloat(prev.grade); if (diff > 0) trendHTML = `<span class="trend-up">▲ +${diff.toFixed(2)}%</span>`; else if (diff < 0) trendHTML = `<span class="trend-down">▼ ${diff.toFixed(2)}%</span>`; } content += ` <div id="summary-${courseId}"> <div class="course-grade-summary"> <div><div class="course-name">${course.name}</div><div class="course-last-updated">Last calculated: ${new Date(latest.date).toLocaleDateString()}</div></div> <div class="course-final-grade">${parseFloat(latest.grade).toFixed(2)}%</div> <div class="course-trend">${trendHTML}</div> <button class="show-trend-btn" data-courseid="${courseId}">Show Trend</button> </div> </div>`; } modalBody.innerHTML = content; document.querySelectorAll('.show-trend-btn').forEach(btn => btn.onclick = toggleTrendChart); } function toggleTrendChart(event) { const btn = event.target; const courseId = btn.dataset.courseid; const container = document.getElementById(`summary-${courseId}`); const existingChart = container.querySelector('.trend-chart-container'); if (existingChart) { existingChart.remove(); btn.textContent = 'Show Trend'; } else { const data = getStoredData()[courseId]; if (data && data.history.length > 1) { const chartDiv = document.createElement('div'); chartDiv.className = 'trend-chart-container'; createTrendChart(data.history, chartDiv); container.appendChild(chartDiv); btn.textContent = 'Hide Trend'; } } } function createTrendChart(history, container) { const grades = history.map(p => parseFloat(p.grade)); const minGrade = Math.min(...grades); const maxGrade = Math.max(...grades); const gradeRange = maxGrade - minGrade; const w = 600, h = 150, p = 30; const gradeSpan = gradeRange < 10 ? 10 : gradeRange; // Min 10pt span to avoid flat lines const y_start = Math.floor(minGrade - (gradeSpan * 0.1)); const getX = (i) => p + i * (w - 2*p) / (history.length - 1); const getY = (grade) => h - p - ((grade - y_start) / gradeSpan * (h - 2*p)); let points = ""; history.forEach((point, i) => { points += `${getX(i)},${getY(parseFloat(point.grade))} `; }); let y_axis_labels = ''; for(let i=0; i<=2; i++){ const grade = y_start + (i/2 * gradeSpan); y_axis_labels += `<text x="${p-10}" y="${getY(grade)}" text-anchor="end" alignment-baseline="middle" font-size="10" fill="#666">${grade.toFixed(1)}%</text>`; } container.innerHTML = ` <svg width="100%" viewBox="0 0 ${w} ${h}"> <line x1="${p}" y1="${h-p}" x2="${w-p}" y2="${h-p}" stroke="#ccc"/> <line x1="${p}" y1="${p}" x2="${p}" y2="${h-p}" stroke="#ccc"/> ${y_axis_labels} <polyline points="${points}" fill="none" stroke="#007bff" stroke-width="2"/> </svg>`; } } })();