// ==UserScript== // @name 第一师范学院成绩自动填报助手 // @namespace http://tampermonkey.net/ // @version 3.7 // @description 自动识别成绩类型,支持灵活的Excel列顺序和行顺序,模板包含已填报数据(兼容篡改猴/ScriptCat) // @author SoyaBean // @match http://jwgl.hnfnu.edu.cn:9080/eams/teach/grade/course/teacher!inputGA.action* // @match http://jwgl.hnfnu.edu.cn:9080/eams/teach/grade/delaymakeup/retake-manage-teacher!inputReady.action* // @match https://jwgl.hnfnu.edu.cn:9080/eams/teach/grade/course/teacher!inputGA.action* // @match https://jwgl.hnfnu.edu.cn:9080/eams/teach/grade/delaymakeup/retake-manage-teacher!inputReady.action* // @require https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js // @grant GM_xmlhttpRequest // @grant unsafeWindow // @connect cdn.jsdelivr.net // @connect unpkg.com // @connect cdn.bootcdn.net // ==/UserScript== (function () { 'use strict'; /* ================================================================== * XLSX 库多策略加载器(篡改猴 / ScriptCat 双平台兼容) * ================================================================== */ // 全局保存解析出的 XLSX 库对象 let XLSXLib = null; // CDN 备选列表(GM_xmlhttpRequest 下载用) const XLSX_CDN_LIST = [ 'https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js', 'https://unpkg.com/xlsx@0.18.5/dist/xlsx.full.min.js', 'https://cdn.bootcdn.net/ajax/libs/xlsx/0.18.5/xlsx.full.min.js' ]; // 策略①②:尝试从各种作用域直接获取 XLSX function tryGetXLSXFromScope() { // 策略①:@require 注入到脚本作用域(Tampermonkey 典型情况) try { // eslint-disable-next-line no-undef if (typeof XLSX !== 'undefined' && XLSX && XLSX.utils) { return XLSX; } } catch (e) { /* ignore */ } // 策略②:unsafeWindow(部分环境挂载在页面真实 window 上) try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow.XLSX && unsafeWindow.XLSX.utils) { return unsafeWindow.XLSX; } } catch (e) { /* ignore */ } // 沙箱 window try { if (window.XLSX && window.XLSX.utils) { return window.XLSX; } } catch (e) { /* ignore */ } return null; } // 策略③:GM_xmlhttpRequest 下载库源码,在脚本作用域内执行 // (ScriptCat 终极方案:完全不依赖页面 window,不受沙箱隔离影响) function loadXLSXByGMXHR(cdnIndex) { return new Promise((resolve, reject) => { if (cdnIndex >= XLSX_CDN_LIST.length) { reject(new Error('所有 CDN 均加载失败')); return; } const url = XLSX_CDN_LIST[cdnIndex]; console.log(`[成绩助手] 正在通过 GM_xmlhttpRequest 加载 XLSX:${url}`); if (typeof GM_xmlhttpRequest === 'undefined') { reject(new Error('GM_xmlhttpRequest 不可用')); return; } GM_xmlhttpRequest({ method: 'GET', url: url, timeout: 20000, onload: function (response) { if (response.status === 200 && response.responseText) { try { // 用 CommonJS 方式执行 UMD 库,导出结果被捕获到 module.exports // 这样 XLSX 直接进入脚本作用域,绕过所有沙箱隔离 const module = { exports: {} }; const runner = new Function( 'module', 'exports', response.responseText + '\n;if (typeof XLSX !== "undefined") { module.exports = XLSX; }' ); runner(module, module.exports); const lib = module.exports; if (lib && lib.utils && lib.utils.book_new) { console.log('[成绩助手] XLSX 通过 GM_xmlhttpRequest 加载成功'); resolve(lib); } else { reject(new Error('XLSX 执行后未获取到有效对象')); } } catch (err) { console.warn(`[成绩助手] CDN ${cdnIndex} 执行失败,尝试下一个`, err); loadXLSXByGMXHR(cdnIndex + 1).then(resolve).catch(reject); } } else { console.warn(`[成绩助手] CDN ${cdnIndex} 响应异常,尝试下一个`); loadXLSXByGMXHR(cdnIndex + 1).then(resolve).catch(reject); } }, onerror: function () { console.warn(`[成绩助手] CDN ${cdnIndex} 网络错误,尝试下一个`); loadXLSXByGMXHR(cdnIndex + 1).then(resolve).catch(reject); }, ontimeout: function () { console.warn(`[成绩助手] CDN ${cdnIndex} 超时,尝试下一个`); loadXLSXByGMXHR(cdnIndex + 1).then(resolve).catch(reject); } }); }); } // 统一加载入口:依次尝试所有策略 async function ensureXLSX() { if (XLSXLib) return XLSXLib; // 先尝试作用域直取(Tampermonkey 走这里,零延迟) XLSXLib = tryGetXLSXFromScope(); if (XLSXLib) { console.log('[成绩助手] XLSX 从脚本作用域获取成功'); return XLSXLib; } // ScriptCat 走这里:GM_xmlhttpRequest 下载 + 脚本内执行 XLSXLib = await loadXLSXByGMXHR(0); return XLSXLib; } // 同步获取(供已确保加载后的函数使用) function getXLSX() { if (!XLSXLib) { XLSXLib = tryGetXLSXFromScope(); } return XLSXLib; } /* ================================================================== * 初始化入口 * ================================================================== */ function init() { createControlPanel(); // 后台预加载 XLSX(不阻塞面板显示) ensureXLSX() .then(() => { console.log('[成绩助手] XLSX 库准备就绪'); }) .catch((err) => { console.error('[成绩助手] XLSX 库预加载失败:', err); showStatus('⚠️ XLSX 库预加载失败,点击功能按钮时将自动重试。\n如持续失败请检查网络。', 'warning'); }); } if (document.readyState === 'complete') { init(); } else { window.addEventListener('load', init); } /* ================================================================== * 控制面板 * ================================================================== */ function createControlPanel() { // 防止重复创建 if (document.getElementById('grade-auto-fill-container')) return; const container = document.createElement('div'); container.id = 'grade-auto-fill-container'; container.style.cssText = ` position: fixed; top: 20px; right: 20px; z-index: 10000; `; const toggleBtn = document.createElement('button'); toggleBtn.id = 'grade-toggle-btn'; toggleBtn.innerHTML = '📊'; toggleBtn.style.cssText = ` position: absolute; top: 0; right: 0; width: 40px; height: 40px; background: #1976d2; color: white; border: none; border-radius: 50%; cursor: move; font-size: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.3); display: none; z-index: 10001; `; const panel = document.createElement('div'); panel.id = 'grade-auto-fill-panel'; panel.style.cssText = ` width: 320px; background: #fff; border: 2px solid #333; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.3); transition: all 0.3s ease; `; const header = document.createElement('div'); header.style.cssText = ` padding: 10px 15px; background: #1976d2; color: white; border-radius: 6px 6px 0 0; cursor: move; user-select: none; display: flex; justify-content: space-between; align-items: center; `; header.innerHTML = `

