// ==UserScript== // @name WHUT教务系统小助手 // @namespace http://tampermonkey.net/ // @version 3.6.1 // @description 现代化教务浮窗助手:支持成绩分析与隐藏成绩回填、学业监测报告、GPA 查询、官方证明下载和一键评教,面板可拖拽、缩放并关闭回悬浮球。 // @author 毫厘 // @match *://jwxt.whut.edu.cn/jwapp/sys/* // @match *://jwxt.whut.edu.cn/jwmobile/index#/kwApp/cjcx/* // @require https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js // @require https://cdn.sheetjs.com/xlsx-latest/package/dist/xlsx.full.min.js // @require https://cdn.jsdelivr.net/npm/sweetalert2@11 // @grant GM_addStyle // @grant GM_xmlhttpRequest // @connect jwxt.whut.edu.cn // @run-at document-start // @license MIT // ==/UserScript== (function() { 'use strict'; // ================= 🎨 现代化样式配置 ================= const THEME = { primary: "#4f46e5", primaryHover: "#4338ca", accent: "#ec4899", bg: "#f8fafc", panelBg: "rgba(255, 255, 255, 0.95)", border: "#e2e8f0", textMain: "#1e293b", textSub: "#64748b", success: "#10b981", warning: "#f59e0b", danger: "#ef4444" }; GM_addStyle(` /* 悬浮球样式 (支持拖拽) */ #whut-fab { position: fixed; width: 56px; height: 56px; background: linear-gradient(135deg, ${THEME.primary}, ${THEME.accent}); color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 24px; cursor: grab; box-shadow: 0 4px 14px rgba(79, 70, 229, 0.4); z-index: 100000; transition: transform 0.2s, box-shadow 0.2s; user-select: none; } #whut-fab:hover { transform: scale(1.05); box-shadow: 0 6px 20px rgba(79, 70, 229, 0.6); } #whut-fab:active { cursor: grabbing; transform: scale(0.95); } /* 悬浮窗口化主面板 */ #whut-helper-panel { display: none; position: fixed; width: 600px; min-width: 420px; max-width: calc(100vw - 32px); height: 85vh; min-height: 420px; max-height: calc(100vh - 32px); background: ${THEME.panelBg}; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); box-shadow: 0 20px 40px rgba(0,0,0,0.15); border-radius: 16px; border: 1px solid rgba(255,255,255,0.5); z-index: 99999; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; flex-direction: column; overflow: hidden; font-size: 13px; resize: none; } #whut-helper-panel.open { display: flex; animation: panelPopIn 0.3s cubic-bezier(0.16, 1, 0.3, 1); } .helper-header { background: linear-gradient(180deg, rgba(255,255,255,0.92), rgba(255,255,255,0.68)); padding: 16px 18px 0; flex-shrink: 0; cursor: grab; user-select: none; border-bottom: 1px solid rgba(226,232,240,0.72); } .helper-header:active { cursor: grabbing; } .header-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; pointer-events: none; } .header-title { font-size: 18px; font-weight: 700; color: ${THEME.textMain}; display: flex; align-items: center; gap: 8px; } .header-actions { display: flex; align-items: center; gap: 6px; pointer-events: auto; } .header-action { width: 30px; height: 30px; border: 1px solid ${THEME.border}; background: white; color: ${THEME.textSub}; border-radius: 8px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; font-size: 15px; line-height: 1; transition: 0.2s; } .header-action:hover { color: ${THEME.primary}; border-color: ${THEME.primary}; background: #f8fafc; } .header-close:hover { color: ${THEME.danger}; border-color: #fecaca; background: #fee2e2; } .helper-tabs { display: flex; gap: 8px; padding: 2px 0 12px; overflow-x: auto; scrollbar-width: none; pointer-events: auto; } .helper-tab { padding: 8px 12px; cursor: pointer; color: ${THEME.textSub}; font-weight: 700; font-size: 13px; position: relative; transition: 0.2s; white-space: nowrap; border: 1px solid transparent; border-radius: 999px; background: transparent; } .helper-tab:hover { color: ${THEME.primary}; background: #eef2ff; } .helper-tab.active { color: ${THEME.primary}; background: #eef2ff; border-color: #c7d2fe; box-shadow: 0 2px 8px rgba(79,70,229,0.10); } .helper-tab.active::after { content: none; } .tab-view { display: none; flex-direction: column; flex: 1; overflow: hidden; } .tab-view.active { display: flex; animation: fadeIn 0.3s; } /* 通用工具栏与列表样式 */ .helper-toolbar { padding: 12px 20px; background: rgba(255,255,255,0.76); border-bottom: 1px solid ${THEME.border}; flex-shrink: 0; } .search-box { display: flex; gap: 8px; margin-bottom: 10px; } .search-input { flex: 1; padding: 8px 12px; border: 1px solid ${THEME.border}; border-radius: 8px; font-size: 13px; background: white; outline: none; transition: 0.2s; } .search-input:focus { border-color: ${THEME.primary}; box-shadow: 0 0 0 2px rgba(79,70,229,0.1); } .btn-modern { padding: 8px 16px; background: ${THEME.primary}; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 700; transition: 0.2s; box-shadow: 0 2px 8px rgba(79,70,229,0.12); } .btn-modern:hover { background: ${THEME.primaryHover}; } .btn-outline { background: white; color: ${THEME.textSub}; border: 1px solid ${THEME.border}; } .btn-outline:hover { color: ${THEME.primary}; border-color: ${THEME.primary}; background: #f8fafc; } .btn-modern:disabled { opacity: 0.6; cursor: not-allowed; } .filter-row { display: flex; gap: 8px; margin-bottom: 10px; align-items: center; } .filter-select { flex: 1; min-width: 0; height: 34px; padding: 6px 30px 6px 10px; border: 1px solid ${THEME.border}; border-radius: 8px; font-size: 12px; background-color: white; color: ${THEME.textMain}; outline: none; box-sizing: border-box; appearance: none; background-image: linear-gradient(45deg, transparent 50%, ${THEME.textSub} 50%), linear-gradient(135deg, ${THEME.textSub} 50%, transparent 50%); background-position: calc(100% - 16px) 14px, calc(100% - 11px) 14px; background-size: 5px 5px, 5px 5px; background-repeat: no-repeat; } .filter-select:focus { border-color: ${THEME.primary}; box-shadow: 0 0 0 2px rgba(79,70,229,0.1); } .semester-filter { flex: 1.2; position: relative; min-width: 0; } .semester-filter-button { width: 100%; height: 34px; padding: 6px 10px; border: 1px solid ${THEME.border}; border-radius: 8px; background: white; color: ${THEME.textMain}; font-size: 12px; outline: none; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 6px; } .semester-filter-button:hover { border-color: ${THEME.primary}; } .semester-filter-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .semester-menu { display: none; position: absolute; left: 0; right: 0; top: calc(100% + 6px); background: white; border: 1px solid ${THEME.border}; border-radius: 8px; box-shadow: 0 12px 26px rgba(15,23,42,0.14); padding: 8px; z-index: 100001; max-height: 260px; overflow-y: auto; } .semester-filter.open .semester-menu { display: block; } .semester-actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; padding-bottom: 8px; margin-bottom: 6px; border-bottom: 1px dashed ${THEME.border}; } .semester-action { border: 1px solid ${THEME.border}; background: #f8fafc; color: ${THEME.textSub}; border-radius: 6px; padding: 5px 0; font-size: 11px; cursor: pointer; } .semester-action:hover { color: ${THEME.primary}; border-color: ${THEME.primary}; background: white; } .semester-option { display: flex; align-items: center; gap: 8px; padding: 7px 6px; border-radius: 6px; color: ${THEME.textMain}; font-size: 12px; cursor: pointer; } .semester-option:hover { background: #f8fafc; } .semester-option input { width: 14px; height: 14px; accent-color: ${THEME.primary}; cursor: pointer; } .action-row { display: flex; gap: 8px; } .analysis-toolbar { display: block; } .analysis-filter-grid { display: grid; grid-template-columns: minmax(180px, 1.35fr) minmax(110px, 1fr) minmax(96px, .85fr) minmax(96px, .85fr); gap: 8px; align-items: end; } .analysis-toolbar .search-box { margin-bottom: 10px; } .field-control { min-width: 0; } .field-label { display: block; margin: 0 0 5px; color: ${THEME.textSub}; font-size: 11px; font-weight: 800; } .field-control .search-input, .field-control .filter-select, .field-control .semester-filter-button { height: 34px; border-radius: 8px; box-sizing: border-box; } .analysis-actions { display: flex; justify-content: flex-end; gap: 8px; } .analysis-actions .btn-modern { flex: 0 1 150px; padding-left: 12px; padding-right: 12px; } .eval-toolbar { display: grid; gap: 10px; } .eval-status-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; } .eval-status-note { color: ${THEME.textSub}; font-size: 11px; white-space: nowrap; } .eval-status-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .eval-config-card { background: white; border: 1px solid ${THEME.border}; border-radius: 10px; padding: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.02); display: grid; gap: 10px; } .eval-config-card.collapsed { display: none; } .eval-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; align-items: end; } .eval-comment { grid-column: 1 / -1; } .eval-field { min-width: 0; } .eval-field textarea, .eval-field select, .eval-field input { width: 100%; border: 1px solid ${THEME.border}; border-radius: 8px; color: ${THEME.textMain}; background: #f8fafc; outline: none; box-sizing: border-box; font: inherit; font-size: 12px; transition: 0.2s; } .eval-field textarea { height: 58px; padding: 8px 10px; resize: vertical; min-height: 44px; } .eval-field select, .eval-field input { height: 34px; padding: 6px 9px; } .eval-field textarea:focus, .eval-field select:focus, .eval-field input:focus { border-color: ${THEME.primary}; background: white; box-shadow: 0 0 0 2px rgba(79,70,229,0.10); } .eval-config-actions { display: flex; justify-content: flex-end; gap: 8px; } .eval-footer { display: flex; gap: 8px; border-top: 1px solid ${THEME.border}; border-bottom: none; } .eval-footer .btn-modern { min-height: 36px; } .helper-content { flex: 1; overflow-y: auto; padding: 14px 20px; background: ${THEME.bg}; scrollbar-width: thin; } .course-item { background: white; margin-bottom: 10px; border-radius: 10px; box-shadow: 0 2px 6px rgba(0,0,0,0.03); border: 1px solid ${THEME.border}; transition: transform 0.2s, box-shadow 0.2s; overflow: hidden; } .course-item:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.06); } .course-summary { padding: 14px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; } .course-main { flex: 1; min-width: 0; margin-right: 12px; } .course-title { font-weight: 600; color: ${THEME.textMain}; font-size: 14px; margin-bottom: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .course-tags { font-size: 11px; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } .tag { padding: 2px 6px; border-radius: 4px; color: ${THEME.textSub}; background: #f1f5f9; } .tag-credit { color: ${THEME.primary}; background: #e0e7ff; } .tag-compulsory { color: ${THEME.accent}; background: #fce7f3; font-weight: bold; } .course-score { font-weight: 700; font-size: 18px; } .score-high { color: ${THEME.success}; } .score-med { color: ${THEME.warning}; } .score-low { color: ${THEME.danger}; } .score-none { color: #cbd5e1; font-size: 14px; } .helper-footer { background: white; padding: 14px 20px; border-top: 1px solid ${THEME.border}; flex-shrink: 0; box-shadow: 0 -4px 10px rgba(0,0,0,0.02); pointer-events: none; } .footer-stat { display: flex; justify-content: space-between; margin-bottom: 6px; align-items: center; font-size: 13px; color: ${THEME.textSub}; } .footer-stat b { font-size: 15px; color: ${THEME.textMain}; } .stat-highlight b { font-size: 18px; color: ${THEME.primary}; } .stat-highlight span:first-child b { color: ${THEME.accent}; } /* 成绩详情与分布图 */ .detail-panel { background: #f8fafc; border-top: 1px dashed ${THEME.border}; display: none; padding: 12px 14px; } .detail-panel.open { display: block; animation: slideIn 0.2s ease; } .compare-table { width: 100%; font-size: 12px; border-collapse: collapse; margin-bottom: 10px; background: white; border-radius: 6px; overflow: hidden; border: 1px solid #f1f5f9; text-align: center; } .compare-table th { background: #f1f5f9; color: ${THEME.textSub}; font-weight: 500; padding: 8px 4px; } .compare-table td { padding: 8px 4px; color: ${THEME.textMain}; border-bottom: 1px solid #f1f5f9; } .val-rank { color: ${THEME.accent}; font-weight: bold; } .dist-container { padding: 5px 0; } .dist-row { display: flex; align-items: center; margin-bottom: 6px; font-size: 11px; } .dist-name { width: 40px; color: ${THEME.textSub}; text-align: right; margin-right: 8px; } .dist-track { flex: 1; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; } .dist-fill { height: 100%; background: ${THEME.primary}; border-radius: 3px; opacity: 0.8; transition: width 0.3s ease; } .dist-num { width: 25px; margin-left: 8px; color: ${THEME.textSub}; font-weight: 500;} /* ====== 导出/打印/GPA视图专属样式 ====== */ .export-container, .gpa-container { padding: 20px; overflow-y: auto; background: ${THEME.bg}; flex: 1; } .export-card, .gpa-card { background: white; border-radius: 12px; padding: 20px; margin-bottom: 16px; border: 1px solid ${THEME.border}; box-shadow: 0 2px 8px rgba(0,0,0,0.02); } .export-card h3 { margin: 0 0 10px 0; color: ${THEME.textMain}; font-size: 15px; display: flex; align-items: center; gap: 8px; } .export-card p { margin: 0 0 16px 0; color: ${THEME.textSub}; font-size: 13px; line-height: 1.5; } .print-row { display:flex; justify-content:space-between; align-items:center; background:#f8fafc; padding:12px; border:1px solid #e2e8f0; border-radius:8px; transition:0.2s; } .print-row:hover { border-color:${THEME.primary}; background:white; box-shadow:0 2px 8px rgba(0,0,0,0.03); } .print-name { font-weight:500; color:#1e293b; font-size:13px; } .gpa-card-title { font-size: 16px; font-weight: 700; color: ${THEME.textMain}; margin-bottom: 15px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid ${THEME.border}; padding-bottom: 10px; } .gpa-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-bottom: 15px; } .gpa-item { display: flex; flex-direction: column; background: #f8fafc; padding: 12px; border-radius: 8px; border: 1px solid #e2e8f0; } .gpa-item-label { font-size: 11px; color: ${THEME.textSub}; margin-bottom: 4px; } .gpa-item-value { font-size: 18px; font-weight: bold; color: ${THEME.primary}; } .gpa-item-value.accent { color: ${THEME.accent}; } .gpa-ranks { display: flex; gap: 10px; font-size: 12px; color: ${THEME.textSub}; background: #f1f5f9; padding: 8px 12px; border-radius: 6px; } .gpa-ranks span b { color: ${THEME.textMain}; } /* 评教视图专属样式 */ #pj-list-area { padding: 12px 20px !important; background: ${THEME.bg} !important; } #pj-list-area > div { background: white; border: 1px solid ${THEME.border}; border-radius: 10px; overflow: hidden; margin-bottom: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.02); } .pj-group-header { padding: 12px 14px; background: linear-gradient(180deg, #ffffff, #f8fafc); font-weight: 800; font-size: 13px; color: ${THEME.textMain}; cursor: pointer; display: flex; align-items: center; border-bottom: 1px solid ${THEME.border}; gap: 8px; } .pj-group-header:hover { background: #f8fafc; } .pj-group-content { background: white; } .pj-item { padding: 12px 14px; background: white; border-bottom: 1px solid #f1f5f9; display: flex; align-items: center; justify-content: space-between; transition: 0.2s; } .pj-item:hover { background: #f8fafc; } .pj-item:last-child { border-bottom: none; } .pj-item-main { flex: 1; margin-left: 10px; line-height: 1.4; } .pj-item-title { font-size: 13px; font-weight: 600; color: ${THEME.textMain}; } .pj-item-sub { font-size: 11px; color: ${THEME.textSub}; } .pj-badge { font-size: 10px; padding: 3px 7px; border-radius: 999px; font-weight: 800; margin-bottom: 4px; display: inline-block; } .pj-badge.ing { color: ${THEME.success}; background: #d1fae5; } .pj-badge.done { color: ${THEME.primary}; background: #e0e7ff; } .pj-badge.wait { color: ${THEME.textSub}; background: #e2e8f0; } .pj-badge.end { color: ${THEME.danger}; background: #fee2e2; } .pj-log { font-size: 10px; color: ${THEME.warning}; text-align: right; } /* 学业监测视图 */ .monitor-container { padding: 16px 20px; overflow-y: auto; background: ${THEME.bg}; flex: 1; } .monitor-toolbar-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; } .monitor-status { font-size: 12px; color: ${THEME.textSub}; font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .monitor-actions { display: flex; gap: 8px; flex-shrink: 0; } .monitor-overview { background: white; border: 1px solid ${THEME.border}; border-radius: 10px; padding: 16px; margin-bottom: 14px; box-shadow: 0 2px 8px rgba(0,0,0,0.02); } .monitor-overview-top { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 14px; } .monitor-status-pill { padding: 5px 9px; border-radius: 999px; font-size: 12px; font-weight: 700; white-space: nowrap; } .monitor-status-pill.pass { color: #047857; background: #d1fae5; } .monitor-status-pill.warn { color: #b91c1c; background: #fee2e2; } .monitor-progress-track { height: 10px; background: #e2e8f0; border-radius: 999px; overflow: hidden; margin-bottom: 8px; } .monitor-progress-fill { height: 100%; background: linear-gradient(90deg, ${THEME.primary}, ${THEME.success}); border-radius: 999px; transition: width 0.3s ease; } .monitor-progress-meta { display: flex; justify-content: space-between; color: ${THEME.textSub}; font-size: 12px; } .monitor-section-title { font-size: 13px; font-weight: 700; color: ${THEME.textMain}; margin: 14px 0 8px; display: flex; align-items: center; gap: 6px; } .monitor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } .monitor-card { background: white; border: 1px solid ${THEME.border}; border-radius: 8px; padding: 12px; min-width: 0; box-shadow: 0 2px 8px rgba(0,0,0,0.02); } .monitor-card-title { font-weight: 700; color: ${THEME.textMain}; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 8px; } .monitor-card-meta { display: flex; justify-content: space-between; color: ${THEME.textSub}; font-size: 12px; margin-bottom: 8px; } .monitor-mini-track { height: 6px; background: #e2e8f0; border-radius: 999px; overflow: hidden; } .monitor-mini-fill { height: 100%; background: ${THEME.primary}; border-radius: 999px; } .monitor-groups { background: white; border: 1px solid ${THEME.border}; border-radius: 8px; overflow: hidden; } .monitor-group { border-bottom: 1px solid ${THEME.border}; } .monitor-group:last-child { border-bottom: none; } .monitor-group-header { width: 100%; border: none; background: white; padding: 12px 14px; display: flex; align-items: center; gap: 8px; cursor: pointer; color: ${THEME.textMain}; text-align: left; } .monitor-group-header:hover { background: #f8fafc; } .monitor-group-arrow { width: 14px; color: ${THEME.textSub}; font-size: 12px; } .monitor-group-title { font-weight: 800; font-size: 14px; } .monitor-group-credit { color: ${THEME.success}; font-weight: 700; font-size: 13px; } .monitor-group-badge { margin-left: auto; padding: 3px 7px; border-radius: 999px; font-size: 11px; font-weight: 700; white-space: nowrap; } .monitor-group-badge.pass { color: #047857; background: #d1fae5; } .monitor-group-badge.warn { color: #b91c1c; background: #fee2e2; } .monitor-group-body { overflow-x: auto; border-top: 1px solid ${THEME.border}; } .monitor-course-table { width: 100%; min-width: 690px; border-collapse: collapse; font-size: 12px; } .monitor-course-table th { background: #f1f5f9; color: ${THEME.textMain}; font-weight: 800; text-align: left; padding: 10px 12px; white-space: nowrap; } .monitor-course-table td { padding: 10px 12px; border-top: 1px solid #edf2f7; color: ${THEME.textMain}; vertical-align: middle; } .monitor-course-row.pending td { color: #94a3b8; background: #f8fafc; } .monitor-course-name { line-height: 1.45; max-width: 250px; } .monitor-pass-dot { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 50%; color: white; font-weight: 800; font-size: 13px; } .monitor-pass-dot.pass { background: #60c33f; } .monitor-pass-dot.fail { background: ${THEME.danger}; } .monitor-pass-dot.pending { background: #cbd5e1; color: #64748b; } @media (max-width: 620px) { .analysis-filter-grid, .eval-grid { grid-template-columns: 1fr; } .analysis-actions { justify-content: stretch; } .analysis-actions .btn-modern { flex: 1; } } .monitor-warning { background: #fff7f7; border: 1px solid #fecaca; color: #991b1b; border-radius: 8px; padding: 10px 12px; margin-bottom: 8px; font-size: 12px; line-height: 1.45; } .monitor-empty { text-align:center; padding:32px 12px; color:#94a3b8; } @keyframes panelPopIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes slideIn { from { opacity: 0; height: 0; } to { opacity: 1; height: auto; } } .swal2-container { z-index: 1000000 !important; } `); // ================= 🧠 统一状态与全局接口 ================= const Toast = Swal.mixin({ toast: true, position: 'top-end', showConfirmButton: false, timer: 3000, timerProgressBar: true }); const EVAL_DEFAULT_CONFIG = { comment: "老师讲课重点突出,条理清晰,内容丰富,受益匪浅。", delay: 1500, randomScore: false }; let state = { userId: null, userName: null, dataMap: new Map(), rawData: [], filteredData: [], filters: { search: '', semesters: new Set(), semesterAll: true, type: 'all', sort: 'semester_desc' }, semesters: new Set(), types: new Set(), isFetching: false, isPrintLoaded: false, stats: { totalCredit: 0, avgGPA: 0, compulsoryGPA: 0, count: 0 }, monitor: { isLoaded: false, isRefreshing: false, isLoading: false, renderAfterLoad: false, overview: { required: 0, earned: 0, passed: true, calculatedTime: "" }, categories: [], warnings: [], raw: null, scoreFallbacks: [], openGroups: new Set(), openGroupsTouched: false }, eval: { isLoaded: false, isRunning: false, courses: [], termCode: '', config: { comment: localStorage.getItem('whut_pj_comment') || EVAL_DEFAULT_CONFIG.comment, delay: parseInt(localStorage.getItem('whut_pj_delay')) || EVAL_DEFAULT_CONFIG.delay, randomScore: localStorage.getItem('whut_pj_random') === null ? EVAL_DEFAULT_CONFIG.randomScore : localStorage.getItem('whut_pj_random') !== 'false' } } }; const debounce = (func, wait) => { let timeout; return function(...args) { clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, args), wait); }; }; const escapeHTML = (val) => String(val ?? '').replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); const toNum = (val, fallback = 0) => { const n = parseFloat(val); return Number.isFinite(n) ? n : fallback; }; const compactParams = (obj) => Object.fromEntries(Object.entries(obj || {}).filter(([, v]) => v !== undefined && v !== null && v !== '')); const API = { req: (opts) => new Promise((resolve, reject) => GM_xmlhttpRequest({ ...opts, onload: resolve, onerror: reject })), postJSON: async (url, dataStr) => { try { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest' }, body: dataStr }); return await res.json(); } catch (e) { return null; } }, postForm: async (url, data = {}) => { const body = data instanceof URLSearchParams ? data : new URLSearchParams(compactParams(data)); const res = await API.req({ method: 'POST', url, headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest' }, data: body.toString() }); return JSON.parse(res.responseText); }, getUserInfo: async () => { if (state.userId && state.userName) return { userId: state.userId, userName: state.userName }; const res = await API.req({ method: 'GET', url: 'https://jwxt.whut.edu.cn/jwapp/sys/homeapp/api/home/currentUser.do' }); const data = JSON.parse(res.responseText); if (data.code === "0" && data.datas) { state.userId = data.datas.userId; state.userName = data.datas.userName; return data.datas; } throw new Error("未能获取身份信息,请确认已登录教务系统"); } }; // ================= 🚀 初始化 UI 框架 ================= function initUI() { if (document.getElementById('whut-fab')) return; const savedFab = JSON.parse(localStorage.getItem('whut_fab_pos')) || { right: '30px', bottom: '30px' }; const savedPanel = JSON.parse(localStorage.getItem('whut_panel_pos')) || { left: 'calc(50% - 300px)', top: '10vh' }; const fab = document.createElement('div'); fab.id = 'whut-fab'; fab.innerHTML = '✨'; fab.title = "打开小助手"; if(savedFab.right) fab.style.right = savedFab.right; if(savedFab.bottom) fab.style.bottom = savedFab.bottom; if(savedFab.left) fab.style.left = savedFab.left; if(savedFab.top) fab.style.top = savedFab.top; document.body.appendChild(fab); const panel = document.createElement('div'); panel.id = 'whut-helper-panel'; panel.style.left = savedPanel.left; panel.style.top = savedPanel.top; panel.innerHTML = `
🎓 教务系统小助手 3.6.1
🎓 学业监测
📊 成绩分析
🏆 GPA查询
🖨️ 官方证明
📝 一键评教
🔄 正在抓取成绩...
⏳ 点击加载综合 GPA 数据...
⏳ 点击加载个人学业监测数据...
⏳ 等待加载学业监测报告...
⏳ 初始化评教数据...
点击配置后保存生效
`; document.body.appendChild(panel); const isFabDragging = makeDraggable(fab, fab, 'whut_fab_pos'); makeDraggable(panel, document.getElementById('panel-drag-handle'), 'whut_panel_pos'); clampFabToViewport(fab); bindUIEvents(fab, panel, isFabDragging); if (state.rawData.length) { updateFilterUI(); applyFilters(); } else proactiveFetch(); MonitorModule.init(); } function clampValue(value, min, max) { if (max < min) return min; return Math.min(Math.max(value, min), max); } function clampPanelToViewport(panel) { const margin = 8; const rect = panel.getBoundingClientRect(); const maxWidth = Math.max(320, window.innerWidth - margin * 2); const maxHeight = Math.max(320, window.innerHeight - margin * 2); const width = Math.min(600, maxWidth); const height = Math.min(Math.round(window.innerHeight * 0.85), maxHeight); let left = parseFloat(panel.style.left); let top = parseFloat(panel.style.top); if (!Number.isFinite(left)) left = rect.left; if (!Number.isFinite(top)) top = rect.top; panel.style.right = 'auto'; panel.style.bottom = 'auto'; panel.style.width = `${Math.round(width)}px`; panel.style.height = `${Math.round(height)}px`; panel.style.left = `${Math.round(clampValue(left, margin, window.innerWidth - width - margin))}px`; panel.style.top = `${Math.round(clampValue(top, margin, window.innerHeight - height - margin))}px`; } function clampFabToViewport(fab) { const margin = 8; const rect = fab.getBoundingClientRect(); const size = rect.width || 56; let left = parseFloat(fab.style.left); let top = parseFloat(fab.style.top); if (!Number.isFinite(left)) left = rect.left; if (!Number.isFinite(top)) top = rect.top; fab.style.right = 'auto'; fab.style.bottom = 'auto'; fab.style.left = `${Math.round(clampValue(left, margin, window.innerWidth - size - margin))}px`; fab.style.top = `${Math.round(clampValue(top, margin, window.innerHeight - size - margin))}px`; } function saveFabState(fab) { localStorage.setItem('whut_fab_pos', JSON.stringify({ left: fab.style.left, top: fab.style.top })); } function placePanelFromFab(panel, fab) { const fabRect = fab.getBoundingClientRect(); const panelRect = panel.getBoundingClientRect(); const placeLeft = fabRect.left + fabRect.width / 2 > window.innerWidth / 2 ? fabRect.right - panelRect.width : fabRect.left; const placeTop = fabRect.top + fabRect.height / 2 > window.innerHeight / 2 ? fabRect.bottom - panelRect.height : fabRect.top; panel.style.left = `${Math.round(placeLeft)}px`; panel.style.top = `${Math.round(placeTop)}px`; clampPanelToViewport(panel); } function placeFabFromPanel(fab, panel) { const panelRect = panel.getBoundingClientRect(); const size = 56; const placeLeft = panelRect.left + panelRect.width / 2 > window.innerWidth / 2 ? panelRect.right - size : panelRect.left; const placeTop = panelRect.top + panelRect.height / 2 > window.innerHeight / 2 ? panelRect.bottom - size : panelRect.top; fab.style.display = 'flex'; fab.style.left = `${Math.round(placeLeft)}px`; fab.style.top = `${Math.round(placeTop)}px`; fab.style.right = 'auto'; fab.style.bottom = 'auto'; clampFabToViewport(fab); saveFabState(fab); } function openPanelFromFab(fab, panel) { clampFabToViewport(fab); panel.classList.add('open'); placePanelFromFab(panel, fab); fab.style.display = 'none'; savePanelState(panel); } function closePanelToFab(fab, panel) { clampPanelToViewport(panel); savePanelState(panel); placeFabFromPanel(fab, panel); panel.classList.remove('open'); } function makeDraggable(el, handle, storageKey) { let isDragging = false, startX, startY, initialX, initialY; handle.addEventListener('mousedown', (e) => { if (e.target.closest('.header-close') || e.target.closest('.helper-tabs') || e.target.tagName.toLowerCase() === 'button' || e.target.tagName.toLowerCase() === 'textarea' || e.target.tagName.toLowerCase() === 'input' || e.target.tagName.toLowerCase() === 'select') return; e.preventDefault(); startX = e.clientX; startY = e.clientY; const rect = el.getBoundingClientRect(); initialX = rect.left; initialY = rect.top; el.style.right = 'auto'; el.style.bottom = 'auto'; el.style.left = initialX + 'px'; el.style.top = initialY + 'px'; el.style.transition = 'none'; document.addEventListener('mousemove', drag); document.addEventListener('mouseup', dragEnd); }); function drag(e) { const dx = e.clientX - startX; const dy = e.clientY - startY; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) isDragging = true; el.style.left = (initialX + dx) + 'px'; el.style.top = (initialY + dy) + 'px'; } function dragEnd() { document.removeEventListener('mousemove', drag); document.removeEventListener('mouseup', dragEnd); el.style.transition = ''; if (el.id === 'whut-helper-panel') clampPanelToViewport(el); if (el.id === 'whut-fab') clampFabToViewport(el); if (isDragging && storageKey) { const saved = JSON.parse(localStorage.getItem(storageKey) || '{}'); const next = { ...saved, left: el.style.left, top: el.style.top }; localStorage.setItem(storageKey, JSON.stringify(next)); } setTimeout(() => isDragging = false, 50); } return () => isDragging; } function savePanelState(panel) { const rect = panel.getBoundingClientRect(); const saved = JSON.parse(localStorage.getItem('whut_panel_pos') || '{}'); localStorage.setItem('whut_panel_pos', JSON.stringify({ ...saved, left: panel.style.left || `${Math.round(rect.left)}px`, top: panel.style.top || `${Math.round(rect.top)}px` })); } function bindUIEvents(fab, panel, isFabDragging) { fab.addEventListener('click', () => { if (!isFabDragging()) openPanelFromFab(fab, panel); }); document.getElementById('h-close').addEventListener('click', () => closePanelToFab(fab, panel)); window.addEventListener('resize', debounce(() => { if (panel.classList.contains('open')) { clampPanelToViewport(panel); savePanelState(panel); } else { fab.style.display = 'flex'; clampFabToViewport(fab); saveFabState(fab); } }, 120)); document.querySelectorAll('.helper-tab').forEach(tab => { tab.addEventListener('click', (e) => { document.querySelectorAll('.helper-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-view').forEach(v => v.classList.remove('active')); e.target.classList.add('active'); const targetId = e.target.dataset.target; document.getElementById(targetId).classList.add('active'); if (targetId === 'view-print' && !state.isPrintLoaded) PrintModule.init(); if (targetId === 'view-gpa') GPAModule.init(); if (targetId === 'view-monitor') MonitorModule.init(); if (targetId === 'view-eval') EvalModule.init(); }); }); const debouncedFilter = debounce(() => applyFilters(), 300); document.getElementById('h-search').addEventListener('input', (e) => { state.filters.search = e.target.value.trim(); debouncedFilter(); }); document.getElementById('h-type').addEventListener('change', (e) => { state.filters.type = e.target.value.trim(); applyFilters(); }); document.getElementById('h-sort').addEventListener('change', (e) => { state.filters.sort = e.target.value.trim(); applyFilters(); }); bindSemesterFilterEvents(); document.getElementById('h-reset').addEventListener('click', () => { state.filters = { search: '', semesters: new Set(state.semesters), semesterAll: true, type: 'all', sort: 'semester_desc' }; document.getElementById('h-search').value = ''; document.getElementById('h-type').value = 'all'; document.getElementById('h-sort').value = 'semester_desc'; updateFilterUI(); applyFilters(); }); document.getElementById('btn-exp-cur-xls').addEventListener('click', ContextualExport.toExcel); document.getElementById('btn-exp-cur-img').addEventListener('click', ContextualExport.toImage); document.getElementById('btn-gpa-semester-manager').addEventListener('click', GPAModule.openSemesterManager); document.getElementById('btn-exp-gpa-xls').addEventListener('click', GPAModule.exportToExcel); document.getElementById('btn-exp-gpa-img').addEventListener('click', GPAModule.exportToImage); document.getElementById('btn-monitor-refresh').addEventListener('click', MonitorModule.refresh); document.getElementById('btn-monitor-export').addEventListener('click', MonitorModule.exportReport); document.getElementById('print-container').addEventListener('click', (e) => { const btn = e.target.closest('.btn-dl-pdf'); if (btn) PrintModule.downloadPDF(btn.dataset.wid, btn.dataset.cdmc); }); document.getElementById('monitor-content').addEventListener('click', (e) => { const header = e.target.closest('.monitor-group-header'); if (!header) return; const id = header.dataset.groupId; if (!id) return; state.monitor.openGroupsTouched = true; if (state.monitor.openGroups.has(id)) state.monitor.openGroups.delete(id); else state.monitor.openGroups.add(id); MonitorModule.render(); }); // 评教配置面板 const fillEvalConfigForm = () => { document.getElementById('pj-cfg-comment').value = state.eval.config.comment; document.getElementById('pj-cfg-random').value = state.eval.config.randomScore ? 'true' : 'false'; document.getElementById('pj-cfg-delay').value = state.eval.config.delay; }; fillEvalConfigForm(); document.getElementById('pj-config-toggle').addEventListener('click', () => { const card = document.getElementById('pj-config-card'); const isOpening = card.classList.contains('collapsed'); if (isOpening) fillEvalConfigForm(); card.classList.toggle('collapsed'); }); document.getElementById('pj-cfg-save').addEventListener('click', async () => { const result = await Swal.fire({ title: '保存评教配置?', text: '保存后将用于后续一键评教任务。', icon: 'question', showCancelButton: true, confirmButtonText: '保存', cancelButtonText: '取消' }); if (!result.isConfirmed) return; state.eval.config.comment = document.getElementById('pj-cfg-comment').value; state.eval.config.randomScore = document.getElementById('pj-cfg-random').value === 'true'; state.eval.config.delay = parseInt(document.getElementById('pj-cfg-delay').value) || 1500; localStorage.setItem('whut_pj_comment', state.eval.config.comment); localStorage.setItem('whut_pj_random', state.eval.config.randomScore); localStorage.setItem('whut_pj_delay', state.eval.config.delay); document.getElementById('pj-config-card').classList.add('collapsed'); Toast.fire({ icon: 'success', title: '评教配置已保存' }); }); document.getElementById('pj-cfg-reset').addEventListener('click', async () => { const result = await Swal.fire({ title: '恢复默认评教配置?', text: '当前填写内容会被默认配置覆盖。', icon: 'warning', showCancelButton: true, confirmButtonText: '恢复默认', cancelButtonText: '取消' }); if (!result.isConfirmed) return; state.eval.config = { ...EVAL_DEFAULT_CONFIG }; localStorage.setItem('whut_pj_comment', state.eval.config.comment); localStorage.setItem('whut_pj_random', state.eval.config.randomScore); localStorage.setItem('whut_pj_delay', state.eval.config.delay); fillEvalConfigForm(); Toast.fire({ icon: 'success', title: '已恢复默认配置' }); }); document.getElementById('pj-refresh-btn').addEventListener('click', EvalModule.loadData); document.getElementById('pj-run-btn').addEventListener('click', EvalModule.runSelected); } function bindSemesterFilterEvents() { const wrap = document.getElementById('semester-filter'); const toggle = document.getElementById('h-semester-toggle'); const menu = document.getElementById('h-semester-menu'); toggle.addEventListener('click', (e) => { e.stopPropagation(); wrap.classList.toggle('open'); }); menu.addEventListener('click', (e) => e.stopPropagation()); document.addEventListener('click', (e) => { if (!e.target.closest('#semester-filter')) wrap.classList.remove('open'); }); menu.addEventListener('change', (e) => { const target = e.target; if (!target.matches('input[type="checkbox"]')) return; const allSemesters = Array.from(state.semesters); if (target.dataset.role === 'all') { state.filters.semesters = target.checked ? new Set(allSemesters) : new Set(); } else { const value = target.value; if (target.checked) state.filters.semesters.add(value); else state.filters.semesters.delete(value); } state.filters.semesterAll = state.filters.semesters.size === allSemesters.length && allSemesters.length > 0; renderSemesterMenu(); applyFilters(); }); menu.addEventListener('click', (e) => { const action = e.target.dataset.semAction; if (!action) return; const allSemesters = Array.from(state.semesters); if (action === 'all') state.filters.semesters = new Set(allSemesters); if (action === 'invert') state.filters.semesters = new Set(allSemesters.filter(s => !state.filters.semesters.has(s))); if (action === 'clear') state.filters.semesters = new Set(); state.filters.semesterAll = state.filters.semesters.size === allSemesters.length && allSemesters.length > 0; renderSemesterMenu(); applyFilters(); }); } // ================= 1. 核心数据引擎与分析 ================= const originalOpen = XMLHttpRequest.prototype.open; const originalSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function(method, url) { this._url = url; return originalOpen.apply(this, arguments); }; XMLHttpRequest.prototype.send = function(body) { this.addEventListener('load', function() { if (this._url && this._url.includes('xscjcx.do') && this.responseText && !state.isFetching) { try { const res = JSON.parse(this.responseText); if (res.datas && res.datas.xscjcx) processDataPart(res.datas.xscjcx.rows || [], res.datas.xscjcx.totalSize || 0); } catch (e) {} } }); return originalSend.apply(this, arguments); }; async function proactiveFetch() { if (state.isFetching || state.rawData.length > 0) return; state.isFetching = true; const listEl = document.getElementById('h-list'); try { const res = await fetch('/jwapp/sys/cjcx/modules/cjcx/xscjcx.do', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest' }, body: new URLSearchParams({ 'querySetting': JSON.stringify([{ name: "SFYX", value: "1", linkOpt: "AND", builder: "m_value_equal" }]), '*json': '1', 'pageSize': '1000', 'pageNumber': '1', 'order': '-XNXQDM,-KCH,-KXH' }) }).then(r => r.json()); if (res.datas?.xscjcx?.rows) processDataPart(res.datas.xscjcx.rows, res.datas.xscjcx.totalSize || 0); else if(listEl) listEl.innerHTML = '
⚠️ 自动获取失败,请确认已成功登录。
'; } catch(e) { if(listEl) listEl.innerHTML = '
⚠️ 网络异常,请尝试手动进入“成绩查询”页面。
'; } finally { state.isFetching = false; } } function renderSemesterMenu() { const menu = document.getElementById('h-semester-menu'); const label = document.getElementById('h-semester-label'); if (!menu || !label) return; const semesters = Array.from(state.semesters); const selected = state.filters.semesters; const allChecked = semesters.length > 0 && selected.size === semesters.length; const noneChecked = selected.size === 0; const checkedAttr = (flag) => flag ? 'checked' : ''; menu.innerHTML = `
${semesters.map(v => ``).join('')} `; const allBox = menu.querySelector('input[data-role="all"]'); if (allBox) allBox.indeterminate = !allChecked && !noneChecked; state.filters.semesterAll = allChecked; label.textContent = allChecked ? '全部学期' : (noneChecked ? '未选择学期' : `已选 ${selected.size}/${semesters.length} 学期`); } function isHiddenScoreText(item) { const text = typeof item === 'object' ? item?._scoreText : item; return String(text ?? '').trim() === '未评教'; } function sameText(a, b) { const sa = String(a ?? '').trim(); const sb = String(b ?? '').trim(); return !!sa && !!sb && sa === sb; } function hasScoreValue(val) { return val !== undefined && val !== null && String(val).trim() !== ''; } function isNumericScore(val) { return /^-?\d+(\.\d+)?$/.test(String(val ?? '').trim()); } function pickScoreField(courseObj, keys) { for (const key of keys) { const val = courseObj?.[key]; if (hasScoreValue(val)) return String(val).trim(); } return ''; } function cleanCourseScore(courseObj) { return pickScoreField(courseObj, ['XSZCJ', 'ZCJ', 'CJ', 'scoreView', 'score', 'scoreValue', 'point', 'grade', 'gradeText']) || '-'; } function calcWhutGPAFromScore(score) { const scoreNum = parseFloat(score); if (!Number.isFinite(scoreNum)) return null; if (scoreNum < 60) return 0; return Math.min(5, (scoreNum - 50) / 10); } function formatCreditValue(val) { const n = parseFloat(val); if (!Number.isFinite(n)) return '-'; return n.toFixed(2).replace(/\.?0+$/, ''); } function gradeTextToGPA(text) { const s = String(text ?? '').trim(); if (!s) return null; if (/不及格|不通过|不合格|Fail/i.test(s)) return 0; if (/优秀|Excellent/i.test(s)) return 4.5; if (/良好|Good/i.test(s)) return 3.5; if (/中等|Average/i.test(s)) return 2.5; if (/及格/i.test(s)) return 1.5; if (/通过|合格|Pass/i.test(s)) return 3; return null; } function pickCourseGPA(courseObj, scoreNum, scoreText) { const rawGPA = toNum(courseObj?.XFJD, NaN); if (Number.isFinite(rawGPA) && rawGPA >= 0) return rawGPA; const scoreGPA = calcWhutGPAFromScore(scoreNum); if (scoreGPA !== null) return scoreGPA; return gradeTextToGPA([scoreText, courseObj?.XSZCJMC, courseObj?.DJCJ_DISPLAY].filter(Boolean).join(' ')); } function hasEffectiveScoreForGPA(course) { const scoreText = String(course?._scoreText ?? '').trim(); if (!scoreText || scoreText === '-' || isHiddenScoreText(course)) return false; if (Number.isFinite(course?._score)) return true; return !isHiddenScoreText(scoreText); } function hasValidGPAForStats(course) { return course?.SFYX === '1' && course._credit > 0 && Number.isFinite(course._gpa) && course._gpa >= 0 && hasEffectiveScoreForGPA(course); } function getCourseGPAKey(course) { return String(course?.KCH || course?._name || course?.KCM || course?._uid || '').trim(); } function compareCourseForGPA(a, b) { const ga = Number.isFinite(a?._gpa) ? a._gpa : -Infinity; const gb = Number.isFinite(b?._gpa) ? b._gpa : -Infinity; if (ga !== gb) return ga - gb; const sa = Number.isFinite(a?._score) ? a._score : -Infinity; const sb = Number.isFinite(b?._score) ? b._score : -Infinity; if (sa !== sb) return sa - sb; return String(a?._semester || '').localeCompare(String(b?._semester || '')); } function getBestCoursesForGPA(courses) { const map = new Map(); courses.forEach(course => { if (!hasValidGPAForStats(course)) return; const key = getCourseGPAKey(course); if (!key) return; const prev = map.get(key); if (!prev || compareCourseForGPA(course, prev) > 0) map.set(key, course); }); return Array.from(map.values()); } function pickNumericCourseScore(courseObj) { for (const key of ['ZCJ', 'CJ', 'XSZCJ', 'scoreView', 'score', 'scoreValue', 'point']) { const val = courseObj?.[key]; if (hasScoreValue(val) && isNumericScore(val)) return String(val).trim(); } return ''; } function formatCourseScoreDisplay(courseObj) { const gradeText = pickScoreField(courseObj, ['XSZCJMC', 'DJCJ_DISPLAY']); const numericScore = pickNumericCourseScore(courseObj); const cleanScore = cleanCourseScore(courseObj); if (hasScoreValue(gradeText) && !isHiddenScoreText(gradeText) && !isNumericScore(gradeText)) { return hasScoreValue(numericScore) && isNumericScore(numericScore) ? `${gradeText}(${numericScore})` : gradeText; } if (cleanScore !== '-') { if (isHiddenScoreText(cleanScore) && hasScoreValue(numericScore)) return numericScore; return !isNumericScore(cleanScore) && hasScoreValue(numericScore) && isNumericScore(numericScore) ? `${cleanScore}(${numericScore})` : cleanScore; } if (isHiddenScoreText(gradeText) && hasScoreValue(numericScore)) return numericScore; if (hasScoreValue(gradeText)) return gradeText; if (hasScoreValue(courseObj?.ZCJ)) return String(courseObj.ZCJ).trim(); return '-'; } function pickAnyField(obj, keys) { for (const key of keys) { const val = obj?.[key]; if (val !== undefined && val !== null && val !== '') return val; } return ''; } function monitorChildArrays(node) { return ['children', 'CHILDREN', 'childList', 'childNodes', 'nodes', 'items', 'data', 'rows', 'list', 'checkCourseVOS', 'courseList', 'courses', 'courseVOS'] .flatMap(key => Array.isArray(node?.[key]) ? [{ key, rows: node[key] }] : []); } function looksLikeMonitorCourseRow(row) { if (!row || typeof row !== 'object') return false; const hasCourseName = hasScoreValue(pickAnyField(row, ['KCH', 'KCM', 'KCDM', 'KCMC', 'KCBH', 'courseId', 'courseCode', 'courseNo', 'courseName', 'courseTitle'])); const hasCourseMetric = hasScoreValue(pickAnyField(row, ['XF', 'KCXF', 'credit', 'credits', 'courseCredit', 'score', 'scoreView', 'CJ', 'ZCJ', 'XSZCJ'])); const hasCategoryChildren = monitorChildArrays(row).some(({ rows }) => rows.some(child => child && typeof child === 'object' && !looksLikeMonitorCourseRowShallow(child))); return hasCourseName && hasCourseMetric && !hasCategoryChildren; } function looksLikeMonitorCourseRowShallow(row) { if (!row || typeof row !== 'object') return false; const hasCourseName = hasScoreValue(pickAnyField(row, ['KCH', 'KCM', 'KCDM', 'KCMC', 'KCBH', 'courseId', 'courseCode', 'courseNo', 'courseName', 'courseTitle'])); const hasCourseMetric = hasScoreValue(pickAnyField(row, ['XF', 'KCXF', 'credit', 'credits', 'courseCredit', 'score', 'scoreView', 'CJ', 'ZCJ', 'XSZCJ'])); return hasCourseName && hasCourseMetric; } function collectMonitorCourseRows(node, moduleName = '') { const rows = []; const visit = (node, moduleName = '') => { if (!node) return; if (Array.isArray(node)) { node.forEach(item => visit(item, moduleName)); return; } if (typeof node !== 'object') return; const currModule = node.name || node.MC || node.courseCategoryName || node.moduleName || moduleName; const arrays = monitorChildArrays(node); if (looksLikeMonitorCourseRow(node)) rows.push(normalizeMonitorCourse(node, currModule)); arrays.forEach(({ rows: childRows }) => childRows.forEach(child => visit(child, currModule))); if (!arrays.length && !looksLikeMonitorCourseRowShallow(node)) Object.values(node).forEach(val => { if (Array.isArray(val) || (val && typeof val === 'object')) visit(val, currModule); }); }; visit(node, moduleName); return rows; } function dedupeMonitorCourses(rows) { const seen = new Set(); return rows.filter(r => { const key = [r.courseId || r.courseName || '', r.semester || '', r.scoreText ?? r.score ?? '', r.moduleName || ''].join('|'); if (seen.has(key)) return false; seen.add(key); return true; }); } function normalizeScoreFallbackRows(xyData) { const rows = dedupeMonitorCourses([ ...collectMonitorCourseRows(xyData?.fanbx || xyData?.FANBX || xyData?.fanBx || xyData?.fanbxTree || xyData?.programTree || xyData?.schemeTree || xyData), ...collectMonitorCourseRows(xyData?.fawbx, '方案外课程'), ...collectMonitorCourseRows(xyData?.fawbxMap, '方案外课程') ]); return rows.filter(r => (r.courseId || r.courseName) && (r.scoreText !== undefined && r.scoreText !== null && r.scoreText !== '')); } function normalizeMonitorCourse(r, moduleName = '') { const isGradeRow = r && (r.KCH !== undefined || r.KCM !== undefined || r.CJ !== undefined || r.XF !== undefined); const statusText = String(r?.SFJG_DISPLAY || r?.ZT_DISPLAY || r?.statusName || '').trim(); const passed = isGradeRow ? (r.SFJG === '1' || r.ZT === '01' || /通过|合格|是/.test(statusText)) : !(r.passed === false || r.passed === 'false' || r.status === '02' || r.status === '0'); const courseId = pickAnyField(r, ['KCH', 'KCDM', 'KCBH', 'courseId', 'courseCode', 'courseNo', 'code']); const courseName = pickAnyField(r, ['KCM', 'KCMC', 'courseName', 'courseTitle', 'name', 'text']); const semester = pickAnyField(r, ['CJXNXQDM', 'XKXNXQDM', 'JHXNXQDM', 'XNXQDM', 'pointSchoolYearTermCode', 'courseSelectionSchoolYearTermCode', 'schoolYearTermCode', 'termCode']); const scoreText = cleanCourseScore(r); const score = pickNumericCourseScore(r) || (scoreText !== '-' ? scoreText : ''); return { courseId, courseName, semester, scoreText, score, credit: pickAnyField(r, ['XF', 'KCXF', 'credit', 'credits', 'courseCredit']), nature: pickAnyField(r, ['KCXZDM_DISPLAY', 'KCXZDM', 'courseNature', 'nature', 'categoryName']), passed, replacedCourseId: pickAnyField(r, ['TDKCH', 'replacedCourseId', 'replaceCourseId']), replacedCourseName: pickAnyField(r, ['TDKCM', 'replacedCourseName', 'replaceCourseName']), replacedCourseCredit: pickAnyField(r, ['TDKCXF', 'replacedCourseCredit', 'replaceCourseCredit']), moduleName, raw: r || {} }; } function findScoreFallback(course) { const fallbacks = state.monitor.scoreFallbacks || []; return fallbacks.find(r => sameText(r.courseId, course.KCH) && sameText(r.semester, course.XNXQDM)) || fallbacks.find(r => sameText(r.courseName, course.KCM) && sameText(r.semester, course.XNXQDM)) || fallbacks.find(r => sameText(r.courseId, course.KCH)) || fallbacks.find(r => sameText(r.courseName, course.KCM)); } function applyScoreFallbacks() { if (!state.rawData.length || !state.monitor.scoreFallbacks.length) return false; let changed = false; state.rawData.forEach(c => { if (!isHiddenScoreText(c)) return; const fb = findScoreFallback(c); if (!fb) return; const formattedScore = formatCourseScoreDisplay(fb.raw || fb); const scoreText = formattedScore !== '-' ? formattedScore : (fb.scoreText ?? fb.score); if (!hasScoreValue(scoreText) || scoreText === '-' || isHiddenScoreText(scoreText)) return; const scoreNum = parseFloat(fb.score ?? fb.scoreText); c._scoreText = String(scoreText); c.XSZCJMC = String(scoreText); if (Number.isFinite(scoreNum)) { c._score = scoreNum; c.ZCJ = scoreNum; const fixedGPA = pickCourseGPA(c, scoreNum, scoreText); if (fixedGPA !== null && (!Number.isFinite(c._gpa) || c._gpa < 0)) { c._gpa = fixedGPA; c.XFJD = fixedGPA; } } c._scoreFixedByMonitor = true; changed = true; }); if (changed) { applyFilters(); Toast.fire({ icon: 'success', title: '已从学业监测回填隐藏成绩' }); } return changed; } function normalizeGradeCourseRow(r) { const scoreNum = parseFloat(pickNumericCourseScore(r)); const scoreText = formatCourseScoreDisplay(r); return { ...r, _score: Number.isFinite(scoreNum) ? scoreNum : null, _credit: toNum(r.XF, 0), _gpa: pickCourseGPA(r, scoreNum, scoreText), _scoreText: scoreText, _semester: r.XNXQDM || '', _name: r.KCM || '', _isCompulsory: (r.KCXZDM_DISPLAY || '').indexOf('选修') === -1 }; } function processDataPart(rows, total) { if (!rows || rows.length === 0) return; rows.forEach(r => { if (r.SFYX !== '1') return; state.dataMap.set(`${r.XNXQDM}-${r.KCH}`, normalizeGradeCourseRow(r)); }); state.rawData = Array.from(state.dataMap.values()); state.rawData.forEach((item, index) => item._uid = index); state.semesters = new Set(state.rawData.map(r => r.XNXQDM).filter(Boolean).sort().reverse()); state.types = new Set(state.rawData.map(r => r.KCXZDM_DISPLAY).filter(Boolean).sort()); updateFilterUI(); applyFilters(); if (state.rawData.some(isHiddenScoreText)) setTimeout(() => MonitorModule.patchHiddenScores(), 0); } function updateFilterUI() { if (state.filters.semesterAll) state.filters.semesters = new Set(state.semesters); else state.filters.semesters = new Set(Array.from(state.filters.semesters).filter(v => state.semesters.has(v))); const typeEl = document.getElementById('h-type'); if (!typeEl) return; const buildOpts = (set, def) => `` + Array.from(set).map(v => ``).join(''); const currentType = state.filters.type || 'all'; typeEl.innerHTML = buildOpts(state.types, "全部性质"); typeEl.value = state.types.has(currentType) ? currentType : 'all'; state.filters.type = typeEl.value; renderSemesterMenu(); } function applyFilters() { const { search, semesters, type, sort } = state.filters; let res = state.rawData.filter(item => (!search || item._name.includes(search) || (item.KCH && item.KCH.includes(search))) && (semesters.size === 0 ? false : semesters.has(item._semester)) && (type === 'all' || item.KCXZDM_DISPLAY === type)); const gpaSortValue = (item, emptyValue) => Number.isFinite(item?._gpa) ? item._gpa : emptyValue; res.sort((a, b) => { switch(sort) { case 'gpa_desc': return gpaSortValue(b, -Infinity) - gpaSortValue(a, -Infinity); case 'gpa_asc': return gpaSortValue(a, Infinity) - gpaSortValue(b, Infinity); case 'credit_desc': return b._credit - a._credit; case 'credit_asc': return a._credit - b._credit; default: return a._semester !== b._semester ? b._semester.localeCompare(a._semester) : a._name.localeCompare(b._name, 'zh'); } }); state.filteredData = res; if (document.getElementById('h-list')) renderList(); if (document.getElementById('h-footer')) renderFooter(); } function renderList() { const list = document.getElementById('h-list'); if (!list) return; if (state.filteredData.length === 0) { list.innerHTML = `
无匹配数据
`; return; } const frag = document.createDocumentFragment(); state.filteredData.forEach(c => { const item = document.createElement('div'); item.className = 'course-item'; let scoreCls = 'score-none'; if (Number.isFinite(c._score)) { if (c._score >= 90) scoreCls = 'score-high'; else if (c._score < 60) scoreCls = 'score-low'; else scoreCls = 'score-med'; } const fixedTag = c._scoreFixedByMonitor ? '监测回填' : ''; item.innerHTML = `
${escapeHTML(c.KCM)}
${escapeHTML(formatCreditValue(c._credit))} 学分${escapeHTML(c.KCXZDM_DISPLAY || '其他')}${escapeHTML(c.XNXQDM)}${fixedTag}
${escapeHTML(c._scoreText)}
`; item.querySelector('.course-summary').addEventListener('click', () => toggleDetail(c)); frag.appendChild(item); }); list.innerHTML = ''; list.appendChild(frag); } function renderFooter() { const footer = document.getElementById('h-footer'); if (!footer) return; let tCred = 0, count = 0; let gpaCredTotal = 0, tGPA = 0; let gpaCredComp = 0, cGPA = 0; state.filteredData.forEach(c => { if (c.SFYX !== '1' || c._credit <= 0) return; tCred += c._credit; count++; }); getBestCoursesForGPA(state.filteredData).forEach(c => { gpaCredTotal += c._credit; tGPA += c._gpa * c._credit; if (c._isCompulsory) { gpaCredComp += c._credit; cGPA += c._gpa * c._credit; } }); footer.innerHTML = ``; state.stats = { count, totalCredit: formatCreditValue(tCred), avgGPA: gpaCredTotal ? (tGPA/gpaCredTotal).toFixed(3) : '-', compulsoryGPA: gpaCredComp ? (cGPA/gpaCredComp).toFixed(3) : '-' }; } async function toggleDetail(c) { const p = document.getElementById(`detail-${c._uid}`); if (p.classList.contains('open')) { p.classList.remove('open'); return; } document.querySelectorAll('.detail-panel.open').forEach(el => el.classList.remove('open')); p.classList.add('open'); if (p.dataset.loaded) return; if (!c.JXBID) { p.innerHTML = '
该课程无教学班关联信息,系统未计算排名。
'; p.dataset.loaded = 'true'; return; } p.innerHTML = '
⌛ 正在拉取班级对比数据...
'; try { if (!state.userId) { const u = await API.getUserInfo(); state.userId = u.userId; } const basePms = { JXBID: c.JXBID, XNXQDM: c.XNXQDM, TJLX: '01' }; const req = (ep, dt) => fetch("/jwapp/sys/cjcx/modules/cjcx/" + ep, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, body: new URLSearchParams(dt) }).then(r => r.json()); const [cRank, cStat, cDist] = await Promise.all([ req('jxbxspmcx.do', { ...basePms, XH: state.userId }), req('jxbcjtjcx.do', basePms), req('jxbcjfbcx.do', { ...basePms, '*order': '+DJDM' }) ]); const rankRows = cRank.datas?.jxbxspmcx?.rows || []; const r1 = rankRows.find(r => r.XH === state.userId) || rankRows[0] || {}; const s1 = (cStat.datas?.jxbcjtjcx?.rows || [])[0] || {}; const d1 = cDist.datas?.jxbcjfbcx?.rows || []; const pmText = r1.PM ? `${r1.PM} / ${r1.ZRS||'-'}` : '无排名'; p.innerHTML = `
班级排名最高分平均分最低分
${pmText}${s1.ZGF||'-'}${s1.PJF||'-'}${s1.ZDF||'-'}
班级成绩分布
${renderDistChart(d1)}
`; p.dataset.loaded = 'true'; } catch (e) { p.innerHTML = `
请求失败或数据未公开
`; } } function renderDistChart(dist) { if (!dist || !dist.length) return '
无分布数据
'; dist.sort((a, b) => (a.DJDM || '99').localeCompare(b.DJDM || '99')); const max = Math.max(...dist.map(r => r.DJSL || 0), 1); return dist.map(r => { const pct = ((r.DJSL || 0) / max) * 100; return `
${r.DJDM_DISPLAY||r.DJDM}
${r.DJSL}
`; }).join(''); } const ContextualExport = { toExcel: () => { if (!state.filteredData.length) return Swal.fire('提示', '没有数据可导出', 'info'); exportToBrowser(state.filteredData.map(c => ({ "学年学期": c.XNXQDM, "课程名称": c.KCM, "代码": c.KCH, "性质": c.KCXZDM_DISPLAY, "学分": c._credit, "成绩": c._scoreText, "绩点": Number.isFinite(c._gpa) ? c._gpa : '', "平时分": c.PSCJ || '', "期末分": c.QMCJ || '' })), `WHUT_成绩分析列表_${new Date().getTime()}`, 'xlsx'); }, toImage: async () => { if (!state.filteredData.length) return Swal.fire('提示', '没有数据可导出', 'info'); Swal.fire({ title: '生成中...', allowOutsideClick: false, didOpen: () => Swal.showLoading() }); const clone = document.getElementById('h-list').cloneNode(true); clone.style.cssText = `position:fixed; top:-9999px; left:-9999px; width:600px; height:auto; overflow:visible; background:white; padding:30px; font-family:sans-serif; box-sizing:border-box;`; let th = ``; state.filteredData.forEach(c => th += ``); th += `
学期课程学分成绩
${c.XNXQDM}${c.KCM}${formatCreditValue(c._credit)}${c._scoreText}
`; clone.innerHTML = `

