// ==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 = `
新增成绩
共 ${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}` : ""}
`;
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 = `
${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);
};
})();