📊 第一师范学院成绩自动填报助手

`; const content = document.createElement('div'); content.style.cssText = 'padding: 15px;'; content.innerHTML = `

第一步:导出成绩数据

包含所有学生信息和已填报的成绩

第二步:上传修改后的成绩表

可直接点击"自动填入",预览为可选项

📖 使用说明
  1. 点击"导出当前成绩Excel"下载包含已有成绩的表格
  2. 在Excel中修改成绩(只填数字)
  3. 空白成绩也会被填入为空
  4. 非数字内容将被跳过
  5. Excel中列顺序和行顺序可自由调整
  6. 点击"自动填入"会先清空原有数据

⚠️ 注意:自动填入前会清空所有原有成绩!

作者:舒宜彬 | v3.7
`; panel.appendChild(header); panel.appendChild(content); container.appendChild(toggleBtn); container.appendChild(panel); document.body.appendChild(container); bindEvents(); makeDraggable(container, header); makeDraggable(container, toggleBtn); } /* ================================================================== * 事件绑定 * ================================================================== */ function bindEvents() { document.getElementById('download-template-btn').addEventListener('click', downloadTemplate); document.getElementById('preview-btn').addEventListener('click', handlePreview); document.getElementById('fill-btn').addEventListener('click', handleFill); document.getElementById('clear-all-btn').addEventListener('click', handleClearAll); document.getElementById('minimize-btn').addEventListener('click', function () { document.getElementById('grade-auto-fill-panel').style.display = 'none'; document.getElementById('grade-toggle-btn').style.display = 'block'; }); document.getElementById('close-btn').addEventListener('click', function () { if (confirm('确定要关闭成绩填报助手吗?')) { document.getElementById('grade-auto-fill-container').remove(); } }); document.getElementById('grade-toggle-btn').addEventListener('click', function (e) { if (e.target.dataset.dragging === 'true') { e.target.dataset.dragging = 'false'; return; } document.getElementById('grade-auto-fill-panel').style.display = 'block'; document.getElementById('grade-toggle-btn').style.display = 'none'; }); document.querySelectorAll('#grade-auto-fill-panel button').forEach(btn => { if (btn.id !== 'minimize-btn' && btn.id !== 'close-btn') { btn.addEventListener('mouseenter', function () { this.style.opacity = '0.9'; }); btn.addEventListener('mouseleave', function () { this.style.opacity = '1'; }); } }); } /* ================================================================== * 拖动功能 * ================================================================== */ function makeDraggable(container, handle) { let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0; let isDragging = false; handle.onmousedown = function (e) { e = e || window.event; e.preventDefault(); pos3 = e.clientX; pos4 = e.clientY; isDragging = false; document.onmouseup = closeDragElement; document.onmousemove = elementDrag; }; function elementDrag(e) { e = e || window.event; e.preventDefault(); pos1 = pos3 - e.clientX; pos2 = pos4 - e.clientY; pos3 = e.clientX; pos4 = e.clientY; if (Math.abs(pos1) > 5 || Math.abs(pos2) > 5) { isDragging = true; if (handle.id === 'grade-toggle-btn') { handle.dataset.dragging = 'true'; } } container.style.top = (container.offsetTop - pos2) + 'px'; container.style.left = (container.offsetLeft - pos1) + 'px'; container.style.right = 'auto'; } function closeDragElement() { document.onmouseup = null; document.onmousemove = null; if (handle.id === 'grade-toggle-btn' && !isDragging) { handle.dataset.dragging = 'false'; } } } /* ================================================================== * 成绩清空 * ================================================================== */ function clearAllGrades() { let clearedCount = 0; const scoreInputs = document.querySelectorAll('input[name$=".score"]'); scoreInputs.forEach(input => { if (input.value !== '') { input.value = ''; input.dispatchEvent(new Event('change', { bubbles: true })); input.dispatchEvent(new Event('blur', { bubbles: true })); clearedCount++; } }); return { total: scoreInputs.length, cleared: clearedCount }; } function handleClearAll() { if (confirm('确定要清空所有成绩吗?\n此操作不可恢复!')) { const result = clearAllGrades(); showStatus(`清空完成!\n共清空 ${result.cleared} 个成绩\n总计 ${result.total} 个成绩输入框`, 'warning'); } } /* ================================================================== * 成绩类型识别 * ================================================================== */ function identifyGradeTypes() { const gradeTypes = []; const firstHeaderRow = document.querySelector('table.gridtable thead tr:first-child'); if (firstHeaderRow) { firstHeaderRow.querySelectorAll('th').forEach(header => { const text = header.textContent.trim(); if (text.includes('成绩') && !text.includes('总评成绩')) { gradeTypes.push(text); } }); } return gradeTypes; } /* ================================================================== * 导出成绩模板(异步确保 XLSX 已加载) * ================================================================== */ async function downloadTemplate() { let XLSX; try { showStatus('正在准备 XLSX 库...', 'info'); XLSX = await ensureXLSX(); } catch (err) { showStatus('❌ XLSX 库加载失败:' + err.message + '\n请检查网络后重试。', 'error'); return; } const students = []; const gradeTypes = identifyGradeTypes(); if (gradeTypes.length === 0) { showStatus('未能识别到成绩类型!', 'error'); return; } showStatus(`正在导出数据...\n成绩类型:${gradeTypes.join('、')}`, 'info'); const studentRows = document.querySelectorAll('#mylist tr'); let exportedGradeCount = 0; studentRows.forEach(row => { const cells = row.querySelectorAll('td'); if (cells.length > 2) { const studentObj = { '学号': cells[0].textContent.trim(), '姓名': cells[1].textContent.trim(), '行政班': cells[2].textContent.trim() }; const scoreInputs = row.querySelectorAll('input[name$=".score"]'); gradeTypes.forEach((gradeType, index) => { if (index < scoreInputs.length) { const score = scoreInputs[index].value; studentObj[gradeType] = score; if (score !== '') exportedGradeCount++; } else { studentObj[gradeType] = ''; } }); students.push(studentObj); } }); if (students.length === 0) { showStatus('未能从页面提取到学生信息!', 'error'); return; } const wb = XLSX.utils.book_new(); const ws = XLSX.utils.json_to_sheet(students); const cols = [{ wch: 15 }, { wch: 12 }, { wch: 15 }]; gradeTypes.forEach(() => cols.push({ wch: 12 })); ws['!cols'] = cols; const range = XLSX.utils.decode_range(ws['!ref']); for (let R = 1; R <= range.e.r; ++R) { for (let C = 3; C < 3 + gradeTypes.length; ++C) { const cellAddress = XLSX.utils.encode_cell({ r: R, c: C }); if (ws[cellAddress] && ws[cellAddress].v) { ws[cellAddress].s = { fill: { fgColor: { rgb: 'E8F5E9' } } }; } } } XLSX.utils.book_append_sheet(wb, ws, '成绩数据'); const courseInfo = document.querySelector('.grade-input-lesson-info'); let courseName = '成绩'; if (courseInfo) { const match = courseInfo.textContent.match(/课程名称:([^课程类别]+)/); if (match) courseName = match[1].trim(); } XLSX.writeFile(wb, `${courseName}_成绩数据_${new Date().toLocaleDateString().replace(/\//g, '-')}.xlsx`); showStatus(`导出成功!\n共${students.length}名学生\n已填报成绩:${exportedGradeCount}个\n成绩类型:${gradeTypes.join('、')}\n\n已填报的成绩在Excel中用浅绿色标记`, 'success'); } /* ================================================================== * Excel 读取与预览 * ================================================================== */ let excelData = null; let skippedRecords = []; async function handlePreview() { const file = document.getElementById('excel-file-input').files[0]; if (!file) { showStatus('请先选择Excel文件!', 'error'); return; } try { showStatus('正在读取Excel文件...', 'info'); excelData = await parseExcelFile(file); const excelGradeTypes = excelData.length > 0 ? Object.keys(excelData[0]).filter(k => k.includes('成绩') && !k.includes('总评')) : []; let validCount = 0, emptyCount = 0, invalidCount = 0, totalGrades = 0; excelData.forEach(row => { let hasValid = false, hasEmpty = false; excelGradeTypes.forEach(gt => { const score = String(row[gt] === undefined || row[gt] === null ? '' : row[gt]).trim(); if (score === '') { hasEmpty = true; emptyCount++; } else if (!isNaN(score)) { hasValid = true; totalGrades++; } else { invalidCount++; } }); if (hasValid || hasEmpty) validCount++; }); let preview = `成功读取 ${excelData.length} 条数据\n`; preview += `识别到的成绩类型:${excelGradeTypes.join('、')}\n`; preview += `有效数据行:${validCount} 条\n`; preview += `数字成绩:${totalGrades} 个\n`; preview += `空白成绩:${emptyCount} 个\n`; if (invalidCount > 0) preview += `非数字成绩:${invalidCount} 处(将被跳过)\n`; preview += '\n数据预览(前3条):\n'; excelData.slice(0, 3).forEach((row, i) => { preview += `${i + 1}. ${row['学号']} ${row['姓名']}\n`; excelGradeTypes.forEach(gt => { const s = row[gt]; preview += ` ${gt}: ${s === '' || s === undefined || s === null ? '(空)' : s}\n`; }); }); showStatus(preview, 'info'); } catch (error) { showStatus('读取文件失败:' + error.message, 'error'); } } /* ================================================================== * 自动填入 * ================================================================== */ async function handleFill() { const file = document.getElementById('excel-file-input').files[0]; if (!file) { showStatus('请先选择Excel文件!', 'error'); return; } excelData = null; try { showStatus('正在读取Excel文件...', 'info'); excelData = await parseExcelFile(file); } catch (error) { showStatus('读取文件失败:' + error.message, 'error'); return; } if (confirm('确定要自动填入成绩吗?\n注意:填入前会先清空所有原有成绩!')) { showStatus('正在清空原有成绩...', 'info'); const clearResult = clearAllGrades(); setTimeout(() => { showStatus('正在填入新成绩...', 'info'); const result = fillGrades(excelData); showDetailedReport(result, clearResult); }, 500); } } /* ================================================================== * Excel 解析(异步确保 XLSX 已加载) * ================================================================== */ function parseExcelFile(file) { return new Promise((resolve, reject) => { // 先确保 XLSX 库可用 ensureXLSX().then((XLSX) => { const reader = new FileReader(); reader.onload = function (e) { try { const workbook = XLSX.read(e.target.result, { type: 'binary' }); const worksheet = workbook.Sheets[workbook.SheetNames[0]]; resolve(XLSX.utils.sheet_to_json(worksheet)); } catch (err) { reject(err); } }; reader.onerror = function () { reject(new Error('文件读取失败')); }; reader.readAsBinaryString(file); }).catch((err) => { reject(new Error('XLSX 库加载失败:' + err.message)); }); }); } /* ================================================================== * 成绩填入核心逻辑 * ================================================================== */ function fillGrades(data) { let filledCount = 0, emptyFills = 0; let notFoundStudents = [], successfulFills = []; skippedRecords = []; const pageGradeTypes = identifyGradeTypes(); const pageStudentMap = new Map(); document.querySelectorAll('#mylist tr').forEach(row => { const cells = row.querySelectorAll('td'); if (cells.length > 2) { pageStudentMap.set( `${cells[0].textContent.trim()}_${cells[1].textContent.trim()}`, row ); } }); data.forEach(student => { const studentId = String(student['学号'] || '').trim(); const studentName = String(student['姓名'] || '').trim(); if (!studentId || !studentName) return; const studentRow = pageStudentMap.get(`${studentId}_${studentName}`); if (studentRow) { let hasValidOperation = false; const filledScores = []; const scoreInputs = studentRow.querySelectorAll('input[name$=".score"]'); pageGradeTypes.forEach((gradeType, index) => { let score = null; Object.keys(student).forEach(key => { if (key === gradeType || key.includes(gradeType.replace('成绩', '')) || gradeType.includes(key.replace('成绩', ''))) { score = student[key]; } }); if (score !== null && score !== undefined && index < scoreInputs.length) { const scoreStr = String(score).trim(); if (scoreStr === '') { scoreInputs[index].value = ''; scoreInputs[index].dispatchEvent(new Event('change', { bubbles: true })); scoreInputs[index].dispatchEvent(new Event('blur', { bubbles: true })); hasValidOperation = true; emptyFills++; filledScores.push(`${gradeType}:(空)`); } else if (!isNaN(scoreStr)) { scoreInputs[index].value = scoreStr; scoreInputs[index].dispatchEvent(new Event('change', { bubbles: true })); scoreInputs[index].dispatchEvent(new Event('blur', { bubbles: true })); hasValidOperation = true; filledScores.push(`${gradeType}:${scoreStr}`); } else { skippedRecords.push({ 学号: studentId, 姓名: studentName, 原因: `${gradeType}"${scoreStr}"不是有效数字` }); } } }); if (hasValidOperation) { filledCount++; successfulFills.push({ 学号: studentId, 姓名: studentName, 成绩: filledScores.join(', ') }); } } else { const hasAnyGrade = Object.keys(student).some( key => key.includes('成绩') && student[key] !== undefined && student[key] !== null ); if (hasAnyGrade) notFoundStudents.push({ 学号: studentId, 姓名: studentName }); } }); return { total: data.length, filledCount, emptyFills, skippedRecords, notFoundStudents, successfulFills }; } /* ================================================================== * 状态显示 * ================================================================== */ function showStatus(message, type = 'info') { const statusArea = document.getElementById('status-area'); const statusText = document.getElementById('status-text'); if (!statusArea || !statusText) { console.log('[成绩助手] ' + message); return; } statusArea.style.display = 'block'; statusText.textContent = message; statusText.style.whiteSpace = 'pre-wrap'; const styles = { success: { bg: '#d4edda', color: '#155724' }, error: { bg: '#f8d7da', color: '#721c24' }, warning: { bg: '#fff3cd', color: '#856404' }, info: { bg: '#f5f5f5', color: '#333' } }; const s = styles[type] || styles.info; statusArea.style.background = s.bg; statusArea.style.color = s.color; } /* ================================================================== * 详细报告 * ================================================================== */ function showDetailedReport(result, clearResult) { let message = `=== 填报完成 ===\n\n`; if (clearResult) message += `🗑️ 已清空 ${clearResult.cleared} 个原有成绩\n\n`; message += `✅ 成功填报:${result.filledCount} 条`; if (result.emptyFills > 0) message += `(含${result.emptyFills}处空值)`; message += '\n'; if (result.successfulFills.length > 0 && result.successfulFills.length <= 5) { message += '\n成功填报的学生:\n'; result.successfulFills.forEach(s => { message += ` ${s.学号} ${s.姓名}\n ${s.成绩}\n`; }); } else if (result.successfulFills.length > 5) { message += '\n成功填报的学生(显示前5条):\n'; result.successfulFills.slice(0, 5).forEach(s => { message += ` ${s.学号} ${s.姓名}\n ${s.成绩}\n`; }); message += ` ...还有${result.successfulFills.length - 5}条\n`; } if (result.notFoundStudents.length > 0) { message += `\n❌ 未找到的学生(${result.notFoundStudents.length}人):\n`; result.notFoundStudents.slice(0, 5).forEach(s => { message += ` ${s.学号} ${s.姓名}\n`; }); if (result.notFoundStudents.length > 5) { message += ` ...还有${result.notFoundStudents.length - 5}人\n`; } } if (result.skippedRecords.length > 0) { message += `\n⚠️ 跳过的非数字记录(${result.skippedRecords.length}条):\n`; result.skippedRecords.slice(0, 5).forEach(s => { message += ` ${s.学号} ${s.姓名}:${s.原因}\n`; }); if (result.skippedRecords.length > 5) { message += ` ...还有${result.skippedRecords.length - 5}条\n`; } } showStatus(message, result.filledCount > 0 ? 'success' : 'warning'); if (skippedRecords.length > 0) addDownloadButton(); } /* ================================================================== * 跳过记录下载 * ================================================================== */ function addDownloadButton() { if (document.getElementById('download-skipped-btn')) return; const btnContainer = document.createElement('div'); btnContainer.style.marginTop = '10px'; const downloadBtn = document.createElement('button'); downloadBtn.id = 'download-skipped-btn'; downloadBtn.textContent = '📊 下载跳过记录'; downloadBtn.style.cssText = 'width: 100%; padding: 8px; background: #ff9800; color: white; border: none; border-radius: 4px; cursor: pointer;'; downloadBtn.addEventListener('click', downloadSkippedRecords); btnContainer.appendChild(downloadBtn); document.getElementById('status-area').appendChild(btnContainer); } async function downloadSkippedRecords() { if (skippedRecords.length === 0) { showStatus('没有跳过的记录!', 'info'); return; } let XLSX; try { XLSX = await ensureXLSX(); } catch (err) { showStatus('❌ XLSX 库加载失败:' + err.message, 'error'); return; } const wb = XLSX.utils.book_new(); const ws = XLSX.utils.json_to_sheet(skippedRecords); ws['!cols'] = [{ wch: 15 }, { wch: 12 }, { wch: 40 }]; XLSX.utils.book_append_sheet(wb, ws, '跳过记录'); XLSX.writeFile(wb, `跳过记录_${new Date().toLocaleDateString().replace(/\//g, '-')}.xlsx`); showStatus(`已下载 ${skippedRecords.length} 条跳过记录`, 'success'); } })();