WHUT 成绩分析报告

总课程
${state.stats.count}
总学分
${state.stats.totalCredit}
必修绩点
${state.stats.compulsoryGPA}
全科绩点
${state.stats.avgGPA}
${th}`; document.body.appendChild(clone); try { const canvas = await html2canvas(clone, { scale: 2, useCORS: true }); canvas.toBlob(b => { const l = document.createElement('a'); l.href = URL.createObjectURL(b); l.download = `WHUT_成绩长图.png`; l.click(); Swal.close(); }); } catch (e) { Swal.fire('错误', '图片生成失败', 'error'); } finally { document.body.removeChild(clone); } } }; // ================= 3. GPA模块 ================= const GPAModule = { isLoaded: false, cache: null, currentSemesters: [], init: async () => { if (GPAModule.isLoaded) return; const container = document.getElementById('gpa-content'); container.innerHTML = '
🔄 正在静默测算全量学期数据...
'; try { const userInfo = await API.getUserInfo(); const homeRes = await API.req({ method: 'GET', url: 'https://jwxt.whut.edu.cn/jwapp/sys/homeapp/api/home/currentUser.do' }); const currentSem = JSON.parse(homeRes.responseText).datas.welcomeInfo.xnxqdm; const defaultSems = GPAModule.calcSemesters(userInfo.userId, currentSem); const sems = GPAModule.getActiveSemesters(defaultSems); GPAModule.currentSemesters = sems; const overallReq = API.req({ method: 'POST', url: 'https://jwxt.whut.edu.cn/jwapp/sys/cjgl/modules/xsgpajs/cxxsqxcpjjd.do', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, data: new URLSearchParams({ 'querySetting': '[]', '*order': '-XZNJ,+YXDM,+ZYDM,+XH', pageSize: 20, pageNumber: 1 }).toString() }); const semReqs = sems.map(sem => API.req({ method: 'POST', url: 'https://jwxt.whut.edu.cn/jwapp/sys/cjgl/modules/xsgpajs/cxxsxqpjjd.do', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, data: new URLSearchParams({ querySetting: JSON.stringify([{ name: "XNXQDM", linkOpt: "AND", builderList: "cbl_m_List", builder: "m_value_equal", value: sem }]), '*order': '-XNXQDM,-XZNJ,+YXDM,+ZYDM,+XH', pageSize: 20, pageNumber: 1 }).toString() }).then(r => ({ sem, data: JSON.parse(r.responseText)?.datas?.cxxsxqpjjd?.rows?.[0] || null }))); const [overallRes, ...semResponses] = await Promise.all([overallReq, ...semReqs]); const overallData = JSON.parse(overallRes.responseText).datas?.cxxsqxcpjjd?.rows?.[0] || null; GPAModule.cache = { overall: overallData, semesters: semResponses }; GPAModule.render(overallData, semResponses, container); GPAModule.isLoaded = true; } catch (e) { container.innerHTML = `
加载出错: ${e.message}
`; } }, calcSemesters: (userId, currentSem) => { let gradeStr = userId.length === 13 ? userId.substring(3, 5) : (userId.length === 10 ? userId.substring(2, 4) : currentSem.substring(2, 4)); let startYear = parseInt("20" + gradeStr), currentYear = parseInt(currentSem.split('-')[0]), currentTerm = parseInt(currentSem.split('-')[2]), sems = []; for (let y = startYear; y <= currentYear; y++) for (let t = 1; t <= 2; t++) if (!(y === currentYear && t >= currentTerm) && !(y > currentYear)) sems.push(`${y}-${y+1}-${t}`); return sems.reverse(); }, getSavedSemesters: () => { try { const saved = JSON.parse(localStorage.getItem('whut_gpa_semesters') || 'null'); return Array.isArray(saved) ? saved.filter(GPAModule.isValidSemester) : []; } catch (e) { return []; } }, getActiveSemesters: (defaultSems) => { const saved = GPAModule.getSavedSemesters(); return saved.length ? saved : defaultSems; }, isValidSemester: (sem) => /^\d{4}-\d{4}-[1-2]$/.test(String(sem || '').trim()), reload: async () => { GPAModule.isLoaded = false; GPAModule.cache = null; await GPAModule.init(); }, openSemesterManager: async () => { const userInfo = await API.getUserInfo(); const homeRes = await API.req({ method: 'GET', url: 'https://jwxt.whut.edu.cn/jwapp/sys/homeapp/api/home/currentUser.do' }); const currentSem = JSON.parse(homeRes.responseText).datas.welcomeInfo.xnxqdm; const defaultSems = GPAModule.calcSemesters(userInfo.userId, currentSem); const savedSems = GPAModule.getSavedSemesters(); const sems = (savedSems.length ? savedSems : (GPAModule.currentSemesters.length ? GPAModule.currentSemesters : defaultSems)); const renderRows = (list) => list.map(sem => `
`).join(''); const result = await Swal.fire({ title: 'GPA 学期管理', html: `
格式:2025-2026-2。保存后将使用这里的学期列表加载 GPA;刷新网页后仍会保留。
${renderRows(sems)}
`, width: 560, showDenyButton: true, showCancelButton: true, confirmButtonText: '保存', denyButtonText: '恢复默认', cancelButtonText: '取消', didOpen: (modal) => { const list = modal.querySelector('#gpa-sem-list'); const addRow = (value = '') => { const wrap = document.createElement('div'); wrap.className = 'gpa-sem-row'; wrap.style.cssText = 'display:flex; gap:8px; margin-bottom:8px;'; wrap.innerHTML = ``; list.appendChild(wrap); }; modal.querySelector('#gpa-sem-add').addEventListener('click', () => addRow('')); list.addEventListener('click', (e) => { const btn = e.target.closest('.gpa-sem-del'); if (btn) btn.closest('.gpa-sem-row')?.remove(); }); }, preConfirm: () => { const vals = Array.from(document.querySelectorAll('.swal2-container .gpa-sem-input')).map(i => i.value.trim()).filter(Boolean); const unique = Array.from(new Set(vals)); if (!unique.length) { Swal.showValidationMessage('请至少保留一个学期'); return false; } const invalid = unique.find(v => !GPAModule.isValidSemester(v)); if (invalid) { Swal.showValidationMessage(`学期格式不正确:${invalid}`); return false; } return unique; } }); if (result.isDenied) { const confirm = await Swal.fire({ title: '恢复自动学期?', text: '将删除本地保存的自定义学期列表。', icon: 'warning', showCancelButton: true, confirmButtonText: '恢复默认', cancelButtonText: '取消' }); if (!confirm.isConfirmed) return; localStorage.removeItem('whut_gpa_semesters'); Toast.fire({ icon: 'success', title: '已恢复自动学期' }); await GPAModule.reload(); return; } if (!result.isConfirmed) return; const confirm = await Swal.fire({ title: '保存自定义学期?', text: '保存后会重新加载 GPA 数据。', icon: 'question', showCancelButton: true, confirmButtonText: '保存并重载', cancelButtonText: '取消' }); if (!confirm.isConfirmed) return; localStorage.setItem('whut_gpa_semesters', JSON.stringify(result.value)); Toast.fire({ icon: 'success', title: 'GPA 学期列表已保存' }); await GPAModule.reload(); }, render: (overall, semResponses, container) => { let html = overall ? `
🏆 全学程综合 GPA
平均学分绩点${overall.PJXFJD || '-'}
班排: ${overall.PJXFJDBJPM || '-'}/${overall.BJZRS||'-'}专排: ${overall.PJXFJDZYPM || '-'}/${overall.ZYZRS||'-'}
必修课均绩点${overall.BXKPJXFJD || '-'}
班排: ${overall.BXKPJXFJDBJPM || '-'}/${overall.BJZRS||'-'}专排: ${overall.BXKPJXFJDZYPM || '-'}/${overall.ZYZRS||'-'}
` : `
暂无全学程 GPA 数据
`; html += `

