// ==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 = `
🎓 学业监测
📊 成绩分析
🏆 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 += `| ${c.XNXQDM} | ${c.KCM} | ${formatCreditValue(c._credit)} | ${c._scoreText} |
`); th += `
`;
clone.innerHTML = `WHUT 成绩分析报告
总学分
${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||'-'}
` : ``;
html += `📅 各学期明细
`;
semResponses.forEach(res => {
if (!res.data) html += ``;
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 = ``;
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 = ``;
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 += `
${report.CDMC}
`; });
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();
})();