// ==UserScript== // @name 新版北大树洞美化 // @namespace http://tampermonkey.net/ // @version 2.0.1 // @description 新版北大树洞美化 // @match *://treehole.pku.edu.cn/* // @run-at document-start // @grant GM_addStyle // @grant unsafeWindow // ==/UserScript== (function () { "use strict"; const W = unsafeWindow; // ==================== 变量 ==================== const isGradesPage = () => W.location.href.includes("/grades"); let gradeData = null; let gradeUIInjected = false; let gradeStylesInjected = false; let lastUpdated = null; let autoReloadTimer = null; let autoReloadEnabled = false; let nextUpdateTime = null; let uiUpdateTimer = null; const AUTO_RELOAD_INTERVAL = 300000; let options = { hideText: false, judgeByGpa: false, collapseAll: false, gradePreset: "no_calculation", }; let newBlocks = []; let shownCourseIds = []; let pendingShownIds = null; let currentNotification = null; const STORAGE_KEYS = { hideText: "GRADE_HUH_HIDE_TEXT", judgeByGpa: "GRADE_HUH_JUDGE_BY_GPA", collapseAll: "GRADE_HUH_COLLAPSE_ALL", gradePreset: "GRADE_HUH_PRESET_ID", shownCourses: "GRADE_HUH_SCORE_SHOWN", customMappings: "GRADE_HUH_CUSTOM_MAPPINGS", customStrategy: "GRADE_HUH_CUSTOM_STRATEGY", }; function readShownCourseIds() { try { const stored = localStorage.getItem(STORAGE_KEYS.shownCourses); if (!stored) return []; const parsed = JSON.parse(stored); return Array.isArray(parsed) ? parsed.map(String) : []; } catch (e) { return []; } } function writeShownCourseIds(ids) { try { localStorage.setItem(STORAGE_KEYS.shownCourses, JSON.stringify(ids)); } catch (e) {} } function loadOptions() { try { const hideText = localStorage.getItem(STORAGE_KEYS.hideText); if (hideText !== null) options.hideText = hideText === "1"; const judgeByGpa = localStorage.getItem(STORAGE_KEYS.judgeByGpa); if (judgeByGpa !== null) options.judgeByGpa = judgeByGpa === "1"; const collapseAll = localStorage.getItem(STORAGE_KEYS.collapseAll); if (collapseAll !== null) options.collapseAll = collapseAll === "1"; const gradePreset = localStorage.getItem(STORAGE_KEYS.gradePreset); if (gradePreset && gradePreset in GRADE_PRESETS) { options.gradePreset = gradePreset; } } catch (e) { console.error("Failed to load options", e); } } function saveOptions() { try { localStorage.setItem(STORAGE_KEYS.hideText, options.hideText ? "1" : "0"); localStorage.setItem( STORAGE_KEYS.judgeByGpa, options.judgeByGpa ? "1" : "0", ); localStorage.setItem( STORAGE_KEYS.collapseAll, options.collapseAll ? "1" : "0", ); localStorage.setItem(STORAGE_KEYS.gradePreset, options.gradePreset); } catch (e) { console.error("Failed to save options", e); } } function detectNewCourses(allCourses) { if (shownCourseIds.length === 0) { shownCourseIds = readShownCourseIds(); } const allIds = allCourses.map((c) => c.id); pendingShownIds = allIds; if (shownCourseIds.length === 0) { shownCourseIds = allIds; writeShownCourseIds(allIds); return []; } const unseen = allCourses .map((course, index) => shownCourseIds.includes(course.id) ? null : index, ) .filter((index) => index !== null); return sortCourseIndices(allCourses, unseen); } function requestNotificationPermission() { if (!("Notification" in window)) return; if (Notification.permission === "default") { Notification.requestPermission().catch(() => {}); } } function sendNewCoursesNotification(allCourses, newIndices) { if (newIndices.length === 0) return; if (!("Notification" in window)) return; if (Notification.permission !== "granted") return; const names = newIndices .map((idx) => allCourses[idx]?.name) .filter(Boolean); if (names.length === 0) return; currentNotification = new Notification(`新增 ${names.length} 门成绩`, { body: names.join("、"), }); } function sortCourseIndices(courses, indices) { return [...indices].sort((aIdx, bIdx) => { const a = courses[aIdx]; const b = courses[bIdx]; if (!a || !b) return 0; const yearA = normalizeYearForSort(a.year); const yearB = normalizeYearForSort(b.year); if (yearA !== yearB) return yearB - yearA; const semA = normalizeSemesterForSort(a.semester); const semB = normalizeSemesterForSort(b.semester); if (semA !== semB) return semB - semA; const gpaA = courseGpaFromScore(a.score) ?? Number.NEGATIVE_INFINITY; const gpaB = courseGpaFromScore(b.score) ?? Number.NEGATIVE_INFINITY; if (gpaA !== gpaB) return gpaB - gpaA; const failA = isFail(a.score) ? 1 : 0; const failB = isFail(b.score) ? 1 : 0; if (failA !== failB) return failB - failA; return bIdx - aIdx; }); } function normalizeYearForSort(year) { if (Number.isNaN(year)) return Number.NEGATIVE_INFINITY; if (year >= 1900 || year <= -1900) return year; if (year >= 0 && year < 100) return year >= 90 ? 1900 + year : 2000 + year; if (year <= 0 && year > -100) { const abs = Math.abs(year); return abs >= 90 ? 1900 - abs : 2000 - abs; } return year; } function normalizeSemesterForSort(semester) { if (Number.isNaN(semester)) return Number.NEGATIVE_INFINITY; return semester; } function dismissNewBlock() { if (currentNotification) { currentNotification.close(); currentNotification = null; } if (pendingShownIds) { shownCourseIds = pendingShownIds; writeShownCourseIds(pendingShownIds); } newBlocks = []; } function calcGpaDelta(allCourses, newIndices) { if (newIndices.length === 0) return { delta: 0, type: "keep" }; const newIndexSet = new Set(newIndices); let newTotCredit = 0, newTotGpa = 0; allCourses.forEach((c) => { const gpa = courseGpaFromScore(c.score); if (gpa !== null && c.credit > 0) { newTotCredit += c.credit; newTotGpa += c.credit * gpa; } }); const newGpa = newTotCredit > 0 ? newTotGpa / newTotCredit : 0; let oldTotCredit = 0, oldTotGpa = 0; allCourses.forEach((c, idx) => { if (newIndexSet.has(idx)) return; const gpa = courseGpaFromScore(c.score); if (gpa !== null && c.credit > 0) { oldTotCredit += c.credit; oldTotGpa += c.credit * gpa; } }); const oldGpa = oldTotCredit > 0 ? oldTotGpa / oldTotCredit : 0; const delta = newGpa - oldGpa; let type = "keep"; if (delta >= 0.0005) type = "up"; else if (delta <= -0.0005) type = "down"; return { delta, type }; } function formatDelta(delta) { if (Math.abs(delta) >= 1) return delta.toFixed(2); return delta.toFixed(3).replace("0.", "."); } function renderNewBlockCard( allCourses, newIndices, hideText, judgeByGpa, onDismiss, onTamper, onUntamper, ) { const section = document.createElement("div"); section.className = "grade-section"; const deltaInfo = calcGpaDelta(allCourses, newIndices); const svgStyles = { keep: { borderStop: "#a38aa7", bgDown: "#5b406c", fontSize: "35px", stroke: "#473663", }, up: { borderStop: "rgba(137, 202, 207, 0.99)", bgDown: "#2e4561", fontSize: "38px", stroke: "#4c9ca0", }, down: { borderStop: "#bd6675", bgDown: "#632a43", fontSize: "38px", stroke: "#87495f", }, }; const style = svgStyles[deltaInfo.type]; const header = document.createElement("div"); header.className = "new-block-header"; header.innerHTML = `
GPA ${deltaInfo.type === "keep" ? "-KEEP-" : (deltaInfo.type === "up" ? "+" : "") + formatDelta(deltaInfo.delta)}
新增成绩
共 ${newIndices.length} 门课程
`; section.appendChild(header); const dismissBtn = header.querySelector(".new-block-dismiss"); if (dismissBtn) { dismissBtn.addEventListener("click", onDismiss); } newIndices.forEach((idx) => { const course = allCourses[idx]; if (course) { section.appendChild( renderCourseRow(course, hideText, judgeByGpa, onTamper, onUntamper), ); } }); return section; } function renderCustomPresetEditor(onSave, onClose) { const currentMappings = { ...GRADE_PRESETS.custom.mappings }; let currentStrategy = GRADE_PRESETS.custom.strategy; const overlay = document.createElement("div"); overlay.className = "preset-editor-overlay"; const modal = document.createElement("div"); modal.className = "preset-editor-modal"; const content = document.createElement("div"); content.className = "preset-editor-content"; const title = document.createElement("h2"); title.className = "preset-editor-title"; title.textContent = "自定义等级制转换"; content.appendChild(title); const strategySection = document.createElement("div"); const strategyLabel = document.createElement("label"); strategyLabel.className = "preset-editor-label"; strategyLabel.textContent = "转换方式"; strategySection.appendChild(strategyLabel); const radioGroup = document.createElement("div"); radioGroup.className = "preset-editor-radio-group"; const createRadio = (value, label) => { const radioLabel = document.createElement("label"); radioLabel.className = "preset-editor-radio"; const radio = document.createElement("input"); radio.type = "radio"; radio.name = "strategy"; radio.value = value; radio.checked = currentStrategy === value; radio.addEventListener("change", () => { currentStrategy = value; updateInputLabels(); }); radioLabel.appendChild(radio); radioLabel.appendChild(document.createTextNode(label)); return radioLabel; }; radioGroup.appendChild(createRadio("direct_gpa", "直接映射 GPA")); radioGroup.appendChild(createRadio("score_to_gpa", "转换为百分制")); strategySection.appendChild(radioGroup); content.appendChild(strategySection); const mappingsSection = document.createElement("div"); const tableHeader = document.createElement("div"); tableHeader.className = "preset-editor-table-header"; const headerLabel = document.createElement("span"); headerLabel.textContent = "等级"; const headerValue = document.createElement("span"); headerValue.id = "preset-editor-value-label"; tableHeader.appendChild(headerLabel); tableHeader.appendChild(headerValue); mappingsSection.appendChild(tableHeader); const updateInputLabels = () => { const label = document.getElementById("preset-editor-value-label"); if (label) { label.textContent = currentStrategy === "direct_gpa" ? "GPA (0-4)" : "百分制 (0-100)"; } LETTER_GRADES.forEach((grade) => { const input = document.getElementById(`preset-input-${grade}`); if (input) { input.step = currentStrategy === "direct_gpa" ? "0.1" : "1"; input.max = currentStrategy === "direct_gpa" ? "4" : "100"; } }); }; LETTER_GRADES.forEach((grade) => { const row = document.createElement("div"); row.className = "preset-editor-row"; const gradeLabel = document.createElement("span"); gradeLabel.className = "preset-editor-grade"; gradeLabel.textContent = grade; const input = document.createElement("input"); input.type = "number"; input.id = `preset-input-${grade}`; input.className = "preset-editor-input"; input.step = currentStrategy === "direct_gpa" ? "0.1" : "1"; input.min = "0"; input.max = currentStrategy === "direct_gpa" ? "4" : "100"; input.placeholder = "留空不计算"; const val = currentMappings[grade]; input.value = isNaN(val) ? "" : val; input.addEventListener("input", (e) => { const numVal = e.target.value === "" ? NaN : parseFloat(e.target.value); currentMappings[grade] = numVal; }); row.appendChild(gradeLabel); row.appendChild(input); mappingsSection.appendChild(row); }); content.appendChild(mappingsSection); modal.appendChild(content); const actions = document.createElement("div"); actions.className = "preset-editor-actions"; const cancelBtn = document.createElement("button"); cancelBtn.type = "button"; cancelBtn.className = "preset-editor-btn-cancel"; cancelBtn.textContent = "取消"; cancelBtn.addEventListener("click", onClose); const saveBtn = document.createElement("button"); saveBtn.type = "button"; saveBtn.className = "preset-editor-btn-save"; saveBtn.textContent = "保存"; saveBtn.addEventListener("click", () => { saveCustomPreset(currentMappings, currentStrategy); onSave(); }); actions.appendChild(cancelBtn); actions.appendChild(saveBtn); modal.appendChild(actions); overlay.appendChild(modal); overlay.addEventListener("click", (e) => { if (e.target === overlay) onClose(); }); updateInputLabels(); return overlay; } // ==================== URL监控 ==================== let hiddenOriginalElements = []; function removeGradeUI() { if (!gradeUIInjected) return; const gradeRoot = document.getElementById("grade-huh-root"); if (gradeRoot) { gradeRoot.remove(); } hiddenOriginalElements.forEach((el) => { if (el && el.style) el.style.display = ""; }); hiddenOriginalElements = []; if (autoReloadTimer) { clearInterval(autoReloadTimer); autoReloadTimer = null; } if (uiUpdateTimer) { clearInterval(uiUpdateTimer); uiUpdateTimer = null; } autoReloadEnabled = false; nextUpdateTime = null; gradeUIInjected = false; gradeData = null; } function onUrlChange() { if (!isGradesPage() && gradeUIInjected) { removeGradeUI(); } } const originalPushState = W.history.pushState; const originalReplaceState = W.history.replaceState; W.history.pushState = function () { const result = originalPushState.apply(this, arguments); onUrlChange(); return result; }; W.history.replaceState = function () { const result = originalReplaceState.apply(this, arguments); onUrlChange(); return result; }; W.addEventListener("popstate", onUrlChange); // ==================== 等级制换算 ==================== const LETTER_GRADES = [ "A+", "A", "A-", "B+", "B", "B-", "C+", "C", "C-", "D+", "D", "F", ]; const GRADE_PRESETS = { no_calculation: { name: "不参与计算", strategy: "direct_gpa", mappings: { "A+": NaN, A: NaN, "A-": NaN, "B+": NaN, B: NaN, "B-": NaN, "C+": NaN, C: NaN, "C-": NaN, "D+": NaN, D: NaN, F: NaN, }, }, graduate: { name: "研究生", strategy: "direct_gpa", mappings: { "A+": 4.0, A: 4.0, "A-": 3.7, "B+": 3.3, B: 3.0, "B-": 2.7, "C+": 2.3, C: 2.0, "C-": 1.7, "D+": 1.3, D: 1.0, F: 0, }, }, guanghua: { name: "光华", strategy: "score_to_gpa", mappings: { "A+": 97, A: 92, "A-": 87, "B+": 82, B: 79, "B-": 76, "C+": 73, C: 69, "C-": 65, "D+": 62, D: 60, F: 30, }, }, life_sciences: { name: "生科", strategy: "direct_gpa", mappings: { "A+": 3.9, A: 3.9, "A-": 3.9, "B+": 3.4, B: 3.4, "B-": 3.4, "C+": 2.5, C: 2.5, "C-": 2.5, "D+": 1.5, D: 1.5, F: 0, }, }, foreign_languages: { name: "外院", strategy: "score_to_gpa", mappings: { "A+": 90, A: 90, "A-": 87, "B+": 83, B: 79, "B-": 76, "C+": 73, C: 70, "C-": 66, "D+": 63, D: 60, F: 30, }, }, custom: { name: "自定义", strategy: "direct_gpa", mappings: { "A+": 4.0, A: 4.0, "A-": 3.7, "B+": 3.3, B: 3.0, "B-": 2.7, "C+": 2.3, C: 2.0, "C-": 1.7, "D+": 1.3, D: 1.0, F: 0, }, }, }; const CUSTOM_PRESET_KEYS = { mappings: "GRADE_HUH_CUSTOM_MAPPINGS", strategy: "GRADE_HUH_CUSTOM_STRATEGY", }; function loadCustomPreset() { try { const storedMappings = localStorage.getItem(CUSTOM_PRESET_KEYS.mappings); if (storedMappings) { GRADE_PRESETS.custom.mappings = JSON.parse(storedMappings); } const storedStrategy = localStorage.getItem(CUSTOM_PRESET_KEYS.strategy); if ( storedStrategy === "direct_gpa" || storedStrategy === "score_to_gpa" ) { GRADE_PRESETS.custom.strategy = storedStrategy; } } catch (e) { console.error("Failed to load custom preset", e); } } function saveCustomPreset(mappings, strategy) { try { localStorage.setItem( CUSTOM_PRESET_KEYS.mappings, JSON.stringify(mappings), ); localStorage.setItem(CUSTOM_PRESET_KEYS.strategy, strategy); GRADE_PRESETS.custom.mappings = mappings; GRADE_PRESETS.custom.strategy = strategy; } catch (e) { console.error("Failed to save custom preset", e); } } loadCustomPreset(); loadOptions(); // ==================== 水印去除 ==================== GM_addStyle(` [class*="el-watermark"], div[class^="el-watermark"], .el-watermark, .el-watermark__content { display: none !important; opacity: 0 !important; visibility: hidden !important; pointer-events: none !important; background-image: none !important; background: none !important; z-index: -9999 !important; } [style*="pointer-events: none"][style*="background-image: url"] { background-image: none !important; } [style*="origin1-"], [style*="origin2-"], [style*="origin3-"], .box-top[style*="background-image"], .msy-post[style*="background-image"] { background-image: none !important; } canvas { display: none !important; opacity: 0 !important; width: 0 !important; height: 0 !important; } .replyItem { background-color: var(--theme_bgc_color, #f5f5f5) !important; } html.dark .replyItem { background-color: #2c2c2c !important; } `); const oF = W.fetch; const X = W.XMLHttpRequest.prototype, oO = X.open, oS = X.send; X.open = function (m, u) { this._url = u; this._isScoreApi = u && u.includes("/chapi/api/course/score_v2"); return oO.apply(this, arguments); }; X.send = function (b) { if (this._isScoreApi && isGradesPage()) { const xhr = this; const originalOnLoad = xhr.onload; xhr.onload = function () { try { const data = JSON.parse(xhr.responseText); if (data && data.success !== false) { gradeData = data; if (gradeUIInjected) { updateGradeUI(); } else { injectGradeUI(); } } } catch (e) {} if (originalOnLoad) originalOnLoad.apply(this, arguments); }; } return oS.apply(this, arguments); }; new MutationObserver((ms) => ms.forEach((m) => m.addedNodes.forEach((n) => { if (n.nodeType !== 1) return; if (n.tagName === "CANVAS") { n.style.display = "none"; } if ( n.classList && (n.classList.contains("el-watermark") || n.className?.includes?.("watermark")) ) { n.style.display = "none"; n.style.backgroundImage = "none"; } if (n.querySelectorAll) { n.querySelectorAll('[class*="watermark"], canvas').forEach((el) => { el.style.display = "none"; if (el.style.backgroundImage) { el.style.backgroundImage = "none"; } }); } }), ), ).observe(document, { childList: true, subtree: true }); // ==================== 成绩美化 ==================== const gradeStyles = ` .grade-huh-container { background-color: #1a1a1a; color: #ffffff; font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif; padding-bottom: 48px; min-height: calc(100vh - 60px); } html:not(.root-dark-mode) .grade-huh-container { background-color: #f5f5f5; color: #333333; } .grade-huh-inner { max-width: 800px; margin: 0 auto; padding: 1px 16px 0; } .grade-huh-header { text-align: center; padding: 20px 0; } .grade-huh-header h1 { font-size: 24px; margin: 0 0 10px 0; color: #fff; } .grade-huh-header .student-info { font-size: 14px; color: #aaa; } .grade-huh-controls { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 12px 24px; margin: 32px auto 0; max-width: 100%; font-size: 0.8em; } .grade-huh-controls button { background: none; border: none; color: lightblue; cursor: pointer; display: inline-flex; align-items: center; gap: 0.35em; padding: 0; font: inherit; } .grade-huh-controls button:disabled { opacity: 0.6; cursor: not-allowed; } .grade-huh-controls button svg { width: 1em; height: 1em; } .grade-huh-controls .preset-select { display: inline-flex; align-items: center; gap: 0.35em; width: 100%; justify-content: center; } @media (min-width: 768px) { .grade-huh-controls .preset-select { width: auto; } } .grade-huh-controls .preset-label { color: lightblue; } .grade-huh-controls select { background: transparent; border: 1px solid lightblue; color: lightblue; cursor: pointer; padding: 2px 24px 2px 8px; border-radius: 4px; font-size: 0.9em; appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath fill='%23add8e6' d='M2 4l4 4 4-4'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 0.5rem center; background-size: 10px; } .grade-huh-controls select option { background: #333; color: white; } .grade-huh-refresh { margin-top: 32px; text-align: center; display: flex; justify-content: center; font-size: 0.85em; } .grade-huh-refresh button { background: none; border: none; color: lightblue; cursor: pointer; display: inline-flex; align-items: center; gap: 0.35em; padding: 0; font: inherit; } .grade-huh-refresh button:disabled { opacity: 0.6; cursor: not-allowed; } .grade-huh-refresh .update-time { color: #888; margin-left: 8px; } .grade-huh-content { font-size: 1.2em; } .grade-section { margin-top: 32px; animation: gradeHuhFadeIn 0.15s ease-out; } .grade-section:first-of-type { margin-top: 0; } @keyframes gradeHuhFadeIn { from { opacity: 0; } 20% { opacity: 0; } to { opacity: 1; } } @keyframes gradeHuhRainbow { 0% { background-position-x: 0; } 100% { background-position-x: -1000px; } } .semester-header { display: flex; padding: 4px 8px; color: black; text-shadow: 0 0 3px white; position: sticky; top: 0; z-index: 10; box-shadow: 0 0 6px rgba(0,0,0,0.8); cursor: pointer; } .course-row { display: flex; padding: 4px; color: black; text-shadow: 0 0 3px white; position: relative; box-shadow: 0 -1px 0 #7f7f7f; margin: 0 16px; } .course-row.rainbow { animation: gradeHuhRainbow 5s linear infinite; background-size: 1000px; } .cell-left { flex: 0 0 3.5em; text-align: center; overflow: hidden; min-width: 0; } .cell-middle { flex: 1; min-width: 0; text-align: left; } .cell-right { flex: 0 0 3.5em; text-align: center; overflow: hidden; min-width: 0; max-width: 4em; } .cell-content { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; min-width: 0; padding: 0.15rem 0; transition: opacity 0.15s ease-out; } .cell-middle .cell-content { align-items: flex-start; } .cell-main { line-height: 1.1; font-weight: 600; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .cell-sub { font-size: 60%; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .cell-middle .cell-main { font-weight: 500; } .cell-middle .cell-sub { margin-top: 0.35em; white-space: normal; } .hide-text { opacity: 0; } .course-extras { font-size: 60%; width: 100%; text-shadow: none; overflow-wrap: anywhere; max-height: 0; overflow: hidden; transition: max-height 0.15s ease-out; } .course-extras.show { max-height: 112px; overflow-y: auto; overflow-x: hidden; margin-top: 0.35em; } .course-extras p { margin: 0; } .course-extras a { color: inherit; } .grade-huh-container ::-webkit-scrollbar { width: 4px; height: 4px; } .grade-huh-container ::-webkit-scrollbar-track { background: transparent; } .grade-huh-container ::-webkit-scrollbar-thumb { background: rgba(128, 128, 128, 0.4); border-radius: 2px; } .grade-huh-container ::-webkit-scrollbar-thumb:hover { background: rgba(128, 128, 128, 0.6); } .grade-huh-container * { scrollbar-width: thin; scrollbar-color: rgba(128, 128, 128, 0.4) transparent; } @keyframes gradeHuhSpin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes gradeHuhFadeOut { from { opacity: 1; } to { opacity: 0.3; } } @keyframes gradeHuhFadeInContent { from { opacity: 0.3; } to { opacity: 1; } } .grade-huh-refresh button.loading svg { animation: gradeHuhSpin 1s linear infinite; } .grade-huh-refresh button.loading { pointer-events: none; opacity: 0.7; } .grade-huh-content.refreshing { animation: gradeHuhFadeOut 0.2s ease-out forwards; } .grade-huh-content.refreshed { animation: gradeHuhFadeInContent 0.3s ease-out forwards; } .tampered-row { text-decoration: line-through; text-decoration-color: rgba(239, 68, 68, 0.5); } .tampered-row * { text-decoration: line-through; text-decoration-color: rgba(239, 68, 68, 0.5); } .tamper-warning { display: inline-block; cursor: pointer; padding: 0 4px; border-radius: 4px; margin-right: 0.4em; color: #ef4444; text-decoration: none !important; position: relative; } .tamper-warning * { text-decoration: none !important; } .tamper-warning:hover { background: #ef4444; color: white; text-shadow: none; } .tamper-warning .tooltip { display: none; position: absolute; font-size: 0.8rem; width: 6rem; height: 1.2em; line-height: 1.2em; top: 1.25rem; left: -0.25rem; border-radius: 4px; text-align: center; color: white; background: rgba(0,0,0,0.6); pointer-events: none; z-index: 100; } .tamper-warning:hover .tooltip { display: block; } .score-input { background: transparent; border: 0; font: inherit; text-align: center; width: 100%; max-width: 4em; color: inherit; text-shadow: 0 0 3px white; line-height: 1em; padding: 0; margin: 0; overflow: hidden; text-overflow: ellipsis; cursor: pointer; } .score-input:focus { cursor: text; outline: none; } .new-block-header { display: flex; padding: 4px 8px; padding-left: 0.9em; padding-right: 24px; align-items: center; background: hsl(0,0%,90%); color: black; text-shadow: 0 0 3px white; } .new-block-svg { flex: 0 0 auto; padding: 0 4px; display: flex; align-items: center; margin-right: 0.6em; } .new-block-svg svg { height: 3rem; vertical-align: top; text-shadow: none; pointer-events: none; } .new-block-dismiss { height: 1.65em; width: 5rem; font-size: 1rem; margin: 4px 8px; background: white; color: black; border: 1px solid black; border-radius: 4px; cursor: pointer; outline: none; } .new-block-dismiss:hover { background: black; color: white; } .preset-editor-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.6); display: flex; align-items: center; justify-content: center; padding: 16px; z-index: 9999; } .preset-editor-modal { max-height: 85vh; width: 100%; max-width: 400px; overflow: hidden; background: #222; color: white; border-radius: 16px; box-shadow: 0 10px 30px rgba(0,0,0,0.6); border: 1px solid rgba(255,255,255,0.18); display: flex; flex-direction: column; } .preset-editor-content { padding: 24px; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; } .preset-editor-title { font-size: 18px; font-weight: bold; margin: 0; } .preset-editor-label { display: block; font-size: 14px; color: rgba(255,255,255,0.7); margin-bottom: 8px; } .preset-editor-radio-group { display: flex; gap: 16px; } .preset-editor-radio { display: flex; align-items: center; gap: 8px; color: rgba(255,255,255,0.9); cursor: pointer; font-size: 14px; } .preset-editor-radio input { accent-color: lightblue; } .preset-editor-table-header { display: flex; font-size: 14px; color: rgba(255,255,255,0.7); margin-bottom: 8px; padding: 0 4px; } .preset-editor-table-header span:first-child { width: 64px; } .preset-editor-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } .preset-editor-grade { width: 64px; font-family: monospace; color: rgba(255,255,255,0.9); font-size: 14px; padding-left: 4px; } .preset-editor-input { flex: 1; background: rgba(0,0,0,0.35); color: white; padding: 6px 8px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.45); font-size: 14px; outline: none; } .preset-editor-input::placeholder { color: rgba(255,255,255,0.4); } .preset-editor-input:focus { border-color: rgba(173,216,230,0.9); box-shadow: 0 0 0 2px rgba(173,216,230,0.35); } .preset-editor-actions { display: flex; justify-content: flex-end; gap: 12px; padding: 16px 24px; background: rgba(255,255,255,0.05); border-top: 1px solid rgba(255,255,255,0.08); } .preset-editor-btn-cancel { padding: 6px 16px; color: rgba(255,255,255,0.7); background: none; border: none; cursor: pointer; font-size: 14px; } .preset-editor-btn-cancel:hover { color: white; } .preset-editor-btn-save { padding: 6px 16px; background: rgba(173,216,230,0.2); color: lightblue; border-radius: 20px; border: 1px solid lightblue; cursor: pointer; font-size: 14px; } .preset-editor-btn-save:hover { background: rgba(173,216,230,0.3); } .preset-edit-btn { background: none; border: none; color: rgba(255,255,255,0.6); cursor: pointer; padding: 4px; display: flex; align-items: center; margin-left: 4px; } .preset-edit-btn:hover { color: lightblue; } .preset-edit-btn svg { width: 14px; height: 14px; } `; // ==================== 成绩解析 ==================== const STATIC_GPA = { P: null, NP: null, EX: null, IP: null, I: null, W: null, }; const DESCRIPTION = { P: "通过", NP: "未通过", EX: "免修", IP: "跨学期", I: "缓考", W: "退课", }; const SQRT3 = Math.sqrt(3); function normalizeScore(score) { if (score === "合格") return "P"; if (score === "不合格") return "NP"; if (score === "缓考") return "I"; if (score === "免修") return "EX"; const num = Number(score); return isNaN(num) ? score : num; } function convertLetterGrade(letter) { const preset = GRADE_PRESETS[options.gradePreset] || GRADE_PRESETS.no_calculation; if (!(letter in preset.mappings)) return null; const value = preset.mappings[letter]; if (isNaN(value)) return null; if (preset.strategy === "direct_gpa") return value; return value >= 60 ? 4 - (3 * Math.pow(100 - value, 2)) / 1600 : null; } function courseGpaFromScore(score) { const num = Number(score); if (!isNaN(num)) { return num >= 60 ? 4 - (3 * Math.pow(100 - num, 2)) / 1600 : null; } if (score in STATIC_GPA) return STATIC_GPA[score]; if (LETTER_GRADES.includes(score)) return convertLetterGrade(score); return null; } function shouldCalcCredit(score) { const excludedStatuses = ["I", "W", "IP"]; if (typeof score === "string" && excludedStatuses.includes(score)) return false; return true; } function isFail(score) { return ( score === "NP" || score === "F" || (!isNaN(Number(score)) && Number(score) < 60) ); } function isFull(score) { if (LETTER_GRADES.includes(score)) return convertLetterGrade(score) === 4.0; return Number(score) > 99.995; } function guessScoreFromGpa(gpa) { if (gpa === null) return "--.-"; if (gpa >= 4) return 100; if (gpa >= 1) return (-40 * SQRT3 * Math.sqrt(4 - gpa) + 300) / 3; return "--.-"; } function fix(num, dig) { if (typeof num !== "number") return num; return num .toFixed(dig) .replace(/^(.*?)0+$/, "$1") .replace(/\.$/, ""); } function describe(score) { return DESCRIPTION[score] || "-.--"; } function checkScore(score) { const num = Number(score); if (!isNaN(num)) { return num <= 100.001 && num >= -0.001; } return score in STATIC_GPA || LETTER_GRADES.includes(score); } function scoreTampered(courses) { return courses.some( (course) => `${course.score}` !== `${course.trueScore}`, ); } // ==================== 颜色生成 ==================== function prec(score, judgeByGpa) { if (judgeByGpa) { const gpa = courseGpaFromScore(score); if (gpa === null) return 0; return (gpa - 1) / 3; } const num = Number(guessScoreFromGpa(courseGpaFromScore(score))); if (isNaN(num)) return 0; return (num - 60) / 40; } function cannotJudge(score) { return courseGpaFromScore(score) === null; } function colorizeSemester(score, judgeByGpa) { if (cannotJudge(score)) return "hsl(240,50%,90%)"; return `hsl(${120 * prec(score, judgeByGpa)},${judgeByGpa ? 97 : 100}%,70%)`; } function colorizeCourse(score, judgeByGpa) { if (cannotJudge(score) || Number(score) < 60) return "hsl(340,60%,65%)"; return `hsl(${120 * prec(score, judgeByGpa)},${judgeByGpa ? 57 : 60}%,65%)`; } function colorizeCourseBar(score, judgeByGpa) { if (cannotJudge(score) || Number(score) < 60) { const color = "hsl(240,50%,90%)"; return [color, color, isFail(score) ? 0 : 1]; } const p = prec(score, judgeByGpa); const colorL = `hsl(${120 * p},${judgeByGpa ? 97 : 100}%,75%)`; const colorR = `hsl(${120 * p},${judgeByGpa ? 97 : 100}%,70%)`; return [colorL, colorR, Math.max(p, 0.01)]; } function makeScoreGradient(score, judgeByGpa) { if (isFull(score)) { return `linear-gradient(-45deg, hsl(120, 90%, 88%), hsl(0, 100%, 91%), hsl(240, 100%, 91%), hsl(120, 90%, 88%), hsl(0, 100%, 91%), hsl(240, 100%, 91%), hsl(120, 90%, 88%), hsl(0, 100%, 91%), hsl(240, 100%, 91%), hsl(120, 90%, 88%), hsl(0, 100%, 91%), hsl(240, 100%, 91%), hsl(120, 90%, 88%) ) 0 0/1800px 200px`; } const [fgL, fgR, width] = colorizeCourseBar(score, judgeByGpa); const bg = colorizeCourse(score, judgeByGpa); const wp = `${width * 100}%`; return `linear-gradient(to right, ${fgL}, ${fgR} ${wp}, ${bg} ${wp})`; } // ==================== 数据解析 ==================== function parseTeacher(line) { if (!line) return "(无教师信息)"; const parts = line.split(","); const teacher = parts[0]; const res = /^[^-]+-([^$]+)\$([^$]*)\$([^$]*)$/.exec(teacher); if (res) { return `${res[1]}(${res[2]})${parts.length > 1 ? `等${parts.length}人` : ""}`; } return `${teacher}${parts.length > 1 ? ` 等${parts.length}人` : ""}`; } function normalizeCourseType(value) { const trimmed = (value || "").replace(/\s+/g, " ").trim(); if (!trimmed) return "未分类"; return ( trimmed .replace(/必修课/g, "必修") .replace(/选修课/g, "选修") .trim() || "未分类" ); } function extraInfos(row) { const extras = []; if (row.kch) extras.push({ label: "课程号", value: row.kch }); if (row.cjjlfs) extras.push({ label: "成绩记录方式", value: row.cjjlfs }); if (row.zxjhbh) extras.push({ label: "执行计划编号", value: row.zxjhbh, href: `https://elective.pku.edu.cn/elective2008/edu/pku/stu/elective/controller/courseDetail/getCourseDetail.do?kclx=BK&course_seq_no=${encodeURIComponent(row.zxjhbh)}`, }); if (row.ywmc) extras.push({ label: "课程英文名", value: row.ywmc }); if (row.kctx) extras.push({ label: "课程体系", value: row.kctx }); if (row.jxbh) extras.push({ label: "教学班号", value: row.jxbh }); if (row.skjsxm) extras.push({ label: "教师信息", value: row.skjsxm }); if (row.bkcjbh) extras.push({ label: "成绩编号", value: row.bkcjbh }); if (row.xslb) extras.push({ label: "学生类别", value: row.xslb }); return extras.filter((e) => e.value && e.value.trim().length > 0); } function parseScore(data) { const scoreData = data.data?.score || data; const jbxx = scoreData.jbxx || {}; const gpaInfo = scoreData.gpa || {}; const xslb = scoreData.xslb || "bks"; const isGraduate = xslb === "yjs"; const studentInfo = { name: jbxx.xm || "", studentId: jbxx.xh || "", department: jbxx.xsmc || jbxx.yxmc || "", major: jbxx.zymc || "", grade: jbxx.grade || jbxx.njmc || "", overallGpa: isGraduate ? null : gpaInfo.gpa ? parseFloat(gpaInfo.gpa) : null, }; const rawRows = isGraduate ? scoreData.scoreLists || [] : scoreData.cjxx || []; const semesterMap = new Map(); let globalIndex = 0; rawRows.forEach((row) => { const semesterKey = `${row.xnd || ""}${row.xq || ""}`; const semesterName = `${row.xnd || "--"}学年 第${row.xq || "--"}学期`; if (!semesterMap.has(semesterKey)) { semesterMap.set(semesterKey, { key: semesterKey, name: semesterName, xnd: row.xnd, xq: row.xq, year: Number.parseInt(row.xnd, 10), semester: Number.parseInt(row.xq, 10), courses: [], gpa: null, }); } const rawScore = isGraduate ? row.cj : row.xqcj; const rawType = isGraduate ? row.kclb : row.kclbmc; const score = normalizeScore(rawScore || row.cj); const gpa = courseGpaFromScore(score); const credit = parseFloat(row.xf) || 0; semesterMap.get(semesterKey).courses.push({ id: row.kch || `${row.kcmc}-${row.xnd}-${row.xq}`, globalIndex: globalIndex++, name: row.kcmc || "未知课程", score: score, trueScore: score, gpa: gpa, credit: credit, type: normalizeCourseType(rawType), teacher: parseTeacher(row.skjsxm), extras: extraInfos(row), raw: row, }); }); const semesters = Array.from(semesterMap.values()).map((sem) => { let gpaCredit = 0; let totalGpaCredit = 0; let totalCredit = 0; sem.courses.forEach((course) => { if (shouldCalcCredit(course.score)) { totalCredit += course.credit; } if (course.gpa !== null && course.credit > 0) { gpaCredit += course.credit; totalGpaCredit += course.gpa * course.credit; } }); sem.gpa = gpaCredit > 0 ? totalGpaCredit / gpaCredit : null; sem.totalCredit = totalCredit; sem.gpaCredit = gpaCredit; return sem; }); semesters.sort((a, b) => { if (a.year !== b.year) return b.year - a.year; return b.semester - a.semester; }); return { studentInfo, semesters }; } // ==================== UI渲染 ==================== const iconWarning = ''; function renderCourseRow(course, hideText, judgeByGpa, onTamper, onUntamper) { const tampered = `${course.score}` !== `${course.trueScore}`; const div = document.createElement("div"); div.className = "course-row" + (!tampered && isFull(course.score) ? " rainbow" : "") + (tampered ? " tampered-row" : ""); div.style.background = makeScoreGradient(course.score, judgeByGpa); div.dataset.courseId = course.id; const scoreDisplay = typeof course.score === "number" ? fix(course.score, 1) : course.score; const gpa = courseGpaFromScore(course.score); const gpaDisplay = gpa !== null ? gpa.toFixed(3) : describe(course.score); const numericScore = Number(course.score); const shouldHideRight = hideText && (gpa !== null || (!isNaN(numericScore) && isFinite(numericScore) && numericScore < 60)); div.innerHTML = `
${fix(course.credit, 1)}
学分
${tampered ? `${iconWarning}非真实成绩` : ""}${course.name}
${course.type}${course.teacher ? ` - ${course.teacher}` : ""}
${gpaDisplay}
`; const scoreInput = div.querySelector(".score-input"); if (scoreInput && onTamper) { scoreInput.addEventListener("blur", () => { const newValue = scoreInput.value.trim().toUpperCase(); if (checkScore(newValue)) { onTamper(course, newValue); } else { scoreInput.value = scoreDisplay; } }); scoreInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { scoreInput.blur(); } }); scoreInput.addEventListener("click", (e) => { e.stopPropagation(); }); } const warningEl = div.querySelector(".tamper-warning"); if (warningEl && onUntamper) { warningEl.addEventListener("click", (e) => { e.stopPropagation(); onUntamper(course); }); } const extrasDiv = div.querySelector(".course-extras"); if (course.extras && course.extras.length > 0) { course.extras.forEach((e) => { const p = document.createElement("p"); if (e.href) { p.innerHTML = `${e.label}:${e.value}
`; } else { p.innerHTML = `${e.label}:${e.value}
`; } extrasDiv.appendChild(p); }); div.style.cursor = "pointer"; div.addEventListener("click", (e) => { if ( !e.target.closest(".score-input") && !e.target.closest(".tamper-warning") && !e.target.closest("a") ) { extrasDiv.classList.toggle("show"); } }); } return div; } function renderSemesterSection( semester, hideText, judgeByGpa, collapsed, onTamper, onUntamper, ) { const section = document.createElement("div"); section.className = "grade-section"; section.dataset.semesterKey = semester.key; const tampered = semester.courses.some( (c) => `${c.score}` !== `${c.trueScore}`, ); const gpaDisplay = semester.gpa !== null ? semester.gpa.toFixed(3) : "-.---"; const scoreDisplay = fix(guessScoreFromGpa(semester.gpa), 1); const header = document.createElement("div"); header.className = "semester-header" + (tampered ? " tampered-row" : ""); header.style.background = colorizeSemester( guessScoreFromGpa(semester.gpa), judgeByGpa, ); header.innerHTML = `
${fix(semester.totalCredit, 1)}
学分
${semester.name}
共 ${semester.courses.length} 门课程
${gpaDisplay}
${scoreDisplay}
`; section.appendChild(header); const coursesContainer = document.createElement("div"); coursesContainer.className = "semester-courses-container"; coursesContainer.style.display = collapsed ? "none" : "block"; const sortedCourses = [...semester.courses].sort((a, b) => { const gpa1 = a.gpa !== null ? a.gpa : 0; const gpa2 = b.gpa !== null ? b.gpa : 0; const fail1 = isFail(a.score) ? 1 : 0; const fail2 = isFail(b.score) ? 1 : 0; if (gpa1 !== gpa2) return gpa2 - gpa1; if (fail1 !== fail2) return fail2 - fail1; return b.globalIndex - a.globalIndex; }); sortedCourses.forEach((course) => { coursesContainer.appendChild( renderCourseRow(course, hideText, judgeByGpa, onTamper, onUntamper), ); }); section.appendChild(coursesContainer); header.addEventListener("click", () => { const isHidden = coursesContainer.style.display === "none"; coursesContainer.style.display = isHidden ? "block" : "none"; }); return section; } function renderOverallSection( studentInfo, semesters, hideText, judgeByGpa, isopGpa, ) { const section = document.createElement("div"); section.className = "grade-section"; section.id = "overall-section"; const allCourses = []; semesters.forEach((sem) => { sem.courses.forEach((course) => allCourses.push(course)); }); const tampered = scoreTampered(allCourses); let gpaCredit = 0; let totalGpaCredit = 0; let totalCredit = 0; allCourses.forEach((course) => { if (shouldCalcCredit(course.score)) { totalCredit += course.credit; } if (course.gpa !== null && course.credit > 0) { gpaCredit += course.credit; totalGpaCredit += course.gpa * course.credit; } }); const calculatedGpa = gpaCredit > 0 ? totalGpaCredit / gpaCredit : null; const gpaDisplay = calculatedGpa !== null ? calculatedGpa.toFixed(3) : "-.---"; const scoreDisplay = fix(guessScoreFromGpa(calculatedGpa), 1); const categoryMap = new Map(); allCourses.forEach((course) => { if (!categoryMap.has(course.type)) { categoryMap.set(course.type, []); } categoryMap.get(course.type).push(course); }); const categories = Array.from(categoryMap.entries()) .map(([name, courses]) => { let catGpaCredit = 0, catTotalGpaCredit = 0, catTotalCredit = 0; courses.forEach((c) => { if (shouldCalcCredit(c.score)) catTotalCredit += c.credit; if (c.gpa !== null && c.credit > 0) { catGpaCredit += c.credit; catTotalGpaCredit += c.gpa * c.credit; } }); const catGpa = catGpaCredit > 0 ? catTotalGpaCredit / catGpaCredit : null; const details = courses.map((c) => { const scoreStr = typeof c.score === "number" ? fix(c.score, 1) : c.score; return `${fix(c.credit, 1)}学分 · ${c.name} · ${scoreStr}`; }); return { name, count: courses.length, credit: catTotalCredit, gpa: catGpa, score: guessScoreFromGpa(catGpa), details, }; }) .sort((a, b) => { const gpaA = a.gpa ?? -Infinity, gpaB = b.gpa ?? -Infinity; if (gpaA !== gpaB) return gpaB - gpaA; if (a.credit !== b.credit) return b.credit - a.credit; if (a.count !== b.count) return b.count - a.count; return a.name.localeCompare(b.name, "zh-Hans"); }); const header = document.createElement("div"); header.className = "semester-header" + (tampered ? " tampered-row" : ""); header.style.background = colorizeSemester( guessScoreFromGpa(calculatedGpa), judgeByGpa, ); header.style.cursor = "pointer"; const officialGpaDisplay = isopGpa !== null ? isopGpa : "-.--"; header.innerHTML = `
${fix(totalCredit, 1)}
学分
总绩点
共 ${allCourses.length} 门课程,官方 GPA:${officialGpaDisplay}
${gpaDisplay}
${scoreDisplay}
`; section.appendChild(header); const categoriesContainer = document.createElement("div"); categoriesContainer.style.display = "block"; categories.forEach((cat) => { const catRow = document.createElement("div"); catRow.className = "course-row"; catRow.style.background = makeScoreGradient(cat.score, judgeByGpa); const catGpaDisplay = cat.gpa !== null ? cat.gpa.toFixed(3) : "-.---"; const catScoreDisplay = fix(cat.score, 1); catRow.innerHTML = `
${fix(cat.credit, 1)}
学分
${cat.name}
共 ${cat.count} 门课程
${catGpaDisplay}
${catScoreDisplay}
`; const extrasDiv = catRow.querySelector(".course-extras"); if (cat.details && cat.details.length > 0) { cat.details.forEach((line) => { const p = document.createElement("p"); p.textContent = line; const br = document.createElement("br"); p.appendChild(br); extrasDiv.appendChild(p); }); catRow.style.cursor = "pointer"; catRow.addEventListener("click", () => { extrasDiv.classList.toggle("show"); }); } categoriesContainer.appendChild(catRow); }); section.appendChild(categoriesContainer); header.addEventListener("click", () => { const isHidden = categoriesContainer.style.display === "none"; categoriesContainer.style.display = isHidden ? "block" : "none"; }); return section; } function renderGradeUI(data) { const { studentInfo, semesters } = parseScore(data); const container = document.createElement("div"); container.className = "grade-huh-container"; container.id = "grade-huh-root"; const inner = document.createElement("div"); inner.className = "grade-huh-inner"; const controls = document.createElement("div"); controls.className = "grade-huh-controls"; controls.id = "grade-huh-controls"; inner.appendChild(controls); const content = document.createElement("div"); content.id = "grade-huh-content"; content.className = "grade-huh-content"; inner.appendChild(content); container.appendChild(inner); const iconRefresh = ''; const iconShow = ''; const iconHide = ''; const iconDisplay = ''; const iconReload = ''; const relativeTimeFormatter = new Intl.RelativeTimeFormat("zh-CN", { numeric: "auto", }); const TIME_DIVISIONS = [ [60, "second"], [60, "minute"], [24, "hour"], [7, "day"], [4.34524, "week"], [12, "month"], [Number.POSITIVE_INFINITY, "year"], ]; function formatRelativeTime(target, base = Date.now()) { if (!target) return "--"; const targetMs = target instanceof Date ? target.getTime() : target; let delta = (targetMs - base) / 1000; for (const [amount, unit] of TIME_DIVISIONS) { if (Math.abs(delta) < amount) { return relativeTimeFormatter.format(Math.round(delta), unit); } delta /= amount; } return relativeTimeFormatter.format(Math.round(delta), "year"); } const iconEdit = ''; function updateControls() { const presetOptions = Object.entries(GRADE_PRESETS) .map( ([id, preset]) => ``, ) .join(""); const editBtnHtml = options.gradePreset === "custom" ? `` : ""; controls.innerHTML = ` 等级制换算规则: ${editBtnHtml} `; } // ========== 局部更新 ========== function updateCourseRowDOM(course) { const content = document.getElementById("grade-huh-content"); if (!content) return; const row = content.querySelector(`[data-course-id="${course.id}"]`); if (!row) return; const tampered = `${course.score}` !== `${course.trueScore}`; const gpa = courseGpaFromScore(course.score); const scoreDisplay = typeof course.score === "number" ? fix(course.score, 1) : course.score; const gpaDisplay = gpa !== null ? gpa.toFixed(3) : describe(course.score); row.style.background = makeScoreGradient( course.score, options.judgeByGpa, ); row.className = "course-row" + (!tampered && isFull(course.score) ? " rainbow" : "") + (tampered ? " tampered-row" : ""); const scoreInput = row.querySelector(".score-input"); if (scoreInput) scoreInput.value = scoreDisplay; const gpaSub = row.querySelector(".cell-right .cell-sub"); if (gpaSub) gpaSub.textContent = gpaDisplay; const cellMain = row.querySelector(".cell-middle .cell-main"); if (cellMain) { const existingWarning = cellMain.querySelector(".tamper-warning"); if (tampered && !existingWarning) { const warningSpan = document.createElement("span"); warningSpan.className = "tamper-warning"; warningSpan.innerHTML = `${iconWarning}非真实成绩`; warningSpan.addEventListener("click", (e) => { e.stopPropagation(); onUntamper(course); }); cellMain.insertBefore(warningSpan, cellMain.firstChild); } else if (!tampered && existingWarning) { existingWarning.remove(); } } const numericScore = Number(course.score); const shouldHideRight = options.hideText && (gpa !== null || (!isNaN(numericScore) && isFinite(numericScore) && numericScore < 60)); const rightContent = row.querySelector(".cell-right .cell-content"); if (rightContent) { rightContent.classList.toggle("hide-text", shouldHideRight); } } function updateSemesterHeaderDOM(semester) { const content = document.getElementById("grade-huh-content"); if (!content) return; const section = content.querySelector( `[data-semester-key="${semester.key}"]`, ); if (!section) return; const header = section.querySelector(".semester-header"); if (!header) return; const tampered = semester.courses.some( (c) => `${c.score}` !== `${c.trueScore}`, ); const gpaDisplay = semester.gpa !== null ? semester.gpa.toFixed(3) : "-.---"; const scoreDisplay = fix(guessScoreFromGpa(semester.gpa), 1); header.style.background = colorizeSemester( guessScoreFromGpa(semester.gpa), options.judgeByGpa, ); header.className = "semester-header" + (tampered ? " tampered-row" : ""); const creditMain = header.querySelector(".cell-left .cell-main"); if (creditMain) creditMain.textContent = fix(semester.totalCredit, 1); const rightMain = header.querySelector(".cell-right .cell-main"); const rightSub = header.querySelector(".cell-right .cell-sub"); if (rightMain) rightMain.textContent = gpaDisplay; if (rightSub) rightSub.textContent = scoreDisplay; } function updateOverallSectionDOM() { const content = document.getElementById("grade-huh-content"); if (!content) return; const section = content.querySelector("#overall-section"); if (!section) return; const header = section.querySelector(".semester-header"); if (!header) return; const allCourses = []; semesters.forEach((sem) => sem.courses.forEach((c) => allCourses.push(c)), ); const tampered = scoreTampered(allCourses); let gpaCredit = 0, totalGpaCredit = 0, totalCredit = 0; allCourses.forEach((course) => { if (shouldCalcCredit(course.score)) totalCredit += course.credit; if (course.gpa !== null && course.credit > 0) { gpaCredit += course.credit; totalGpaCredit += course.gpa * course.credit; } }); const calculatedGpa = gpaCredit > 0 ? totalGpaCredit / gpaCredit : null; const gpaDisplay = calculatedGpa !== null ? calculatedGpa.toFixed(3) : "-.---"; const scoreDisplay = fix(guessScoreFromGpa(calculatedGpa), 1); header.style.background = colorizeSemester( guessScoreFromGpa(calculatedGpa), options.judgeByGpa, ); header.className = "semester-header" + (tampered ? " tampered-row" : ""); const creditMain = header.querySelector(".cell-left .cell-main"); if (creditMain) creditMain.textContent = fix(totalCredit, 1); const rightMain = header.querySelector(".cell-right .cell-main"); const rightSub = header.querySelector(".cell-right .cell-sub"); if (rightMain) rightMain.textContent = gpaDisplay; if (rightSub) rightSub.textContent = scoreDisplay; } function resortSemesterCourses(semester) { const content = document.getElementById("grade-huh-content"); if (!content) return; const section = content.querySelector( `[data-semester-key="${semester.key}"]`, ); if (!section) return; const container = section.querySelector(".semester-courses-container"); if (!container) return; const sortedCourses = [...semester.courses].sort((a, b) => { const gpa1 = a.gpa !== null ? a.gpa : 0; const gpa2 = b.gpa !== null ? b.gpa : 0; const fail1 = isFail(a.score) ? 1 : 0; const fail2 = isFail(b.score) ? 1 : 0; if (gpa1 !== gpa2) return gpa2 - gpa1; if (fail1 !== fail2) return fail2 - fail1; return b.globalIndex - a.globalIndex; }); sortedCourses.forEach((course) => { const row = container.querySelector(`[data-course-id="${course.id}"]`); if (row) container.appendChild(row); }); } function recalculateSemesterGpa(semester) { let gpaCredit = 0, totalGpaCredit = 0, totalCredit = 0; semester.courses.forEach((c) => { if (shouldCalcCredit(c.score)) totalCredit += c.credit; const gpa = courseGpaFromScore(c.score); if (gpa !== null && c.credit > 0) { gpaCredit += c.credit; totalGpaCredit += gpa * c.credit; } }); semester.gpa = gpaCredit > 0 ? totalGpaCredit / gpaCredit : null; semester.totalCredit = totalCredit; } function updateCourseAndRefreshUI(course) { const courseSemester = semesters.find((sem) => sem.courses.some((c) => c.id === course.id), ); if (courseSemester) { recalculateSemesterGpa(courseSemester); } updateCourseRowDOM(course); if (courseSemester) { updateSemesterHeaderDOM(courseSemester); resortSemesterCourses(courseSemester); } updateOverallSectionDOM(); } function onTamper(course, newValue) { if (!checkScore(newValue)) return; course.score = newValue.toUpperCase(); course.gpa = courseGpaFromScore(course.score); updateCourseAndRefreshUI(course); } function onUntamper(course) { course.score = course.trueScore; course.gpa = courseGpaFromScore(course.score); updateCourseAndRefreshUI(course); } function collectAllCourses() { const allCourses = []; semesters.forEach((sem) => { sem.courses.forEach((course) => { allCourses.push(course); }); }); return allCourses; } const initialCourses = collectAllCourses(); newBlocks = detectNewCourses(initialCourses); sendNewCoursesNotification(initialCourses, newBlocks); function rebuildContent() { content.innerHTML = ""; const refreshDiv = document.createElement("div"); refreshDiv.className = "grade-huh-refresh"; refreshDiv.innerHTML = ` ${formatRelativeTime(lastUpdated)} `; content.appendChild(refreshDiv); const allCourses = collectAllCourses(); if (newBlocks.length > 0) { const onDismiss = () => { dismissNewBlock(); newBlocks = []; rebuildContent(); bindControlEvents(); }; content.appendChild( renderNewBlockCard( allCourses, newBlocks, options.hideText, options.judgeByGpa, onDismiss, onTamper, onUntamper, ), ); } semesters.forEach((sem) => { content.appendChild( renderSemesterSection( sem, options.hideText, options.judgeByGpa, options.collapseAll, onTamper, onUntamper, ), ); }); content.appendChild( renderOverallSection( studentInfo, semesters, options.hideText, options.judgeByGpa, studentInfo.overallGpa, ), ); } function bindControlEvents() { const btnAutoReload = document.getElementById("btnAutoReload"); const btnHideText = document.getElementById("btnHideText"); const btnJudgeByGpa = document.getElementById("btnJudgeByGpa"); const btnCollapseAll = document.getElementById("btnCollapseAll"); const presetSelect = document.getElementById("gradePresetSelect"); const btnRefresh = document.getElementById("btnRefresh"); if (btnAutoReload) { btnAutoReload.onclick = () => { if (autoReloadEnabled) { clearInterval(autoReloadTimer); autoReloadTimer = null; autoReloadEnabled = false; nextUpdateTime = null; } else { nextUpdateTime = Date.now() + AUTO_RELOAD_INTERVAL; autoReloadTimer = setInterval(() => { W.location.reload(); }, AUTO_RELOAD_INTERVAL); autoReloadEnabled = true; } updateControls(); bindControlEvents(); }; } if (btnHideText) { btnHideText.onclick = () => { options.hideText = !options.hideText; saveOptions(); const contentEl = document.getElementById("grade-huh-content"); if (contentEl) { contentEl .querySelectorAll(".cell-content, .course-extras, .hide-text") .forEach((el) => { if (el.classList.contains("hide-text")) { el.classList.remove("hide-text"); } }); if (options.hideText) { contentEl .querySelectorAll( ".course-row .cell-middle .cell-content, .course-row .cell-right .cell-content", ) .forEach((el) => { el.classList.add("hide-text"); }); contentEl .querySelectorAll(".semester-header .cell-right .cell-content") .forEach((el) => { el.classList.add("hide-text"); }); contentEl .querySelectorAll( ".semester-header .cell-middle .cell-sub span", ) .forEach((el) => { el.classList.add("hide-text"); }); contentEl.querySelectorAll(".course-extras").forEach((el) => { el.classList.add("hide-text"); }); } } updateControls(); bindControlEvents(); }; } if (btnJudgeByGpa) { btnJudgeByGpa.onclick = () => { options.judgeByGpa = !options.judgeByGpa; saveOptions(); updateControls(); rebuildContent(); bindControlEvents(); }; } if (btnCollapseAll) { btnCollapseAll.onclick = () => { options.collapseAll = !options.collapseAll; saveOptions(); updateControls(); bindControlEvents(); const containers = content.querySelectorAll( ".semester-courses-container", ); containers.forEach((container) => { container.style.display = options.collapseAll ? "none" : "block"; }); }; } if (presetSelect) { presetSelect.onchange = (e) => { options.gradePreset = e.target.value; saveOptions(); updateControls(); rebuildContent(); bindControlEvents(); }; } const btnEditPreset = document.getElementById("btnEditPreset"); if (btnEditPreset) { btnEditPreset.onclick = () => { const onSave = () => { document.getElementById("preset-editor-overlay")?.remove(); rebuildContent(); bindControlEvents(); }; const onClose = () => { document.getElementById("preset-editor-overlay")?.remove(); }; const editor = renderCustomPresetEditor(onSave, onClose); editor.id = "preset-editor-overlay"; document.body.appendChild(editor); }; } if (btnRefresh) { btnRefresh.onclick = () => { if (isRefreshing) return; isRefreshing = true; btnRefresh.classList.add("loading"); const queryBtn = document.querySelector("button.osu-button"); if (queryBtn) { queryBtn.click(); } else { W.location.reload(); } }; } } updateControls(); rebuildContent(); setTimeout(bindControlEvents, 0); if (uiUpdateTimer) clearInterval(uiUpdateTimer); uiUpdateTimer = setInterval(() => { if (autoReloadEnabled && nextUpdateTime) { const btnAutoReload = document.getElementById("btnAutoReload"); if (btnAutoReload) { const span = btnAutoReload.querySelector("span"); if (span) span.textContent = formatRelativeTime(nextUpdateTime); } } const updateTimeEl = document.querySelector( ".grade-huh-refresh .update-time", ); if (updateTimeEl) { updateTimeEl.textContent = formatRelativeTime(lastUpdated); } }, 1000); return container; } // ==================== 页面替换 ==================== let isRefreshing = false; function updateGradeUI() { if (!gradeData || !gradeUIInjected) return; lastUpdated = Date.now(); const oldRoot = document.getElementById("grade-huh-root"); if (!oldRoot) return; const contentEl = oldRoot.querySelector("#grade-huh-content"); if (contentEl) { contentEl.classList.remove("refreshed"); contentEl.classList.add("refreshing"); setTimeout(() => { if (uiUpdateTimer) { clearInterval(uiUpdateTimer); uiUpdateTimer = null; } const contentArea = oldRoot.parentElement; const newUI = renderGradeUI(gradeData); oldRoot.remove(); contentArea.appendChild(newUI); const newContentEl = newUI.querySelector("#grade-huh-content"); if (newContentEl) { newContentEl.classList.add("refreshed"); } isRefreshing = false; }, 200); } else { if (uiUpdateTimer) { clearInterval(uiUpdateTimer); uiUpdateTimer = null; } const contentArea = oldRoot.parentElement; const newUI = renderGradeUI(gradeData); oldRoot.remove(); contentArea.appendChild(newUI); isRefreshing = false; } } function injectGradeUI() { if (!gradeData || gradeUIInjected) return; gradeUIInjected = true; lastUpdated = Date.now(); requestNotificationPermission(); if (!gradeStylesInjected) { GM_addStyle(gradeStyles); gradeStylesInjected = true; } const tryReplace = () => { const contentArea = document.querySelector(".content") || document.querySelector("#app") || document.body; if (!contentArea) { setTimeout(tryReplace, 100); return; } const gradeUI = renderGradeUI(gradeData); hiddenOriginalElements = Array.from(contentArea.children); hiddenOriginalElements.forEach((el) => { if (el && el.style) el.style.display = "none"; }); contentArea.appendChild(gradeUI); }; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", tryReplace); } else { tryReplace(); } } // ==================== API拦截 ==================== const isScoreApi = (url) => url && url.includes("/chapi/api/course/score_v2"); const originalFetch = oF; W.fetch = function (input, init) { const url = input instanceof Request ? input.url : input; if (isBad(url)) { return new Response(Z, { status: 200, headers: { "Content-Type": "image/png" }, }); } if (isGradesPage() && isScoreApi(url)) { return originalFetch.apply(W, arguments).then((response) => { const clonedResponse = response.clone(); clonedResponse .json() .then((data) => { if (data && data.success !== false) { gradeData = data; if (gradeUIInjected) { updateGradeUI(); } else { injectGradeUI(); } } }) .catch(() => {}); return response; }); } return originalFetch.apply(W, arguments); }; })();