📅 各学期明细

`; semResponses.forEach(res => { if (!res.data) html += `
${res.sem} 暂无数据
`; else html += `
📍 ${res.sem}
平均绩点 / 班排 / 专排
${res.data.PJXFJD || '-'}No.${res.data.PJXFJDBJPM || '-'} / No.${res.data.PJXFJDZYPM || '-'}
必修绩点 / 班排 / 专排
${res.data.BXKPJXFJD || '-'}No.${res.data.BXKPJXFJDBJPM || '-'} / No.${res.data.BXKPJXFJDZYPM || '-'}
`; }); container.innerHTML = html; }, exportToExcel: () => { if (!GPAModule.cache) return Swal.fire('提示', '请先等待 GPA 数据加载完成', 'warning'); let rawRows = []; if (GPAModule.cache.overall) rawRows.push({ ...GPAModule.cache.overall, XNXQDM: "全学程" }); GPAModule.cache.semesters.forEach(s => { if (s.data) rawRows.push({ ...s.data, XNXQDM: s.sem }); }); exportToBrowser(rawRows.map(row => Object.fromEntries(MAPPING_GPA_NEW.map(m => [m.label, row[m.key] ?? row[m.fallback] ?? '']))), `WHUT_综合GPA与学期排名_${new Date().getTime()}`, 'xlsx'); }, exportToImage: async () => { const container = document.getElementById('gpa-content'); if (!GPAModule.isLoaded || !container) return Swal.fire('提示', '请先等待数据加载', 'warning'); Swal.fire({ title: '生成中...', allowOutsideClick: false, didOpen: () => Swal.showLoading() }); try { const clone = container.cloneNode(true); clone.style.cssText = `position:fixed; top:-9999px; left:-9999px; width:500px; height:auto; overflow:visible; background:${THEME.bg}; padding:20px; box-sizing:border-box;`; document.body.appendChild(clone); const canvas = await html2canvas(clone, { scale: 2, backgroundColor: THEME.bg, useCORS: true }); document.body.removeChild(clone); canvas.toBlob(b => { const l = document.createElement('a'); l.href = URL.createObjectURL(b); l.download = `WHUT_GPA面板报告.png`; l.click(); Swal.close(); }); } catch (e) { Swal.fire('错误', '图片生成失败', 'error'); } } }; // ================= 4. 一键评教模块 (新融合) ================= const EvalModule = { init: async () => { if (state.eval.isLoaded) return; EvalModule.loadData(); }, setStatus: (msg) => { document.getElementById('pj-status').innerHTML = msg; }, async loadData() { EvalModule.setStatus("🔄 获取评教课程数据..."); const listArea = document.getElementById('pj-list-area'); listArea.innerHTML = ''; state.eval.courses = []; try { const termRes = await API.postJSON('/jwapp/sys/jwpubapp/modules/gg/cxmrxnxq.do', "ZCSDM=DQXNXQDM&CSDM=SYS&SFSY=1&*order=%2BPX%2C%2BWID"); const term = termRes?.datas?.cxmrxnxq?.rows?.[0]?.XNXQDM; if (!term) return EvalModule.setStatus("❌ 无法获取学期"); state.eval.termCode = term; const lxRes = await API.postJSON('/jwapp/sys/pjapp/api/wdpj/getPjlx.do', `XNXQDM=${term}`); let types = lxRes?.datas?.getPjlx || []; if (!types.length) types = [{PJLXDM:'01', PJLXMC:'默认'}]; let allCourses = []; for (let t of types) { const code = t.PJLXDM || t.DM || '01'; const listRes = await API.postJSON('/jwapp/sys/pjapp/api/wdpj/getDpwj.do', `PJLXDM=${code}&querySetting=${encodeURIComponent(JSON.stringify([{"name":"PJLXDM","value":code,"builder":"equal","linkOpt":"AND"},{"name":"XNXQDM","value":term,"builder":"m_value_equal","linkOpt":"AND"}]))}`); const list = listRes?.datas?.getDpwj || []; list.forEach(c => { c.PJLXDM = code; c.PJLXMC = t.PJLXMC || t.MC || ''; }); allCourses = allCourses.concat(list); } state.eval.courses = allCourses; EvalModule.renderGroups(allCourses); EvalModule.setStatus(`✅ 评教列表获取成功: 共 ${allCourses.length} 门`); state.eval.isLoaded = true; } catch (e) { EvalModule.setStatus("❌ 数据加载异常"); } }, renderGroups(courses) { const listArea = document.getElementById('pj-list-area'); if (courses.length === 0) return listArea.innerHTML = '
暂无评教任务
'; const groups = { ing: [], wait: [], end: [] }; const now = new Date(); courses.forEach((c, idx) => { c._idx = idx; let start = new Date(), end = new Date(); try { if (c.KSSJ) start = new Date(c.KSSJ.replace(/-/g, "/")); if (c.JSSJ) end = new Date(c.JSSJ.replace(/-/g, "/")); } catch(e){} if (c.BPJSSFYPG === '1') { c._status = 'done'; groups.ing.push(c); } else if (now < start) { c._status = 'wait'; groups.wait.push(c); } else if (now > end) { c._status = 'end'; groups.end.push(c); } else { c._status = 'ing'; groups.ing.push(c); } }); const createGrp = (title, count, isOpen, canCheck) => { const g = document.createElement('div'); const cbHtml = canCheck ? `` : ''; g.innerHTML = `
${cbHtml}${title} (${count})${isOpen?'▼':'▶'}
`; g.querySelector('.pj-group-header').onclick = (e) => { if(e.target.type !== 'checkbox') { const c = g.querySelector('.pj-group-content'), a = g.querySelector('.pj-arrow'), show = c.style.display === 'none'; c.style.display = show?'block':'none'; a.innerText = show?'▼':'▶'; } }; if(canCheck) g.querySelector('.pj-grp-cb').onchange = (e) => g.querySelectorAll('.pj-item-cb:not([disabled])').forEach(cb => cb.checked = e.target.checked); listArea.appendChild(g); return g.querySelector('.pj-group-content'); }; const createItem = (c) => { let badge = '', disabled = true, checked = false; if (c._status === 'done') { badge = `已完成`; disabled = false; } else if (c._status === 'ing') { badge = `进行中`; disabled = false; checked = true; } else if (c._status === 'wait') { badge = `未开始`; } else { badge = `已结束`; } const div = document.createElement('div'); div.className = 'pj-item'; div.innerHTML = `
${c.KCM}
${c.XM}
${badge}
`; return div; }; if (groups.ing.length > 0) { const c = createGrp('🟢 进行中 / 可重评', groups.ing.length, true, true); groups.ing.forEach(course => c.appendChild(createItem(course))); } if (groups.wait.length > 0) { const c = createGrp('⚪ 未开始', groups.wait.length, false, false); groups.wait.forEach(course => c.appendChild(createItem(course))); } if (groups.end.length > 0) { const c = createGrp('🔴 已结束', groups.end.length, false, false); groups.end.forEach(course => c.appendChild(createItem(course))); } }, async runSelected() { if (state.eval.isRunning) return; const cbs = document.querySelectorAll('.pj-item-cb:checked'); if (cbs.length === 0) return Toast.fire({ icon: 'info', title: '请先勾选需要评教的课程' }); state.eval.isRunning = true; const btn = document.getElementById('pj-run-btn'); btn.disabled = true; btn.innerText = '⏳ 自动评教进行中...'; btn.style.background = '#9e9e9e'; let success = 0; for (let i = 0; i < cbs.length; i++) { const idx = cbs[i].value; const c = state.eval.courses[idx]; const logEl = document.getElementById(`pj-log-${idx}`); logEl.innerText = "请求题目..."; try { const wjRes = await API.postJSON('/jwapp/sys/pjapp/api/wdpj/getWjtxxx.do', `GROUPNO=${c.GROUPNO}&PJLXDM=${c.PJLXDM||'01'}&XUH=${c.XUH||1}&JXBID=${c.JXBID||''}&KCH=${c.KCH||''}`); const wjData = wjRes?.datas?.getWjtxxx; if (!wjData || !wjData.teachers?.length) throw new Error("无题目"); const tInfo = wjData.teachers[0], daArray = []; wjData.questionList.forEach(q => { if (q.TX === '01' && q.questionOptions.length > 0) { const opts = [...q.questionOptions].sort((a, b) => b.FZ - a.FZ); let sel = opts[0]; if (state.eval.config.randomScore && Math.random() > 0.9 && opts.length > 1) sel = opts[1]; const sDa = { "TMXXID": sel.WID, "FJXX": "" }; daArray.push({ "DA": sDa, "XXID": sel.WID, "DAStr": JSON.stringify(sDa), "YWZJ": tInfo.PJGXID, "WID": "", "DF": sel.FZ, "WJID": wjData.WJID, "TMID": q.TMID, "TX": "01" }); } else if (q.TX === '02') { daArray.push({ "DA": state.eval.config.comment, "DAStr": state.eval.config.comment, "YWZJ": tInfo.PJGXID, "WID": "", "DF": null, "WJID": wjData.WJID, "TMID": q.TMID, "TX": "02" }); } }); logEl.innerText = "提交中..."; const payload = [{ "XM": tInfo.XM, "KCM": tInfo.KCM, "PJZT": "1", "DF": "100.0", "PJGXID": tInfo.PJGXID, "DA": daArray, "XUH": c.XUH || 1, "FJTXXX": { "TKZC": "12", "WID": "" }, "WJID": wjData.WJID, "questionAnswers": JSON.stringify(daArray) }]; const pStr = "requestParamStr=" + encodeURIComponent(JSON.stringify(payload)); await API.postJSON('/jwapp/sys/pjapp/api/wdpj/calculateQuestionnaireAnswerScore.do', pStr); const subRes = await API.postJSON('/jwapp/sys/pjapp/api/wdpj/commitQuestionnaireAnswer.do', pStr); if (subRes?.code === '0') { logEl.innerHTML = `✅ 成功`; success++; } else throw new Error(subRes?.msg || "失败"); } catch(e) { logEl.innerHTML = `❌ ${e.message}`; } if (i < cbs.length - 1) await new Promise(r => setTimeout(r, state.eval.config.delay)); } state.eval.isRunning = false; btn.disabled = false; btn.innerText = '🚀 开始一键评教'; btn.style.background = THEME.success; Swal.fire({ icon: 'success', title: '评教任务结束', text: `成功提交 ${success} / ${cbs.length} 门课程` }); } }; // ================= 5. 个人学业监测报告模块 ================= const MonitorModule = { init: async () => { if (state.monitor.isLoaded) { MonitorModule.render(); MonitorModule.setStatus(`✅ 数据已同步${state.monitor.overview.calculatedTime ? `:${escapeHTML(state.monitor.overview.calculatedTime)}` : ''}`); return; } if (state.monitor.isLoading) { state.monitor.renderAfterLoad = true; MonitorModule.setStatus('🔄 正在从服务器同步学业监测数据...'); return; } await MonitorModule.loadData(); }, setStatus: (msg) => { const el = document.getElementById('monitor-status'); if (el) el.innerHTML = msg; }, setBusy: (busy, text) => { const refreshBtn = document.getElementById('btn-monitor-refresh'); const exportBtn = document.getElementById('btn-monitor-export'); if (refreshBtn) { refreshBtn.disabled = busy; if (text) refreshBtn.innerText = text; else refreshBtn.innerText = '🔄 手动同步'; } if (exportBtn) exportBtn.disabled = busy; }, request: async (url, params = {}, opts = {}) => { const res = await API.req({ method: 'POST', url, headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest' }, data: new URLSearchParams(compactParams(params)).toString(), responseType: opts.blob ? 'blob' : undefined }); if (res.status && res.status >= 400) throw new Error(`HTTP ${res.status}`); if (opts.blob) { if (!res.response) throw new Error('空文件流'); return res.response; } const text = res.responseText || ''; if (!text || /^\s* { for (const key of keys) if (obj && obj[key] !== undefined && obj[key] !== null && obj[key] !== '') return obj[key]; return ''; }, pickNum: (obj, keys, fallback = 0) => { for (const key of keys) { const n = parseFloat(obj?.[key]); if (Number.isFinite(n)) return n; } return fallback; }, unwrap: (res, key) => { const datas = res?.datas ?? res?.data ?? res; if (!datas) return {}; let node = datas[key] ?? datas; if (!node && typeof datas === 'object') node = Object.values(datas).find(v => v && (v.rows || v.data || v.fanbx || Array.isArray(v))); if (node?.rows?.length === 1) { const row = node.rows[0]; const structuralKeys = ['fanbx', 'FANBX', 'fanBx', 'fawbx', 'fawbxMap', 'children', 'CHILDREN', 'childList', 'moduleList', 'categoryList', 'courseCategoryList', 'checkCourseVOS', 'courseList', 'data']; if (row && typeof row === 'object' && structuralKeys.some(k => row[k] !== undefined)) node = row; } return node || {}; }, rowsOf: (node) => { if (Array.isArray(node)) return node; if (Array.isArray(node?.rows)) return node.rows; if (Array.isArray(node?.data)) return node.data; if (Array.isArray(node?.list)) return node.list; return node && typeof node === 'object' ? [node] : []; }, pickBynf: (...values) => { for (const val of values) { if (val === undefined || val === null || val === '') continue; const text = String(val).trim(); if (text === '*') return '*'; return text.match(/\d{4}/)?.[0] || text; } return ''; }, isNewPlanCode: (plan) => { const pyfadm = String(plan?.PYFADM || plan?.value || '').trim(); return !!pyfadm && !/^\d+$/.test(pyfadm); }, buildParams: (userInfo, basicRow = {}) => { const guessedBynf = MonitorModule.guessGraduationYear(state.userId || userInfo?.userId); const archiveBynf = MonitorModule.pickBynf( MonitorModule.pick(basicRow, ['BYNF', 'YJBYNF', 'YJBYRQ', 'BYYF']), MonitorModule.isNewPlanCode(basicRow) ? '*' : guessedBynf ); return compactParams({ XH: state.userId || userInfo?.userId, XSBH: state.userId || userInfo?.userId, PYFADM: MonitorModule.pick(basicRow, ['PYFADM', 'PYFADM_DISPLAY', 'PYFAH']), BYNF: archiveBynf, XZNJ: MonitorModule.pick(basicRow, ['XZNJ', 'NJDM', 'NJ']) }); }, guessGraduationYear: (userId) => { const id = String(userId || ''); const gradeStr = id.length === 13 ? id.substring(3, 5) : (id.length === 10 ? id.substring(2, 4) : ''); const startYear = parseInt(`20${gradeStr}`, 10); return Number.isFinite(startYear) ? String(startYear + 4) : ''; }, buildStudyParams: (userInfo, studentInfo = {}) => { const queryNode = MonitorModule.unwrap(studentInfo, 'queryXsjbxx'); const jbxx = queryNode.jbxx || {}; const plans = Array.isArray(queryNode.faArr) ? queryNode.faArr : []; const plan = plans.find(p => p.XDLXDM === '01') || plans[0] || {}; const bynf = MonitorModule.pickBynf( plan.BYNF, plan.bynf, MonitorModule.isNewPlanCode(plan) ? '*' : '', jbxx.BYNF, jbxx.YJBYNF, jbxx.YJBYRQ, MonitorModule.guessGraduationYear(state.userId || userInfo?.userId) ); return { student: jbxx, plan, basicRes: studentInfo, base: compactParams({ from: 'xyjctjapp', XH: state.userId || userInfo?.userId, PYFADM: plan.PYFADM || plan.value, BYNF: bynf, pageType: 2, hasByjSearch: false }), xy: compactParams({ fromPage: 'grxyjcbg', from: 'xyjctjapp', XH: state.userId || userInfo?.userId, PYFADM: plan.PYFADM || plan.value, BYNF: bynf, pageType: 2, hasByjSearch: false, SCLX: '04', XDLX: plan.XDLXDM || '01' }) }; }, getStudyContext: async (userInfo) => { if (state.monitor.studyParams?.base?.PYFADM) return state.monitor.studyParams; const userId = state.userId || userInfo?.userId; const seedCandidates = Array.from(new Set([MonitorModule.guessGraduationYear(userId), '*', ''])); let studyParams = null; let lastError = null; for (const seedBynf of seedCandidates) { const seedParams = compactParams({ from: 'xyjctjapp', XH: userId, BYNF: seedBynf, pageType: 2, hasByjSearch: false }); try { const basicRes = await MonitorModule.request('/jwapp/sys/byshapp/api/grbg/queryXsjbxx.do', seedParams); const nextParams = MonitorModule.buildStudyParams(userInfo, basicRes); if (nextParams.base.PYFADM) { studyParams = nextParams; break; } lastError = new Error('未获取到培养方案代码 PYFADM'); } catch (e) { lastError = e; } } if (!studyParams) throw lastError || new Error('未获取到培养方案代码 PYFADM'); state.monitor.studyParams = studyParams; state.monitor.student = studyParams.student; state.monitor.plan = studyParams.plan; return studyParams; }, withBynf: (studyParams, bynf) => ({ ...studyParams, base: compactParams({ ...studyParams.base, BYNF: bynf }), xy: compactParams({ ...studyParams.xy, BYNF: bynf }) }), studyParamVariants: (studyParams, userInfo) => { const currentBynf = studyParams?.xy?.BYNF ?? studyParams?.base?.BYNF ?? ''; const guessedBynf = MonitorModule.guessGraduationYear(state.userId || userInfo?.userId); return Array.from(new Set([currentBynf, '*', guessedBynf, ''])) .map(bynf => MonitorModule.withBynf(studyParams, bynf)); }, hasMonitorContent: (normalized) => { return !!( normalized?.categories?.length || normalized?.scoreFallbacks?.length || toNum(normalized?.overview?.required, 0) > 0 || toNum(normalized?.overview?.earned, 0) > 0 ); }, loadData: async ({ force = false, silent = false, render = true } = {}) => { if (state.monitor.isLoading) return; if (state.monitor.isLoaded && !force) { applyScoreFallbacks(); if (render) MonitorModule.render(); return; } state.monitor.isLoading = true; if (!silent) { MonitorModule.setStatus('🔄 正在从服务器同步学业监测数据...'); const content = document.getElementById('monitor-content'); if (content && render) content.innerHTML = '
🔄 正在加载个人学业监测报告...
'; } try { const userInfo = await API.getUserInfo(); const studyParams = await MonitorModule.getStudyContext(userInfo); let selected = null; let lastError = null; for (const candidateParams of MonitorModule.studyParamVariants(studyParams, userInfo)) { try { const xyRes = await MonitorModule.request('/jwapp/sys/byshapp/api/grbg/queryXyzhbx.do', candidateParams.xy); const normalized = MonitorModule.normalize(xyRes, candidateParams.basicRes); selected = { xyRes, normalized, studyParams: candidateParams }; if (MonitorModule.hasMonitorContent(normalized)) break; } catch (e) { lastError = e; } } if (!selected) throw lastError || new Error('学业监测数据请求失败'); const { xyRes, normalized, studyParams: selectedStudyParams } = selected; Object.assign(state.monitor, normalized, { isLoaded: true, raw: { xyRes, basicRes: selectedStudyParams.basicRes }, studyParams: selectedStudyParams, student: selectedStudyParams.student, plan: selectedStudyParams.plan }); applyScoreFallbacks(); if (render) MonitorModule.render(); MonitorModule.setStatus(`✅ 数据已同步${state.monitor.overview.calculatedTime ? `:${escapeHTML(state.monitor.overview.calculatedTime)}` : ''}`); } catch (e) { if (render) document.getElementById('monitor-content').innerHTML = `
加载失败:${escapeHTML(e.message)}
`; MonitorModule.setStatus('❌ 学业监测数据加载失败'); } finally { state.monitor.isLoading = false; if (state.monitor.renderAfterLoad && state.monitor.isLoaded) { state.monitor.renderAfterLoad = false; MonitorModule.render(); MonitorModule.setStatus(`✅ 数据已同步${state.monitor.overview.calculatedTime ? `:${escapeHTML(state.monitor.overview.calculatedTime)}` : ''}`); } } }, fmtCredit: (val) => { return formatCreditValue(val); }, courseState: (course) => { const raw = course?.raw || {}; const statusText = String(raw.SFJG_DISPLAY || raw.ZT_DISPLAY || raw.statusName || '').trim(); const hasScore = hasScoreValue(course?.scoreText) && course.scoreText !== '-' || hasScoreValue(course?.score); if (/未修读|未获得|未选|待修/.test(statusText) && !hasScore) return 'pending'; if ((course?.passed === false || course?.passed === 'false') && !hasScore) return 'pending'; if (raw.SFJG === '0' || raw.ZT === '02' || raw.status === '02' || raw.status === '0') return 'fail'; if (/不及格|不通过|未通过|不合格|否|失败/.test(statusText)) return 'fail'; const scoreNum = parseFloat(course?.score ?? course?.scoreText); if (Number.isFinite(scoreNum) && scoreNum >= 0 && scoreNum < 60) return 'fail'; return 'pass'; }, coursePassed: (course) => { return MonitorModule.courseState(course) === 'pass'; }, categoryNodesOf: (node) => { if (!node) return []; if (Array.isArray(node)) return node.filter(item => item && typeof item === 'object' && !looksLikeMonitorCourseRow(item)); const keys = ['children', 'CHILDREN', 'childList', 'childNodes', 'nodes', 'items', 'moduleList', 'categoryList', 'courseCategoryList', 'kcmkList', 'treeData', 'data', 'rows', 'list']; const nodes = []; keys.forEach(key => { if (!Array.isArray(node?.[key])) return; node[key].forEach(item => { if (item && typeof item === 'object' && !looksLikeMonitorCourseRow(item)) nodes.push(item); }); }); return nodes; }, normalizeCategory: (node, index = 0, path = 'cat') => { const name = MonitorModule.pick(node, ['MC', 'FLMC', 'KCLBMC', 'KCMKMC', 'courseCategoryName', 'moduleName', 'name', 'text']) || '未命名模块'; const id = String(node?.id || `${path}-${index}`); const childNodes = MonitorModule.categoryNodesOf(node); const children = childNodes.map((child, idx) => MonitorModule.normalizeCategory(child, idx, id)); const directRows = []; monitorChildArrays(node).forEach(({ rows }) => { rows.forEach(row => { if (looksLikeMonitorCourseRow(row)) directRows.push(normalizeMonitorCourse(row, name)); }); }); const rows = directRows.concat(children.flatMap(c => c.rows || [])); const requiredKeys = ['creditsRequired', 'YQXF', 'YQZXF', 'ZXF', 'ZDZXF', 'XFYQ', 'requiredCredit', 'requireCredit', 'creditRequire', 'BYXF', 'minCredit', 'minimumCredit', 'requiredCredits', 'planCredit', 'totalCreditRequired']; const earnedKeys = ['creditsEarned', 'HDXF', 'YHXF', 'YXXF', 'TGXF', 'WCXF', 'earnedCredit', 'completeCredit', 'passCredit', 'earnedCredits', 'completedCredit', 'actualCredit', 'gotCredit', 'totalCreditEarned']; let required = MonitorModule.pickNum(node, requiredKeys, NaN); let earned = MonitorModule.pickNum(node, earnedKeys, NaN); if (!Number.isFinite(required)) required = children.reduce((sum, child) => sum + child.required, 0); if (!Number.isFinite(earned)) { earned = children.reduce((sum, child) => sum + child.earned, 0) + directRows.filter(r => MonitorModule.coursePassed(r)).reduce((sum, r) => sum + toNum(r.credit), 0); } const creditPassed = !Number.isFinite(required) || required <= 0 || earned + 1e-6 >= required; return { id, name, required, earned, rows, children, passed: creditPassed }; }, isPassed: (row) => { const text = String(MonitorModule.pick(row, ['SFTG_DISPLAY', 'BYJ_DISPLAY', 'SFJG_DISPLAY', 'JG_DISPLAY', 'ZT_DISPLAY', 'statusName']) || '').trim(); const flag = MonitorModule.pick(row, ['SFTG', 'BYJ', 'SFJG', 'JG', 'passed', 'isPassed']); if (flag === false || flag === 'false' || flag === 0 || flag === '0') return false; if (/不及格|不通过|未通过|不合格|否|失败/.test(text)) return false; if (flag === true || flag === 'true' || flag === 1 || flag === '1') return true; if (/通过|合格|达标|符合|正常|是/.test(text)) return true; return true; }, normalize: (xyRes, basicRes) => { let xyNode = MonitorModule.unwrap(xyRes, 'queryXyzhbx'); if (Array.isArray(xyNode)) xyNode = { data: xyNode }; if (xyNode?.rows && !xyNode.data) xyNode.data = xyNode.rows; const fanbx = xyNode.fanbx || xyNode.FANBX || xyNode.fanBx || xyNode.fanbxMap || xyNode.fanbxTree || xyNode.programTree || xyNode.schemeTree || {}; let categoryNodes = MonitorModule.categoryNodesOf(fanbx); if (!categoryNodes.length) categoryNodes = MonitorModule.categoryNodesOf(xyNode); let categories = categoryNodes.map((node, index) => MonitorModule.normalizeCategory(node, index)); const fawbxRows = dedupeMonitorCourses([ ...collectMonitorCourseRows(xyNode.fawbx, '方案外课程'), ...collectMonitorCourseRows(xyNode.fawbxMap, '方案外课程') ]).filter(r => r.courseId || r.courseName); if (fawbxRows.length) { categories.push({ id: 'fawbx-group', name: '方案外/额外课程', required: 0, earned: fawbxRows.filter(r => MonitorModule.coursePassed(r)).reduce((sum, r) => sum + toNum(r.credit), 0), rows: fawbxRows, children: [], passed: true }); } const scoreFallbacks = normalizeScoreFallbackRows(xyNode); if (!categories.length && scoreFallbacks.length) { const grouped = new Map(); scoreFallbacks.forEach(course => { const key = course.moduleName || course.nature || '其他课程'; const curr = grouped.get(key) || { id: `fallback-${grouped.size}`, name: key, required: 0, earned: 0, rows: [], children: [], passed: true }; curr.rows.push(course); curr.earned += toNum(course.credit); curr.passed = curr.passed && MonitorModule.coursePassed(course); grouped.set(key, curr); }); categories = Array.from(grouped.values()); } const basicNode = MonitorModule.unwrap(basicRes, 'queryXsjbxx'); const required = MonitorModule.pickNum(fanbx, ['creditsRequired', 'YQXF', 'YQZXF', 'ZXF', 'ZDZXF', 'XFYQ', 'requiredCredit', 'requireCredit', 'creditRequire', 'BYXF', 'minCredit', 'minimumCredit', 'requiredCredits', 'planCredit', 'totalCreditRequired'], categories.reduce((sum, c) => sum + c.required, 0)); const earned = MonitorModule.pickNum(fanbx, ['creditsEarned', 'creditsSelection', 'YXXF', 'HDXF', 'YHXF', 'TGXF', 'WCXF', 'earnedCredit', 'earnedCredits', 'completedCredit', 'actualCredit', 'gotCredit', 'totalCreditEarned'], categories.reduce((sum, c) => sum + c.earned, 0)); const warnings = MonitorModule.buildWarnings(scoreFallbacks, xyNode, basicNode); if (!state.monitor.openGroupsTouched) { state.monitor.openGroups = new Set(); } return { overview: { required, earned, passed: (!required || earned + 1e-6 >= required) && warnings.every(w => w.type !== 'danger'), calculatedTime: MonitorModule.pick(xyNode, ['calculatedTime', 'CZSJ', 'GXSJ', 'JSSJ']) || '' }, categories, warnings, scoreFallbacks }; }, buildWarnings: (courses, xyNode, basicNode) => { const warnings = []; const seen = new Set(); courses.forEach(course => { const state = MonitorModule.courseState(course); if (state === 'pass' || state === 'pending') return; const key = `${course.courseId || course.courseName}-${course.semester || ''}`; if (seen.has(key)) return; seen.add(key); warnings.push({ type: 'danger', title: course.courseName || course.courseId || '未命名课程', text: `${course.semester || '-'} ${course.scoreText ?? course.score ?? '未通过'}` }); }); (Array.isArray(xyNode?.fjzbbx) ? xyNode.fjzbbx : []).forEach(item => { const targets = Array.isArray(item.targets) ? item.targets : []; const failedTargets = targets.filter(t => t?.passed === false || t?.passed === 'false'); if (!(item.passed === false || item.passed === 'false' || failedTargets.length)) return; const detail = (failedTargets.length ? failedTargets : targets).map(t => { const result = t?.result !== undefined && t?.result !== null ? `,当前值 ${t.result}` : ''; return `${t?.name || item.name || '附加指标'}${result}`; }).join(';'); warnings.push({ type: 'danger', title: item.name || '附加指标提醒', text: detail || '未达标' }); }); const disciplineKeys = ['CFXX', 'CFZT', 'CFQK', 'CFCJ', 'SFCF', 'SFYCF', 'XSCFQK', 'punishment', 'disciplineStatus']; [basicNode?.jbxx || {}, basicNode || {}].forEach(row => disciplineKeys.forEach(k => { const val = row?.[k]; if (val !== undefined && val !== null && val !== '' && !/^(0|否|无|正常|false)$/i.test(String(val))) { warnings.push({ type: 'danger', title: '处分状态提醒', text: `${k}: ${val}` }); } })); if (!warnings.length) warnings.push({ type: 'ok', title: '暂无预警', text: '未发现不及格课程或附加指标异常。' }); return warnings; }, renderCourseRows: (group) => { const rows = group.rows || []; if (!rows.length) return '暂无课程明细'; return rows.map(course => { const state = MonitorModule.courseState(course); const pass = state === 'pass'; const courseLabel = sameText(course.courseName, course.courseId) ? (course.courseName || course.courseId || '-') : `${course.courseName || '-'}${course.courseId ? `[${course.courseId}]` : ''}`; const replacedLabel = (course.replacedCourseName || course.replacedCourseId) ? (sameText(course.replacedCourseName, course.replacedCourseId) ? (course.replacedCourseName || course.replacedCourseId) : `${course.replacedCourseName || '-'}${course.replacedCourseId ? `[${course.replacedCourseId}]` : ''}`) : '-'; const scoreDisplay = course.scoreText ?? course.score ?? '-'; return `
${escapeHTML(courseLabel)}
${pass ? '✓' : (state === 'pending' ? '-' : '×')} ${escapeHTML(scoreDisplay)} ${escapeHTML(MonitorModule.fmtCredit(course.credit))} ${escapeHTML(replacedLabel)} ${escapeHTML(MonitorModule.fmtCredit(course.replacedCourseCredit))} `; }).join(''); }, renderMonitorGroups: (categories) => { if (!categories.length) return '
暂无培养方案模块数据
'; return `
${categories.map(group => { const open = state.monitor.openGroups.has(group.id); const pct = group.required ? Math.min(100, Math.round((group.earned / group.required) * 100)) : 0; return `
${open ? `
${MonitorModule.renderCourseRows(group)}
课程名[课程号]是否通过成绩学分替代课程名[课程号]学分
` : ''}
`; }).join('')}
`; }, render: () => { const container = document.getElementById('monitor-content'); if (!container) return; const { overview, categories, warnings } = state.monitor; const pct = overview.required ? Math.min(100, Math.round((overview.earned / overview.required) * 100)) : 0; const warningsHtml = warnings.map(w => `
${escapeHTML(w.title)}
${escapeHTML(w.text)}
`).join(''); container.innerHTML = `
毕业资格状态
${overview.passed ? '当前状态正常' : '存在待处理项'}
${overview.passed ? '达标/可继续' : '需关注'}
总学分进度:${MonitorModule.fmtCredit(overview.earned)} / ${overview.required ? MonitorModule.fmtCredit(overview.required) : '-'} 学分${pct}%
📌 培养方案模块
${MonitorModule.renderMonitorGroups(categories || [])}
🚨 预警中心
${warningsHtml} `; }, refresh: async () => { if (state.monitor.isRefreshing) return; state.monitor.isRefreshing = true; MonitorModule.setBusy(true, '⏳ 同步中...'); MonitorModule.setStatus('⏳ 正在触发服务器重新计算...'); try { const userInfo = await API.getUserInfo(); const studyParams = await MonitorModule.getStudyContext(userInfo); const res = await MonitorModule.request('/jwapp/sys/byshapp/api/grbg/refresh.do', studyParams.xy); const node = MonitorModule.unwrap(res, 'refresh'); const key = node?.ZXJDKEY || node?.refresh || res?.datas?.refresh || res?.ZXJDKEY; if (!key) throw new Error('未获取到同步任务标识 ZXJDKEY'); await MonitorModule.pollProcess(key); state.monitor.isLoaded = false; await MonitorModule.loadData({ force: true }); Toast.fire({ icon: 'success', title: '学业监测数据已同步' }); } catch (e) { Swal.fire('同步失败', e.message, 'error'); MonitorModule.setStatus('❌ 手动同步失败'); } finally { state.monitor.isRefreshing = false; MonitorModule.setBusy(false); } }, pollProcess: (key) => new Promise((resolve, reject) => { let count = 0; const timer = setInterval(async () => { try { count++; const res = await MonitorModule.request('/jwapp/sys/jwpubapp/api/zxjd/queryProcess.do', { ZXJDKEY: key }); const node = MonitorModule.unwrap(res, 'queryProcess'); const data = MonitorModule.rowsOf(node)[0] || node || {}; MonitorModule.setStatus(`⏳ 后台计算中${data.ZXJD ? `:${escapeHTML(data.ZXJD)}` : ''}`); if (data.ZXBZ === '1' || data.ZXBZ === 1 || data.finished === true || data.finished === 'true') { clearInterval(timer); resolve(); } else if (count > 80) { clearInterval(timer); reject(new Error('后台同步超时,请稍后重试')); } } catch (e) { clearInterval(timer); reject(e); } }, 1500); }), exportReport: async () => { try { MonitorModule.setBusy(true); MonitorModule.setStatus('📥 正在生成学业综合表现文件...'); const userInfo = await API.getUserInfo(); const studyParams = await MonitorModule.getStudyContext(userInfo); const blob = await MonitorModule.request('/jwapp/sys/byshapp/api/grbg/exportFanbxkcxx.do', studyParams.xy, { blob: true }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = '学业综合表现.xls'; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1000); Toast.fire({ icon: 'success', title: '学业综合表现已导出' }); MonitorModule.setStatus('✅ 导出完成'); } catch (e) { Swal.fire('导出失败', e.message, 'error'); } finally { MonitorModule.setBusy(false); } }, patchHiddenScores: async () => { if (!state.rawData.some(isHiddenScoreText)) return; if (applyScoreFallbacks() || state.monitor.isLoading) return; await MonitorModule.loadData({ silent: true, render: false }); applyScoreFallbacks(); } }; // ================= 6. 通用导出工具 ================= const MAPPING_GPA_NEW = [ { label: "学年学期", key: "XNXQDM" }, { label: "学号", key: "XH" }, { label: "姓名", key: "XM" }, { label: "性别", key: "XBDM_DISPLAY", fallback: "XBDM" }, { label: "年级", key: "XZNJ_DISPLAY", fallback: "XZNJ" }, { label: "院系", key: "YXDM_DISPLAY", fallback: "YXDM" }, { label: "专业", key: "ZYDM_DISPLAY", fallback: "ZYDM" }, { label: "班级", key: "BJDM_DISPLAY", fallback: "BJMC" }, { label: "班级总人数", key: "BJZRS", fallback: "BY3R3ZRS" }, { label: "专业总人数", key: "ZYZRS" }, { label: "必修课首次成绩平均学分绩点", key: "BXKPJXFJD" }, { label: "必修课首次成绩平均学分绩点班级排名", key: "BXKPJXFJDBJPM" }, { label: "必修课首次成绩平均学分绩点专业排名", key: "BXKPJXFJDZYPM" }, { label: "平均学分绩点", key: "PJXFJD" }, { label: "平均学分绩点班级排名", key: "PJXFJDBJPM" }, { label: "平均学分绩点专业排名", key: "PJXFJDZYPM" }, { label: "操作时间", key: "CZSJ" }, { label: "平均每学期获得学分数", key: "PJMXQHDXF" } ]; function exportToBrowser(data, filename, format) { if (!data || data.length === 0) return Swal.fire('提示', '没有数据可导出', 'info'); if (format === 'xlsx') { const ws = XLSX.utils.json_to_sheet(data); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Sheet1'); XLSX.writeFile(wb, `${filename}.xlsx`); } else { const headers = Object.keys(data[0]); const rows = [headers.join(',')].concat(data.map(r => headers.map(h => { let c = String(r[h] || ''); if (/[",\n]/.test(c)) c = `"${c.replace(/"/g, '""')}"`; return c; }).join(','))); const blob = new Blob([new Uint8Array([0xEF, 0xBB, 0xBF]), rows.join('\n')], { type: 'text/csv;charset=utf-8;' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `${filename}.csv`; link.click(); } } // ================= 6. 官方证明下载模块 ================= const PrintModule = { init: async () => { const container = document.getElementById('print-container'); container.innerHTML = '
🔄 正在获取可打印材料列表...
'; try { await API.getUserInfo(); const res = JSON.parse((await API.req({ method: 'POST', url: 'https://jwxt.whut.edu.cn/jwapp/sys/zmdynxd/zmdy/queryAllZmdy.do', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } })).responseText); if (res.success && res.datas) { PrintModule.render(res.datas, container); state.isPrintLoaded = true; } else container.innerHTML = `
获取列表失败,可能未配置或接口异常。
`; } catch(e) { container.innerHTML = `
加载出错: ${e.message}
`; } }, render: (categories, container) => { let html = ''; categories.forEach(group => { html += `

