您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
选课网体验增强
// ==UserScript== // @name Refined Elective // @namespace https://greasyfork.org/users/1429968 // @version 1.4.1 // @description 选课网体验增强 // @author ha0xin // @match https://elective.pku.edu.cn/elective2008/edu/pku/stu/elective/controller/* // @exclude https://elective.pku.edu.cn/elective2008/edu/pku/stu/elective/controller/courseQuery/goNested.do* // @license MIT License // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // ==/UserScript== (function () { "use strict"; // --- 配置项 --- const CONFIG_HIGHLIGHT_ENABLED = "conflictHighlightEnabled"; const CONFIG_PROGRESS_BAR_ENABLED = "progressBarEnabled"; const CONFIG_TABLE_SORTER_ENABLED = "tableSorterEnabled"; const CONFIG_PROGRESS_OVERFLOW_ENABLED = "progressOverflowEnabled"; // ========================================================================= // 功能一:课程冲突高亮 // ========================================================================= const conflictHighlighter = { // --- Properties to store column indexes --- courseNameColumnIndex: null, courseTimeColumnIndex: null, parentScope: null, // --- Helper functions --- parseTimeSegment(text) { const weekTypeMatch = text.match(/(每周|单周|双周)/); const weekType = weekTypeMatch ? weekTypeMatch[1] : "每周"; const dayMatch = text.match(/周([一二三四五六日])/); const dayMap = { 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 日: 7 }; const day = dayMap[dayMatch?.[1]] || null; const sectionMatch = text.match(/(\d+)~(\d+)节/); const sections = []; if (sectionMatch) { const start = parseInt(sectionMatch[1], 10); const end = parseInt(sectionMatch[2], 10); for (let i = start; i <= end; i++) sections.push(i); } return { weekType, day, sections }; }, parseCourseTime(cell) { const timeSegments = []; if (!cell) return timeSegments; const html = cell.innerHTML.replace(/<br\s*\/?>/g, "|"); const texts = html.split("|").filter((t) => t.trim()); texts.forEach((text) => { const cleanText = text.replace(/周数信息.*?节/g, ""); const segment = this.parseTimeSegment(cleanText); if (segment.day && segment.sections.length > 0) { timeSegments.push(segment); } }); return timeSegments; }, isConflict(seg1, seg2) { const weekConflict = seg1.weekType === "每周" || seg2.weekType === "每周" || seg1.weekType === seg2.weekType; return weekConflict && seg1.day === seg2.day && seg1.sections.some((s) => seg2.sections.includes(s)); }, checkCoursesConflict(courses, selectedCourses) { const conflicts = new Map(); courses.forEach((course) => { selectedCourses.forEach((selectedCourse) => { course.timeSegments.forEach((seg1) => { selectedCourse.timeSegments.forEach((seg2) => { if (this.isConflict(seg1, seg2)) { if (!conflicts.has(course.element)) { conflicts.set(course.element, []); } if (!conflicts.get(course.element).includes(selectedCourse.name)) { conflicts.get(course.element).push(selectedCourse.name); } } }); }); }); }); return conflicts; }, extractCoursesFromPage(parent, timeColumnIndex, nameColumnIndex) { const rows = parent.querySelectorAll("table.datagrid tr:is(.datagrid-odd, .datagrid-even)"); const allCourses = []; rows.forEach((row) => { const cells = row.querySelectorAll("td"); if (cells.length > timeColumnIndex && cells.length > nameColumnIndex) { const timeCell = cells[timeColumnIndex]; const timeSegments = this.parseCourseTime(timeCell); if (timeSegments.length > 0) { allCourses.push({ element: row, name: cells[nameColumnIndex].textContent.trim(), timeSegments: timeSegments, }); } } }); return allCourses; }, extractSelectedCourses() { const rows = document.querySelectorAll("table.datagrid tr:is(.datagrid-odd, .datagrid-even)"); const selectedCourses = []; rows.forEach((row) => { const cells = row.querySelectorAll("td"); if (cells.length < 10) return; const timeCell = cells[7]; const statusCell = cells[9]; const timeSegments = this.parseCourseTime(timeCell); if ( timeSegments.length > 0 && statusCell && (statusCell.textContent.trim() === "已选上" || statusCell.textContent.trim() === "待抽签") ) { selectedCourses.push({ element: row, name: cells[0].textContent.trim(), timeSegments: timeSegments, }); } }); return selectedCourses; }, highlightConflicts(allCourses, conflictElements, courseNameColumnIndex) { allCourses.forEach((course) => { const courseElement = course.element.querySelector(`td:nth-child(${courseNameColumnIndex + 1})`); if (!courseElement) return; if (conflictElements.has(course.element)) { courseElement.style.backgroundColor = "#ffcccc"; const conflictingCourses = conflictElements.get(course.element).join(", "); courseElement.title = `与以下已选课程冲突: ${conflictingCourses}`; } else { courseElement.style.backgroundColor = "#ccffcc"; courseElement.title = "无时间冲突"; } }); }, // --- Function to re-apply highlighting using saved settings --- reHighlight() { console.log("课程冲突高亮: 重新高亮..."); if (this.courseNameColumnIndex === null || this.courseTimeColumnIndex === null) { console.log("课程冲突高亮: 未找到初始列索引,跳过重新高亮。"); return; } const selectedCourses = JSON.parse(GM_getValue("selectedCourses", "[]")); if (selectedCourses.length === 0) return; const allCourses = this.extractCoursesFromPage( this.parentScope, this.courseTimeColumnIndex, this.courseNameColumnIndex ); const conflictElements = this.checkCoursesConflict(allCourses, selectedCourses); this.highlightConflicts(allCourses, conflictElements, this.courseNameColumnIndex); }, // --- Original run function, now saves its findings --- run() { const href = window.location.href; const isResultPage = href.includes("showResults.do"); const isQueryPage = href.includes("courseQuery/") || href.includes("getCurriculmByForm.do") || href.includes("engGridFilter.do") || href.includes("addToPlan.do"); const isPlanPage = href.includes("electivePlan/"); const isWorkPage = href.includes("electiveWork/"); const isSupplyCancelPage = href.includes("supplement/"); if (isResultPage) { console.log("课程冲突高亮: 正在已选课程页面提取数据..."); const selectedCourses = this.extractSelectedCourses(); GM_setValue("selectedCourses", JSON.stringify(selectedCourses)); console.log("课程冲突高亮: 已选课程数据已存储", selectedCourses); alert("已成功更新已选课程列表!现在可以去其他页面查看冲突情况。"); return; } const selectedCourses = JSON.parse(GM_getValue("selectedCourses", "[]")); if (selectedCourses.length === 0) { console.log("课程冲突高亮: 未找到已选课程数据,请先访问“已选课程”页面以同步数据。"); return; } let nameIndex, timeIndex, parent = document; let pageType = ""; if (isQueryPage) { pageType = "添加课程页面"; const tableHeader = document.querySelector("table.datagrid tr[class*='datagrid-']"); const isEngQueryPage = tableHeader && tableHeader.querySelector("th > form#engfilterForm") !== null; nameIndex = 1; timeIndex = isEngQueryPage ? 10 : 9; } else if (isPlanPage) { pageType = "选课计划页面"; nameIndex = 1; timeIndex = 8; } else if (isWorkPage || isSupplyCancelPage) { pageType = isWorkPage ? "预选页面" : "补退选页面"; const scopeSelector = isWorkPage ? "#scopeOneSpan" : "body"; const container = document.querySelector(scopeSelector); if (!container) return; const allTrs = container.querySelectorAll("table > tbody > tr"); const targetTr = Array.from(allTrs).find((tr) => tr.textContent.includes("选课计划中本学期可选列表")); if (targetTr && targetTr.nextElementSibling) { parent = targetTr.nextElementSibling; nameIndex = 0; timeIndex = 8; } else { return; } } else { return; } // Save the calculated indexes for later use this.courseNameColumnIndex = nameIndex; this.courseTimeColumnIndex = timeIndex; this.parentScope = parent; console.log(`课程冲突高亮: 正在分析 ${pageType}`); const allCourses = this.extractCoursesFromPage(parent, timeIndex, nameIndex); const conflictElements = this.checkCoursesConflict(allCourses, selectedCourses); this.highlightConflicts(allCourses, conflictElements, nameIndex); }, }; // ========================================================================= // 功能二:课程空余及满员高亮显示(进度条版) // ========================================================================= const progressBar = { COLORS: { LOW: "hsl(120, 70%, 80%)", MEDIUM: "hsl(55, 85%, 75%)", HIGH: "hsl(30, 90%, 80%)", FULL: "hsl(0, 100%, 90%)", OVERFLOW: "hsl(15, 100%, 85%)", EMPTY_BG: "hsl(0, 0%, 95%)", ZERO_LIMIT: "hsl(0, 0%, 88%)", }, COLUMN_WIDTH: "120px", run() { console.log("课程容量进度条: 正在渲染..."); const overflowEnabled = GM_getValue(CONFIG_PROGRESS_OVERFLOW_ENABLED, false); const tables = document.querySelectorAll("table.datagrid"); tables.forEach((table) => { const headers = table.querySelectorAll("tr.datagrid-header th"); if (headers.length === 0) return; const limitColumnIndex = Array.from(headers).findIndex((header) => header.textContent.trim().includes("限数/已选")); if (limitColumnIndex === -1) return; const limitHeader = headers[limitColumnIndex]; if (limitHeader) { limitHeader.style.width = this.COLUMN_WIDTH; } const dataRows = table.querySelectorAll("tbody tr:is(.datagrid-odd, .datagrid-even, .datagrid-all)"); console.log(`课程容量进度条: 找到 ${dataRows.length} 条课程数据行进行处理...`); dataRows.forEach((row) => { const cell = row.cells[limitColumnIndex]; if (!cell) return; cell.style.textAlign = "center"; cell.style.fontWeight = "500"; cell.style.position = "relative"; // 为溢出功能重置相关样式 if (overflowEnabled) { cell.style.overflow = "visible"; cell.style.zIndex = "1"; cell.style.position = "relative"; // 确保定位上下文正确 } else { cell.style.overflow = "hidden"; cell.style.zIndex = "auto"; } const text = cell.textContent.trim(); if (!text.includes("/")) return; const [limitStr, selectedStr] = text.split("/"); const limit = parseInt(limitStr.trim(), 10); const selected = parseInt(selectedStr.trim(), 10); if (isNaN(limit) || isNaN(selected)) return; if (limit === 0) { cell.style.backgroundColor = this.COLORS.ZERO_LIMIT; cell.title = "无名额限制"; return; } const actualPercentage = (selected / limit) * 100; const displayPercentage = overflowEnabled ? actualPercentage : Math.min(actualPercentage, 100); cell.title = `已选: ${selected}, 限额: ${limit}, 占用率: ${actualPercentage.toFixed(1)}%`; let barColor; if (selected >= limit) { barColor = actualPercentage > 100 ? this.COLORS.OVERFLOW : this.COLORS.FULL; } else if (actualPercentage < 70) { barColor = this.COLORS.LOW; } else if (actualPercentage < 90) { barColor = this.COLORS.MEDIUM; } else { barColor = this.COLORS.HIGH; } if (overflowEnabled && actualPercentage > 100) { // 创建溢出的进度条效果 const backgroundDiv = document.createElement("div"); backgroundDiv.style.position = "absolute"; backgroundDiv.style.top = "0"; backgroundDiv.style.left = "0"; backgroundDiv.style.width = `${displayPercentage}%`; backgroundDiv.style.height = "100%"; backgroundDiv.style.backgroundColor = barColor; backgroundDiv.style.opacity = "0.6"; // 添加半透明效果 backgroundDiv.style.zIndex = "-1"; // 确保在文字后面 backgroundDiv.style.pointerEvents = "none"; // 清除之前的背景div(如果存在) const existingDiv = cell.querySelector(".overflow-progress-bar"); if (existingDiv) { existingDiv.remove(); } backgroundDiv.className = "overflow-progress-bar"; cell.appendChild(backgroundDiv); cell.style.background = this.COLORS.EMPTY_BG; cell.style.position = "relative"; // 确保文字内容显示在前面 cell.style.zIndex = "1"; // 确保单元格内容在前面 } else { // 移除可能存在的溢出div const existingDiv = cell.querySelector(".overflow-progress-bar"); if (existingDiv) { existingDiv.remove(); } // 使用原来的渐变背景方式 cell.style.background = `linear-gradient(to right, ${barColor} ${displayPercentage}%, ${this.COLORS.EMPTY_BG} ${displayPercentage}%)`; } }); }); }, }; // ========================================================================= // 功能三:课程排序 // ========================================================================= const tableSorter = { // 存储当前排序状态 currentSortColumn: null, currentSortDirection: null, currentTable: null, run() { console.log("表格排序: 正在初始化..."); document.querySelectorAll("table.datagrid").forEach((table) => { const tbody = table.querySelector("tbody"); if (!tbody) return; const headerRow = tbody.querySelector("tr.datagrid-header"); if (!headerRow) return; headerRow.querySelectorAll("th").forEach((header, index) => { if (header.querySelector("form, input, button")) return; header.style.cursor = "pointer"; header.title = "点击排序"; header.addEventListener("click", () => this.sort(table, index)); }); }); }, // 重新应用当前的排序规则 reApplySort() { if (this.currentSortColumn !== null && this.currentTable) { console.log(`表格排序: 重新应用排序 - 列${this.currentSortColumn}, 方向${this.currentSortDirection}`); this.performSort(this.currentTable, this.currentSortColumn, this.currentSortDirection, false); } }, sort(table, colIndex) { const tbody = table.querySelector("tbody"); if (!tbody) return; const headerItem = tbody.querySelector(`tr.datagrid-header th:nth-child(${colIndex + 1})`); const currentDir = headerItem.dataset.sortDir || "desc"; const newDir = currentDir === "desc" ? "asc" : "desc"; // 保存排序状态 this.currentSortColumn = colIndex; this.currentSortDirection = newDir; this.currentTable = table; this.performSort(table, colIndex, newDir, true); }, performSort(table, colIndex, sortDir, updateHeader) { const tbody = table.querySelector("tbody"); if (!tbody) return; const dataRows = Array.from(tbody.querySelectorAll("tr:is(.datagrid-odd, .datagrid-even, .datagrid-all)")); const allPaginationRows = Array.from(tbody.querySelectorAll('tr:has(form[name="pageForm"])')); if (dataRows.length === 0) return; let masterPaginationRow = null; if (allPaginationRows.length > 0) { masterPaginationRow = allPaginationRows[0]; } if (updateHeader) { const headerItem = tbody.querySelector(`tr.datagrid-header th:nth-child(${colIndex + 1})`); headerItem.dataset.sortDir = sortDir; tbody .querySelectorAll("tr.datagrid-header th") .forEach((th) => (th.innerHTML = th.innerHTML.replace(/ [▲▼]$/, ""))); headerItem.innerHTML += sortDir === "asc" ? " ▲" : " ▼"; } const headerItem = tbody.querySelector(`tr.datagrid-header th:nth-child(${colIndex + 1})`); const headerText = headerItem.textContent.replace(/ [▲▼]$/, "").trim(); const isCapacityColumn = headerText.startsWith("限数/已选"); dataRows.sort((a, b) => { const cellA = a.cells[colIndex]; const cellB = b.cells[colIndex]; let valA, valB; if (isCapacityColumn) { const getPercentage = (cell) => { if (!cell) return 0; const text = cell.textContent.trim(); if (!text.includes("/")) return -1; const [limitStr, selectedStr] = text.split("/"); const limit = parseInt(limitStr.trim(), 10); const selected = parseInt(selectedStr.trim(), 10); if (isNaN(limit) || isNaN(selected) || limit === 0) { return 0; } return (selected / limit) * 100; }; valA = getPercentage(cellA); valB = getPercentage(cellB); } else { valA = cellA.textContent.trim(); valB = cellB.textContent.trim(); const isNumeric = !isNaN(parseFloat(valA)) && isFinite(valA) && !isNaN(parseFloat(valB)) && isFinite(valB); if (isNumeric) { valA = parseFloat(valA); valB = parseFloat(valB); } } if (valA < valB) return sortDir === "asc" ? -1 : 1; if (valA > valB) return sortDir === "asc" ? 1 : -1; return 0; }); dataRows.forEach((row) => row.remove()); allPaginationRows.forEach((row) => row.remove()); if (masterPaginationRow) { tbody.appendChild(masterPaginationRow); } dataRows.forEach((row, i) => { tbody.appendChild(row); row.className = i % 2 === 0 ? "datagrid-odd" : "datagrid-even"; }); if (masterPaginationRow) { tbody.appendChild(masterPaginationRow.cloneNode(true)); } }, }; // ========================================================================= // 功能四:加载所有分页数据 // ========================================================================= const allPagesLoader = { async fetchAllPages(button) { const table = button.closest("table.datagrid"); const tbody = table.querySelector("tbody"); const paginationSelect = document.querySelector('select[name="netui_row"]'); if (!table || !tbody || !paginationSelect) { alert("无法找到分页元素!"); return; } button.textContent = "正在加载中..."; button.disabled = true; // 1. 找到包含下拉菜单的表单 const pageForm = paginationSelect.closest('form[name="pageForm"]'); if (!pageForm) { alert("关键的翻页表单 'pageForm' 未找到!"); button.textContent = "初始化失败"; button.disabled = false; return; } // 获取当前默认的分页选项 const paginationOptionElement = paginationSelect.querySelector("option[selected]"); const currentPage = parseInt(paginationOptionElement.innerText); console.log(`当前页: ${currentPage}`); // 2. 从表单的 action 属性构建基础 URL const baseActionUrl = new URL(pageForm.action, window.location.href).href; // 3. 获取表单中所有参数(包括所有隐藏的查询条件) const formParams = new URLSearchParams(); // 4. 获取所有需要抓取的页面选项(跳过当前页) const pageOptions = Array.from(paginationSelect.options).filter((opt) => parseInt(opt.innerText) !== currentPage); // 5. 遍历页面选项,生成每一个页面的准确 URL const urlsToFetch = pageOptions.map((opt) => { // 在表单参数副本上,设置正确的页码 formParams.set("netui_row", opt.value); // 组合成最终的 URL return `${baseActionUrl}?${formParams.toString()}`; }); console.log("所有页 URL:"); console.log(urlsToFetch); if (urlsToFetch.length === 0) { button.textContent = "只有一页"; return; } console.log(`准备获取 ${urlsToFetch.length} 个页面的数据...`); try { const responses = await Promise.all(urlsToFetch.map((url) => fetch(url))); const htmlStrings = await Promise.all(responses.map((res) => res.text())); const parser = new DOMParser(); const newRows = []; htmlStrings.forEach((html) => { const doc = parser.parseFromString(html, "text/html"); const rows = doc.querySelectorAll("table.datagrid tbody tr:is(.datagrid-odd, .datagrid-even)"); newRows.push(...rows); }); console.log(`成功获取了 ${newRows.length} 条新的课程数据。`); // 移除页面底部的所有翻页行 const allPaginationRows = tbody.querySelectorAll('tr:has(form[name="pageForm"])'); allPaginationRows.forEach((row) => row.remove()); // 将新抓取的数据行添加到表格中 const fragment = document.createDocumentFragment(); newRows.forEach((row) => fragment.appendChild(row)); tbody.appendChild(fragment); button.textContent = "全部加载完毕!"; button.style.backgroundColor = "#90ee90"; // 重新计算并设置所有数据行的奇偶样式 const allDataRows = tbody.querySelectorAll("tr:is(.datagrid-odd, .datagrid-even)"); allDataRows.forEach((row, i) => { row.className = i % 2 === 0 ? "datagrid-odd" : "datagrid-even"; }); // 重新应用排序(如果之前有排序的话) if (GM_getValue(CONFIG_TABLE_SORTER_ENABLED, true)) { tableSorter.reApplySort(); } // 重新应用冲突高亮 if (GM_getValue(CONFIG_HIGHLIGHT_ENABLED, true)) { conflictHighlighter.reHighlight(); } // 重新计算进度条 if (GM_getValue(CONFIG_PROGRESS_BAR_ENABLED, true)) { progressBar.run(); } } catch (error) { console.error("加载所有页面时出错:", error); button.textContent = "加载失败,请查看控制台"; button.style.backgroundColor = "#ffcccb"; button.disabled = false; } }, run() { console.log("加载所有页: 正在初始化..."); const paginationCell = document.querySelector('tr:has(form[name="pageForm"]) > td[align="right"]'); // 总页数 const totalPages = Array.from(paginationCell.querySelectorAll("option")).length; console.log(`加载所有页: 检测到总页数为 ${totalPages}`); if (totalPages <= 1) return; if (paginationCell && !document.getElementById("load-all-pages-btn")) { const loadButton = document.createElement("button"); loadButton.id = "load-all-pages-btn"; loadButton.textContent = "✨ 加载所有页"; loadButton.style.marginLeft = "15px"; loadButton.style.padding = "2px 8px"; loadButton.style.cursor = "pointer"; loadButton.style.border = "1px solid #ccc"; loadButton.style.borderRadius = "4px"; loadButton.addEventListener("click", (e) => this.fetchAllPages(e.target)); paginationCell.appendChild(loadButton); } }, }; // ========================================================================= // 脚本菜单注册与主执行逻辑 // ========================================================================= function setupMenu() { let highlightEnabled = GM_getValue(CONFIG_HIGHLIGHT_ENABLED, true); let progressBarEnabled = GM_getValue(CONFIG_PROGRESS_BAR_ENABLED, true); let tableSorterEnabled = GM_getValue(CONFIG_TABLE_SORTER_ENABLED, true); let progressOverflowEnabled = GM_getValue(CONFIG_PROGRESS_OVERFLOW_ENABLED, false); GM_registerMenuCommand(`${highlightEnabled ? "✅ 禁用" : "❌ 启用"} 课程冲突高亮`, () => { const newState = !highlightEnabled; GM_setValue(CONFIG_HIGHLIGHT_ENABLED, newState); alert(`课程冲突高亮功能已${newState ? "启用" : "禁用"}。\n请刷新页面以应用更改。`); location.reload(); }); GM_registerMenuCommand(`${progressBarEnabled ? "✅ 禁用" : "❌ 启用"} 容量进度条`, () => { const newState = !progressBarEnabled; GM_setValue(CONFIG_PROGRESS_BAR_ENABLED, newState); alert(`课程容量进度条功能已${newState ? "启用" : "禁用"}。\n请刷新页面以应用更改。`); location.reload(); }); GM_registerMenuCommand(`${progressOverflowEnabled ? "✅ 禁用" : "❌ 启用"} 进度条溢出显示`, () => { const newState = !progressOverflowEnabled; GM_setValue(CONFIG_PROGRESS_OVERFLOW_ENABLED, newState); alert( `进度条溢出显示功能已${ newState ? "启用" : "禁用" }。\n启用后,选课人数超过限额的课程进度条会向右溢出显示真实长度。\n请刷新页面以应用更改。` ); location.reload(); }); GM_registerMenuCommand(`${tableSorterEnabled ? "✅ 禁用" : "❌ 启用"} 表格排序`, () => { const newState = !tableSorterEnabled; GM_setValue(CONFIG_TABLE_SORTER_ENABLED, newState); alert(`表格排序功能已${newState ? "启用" : "禁用"}。\n请刷新页面以应用更改。`); location.reload(); }); } function main() { // 依次执行各项功能 if (GM_getValue(CONFIG_PROGRESS_BAR_ENABLED, true)) { progressBar.run(); } if (GM_getValue(CONFIG_TABLE_SORTER_ENABLED, true)) { tableSorter.run(); } if (GM_getValue(CONFIG_HIGHLIGHT_ENABLED, true)) { conflictHighlighter.run(); } allPagesLoader.run(); } // --- 脚本启动 --- setupMenu(); if (document.readyState === "complete" || document.readyState === "interactive") { main(); } else { window.addEventListener("load", main); } })();