📁 ${group.CDMC}

`; group.REPORTS.forEach(report => { html += ``; }); html += `
`; }); container.innerHTML = html; }, downloadPDF: async (wid, cdmc) => { try { Swal.fire({ title: '正在生成 PDF', html: '后台渲染中,请稍候...', allowOutsideClick: false, didOpen: () => Swal.showLoading() }); await API.req({ method: 'POST', url: 'https://jwxt.whut.edu.cn/jwapp/sys/zmdynxd/zmdy/printBefore.do', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, data: `wid=${wid}` }); GM_xmlhttpRequest({ method: 'GET', url: `https://jwxt.whut.edu.cn/jwapp/sys/zmdynxd/zmdy/printZm.do?&wid=${wid}`, responseType: 'blob', onload: function(res) { if (res.status === 200 && res.response) { if (res.response.type.includes('html')) return Swal.fire('错误', '系统未能生成有效 PDF', 'error'); const url = URL.createObjectURL(res.response); const a = document.createElement('a'); a.href = url; a.download = `${state.userName}-${state.userId}-${cdmc}.pdf`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); Swal.fire({ icon: 'success', title: '下载成功!', timer: 2000 }); } else Swal.fire('错误', `下载失败,状态码: ${res.status}`, 'error'); }, onerror: function() { Swal.fire('错误', '请求被阻断或网络断开', 'error'); } }); } catch (e) { Swal.fire('错误', e.message, 'error'); } } }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initUI); else initUI(); })();