// ==UserScript== // @name WHUT教务系统小助手 // @namespace http://tampermonkey.net/ // @version 4.0.0.1 // @description 支持学生/全校/教室课表、学业监测、GPA 课程明细、官方证明、一键评教和自动更新。 // @author 毫厘 // @match *://jwxt.whut.edu.cn/jwapp/sys/* // @match *://jwxt.whut.edu.cn/jwmobile/* // @homepageURL https://scriptcat.org/zh-CN/script-show-page/6588 // @supportURL https://scriptcat.org/zh-CN/script-show-page/6588 // @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://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js // @grant GM_xmlhttpRequest // @grant GM_info // @grant GM_openInTab // @noframes // @connect jwxt.whut.edu.cn // @connect home.haoli.site // @run-at document-start // @license MIT // ==/UserScript== // ============================================================================ // 模块:成绩单结构化解析器 // 职责:解析官方成绩单文本,并将课程名称、性质、学分和成绩归一化。 // ============================================================================ (function (root, factory) { 'use strict'; const api = factory(); if (typeof module === 'object' && module.exports) module.exports = api; if (root) root.WHUTTranscriptParser = api; })(typeof globalThis !== 'undefined' ? globalThis : this, function () { 'use strict'; const PARSER_VERSION = '2.0.2'; const HEADER_TOP = ['课程', '课程', '学', '成']; const HEADER_BOTTOM = ['名称', '性质', '分', '绩']; const SCORE_PATTERN = /^(?:100(?:\.0+)?|\d{1,2}(?:\.\d+)?|优秀|良好|中等|及格|不及格|合格|不合格|通过|不通过|免修|优|良|中)$/; function textOf(value) { return String(value == null ? '' : value).normalize('NFKC').trim(); } function normalizeText(value) { return textOf(value) .replace(/[\s\[\]【】()(){}<>《》·•,,。::;;'"“”‘’_\-—–−]/g, '') .toLowerCase(); } function normalizeSemester(value) { const match = textOf(value).match(/((?:19|20)\d{2})\s*[-–—−]\s*((?:19|20)\d{2})\s*[-–—−]\s*([12])/); return match ? `${match[1]}-${match[2]}-${match[3]}` : ''; } function cleanScore(value) { const score = textOf(value).replace(/\s+/g, '').replace(/分$/, ''); return SCORE_PATTERN.test(score) ? score : ''; } function numeric(value) { const number = Number.parseFloat(value); return Number.isFinite(number) ? number : NaN; } function itemCenter(item) { const center = numeric(item && item.centerX); if (Number.isFinite(center)) return center; return numeric(item && item.x) + Math.max(0, numeric(item && item.width) || 0) / 2; } function normalizedRows(rows) { return (Array.isArray(rows) ? rows : []).map((row, rowIndex) => ({ ...row, rowIndex: Number.isInteger(row.rowIndex) ? row.rowIndex : rowIndex, pageNumber: Number(row.pageNumber || 1), y: numeric(row.y) || 0, items: (Array.isArray(row.items) ? row.items : []).map((item, sourceIndex) => ({ ...item, sourceIndex: Number.isInteger(item.sourceIndex) ? item.sourceIndex : sourceIndex, text: textOf(item.text != null ? item.text : item.str), x: numeric(item.x != null ? item.x : item.transform && item.transform[4]) || 0, y: numeric(item.y != null ? item.y : item.transform && item.transform[5]) || 0, width: Math.max(0, numeric(item.width) || 0), height: Math.max(1, Math.abs(numeric(item.height) || 10)), centerX: itemCenter(item) })).filter(item => item.text).sort((a, b) => a.x - b.x) })).sort((a, b) => a.pageNumber - b.pageNumber || b.y - a.y); } function labelItems(row, allowed) { return row.items.filter(item => allowed.includes(textOf(item.text).replace(/\s+/g, ''))); } function sequenceMatches(items, pattern) { if (items.length < pattern.length || items.length % pattern.length !== 0) return false; return items.every((item, index) => textOf(item.text).replace(/\s+/g, '') === pattern[index % pattern.length]); } function detectLayout(pageRows) { const rows = normalizedRows(pageRows); let best = null; for (const topRow of rows) { const top = labelItems(topRow, HEADER_TOP); if (!sequenceMatches(top, HEADER_TOP)) continue; for (const bottomRow of rows) { const gap = topRow.y - bottomRow.y; if (gap <= 0 || gap > 24) continue; const bottom = labelItems(bottomRow, HEADER_BOTTOM); if (!sequenceMatches(bottom, HEADER_BOTTOM) || bottom.length !== top.length) continue; const groupCount = top.length / 4; if (!best || groupCount > best.groupCount) best = { topRow, bottomRow, top, bottom, groupCount }; } } if (!best) return null; const columns = []; for (let index = 0; index < best.groupCount; index++) { const offset = index * 4; const centers = ['name', 'nature', 'credit', 'score'].map((field, fieldIndex) => ({ field, center: (itemCenter(best.top[offset + fieldIndex]) + itemCenter(best.bottom[offset + fieldIndex])) / 2 })); columns.push({ index, fields: Object.fromEntries(centers.map(entry => [entry.field, entry.center])) }); } const period = columns.length > 1 ? columns.slice(1).reduce((sum, column, index) => sum + column.fields.name - columns[index].fields.name, 0) / (columns.length - 1) : Math.max(180, (columns[0].fields.score - columns[0].fields.name) * 1.65); const firstFieldSpan = columns[0].fields.nature - columns[0].fields.name; const tableLeft = columns[0].fields.name - firstFieldSpan; columns.forEach((column, index) => { // 多栏成绩单的相邻课程名称单元格紧贴上一栏成绩单元格。 // 以重复表头周期切栏,避免较长的下一栏课程名中心点落入上一栏。 column.left = tableLeft + period * index; column.right = tableLeft + period * (index + 1); column.fieldCuts = [ (column.fields.name + column.fields.nature) / 2, (column.fields.nature + column.fields.credit) / 2, (column.fields.credit + column.fields.score) / 2 ]; }); return { pageNumber: best.topRow.pageNumber, headerTopY: best.topRow.y, headerBottomY: best.bottomRow.y, columns }; } function fieldFor(column, item) { const center = itemCenter(item); if (center < column.fieldCuts[0]) return 'name'; if (center < column.fieldCuts[1]) return 'nature'; if (center < column.fieldCuts[2]) return 'credit'; return 'score'; } function joinItems(items) { return items.slice().sort((a, b) => a.x - b.x).map(item => item.text).join('').trim(); } function isFooterOrHeader(text) { const compact = normalizeText(text); return !compact || /^(课程名称|课程性质|学分|成绩)+$/.test(compact) || /毕业应取得总学分|已获得总学分|平均学分绩点|打印日期|本科生成绩单/.test(textOf(text)); } function parseRows(inputRows) { const rows = normalizedRows(inputRows); const pages = new Map(); rows.forEach(row => { if (!pages.has(row.pageNumber)) pages.set(row.pageNumber, []); pages.get(row.pageNumber).push(row); }); const records = []; const issues = []; let inheritedSemester = ''; let inheritedGeometry = null; [...pages.entries()].sort((a, b) => a[0] - b[0]).forEach(([pageNumber, pageRows]) => { pageRows.sort((a, b) => b.y - a.y); const detected = detectLayout(pageRows); const layout = detected || (inheritedGeometry ? { ...inheritedGeometry, pageNumber, headerTopY: Number.POSITIVE_INFINITY, headerBottomY: Number.POSITIVE_INFINITY } : null); if (!layout) { issues.push({ type: 'layout-missing', pageNumber }); return; } if (detected) inheritedGeometry = { ...detected, columns: detected.columns.map(column => ({ ...column, fields: { ...column.fields }, fieldCuts: [...column.fieldCuts] })) }; for (const column of layout.columns) { let currentSemester = inheritedSemester; const dataRows = pageRows.filter(row => !detected || row.y < layout.headerBottomY - 0.8); for (const row of dataRows) { const items = row.items.filter(item => { const center = itemCenter(item); return center >= column.left && center < column.right; }); if (!items.length) continue; const semester = normalizeSemester(joinItems(items)); if (semester) { currentSemester = semester; continue; } const fields = { name: [], nature: [], credit: [], score: [] }; items.forEach(item => fields[fieldFor(column, item)].push(item)); const name = joinItems(fields.name); const nature = joinItems(fields.nature); const creditText = joinItems(fields.credit); const scoreText = joinItems(fields.score); const score = cleanScore(scoreText); const credit = numeric(creditText); const combined = joinItems(items); if (isFooterOrHeader(combined) || !name) continue; if (!score && !Number.isFinite(credit) && !nature) continue; const record = { semester: currentSemester, name, nature, credit: Number.isFinite(credit) ? credit : null, score, page: pageNumber, columnIndex: column.index, rowIndex: row.rowIndex, y: row.y, rawText: combined }; records.push(record); if (!score) issues.push({ type: 'score-missing', pageNumber, columnIndex: column.index, rowIndex: row.rowIndex, name }); } inheritedSemester = currentSemester; } }); return { parserVersion: PARSER_VERSION, records, issues }; } function courseValue(course, keys) { for (const key of keys) { const value = course && course[key]; if (value !== undefined && value !== null && value !== '') return value; } return ''; } function matchCourse(course, records) { const name = normalizeText(courseValue(course, ['KCM', '_name', 'courseName', 'name'])); const semester = normalizeSemester(courseValue(course, ['XNXQDM', '_semester', 'semester'])); const credit = numeric(courseValue(course, ['XF', '_credit', 'credit'])); if (!name) return null; const candidates = (Array.isArray(records) ? records : []) .filter(record => normalizeText(record.name) === name) // 学期在两侧均明确时必须一致,避免同名课程跨学期串分。 .filter(record => !semester || !record.semester || record.semester === semester) .map(record => { const semesterMatched = !!semester && !!record.semester && record.semester === semester; const creditMatched = Number.isFinite(credit) && Number.isFinite(record.credit) && Math.abs(record.credit - credit) < 0.001; let rank = 100; if (semesterMatched) rank += 80; if (creditMatched) rank += 30; else if (Number.isFinite(credit) && Number.isFinite(record.credit)) rank -= 15; return { record, score: cleanScore(record.score), rank, semesterMatched, creditMatched }; }).filter(candidate => candidate.score); if (!candidates.length) return null; candidates.sort((a, b) => b.rank - a.rank || a.record.page - b.record.page || a.record.columnIndex - b.record.columnIndex || b.record.y - a.record.y); const bestRank = candidates[0].rank; const strongest = candidates.filter(candidate => candidate.rank === bestRank); const scores = [...new Set(strongest.map(candidate => candidate.score))]; if (scores.length !== 1) { return { conflict: true, confidence: 'low', reason: 'multiple-conflicting-records', candidates: strongest }; } const best = strongest[0]; const confidence = best.semesterMatched && best.creditMatched ? 'high' : 'medium'; return { conflict: false, score: best.score, confidence, reason: confidence === 'high' ? 'name-semester-credit-exact' : (best.semesterMatched ? 'name-semester-exact' : 'name-only'), record: best.record, candidates }; } return { version: PARSER_VERSION, normalizeText, normalizeSemester, cleanScore, isScoreText: value => !!cleanScore(value), detectLayout, parseRows, matchCourse }; }); (function() { 'use strict'; // 教务系统会在 iframe 中打开“成绩查询”等业务页面。 // 仅允许顶层页面创建应用 UI,避免每进入一个业务页就多出一个悬浮球。 if (window.top !== window.self) return; const TRANSCRIPT_PARSER = globalThis.WHUTTranscriptParser || null; // ======================================================================== // 模块:设计系统 // 职责:集中维护规范令牌、组件样式、业务视图样式与响应式规则。 // ======================================================================== // 导出画布不能解析 Shadow DOM 变量,因此只保留其实际需要的浅色调色板。 const EXPORT_THEME = Object.freeze({ primary: '#1e88e5', deepBlue: '#1565c0', bg: 'rgb(239, 241, 245)', border: 'rgb(211, 215, 225)', textMain: 'rgb(76, 79, 105)', textSub: 'rgb(108, 111, 133)' }); const PANEL_LAYOUT = Object.freeze({ width: 680, heightRatio: 0.82, viewportMargin: 12 }); const ICON_PATHS = Object.freeze({ academic: '', chart: '', trophy: '', printer: '', clipboard: '', settings: '', refresh: '', close: '', search: '', download: '', image: '', table: '', rocket: '', calendar: '', pin: '', folder: '', themeAuto: '', sun: '', moon: '', check: '', chevronDown: '', chevronRight: '', clock: '', minus: '', warning: '', help: '', info: '' }); const iconSVG = (name, className = 'uikit-icon', strokeWidth = 2) => ``; const STYLE_MODULES = Object.freeze({ foundation: ` :host { all: initial; color-scheme: light dark; --uikit-brand: #1e88e5; --uikit-brand-rgb: 30 136 229; --uikit-brand-2: #1565c0; --uikit-brand-hover: #1565c0; --uikit-brand-soft: rgb(30 136 229 / 12%); --uikit-brand-soft-hover: rgb(30 136 229 / 18%); --uikit-brand-text: #1565c0; --uikit-focus-ring: rgb(30 136 229 / 28%); --uikit-brand-outline: rgb(30 136 229 / 38%); --uikit-success: rgb(46 132 104); --uikit-success-soft: rgb(46 132 104 / 14%); --uikit-warning: rgb(183 103 11); --uikit-warning-soft: rgb(183 103 11 / 15%); --uikit-danger: rgb(190 51 64); --uikit-danger-hover: rgb(163 38 51); --uikit-danger-soft: rgb(190 51 64 / 13%); --uikit-bg: rgb(239 241 245); --uikit-bg-muted: rgb(230 233 239); --uikit-bg-elevated: rgb(248 249 252); --uikit-surface-hover: rgb(222 226 235); --uikit-surface-active: rgb(214 220 231); --uikit-border: rgb(205 214 244); --uikit-border-strong: rgb(188 192 204); --uikit-text: rgb(76 79 105); --uikit-text-muted: rgb(108 111 133); --uikit-text-faint: rgb(93 96 118); --uikit-overlay: rgb(30 30 46 / 38%); --uikit-radius-xs: 4px; --uikit-radius-sm: 6px; --uikit-radius-md: 10px; --uikit-radius-lg: 16px; --uikit-radius-pill: 999px; --uikit-shadow-sm: 0 1px 3px rgb(0 0 0 / 5%); --uikit-shadow-md: 0 10px 25px -5px rgb(30 136 229 / 15%); --uikit-shadow-lg: 0 20px 50px rgb(30 30 46 / 16%); --uikit-duration-fast: 160ms; --uikit-duration-interaction: 300ms; --uikit-duration-normal: 280ms; --uikit-duration-slow: 520ms; --uikit-theme-duration: 800ms; --uikit-ease-standard: cubic-bezier(.2, 0, 0, 1); --uikit-ease-emphasized: cubic-bezier(.34, 1.56, .64, 1); --uikit-ease-out-expo: cubic-bezier(.19, 1, .22, 1); --uikit-ease-out-back: cubic-bezier(.34, 1.56, .64, 1.1); --uikit-theme-ease: cubic-bezier(.19, 1, .22, 1); --uikit-space-1: 4px; --uikit-space-2: 8px; --uikit-space-3: 12px; --uikit-space-4: 16px; --uikit-space-6: 24px; --uikit-text-2xs: 10px; --uikit-text-xs: 12px; --uikit-text-sm: 13px; --uikit-text-base: 14px; --uikit-text-lg: 16px; --uikit-line-tight: 1.35; --uikit-line-normal: 1.5; --uikit-line-relaxed: 1.6; --uikit-control-height-sm: 30px; --uikit-control-height-md: 36px; --uikit-row-alt: rgb(235 238 243); --uikit-scrollbar-size: 9px; --uikit-scrollbar-thumb: rgb(108 111 133 / 38%); --uikit-scrollbar-thumb-hover: rgb(76 79 105 / 60%); --uikit-scrollbar-track: transparent; --uikit-z-panel: 10; --uikit-z-fab: 20; --uikit-z-menu: 90; --uikit-z-dialog: 100; --uikit-z-toast: 110; font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", Arial, sans-serif; font-size: var(--uikit-text-base); line-height: var(--uikit-line-normal); color: var(--uikit-text); -webkit-text-size-adjust: 100%; } :host([data-theme='light']) { color-scheme: light; } :host([data-theme='dark']) { color-scheme: dark; --uikit-brand: #87b0f9; --uikit-brand-rgb: 135 176 249; --uikit-brand-2: #74a5ec; --uikit-brand-hover: #99befc; --uikit-brand-soft: rgb(135 176 249 / 16%); --uikit-brand-soft-hover: rgb(135 176 249 / 24%); --uikit-brand-text: rgb(198 208 245); --uikit-focus-ring: rgb(135 176 249 / 34%); --uikit-brand-outline: rgb(135 176 249 / 48%); --uikit-bg: rgb(30 30 46); --uikit-bg-muted: rgb(24 24 37); --uikit-bg-elevated: rgb(36 36 58); --uikit-surface-hover: rgb(43 44 67); --uikit-surface-active: rgb(51 53 78); --uikit-border: rgb(54 58 79); --uikit-border-strong: rgb(75 82 112); --uikit-text: rgb(198 208 245); --uikit-text-muted: rgb(161 168 201); --uikit-text-faint: rgb(125 133 166); --uikit-overlay: rgb(0 0 0 / 58%); --uikit-success-soft: rgb(64 160 120 / 20%); --uikit-warning-soft: rgb(223 142 29 / 20%); --uikit-danger-hover: rgb(231 99 110); --uikit-danger-soft: rgb(210 77 87 / 20%); --uikit-row-alt: rgb(28 28 43); --uikit-scrollbar-thumb: rgb(161 168 201 / 34%); --uikit-scrollbar-thumb-hover: rgb(198 208 245 / 56%); --uikit-shadow-sm: 0 1px 3px rgb(0 0 0 / 28%); --uikit-shadow-md: 0 10px 25px -5px rgb(0 0 0 / 46%); --uikit-shadow-lg: 0 20px 50px rgb(0 0 0 / 64%); } *, *::before, *::after { box-sizing: border-box; } [hidden] { display: none !important; } [aria-busy='true'], .is-loading { cursor: progress !important; } button, input, select, textarea { color-scheme: inherit; font: inherit; } button { -webkit-tap-highlight-color: transparent; } .uikit-icon { width: 1em; height: 1em; display: inline-block; flex: 0 0 auto; color: currentColor; vertical-align: -.125em; } .uikit-root { position: fixed; inset: 0; z-index: 2147483647; pointer-events: none; } .uikit-layer { position: fixed; inset: 0; pointer-events: none; } .uikit-layer > * { pointer-events: auto; } .uikit-layer--panel { z-index: var(--uikit-z-panel); } .uikit-layer--menu { z-index: var(--uikit-z-menu); } .uikit-layer--dialog { z-index: var(--uikit-z-dialog); } .uikit-layer--toast { z-index: var(--uikit-z-toast); } #whut-helper-panel:focus { outline: none; } #whut-helper-panel ::selection { color: white; background: var(--uikit-brand); } .whut-sr-only { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; } #whut-helper-panel :focus-visible, #whut-fab:focus-visible, .uikit-dialog-backdrop :focus-visible { outline: 3px solid var(--uikit-focus-ring); outline-offset: 2px; } #whut-helper-panel input:focus-visible, #whut-helper-panel select:focus-visible, #whut-helper-panel textarea:focus-visible, .uikit-dialog-backdrop input:focus-visible, .uikit-dialog-backdrop select:focus-visible, .uikit-dialog-backdrop textarea:focus-visible { outline: none; } .uikit-root, .uikit-root * { scrollbar-width: thin; scrollbar-color: var(--uikit-scrollbar-thumb) var(--uikit-scrollbar-track); } .uikit-root::-webkit-scrollbar, .uikit-root *::-webkit-scrollbar { width: var(--uikit-scrollbar-size); height: var(--uikit-scrollbar-size); } .uikit-root::-webkit-scrollbar-track, .uikit-root *::-webkit-scrollbar-track { background: var(--uikit-scrollbar-track); } .uikit-root::-webkit-scrollbar-thumb, .uikit-root *::-webkit-scrollbar-thumb { min-width: 28px; min-height: 28px; background: var(--uikit-scrollbar-thumb); border: 2px solid transparent; border-radius: var(--uikit-radius-pill); background-clip: content-box; } .uikit-root::-webkit-scrollbar-thumb:hover, .uikit-root *::-webkit-scrollbar-thumb:hover { background-color: var(--uikit-scrollbar-thumb-hover); } .uikit-root::-webkit-scrollbar-corner, .uikit-root *::-webkit-scrollbar-corner { background: transparent; } .uikit-hover-lifted { transform: translateY(-1px); } .btn-modern.uikit-hover-lifted { transform: translateY(-2px); } .uikit-hover-bridge { position: fixed; z-index: calc(var(--uikit-z-toast) + 1); display: none; pointer-events: auto; background: transparent; } `, surfaces: ` .course-item, .export-card, .gpa-card, .eval-config-card, .eval-summary, #pj-list-area > .pj-group, .eval-history-loader, .monitor-overview, .monitor-groups { background: var(--uikit-bg-elevated); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); box-shadow: var(--uikit-shadow-sm); } .course-item, .gpa-card, #pj-list-area > .pj-group, .eval-history-loader { transition: background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), border-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), box-shadow var(--uikit-duration-interaction) var(--uikit-ease-out-expo), transform var(--uikit-duration-interaction) var(--uikit-ease-out-back); } .gpa-card:hover, .gpa-card.uikit-hover-lifted, #pj-list-area > .pj-group:hover, #pj-list-area > .pj-group.uikit-hover-lifted, .eval-history-loader:hover, .eval-history-loader.uikit-hover-lifted { border-color: var(--uikit-brand-outline); box-shadow: 0 7px 18px rgb(var(--uikit-brand-rgb) / 9%); } `, panelShell: ` #whut-fab { position: fixed; right: max(30px, env(safe-area-inset-right)); bottom: max(30px, env(safe-area-inset-bottom)); width: 56px; height: 56px; z-index: var(--uikit-z-fab); display: inline-flex; align-items: center; justify-content: center; color: #fff; font-size: 24px; cursor: grab; touch-action: none; user-select: none; background: linear-gradient(135deg, var(--uikit-brand-2), var(--uikit-brand)); border: 0; border-radius: 50%; box-shadow: var(--uikit-shadow-md); backface-visibility: hidden; will-change: transform, background-color, box-shadow; transition: transform var(--uikit-duration-interaction) var(--uikit-ease-out-back), box-shadow var(--uikit-duration-interaction) var(--uikit-ease-out-expo), filter var(--uikit-duration-interaction) var(--uikit-ease-out-expo); } #whut-fab:hover { transform: scale(1.1); box-shadow: 0 14px 30px rgb(var(--uikit-brand-rgb) / 24%); filter: brightness(1.025); } #whut-fab:active, #whut-fab.is-dragging { cursor: grabbing; transform: scale(.95); transition-duration: 100ms; } #whut-fab.is-dragging { box-shadow: 0 18px 38px rgb(var(--uikit-brand-rgb) / 30%); } .whut-fab-glyph { font-size: 24px; line-height: 1; } .whut-fab-pulse { position: absolute; right: 5px; top: 5px; width: 9px; height: 9px; border-radius: 50%; background: var(--uikit-success); box-shadow: 0 0 0 3px var(--uikit-bg); } #whut-helper-panel { container: whut-panel / inline-size; display: none; position: fixed; width: ${PANEL_LAYOUT.width}px; min-width: 440px; height: 82vh; min-height: 480px; max-width: calc(100vw - ${PANEL_LAYOUT.viewportMargin * 2}px); max-height: calc(100dvh - ${PANEL_LAYOUT.viewportMargin * 2}px); z-index: var(--uikit-z-panel); flex-direction: column; overflow: hidden; color: var(--uikit-text); font-size: var(--uikit-text-base); line-height: var(--uikit-line-normal); background: var(--uikit-bg); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-lg); box-shadow: var(--uikit-shadow-lg); transform-origin: right bottom; transition: background-color var(--uikit-theme-duration) var(--uikit-theme-ease), color var(--uikit-theme-duration) var(--uikit-theme-ease), border-color var(--uikit-theme-duration) var(--uikit-theme-ease), box-shadow var(--uikit-theme-duration) var(--uikit-theme-ease); } #whut-helper-panel.open { display: flex; animation: uikitPanelIn var(--uikit-duration-normal) var(--uikit-ease-emphasized) both; } #whut-helper-panel.is-dragging { opacity: .94; box-shadow: var(--uikit-shadow-lg); } .helper-header { flex: 0 0 auto; padding: 0; cursor: grab; user-select: none; touch-action: none; background: var(--uikit-bg); border-bottom: 1px solid var(--uikit-border); } .helper-header:active { cursor: grabbing; } .header-top { min-height: 58px; margin: 0; padding: var(--uikit-space-2) 10px var(--uikit-space-2) var(--uikit-space-4); display: flex; align-items: center; justify-content: space-between; gap: var(--uikit-space-3); pointer-events: none; background: linear-gradient(135deg, var(--uikit-bg-muted), var(--uikit-bg)); border-bottom: 1px solid var(--uikit-border); } .header-title { min-width: 0; display: flex; align-items: center; gap: 10px; color: var(--uikit-text); } .header-logo { width: 36px; height: 36px; flex: 0 0 36px; display: inline-flex; align-items: center; justify-content: center; color: var(--uikit-brand-text); font-size: 19px; background: var(--uikit-brand-soft); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); box-shadow: var(--uikit-shadow-sm); } .header-brand-copy { min-width: 0; display: flex; flex-direction: column; gap: 2px; } .header-name { overflow: hidden; color: var(--uikit-text); font-size: var(--uikit-text-lg); font-weight: 800; letter-spacing: .01em; text-overflow: ellipsis; white-space: nowrap; } .header-meta { display: flex; align-items: center; gap: 5px; pointer-events: auto; touch-action: auto; } .header-version, .header-author { min-height: 18px; padding: 1px 6px; display: inline-flex; align-items: center; color: var(--uikit-brand-text); font: inherit; font-size: 9px; font-weight: 800; line-height: 1; text-decoration: none; cursor: pointer; background: var(--uikit-brand-soft); border: 0; border-radius: var(--uikit-radius-pill); transition: color var(--uikit-duration-fast), background-color var(--uikit-duration-fast), box-shadow var(--uikit-duration-fast), transform var(--uikit-duration-fast); } .header-author { color: var(--uikit-text-muted); background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); } .header-version:hover, .header-version.uikit-hover-lifted, .header-author:hover, .header-author.uikit-hover-lifted { color: var(--uikit-brand-text); background: var(--uikit-brand-soft-hover); } .header-version:active, .header-author:active { transform: translateY(0); } .header-actions { flex: 0 0 auto; display: flex; align-items: center; gap: 5px; pointer-events: auto; touch-action: auto; } .header-action { min-width: 32px; height: 32px; padding: 0 8px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); line-height: 1; font-weight: 750; cursor: pointer; background: transparent; border: 1px solid transparent; border-radius: var(--uikit-radius-pill); box-shadow: none; backface-visibility: hidden; will-change: transform, background-color, box-shadow; transition: background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), border-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), box-shadow var(--uikit-duration-interaction) var(--uikit-ease-out-expo), transform var(--uikit-duration-interaction) var(--uikit-ease-out-back); } .header-action:hover { color: var(--uikit-text); background: color-mix(in srgb, currentColor 7%, transparent); transform: scale(1.1); box-shadow: 0 4px 12px rgb(0 0 0 / 9%), inset 0 0 0 1px color-mix(in srgb, currentColor 8%, transparent); } .header-action:active { background: color-mix(in srgb, currentColor 11%, transparent); transform: scale(.95); box-shadow: none; transition-duration: 100ms; } .header-theme, .header-diagnostics, .header-help { width: 32px; padding: 0; } .header-theme { font-size: 18px; } .header-diagnostics, .header-help { font-size: 15px; } .header-theme[data-theme-mode='auto'] { color: var(--uikit-text-muted); background: color-mix(in srgb, var(--uikit-text) 7%, transparent); border-color: var(--uikit-border); } .header-theme[data-theme-mode='light'] { color: var(--uikit-warning); background: var(--uikit-warning-soft); border-color: color-mix(in srgb, var(--uikit-warning) 38%, transparent); } .header-theme[data-theme-mode='dark'] { color: var(--uikit-brand-text); background: var(--uikit-brand-soft); border-color: var(--uikit-brand-outline); } .header-update { min-width: 88px; border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-sm); background: var(--uikit-bg); } .header-update:hover { color: var(--uikit-brand-text); border-color: var(--uikit-brand); background: var(--uikit-brand-soft-hover); } .header-update.has-update { color: var(--uikit-success); background: var(--uikit-success-soft); border-color: color-mix(in srgb, var(--uikit-success) 38%, transparent); } .header-update.is-checking > span:first-child { display: inline-block; animation: uikitSpin .8s linear infinite; } .header-close { width: 32px; padding: 0; color: var(--uikit-danger); font-size: 18px; font-weight: 500; } .header-close:hover { color: var(--uikit-danger); background: var(--uikit-danger-soft); border-color: color-mix(in srgb, var(--uikit-danger) 35%, transparent); transform: scale(1.1); } `, navigation: ` .helper-tabs { flex: 0 0 auto; display: flex; gap: 0; padding: 0; overflow-x: auto; overflow-y: hidden; pointer-events: auto; touch-action: pan-x; overscroll-behavior-inline: contain; scroll-snap-type: x proximity; background: var(--uikit-bg); } .helper-tab { position: relative; min-width: 104px; min-height: 44px; flex: 1 0 auto; padding: var(--uikit-space-2); display: inline-flex; align-items: center; justify-content: center; gap: var(--uikit-space-1); color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); font-weight: 700; white-space: nowrap; cursor: pointer; background: transparent; border: 0; scroll-snap-align: center; will-change: transform; transition: color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), transform var(--uikit-duration-interaction) var(--uikit-ease-out-back); } .helper-tab:hover, .helper-tab.uikit-hover-lifted { color: var(--uikit-text); background: color-mix(in srgb, currentColor 5%, transparent); } .helper-tab:active { transform: translateY(0) scale(.97); transition-duration: 100ms; } .helper-tab.active { color: var(--uikit-brand); } .helper-tab.active::after { content: ""; position: absolute; right: 8px; bottom: 0; left: 8px; height: 3px; border-radius: 3px 3px 0 0; background: var(--uikit-brand); } .helper-tab-icon { font-size: 14px; line-height: 1; } .helper-tab-label-short { display: none; } .tab-view { display: none; flex: 1 1 auto; min-height: 0; flex-direction: column; overflow: hidden; } .tab-view.active { display: flex; animation: uikitViewIn var(--uikit-duration-fast) var(--uikit-ease-standard) both; } `, controls: ` .helper-toolbar { flex: 0 0 auto; padding: var(--uikit-space-3) var(--uikit-space-4); background: var(--uikit-bg); border-bottom: 1px solid var(--uikit-border); } .analysis-disclosure-bar { display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 8px; padding: 7px var(--uikit-space-4); border-bottom: 1px solid var(--uikit-border); color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); } .summary-tags { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; min-width: 0; } .summary-tags .tag { margin: 0; max-width: 100%; overflow-wrap: anywhere; } .analysis-disclosure-bar .summary-tags { flex: 1; } .analysis-search-row { flex: 0 0 auto; padding: 10px var(--uikit-space-4) 0; background: var(--uikit-bg); } .analysis-search-row .search-box { grid-template-columns: minmax(0, 1fr); margin-bottom: 0; } .disclosure-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 6px; } .analysis-toolbar .search-box { grid-template-columns: minmax(0, 1fr); } button.footer-value { padding: 2px 4px; border: 0; border-radius: var(--uikit-radius-sm); background: transparent; font-family: inherit; cursor: pointer; } button.footer-value:hover { background: var(--uikit-surface-hover); } button.footer-value:focus-visible { outline: 2px solid var(--uikit-brand); outline-offset: 2px; } .disclosure-toggle { flex: 0 0 auto; } .disclosure-toggle[aria-expanded='false'] .uikit-icon { transform: rotate(-90deg); } .search-box { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--uikit-space-2); margin-bottom: 9px; } .filter-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: center; gap: var(--uikit-space-2); margin-bottom: 9px; } .action-row { display: flex; gap: var(--uikit-space-2); } .action-row > .btn-modern, .btn-flex { flex: 1 1 0; } .search-input, .eval-field textarea, .eval-field input, .uikit-dialog .gpa-sem-input, .ui-select-trigger { width: 100%; min-height: var(--uikit-control-height-md); color: var(--uikit-text); background: var(--uikit-bg); border: 1px solid var(--uikit-border-strong); border-radius: var(--uikit-radius-sm); outline: none; transition: border-color var(--uikit-duration-fast), box-shadow var(--uikit-duration-fast), background var(--uikit-duration-fast); } .ui-search { position: relative; min-width: 0; } .search-input { min-width: 0; height: var(--uikit-control-height-md); padding: 7px 36px; font-size: var(--uikit-text-sm); } .search-input::-webkit-search-cancel-button { appearance: none; } .ui-search-icon, .ui-search-clear { position: absolute; top: 50%; z-index: 1; display: inline-flex; align-items: center; justify-content: center; color: var(--uikit-text-faint); transform: translateY(-50%); } .ui-search-icon { left: 11px; width: 18px; height: 18px; pointer-events: none; } .ui-search-clear { right: 6px; width: 28px; height: 28px; padding: 0; cursor: pointer; background: transparent; border: 0; border-radius: var(--uikit-radius-pill); transition: color var(--uikit-duration-fast), background-color var(--uikit-duration-fast), opacity var(--uikit-duration-fast); } .ui-search-clear:hover { color: var(--uikit-text); background: var(--uikit-surface-hover); } .ui-search-clear[hidden] { display: none; } .search-input::placeholder, .eval-field textarea::placeholder { color: var(--uikit-text-faint); } .eval-field input { padding: var(--uikit-space-2) 10px; font-size: var(--uikit-text-xs); } .search-input:focus, .ui-select-trigger:focus-visible, .eval-field textarea:focus, .eval-field input:focus, .uikit-dialog .gpa-sem-input:focus { border-color: var(--uikit-brand); box-shadow: 0 0 0 3px var(--uikit-focus-ring); } .search-input:disabled, .eval-field textarea:disabled, .eval-field input:disabled, .uikit-dialog .gpa-sem-input:disabled { opacity: .56; cursor: not-allowed; } .ui-select { position: relative; width: 100%; min-width: 0; } .ui-select-native { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; clip-path: inset(50%) !important; white-space: nowrap !important; border: 0 !important; opacity: 0 !important; pointer-events: none !important; } .ui-select-trigger { position: relative; height: var(--uikit-control-height-md); padding: 7px 34px 7px 11px; display: flex; align-items: center; gap: var(--uikit-space-2); color: var(--uikit-text); font-size: var(--uikit-text-xs); font-weight: 650; line-height: 1; text-align: left; cursor: pointer; } .ui-select-value { min-width: 0; overflow: hidden; flex: 1; text-overflow: ellipsis; white-space: nowrap; } .ui-select-chevron { position: absolute; top: 50%; right: 11px; color: var(--uikit-text-faint); pointer-events: none; transform: translateY(-50%); transition: transform var(--uikit-duration-fast), color var(--uikit-duration-fast); } .ui-select.is-open .ui-select-chevron { transform: translateY(-50%) rotate(180deg); } .ui-select.is-open .ui-select-trigger { color: var(--uikit-brand-text); border-color: var(--uikit-brand); box-shadow: 0 0 0 3px var(--uikit-focus-ring); } .ui-select-trigger:disabled { opacity: .56; cursor: not-allowed; } .ui-select-menu { display: none; z-index: var(--uikit-z-menu); max-height: min(320px, calc(100vh - 24px)); padding: 5px; overflow-y: auto; color: var(--uikit-text); background: var(--uikit-bg-elevated); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); box-shadow: var(--uikit-shadow-lg); transform-origin: top center; } .ui-select-menu { position: fixed; } .ui-select-menu.is-open { display: grid; gap: 2px; animation: uikitOverlayIn var(--uikit-duration-normal) var(--uikit-ease-standard) both; } .ui-select-option { width: 100%; min-height: 34px; padding: 7px 9px; display: flex; align-items: center; gap: var(--uikit-space-2); color: var(--uikit-text); font-size: var(--uikit-text-xs); font-weight: 650; line-height: var(--uikit-line-tight); text-align: left; cursor: pointer; background: transparent; border: 0; border-radius: var(--uikit-radius-sm); backface-visibility: hidden; will-change: transform, background-color; transition: background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), transform var(--uikit-duration-interaction) var(--uikit-ease-out-back); } .ui-select-option:hover, .ui-select-option.is-active, .ui-select-option.uikit-hover-lifted { color: var(--uikit-text); background: var(--uikit-surface-hover); } .ui-select-option:active { transform: scale(.98); transition-duration: 100ms; } .ui-select-option[aria-selected='true'] { color: var(--uikit-brand-text); font-weight: 800; background: var(--uikit-brand-soft); } .ui-select-option:disabled { opacity: .42; cursor: not-allowed; transform: none; } .ui-select-option-check { width: 17px; flex: 0 0 17px; color: transparent; font-size: var(--uikit-text-xs); font-weight: 900; text-align: center; } .ui-select-option[aria-selected='true'] .ui-select-option-check { color: var(--uikit-brand); } .ui-select-option-label { min-width: 0; overflow: hidden; flex: 1; text-overflow: ellipsis; white-space: nowrap; } .ui-select-actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; padding: 2px 2px 7px; margin-bottom: 3px; border-bottom: 1px solid var(--uikit-border); } .ui-select-action { min-height: var(--uikit-control-height-sm); padding: 4px 6px; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); font-weight: 750; cursor: pointer; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-sm); transition: color var(--uikit-duration-fast), background-color var(--uikit-duration-fast), border-color var(--uikit-duration-fast), transform var(--uikit-duration-fast); } .ui-select-action:hover, .ui-select-action.uikit-hover-lifted { color: var(--uikit-text); background: var(--uikit-surface-hover); border-color: var(--uikit-border-strong); } .ui-select-action:active { background: var(--uikit-surface-active); transform: scale(.98); } .pj-grp-cb, .pj-item-cb { width: 16px; height: 16px; margin: 0; accent-color: var(--uikit-brand); cursor: pointer; transition: transform var(--uikit-duration-fast) var(--uikit-ease-emphasized), opacity var(--uikit-duration-fast) var(--uikit-ease-standard); } .pj-grp-cb:hover, .pj-item-cb:not(:disabled):hover { transform: scale(1.1); } .pj-grp-cb:active, .pj-item-cb:not(:disabled):active { transform: scale(.92); } .btn-modern, .uikit-dialog .btn-modern { --uikit-button-bg: var(--uikit-brand); --uikit-button-hover-bg: var(--uikit-brand-hover); position: relative; min-height: var(--uikit-control-height-md); padding: var(--uikit-space-2) var(--uikit-space-3); display: inline-flex; align-items: center; justify-content: center; gap: var(--uikit-space-2); overflow: hidden; color: #fff; font-size: var(--uikit-text-xs); font-weight: 750; line-height: 1.2; cursor: pointer; background-color: var(--uikit-button-bg); border: 1px solid transparent; border-radius: var(--uikit-radius-sm); box-shadow: var(--uikit-shadow-sm); backface-visibility: hidden; will-change: transform, background-color, box-shadow; transition: transform var(--uikit-duration-interaction) var(--uikit-ease-out-back), box-shadow var(--uikit-duration-interaction) var(--uikit-ease-out-expo), opacity var(--uikit-duration-interaction) var(--uikit-ease-out-expo), color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), border-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo); } .btn-modern:hover:not(:disabled):not(.is-loading), .btn-modern.uikit-hover-lifted, .uikit-dialog .btn-modern:hover:not(:disabled):not(.is-loading), .uikit-dialog .btn-modern.uikit-hover-lifted { background-color: var(--uikit-button-hover-bg); box-shadow: 0 4px 10px rgb(0 0 0 / 11%); } .btn-modern:active:not(:disabled):not(.is-loading), .uikit-dialog .btn-modern:active:not(:disabled):not(.is-loading) { transform: translateY(-1px); box-shadow: var(--uikit-shadow-sm); transition-duration: 100ms; } .btn-modern:disabled, .btn-modern.is-loading, .uikit-dialog .btn-modern:disabled { opacity: .52; cursor: not-allowed; transform: none; box-shadow: none; filter: none; } .btn-outline, .uikit-dialog .btn-outline { --uikit-button-bg: transparent; --uikit-button-hover-bg: color-mix(in srgb, var(--uikit-text) 7%, transparent); color: var(--uikit-text); border-color: var(--uikit-border-strong); box-shadow: none; } .btn-outline:hover:not(:disabled):not(.is-loading), .uikit-dialog .btn-outline:hover:not(:disabled):not(.is-loading) { border-color: var(--uikit-border-strong); box-shadow: none; } .btn-success { --uikit-button-bg: var(--uikit-success); --uikit-button-hover-bg: color-mix(in srgb, var(--uikit-success) 88%, black); } .btn-danger { --uikit-button-bg: var(--uikit-danger); --uikit-button-hover-bg: var(--uikit-danger-hover); } .btn-spinner { width: 14px; height: 14px; flex: 0 0 14px; border: 2px solid rgb(255 255 255 / 46%); border-top-color: currentColor; border-radius: 50%; animation: uikitSpin .72s linear infinite; } .btn-outline .btn-spinner { border-color: var(--uikit-border); border-top-color: var(--uikit-brand); } .btn-compact { min-height: 28px; padding: 4px 9px; font-size: var(--uikit-text-xs); } .btn-block { width: 100%; } .field-label { display: block; margin: 0 0 6px; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); font-weight: 700; } `, feedbackAndLayout: ` .helper-content, .monitor-container, .gpa-container, .export-container { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: var(--uikit-space-4); background: var(--uikit-bg-muted); overscroll-behavior: contain; } .ui-state { min-height: 180px; padding: var(--uikit-space-6) var(--uikit-space-3); display: grid; place-items: center; align-content: center; color: var(--uikit-text-muted); text-align: center; } .ui-state.compact { min-height: 92px; padding: 18px 12px; } .ui-state-icon { width: 44px; height: 44px; margin-bottom: var(--uikit-space-3); display: inline-flex; align-items: center; justify-content: center; color: var(--uikit-brand); font-size: 21px; background: var(--uikit-brand-soft); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); } .ui-state-spinner { width: 21px; height: 21px; border: 2px solid var(--uikit-border); border-top-color: var(--uikit-brand); border-radius: 50%; animation: uikitSpin .75s linear infinite; } .ui-state-title { color: var(--uikit-text); font-size: var(--uikit-text-sm); font-weight: 800; } .ui-state-detail { max-width: 430px; margin-top: 5px; color: var(--uikit-text-faint); font-size: var(--uikit-text-xs); line-height: var(--uikit-line-relaxed); } .ui-state.error .ui-state-icon { color: var(--uikit-danger); background: var(--uikit-danger-soft); border-color: color-mix(in srgb, var(--uikit-danger) 28%, transparent); } .ui-state.warning .ui-state-icon { color: var(--uikit-warning); background: var(--uikit-warning-soft); border-color: color-mix(in srgb, var(--uikit-warning) 28%, transparent); } .ui-state.empty .ui-state-icon { color: var(--uikit-text-muted); background: var(--uikit-bg); border-color: var(--uikit-border); } .text-muted { color: var(--uikit-text-faint) !important; } .text-success { color: var(--uikit-success) !important; } .text-danger { color: var(--uikit-danger) !important; } .section-heading { margin: 18px 2px 10px; display: flex; align-items: center; gap: var(--uikit-space-2); color: var(--uikit-text); font-size: var(--uikit-text-sm); font-weight: 800; } .section-heading::after { content: ""; flex: 1; height: 1px; background: var(--uikit-border); } `, gradeAnalysis: ` .course-item { position: relative; margin-bottom: 9px; overflow: hidden; } .course-item::before { content: ""; position: absolute; left: 0; top: 14px; bottom: 14px; width: 3px; border-radius: 0 3px 3px 0; background: transparent; transition: background-color var(--uikit-duration-fast) var(--uikit-ease-standard); } .course-item:hover, .course-item.uikit-hover-lifted { border-color: var(--uikit-border-strong); box-shadow: 0 6px 14px rgb(0 0 0 / 7%); } .course-item:focus-within { border-color: var(--uikit-brand); transform: translateY(-1px); box-shadow: 0 0 0 3px var(--uikit-focus-ring); } .course-item:focus-within::before { background: var(--uikit-brand); } .course-summary { width: 100%; min-height: 78px; padding: var(--uikit-space-3) 14px; display: flex; align-items: center; justify-content: space-between; gap: 14px; color: inherit; text-align: left; cursor: pointer; background: var(--uikit-bg-elevated); border: 0; transition: background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo); } .course-summary:hover { background: var(--uikit-surface-hover); } .course-main { flex: 1 1 auto; min-width: 0; } .course-title { display: block; margin-bottom: var(--uikit-space-2); overflow: hidden; color: var(--uikit-text); font-size: var(--uikit-text-sm); font-weight: 800; text-overflow: ellipsis; white-space: nowrap; } .course-tags { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; font-size: 10px; } .tag { min-height: 23px; padding: 3px 8px; display: inline-flex; align-items: center; color: var(--uikit-text-muted); font-weight: 650; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-pill); } .tag-credit { color: var(--uikit-brand-text); background: var(--uikit-brand-soft); border-color: transparent; } .tag-compulsory { color: var(--uikit-warning); font-weight: 750; background: var(--uikit-warning-soft); border-color: transparent; } .tag-source-monitor { color: var(--uikit-success); background: var(--uikit-success-soft); border-color: transparent; } .tag-source-transcript { color: var(--uikit-brand-text); background: var(--uikit-brand-soft); border-color: transparent; } .course-score { flex: 0 0 auto; min-width: 72px; color: var(--uikit-brand); font-size: 18px; font-weight: 850; text-align: right; font-variant-numeric: tabular-nums; } .score-high { color: var(--uikit-success); } .score-med { color: var(--uikit-warning); } .score-low { color: var(--uikit-danger); } .score-none { color: var(--uikit-text-faint); font-size: var(--uikit-text-sm); font-weight: 700; } .helper-footer { flex: 0 0 auto; padding: 10px 14px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--uikit-space-2); background: var(--uikit-bg-elevated); border-top: 1px solid var(--uikit-border); } .footer-metric { min-width: 0; padding: var(--uikit-space-2) 10px; display: flex; align-items: center; justify-content: space-between; gap: 7px; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-sm); } .footer-label { overflow: hidden; color: var(--uikit-text-faint); font-size: 10px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } .footer-value { display: inline-flex; align-items: baseline; gap: 3px; color: var(--uikit-text); font-size: var(--uikit-text-base); font-weight: 900; font-variant-numeric: tabular-nums; } .footer-value small { color: var(--uikit-text-faint); font-size: 9px; font-weight: 700; } .footer-metric.is-deep .footer-value { color: var(--uikit-brand-text); font-size: 17px; } .footer-metric.is-primary .footer-value { color: var(--uikit-brand); font-size: 17px; } .detail-panel { display: none; padding: 15px var(--uikit-space-4) var(--uikit-space-4); background: var(--uikit-bg-muted); border-top: 1px solid var(--uikit-border); } .detail-panel.open { display: block; animation: uikitDetailIn var(--uikit-duration-normal) var(--uikit-ease-standard) both; } .compare-table { width: 100%; margin-bottom: 13px; overflow: hidden; color: var(--uikit-text); font-size: 11px; text-align: center; background: var(--uikit-bg); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); border-collapse: separate; border-spacing: 0; } .compare-table th { padding: 9px 6px; color: var(--uikit-text-muted); font-weight: 750; background: var(--uikit-brand-soft); border-bottom: 1px solid var(--uikit-border); } .compare-table td { padding: 10px 6px; color: var(--uikit-text); font-weight: 650; } .val-rank { color: var(--uikit-brand-text); font-weight: 850; } .detail-section-head { margin: 12px 0 8px; display: flex; align-items: center; gap: 8px; color: var(--uikit-text-muted); font-size: 11px; font-weight: 800; } .detail-section-head::after { content: ""; flex: 1; height: 1px; background: var(--uikit-border); } .dist-container { padding: 3px 1px 1px; } .dist-row { display: flex; align-items: center; gap: 9px; min-height: 25px; color: var(--uikit-text-muted); font-size: 11px; } .dist-name { width: 38px; text-align: right; } .dist-track { flex: 1; height: 7px; overflow: hidden; background: var(--uikit-border); border-radius: var(--uikit-radius-pill); } .dist-fill { height: 100%; background: linear-gradient(90deg, var(--uikit-brand-2), var(--uikit-brand)); border-radius: var(--uikit-radius-pill); transition: width var(--uikit-duration-normal) var(--uikit-ease-standard); } .dist-num { width: 30px; font-weight: 750; } .table-empty { padding: 22px 12px !important; color: var(--uikit-text-faint) !important; text-align: center !important; } `, gpaAndDocuments: ` .export-card, .gpa-card { margin-bottom: var(--uikit-space-3); padding: 14px; } .gpa-card.is-overall { position: relative; overflow: hidden; border-color: var(--uikit-brand-outline); } .gpa-card.is-overall::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: linear-gradient(90deg, var(--uikit-brand), var(--uikit-brand-2)); } .gpa-card.is-year { position: relative; overflow: hidden; padding: 15px 17px; } .gpa-card.is-year::before { content: ""; position: absolute; inset: 12px auto 12px 0; width: 3px; border-radius: 0 3px 3px 0; background: var(--uikit-brand); } .gpa-card.is-semester { padding: 15px 17px; } .gpa-card.is-empty { padding: 13px 15px; } .gpa-card-title { margin-bottom: 14px; padding-bottom: 10px; display: flex; align-items: center; gap: var(--uikit-space-2); color: var(--uikit-text); font-size: var(--uikit-text-sm); font-weight: 800; border-bottom: 1px solid var(--uikit-border); } .gpa-card-title.is-compact { margin: 0; padding: 0; justify-content: space-between; font-size: var(--uikit-text-xs); border: 0; } .gpa-container { --gpa-sticky-offset: 16px; } .gpa-segmented { position: sticky; top: calc(-1 * var(--gpa-sticky-offset)); z-index: 3; margin: 0 0 14px; padding: 3px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 3px; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); box-shadow: var(--uikit-shadow-sm); } .gpa-segment-button { min-width: 0; min-height: 34px; padding: 6px 10px; overflow: hidden; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); font-weight: 750; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; background: transparent; border: 0; border-radius: var(--uikit-radius-sm); transition: color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), box-shadow var(--uikit-duration-interaction) var(--uikit-ease-out-expo), transform var(--uikit-duration-interaction) var(--uikit-ease-out-back); } .gpa-segment-button:hover:not(:disabled), .gpa-segment-button.uikit-hover-lifted { color: var(--uikit-text); background: var(--uikit-surface-hover); } .gpa-segment-button:active:not(:disabled) { transform: scale(.97); transition-duration: 100ms; } .gpa-segment-button.active { color: var(--uikit-brand-text); background: var(--uikit-bg); box-shadow: inset 0 0 0 1px var(--uikit-brand-outline), var(--uikit-shadow-sm); } .gpa-segment-button:disabled { opacity: .45; cursor: not-allowed; } .gpa-section[hidden] { display: none !important; } .gpa-section.is-entering { animation: gpaSectionIn var(--uikit-duration-normal) var(--uikit-ease-standard) both; } .gpa-section-heading { margin: 18px 2px 10px; display: flex; align-items: center; gap: var(--uikit-space-2); color: var(--uikit-text); font-size: var(--uikit-text-sm); font-weight: 800; } .gpa-section-heading::after { content: ""; flex: 1; height: 1px; background: var(--uikit-border); } .gpa-section-count { min-width: 22px; padding: 2px 7px; color: var(--uikit-brand-text); font-size: 10px; text-align: center; background: var(--uikit-brand-soft); border-radius: var(--uikit-radius-pill); } .gpa-flat-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } .gpa-flat-metric { min-width: 0; padding: 2px 18px 3px 2px; } .gpa-flat-metric + .gpa-flat-metric { padding-right: 2px; padding-left: 18px; border-left: 1px solid var(--uikit-border); } .gpa-flat-label { display: block; margin-bottom: 6px; color: var(--uikit-text-muted); font-size: 10px; font-weight: 750; } .gpa-flat-row { display: flex; align-items: center; flex-wrap: wrap; gap: 9px 16px; } .gpa-flat-value { min-width: 78px; display: block; color: var(--uikit-brand); font-size: 24px; font-weight: 900; line-height: 1.15; letter-spacing: 0; font-variant-numeric: tabular-nums; } .gpa-detail-trigger { width: auto; margin: 0; padding: 0; font-family: inherit; text-align: left; cursor: pointer; appearance: none; background: transparent; border: 0; border-radius: var(--uikit-radius-xs); transition: color var(--uikit-duration-fast) var(--uikit-ease-standard), background-color var(--uikit-duration-fast) var(--uikit-ease-standard), box-shadow var(--uikit-duration-fast) var(--uikit-ease-standard), transform var(--uikit-duration-fast) var(--uikit-ease-standard); } .gpa-detail-trigger:hover, .gpa-detail-trigger.uikit-hover-lifted { color: var(--uikit-brand-text); background: var(--uikit-brand-soft-hover); box-shadow: 0 0 0 3px var(--uikit-brand-soft); } .gpa-detail-trigger:active { transform: translateY(0) scale(.985); } .gpa-detail-trigger:focus-visible { outline: 2px solid var(--uikit-brand); outline-offset: 2px; } .gpa-detail-trigger:disabled { cursor: wait; opacity: .58; transform: none; } .gpa-flat-metric.is-compulsory .gpa-flat-value { color: var(--uikit-brand-text); } .gpa-flat-ranks { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; color: var(--uikit-text-muted); font-size: 10px; } .gpa-flat-ranks span { min-height: 23px; padding: 3px 7px; display: inline-flex; align-items: baseline; gap: 3px; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-pill); } .gpa-flat-ranks b { color: var(--uikit-text); font-size: 11px; } .gpa-flat-ranks em { color: var(--uikit-text-faint); font-style: normal; } .gpa-year-minimum { margin: 10px 2px 0; color: var(--uikit-text-faint); font-size: 10px; line-height: 1.5; } .gpa-year-minimum b { margin-left: 4px; color: var(--uikit-brand-text); font-size: 11px; font-variant-numeric: tabular-nums; } .gpa-detail-summary { margin-bottom: 10px; display: flex; flex-wrap: wrap; gap: 6px; } .gpa-detail-summary span { padding: 3px 8px; color: var(--uikit-text-muted); background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-pill); } .gpa-detail-note { margin: 0 0 11px; color: var(--uikit-text-faint); font-size: 10px; line-height: 1.55; text-align: left; } .gpa-detail-table-wrap { max-width: 100%; overflow: auto; border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); } .gpa-detail-table { width: 100%; min-width: 660px; color: var(--uikit-text); font-size: 11px; line-height: 1.45; border-collapse: collapse; background: var(--uikit-bg-elevated); } .gpa-detail-table th, .gpa-detail-table td { padding: 8px 9px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--uikit-border); } .gpa-detail-table th { position: sticky; top: 0; z-index: 1; color: var(--uikit-text-muted); font-size: 10px; white-space: nowrap; background: var(--uikit-bg-muted); } .gpa-detail-table tbody tr:last-child td { border-bottom: 0; } .gpa-detail-table tbody tr:nth-child(even) td { background: var(--uikit-row-alt); } .gpa-detail-table tbody tr:hover td { background: var(--uikit-surface-hover); } .gpa-detail-table .is-number { text-align: center; white-space: nowrap; font-variant-numeric: tabular-nums; } .gpa-detail-course { min-width: 168px; font-weight: 750; overflow-wrap: anywhere; } .gpa-detail-course small { display: block; margin-top: 2px; color: var(--uikit-text-faint); font-size: 10px; font-weight: 600; } .gpa-sem-row { display: flex; gap: 8px; margin-bottom: 8px; } .uikit-dialog .gpa-sem-input { flex: 1; min-width: 0; height: var(--uikit-control-height-md); padding: var(--uikit-space-2) 11px; } .gpa-sem-help { margin-bottom: 11px; color: var(--uikit-text-muted); font-size: 11px; line-height: 1.65; text-align: left; } .export-card h3 { margin: 0 0 11px; display: flex; align-items: center; gap: var(--uikit-space-2); color: var(--uikit-text); font-size: var(--uikit-text-sm); font-weight: 800; } .export-card p { margin: 0 0 15px; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); line-height: var(--uikit-line-relaxed); } .print-list { display: flex; flex-direction: column; gap: 8px; margin-top: 9px; } .print-row { padding: 11px var(--uikit-space-3); display: flex; align-items: center; justify-content: space-between; gap: var(--uikit-space-3); background: var(--uikit-bg-elevated); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); transition: background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), border-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), transform var(--uikit-duration-interaction) var(--uikit-ease-out-back), box-shadow var(--uikit-duration-interaction) var(--uikit-ease-out-expo); } .print-row:hover, .print-row.uikit-hover-lifted { background: var(--uikit-surface-hover); border-color: var(--uikit-border-strong); box-shadow: 0 6px 14px rgb(0 0 0 / 7%); } .print-name { min-width: 0; overflow: hidden; color: var(--uikit-text); font-size: var(--uikit-text-xs); font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } `, evaluation: ` .eval-toolbar { display: grid; gap: 11px; } .eval-status-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .eval-status-copy { min-width: 0; display: flex; align-items: center; gap: 8px; } .eval-status-dot { width: 8px; height: 8px; flex: 0 0 8px; background: var(--uikit-success); border-radius: 50%; box-shadow: 0 0 0 4px var(--uikit-success-soft); } .eval-status-row[data-state='loading'] .eval-status-dot { background: var(--uikit-brand); box-shadow: 0 0 0 4px var(--uikit-brand-soft); animation: evalStatusPulse 1.25s ease-in-out infinite; } .eval-status-row[data-state='error'] .eval-status-dot { background: var(--uikit-danger); box-shadow: 0 0 0 4px var(--uikit-danger-soft); } .eval-status-row[data-state='warning'] .eval-status-dot { background: var(--uikit-warning); box-shadow: 0 0 0 4px var(--uikit-warning-soft); } .eval-status-row[data-state='idle'] .eval-status-dot { background: var(--uikit-text-faint); box-shadow: 0 0 0 4px var(--uikit-border); } .eval-status-actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; } .eval-status-note { color: var(--uikit-text-faint); font-size: var(--uikit-text-xs); white-space: nowrap; } .eval-config-card { padding: var(--uikit-space-3); display: grid; gap: var(--uikit-space-3); animation: uikitDetailIn var(--uikit-duration-fast) var(--uikit-ease-standard) both; } .eval-config-card.collapsed { display: none; } .eval-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 11px; align-items: end; } .eval-comment { grid-column: 1 / -1; } .eval-field { min-width: 0; } .eval-field textarea { min-height: 82px; padding: var(--uikit-space-2) var(--uikit-space-3); resize: vertical; font-size: var(--uikit-text-xs); line-height: var(--uikit-line-relaxed); } .eval-field input { height: var(--uikit-control-height-md); padding: var(--uikit-space-2) 11px; font-size: var(--uikit-text-xs); } .eval-config-actions { display: flex; justify-content: flex-end; gap: 9px; } .eval-footer { display: flex; gap: 10px; border-top: 1px solid var(--uikit-border); border-bottom: 0; } #pj-list-area { padding: 14px var(--uikit-space-4) !important; background: var(--uikit-bg-muted) !important; } .eval-summary { margin-bottom: 12px; padding: 13px 14px; display: grid; grid-template-columns: minmax(150px, 1fr) auto; align-items: center; gap: 14px; color: var(--uikit-text); } .eval-summary-copy { min-width: 0; display: grid; gap: 2px; } .eval-summary-eyebrow { color: var(--uikit-brand-text); font-size: 10px; font-weight: 900; letter-spacing: .1em; } .eval-summary-term { overflow: hidden; color: var(--uikit-text); font-size: 15px; font-weight: 850; text-overflow: ellipsis; white-space: nowrap; } .eval-summary-detail { color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); } .eval-summary-stats { display: grid; grid-template-columns: repeat(4, minmax(48px, auto)); gap: 6px; } .eval-summary-stat { min-height: 48px; padding: 6px 9px; display: grid; place-items: center; align-content: center; color: var(--uikit-text-muted); font-size: 9px; line-height: 1.25; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-sm); } .eval-summary-stat b { color: var(--uikit-text); font-size: 14px; font-variant-numeric: tabular-nums; } .eval-summary-stat.is-active b { color: var(--uikit-brand); } .eval-summary-stat.is-done b { color: var(--uikit-success); } .eval-loading-shell { display: grid; gap: 10px; padding: 1px; } .eval-loading-card { height: 74px; overflow: hidden; position: relative; background: var(--uikit-bg-elevated); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); } .eval-loading-card::after { content: ''; position: absolute; inset: 0; background: linear-gradient(100deg, transparent 20%, rgb(var(--uikit-brand-rgb) / 10%) 48%, transparent 76%); transform: translateX(-100%); animation: evalSkeleton 1.35s var(--uikit-ease-standard) infinite; } .eval-loading-line { height: 9px; margin: 16px 16px 0; background: var(--uikit-border); border-radius: var(--uikit-radius-pill); } .eval-loading-line + .eval-loading-line { width: 44%; margin-top: 11px; opacity: .72; } #pj-list-area > .pj-group { margin-bottom: 11px; overflow: hidden; } .pj-group-head { min-height: 46px; display: flex; align-items: stretch; background: var(--uikit-bg-elevated); border-bottom: 1px solid var(--uikit-border); } .pj-group-select { padding: 0 0 0 13px; display: inline-flex; align-items: center; gap: 6px; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); font-weight: 750; cursor: pointer; } .pj-group-header { min-width: 0; min-height: 45px; padding: 10px 13px; flex: 1; display: flex; align-items: center; gap: 9px; color: var(--uikit-text); font-size: var(--uikit-text-xs); font-weight: 800; text-align: left; cursor: pointer; background: transparent; border: 0; transition: background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), transform var(--uikit-duration-interaction) var(--uikit-ease-out-back); } .pj-group-header:hover { color: var(--uikit-text); background: var(--uikit-surface-hover); } .pj-group-header:active { transform: scale(.99); transition-duration: 100ms; } .pj-group-mark { width: 8px; height: 8px; flex: 0 0 8px; border-radius: 50%; background: var(--uikit-text-faint); box-shadow: 0 0 0 4px var(--uikit-border); } .pj-group-mark.is-active { background: var(--uikit-success); box-shadow: 0 0 0 4px var(--uikit-success-soft); } .pj-group-mark.is-done { background: var(--uikit-brand); box-shadow: 0 0 0 4px var(--uikit-brand-soft); } .pj-group-mark.is-wait { background: var(--uikit-warning); box-shadow: 0 0 0 4px var(--uikit-warning-soft); } .pj-group-mark.is-end { background: var(--uikit-danger); box-shadow: 0 0 0 4px var(--uikit-danger-soft); } .pj-group-title { flex: 1; } .pj-arrow { color: var(--uikit-text-faint); font-size: 15px; transition: transform var(--uikit-duration-fast) var(--uikit-ease-standard); } .pj-group-header[aria-expanded="false"] .pj-arrow { transform: rotate(-90deg); } .pj-group-content { display: grid; grid-template-rows: 1fr; opacity: 1; background: var(--uikit-bg-elevated); transition: grid-template-rows var(--uikit-duration-fast) var(--uikit-ease-standard), opacity var(--uikit-duration-fast) var(--uikit-ease-standard); } .pj-group-content.is-collapsed { grid-template-rows: 0fr; opacity: 0; } .pj-group-items { min-height: 0; overflow: hidden; } .pj-item { padding: 12px 13px; display: flex; align-items: center; gap: 10px; color: var(--uikit-text); background: var(--uikit-bg-elevated); border-bottom: 1px solid var(--uikit-border); transition: background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), transform var(--uikit-duration-interaction) var(--uikit-ease-out-back); } .pj-item:hover, .pj-item.uikit-hover-lifted { color: var(--uikit-text); background: var(--uikit-surface-hover); } .pj-item:last-child { border-bottom: 0; } .pj-item-main { flex: 1 1 auto; min-width: 0; line-height: 1.45; } .pj-item-title { overflow: hidden; color: var(--uikit-text); font-size: 12px; font-weight: 750; text-overflow: ellipsis; white-space: nowrap; } .pj-item-sub { display: flex; flex-wrap: wrap; gap: 4px 8px; color: var(--uikit-text-muted); font-size: 10px; } .pj-item-sub span + span::before { content: '·'; margin-right: 8px; color: var(--uikit-text-faint); } .pj-item-status { flex: 0 0 auto; display: grid; justify-items: end; gap: 4px; text-align: right; } .pj-badge { min-height: 21px; padding: 3px 8px; display: inline-flex; align-items: center; font-size: 10px; font-weight: 850; border-radius: var(--uikit-radius-pill); } .pj-badge.ing { color: var(--uikit-success); background: var(--uikit-success-soft); } .pj-badge.done { color: var(--uikit-brand-text); background: var(--uikit-brand-soft); } .pj-badge.wait { color: var(--uikit-warning); background: var(--uikit-warning-soft); } .pj-badge.end { color: var(--uikit-danger); background: var(--uikit-danger-soft); } .pj-log { margin-top: 3px; color: var(--uikit-warning); font-size: 10px; font-weight: 700; text-align: right; } .pj-item-cb:disabled { cursor: not-allowed; opacity: .46; } .pj-item-state-icon { width: 17px; height: 17px; flex: 0 0 17px; display: inline-flex; align-items: center; justify-content: center; color: var(--uikit-text-faint); font-size: 10px; font-weight: 900; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: 50%; } .pj-item-state-icon.done { color: #fff; background: var(--uikit-success); border-color: var(--uikit-success); } .pj-item-state-icon.wait { color: var(--uikit-warning); background: var(--uikit-warning-soft); border-color: color-mix(in srgb, var(--uikit-warning) 28%, transparent); } .pj-detail-btn { min-height: 28px; padding: 3px 6px; color: var(--uikit-brand-text); font-size: var(--uikit-text-xs); font-weight: 800; cursor: pointer; background: transparent; border: 0; border-radius: var(--uikit-radius-sm); transition: color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), transform var(--uikit-duration-interaction) var(--uikit-ease-out-back); } .pj-detail-btn:hover, .pj-detail-btn.uikit-hover-lifted { color: var(--uikit-brand-text); background: var(--uikit-brand-soft-hover); } .pj-detail-btn:active { transform: scale(.97); transition-duration: 100ms; } .pj-detail-btn:disabled { cursor: not-allowed; opacity: .52; transform: none; } .pj-run-count { min-width: 19px; height: 19px; padding: 0 6px; display: inline-flex; align-items: center; justify-content: center; color: var(--uikit-brand-text); font-size: 10px; font-weight: 900; background: rgb(255 255 255 / 82%); border-radius: var(--uikit-radius-pill); } .eval-history-loader { margin: 0 0 11px; padding: 13px 14px; display: flex; align-items: center; justify-content: space-between; gap: 14px; } .eval-history-copy { min-width: 0; display: grid; gap: 3px; } .eval-history-kicker { color: var(--uikit-brand-text); font-size: 10px; font-weight: 900; letter-spacing: .09em; } .eval-history-title { color: var(--uikit-text); font-size: 12px; font-weight: 850; } .eval-history-meta { overflow: hidden; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; } .eval-history-btn { min-width: 128px; min-height: 38px; flex: 0 0 auto; } .pj-item.is-history { box-shadow: inset 3px 0 0 var(--uikit-brand-outline); } .pj-item-term { padding: 1px 5px; color: var(--uikit-brand-text); background: var(--uikit-brand-soft); border-radius: var(--uikit-radius-pill); } .pj-item-sub .pj-item-term::before { content: none; margin: 0; } .eval-detail-sheet { display: grid; gap: 12px; color: var(--uikit-text); } .eval-detail-overview { padding: 14px; display: flex; align-items: center; justify-content: space-between; gap: 14px; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); } .eval-detail-kicker { color: var(--uikit-brand-text); font-size: 10px; font-weight: 900; letter-spacing: .1em; text-transform: uppercase; } .eval-detail-overview h2, .eval-detail-teacher h3 { margin: 3px 0 0; color: var(--uikit-text); } .eval-detail-overview h2 { font-size: 15px; line-height: 1.35; } .eval-detail-overview p { margin: 4px 0 0; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); } .eval-detail-total { flex: 0 0 auto; padding: 7px 10px; color: var(--uikit-success); font-size: 12px; font-weight: 900; white-space: nowrap; background: var(--uikit-bg-elevated); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-sm); } .eval-detail-deadline { padding: 7px 10px; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); background: var(--uikit-bg-muted); border-radius: var(--uikit-radius-sm); } .eval-detail-teacher { overflow: hidden; background: var(--uikit-bg-elevated); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); } .eval-detail-teacher > header { padding: 11px 13px; display: flex; align-items: center; justify-content: space-between; gap: 12px; background: var(--uikit-bg-muted); border-bottom: 1px solid var(--uikit-border); } .eval-detail-teacher h3 { font-size: 13px; } .eval-detail-score { display: grid; color: var(--uikit-brand); font-size: 17px; font-weight: 900; line-height: 1; text-align: right; font-variant-numeric: tabular-nums; } .eval-detail-score small { margin-top: 3px; color: var(--uikit-text-faint); font-size: 10px; font-weight: 700; } .eval-detail-answers { margin: 0; padding: 0; list-style: none; } .eval-detail-answer { padding: 10px 13px; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 12px; border-bottom: 1px solid var(--uikit-border); } .eval-detail-answer:last-child { border-bottom: 0; } .eval-detail-question { min-width: 0; display: flex; align-items: flex-start; gap: 8px; } .eval-detail-question > span { width: 20px; height: 20px; flex: 0 0 20px; display: inline-flex; align-items: center; justify-content: center; color: var(--uikit-brand-text); font-size: 9px; font-weight: 900; background: var(--uikit-brand-soft); border-radius: 50%; } .eval-detail-question p { margin: 0; color: var(--uikit-text); font-size: 11px; line-height: 1.55; } .eval-detail-result { min-width: 74px; display: grid; justify-items: end; gap: 2px; text-align: right; } .eval-detail-result strong { color: var(--uikit-success); font-size: 11px; } .eval-detail-points { color: var(--uikit-text-faint); font-size: 9px; } .eval-detail-empty { padding: 18px; color: var(--uikit-text-faint); text-align: center; } @media (max-width: 640px) { .eval-history-loader { align-items: stretch; flex-direction: column; gap: 10px; } .eval-history-meta { white-space: normal; } .eval-history-btn { width: 100%; } } @keyframes evalStatusPulse { 50% { opacity: .45; transform: scale(.82); } } @keyframes evalSkeleton { to { transform: translateX(100%); } } `, academicMonitor: ` .monitor-toolbar-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .monitor-status { min-width: 0; overflow: hidden; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); font-weight: 650; text-overflow: ellipsis; white-space: nowrap; } .monitor-actions { display: flex; gap: 8px; flex: 0 0 auto; } .monitor-overview { margin-bottom: 15px; padding: 16px; overflow: hidden; } .monitor-overview-top { margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between; gap: 12px; } .monitor-overview-label { margin-bottom: 3px; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); font-weight: 700; } .monitor-overview-value { color: var(--uikit-text); font-size: 19px; font-weight: 900; letter-spacing: -.025em; } .monitor-status-pill, .monitor-group-badge { min-height: 25px; padding: 4px 9px; display: inline-flex; align-items: center; font-size: 10px; font-weight: 850; white-space: nowrap; border: 1px solid transparent; border-radius: var(--uikit-radius-pill); } .monitor-status-pill.pass, .monitor-group-badge.pass { color: var(--uikit-success); background: var(--uikit-success-soft); border-color: color-mix(in srgb, var(--uikit-success) 28%, transparent); } .monitor-status-pill.warn, .monitor-group-badge.warn { color: var(--uikit-danger); background: var(--uikit-danger-soft); border-color: color-mix(in srgb, var(--uikit-danger) 28%, transparent); } .monitor-progress-track { height: 10px; margin-bottom: 8px; overflow: hidden; background: var(--uikit-border); border-radius: var(--uikit-radius-pill); box-shadow: inset 0 1px 2px rgb(0 0 0 / 7%); } .monitor-progress-fill { height: 100%; background: linear-gradient(90deg, var(--uikit-brand-2), var(--uikit-brand)); border-radius: inherit; transition: width var(--uikit-duration-slow) var(--uikit-ease-standard); } .monitor-progress-meta { display: flex; justify-content: space-between; gap: 12px; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); } .monitor-progress-meta b { color: var(--uikit-brand-text); font-size: 12px; } .monitor-section-title { margin: 17px 2px 9px; display: flex; align-items: center; gap: 8px; color: var(--uikit-text); font-size: 12px; font-weight: 850; } .monitor-section-title::after { content: ""; flex: 1; height: 1px; background: var(--uikit-border); } .monitor-groups { overflow: hidden; } .monitor-group { border-bottom: 1px solid var(--uikit-border); } .monitor-group:last-child { border-bottom: 0; } .monitor-group-header { width: 100%; min-height: 55px; padding: 11px 13px; display: grid; grid-template-columns: 18px minmax(100px, auto) minmax(0, 1fr) auto; align-items: center; gap: 8px; color: var(--uikit-text); text-align: left; cursor: pointer; background: var(--uikit-bg-elevated); border: 0; transition: color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), transform var(--uikit-duration-interaction) var(--uikit-ease-out-back); } .monitor-group-header:hover, .monitor-group-header.uikit-hover-lifted { color: var(--uikit-text); background: var(--uikit-surface-hover); } .monitor-group-header:active { transform: scale(.99); transition-duration: 100ms; } .monitor-group-arrow { color: var(--uikit-text-faint); font-size: 10px; transition: transform var(--uikit-duration-interaction) var(--uikit-ease-out-back); } .monitor-group-header[aria-expanded='true'] .monitor-group-arrow { transform: rotate(90deg); } .monitor-group-title { font-size: 12px; font-weight: 850; } .monitor-group-credit { overflow: hidden; font-size: var(--uikit-text-xs); font-weight: 800; text-overflow: ellipsis; white-space: nowrap; transition: color var(--uikit-duration-fast) var(--uikit-ease-standard); } .monitor-group-credit.pass { color: var(--uikit-success); } .monitor-group-credit.warn { color: var(--uikit-danger); } .monitor-group-badge { justify-self: end; } .monitor-group-body { overflow-x: auto; border-top: 1px solid var(--uikit-border); animation: uikitDetailIn var(--uikit-duration-normal) var(--uikit-ease-standard) both; } .monitor-course-table { width: 100%; min-width: 690px; color: var(--uikit-text); font-size: var(--uikit-text-xs); border-collapse: separate; border-spacing: 0; } .monitor-course-table th { padding: 10px 11px; color: var(--uikit-text-muted); font-weight: 800; text-align: left; white-space: nowrap; background: var(--uikit-bg-muted); border-bottom: 1px solid var(--uikit-border); } .monitor-course-table td { padding: 10px 11px; vertical-align: middle; border-top: 1px solid var(--uikit-border); } .monitor-course-table tbody tr:first-child td { border-top: 0; } .monitor-course-table tbody tr:nth-child(even) td { background: var(--uikit-row-alt); } .monitor-course-table tbody tr:hover td { background: var(--uikit-surface-hover); } .monitor-course-row.pending td { color: var(--uikit-text-faint); background: var(--uikit-bg-muted); } .monitor-course-row.selected td { color: var(--uikit-text-muted); background: var(--uikit-brand-soft); } .monitor-course-name { max-width: 260px; line-height: 1.5; } .monitor-pass-dot { width: 21px; height: 21px; display: inline-flex; align-items: center; justify-content: center; color: #fff; font-size: var(--uikit-text-xs); font-weight: 900; border-radius: 50%; } .monitor-pass-dot.pass { background: var(--uikit-success); box-shadow: 0 3px 8px color-mix(in srgb, var(--uikit-success) 20%, transparent); } .monitor-pass-dot.fail { background: var(--uikit-danger); } .monitor-pass-dot.pending { color: var(--uikit-text-muted); background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); } .monitor-pass-dot.selected { color: var(--uikit-brand); background: var(--uikit-brand-soft); border: 1px solid color-mix(in srgb, var(--uikit-brand) 30%, transparent); } .monitor-warning { margin-bottom: 9px; padding: 11px 12px; color: var(--uikit-danger); font-size: var(--uikit-text-xs); line-height: 1.6; background: var(--uikit-danger-soft); border: 1px solid color-mix(in srgb, var(--uikit-danger) 28%, transparent); border-radius: var(--uikit-radius-sm); } .monitor-warning.is-ok { color: var(--uikit-success); background: var(--uikit-success-soft); border-color: color-mix(in srgb, var(--uikit-success) 28%, transparent); } .monitor-empty { padding: 30px 12px; color: var(--uikit-text-faint); text-align: center; } `, overlaysAndMaintenance: ` #update-container, #update-container-modal, .update-card, .update-card p, .update-card h3, .update-notes, .update-notes *, .uikit-dialog #update-container-modal * { text-align: left !important; } #help-container, #help-container * { text-align: left; } .help-sheet { display: grid; gap: 14px; color: var(--uikit-text); } .help-intro { margin: 0; color: var(--uikit-text-muted); font-size: var(--uikit-text-sm); line-height: var(--uikit-line-relaxed); } .help-section { display: grid; gap: 8px; } .help-section-title { margin: 0; display: flex; align-items: center; gap: 7px; color: var(--uikit-text); font-size: 13px; font-weight: 900; } .help-section-title .uikit-icon { color: var(--uikit-brand-text); } .help-steps { margin: 0; padding-left: 22px; color: var(--uikit-text-muted); line-height: var(--uikit-line-relaxed); } .help-steps li { margin: 4px 0; padding-left: 2px; } .help-feature-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .help-feature { min-width: 0; padding: 10px 11px; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-sm); } .help-feature strong { display: block; margin-bottom: 3px; color: var(--uikit-text); font-size: var(--uikit-text-xs); } .help-feature span { display: block; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); line-height: 1.55; } .help-tips { margin: 0; padding: 10px 12px; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); line-height: var(--uikit-line-relaxed); background: var(--uikit-brand-soft); border-radius: var(--uikit-radius-sm); } .help-developers { margin-top: 2px; padding-top: 14px; display: grid; gap: 9px; border-top: 1px solid var(--uikit-border); } .help-people { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .help-person { min-width: 0; padding: 10px; display: flex; align-items: center; gap: 10px; color: var(--uikit-text); text-decoration: none; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); transition: border-color var(--uikit-duration-fast), background-color var(--uikit-duration-fast), transform var(--uikit-duration-fast); } .help-person:hover, .help-person.uikit-hover-lifted { color: var(--uikit-brand-text); background: var(--uikit-brand-soft); border-color: var(--uikit-brand-outline); } .help-avatar { width: 44px; height: 44px; flex: 0 0 44px; object-fit: cover; background: var(--uikit-bg); border: 1px solid var(--uikit-border); border-radius: 50%; } .help-person-copy { min-width: 0; display: grid; gap: 2px; } .help-person-role { color: var(--uikit-text-faint); font-size: 9px; font-weight: 900; letter-spacing: .07em; text-transform: uppercase; } .help-person-name { overflow: hidden; color: inherit; font-size: var(--uikit-text-sm); font-weight: 850; text-overflow: ellipsis; white-space: nowrap; } .update-card { color: var(--uikit-text); } .update-card-intro { margin: 0 0 13px; color: var(--uikit-text-muted); font-size: var(--uikit-text-sm); line-height: var(--uikit-line-relaxed); } .update-version-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--uikit-space-3); margin-bottom: var(--uikit-space-3); } .update-version-item { min-width: 0; padding: var(--uikit-space-3); display: flex; flex-direction: column; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-sm); } .update-version-label { margin-bottom: 5px; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); font-weight: 700; } .update-version-value { color: var(--uikit-brand); font-size: 20px; font-weight: 900; font-variant-numeric: tabular-nums; } .update-version-value.accent { color: var(--uikit-brand-text); } .update-status { margin-bottom: var(--uikit-space-3); font-size: var(--uikit-text-xs); font-weight: 850; } .update-status.is-new { color: var(--uikit-success); } .update-status.is-current { color: var(--uikit-brand-text); } .update-status.is-error { color: var(--uikit-danger); } .update-notes { margin-bottom: 13px; padding: 12px 14px; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); } .update-notes-title { margin-bottom: 7px; color: var(--uikit-text); font-weight: 850; } .update-notes ul { margin: 0; padding-left: 20px; color: var(--uikit-text-muted); line-height: var(--uikit-line-relaxed); } .update-notes li { display: list-item; margin: 3px 0; text-align: left !important; } .diagnostics-card { color: var(--uikit-text); text-align: left; } .diagnostics-intro { margin: 0 0 12px; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); line-height: var(--uikit-line-relaxed); } .diagnostics-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-bottom: 12px; } .diagnostics-stat { min-width: 0; padding: 10px; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-sm); } .diagnostics-stat-label { display: block; margin-bottom: 4px; color: var(--uikit-text-faint); font-size: var(--uikit-text-2xs); font-weight: 750; } .diagnostics-stat-value { color: var(--uikit-text); font-size: 17px; font-weight: 900; font-variant-numeric: tabular-nums; } .diagnostics-list { max-height: 260px; overflow-y: auto; margin-bottom: 12px; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-sm); } .diagnostics-empty { padding: 24px 12px; color: var(--uikit-text-faint); text-align: center; } .diagnostics-entry { display: grid; grid-template-columns: 58px 52px minmax(0, 1fr); gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--uikit-border); font-size: var(--uikit-text-xs); line-height: var(--uikit-line-normal); } .diagnostics-entry:last-child { border-bottom: 0; } .diagnostics-time, .diagnostics-scope { color: var(--uikit-text-faint); font-variant-numeric: tabular-nums; } .diagnostics-message { min-width: 0; color: var(--uikit-text-muted); overflow-wrap: anywhere; } .diagnostics-entry.is-warn .diagnostics-message { color: var(--uikit-warning); } .diagnostics-entry.is-error .diagnostics-message { color: var(--uikit-danger); font-weight: 750; } .diagnostics-privacy { margin: 0 0 12px; padding: 9px 10px; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); line-height: var(--uikit-line-relaxed); background: var(--uikit-brand-soft); border-radius: var(--uikit-radius-sm); } #whut-toast-stack { position: fixed; top: 18px; left: 50%; z-index: var(--uikit-z-toast); width: min(430px, calc(100vw - 24px)); display: grid; justify-items: center; gap: 8px; transform: translateX(-50%); pointer-events: none; } .whut-helper-toast { position: relative; width: auto; max-width: 100%; min-height: 38px; padding: 9px 34px 9px 13px; display: flex; align-items: center; gap: 8px; overflow: hidden; color: #fff; background: rgb(30 41 59 / 94%); border: 0; border-radius: var(--uikit-radius-pill); box-shadow: var(--uikit-shadow-md); pointer-events: auto; animation: uikitToastIn var(--uikit-duration-normal) var(--uikit-ease-emphasized) both; } .whut-helper-toast.success { background: color-mix(in srgb, var(--uikit-success) 94%, transparent); } .whut-helper-toast.warning { background: color-mix(in srgb, var(--uikit-warning) 94%, transparent); } .whut-helper-toast.error { background: color-mix(in srgb, var(--uikit-danger) 94%, transparent); } .whut-helper-toast-icon { width: auto; height: auto; display: inline-flex; align-items: center; justify-content: center; color: inherit; font-size: 14px; background: transparent; } .whut-helper-toast-body { min-width: 0; display: flex; flex-direction: column; } .whut-helper-toast-title { overflow-wrap: anywhere; color: #fff; font-size: var(--uikit-text-sm); font-weight: 700; line-height: var(--uikit-line-normal); } .whut-helper-toast-text { margin-top: 2px; overflow-wrap: anywhere; color: rgb(255 255 255 / 82%); font-size: var(--uikit-text-xs); font-weight: 600; line-height: var(--uikit-line-normal); } .whut-helper-toast-close { position: absolute; top: 50%; right: 6px; width: 25px; height: 25px; padding: 0; display: inline-flex; align-items: center; justify-content: center; color: rgb(255 255 255 / 72%); font-size: 14px; cursor: pointer; background: transparent; border: 0; border-radius: var(--uikit-radius-pill); transform: translateY(-50%); } .whut-helper-toast-close:hover { color: #fff; background: rgb(255 255 255 / 12%); } .whut-helper-toast.is-leaving { animation: uikitToastOut var(--uikit-duration-fast) var(--uikit-ease-standard) both; } .uikit-dialog-backdrop { position: fixed; inset: 0; z-index: var(--uikit-z-dialog); padding: 20px; display: flex; align-items: center; justify-content: center; background: var(--uikit-overlay); backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px); opacity: 0; transition: opacity var(--uikit-duration-fast) var(--uikit-ease-standard); pointer-events: auto; } .uikit-dialog-backdrop.is-show { opacity: 1; } .uikit-dialog { position: relative; width: min(420px, calc(100vw - 32px)); max-height: min(84vh, 760px); overflow: hidden; display: flex; flex-direction: column; color: var(--uikit-text); background: var(--uikit-bg-elevated); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-lg); box-shadow: var(--uikit-shadow-lg); transform: translateY(8px) scale(.98); transition: transform var(--uikit-duration-normal) var(--uikit-ease-emphasized); } .uikit-dialog-backdrop.is-show .uikit-dialog { transform: translateY(0) scale(1); } .uikit-dialog::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 5px; background: var(--uikit-brand); } .uikit-dialog.is-danger::before { background: var(--uikit-danger); } .uikit-dialog__header { padding: 18px 42px 10px 24px; } .uikit-dialog__eyebrow { color: var(--uikit-brand-text); font-size: var(--uikit-text-xs); font-weight: 900; letter-spacing: .12em; text-transform: uppercase; } .uikit-dialog.is-danger .uikit-dialog__eyebrow { color: var(--uikit-danger); } .uikit-dialog__title { margin: 4px 0 0; color: var(--uikit-text); font-size: 18px; font-weight: 850; } .uikit-dialog__close { position: absolute; top: 9px; right: 9px; } .uikit-dialog__content { min-height: 0; padding: 4px 24px 16px; overflow: auto; color: var(--uikit-text-muted); font-size: var(--uikit-text-sm); line-height: var(--uikit-line-relaxed); } .uikit-dialog__content > p { margin: 0; white-space: pre-wrap; } .uikit-dialog__validation { margin-top: 9px; padding: 7px 9px; color: var(--uikit-danger); background: var(--uikit-danger-soft); border-radius: var(--uikit-radius-sm); } .uikit-dialog__actions { padding: 11px 15px 15px 24px; display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid var(--uikit-border); background: var(--uikit-bg-muted); } .uikit-dialog__spinner { width: 22px; height: 22px; margin: 18px auto; border: 2px solid var(--uikit-border); border-top-color: var(--uikit-brand); border-radius: 50%; animation: uikitSpin .75s linear infinite; } `, responsiveAndMotion: ` /* 响应式断点与脚本规范一致:md 640、sm 480、xs 360。 */ .helper-header, .helper-toolbar, .helper-content, .monitor-container, .gpa-container, .export-container, .helper-footer, .uikit-dialog, .uikit-dialog__actions { transition-property: background-color, color, border-color, box-shadow; transition-duration: var(--uikit-theme-duration); transition-timing-function: var(--uikit-theme-ease); } @media (max-width: 640px) { #whut-helper-panel { min-width: 0; width: calc(100vw - 24px) !important; height: min(72vh, 620px) !important; max-width: calc(100vw - 24px); max-height: calc(100vh - 24px); border-radius: var(--uikit-radius-lg); } #whut-fab { right: 12px; bottom: max(12px, env(safe-area-inset-bottom)); } .helper-header { padding: 0; } .header-top { margin: 0; } .header-logo { width: 34px; height: 34px; flex-basis: 34px; border-radius: var(--uikit-radius-md); } .header-name { font-size: 15px; } .helper-tabs { overflow-x: auto; } .helper-toolbar, .helper-content, .monitor-container, .gpa-container, .export-container { padding-left: 14px; padding-right: 14px; } #pj-list-area { padding-left: 14px !important; padding-right: 14px !important; } .monitor-group-header { grid-template-columns: 18px minmax(0, 1fr) auto; } .monitor-group-credit { grid-column: 2 / 4; grid-row: 2; } } @media (max-width: 480px) { .header-update { min-width: 86px; width: auto; padding: 0 9px; } .search-box { grid-template-columns: minmax(0, 1fr) 72px; } .filter-row { grid-template-columns: 1fr; } .action-row { display: grid; grid-template-columns: 1fr; } .monitor-toolbar-row, .eval-status-row { align-items: stretch; flex-direction: column; } .monitor-actions, .eval-status-actions { width: 100%; } .monitor-actions .btn-modern, .eval-status-actions .btn-modern { flex: 1; } .eval-status-note { display: none; } .eval-grid, .gpa-flat-grid, .update-version-grid, .diagnostics-summary { grid-template-columns: 1fr; } .gpa-flat-metric { padding: 2px 2px 12px; } .gpa-flat-metric + .gpa-flat-metric { padding: 12px 2px 2px; border-top: 1px solid var(--uikit-border); border-left: 0; } .eval-comment { grid-column: auto; } .eval-summary { grid-template-columns: 1fr; } .eval-summary-stats { grid-template-columns: repeat(4, minmax(0, 1fr)); } .course-summary { min-height: 82px; padding: 13px 14px; } .course-score { min-width: 62px; font-size: 17px; } .helper-footer { grid-template-columns: repeat(2, minmax(0, 1fr)); padding-left: 12px; padding-right: 12px; } .monitor-overview-value { font-size: 17px; } .monitor-progress-meta { flex-direction: column; gap: 3px; } .print-row { align-items: stretch; flex-direction: column; } .print-row .btn-modern { width: 100%; } .help-feature-grid, .help-people { grid-template-columns: 1fr; } } @media (max-width: 360px) { .header-logo { display: none; } .header-name { font-size: 14px; } .header-actions { gap: 5px; } .header-action { min-width: 35px; width: 35px; height: 35px; padding: 0; } .header-update { min-width: 86px; width: auto; padding: 0 9px; } .helper-toolbar { padding-top: 11px; padding-bottom: 11px; } .helper-content, .monitor-container, .gpa-container, .export-container { padding: 12px 10px; } .gpa-container { --gpa-sticky-offset: 12px; } #pj-list-area { padding: 12px 10px !important; } .eval-summary-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } } /* 根据面板本身的宽度适配,也覆盖桌面窗口缩放。 */ @container whut-panel (max-width: 640px) { .helper-tab { min-width: 0; flex: 1 1 0; padding-inline: 4px; } .helper-tab-label { display: none; } .helper-tab-label-short { display: inline; } .helper-toolbar, .helper-content, .monitor-container, .gpa-container, .export-container { padding-inline: 12px; } .monitor-group-header { grid-template-columns: 18px minmax(0, 1fr) auto; } .monitor-group-credit { grid-column: 2 / 4; grid-row: 2; } } @container whut-panel (max-width: 480px) { .filter-row, .eval-grid, .gpa-flat-grid { grid-template-columns: minmax(0, 1fr); } .action-row { display: grid; grid-template-columns: minmax(0, 1fr); } .monitor-toolbar-row, .eval-status-row { align-items: stretch; flex-direction: column; } .monitor-actions, .eval-status-actions { width: 100%; flex-wrap: wrap; } .monitor-actions .btn-modern, .eval-status-actions .btn-modern { flex: 1; } .gpa-flat-metric + .gpa-flat-metric { border-left: 0; border-top: 1px solid var(--uikit-border); } .eval-comment { grid-column: auto; } .eval-summary { grid-template-columns: minmax(0, 1fr); } .helper-footer { grid-template-columns: repeat(2, minmax(0, 1fr)); } .analysis-disclosure-bar, .analysis-search-row { padding-inline: 12px; } } @container whut-panel (max-width: 420px) { .helper-tab-icon { display: none; } .helper-tab { min-height: 44px; font-size: 12px; } } @media (pointer: coarse) { .search-input, .eval-field input, .eval-field textarea, .uikit-dialog .gpa-sem-input { font-size: 16px; } .ui-select-trigger, .ui-select-option, .gpa-segment-button { min-height: 40px; } .ui-search-clear, .schedule-detail-link { min-height: 32px; } .helper-tab, .ui-select-trigger, .schedule-detail-link, .btn-modern { touch-action: manipulation; } } @media (max-height: 520px) { .header-top { min-height: 44px; padding-block: 4px; gap: 6px; } .header-logo { width: 28px; height: 28px; flex-basis: 28px; } .header-name { font-size: 14px; } .helper-tab { min-height: 40px; } .uikit-dialog__header { padding-top: 10px; } .uikit-dialog__actions { padding-block: 8px; } } @media (prefers-reduced-motion: reduce) { :host, :host * { scroll-behavior: auto !important; animation-duration: 1ms !important; animation-iteration-count: 1 !important; transition-duration: 1ms !important; } .uikit-hover-lifted { transform: none; } } @media (prefers-contrast: more) { .btn-modern, .header-action, .search-input, .ui-select-trigger, .eval-field textarea, .eval-field input, .uikit-dialog .gpa-sem-input, #whut-helper-panel, .uikit-dialog, .course-item, .gpa-card, .gpa-segmented, .export-card, .monitor-overview, .monitor-groups, .eval-config-card, #pj-list-area > .pj-group { border-width: 2px; } :focus-visible { outline-width: 4px !important; } } @media (forced-colors: active) { :host { forced-color-adjust: auto; } .helper-tab.active, .gpa-segment-button.active, .ui-select-option[aria-selected='true'] { color: HighlightText; background: Highlight; outline: 1px solid Highlight; } .btn-modern, .header-action, .search-input, .ui-select-trigger, .ui-select-menu, .course-item, .gpa-card, .export-card, .monitor-overview, .monitor-groups { border-color: ButtonText; } .btn-spinner, .ui-state-spinner { border-color: CanvasText; border-top-color: Highlight; } .monitor-progress-fill, .dist-fill { background: Highlight; } } @keyframes uikitPanelIn { from { opacity: 0; transform: translateY(10px) scale(.975); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes uikitViewIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } @keyframes uikitDetailIn { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: translateY(0); } } @keyframes uikitOverlayIn { from { opacity: 0; transform: translateY(-6px) scale(.985); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes uikitToastIn { from { opacity: 0; transform: translateY(-8px) scale(.97); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes uikitToastOut { to { opacity: 0; transform: translateY(-8px) scale(.97); } } @keyframes uikitSpin { to { transform: rotate(360deg); } } @keyframes gpaSectionIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } } ` }); const SCHEDULE_STYLE = ` .schedule-content { display: flex; flex-direction: column; gap: 10px; overflow-x: hidden; container: whut-schedule / inline-size; } .schedule-content > * { flex-shrink: 0; } .schedule-summary-bar { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); background: var(--uikit-bg-elevated); } .schedule-summary-bar .schedule-status { flex: 1; min-width: 0; flex-wrap: nowrap; overflow-x: auto; scrollbar-width: thin; } .schedule-status .tag { flex: 0 0 auto; max-width: none; white-space: nowrap; } .schedule-status .tag:only-child { max-width: 100%; white-space: normal; } .schedule-summary-bar .schedule-actions { flex: 0 0 auto; flex-wrap: nowrap; gap: 5px; } .schedule-summary-bar .btn-modern { min-height: 28px; padding: 4px 7px; font-size: 11px; white-space: nowrap; } .schedule-export-short { display: none; } #schedule-week-prev .uikit-icon, #schedule-back .uikit-icon { transform: rotate(180deg); } .schedule-controls { display: grid; gap: 9px; container-type: inline-size; } #schedule-modes { margin: 0; position: relative; top: auto; } .schedule-form { display: flex; flex-direction: column; gap: 10px; min-width: 0; } .schedule-search-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 8px; } .schedule-primary { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); align-items: end; gap: 8px; } .schedule-form-actions { display: flex; justify-content: flex-end; gap: 6px; padding-bottom: 1px; } .schedule-form-actions .btn-modern { white-space: nowrap; } .schedule-field { display: flex; flex-direction: column; gap: 3px; min-width: 0; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); } .schedule-field .search-input { width: 100%; min-width: 0; padding-left: 10px; padding-right: 10px; } .schedule-fields { display: grid; grid-column: 1; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 8px; } .schedule-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } .schedule-status, .schedule-note { color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); overflow-wrap: anywhere; } .schedule-status { margin: 0; min-height: 1.5em; } .schedule-result-list { display: grid; gap: 8px; } .schedule-result { display: flex; flex-direction: column; gap: 5px; align-items: stretch; width: 100%; padding: 12px; text-align: left; color: var(--uikit-text); background: var(--uikit-bg-elevated); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); cursor: pointer; } .schedule-result:hover { background: var(--uikit-surface-hover); } .schedule-result small { color: var(--uikit-text-muted); overflow-wrap: anywhere; } .schedule-result-title { font-weight: 750; overflow-wrap: anywhere; } .schedule-pager { justify-content: center; padding: 12px 0; } .scroll-sync-top { overflow-x: scroll; overflow-y: hidden; height: 14px; max-width: 100%; } .scroll-sync-top > div { height: 1px; } .schedule-grid-shell { margin-top: 8px; min-width: 0; max-width: 100%; overflow: hidden; } .schedule-week-scroll { overflow: auto; max-height: min(900px, 80vh); max-height: min(900px, 80dvh); scrollbar-width: none; border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); isolation: isolate; touch-action: pan-x pan-y pinch-zoom; } /* 隐藏原生滚动条本体,避免全局 scrollbar-color / width 再启用底部轨道。 */ .uikit-root .schedule-week-scroll { scrollbar-width: none !important; -ms-overflow-style: none; } .uikit-root .schedule-week-scroll::-webkit-scrollbar { display: none !important; width: 0 !important; height: 0 !important; } .schedule-week { display: grid; grid-template-columns: 80px repeat(7, minmax(108px, 1fr)); min-width: 836px; background: var(--uikit-bg-elevated); } .schedule-grid-head, .schedule-period, .schedule-cell { min-width: 0; border-right: 1px solid var(--uikit-border); border-bottom: 1px solid var(--uikit-border); } .schedule-grid-head { position: sticky; top: 0; z-index: 2; min-height: 34px; display: grid; place-items: center; background: var(--uikit-bg-muted); font-size: var(--uikit-text-xs); font-weight: 750; } .schedule-grid-corner { left: 0; z-index: 3; } .schedule-period { position: sticky; left: 0; z-index: 1; padding: 8px 5px; display: grid; place-content: center; gap: 5px; text-align: center; background: var(--uikit-bg-muted); font-size: var(--uikit-text-xs); } .schedule-period small { color: var(--uikit-text-muted); font-size: 10px; } .schedule-cell { min-height: 78px; padding: 5px; display: grid; align-content: start; gap: 5px; } .schedule-course { --schedule-color: var(--uikit-brand); margin: 0; padding: 7px 8px; background: color-mix(in srgb, var(--schedule-color) 13%, var(--uikit-bg-elevated)); border: 1px solid color-mix(in srgb, var(--schedule-color) 38%, var(--uikit-border)); border-radius: var(--uikit-radius-sm); box-shadow: inset 3px 0 var(--schedule-color); overflow-wrap: anywhere; font-size: var(--uikit-text-xs); } .schedule-course { display: grid; gap: 4px; } .schedule-course strong { color: var(--uikit-text); font-size: 11px; line-height: 1.4; } .schedule-course time, .schedule-course small { color: var(--uikit-text-muted); font-size: 10px; line-height: 1.4; } .schedule-detail-link { justify-self: start; padding: 2px 0; border: 0; border-radius: var(--uikit-radius-sm); background: transparent; color: var(--uikit-brand-text); font-size: 10px; cursor: pointer; } .schedule-detail-link:hover { text-decoration: underline; } .schedule-detail-link:focus-visible { outline: 2px solid var(--uikit-brand); outline-offset: 2px; } .schedule-detail-meta { margin-bottom: 12px; } .schedule-jqxx { white-space: pre-wrap; overflow-wrap: anywhere; margin: 2px 0 0; color: var(--uikit-text); line-height: 1.7; } .schedule-course.is-conflict { border-color: var(--uikit-danger); box-shadow: inset 3px 0 var(--uikit-danger); } .schedule-conflict { color: var(--uikit-danger); font-size: 10px; } .schedule-unscheduled { margin-top: 8px; display: grid; gap: 6px; } .course-score { max-width: 42%; overflow-wrap: anywhere; } @media (max-width: 520px) { .schedule-search-row { grid-template-columns: minmax(0, 1fr); } .schedule-primary { grid-template-columns: repeat(2, minmax(0, 1fr)); } .schedule-arranged-field { grid-column: 1 / -1; } .schedule-filter-toggle span, .schedule-week-step span { display: none; } .schedule-summary-bar { gap: 5px; padding: 6px; } .schedule-summary-bar .schedule-actions { gap: 3px; } } @container whut-schedule (max-width: 520px) { .schedule-search-row { grid-template-columns: minmax(0, 1fr); } .schedule-primary { grid-template-columns: repeat(2, minmax(0, 1fr)); } .schedule-arranged-field { grid-column: 1 / -1; } } @container whut-schedule (max-width: 560px) { .schedule-summary-bar { gap: 5px; padding: 6px; } .schedule-summary-bar .schedule-actions, .schedule-summary-bar .disclosure-actions { gap: 3px; } .schedule-filter-toggle span, .schedule-week-step span, .schedule-export-full { display: none; } .schedule-export-short { display: inline; } .schedule-summary-bar .btn-modern { padding-inline: 6px; } .schedule-back-label { display: none; } .schedule-week { grid-template-columns: 64px repeat(7, minmax(108px, 1fr)); min-width: 820px; } } @media (pointer: coarse) { .schedule-summary-bar .btn-modern { min-height: 34px; min-width: 34px; } .scroll-sync-top { height: 18px; } } `; const APP_STYLE = Object.values(STYLE_MODULES).join('\n') + SCHEDULE_STYLE; // ======================================================================== // 模块:UI 运行时 // 职责:管理单一 Host、Shadow Root、主题状态与分层浮层容器。 // ======================================================================== const UIKitRuntime = (() => { const HOST_ID = 'whut-uikit-host'; const THEME_KEY = 'uikit:whut-jw-helper:theme'; const layerNames = ['panel', 'menu', 'dialog', 'toast']; let host = null; let shadow = null; let root = null; let layers = null; let styleNode = null; let themeMode = 'auto'; let mediaQuery = null; let mediaListener = null; const storageGet = (key, fallback) => { try { return localStorage.getItem(key) || fallback; } catch (e) { return fallback; } }; const storageSet = (key, value) => { try { localStorage.setItem(key, value); } catch (e) { /* 页面可能限制存储 */ } }; const validTheme = value => ['light', 'dark', 'auto'].includes(value) ? value : 'auto'; function resolveTheme() { if (themeMode !== 'auto') return themeMode; return mediaQuery?.matches ? 'dark' : 'light'; } function applyTheme() { if (!host) return; host.dataset.themeMode = themeMode; host.dataset.theme = resolveTheme(); } function bindThemeListener() { if (mediaQuery || typeof matchMedia !== 'function') return; mediaQuery = matchMedia('(prefers-color-scheme: dark)'); mediaListener = () => { if (themeMode === 'auto') applyTheme(); }; if (typeof mediaQuery.addEventListener === 'function') mediaQuery.addEventListener('change', mediaListener); else mediaQuery.addListener?.(mediaListener); } function appendHost() { const parent = document.documentElement || document.body; if (parent && host && !host.isConnected) parent.appendChild(host); } function createRuntime(cssText = APP_STYLE) { host = document.createElement('div'); host.id = HOST_ID; // layout/paint containment 会把 fixed 子元素错误约束在 0×0 Host 内, // 因此这里只隔离样式,让面板和浮层继续以浏览器视口为定位基准。 host.style.cssText = 'all:initial;position:fixed;inset:0 auto auto 0;width:0;height:0;z-index:2147483647;pointer-events:none;contain:style;'; shadow = host.attachShadow({ mode: 'open' }); styleNode = document.createElement('style'); styleNode.dataset.uikitStyle = 'whut-jw-helper'; styleNode.textContent = cssText; root = document.createElement('div'); root.className = 'uikit-root'; root.setAttribute('data-uikit-version', '1.0.0'); layers = Object.create(null); layerNames.forEach(name => { const layer = document.createElement('div'); layer.className = `uikit-layer uikit-layer--${name}`; layer.dataset.layer = name; root.appendChild(layer); layers[name] = layer; }); shadow.append(styleNode, root); appendHost(); bindThemeListener(); themeMode = validTheme(storageGet(THEME_KEY, 'auto')); applyTheme(); return api; } function ensure(cssText = APP_STYLE) { if (host && shadow && root && layers) { if (!host.isConnected) appendHost(); if (styleNode && styleNode.textContent !== cssText) styleNode.textContent = cssText; return api; } const existing = document.getElementById(HOST_ID); if (existing) existing.remove(); return createRuntime(cssText); } function setTheme(mode) { themeMode = validTheme(mode); storageSet(THEME_KEY, themeMode); ensure(); applyTheme(); shadow?.dispatchEvent(new CustomEvent('uikit-theme-change', { detail: { mode: themeMode, resolved: resolveTheme() } })); } function cycleTheme() { const modes = ['auto', 'light', 'dark']; setTheme(modes[(modes.indexOf(themeMode) + 1) % modes.length]); return themeMode; } function mount(layerName, node) { ensure(); const layer = layers[layerName] || layers.panel; layer.appendChild(node); return node; } function destroy() { if (mediaQuery && mediaListener) { if (typeof mediaQuery.removeEventListener === 'function') mediaQuery.removeEventListener('change', mediaListener); else mediaQuery.removeListener?.(mediaListener); } host?.remove(); host = shadow = root = layers = styleNode = mediaQuery = mediaListener = null; } const api = { ensure, mount, cycleTheme, getTheme: () => themeMode, getResolvedTheme: () => resolveTheme(), getById: id => (ensure(), shadow.getElementById(id)), queryAll: selector => (ensure(), Array.from(shadow.querySelectorAll(selector))), get shadow() { ensure(); return shadow; }, destroy }; return api; })(); const uiGet = id => UIKitRuntime.getById(id); const uiQueryAll = selector => UIKitRuntime.queryAll(selector); // ======================================================================== // 组件:按钮 // 职责:统一创建、图标按钮语义、异步锁定及加载文案恢复。 // ======================================================================== const ButtonControl = Object.freeze({ enhance(button) { if (!button) return null; if (!button.type) button.type = 'button'; button.classList.add('uikit-button'); if (button.classList.contains('header-action')) { button.classList.add('uikit-icon-button'); const accessibleName = button.getAttribute('aria-label') || button.title; if (accessibleName) { button.title ||= accessibleName; button.setAttribute('aria-label', accessibleName); } } return button; }, enhanceAll(root) { root?.querySelectorAll('button.btn-modern, button.header-action').forEach(button => this.enhance(button)); }, create(label, { className = '', icon = '', title = '', ariaLabel = '' } = {}) { const button = document.createElement('button'); button.type = 'button'; button.className = `btn-modern ${className}`.trim(); if (icon) button.insertAdjacentHTML('afterbegin', iconSVG(icon)); const text = document.createElement('span'); text.textContent = String(label ?? ''); button.appendChild(text); if (title) button.title = title; if (ariaLabel || title) button.setAttribute('aria-label', ariaLabel || title); return this.enhance(button); }, setLoading(button, busy, busyLabel = '处理中...') { if (!button) return; this.enhance(button); if (busy) { if (!button.dataset.idleHtml) { button.dataset.idleHtml = button.innerHTML; button.dataset.idleDisabled = String(button.disabled); } button.disabled = true; button.classList.add('is-loading'); button.setAttribute('aria-busy', 'true'); button.replaceChildren(); const spinner = document.createElement('span'); spinner.className = 'btn-spinner'; spinner.setAttribute('aria-hidden', 'true'); const label = document.createElement('span'); label.textContent = String(busyLabel || '处理中...'); button.append(spinner, label); return; } button.classList.remove('is-loading'); button.removeAttribute('aria-busy'); if (button.dataset.idleHtml) button.innerHTML = button.dataset.idleHtml; button.disabled = button.dataset.idleDisabled === 'true'; delete button.dataset.idleHtml; delete button.dataset.idleDisabled; } }); // ======================================================================== // 组件:对话框 // 职责:提供队列、焦点锁定、焦点恢复、异步确认与表单校验。 // ======================================================================== const AppDialog = (() => { let current = null; let queue = Promise.resolve(); let showValidationHandler = null; const normalizeOptions = args => { if (typeof args[0] === 'string') return { title: args[0], text: args[1] || '', icon: args[2] || 'info' }; return { ...(args[0] || {}) }; }; function focusableOf(dialog) { return Array.from(dialog.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])')) .filter(node => !node.hidden && node.getAttribute('aria-hidden') !== 'true'); } function closeCurrent(result = { isDismissed: true }) { if (!current) return; const entry = current; current = null; showValidationHandler = null; clearTimeout(entry.timer); entry.backdrop.classList.remove('is-show'); setTimeout(() => entry.backdrop.remove(), 170); entry.previousFocus?.focus?.({ preventScroll: true }); entry.resolve(result); } async function open(options) { UIKitRuntime.ensure(); const previousFocus = UIKitRuntime.shadow.activeElement || document.activeElement; const backdrop = document.createElement('div'); backdrop.className = 'uikit-dialog-backdrop'; const dialog = document.createElement('section'); dialog.className = `uikit-dialog${options.icon === 'error' || options.tone === 'danger' ? ' is-danger' : ''}`; dialog.setAttribute('role', options.icon === 'warning' || options.icon === 'error' ? 'alertdialog' : 'dialog'); dialog.setAttribute('aria-modal', 'true'); if (options.width) dialog.style.width = typeof options.width === 'number' ? `${options.width}px` : String(options.width); const titleId = `uikit-dialog-title-${Date.now().toString(36)}`; const descId = `uikit-dialog-desc-${Date.now().toString(36)}`; const header = document.createElement('header'); header.className = 'uikit-dialog__header'; const eyebrow = document.createElement('div'); eyebrow.className = 'uikit-dialog__eyebrow'; eyebrow.textContent = options.eyebrow || ({ success: '操作完成', error: '发生错误', warning: '请注意', question: '请确认' }[options.icon] || 'WHUT 助手'); const title = document.createElement('h2'); title.className = 'uikit-dialog__title'; title.id = titleId; title.textContent = options.title || '提示'; header.append(eyebrow, title); dialog.setAttribute('aria-labelledby', titleId); if (options.showCloseButton) { const close = document.createElement('button'); close.type = 'button'; close.className = 'header-action uikit-dialog__close'; close.setAttribute('aria-label', '关闭对话框'); close.innerHTML = iconSVG('close'); ButtonControl.enhance(close); close.addEventListener('click', () => closeCurrent({ isDismissed: true, dismiss: 'close' })); header.appendChild(close); } const content = document.createElement('div'); content.className = 'uikit-dialog__content'; content.id = descId; dialog.setAttribute('aria-describedby', descId); if (options.unsafeHTML) content.innerHTML = String(options.unsafeHTML); else { const paragraph = document.createElement('p'); paragraph.textContent = options.text || ''; content.appendChild(paragraph); } const validation = document.createElement('div'); validation.className = 'uikit-dialog__validation'; validation.setAttribute('role', 'alert'); validation.hidden = true; content.appendChild(validation); const actions = document.createElement('footer'); actions.className = 'uikit-dialog__actions'; const makeAction = (label, className) => ButtonControl.create(label, { className }); let cancelButton = null; let denyButton = null; let confirmButton = null; if (options.showCancelButton) { cancelButton = makeAction(options.cancelButtonText || '取消', 'btn-outline'); cancelButton.addEventListener('click', () => closeCurrent({ isDismissed: true, dismiss: 'cancel' })); actions.appendChild(cancelButton); } if (options.showDenyButton) { denyButton = makeAction(options.denyButtonText || '稍后', 'btn-outline'); denyButton.addEventListener('click', () => closeCurrent({ isDenied: true, value: false })); actions.appendChild(denyButton); } if (options.showConfirmButton !== false) { confirmButton = makeAction(options.confirmButtonText || '确定', options.tone === 'danger' ? 'btn-danger' : ''); actions.appendChild(confirmButton); } if (actions.children.length) dialog.append(header, content, actions); else dialog.append(header, content); backdrop.appendChild(dialog); UIKitRuntime.mount('dialog', backdrop); const resultPromise = new Promise(resolve => { current = { backdrop, dialog, content, validation, actions, confirmButton, cancelButton, denyButton, previousFocus, resolve, timer: 0 }; }); options.didMount?.(dialog); const showValidation = message => { validation.textContent = String(message || '请检查输入内容'); validation.hidden = false; }; if (confirmButton) confirmButton.addEventListener('click', async () => { validation.hidden = true; let value = true; if (typeof options.preConfirm === 'function') { confirmButton.disabled = true; confirmButton.classList.add('is-loading'); try { value = await options.preConfirm(); } catch (error) { showValidation(error?.message || String(error)); value = false; } finally { confirmButton.disabled = false; confirmButton.classList.remove('is-loading'); } if (value === false || !validation.hidden) return; } closeCurrent({ isConfirmed: true, value }); }); const keyHandler = event => { if (!current || current.dialog !== dialog) return; if (event.key === 'Escape' && options.closeOnEscape !== false) { event.preventDefault(); closeCurrent({ isDismissed: true, dismiss: 'escape' }); return; } if (event.key !== 'Tab') return; const focusable = focusableOf(dialog); if (!focusable.length) { event.preventDefault(); dialog.focus(); return; } const first = focusable[0]; const last = focusable[focusable.length - 1]; if (event.shiftKey && UIKitRuntime.shadow.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && UIKitRuntime.shadow.activeElement === last) { event.preventDefault(); first.focus(); } }; backdrop.addEventListener('keydown', keyHandler); backdrop.addEventListener('mousedown', event => { if (event.target === backdrop && options.allowOutsideClick !== false && options.closeOnBackdrop !== false) { closeCurrent({ isDismissed: true, dismiss: 'backdrop' }); } }); requestAnimationFrame(() => { if (!current || current.dialog !== dialog || !backdrop.isConnected) return; backdrop.classList.add('is-show'); const initial = options.initialFocus === 'cancel' ? cancelButton : (confirmButton || cancelButton || denyButton || dialog); if (!initial.hasAttribute('tabindex') && initial === dialog) initial.tabIndex = -1; initial?.focus?.({ preventScroll: true }); options.didOpen?.(dialog); }); if (options.timer > 0) current.timer = setTimeout(() => closeCurrent({ isDismissed: true, dismiss: 'timer' }), options.timer); showValidationHandler = showValidation; return resultPromise; } const api = { details({ title, html, width = 780, exportRows = null, exportName = title }) { const rows = Array.isArray(exportRows) ? exportRows.map(row => ({ ...row })) : null; return api.fire({ title, unsafeHTML: html, width: `min(${width}px, calc(100vw - 32px))`, showCloseButton: true, confirmButtonText: '关闭', didMount(dialog) { if (!rows) return; const button = ButtonControl.create('导出为 Excel', { className: 'btn-outline detail-export-excel', icon: 'table' }); button.disabled = rows.length === 0; button.addEventListener('click', () => { dialog.querySelector('.uikit-dialog__validation').hidden = true; try { exportToBrowser(rows, exportName, 'xlsx'); } catch (error) { api.showValidationMessage(error?.message || '导出失败,请重试'); } }); dialog.querySelector('.uikit-dialog__actions').prepend(button); } }); }, fire(...args) { const options = normalizeOptions(args); const task = () => open(options); const result = queue.then(task, task); queue = result.catch(() => ({})); return result; }, close: () => closeCurrent({ isDismissed: true, dismiss: 'programmatic' }), showLoading() { if (!current) return; current.content.innerHTML = '
'; Array.from(current.actions.children).forEach(button => { button.disabled = true; }); }, showValidationMessage(message) { showValidationHandler?.(message); }, getCurrentDialog: () => current?.dialog || null }; return api; })(); // ======================================================================== // 模块:共享基础设施 // 职责:提供通知、状态、存储、诊断日志和通用数据工具。 // ======================================================================== function showHelperToast({ icon = 'info', title = '', text = '', timer = 3000 } = {}) { const type = ['success', 'error', 'warning', 'info'].includes(icon) ? icon : 'info'; const iconMap = { success: 'check', error: 'close', warning: 'warning', info: 'info' }; const duration = Math.max(1200, type === 'error' ? Math.max(timer, 5000) : timer); const dedupeKey = `${type}|${title}|${text}`; let stack = uiGet('whut-toast-stack'); if (!stack) { stack = document.createElement('div'); stack.id = 'whut-toast-stack'; stack.setAttribute('aria-live', 'polite'); stack.setAttribute('aria-atomic', 'false'); UIKitRuntime.mount('toast', stack); } const duplicate = Array.from(stack.children).find(node => node.dataset.dedupeKey === dedupeKey); if (duplicate) { duplicate.classList.remove('is-leaving'); duplicate._uikitRefresh?.(); duplicate.animate?.([{ transform: 'scale(.98)' }, { transform: 'scale(1)' }], { duration: 160, easing: 'ease-out' }); return; } while (stack.children.length >= 3) stack.firstElementChild?.remove(); const toast = document.createElement('div'); toast.className = `whut-helper-toast ${type}`; toast.setAttribute('role', type === 'error' ? 'alert' : 'status'); toast.dataset.dedupeKey = dedupeKey; const iconNode = document.createElement('span'); iconNode.className = 'whut-helper-toast-icon'; iconNode.setAttribute('aria-hidden', 'true'); iconNode.innerHTML = iconSVG(iconMap[type]); const body = document.createElement('span'); body.className = 'whut-helper-toast-body'; const titleNode = document.createElement('span'); titleNode.className = 'whut-helper-toast-title'; titleNode.textContent = title || text || ''; body.appendChild(titleNode); if (title && text) { const textNode = document.createElement('span'); textNode.className = 'whut-helper-toast-text'; textNode.textContent = text; body.appendChild(textNode); } const closeButton = document.createElement('button'); closeButton.type = 'button'; closeButton.className = 'whut-helper-toast-close'; closeButton.setAttribute('aria-label', '关闭通知'); closeButton.innerHTML = iconSVG('close'); toast.append(iconNode, body, closeButton); let timerId = 0; const dismiss = () => { if (!toast.isConnected || toast.classList.contains('is-leaving')) return; clearTimeout(timerId); toast.classList.add('is-leaving'); setTimeout(() => { toast.remove(); if (!stack.children.length) stack.remove(); }, 190); }; closeButton.addEventListener('click', dismiss); stack.appendChild(toast); const scheduleDismiss = () => { clearTimeout(timerId); timerId = setTimeout(dismiss, duration); }; toast._uikitRefresh = scheduleDismiss; scheduleDismiss(); } const Toast = Object.freeze({ fire: showHelperToast }); const UIKit = Object.freeze({ ensure: () => UIKitRuntime.ensure(APP_STYLE), mount: (layer, node) => UIKitRuntime.mount(layer, node), cycleTheme: () => UIKitRuntime.cycleTheme(), getTheme: () => UIKitRuntime.getTheme(), destroy: () => UIKitRuntime.destroy() }); const EVAL_DEFAULT_CONFIG = { comment: "老师讲课重点突出,条理清晰,内容丰富,受益匪浅。", delay: 1500, randomScore: false }; const SCRIPT_ID = 'whut-jw-helper'; const SCRIPT_UPDATE_ORIGIN = 'https://home.haoli.site'; const SCRIPT_UPDATE = Object.freeze({ metaURL: `${SCRIPT_UPDATE_ORIGIN}/assets/scripts/${SCRIPT_ID}/${SCRIPT_ID}.meta.js`, infoURL: `${SCRIPT_UPDATE_ORIGIN}/assets/scripts/${SCRIPT_ID}/${SCRIPT_ID}.latest.json`, downloadURL: `${SCRIPT_UPDATE_ORIGIN}/assets/scripts/${SCRIPT_ID}/${SCRIPT_ID}.user.js`, projectURL: 'https://scriptcat.org/zh-CN/script-show-page/6588' }); const UPDATE_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; const SCRIPT_VERSION = '4.0.0'; // 统一的容错存储适配器必须先于状态初始化创建,避免初始化阶段再保留一层只读包装。 const safeStorage = Object.freeze({ get(key, fallback = null) { try { const value = localStorage.getItem(key); return value === null ? fallback : value; } catch (e) { return fallback; } }, set(key, value) { try { localStorage.setItem(key, String(value)); return true; } catch (e) { return false; } }, remove(key) { try { localStorage.removeItem(key); return true; } catch (e) { return false; } }, getJSON(key, fallback = {}) { try { const value = JSON.parse(this.get(key, 'null')); return value && typeof value === 'object' && !Array.isArray(value) ? value : fallback; } catch (e) { return fallback; } }, setJSON(key, value) { return this.set(key, JSON.stringify(value)); } }); const state = { userId: null, userName: null, userInfo: null, userInfoLoadedAt: 0, dataMap: new Map(), rawData: [], filteredData: [], filters: { search: '', semesters: new Set(), semesterAll: true, types: new Set(), typeAll: true, sort: 'semester_desc' }, semesters: new Set(), types: new Set(), isFetching: false, isPrintLoaded: false, isPrintLoading: 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: [], scoreFallbacks: [], studyParams: null, openGroups: new Set(), openGroupsTouched: false }, transcript: { isLoading: false, isLoaded: false, promise: null, report: null, records: [], error: '', lastAttempt: 0 }, mobileScores: { loaded: false, promise: null, rows: [], attempted: false }, update: { isLoaded: false, isChecking: false, info: null, error: '', hasNew: false }, eval: { isLoaded: false, isLoading: false, isLoadingHistory: false, isRunning: false, loadPromise: null, historyPromise: null, lastError: '', courses: [], pendingCourses: [], completedCourses: [], completedSemesters: [], completedLoaded: false, types: [], termCode: '', detailCache: new Map(), config: { comment: safeStorage.get('whut_pj_comment', EVAL_DEFAULT_CONFIG.comment) || EVAL_DEFAULT_CONFIG.comment, delay: parseInt(safeStorage.get('whut_pj_delay', ''), 10) || EVAL_DEFAULT_CONFIG.delay, randomScore: safeStorage.get('whut_pj_random', String(EVAL_DEFAULT_CONFIG.randomScore)) !== 'false' } } }; const debounce = (func, wait) => { let timeout = null; function debounced(...args) { clearTimeout(timeout); timeout = setTimeout(() => { timeout = null; func.apply(this, args); }, wait); } debounced.cancel = () => { clearTimeout(timeout); timeout = null; }; return debounced; }; // 全局事件统一登记,页面退出时可一次释放;面板内部事件随 Shadow Host 移除。 const AppEvents = (() => { const controller = new AbortController(); function listen(target, type, listener, options = {}) { if (!target?.addEventListener || controller.signal.aborted) return; const normalized = typeof options === 'boolean' ? { capture: options } : options; target.addEventListener(type, listener, { ...normalized, signal: controller.signal }); } return Object.freeze({ listen, destroy: () => controller.abort() }); })(); const AppResources = (() => { const cleanups = new Set(); const timeouts = new Set(); const intervals = new Set(); let destroyed = false; return Object.freeze({ add(cleanup) { if (typeof cleanup !== 'function') return cleanup; if (destroyed) { try { cleanup(); } catch (error) { /* 页面退出时忽略清理异常 */ } } else cleanups.add(cleanup); return cleanup; }, schedule(callback, delay) { if (destroyed) return null; const timer = setTimeout(() => { timeouts.delete(timer); callback(); }, delay); timeouts.add(timer); return timer; }, repeat(callback, delay) { if (destroyed) return null; const timer = setInterval(callback, delay); intervals.add(timer); return timer; }, clearRepeat(timer) { clearInterval(timer); intervals.delete(timer); }, destroy() { if (destroyed) return; destroyed = true; timeouts.forEach(clearTimeout); intervals.forEach(clearInterval); [...cleanups].reverse().forEach(cleanup => { try { cleanup(); } catch (error) { /* 页面退出时忽略清理异常 */ } }); timeouts.clear(); intervals.clear(); cleanups.clear(); } }); })(); // ======================================================================== // 组件:稳定悬停上浮 // 职责:以上浮前的固定命中区域驱动动效,并用透明桥接层覆盖位移空隙, // 避免边缘悬停时 :hover 反复失效造成控件和鼠标指针闪烁。 // ======================================================================== const HoverLiftControl = (() => { const ACTIVE_CLASS = 'uikit-hover-lifted'; const TARGET_SELECTOR = [ '.header-version', '.header-author', '.helper-tab', '.ui-select-option:not(:disabled)', '.ui-select-action:not(:disabled)', '.btn-modern:not(:disabled):not(.is-loading)', '.course-item', '.gpa-card', '.gpa-segment-button:not(:disabled)', '.gpa-detail-trigger:not(:disabled)', '.print-row', '#pj-list-area > .pj-group', '.eval-history-loader', '.pj-item', '.pj-detail-btn:not(:disabled)', '.monitor-group-header:not(:disabled)', '.help-person' ].join(','); let root = null; let bridge = null; let activeTarget = null; let stableRect = null; let activePointerId = null; let controller = null; let observer = null; function isEligible(target) { return target?.isConnected && target.matches(TARGET_SELECTOR) && !target.matches(':disabled, [aria-disabled="true"], .is-loading'); } function targetFromEvent(event) { const origin = event.target instanceof Element ? event.target : null; if (!origin || origin === bridge) return null; const target = origin.closest(TARGET_SELECTOR); return target && root?.contains(target) ? target : null; } function pointWithinStableRect(event) { if (!stableRect) return false; return event.clientX >= stableRect.left && event.clientX <= stableRect.right && event.clientY >= stableRect.top && event.clientY <= stableRect.bottom + 1; } function hideBridge() { if (bridge) bridge.style.display = 'none'; } function clear() { activeTarget?.classList.remove(ACTIVE_CLASS); activeTarget = null; stableRect = null; activePointerId = null; hideBridge(); } function showBridge(target, rect, lift) { if (!bridge || lift <= 0) return hideBridge(); bridge.style.left = `${rect.left}px`; bridge.style.top = `${rect.bottom - lift}px`; bridge.style.width = `${rect.width}px`; bridge.style.height = `${lift + 1}px`; bridge.style.cursor = getComputedStyle(target).cursor || 'default'; bridge.style.display = 'block'; } function activate(target, event) { if (!isEligible(target) || event.pointerType === 'touch') return; if (activeTarget === target) return; clear(); const rect = target.getBoundingClientRect(); const reduceMotion = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; const lift = reduceMotion ? 0 : (target.matches('.btn-modern') ? 2 : 1); activeTarget = target; stableRect = { left: rect.left, right: rect.right, top: rect.top - lift, bottom: rect.bottom }; activePointerId = event.pointerId; target.classList.add(ACTIVE_CLASS); showBridge(target, rect, lift); } function handlePointerOver(event) { const target = targetFromEvent(event); if (target) activate(target, event); } function handlePointerMove(event) { if (activeTarget) { if (!isEligible(activeTarget) || (activePointerId !== null && event.pointerId !== activePointerId) || !pointWithinStableRect(event)) clear(); else return; } const target = targetFromEvent(event); if (target) activate(target, event); } function forwardBridgeClick(event) { if (event.button !== 0 || !isEligible(activeTarget)) return; if (activeTarget.matches('button, a[href], [role="button"]')) activeTarget.click(); } function init(eventRoot = UIKitRuntime.shadow) { if (controller || !eventRoot) return destroy; root = eventRoot; controller = new AbortController(); bridge = document.createElement('span'); bridge.className = 'uikit-hover-bridge'; bridge.setAttribute('aria-hidden', 'true'); root.appendChild(bridge); const options = { capture: true, signal: controller.signal }; root.addEventListener('pointerover', handlePointerOver, options); root.addEventListener('pointermove', handlePointerMove, options); document.addEventListener('pointermove', handlePointerMove, options); document.addEventListener('pointerout', event => { if (!event.relatedTarget) clear(); }, options); root.addEventListener('scroll', clear, options); window.addEventListener('resize', clear, options); window.addEventListener('blur', clear, options); bridge.addEventListener('click', forwardBridgeClick, { signal: controller.signal }); observer = new MutationObserver(() => { if (activeTarget && !isEligible(activeTarget)) clear(); }); observer.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ['class', 'disabled', 'aria-disabled'] }); return destroy; } function destroy() { clear(); observer?.disconnect(); controller?.abort(); bridge?.remove(); root = bridge = controller = observer = null; } return Object.freeze({ init, destroy }); })(); const escapeHTML = (val) => String(val ?? '').replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); const FileDownload = Object.freeze({ saveBlob(blob, filename, { revokeDelay = 1500 } = {}) { if (!(blob instanceof Blob) || !blob.size) throw new Error('下载文件为空'); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = String(filename || `WHUT_导出_${Date.now()}`); link.style.display = 'none'; document.body.appendChild(link); link.click(); link.remove(); AppResources.schedule(() => URL.revokeObjectURL(url), Math.max(500, revokeDelay)); return link.download; }, saveText(text, filename, type = 'application/json;charset=utf-8') { return this.saveBlob(new Blob([String(text ?? '')], { type }), filename); } }); const Diagnostics = (() => { const SCHEMA = 'whut-helper-diagnostics-v1'; const MAX_ENTRIES = 400; const startedAt = Date.now(); const entries = []; let sequence = 0; const sensitiveKey = /^(?:xh|xsbh|userid|username|xm|name|student|studentid|cookie|authorization|token|session|gs_sessionid|_weu)$/i; const redactText = value => String(value ?? '') .replace(/([?&](?:token|ticket|session|wid|xh|xsbh|userid)=)[^&#\s]+/gi, '$1[REDACTED]') .replace(/\b(\d{3})\d{5,8}(\d{2})\b/g, '$1******$2') .replace(/(GS_SESSIONID|_WEU|Authorization|Cookie)\s*[:=]\s*[^;\s]+/gi, '$1=[REDACTED]'); function sanitize(value, depth = 0, key = '') { if (sensitiveKey.test(String(key || ''))) return '[REDACTED]'; if (value instanceof Error) { return { name: redactText(value.name || 'Error'), message: redactText(value.message || String(value)), stack: redactText(String(value.stack || '')).split('\n').slice(0, 6).join('\n') }; } if (value === null || value === undefined || typeof value === 'boolean' || typeof value === 'number') return value; if (typeof value === 'string') return redactText(value).slice(0, 1200); if (depth >= 4) return '[MAX_DEPTH]'; if (Array.isArray(value)) return value.slice(0, 30).map(item => sanitize(item, depth + 1)); if (typeof value === 'object') { const result = {}; Object.entries(value).slice(0, 40).forEach(([childKey, childValue]) => { result[childKey] = sanitize(childValue, depth + 1, childKey); }); return result; } return redactText(value); } function add(level, scope, message, detail = null) { const normalizedLevel = ['debug', 'info', 'warn', 'error'].includes(level) ? level : 'info'; const entry = { id: ++sequence, time: new Date().toISOString(), level: normalizedLevel, scope: redactText(scope || 'app').slice(0, 80), message: redactText(message || '').slice(0, 500), detail: detail === null || detail === undefined ? null : sanitize(detail) }; entries.push(entry); if (entries.length > MAX_ENTRIES) entries.splice(0, entries.length - MAX_ENTRIES); if (normalizedLevel === 'warn' || normalizedLevel === 'error') { const method = normalizedLevel === 'error' ? 'error' : 'warn'; console[method](`[WHUT Helper][${entry.scope}] ${entry.message}`, entry.detail || ''); } return entry; } function counts() { return entries.reduce((result, entry) => { result[entry.level] = (result[entry.level] || 0) + 1; return result; }, { debug: 0, info: 0, warn: 0, error: 0 }); } function snapshot() { return { schema: SCHEMA, generatedAt: new Date().toISOString(), script: { name: 'WHUT教务系统小助手', version: SCRIPT_VERSION, runtimeMs: Date.now() - startedAt }, environment: { url: `${location.origin}${location.pathname}`, userAgent: navigator.userAgent, language: navigator.language, theme: UIKitRuntime.getResolvedTheme() }, state: { courses: state.rawData.length, filteredCourses: state.filteredData.length, semesters: state.semesters.size, monitorLoaded: state.monitor.isLoaded, transcriptLoaded: state.transcript.isLoaded, transcriptRecords: state.transcript.records.length, gpaLoaded: !!GPAModule?.isLoaded, evalLoaded: state.eval.isLoaded, printLoaded: state.isPrintLoaded }, counts: counts(), logs: entries.map(entry => sanitize(entry)) }; } function render(container) { if (!container) return; const summary = counts(); const recent = entries.slice(-60).reverse(); container.innerHTML = `

诊断日志用于定位接口、解析和导出异常。仅保留本次页面会话中的最近 ${MAX_ENTRIES} 条记录。

总记录${entries.length}
请求错误${summary.error}
警告${summary.warn}
运行时间${Math.max(1, Math.round((Date.now() - startedAt) / 60000))}m
${recent.length ? recent.map(entry => `
${escapeHTML(entry.time.slice(11, 19))} ${escapeHTML(entry.scope)} ${escapeHTML(entry.message)}
`).join('') : '
当前暂无诊断记录
'}

导出时不会包含 Cookie、请求正文或完整学号;常见身份字段和长数字标识会自动脱敏。

`; } function exportLogs() { const payload = snapshot(); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); FileDownload.saveText(JSON.stringify(payload, null, 2), `WHUT_助手诊断_${timestamp}.json`); add('info', 'diagnostics', '诊断日志已导出', { entries: payload.logs.length }); Toast.fire({ icon: 'success', title: '诊断日志已导出' }); } function openPanel() { AppDialog.fire({ title: '诊断与日志', unsafeHTML: '
', width: 660, showConfirmButton: false, showCloseButton: true, didMount: modal => { const container = modal.querySelector('#diagnostics-container-modal'); render(container); container?.addEventListener('click', event => { const action = event.target.closest('[data-diagnostics-action]')?.dataset.diagnosticsAction; if (action === 'export') exportLogs(); if (action === 'clear') { entries.length = 0; add('info', 'diagnostics', '诊断日志已清空'); render(container); } }); } }); } return Object.freeze({ debug: (scope, message, detail) => add('debug', scope, message, detail), info: (scope, message, detail) => add('info', scope, message, detail), warn: (scope, message, detail) => add('warn', scope, message, detail), error: (scope, message, detail) => add('error', scope, message, detail), openPanel }); })(); Diagnostics.info('lifecycle', '脚本运行环境已初始化', { version: SCRIPT_VERSION }); const UI = Object.freeze({ state(type, title, detail = '', compact = false) { const normalized = ['loading', 'empty', 'error', 'warning', 'success'].includes(type) ? type : 'empty'; const icons = { empty: 'info', error: 'close', warning: 'warning', success: 'check' }; const icon = normalized === 'loading' ? '' : iconSVG(icons[normalized] || 'info'); const role = normalized === 'error' ? 'alert' : 'status'; return `
${escapeHTML(title)} ${detail ? `${escapeHTML(detail)}` : ''}
`; } }); 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 waitFor = ms => new Promise(resolve => setTimeout(resolve, Math.max(0, Number(ms) || 0))); // 数据访问层:统一请求超时、重试、去重、表单编码、解析与取消逻辑。 const API = (() => { const DEFAULT_TIMEOUT = 15000; const FORM_HEADERS = Object.freeze({ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest' }); const activeRequests = new Set(); const inFlight = new Map(); let userInfoPromise = null; const requestLabel = url => { try { const parsed = new URL(String(url || ''), location.href); return `${parsed.origin === location.origin ? '' : parsed.origin}${parsed.pathname}`; } catch (error) { return String(url || '').split('?')[0]; } }; const makeError = (message, code, detail = {}) => Object.assign(new Error(message), { code, ...detail }); const isRetryable = error => error?.retryable === true || ['NETWORK', 'TIMEOUT', 'HTTP_5XX'].includes(error?.code); function requestOnce(options, timeout) { return new Promise((resolve, reject) => { let settled = false; let timer = 0; let handle = null; const finish = (callback, value) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); if (handle) activeRequests.delete(handle); callback(value); }; const requestOptions = { ...options, onload: response => { const status = Number(response?.status) || 0; if (status >= 500) { finish(reject, makeError(`服务器暂时不可用(HTTP ${status})`, 'HTTP_5XX', { status, retryable: true })); return; } if (status >= 400) { finish(reject, makeError(`请求失败(HTTP ${status})`, 'HTTP_ERROR', { status, retryable: false })); return; } finish(resolve, response); }, onerror: response => finish(reject, makeError('网络连接失败', 'NETWORK', { status: Number(response?.status) || 0, retryable: true })), ontimeout: () => finish(reject, makeError(`请求超时(${Math.round(timeout / 1000)} 秒)`, 'TIMEOUT', { retryable: true })), onabort: () => finish(reject, makeError('请求已取消', 'ABORTED', { retryable: false })) }; try { // Safari 用户脚本管理器需要绝对请求地址;保留请求参数和回调。 handle = GM_xmlhttpRequest({ ...requestOptions, url: new URL(requestOptions.url, window.location.href).href }); if (handle) activeRequests.add(handle); timer = setTimeout(() => { finish(reject, makeError(`请求超时(${Math.round(timeout / 1000)} 秒)`, 'TIMEOUT', { retryable: true })); try { handle?.abort?.(); } catch (error) { Diagnostics.debug('network', '取消超时请求时发生异常', error); } }, timeout); } catch (error) { finish(reject, makeError(error?.message || '请求初始化失败', 'NETWORK', { retryable: true, cause: error })); } }); } async function request(options = {}) { const { timeout: rawTimeout, retries: rawRetries, retryDelay: rawRetryDelay, dedupeKey, ...gmOptions } = options; if (!gmOptions.url) throw makeError('请求地址不能为空', 'INVALID_URL', { retryable: false }); const timeout = Math.max(1000, Number(rawTimeout) || DEFAULT_TIMEOUT); const retries = Math.max(0, Math.min(3, Number(rawRetries) || 0)); const retryDelay = Math.max(100, Number(rawRetryDelay) || 350); const method = String(gmOptions.method || 'GET').toUpperCase(); const label = requestLabel(gmOptions.url); const key = dedupeKey ? String(dedupeKey) : ''; if (key && inFlight.has(key)) { Diagnostics.debug('network', '复用进行中的请求', { method, url: label, key }); return inFlight.get(key); } const task = (async () => { const started = performance.now(); Diagnostics.debug('network', '请求开始', { method, url: label, timeout, retries }); let lastError = null; for (let attempt = 0; attempt <= retries; attempt++) { try { const response = await requestOnce(gmOptions, timeout); Diagnostics.info('network', '请求完成', { method, url: label, status: Number(response?.status) || 0, attempt: attempt + 1, durationMs: Math.round(performance.now() - started) }); return response; } catch (error) { lastError = error; if (attempt >= retries || !isRetryable(error)) break; const delay = retryDelay * (2 ** attempt); Diagnostics.warn('network', '请求失败,准备重试', { method, url: label, code: error?.code, status: error?.status || 0, attempt: attempt + 1, retryInMs: delay }); await waitFor(delay); } } Diagnostics.error('network', '请求失败', { method, url: label, code: lastError?.code || 'UNKNOWN', status: lastError?.status || 0, durationMs: Math.round(performance.now() - started), error: lastError }); throw lastError || makeError('请求失败', 'UNKNOWN', { retryable: false }); })(); if (key) inFlight.set(key, task); try { return await task; } finally { if (key && inFlight.get(key) === task) inFlight.delete(key); } } async function requestJSON(options = {}) { try { const response = await request(options); const text = String(response?.responseText ?? (typeof response?.response === 'string' ? response.response : '')).trim(); if (!text) throw makeError('接口返回空内容', 'EMPTY_RESPONSE', { retryable: false }); if (/^]+login|登录页面/i.test(text.slice(0, 2000))) { throw makeError('登录状态失效或接口返回了网页内容', 'AUTH_HTML', { retryable: false }); } return JSON.parse(text); } catch (error) { const normalized = error?.code ? error : makeError('接口返回的 JSON 无法解析', 'INVALID_JSON', { retryable: false, cause: error }); if (!['NETWORK', 'TIMEOUT', 'HTTP_5XX', 'HTTP_ERROR', 'ABORTED'].includes(normalized.code)) { Diagnostics.error('network', '响应解析失败', { method: String(options.method || 'GET').toUpperCase(), url: requestLabel(options.url), code: normalized.code, error: normalized }); } throw normalized; } } function formOptions(options = {}) { const { method = 'POST', headers = {}, data = '', ...rest } = options; const encodedData = typeof data === 'string' ? data : (data instanceof URLSearchParams ? data.toString() : new URLSearchParams(compactParams(data)).toString()); return { ...rest, method, headers: { ...FORM_HEADERS, ...headers }, data: encodedData }; } const requestForm = options => request(formOptions(options)); const requestFormJSON = options => requestJSON(formOptions(options)); const api = { request, requestJSON, requestForm, requestFormJSON, getUserInfo: async ({ force = false } = {}) => { if (!force && state.userInfo && Date.now() - state.userInfoLoadedAt < 300000) return state.userInfo; if (userInfoPromise) return userInfoPromise; userInfoPromise = requestJSON({ method: 'GET', url: 'https://jwxt.whut.edu.cn/jwapp/sys/homeapp/api/home/currentUser.do', retries: 1, dedupeKey: 'current-user' }).then(data => { if (String(data?.code) === '0' && data.datas) { state.userId = data.datas.userId; state.userName = data.datas.userName; state.userInfo = data.datas; state.userInfoLoadedAt = Date.now(); return data.datas; } throw makeError('未能获取身份信息,请确认已登录教务系统', 'USER_INFO', { retryable: false }); }).finally(() => { userInfoPromise = null; }); return userInfoPromise; }, abortAll: reason => { Diagnostics.info('network', '取消全部活动请求', { reason: reason || 'pagehide', count: activeRequests.size }); Array.from(activeRequests).forEach(handle => { try { handle?.abort?.(); } catch (error) { Diagnostics.debug('network', '取消请求时发生异常', error); } }); activeRequests.clear(); } }; return Object.freeze(api); })(); // 移动教务服务:仅复用当前账号会话,认证信息只在内存中使用。 const MobileAPI = (() => { let sessionPromise = null; let sessionToken = ''; let cancelSessionFrame = null; const rejectedTokens = new Set(); AppResources.add(() => { cancelSessionFrame?.(); sessionToken = ''; rejectedTokens.clear(); }); const tokenValue = value => { try { value = decodeURIComponent(String(value || '')).replace(/^"|"$/g, ''); } catch (_) { return ''; } return /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value) ? value : ''; }; function tokenFromCookies(cookies) { return String(cookies || '').split(';').map(part => part.trim()).filter(part => part.startsWith('Authorization=')) .map(part => tokenValue(part.slice('Authorization='.length))).find(token => token && !rejectedTokens.has(token)) || ''; } function authorization() { // 新建立会话的令牌优先,避免桌面路径下的旧 Cookie 覆盖移动路径的新令牌。 return sessionToken && !rejectedTokens.has(sessionToken) ? sessionToken : tokenFromCookies(document.cookie); } function sessionFrame() { return new Promise(resolve => { const frame = document.createElement('iframe'); frame.hidden = true; frame.tabIndex = -1; frame.setAttribute('aria-hidden', 'true'); frame.title = '建立移动教务会话'; let timer = 0, poll = 0, finished = false; const finish = token => { if (finished) return; finished = true; clearTimeout(timer); clearInterval(poll); frame.removeEventListener('load', inspect); frame.remove(); cancelSessionFrame = null; resolve(token || ''); }; const inspect = () => { try { const url = new URL(frame.contentWindow.location.href); if (url.origin !== location.origin || !url.pathname.startsWith('/jwmobile/')) return; const token = tokenFromCookies(frame.contentDocument?.cookie); if (token) finish(token); } catch (_) { /* 统一登录跳转期间可能暂时跨域,等待返回移动教务。 */ } }; cancelSessionFrame = () => finish(''); frame.addEventListener('load', inspect); timer = setTimeout(() => finish(''), 12000); poll = setInterval(inspect, 200); frame.src = '/jwmobile/index'; (document.body || document.documentElement).appendChild(frame); }); } async function prepareSession() { if (sessionPromise) return sessionPromise; sessionPromise = (async () => { try { const response = await API.request({ method: 'GET', url: '/jwmobile/index', timeout: 15000 }); const cookie = String(response.responseHeaders || '').match(/(?:^|\n)set-cookie:\s*Authorization=([^;\r\n]+)/i); const token = cookie && tokenValue(cookie[1]); if (token && !rejectedTokens.has(token)) sessionToken = token; } catch (error) { Diagnostics.debug('mobile-session', '移动入口请求未完成,改用页面建立会话', { code: error?.code }); } if (!authorization()) { // 执行官方页面的正常登录跳转,并在 /jwmobile 路径读取路径限定 Cookie。 sessionToken = await sessionFrame(); } })().finally(() => { sessionPromise = null; }); return sessionPromise; } async function request(path, params = {}, { method = 'GET' } = {}) { if (!/^\/(?:common|score|allSchedule|schedule)\//.test(path)) throw new Error('无效的移动教务接口'); const read = async token => { const query = new URLSearchParams(params).toString(); const response = await API.requestJSON({ method, url: `https://jwxt.whut.edu.cn/jwmobile/biz/v410${path}${method === 'GET' && query ? `?${query}` : ''}`, headers: { Accept: 'application/json', ...(token ? { Authorization: token } : {}), ...(method === 'POST' ? { 'Content-Type': 'application/json' } : {}) }, ...(method === 'POST' ? { data: JSON.stringify(params) } : {}), timeout: 15000 }); if (String(response?.code) !== '200') { const isAuth = /401|403/.test(String(response?.code)) || /登录|认证|token/i.test(response?.msg || ''); throw Object.assign(new Error(isAuth ? '移动教务认证失败' : (response?.msg || '移动教务返回异常')), { code: isAuth ? 'MOBILE_AUTH' : 'MOBILE_RESPONSE' }); } return response; }; if (!authorization()) await prepareSession(); const token = authorization(); try { return await read(token); } catch (error) { if (!['AUTH_HTML', 'MOBILE_AUTH'].includes(error.code) && ![401, 403].includes(error.status)) throw error; if (token) rejectedTokens.add(token); while (rejectedTokens.size > 8) rejectedTokens.delete(rejectedTokens.values().next().value); if (sessionToken === token) sessionToken = ''; if (!authorization() || authorization() === token) await prepareSession(); try { return await read(authorization()); } catch (retryError) { if (['AUTH_HTML', 'MOBILE_AUTH'].includes(retryError?.code) || [401, 403].includes(retryError?.status)) { throw Object.assign(new Error('已尝试自动连接移动教务,但认证仍未成功;请重新登录教务系统后重试'), { code: 'MOBILE_AUTH', retryable: false }); } throw retryError; } } } return Object.freeze({ request }); })(); // ======================================================================== // 组件:分段控件 // 职责:统一单选语义、选中态和方向键 / Home / End 导航。 // ======================================================================== const SegmentedControl = (() => { const instances = new WeakMap(); function enhance(root, { itemSelector = '[data-segment-value]', activeClass = 'active', onChange = () => {} } = {}) { if (!root || instances.has(root)) return instances.get(root) || null; const items = () => Array.from(root.querySelectorAll(itemSelector)).filter(item => !item.disabled); const valueOf = item => item.dataset.segmentValue ?? item.dataset.gpaMode ?? ''; const select = (value, { emit = true, focus = false } = {}) => { const allItems = items(); const target = allItems.find(item => valueOf(item) === String(value)) || allItems[0]; if (!target) return ''; allItems.forEach(item => { const selected = item === target; item.classList.toggle(activeClass, selected); item.setAttribute('aria-checked', String(selected)); item.tabIndex = selected ? 0 : -1; }); if (focus) target.focus({ preventScroll: true }); if (emit) onChange(valueOf(target), target); return valueOf(target); }; const handleClick = event => { const item = event.target.closest(itemSelector); if (item && root.contains(item) && !item.disabled) select(valueOf(item)); }; const handleKeydown = event => { const item = event.target.closest(itemSelector); if (!item || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; const allItems = items(); if (!allItems.length) return; event.preventDefault(); let index = Math.max(0, allItems.indexOf(item)); if (event.key === 'Home') index = 0; else if (event.key === 'End') index = allItems.length - 1; else index = (index + (event.key === 'ArrowRight' ? 1 : -1) + allItems.length) % allItems.length; select(valueOf(allItems[index]), { focus: true }); }; root.setAttribute('role', 'radiogroup'); items().forEach(item => item.setAttribute('role', 'radio')); root.addEventListener('click', handleClick); root.addEventListener('keydown', handleKeydown); const instance = Object.freeze({ select, getValue: () => valueOf(items().find(item => item.getAttribute('aria-checked') === 'true') || items()[0]), destroy() { root.removeEventListener('click', handleClick); root.removeEventListener('keydown', handleKeydown); instances.delete(root); } }); instances.set(root, instance); return instance; } return Object.freeze({ enhance, get: root => instances.get(root) || null }); })(); // ======================================================================== // 组件:搜索框 // 职责:统一延迟检索、清空按钮、Escape 清空和状态同步。 // ======================================================================== const SearchControl = (() => { const instances = new WeakMap(); function enhance(input, { onInput = () => {}, delay = 300 } = {}) { if (!input || instances.has(input)) return instances.get(input) || null; const clearButton = input.closest('.ui-search')?.querySelector('.ui-search-clear'); const emit = debounce(() => onInput(input.value.trim(), input), Math.max(0, delay)); const sync = () => { if (clearButton) clearButton.hidden = !input.value; }; const handleInput = () => { sync(); emit(); }; const clear = ({ focus = true, immediate = true } = {}) => { if (!input.value) { if (focus) input.focus({ preventScroll: true }); return; } input.value = ''; sync(); emit.cancel(); if (immediate) onInput('', input); if (focus) input.focus({ preventScroll: true }); }; const handleKeydown = event => { if (event.key !== 'Escape' || !input.value) return; event.preventDefault(); event.stopPropagation(); clear(); }; const handleClear = () => clear(); input.addEventListener('input', handleInput); input.addEventListener('keydown', handleKeydown); clearButton?.addEventListener('click', handleClear); sync(); const instance = Object.freeze({ clear, setValue(value, { emit: shouldEmit = false } = {}) { input.value = String(value ?? ''); sync(); if (shouldEmit) onInput(input.value.trim(), input); }, destroy() { emit.cancel(); input.removeEventListener('input', handleInput); input.removeEventListener('keydown', handleKeydown); clearButton?.removeEventListener('click', handleClear); instances.delete(input); } }); instances.set(input, instance); return instance; } return Object.freeze({ enhance, get: input => instances.get(input) || null }); })(); // ======================================================================== // 组件:折叠区域,复用焦点处理、展开状态与按钮文案。 // ======================================================================== const DisclosureControl = Object.freeze({ actions({ resetId, toggleId, controlsId, label = '查询条件', toggleClass = '' }) { return `
`; }, enhance(button, content, { label = '查询条件', collapsed = true, onChange = () => {} } = {}) { const setCollapsed = value => { const next = Boolean(value); if (next) { if (content.contains(content.getRootNode().activeElement)) button.focus(); SelectControl.closeAll(); } content.hidden = next; button.setAttribute('aria-expanded', String(!next)); button.setAttribute('aria-controls', content.id); button.setAttribute('aria-label', `${next ? '展开' : '收起'}${label}`); button.querySelector('span').textContent = next ? '展开' : '收起'; onChange(next); }; const toggle = () => setCollapsed(!content.hidden); button.classList.add('disclosure-toggle'); button.addEventListener('click', toggle); setCollapsed(collapsed); return { setCollapsed, destroy: () => button.removeEventListener('click', toggle) }; } }); // 组件:上下横向滚动条同步,尺寸变化时重新测量,销毁时释放监听。 const HorizontalScrollControl = Object.freeze({ enhance(top, viewport, content) { const fromTop = () => { if (viewport.scrollLeft !== top.scrollLeft) viewport.scrollLeft = top.scrollLeft; }; const fromViewport = () => { if (top.scrollLeft !== viewport.scrollLeft) top.scrollLeft = viewport.scrollLeft; }; const measure = () => { top.style.width = `${viewport.clientWidth}px`; top.firstElementChild.style.width = `${viewport.scrollWidth}px`; top.hidden = viewport.scrollWidth <= viewport.clientWidth + 1; fromViewport(); }; top.addEventListener('scroll', fromTop, { passive: true }); viewport.addEventListener('scroll', fromViewport, { passive: true }); const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(measure) : null; observer?.observe(viewport); observer?.observe(content); window.addEventListener('resize', measure); measure(); return { destroy() { observer?.disconnect(); top.removeEventListener('scroll', fromTop); viewport.removeEventListener('scroll', fromViewport); window.removeEventListener('resize', measure); } }; } }); // 组件:选择器(单选 / 多选),统一浮层、定位、键盘导航与关闭逻辑。 const SelectControl = (() => { const instances = new Map(); let active = null; let sequence = 0; let globalsBound = false; const handleDocumentPointerDown = event => { if (!active) return; const path = event.composedPath(); if (!path.includes(active.wrapper) && !path.includes(active.menu)) close(active); }; const handleWindowResize = debounce(() => active && position(active), 60); const handleWindowScroll = event => { if (!active || event.composedPath?.().includes(active.menu)) return; position(active); }; const resolve = target => { if (!target) return null; if (typeof target === 'string') return instances.get(uiGet(target)) || null; return instances.get(target) || null; }; const enabledOptions = instance => Array.from(instance.menu.querySelectorAll('.ui-select-option:not(:disabled)')); function markActive(instance, optionButton) { instance.menu.querySelectorAll('.ui-select-option.is-active').forEach(node => node.classList.remove('is-active')); if (!optionButton) { instance.trigger.removeAttribute('aria-activedescendant'); return; } optionButton.classList.add('is-active'); instance.trigger.setAttribute('aria-activedescendant', optionButton.id); } function selectedOptions(instance) { return Array.from(instance.select.options).filter(option => option.selected); } function selectionLabel(instance) { const options = Array.from(instance.select.options); const selected = selectedOptions(instance); if (!instance.multiple) { return selected[0]?.textContent?.trim() || instance.select.dataset.placeholder || '请选择'; } if (!selected.length) return instance.select.dataset.emptyLabel || '未选择'; if (selected.length === options.length) return instance.select.dataset.allLabel || '全部'; return instance.select.dataset.selectionLabel || `已选 ${selected.length}/${options.length} ${instance.select.dataset.itemLabel || '项'}`; } function sync(instance) { if (!instance) return; instance.value.textContent = selectionLabel(instance); instance.trigger.disabled = instance.select.disabled; instance.trigger.setAttribute('aria-disabled', String(instance.select.disabled)); const currentActive = instance.menu.querySelector('.ui-select-option.is-active'); let firstSelected = null; Array.from(instance.menu.querySelectorAll('.ui-select-option')).forEach(button => { const option = instance.select.options[Number(button.dataset.index)]; const selected = !!option?.selected; button.setAttribute('aria-selected', String(selected)); if (selected && !firstSelected) firstSelected = button; }); if (!currentActive && !instance.menu.contains(UIKitRuntime.shadow.activeElement)) markActive(instance, firstSelected); } function updateMultipleSelection(instance, mode) { if (!instance?.multiple) return; let changed = false; Array.from(instance.select.options).forEach(option => { if (option.disabled) return; const next = mode === 'all' ? true : mode === 'clear' ? false : !option.selected; if (option.selected !== next) { option.selected = next; changed = true; } }); sync(instance); if (changed) instance.select.dispatchEvent(new Event('change', { bubbles: true })); } function buildMenu(instance) { const fragment = document.createDocumentFragment(); if (instance.multiple) { const actions = document.createElement('div'); actions.className = 'ui-select-actions'; actions.setAttribute('role', 'group'); actions.setAttribute('aria-label', '批量选择'); [['all', '全选'], ['invert', '反选'], ['clear', '清空']].forEach(([action, label]) => { const button = document.createElement('button'); button.type = 'button'; button.className = 'ui-select-action'; button.dataset.selectAction = action; button.textContent = label; button.addEventListener('click', event => { event.stopPropagation(); updateMultipleSelection(instance, action); }); actions.appendChild(button); }); fragment.appendChild(actions); } Array.from(instance.select.options).forEach((option, index) => { const button = document.createElement('button'); button.type = 'button'; button.id = `${instance.uid}-option-${index}`; button.className = 'ui-select-option'; button.dataset.index = String(index); button.dataset.value = option.value; button.setAttribute('role', 'option'); button.disabled = option.disabled; button.innerHTML = ``; button.querySelector('.ui-select-option-label').textContent = option.textContent.trim(); button.addEventListener('mouseenter', () => markActive(instance, button)); button.addEventListener('focus', () => markActive(instance, button)); button.addEventListener('click', () => selectIndex(instance, index)); button.addEventListener('keydown', event => handleOptionKeydown(instance, event)); fragment.appendChild(button); }); instance.menu.replaceChildren(fragment); sync(instance); } function position(instance) { if (!instance?.wrapper.classList.contains('is-open')) return; const rect = instance.trigger.getBoundingClientRect(); if (!rect.width || !rect.height) return close(instance); const margin = 12; const width = Math.min(Math.max(rect.width, 180), window.innerWidth - margin * 2); const below = Math.max(96, window.innerHeight - rect.bottom - margin); const above = Math.max(96, rect.top - margin); const openAbove = below < 180 && above > below; const maxHeight = Math.min(320, openAbove ? above : below); const left = clampValue(rect.left, margin, window.innerWidth - width - margin); instance.menu.style.width = `${Math.round(width)}px`; instance.menu.style.left = `${Math.round(left)}px`; instance.menu.style.right = 'auto'; instance.menu.style.maxHeight = `${Math.round(maxHeight)}px`; instance.menu.style.top = openAbove ? 'auto' : `${Math.round(rect.bottom + 6)}px`; instance.menu.style.bottom = openAbove ? `${Math.round(window.innerHeight - rect.top + 6)}px` : 'auto'; instance.menu.style.transformOrigin = openAbove ? 'bottom center' : 'top center'; } function open(instance, { focusOption = false, edge = '' } = {}) { if (!instance || instance.select.disabled) return; if (active && active !== instance) close(active); buildMenu(instance); active = instance; instance.wrapper.classList.add('is-open'); instance.trigger.setAttribute('aria-expanded', 'true'); instance.menu.classList.add('is-open'); UIKit.mount('menu', instance.menu); position(instance); if (focusOption) requestAnimationFrame(() => { const options = enabledOptions(instance); const selected = options.find(button => instance.select.options[Number(button.dataset.index)]?.selected); const target = edge === 'first' ? options[0] : edge === 'last' ? options.at(-1) : (selected || options[0]); target?.focus({ preventScroll: true }); target?.scrollIntoView({ block: 'nearest' }); }); } function close(instance = active, { restoreFocus = false } = {}) { if (!instance) return; instance.wrapper.classList.remove('is-open'); instance.trigger.setAttribute('aria-expanded', 'false'); instance.trigger.removeAttribute('aria-activedescendant'); instance.menu.classList.remove('is-open'); instance.menu.removeAttribute('style'); instance.wrapper.appendChild(instance.menu); if (active === instance) active = null; if (restoreFocus) instance.trigger.focus({ preventScroll: true }); } function selectIndex(instance, index) { const option = instance.select.options[index]; if (!option || option.disabled) return; const changed = instance.multiple ? true : instance.select.selectedIndex !== index; if (instance.multiple) option.selected = !option.selected; else instance.select.selectedIndex = index; sync(instance); if (instance.multiple) instance.menu.querySelector(`[data-index="${index}"]`)?.focus({ preventScroll: true }); else close(instance, { restoreFocus: true }); if (changed) instance.select.dispatchEvent(new Event('change', { bubbles: true })); } function moveFocus(instance, current, delta) { const options = enabledOptions(instance); if (!options.length) return; const index = Math.max(0, options.indexOf(current)); const next = options[(index + delta + options.length) % options.length]; next.focus({ preventScroll: true }); next.scrollIntoView({ block: 'nearest' }); } function handleOptionKeydown(instance, event) { if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); moveFocus(instance, event.currentTarget, event.key === 'ArrowDown' ? 1 : -1); } else if (event.key === 'Home' || event.key === 'End') { event.preventDefault(); const options = enabledOptions(instance); const target = event.key === 'Home' ? options[0] : options.at(-1); target?.focus({ preventScroll: true }); target?.scrollIntoView({ block: 'nearest' }); } else if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(instance, { restoreFocus: true }); } else if (event.key === 'Tab') { close(instance); } } function enhance(select) { if (!select || instances.has(select)) return instances.get(select) || null; const uid = `whut-select-${++sequence}`; const wrapper = document.createElement('div'); wrapper.className = 'ui-select'; wrapper.dataset.selectId = select.id || uid; const trigger = document.createElement('button'); trigger.type = 'button'; trigger.id = `${select.id || uid}-trigger`; trigger.className = 'ui-select-trigger'; trigger.setAttribute('role', 'combobox'); trigger.setAttribute('aria-haspopup', 'listbox'); trigger.setAttribute('aria-expanded', 'false'); trigger.setAttribute('aria-label', select.getAttribute('aria-label') || '请选择'); trigger.innerHTML = ``; const menu = document.createElement('div'); menu.id = `${uid}-menu`; menu.className = 'ui-select-menu'; menu.setAttribute('role', 'listbox'); menu.setAttribute('aria-label', select.getAttribute('aria-label') || '选项'); if (select.multiple) menu.setAttribute('aria-multiselectable', 'true'); trigger.setAttribute('aria-controls', menu.id); select.parentNode.insertBefore(wrapper, select); select.classList.add('ui-select-native'); select.setAttribute('aria-hidden', 'true'); select.tabIndex = -1; wrapper.append(select, trigger, menu); const label = Array.from(wrapper.parentElement?.querySelectorAll('label[for]') || []).find(node => node.htmlFor === select.id); if (label) label.htmlFor = trigger.id; const instance = { uid, select, multiple: select.multiple, wrapper, trigger, value: trigger.querySelector('.ui-select-value'), menu, observer: null }; instances.set(select, instance); trigger.addEventListener('click', event => { event.stopPropagation(); if (wrapper.classList.contains('is-open')) close(instance); else open(instance); }); trigger.addEventListener('keydown', event => { if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) { event.preventDefault(); const edge = event.key === 'Home' ? 'first' : event.key === 'End' ? 'last' : ''; open(instance, { focusOption: true, edge }); } else if ((event.key === 'Enter' || event.key === ' ') && !wrapper.classList.contains('is-open')) { event.preventDefault(); open(instance, { focusOption: true }); } else if (event.key === 'Escape' && wrapper.classList.contains('is-open')) { event.preventDefault(); event.stopPropagation(); close(instance, { restoreFocus: true }); } }); select.addEventListener('change', () => sync(instance)); if (typeof MutationObserver === 'function') { instance.observer = new MutationObserver(() => buildMenu(instance)); instance.observer.observe(select, { childList: true, subtree: true, characterData: true, attributes: true }); } buildMenu(instance); return instance; } function bindGlobals() { if (globalsBound) return; globalsBound = true; document.addEventListener('pointerdown', handleDocumentPointerDown, true); window.addEventListener('resize', handleWindowResize); window.addEventListener('scroll', handleWindowScroll, true); } function destroyAll() { if (active) close(active); handleWindowResize.cancel(); instances.forEach(instance => instance.observer?.disconnect()); instances.clear(); if (globalsBound) { document.removeEventListener('pointerdown', handleDocumentPointerDown, true); window.removeEventListener('resize', handleWindowResize); window.removeEventListener('scroll', handleWindowScroll, true); globalsBound = false; } } return Object.freeze({ initAll(root = uiGet('whut-helper-panel')) { root?.querySelectorAll('select[data-ui-select]').forEach(enhance); bindGlobals(); }, refresh(target) { const instance = resolve(target); if (instance) buildMenu(instance); }, sync(target) { sync(resolve(target)); }, setValue(target, value, { emit = false } = {}) { const instance = resolve(target); if (!instance) return false; if (instance.multiple) { const values = new Set(Array.from(value instanceof Set ? value : (Array.isArray(value) ? value : [value]), item => String(item))); let changed = false; Array.from(instance.select.options).forEach(option => { const selected = values.has(option.value); if (option.selected !== selected) { option.selected = selected; changed = true; } }); sync(instance); if (emit && changed) instance.select.dispatchEvent(new Event('change', { bubbles: true })); return true; } const option = Array.from(instance.select.options).find(item => item.value === String(value)); if (!option) return false; const changed = instance.select.value !== option.value; instance.select.value = option.value; sync(instance); if (emit && changed) instance.select.dispatchEvent(new Event('change', { bubbles: true })); return true; }, closeAll({ restoreFocus = false } = {}) { if (active) close(active, { restoreFocus }); }, destroyAll }); })(); // ======================================================================== // 模块:应用壳与交互装配 // 职责:创建面板、绑定公共交互,并按当前视图调度业务模块。 // ======================================================================== const APP_TABS = Object.freeze([ { id: 'monitor', label: '学业监测', shortLabel: '学业', icon: 'academic', active: true }, { id: 'analysis', label: '成绩分析', shortLabel: '成绩', icon: 'chart' }, { id: 'schedule', label: '课表查询', shortLabel: '课表', icon: 'calendar' }, { id: 'gpa', label: 'GPA查询', shortLabel: '绩点', icon: 'trophy' }, { id: 'print', label: '官方证明', shortLabel: '证明', icon: 'printer' }, { id: 'eval', label: '一键评教', shortLabel: '评教', icon: 'clipboard' } ]); const AdvancedPanel = Object.freeze({ enhance({ fab, panel }) { if (!fab || !panel) throw new Error('AdvancedPanel 缺少悬浮球或面板节点'); const fabDrag = makeDraggable(fab, fab, 'whut_fab_pos'); const panelDrag = makeDraggable(panel, uiGet('panel-drag-handle'), 'whut_panel_pos'); AppResources.add(fabDrag.destroy); AppResources.add(panelDrag.destroy); clampFabToViewport(fab); ButtonControl.enhanceAll(panel); SelectControl.initAll(panel); AppResources.add(HoverLiftControl.init(UIKitRuntime.shadow)); bindUIEvents(fab, panel, fabDrag); return Object.freeze({ fab, panel, isFabDragging: fabDrag }); } }); function initUI() { UIKit.ensure(); if (uiGet('whut-fab')) return; const savedFab = safeStorage.getJSON('whut_fab_pos', { right: '30px', bottom: 'max(30px, env(safe-area-inset-bottom))' }); const savedPanel = safeStorage.getJSON('whut_panel_pos', { left: `calc(50% - ${PANEL_LAYOUT.width / 2}px)`, top: '6vh' }); const fab = document.createElement('button'); fab.id = 'whut-fab'; fab.type = 'button'; fab.innerHTML = ``; fab.title = "打开教务系统小助手"; fab.setAttribute('aria-label', '打开教务系统小助手'); fab.setAttribute('aria-controls', 'whut-helper-panel'); fab.setAttribute('aria-expanded', 'false'); 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; UIKit.mount('panel', fab); const panel = document.createElement('div'); panel.id = 'whut-helper-panel'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'WHUT教务系统小助手'); panel.setAttribute('aria-modal', 'false'); panel.setAttribute('aria-hidden', 'true'); panel.setAttribute('tabindex', '-1'); panel.style.left = savedPanel.left; panel.style.top = savedPanel.top; panel.innerHTML = `
WHUT教务系统小助手 毫厘制作
${APP_TABS.map(tab => ``).join('')}
点击加载个人学业监测数据...
${UI.state('loading', '正在加载学业监测', '培养方案与预警信息将自动同步')}
`; UIKit.mount('panel', panel); AdvancedPanel.enhance({ fab, panel }); if (state.rawData.length) { updateFilterUI(); applyFilters(); } else proactiveFetch(); MonitorModule.init(); AppResources.schedule(() => UpdateModule.autoCheck(), 2500); } function clampValue(value, min, max) { if (max < min) return min; return Math.min(Math.max(value, min), max); } function clampPanelToViewport(panel) { const margin = PANEL_LAYOUT.viewportMargin; 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(PANEL_LAYOUT.width, maxWidth); const height = Math.min(Math.round(window.innerHeight * PANEL_LAYOUT.heightRatio), 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 || 60; 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) { safeStorage.setJSON('whut_fab_pos', { 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 = fab.getBoundingClientRect().width || 60; 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'); panel.setAttribute('aria-hidden', 'false'); fab.setAttribute('aria-expanded', 'true'); placePanelFromFab(panel, fab); fab.style.display = 'none'; savePanelState(panel); AppResources.schedule(() => panel.focus({ preventScroll: true }), 60); } function closePanelToFab(fab, panel) { SelectControl.closeAll(); clampPanelToViewport(panel); savePanelState(panel); placeFabFromPanel(fab, panel); panel.classList.remove('open'); panel.setAttribute('aria-hidden', 'true'); fab.setAttribute('aria-expanded', 'false'); fab.focus({ preventScroll: true }); } function makeDraggable(el, handle, storageKey) { let isDragging = false, startX, startY, initialX, initialY, activePointerId = null; function dragStart(e) { if (e.button !== undefined && e.button !== 0) return; const target = e.target instanceof Element ? e.target : null; if (!target) return; const interactive = target.closest('button, textarea, input, select, a'); const isHandleControl = handle === el && interactive === handle; if (target.closest('.header-close, .helper-tabs') || (interactive && !isHandleControl)) return; isDragging = false; activePointerId = e.pointerId; 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('pointermove', drag, { passive: false }); document.addEventListener('pointerup', dragEnd); document.addEventListener('pointercancel', dragEnd); } handle.addEventListener('pointerdown', dragStart); function drag(e) { if (activePointerId !== null && e.pointerId !== activePointerId) return; const dx = e.clientX - startX; const dy = e.clientY - startY; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { isDragging = true; el.classList.add('is-dragging'); e.preventDefault(); } el.style.left = (initialX + dx) + 'px'; el.style.top = (initialY + dy) + 'px'; } function dragEnd(e) { if (activePointerId !== null && e.pointerId !== activePointerId) return; document.removeEventListener('pointermove', drag); document.removeEventListener('pointerup', dragEnd); document.removeEventListener('pointercancel', dragEnd); activePointerId = null; el.style.transition = ''; el.classList.remove('is-dragging'); if (el.id === 'whut-helper-panel') clampPanelToViewport(el); if (el.id === 'whut-fab') clampFabToViewport(el); if (isDragging && storageKey) { const saved = safeStorage.getJSON(storageKey, {}); const next = { ...saved, left: el.style.left, top: el.style.top }; safeStorage.setJSON(storageKey, next); } AppResources.schedule(() => { isDragging = false; }, 50); } const getDragging = () => isDragging; getDragging.destroy = () => { handle.removeEventListener('pointerdown', dragStart); document.removeEventListener('pointermove', drag); document.removeEventListener('pointerup', dragEnd); document.removeEventListener('pointercancel', dragEnd); }; return getDragging; } function savePanelState(panel) { const rect = panel.getBoundingClientRect(); const saved = safeStorage.getJSON('whut_panel_pos', {}); safeStorage.setJSON('whut_panel_pos', { ...saved, left: panel.style.left || `${Math.round(rect.left)}px`, top: panel.style.top || `${Math.round(rect.top)}px` }); } function loadHelperView(targetId) { VIEW_LOADERS[targetId]?.(); } function activateHelperTab(tab, { focus = false } = {}) { if (!tab) return; SelectControl.closeAll(); const panel = tab.closest('#whut-helper-panel'); const targetId = tab.dataset.target; const target = panel?.querySelector(`#${targetId}`); if (!panel || !target) return; panel.querySelectorAll('.helper-tab').forEach(item => { const active = item === tab; item.classList.toggle('active', active); item.setAttribute('aria-selected', String(active)); item.tabIndex = active ? 0 : -1; }); panel.querySelectorAll('.tab-view').forEach(view => { const active = view === target; view.classList.toggle('active', active); view.setAttribute('aria-hidden', String(!active)); }); if (focus) tab.focus({ preventScroll: true }); loadHelperView(targetId); } function handleHelperTabKeydown(event) { const keys = ['ArrowLeft', 'ArrowRight', 'Home', 'End']; if (!keys.includes(event.key)) return; const tabs = Array.from(event.currentTarget.closest('.helper-tabs').querySelectorAll('.helper-tab')); const current = tabs.indexOf(event.currentTarget); if (current < 0) return; event.preventDefault(); const nextIndex = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (current + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length; activateHelperTab(tabs[nextIndex], { focus: true }); } function syncThemeButton() { const button = uiGet('h-theme'); if (!button) return; const mode = UIKit.getTheme(); const config = { auto: { icon: 'themeAuto', strokeWidth: 1.8, label: '跟随系统', next: '浅色' }, light: { icon: 'sun', label: '浅色', next: '深色' }, dark: { icon: 'moon', label: '深色', next: '跟随系统' } }[mode]; button.dataset.themeMode = mode; button.querySelector('.header-theme-icon').innerHTML = iconSVG(config.icon, 'uikit-icon', config.strokeWidth || 2); button.title = `当前${config.label};点击切换为${config.next}`; button.setAttribute('aria-label', `当前${config.label}主题,切换为${config.next}`); } function bindShellEvents(fab, panel, isFabDragging) { fab.addEventListener('click', () => { if (!isFabDragging()) openPanelFromFab(fab, panel); }); syncThemeButton(); uiGet('h-theme').addEventListener('click', () => { UIKit.cycleTheme(); syncThemeButton(); const labels = { auto: '跟随系统', light: '浅色', dark: '深色' }; Toast.fire({ icon: 'success', title: `已切换为${labels[UIKit.getTheme()]}主题`, timer: 1800 }); }); UIKitRuntime.shadow.addEventListener('uikit-theme-change', syncThemeButton); uiGet('h-diagnostics').addEventListener('click', () => Diagnostics.openPanel()); uiGet('h-update').addEventListener('click', () => UpdateModule.openPanel()); uiGet('h-version').addEventListener('click', () => UpdateModule.openPanel(true)); uiGet('h-help').addEventListener('click', () => HelpModule.open()); uiGet('h-close').addEventListener('click', () => closePanelToFab(fab, panel)); panel.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; SelectControl.closeAll(); closePanelToFab(fab, panel); }); const handlePanelResize = debounce(() => { if (panel.classList.contains('open')) { clampPanelToViewport(panel); savePanelState(panel); } else { fab.style.display = 'flex'; clampFabToViewport(fab); saveFabState(fab); } }, 120); AppResources.add(handlePanelResize.cancel); AppEvents.listen(window, 'resize', handlePanelResize); uiQueryAll('.helper-tab').forEach(tab => { tab.addEventListener('click', (e) => { activateHelperTab(e.currentTarget); if (e.currentTarget.id === 'tab-schedule') ScheduleModule.revealStudentOnClick(); else ScheduleModule.state.secretClicks = 0; }); tab.addEventListener('keydown', handleHelperTabKeydown); }); } function bindAnalysisEvents() { const disclosure = DisclosureControl.enhance(uiGet('analysis-toggle'), uiGet('analysis-controls'), { label: '成绩筛选与导出' }); AppResources.add(disclosure.destroy); const searchControl = SearchControl.enhance(uiGet('h-search'), { onInput: value => { state.filters.search = value; applyFilters(); } }); if (searchControl) AppResources.add(searchControl.destroy); uiGet('h-semester').addEventListener('change', (e) => { const selected = new Set(Array.from(e.target.selectedOptions, option => option.value)); state.filters.semesters = selected; state.filters.semesterAll = selected.size === state.semesters.size && state.semesters.size > 0; applyFilters(); }); uiGet('h-type').addEventListener('change', (e) => { state.filters.types = new Set(Array.from(e.target.selectedOptions, option => option.value)); state.filters.typeAll = state.filters.types.size === state.types.size && state.types.size > 0; applyFilters(); }); uiGet('h-sort').addEventListener('change', (e) => { state.filters.sort = e.target.value.trim(); applyFilters(); }); uiGet('h-reset').addEventListener('click', () => { state.filters = { search: '', semesters: new Set(state.semesters), semesterAll: true, types: new Set(state.types), typeAll: true, sort: 'semester_desc' }; searchControl?.setValue(''); SelectControl.setValue('h-type', state.filters.types); uiGet('h-sort').value = 'semester_desc'; SelectControl.setValue('h-semester', state.filters.semesters); SelectControl.sync('h-type'); SelectControl.sync('h-sort'); updateFilterUI(); applyFilters(); }); uiGet('btn-exp-cur-xls').addEventListener('click', ContextualExport.toExcel); uiGet('h-refresh').addEventListener('click', () => proactiveFetch({ force: true })); uiGet('btn-exp-cur-img').addEventListener('click', ContextualExport.toImage); uiGet('h-footer').addEventListener('click', event => { const trigger = event.target.closest('[data-analysis-gpa]'); if (trigger) void openAnalysisCourseDetails(trigger.dataset.analysisGpa === 'compulsory'); }); renderAnalysisFilterSummary(); } function bindGPAEvents() { uiGet('btn-gpa-semester-manager').addEventListener('click', GPAModule.openSemesterManager); uiGet('btn-exp-gpa-xls').addEventListener('click', GPAModule.exportToExcel); uiGet('btn-exp-gpa-img').addEventListener('click', GPAModule.exportToImage); } function bindMonitorEvents() { uiGet('btn-monitor-refresh').addEventListener('click', MonitorModule.refresh); uiGet('btn-monitor-export').addEventListener('click', MonitorModule.exportReport); uiGet('monitor-content').addEventListener('click', (e) => { const header = e.target.closest('.monitor-group-header'); if (!header) return; const id = header.dataset.groupId; if (!id) return; const container = uiGet('monitor-content'); const scrollTop = container.scrollTop; state.monitor.openGroupsTouched = true; if (state.monitor.openGroups.has(id)) state.monitor.openGroups.delete(id); else state.monitor.openGroups.add(id); MonitorModule.render(); const restoreFocus = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (callback => setTimeout(callback, 0)); restoreFocus(() => { container.scrollTop = scrollTop; Array.from(container.querySelectorAll('.monitor-group-header')).find(btn => btn.dataset.groupId === id)?.focus({ preventScroll: true }); }); }); } function bindPrintEvents() { uiGet('print-container').addEventListener('click', (e) => { const button = e.target.closest('.btn-dl-pdf'); if (button) PrintModule.downloadPDF(button.dataset.wid, button.dataset.cdmc); }); } function fillEvalConfigForm() { uiGet('pj-cfg-comment').value = state.eval.config.comment; SelectControl.setValue('pj-cfg-random', state.eval.config.randomScore ? 'true' : 'false'); uiGet('pj-cfg-delay').value = state.eval.config.delay; } function bindEvaluationEvents() { fillEvalConfigForm(); uiGet('pj-config-toggle').addEventListener('click', () => { const card = uiGet('pj-config-card'); const isOpening = card.classList.contains('collapsed'); if (isOpening) fillEvalConfigForm(); card.classList.toggle('collapsed'); uiGet('pj-config-toggle').setAttribute('aria-expanded', String(isOpening)); }); uiGet('pj-cfg-save').addEventListener('click', async () => { const result = await AppDialog.fire({ title: '保存评教配置?', text: '保存后将用于后续一键评教任务。', icon: 'question', showCancelButton: true, confirmButtonText: '保存', cancelButtonText: '取消' }); if (!result.isConfirmed) return; state.eval.config.comment = uiGet('pj-cfg-comment').value; state.eval.config.randomScore = uiGet('pj-cfg-random').value === 'true'; state.eval.config.delay = parseInt(uiGet('pj-cfg-delay').value) || 1500; safeStorage.set('whut_pj_comment', state.eval.config.comment); safeStorage.set('whut_pj_random', state.eval.config.randomScore); safeStorage.set('whut_pj_delay', state.eval.config.delay); uiGet('pj-config-card').classList.add('collapsed'); uiGet('pj-config-toggle').setAttribute('aria-expanded', 'false'); Toast.fire({ icon: 'success', title: '评教配置已保存' }); }); uiGet('pj-cfg-reset').addEventListener('click', async () => { const result = await AppDialog.fire({ title: '恢复默认评教配置?', text: '当前填写内容会被默认配置覆盖。', icon: 'warning', showCancelButton: true, confirmButtonText: '恢复默认', cancelButtonText: '取消' }); if (!result.isConfirmed) return; state.eval.config = { ...EVAL_DEFAULT_CONFIG }; safeStorage.set('whut_pj_comment', state.eval.config.comment); safeStorage.set('whut_pj_random', state.eval.config.randomScore); safeStorage.set('whut_pj_delay', state.eval.config.delay); fillEvalConfigForm(); Toast.fire({ icon: 'success', title: '已恢复默认配置' }); }); uiGet('pj-refresh-btn').addEventListener('click', () => EvalModule.loadData({ force: true })); uiGet('pj-run-btn').addEventListener('click', EvalModule.runSelected); } function bindUIEvents(fab, panel, isFabDragging) { bindShellEvents(fab, panel, isFabDragging); bindAnalysisEvents(); bindGPAEvents(); bindMonitorEvents(); bindPrintEvents(); bindEvaluationEvents(); } // ======================================================================== // 业务模块:成绩分析 // 职责:接管成绩响应、补全隐藏成绩、筛选统计并生成分析视图。 // ======================================================================== const GradeResponseInterceptor = (() => { const originalOpen = XMLHttpRequest.prototype.open; const originalSend = XMLHttpRequest.prototype.send; let installed = false; function patchedOpen(method, url) { this._whutHelperURL = url; return originalOpen.apply(this, arguments); } function patchedSend() { this.addEventListener('load', function handleGradeResponse() { if (!this._whutHelperURL?.includes('xscjcx.do') || !this.responseText || state.isFetching) return; try { const response = JSON.parse(this.responseText); const result = response?.datas?.xscjcx; if (result) processDataPart(result.rows || [], result.totalSize || 0); } catch (error) { Diagnostics.warn('grades', '页面成绩响应解析失败', error); } }, { once: true }); return originalSend.apply(this, arguments); } return Object.freeze({ install() { if (installed) return; XMLHttpRequest.prototype.open = patchedOpen; XMLHttpRequest.prototype.send = patchedSend; installed = true; }, destroy() { if (!installed) return; if (XMLHttpRequest.prototype.open === patchedOpen) XMLHttpRequest.prototype.open = originalOpen; if (XMLHttpRequest.prototype.send === patchedSend) XMLHttpRequest.prototype.send = originalSend; installed = false; } }); })(); GradeResponseInterceptor.install(); async function proactiveFetch({ force = false } = {}) { if (state.isFetching || (!force && state.rawData.length > 0)) return; state.isFetching = true; const listEl = uiGet('h-list'); ButtonControl.setLoading(uiGet('h-refresh'), true, '刷新中...'); try { // 移动端保留同一课程的多次考试记录;先获取,再用桌面接口补充元数据。 await MobileScoreModule.load({ force }); if (force) { state.transcript.isLoaded = false; state.transcript.lastAttempt = 0; } const 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' }); const res = await API.requestFormJSON({ url: '/jwapp/sys/cjcx/modules/cjcx/xscjcx.do', data: body, timeout: 15000, retries: 1, dedupeKey: 'grades-all' }); if (res.datas?.xscjcx?.rows) processDataPart(res.datas.xscjcx.rows, res.datas.xscjcx.totalSize || 0); else if (!state.rawData.length && listEl) listEl.innerHTML = UI.state('warning', '成绩数据暂未返回', '请确认登录状态,或进入教务系统“成绩查询”页面后重试'); } catch(error) { Diagnostics.error('grades', '主动获取成绩失败', error); if (!state.rawData.length && listEl) listEl.innerHTML = UI.state('error', '成绩数据加载异常', '检查网络状态后重新打开成绩分析模块'); } finally { state.isFetching = false; ButtonControl.setLoading(uiGet('h-refresh'), false); if (state.rawData.some(needsScoreFallback)) runHiddenScoreBackfill(); } } function renderSemesterOptions() { const select = uiGet('h-semester'); if (!select) return; const semesters = Array.from(state.semesters); const selected = state.filters.semesters; const allChecked = semesters.length > 0 && selected.size === semesters.length; select.replaceChildren(...semesters.map(value => { const option = document.createElement('option'); option.value = value; option.textContent = value; option.selected = selected.has(value); return option; })); state.filters.semesterAll = allChecked; SelectControl.refresh(select); } function isHiddenScoreText(item) { const text = typeof item === 'object' ? item?._scoreText : item; return String(text ?? '').trim() === '未评教'; } function isScoreNotice(value) { return /未评教|补考|缓考|暂缓|待定|未录入|未公布|未发布|暂未|未考试|未考|无成绩|成绩未出|待补/.test(String(value ?? '')); } function scoreEvidence(row) { const display = row?._scoreText ?? formatCourseScoreDisplay(row); const numeric = row?._score ?? Number(pickNumericCourseScore(row) || NaN); // 缓考记录中的 0 常是占位值,不能阻止后续来源回填。 if (isScoreNotice(display) && !(Number.isFinite(numeric) && numeric > 0)) return null; if (Number.isFinite(numeric) && numeric >= 0 && numeric <= 100) { const grade = String(display).match(/^(优秀|良好|中等|及格|不及格|合格|不合格|通过|不通过|优|良|中)(?:[((]\s*\d+(?:\.\d+)?\s*[))])?$/); return { text: grade ? `${grade[1]}(${numeric})` : String(numeric), numeric, passed: numeric >= 60 }; } const text = String(display || '').trim(); const grade = TRANSCRIPT_PARSER?.cleanScore(text.replace(/\s*[((]\d+(?:\.\d+)?[))]$/, '')); if (!grade || isScoreNotice(text)) return null; const gpa = gradeTextToGPA(text); return { text, numeric: null, passed: gpa !== null ? gpa > 0 : /免修|^优$|^良$|^中$/.test(text) }; } function needsScoreFallback(course) { return !scoreEvidence(course); } function gradeCourseKey(course) { return JSON.stringify([String(course.XNXQDM || '').trim(), String(course.KCH || '').trim() || `${normalizeMonitorDedupeToken(course.KCM)}|${formatCreditValue(course.XF)}`]); } function applyScoreAttempts(course, rows, source) { const attempts = rows.map(row => ({ row, score: scoreEvidence(row) })).filter(entry => entry.score); if (!attempts.length) return false; const passed = attempts.filter(entry => entry.score.passed); const ranked = (passed.length ? passed : attempts).slice().sort((a, b) => (b.score.numeric ?? -1) - (a.score.numeric ?? -1)); const best = ranked[0]; const texts = [...new Set(attempts.map(entry => entry.score.text))]; course._scoreText = passed.length ? best.score.text : texts.join(' / '); course._score = best.score.numeric; course.ZCJ = best.score.numeric ?? best.score.text; course.XSZCJMC = course._scoreText; course._gpa = best.score.numeric !== null ? calcWhutGPAFromScore(best.score.numeric) : gradeTextToGPA(best.score.text); course.XFJD = course._gpa; course._gpaExcludedReason = ''; course._scoreResolved = true; course._scoreAttempts = attempts.map(({ row, score }) => ({ score: score.text, exam: row.examProp || row.KSXZDM_DISPLAY || '', source })); if (source === 'mobile') course._scoreFixedByMobile = true; if (source === 'monitor') course._scoreFixedByMonitor = true; return true; } const MobileScoreModule = { normalize(response) { return (response?.data?.termScoreList || []).flatMap(term => (term.scoreList || []).map(row => normalizeGradeCourseRow({ ...row, WID: row.id, XNXQDM: row.termCode || term.termCode, KCH: row.courseId, KCM: row.courseName, XF: row.coursePoint, XSZCJ: row.score || row.specificReason || '-', KCXZDM_DISPLAY: row.majorFlag, KSXZDM_DISPLAY: row.examProp, SFYX: '1', _mobileRow: true }))); }, async load({ force = false } = {}) { const cache = state.mobileScores; if (cache.promise) return cache.promise; if (cache.loaded && !force) return cache.rows; cache.promise = (async () => { try { const response = await MobileAPI.request('/score/termScore', { termCode: '*', courseNature: '', courseName: '' }, { method: 'POST' }); if (!Array.isArray(response?.data?.termScoreList)) throw new Error('移动成绩数据格式异常'); cache.rows = MobileScoreModule.normalize(response); cache.loaded = true; processDataPart([], 0, { backfill: false }); return cache.rows; } catch (error) { Diagnostics.warn('grades', '移动成绩读取失败,继续使用其他成绩来源', error); return []; } finally { cache.attempted = true; cache.promise = null; } })(); return cache.promise; } }; 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; const abbreviation = s.match(/^(优|良|中)(?:[((]|$)/)?.[1]; if (abbreviation) return calcWhutGPAFromScore(TRANSCRIPT_GRADE_NUMERIC[abbreviation]); 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 isPendingScoreForGPA(course) { if (course?._scoreResolved && !needsScoreFallback(course)) return false; const text = [ course?._scoreText, course?.XSZCJMC, course?.DJCJ_DISPLAY, course?.CJZT_DISPLAY, course?.CJZT, course?.KSXZDM_DISPLAY, course?.SFJG_DISPLAY, course?.ZT_DISPLAY ].map(v => String(v ?? '').trim()).filter(Boolean).join(' '); return /缓考|暂缓|待定|未录入|未公布|未发布|暂未|未考试|未考|未修读|未获得|未选|待修|无成绩|成绩未出/.test(text); } function hasEffectiveScoreForGPA(course) { const scoreText = String(course?._scoreText ?? '').trim(); if (!scoreText || scoreText === '-' || isHiddenScoreText(course)) return false; if (isPendingScoreForGPA(course)) return false; return true; } const COMPULSORY_GPA_TYPE_CODES = new Set(['01', '05', '09', '10', '11', '13', '15']); const ELECTIVE_GPA_TYPE_CODES = new Set(['02', '03', '04', '16']); const COMPULSORY_GPA_TYPE_NAMES = ['专业必修', '体育必修', '大类必修', '学科必修', '实践课', '英语必修', '通识必修']; const ELECTIVE_GPA_TYPE_NAMES = ['专业选修', '专业限选', '个性课程', '通识选修']; function normalizeCourseTypeCode(value) { const text = String(value ?? '').trim(); if (!text) return ''; const match = text.match(/^\d+$/) || text.match(/\b\d{1,2}\b/); if (!match) return ''; return match[0].padStart(2, '0'); } function normalizeCourseTypeName(value) { return String(value ?? '').replace(/\s+/g, '').trim(); } function isCompulsoryCourseForGPA(courseObj) { const codeFields = [ courseObj?.KCXZDM, courseObj?.KCXZDM_CODE, courseObj?.KCXZDM_VALUE, courseObj?.courseNatureCode, courseObj?.natureCode ]; for (const field of codeFields) { const code = normalizeCourseTypeCode(field); if (COMPULSORY_GPA_TYPE_CODES.has(code)) return true; if (ELECTIVE_GPA_TYPE_CODES.has(code)) return false; } const nameFields = [ courseObj?.KCXZDM_DISPLAY, courseObj?.KCXZMC, courseObj?.KCXZ, courseObj?.courseNature, courseObj?.nature, courseObj?.categoryName ].map(normalizeCourseTypeName).filter(Boolean); for (const name of nameFields) { if (ELECTIVE_GPA_TYPE_NAMES.some(typeName => name === typeName || name.includes(typeName))) return false; if (COMPULSORY_GPA_TYPE_NAMES.some(typeName => name === typeName || name.includes(typeName))) return true; } const joined = nameFields.join(''); if (/选修|限选|个性课程/.test(joined)) return false; if (/必修|实践课/.test(joined)) return true; return false; } 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})` : withGradeNumeric(gradeText); } if (cleanScore !== '-') { if (isHiddenScoreText(cleanScore) && hasScoreValue(numericScore)) return numericScore; return !isNumericScore(cleanScore) && hasScoreValue(numericScore) && isNumericScore(numericScore) ? `${cleanScore}(${numericScore})` : withGradeNumeric(cleanScore); } if (isHiddenScoreText(gradeText) && hasScoreValue(numericScore)) return numericScore; if (hasScoreValue(gradeText)) return withGradeNumeric(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 isSelectedCourse = /已选课/.test(String(pickAnyField(row, ['ZT_DISPLAY', 'statusName', 'courseStatus']) || '')); const hasCategoryChildren = monitorChildArrays(row).some(({ rows }) => rows.some(child => child && typeof child === 'object' && !looksLikeMonitorCourseRowShallow(child))); return hasCourseName && (hasCourseMetric || isSelectedCourse) && !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'])); const isSelectedCourse = /已选课/.test(String(pickAnyField(row, ['ZT_DISPLAY', 'statusName', 'courseStatus']) || '')); return hasCourseName && (hasCourseMetric || isSelectedCourse); } 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 normalizeMonitorDedupeToken(val) { return String(val ?? '').trim().replace(/[\s\u00A0\u200B-\u200D\uFEFF]/g, ''); } function monitorCourseDedupeIdentity(row) { const courseId = normalizeMonitorDedupeToken(row?.courseId); if (courseId) return `id:${courseId}`; const courseName = normalizeMonitorDedupeToken(row?.courseName); const bracketId = courseName.match(/[[\[]([^[\[\]]]+)[\]]]$/)?.[1]; return bracketId ? `id:${normalizeMonitorDedupeToken(bracketId)}` : `name:${courseName}`; } function monitorCourseDisplayScore(row) { if (hasScoreValue(row?.scoreText)) return row.scoreText; if (hasScoreValue(row?.score)) return row.score; return '-'; } function dedupeMonitorCourses(rows, includeModule = true, loose = false) { const seen = new Set(); return rows.filter(r => { const key = [ monitorCourseDedupeIdentity(r), loose ? '' : normalizeMonitorDedupeToken(r.semester), normalizeMonitorDedupeToken(formatCreditValue(r.credit)), normalizeMonitorDedupeToken(monitorCourseDisplayScore(r)), normalizeMonitorDedupeToken(r.replacedCourseId || r.replacedCourseName || '-'), normalizeMonitorDedupeToken(formatCreditValue(r.replacedCourseCredit)), includeModule ? normalizeMonitorDedupeToken(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) && monitorCourseDisplayScore(r) !== '-'); } 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 || []).filter(row => { if (row.semester && course.XNXQDM && !sameText(row.semester, course.XNXQDM)) return false; if (row.courseId && course.KCH) return sameText(row.courseId, course.KCH); return sameText(row.courseName, course.KCM) && hasScoreValue(row.credit) && toNum(row.credit, -1) === course._credit; }); const exact = fallbacks.filter(row => sameText(row.semester, course.XNXQDM)); // 监测无学期时,只有当前课程不存在跨学期同名/重修记录才允许匹配。 if (!exact.length && state.rawData.some(row => row !== course && row.XNXQDM !== course.XNXQDM && (sameText(row.KCH, course.KCH) || sameText(row.KCM, course.KCM)))) return []; return (exact.length ? exact : fallbacks).map(row => normalizeGradeCourseRow(row.raw || row)); } function applyScoreFallbacks() { if (!state.mobileScores.attempted || !state.rawData.length || !state.monitor.scoreFallbacks.length) return false; let changedCount = 0; state.rawData.forEach(c => { if (needsScoreFallback(c) && applyScoreAttempts(c, findScoreFallback(c), 'monitor')) changedCount++; }); if (changedCount) { Diagnostics.info('grades', '学业监测成绩回填完成', { changed: changedCount, remainingHidden: state.rawData.filter(isHiddenScoreText).length }); applyFilters(); Toast.fire({ icon: 'success', title: '已从学业监测回填隐藏成绩' }); } return changedCount > 0; } // 成绩回填子模块:移动成绩优先,仍无具体成绩才读取学业监测与官方成绩单。 // 成绩单仅接受课程名、学期和学分全部一致的高可信记录;冲突项保持原状态。 const TRANSCRIPT_GRADE_NUMERIC = Object.freeze({ '优秀': 95, '优': 95, '良好': 85, '良': 85, '中等': 75, '中': 75, '及格': 65, '不及格': 50 }); function withGradeNumeric(text) { return Object.hasOwn(TRANSCRIPT_GRADE_NUMERIC, text) ? `${text}(${TRANSCRIPT_GRADE_NUMERIC[text]})` : text; } function normalizeTranscriptScore(rawScore) { const score = TRANSCRIPT_PARSER?.cleanScore?.(rawScore) || String(rawScore ?? '').trim(); if (!score) return null; if (Object.prototype.hasOwnProperty.call(TRANSCRIPT_GRADE_NUMERIC, score)) { const numeric = TRANSCRIPT_GRADE_NUMERIC[score]; return { raw: score, display: withGradeNumeric(score), numeric }; } const numeric = parseFloat(score); return { raw: score, display: score, numeric: Number.isFinite(numeric) ? numeric : null }; } // 官方证明服务:统一证明列表查询、PDF 生成、轮询与文件签名校验。 const OfficialDocumentService = Object.freeze({ list: () => API.requestFormJSON({ url: 'https://jwxt.whut.edu.cn/jwapp/sys/zmdynxd/zmdy/queryAllZmdy.do', retries: 1, dedupeKey: 'official-document-list' }), async fetchPDF(wid) { const reportId = String(wid || '').trim(); if (!reportId) throw new Error('证明模板标识为空'); await API.requestForm({ url: 'https://jwxt.whut.edu.cn/jwapp/sys/zmdynxd/zmdy/printBefore.do', data: { wid: reportId }, timeout: 20000, retries: 1, dedupeKey: `official-document-prepare:${reportId}` }); let lastError = 'PDF 尚未生成'; for (let attempt = 0; attempt < 4; attempt++) { if (attempt) await waitFor(500 * attempt); const response = await API.request({ method: 'GET', url: `https://jwxt.whut.edu.cn/jwapp/sys/zmdynxd/zmdy/printZm.do?wid=${encodeURIComponent(reportId)}&_=${Date.now()}`, headers: { Accept: 'application/pdf,*/*;q=0.8', 'Cache-Control': 'no-cache', Pragma: 'no-cache' }, responseType: 'blob', timeout: 30000, retries: 1 }); const raw = response.response; const blob = raw instanceof Blob ? raw : new Blob([raw || response.responseText || '']); const signature = String.fromCharCode(...new Uint8Array(await blob.slice(0, 8).arrayBuffer())); if (signature.startsWith('%PDF-')) return blob; const text = await blob.slice(0, 500).text(); if (/统一身份认证|login|登录页面|
{ if (state.transcript.report?.wid) return state.transcript.report; let selected = null; try { const json = await OfficialDocumentService.list(); const data = json.datas ?? []; const groups = Array.isArray(data) ? data : Object.values(data || {}).flatMap(value => Array.isArray(value) ? value : []); const reports = groups.flatMap(group => { const parentName = String(group?.CDMC || group?.MC || ''); return (Array.isArray(group?.REPORTS) ? group.REPORTS : []).map(report => ({ wid: String(report?.WID || '').trim(), name: String(report?.CDMC || report?.MC || '').trim(), parentName })); }).filter(report => report.wid); const reportScore = report => { const name = `${report.parentName} ${report.name}`; let score = /成绩单/.test(name) ? 100 : (/成绩|学业记录/.test(name) ? 60 : 0); if (/中文|本科|主修|在校/.test(name)) score += 20; if (/英文|English|辅修|第二学位|研究生/i.test(name)) score -= 80; return score; }; selected = reports.sort((a, b) => reportScore(b) - reportScore(a))[0] || null; if (selected && reportScore(selected) <= 0) selected = null; } catch (error) { Diagnostics.warn('transcript', '成绩单列表读取异常,使用默认成绩单模板', error); } state.transcript.report = selected || { wid: TranscriptScoreModule.defaultReportWid, name: '中文成绩单', parentName: '' }; return state.transcript.report; }, groupPageRows: (items, pageNumber) => { const tokens = (items || []).map((item, sourceIndex) => ({ sourceIndex, text: String(item?.str ?? '').trim(), x: toNum(item?.transform?.[4], 0), y: toNum(item?.transform?.[5], 0), width: Math.max(0, toNum(item?.width, 0)), height: Math.max(1, Math.abs(toNum(item?.height ?? item?.transform?.[3], 10))), transform: Array.isArray(item?.transform) ? item.transform.slice(0, 6).map(value => toNum(value, 0)) : [] })).filter(item => item.text) .map(item => ({ ...item, centerX: item.x + item.width / 2 })) .sort((a, b) => b.y - a.y || a.x - b.x); const rows = []; tokens.forEach(token => { const tolerance = Math.max(2.2, Math.min(4.5, token.height * 0.38)); let row = rows.find(candidate => Math.abs(candidate.y - token.y) <= tolerance); if (!row) { row = { pageNumber, y: token.y, items: [] }; rows.push(row); } row.items.push(token); row.y = (row.y * (row.items.length - 1) + token.y) / row.items.length; }); rows.forEach(row => row.items.sort((a, b) => a.x - b.x)); return rows.sort((a, b) => b.y - a.y); }, extractRows: async (blob) => { if (typeof pdfjsLib === 'undefined') throw new Error('PDF.js 未加载'); if (!TRANSCRIPT_PARSER) throw new Error('成绩单结构化解析器未加载'); if (pdfjsLib.GlobalWorkerOptions) pdfjsLib.GlobalWorkerOptions.workerSrc = TranscriptScoreModule.workerURL; const bytes = new Uint8Array(await blob.arrayBuffer()); const task = pdfjsLib.getDocument({ data: bytes, useWorkerFetch: false, isEvalSupported: true }); const doc = await task.promise; const rows = []; try { for (let pageNumber = 1; pageNumber <= doc.numPages; pageNumber++) { const page = await doc.getPage(pageNumber); const content = await page.getTextContent({ disableCombineTextItems: false }); rows.push(...TranscriptScoreModule.groupPageRows(content.items || [], pageNumber)); page.cleanup?.(); } } finally { await doc.destroy?.(); } rows.sort((a, b) => a.pageNumber - b.pageNumber || b.y - a.y); rows.forEach((row, rowIndex) => { row.rowIndex = rowIndex; }); return rows; }, loadRecords: async () => { if (state.transcript.isLoaded) return state.transcript.records; if (state.transcript.promise) return state.transcript.promise; if (state.transcript.error && Date.now() - state.transcript.lastAttempt < 30000) return []; state.transcript.isLoading = true; state.transcript.lastAttempt = Date.now(); let activePromise; activePromise = (async () => { // 先让共享 Promise 写入状态,避免同步完成时留下已结束的任务引用。 await Promise.resolve(); const report = await TranscriptScoreModule.findReport(); const blob = await OfficialDocumentService.fetchPDF(report.wid); const rows = await TranscriptScoreModule.extractRows(blob); const parsed = TRANSCRIPT_PARSER.parseRows(rows); state.transcript.records = parsed.records || []; state.transcript.error = ''; state.transcript.isLoaded = true; Diagnostics.info('transcript', '官方成绩单解析完成', { pages: new Set(rows.map(row => row.pageNumber)).size, rows: rows.length, records: state.transcript.records.length, issues: Array.isArray(parsed.issues) ? parsed.issues.length : 0 }); return state.transcript.records; })().catch(error => { state.transcript.error = error?.message || String(error); Diagnostics.warn('transcript', '官方成绩单兜底读取异常', error); return []; }).finally(() => { state.transcript.isLoading = false; if (state.transcript.promise === activePromise) state.transcript.promise = null; }); state.transcript.promise = activePromise; return activePromise; }, patchCourse: (course, match) => { if (!match || match.conflict || match.confidence !== 'high') return false; if (!TRANSCRIPT_PARSER?.cleanScore(match.score)) return false; const score = normalizeTranscriptScore(match.score); if (!score || isHiddenScoreText(score.raw)) return false; course._scoreText = score.display; course.XSZCJMC = score.display; course._score = Number.isFinite(score.numeric) ? score.numeric : null; course.ZCJ = Number.isFinite(score.numeric) ? score.numeric : score.raw; const gpa = Number.isFinite(score.numeric) ? calcWhutGPAFromScore(score.numeric) : gradeTextToGPA(score.raw); course._gpa = Number.isFinite(gpa) ? gpa : null; if (Number.isFinite(gpa)) course.XFJD = gpa; course._scoreFixedByTranscript = true; course._scoreResolved = true; course._gpaExcludedReason = ''; course._transcriptMatch = { source: 'transcript', confidence: match.confidence, reason: match.reason, rawScore: score.raw, page: match.record?.page, columnIndex: match.record?.columnIndex }; return true; }, apply: async () => { if (!state.rawData.some(needsScoreFallback)) return 0; const records = await TranscriptScoreModule.loadRecords(); if (!records.length) return 0; const targets = state.rawData.filter(needsScoreFallback); let changed = 0; targets.forEach(course => { if (!state.rawData.includes(course) || !needsScoreFallback(course)) return; const match = TRANSCRIPT_PARSER.matchCourse(course, records); if (TranscriptScoreModule.patchCourse(course, match)) changed++; }); if (changed) { applyFilters(); Toast.fire({ icon: 'success', title: `已从官方成绩单回填 ${changed} 门成绩` }); } Diagnostics.info('transcript', '官方成绩单回填完成', { targets: targets.length, changed, unresolved: Math.max(0, targets.length - changed) }); return changed; } }; let hiddenScoreBackfillPromise = null; function waitForMonitorLoad() { if (!state.monitor.isLoading) return Promise.resolve(); return new Promise(resolve => { const timer = AppResources.repeat(() => { // 监测请求自身有超时;等其完成,避免较慢的高优先级来源被成绩单抢先覆盖。 if (!state.monitor.isLoading) { AppResources.clearRepeat(timer); resolve(); } }, 100); }); } function runHiddenScoreBackfill() { if (hiddenScoreBackfillPromise) return hiddenScoreBackfillPromise; hiddenScoreBackfillPromise = (async () => { await Promise.resolve(); if (!state.mobileScores.attempted) await MobileScoreModule.load(); if (!state.rawData.some(needsScoreFallback)) return 0; if (state.monitor.isLoading) await waitForMonitorLoad(); if (!state.monitor.isLoaded && !state.monitor.isLoading) { await MonitorModule.loadData({ silent: true, render: false }); } applyScoreFallbacks(); if (!state.rawData.some(needsScoreFallback)) return 0; return TranscriptScoreModule.apply(); })().catch(error => { Diagnostics.warn('grades', '隐藏成绩回填异常', error); return 0; }).finally(() => { hiddenScoreBackfillPromise = null; }); return hiddenScoreBackfillPromise; } function normalizeGradeCourseRow(r) { const scoreNum = parseFloat(pickNumericCourseScore(r)); const scoreText = formatCourseScoreDisplay(r); const pendingForGPA = isPendingScoreForGPA({ ...r, _scoreText: scoreText }); const gpa = pickCourseGPA(r, scoreNum, scoreText); return { ...r, _score: Number.isFinite(scoreNum) ? scoreNum : null, _credit: toNum(r.XF, 0), _gpa: pendingForGPA ? null : gpa, _gpaExcludedReason: pendingForGPA ? 'pending-score' : '', _scoreText: scoreText, _semester: r.XNXQDM || '', _name: r.KCM || '', _isCompulsory: isCompulsoryCourseForGPA(r) }; } function processDataPart(rows, total, { backfill = true } = {}) { (rows || []).forEach(r => { if (r.SFYX !== '1') return; const attemptKey = r.WID || r.id || JSON.stringify([r.KXH, r.KSXZDM, cleanCourseScore(r), r.XSZCJMC]); state.dataMap.set(`${gradeCourseKey(r)}|${attemptKey}`, normalizeGradeCourseRow(r)); }); const groups = new Map(); [...state.dataMap.values(), ...state.mobileScores.rows].forEach(row => { let key = gradeCourseKey(row); // 仅在缺少课程号且名称、学期、学分唯一吻合时合并,避免同名课程串分。 if (row._mobileRow && !groups.has(key)) { const matches = [...groups.entries()].filter(([, entries]) => entries.some(base => (!base.KCH || !row.KCH) && sameText(base.KCM, row.KCM) && sameText(base.XNXQDM, row.XNXQDM) && base._credit === row._credit)); if (matches.length === 1) key = matches[0][0]; } if (!groups.has(key)) groups.set(key, []); groups.get(key).push(row); }); const previous = new Map(state.rawData.map(row => [gradeCourseKey(row), row])); state.rawData = [...groups.values()].map(entries => { const desktop = entries.filter(row => !row._mobileRow); const mobile = entries.filter(row => row._mobileRow); const course = { ...(desktop[0] || mobile[0]) }; if (!applyScoreAttempts(course, mobile, 'mobile') && !applyScoreAttempts(course, desktop, 'desktop')) { const notice = [...mobile, ...desktop].find(row => isScoreNotice(row._scoreText)); if (notice) course._scoreText = notice._scoreText; } const old = previous.get(gradeCourseKey(course)); if (needsScoreFallback(course) && old && !needsScoreFallback(old)) { if (old._scoreFixedByTranscript) TranscriptScoreModule.patchCourse(course, { confidence: 'high', score: old._transcriptMatch.rawScore }); else if (old._scoreFixedByMonitor) applyScoreAttempts(course, [old], 'monitor'); } return course; }); 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(courseNature).sort()); Diagnostics.info('grades', '成绩数据已载入', { received: rows.length, reportedTotal: toNum(total, rows.length), activeCourses: state.rawData.length, semesters: state.semesters.size, hiddenScores: state.rawData.filter(isHiddenScoreText).length }); updateFilterUI(); applyFilters(); if (backfill && (!state.mobileScores.attempted || state.rawData.some(needsScoreFallback))) AppResources.schedule(() => runHiddenScoreBackfill(), 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 = uiGet('h-type'); if (!typeEl) return; state.filters.types = state.filters.typeAll ? new Set(state.types) : new Set([...state.filters.types].filter(type => state.types.has(type))); typeEl.innerHTML = Array.from(state.types, type => ``).join(''); SelectControl.refresh(typeEl); renderSemesterOptions(); ScheduleModule.syncTermOptions(); } function applyFilters() { const { sort } = state.filters; let res = state.rawData.filter(item => matchesAnalysisFilters(item, state.filters)); 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; renderAnalysisFilterSummary(); if (uiGet('h-list')) renderList(); if (uiGet('h-footer')) renderFooter(); } function courseNature(course) { return String(course?.KCXZDM_DISPLAY || '').trim() || '其他'; } function matchesAnalysisFilters(course, filters) { return (!filters.search || String(course._name || '').includes(filters.search) || String(course.KCH || '').includes(filters.search)) && filters.semesters.has(course._semester) && (filters.typeAll || filters.types.has(courseNature(course))); } const SummaryTags = Object.freeze({ render(labels) { return labels.map(label => `${escapeHTML(label)}`).join(' · '); }, set(element, labels) { if (!element) return; element.classList.add('summary-tags'); element.innerHTML = SummaryTags.render(labels); element.title = labels.join(' · '); } }); function analysisFilterLabels(filters = state.filters) { const labels = [ ...(filters.semesterAll ? ['全部学期'] : filters.semesters.size ? [...filters.semesters] : ['未选择学期']), ...(filters.typeAll ? ['全部性质'] : filters.types.size ? [...filters.types] : ['未选择性质']) ]; if (filters.search) labels.push(`搜索:${filters.search}`); const sorts = { gpa_desc: '绩点从高到低', gpa_asc: '绩点从低到高', credit_desc: '学分从高到低', credit_asc: '学分从低到高' }; if (sorts[filters.sort]) labels.push(sorts[filters.sort]); return labels; } function renderAnalysisFilterSummary() { SummaryTags.set(uiGet('analysis-filter-summary'), analysisFilterLabels()); } function gradeScoreClass(course) { if (Number.isFinite(course?._score)) return course._score >= 90 ? 'score-high' : course._score < 60 ? 'score-low' : 'score-med'; const text = String(course?._scoreText || '').trim().replace(/\s*[((]\d+(?:\.\d+)?[))]$/, ''); if (/不及格|不通过|不合格|未通过|Fail/i.test(text)) return 'score-low'; if (/优秀|^优$|Excellent/i.test(text)) return 'score-high'; if (/良好|中等|及格|通过|合格|免修|^[良中]$|Good|Average|Pass/i.test(text)) return 'score-med'; return 'score-none'; } function renderList() { const list = uiGet('h-list'); if (!list) return; if (state.filteredData.length === 0) { list.innerHTML = UI.state('empty', '暂无匹配课程', '调整学期、性质或搜索关键词后重试'); return; } const frag = document.createDocumentFragment(); state.filteredData.forEach(c => { const item = document.createElement('div'); item.className = 'course-item'; const scoreCls = gradeScoreClass(c); const fixedTag = c._scoreFixedByMonitor ? '监测回填' : (c._scoreFixedByTranscript ? '成绩单回填' : ''); item.innerHTML = `
`; item.querySelector('.course-summary').addEventListener('click', () => toggleDetail(c)); frag.appendChild(item); }); list.innerHTML = ''; list.appendChild(frag); } function renderFooter() { const footer = uiGet('h-footer'); if (!footer) return; let tCred = 0, count = 0; state.filteredData.forEach(c => { if (c.SFYX !== '1' || c._credit <= 0) return; tCred += c._credit; count++; }); const all = analysisGPABreakdown(state.filteredData); const compulsory = analysisGPABreakdown(state.filteredData, true); footer.innerHTML = ` `; state.stats = { count, totalCredit: formatCreditValue(tCred), avgGPA: all.display, compulsoryGPA: compulsory.display }; } function analysisGPABreakdown(courses, compulsory = false) { const rows = getBestCoursesForGPA(courses).filter(course => !compulsory || course._isCompulsory); const credits = rows.reduce((total, course) => total + course._credit, 0); const weighted = rows.reduce((total, course) => total + course._credit * course._gpa, 0); return { rows, credits, weighted, display: credits ? (weighted / credits).toFixed(3) : '-' }; } function renderAnalysisCourseDetails(compulsory = false, data = analysisGPABreakdown(state.filteredData, compulsory)) { const body = data.rows.map(course => `${escapeHTML(course._semester)}${escapeHTML(course.KCM)}${escapeHTML(course.KCH || '-')}${escapeHTML(courseNature(course))}${escapeHTML(course._scoreText)}${escapeHTML(formatCreditValue(course._credit))}${course._gpa.toFixed(3)}`).join(''); return `
${SummaryTags.render(analysisFilterLabels())}
当前绩点 ${data.display}计入课程 ${data.rows.length}计入学分 ${escapeHTML(formatCreditValue(data.credits))}

显示当前筛选范围内实际计入该绩点的课程;同一课程仅保留用于计算的一条成绩。绩点 = 学分绩点合计 ÷ 计入学分。

${data.rows.length ? `
${body}
学期课程课程性质成绩学分课程绩点
` : UI.state('empty', '暂无计入绩点的课程', '可调整筛选条件后查看', true)}`; } function openAnalysisCourseDetails(compulsory = false) { const data = analysisGPABreakdown(state.filteredData, compulsory); return AppDialog.details({ title: `${compulsory ? '必修绩点' : '全科绩点'} · 课程明细`, html: renderAnalysisCourseDetails(compulsory, data), exportName: `WHUT_成绩分析_${compulsory ? '必修' : '全科'}课程明细`, exportRows: data.rows.map(course => ({ '学期': course._semester, '课程': course.KCM, '课程代码': course.KCH || '', '课程性质': courseNature(course), '成绩': course._scoreText, '学分': course._credit, '课程绩点': Number(course._gpa.toFixed(3)) })) }); } async function toggleDetail(c) { const p = uiGet(`detail-${c._uid}`); const summary = p?.previousElementSibling; if (!p) return; if (p.classList.contains('open')) { p.classList.remove('open'); summary?.setAttribute('aria-expanded', 'false'); return; } uiQueryAll('#whut-helper-panel .detail-panel.open').forEach(el => { el.classList.remove('open'); el.previousElementSibling?.setAttribute('aria-expanded', 'false'); }); p.classList.add('open'); summary?.setAttribute('aria-expanded', 'true'); if (p.dataset.loaded) return; if (!c.JXBID) { p.innerHTML = UI.state('empty', '暂无班级对比数据', '该课程未关联教学班,因此不展示排名', true); p.dataset.loaded = 'true'; return; } p.innerHTML = UI.state('loading', '正在获取班级对比数据', '', true); 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, data) => API.requestFormJSON({ url: `/jwapp/sys/cjcx/modules/cjcx/${ep}`, data, timeout: 12000, retries: 1 }); 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 ? `${escapeHTML(r1.PM)} / ${escapeHTML(r1.ZRS || '-')}` : '无排名'; p.innerHTML = `
班级排名最高分平均分最低分
${pmText}${escapeHTML(s1.ZGF || '-')}${escapeHTML(s1.PJF || '-')}${escapeHTML(s1.ZDF || '-')}
班级成绩分布
${renderDistChart(d1)}
`; p.dataset.loaded = 'true'; } catch (e) { p.innerHTML = UI.state('error', '班级对比数据加载异常', '稍后重新展开课程详情进行重试', true); } } function renderDistChart(dist) { if (!dist || !dist.length) return '
暂无分布数据
'; dist.sort((a, b) => String(a.DJDM || '99').localeCompare(String(b.DJDM || '99'))); const max = Math.max(...dist.map(r => toNum(r.DJSL, 0)), 1); return dist.map(r => { const count = Math.max(0, toNum(r.DJSL, 0)); const pct = Math.min(100, (count / max) * 100); return `
${escapeHTML(r.DJDM_DISPLAY || r.DJDM || '-')}
${escapeHTML(count)}
`; }).join(''); } const LongImageExport = (() => { const responsiveLayoutProperties = new Set([ 'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height', 'inline-size', 'block-size', 'min-inline-size', 'max-inline-size', 'min-block-size', 'max-block-size', 'flex-basis', 'grid-template-columns', 'grid-template-rows', 'grid-auto-columns', 'grid-auto-rows', 'contain-intrinsic-width', 'contain-intrinsic-height', 'contain-intrinsic-inline-size', 'contain-intrinsic-block-size' ]); let activeExport = null; const isTransparent = color => !color || color === 'transparent' || /^rgba?\([^)]*,\s*0(?:\.0+)?\s*\)$/i.test(color); function inlineComputedStyles(source, clone) { const sourceNodes = [source, ...source.querySelectorAll('*')]; const cloneNodes = [clone, ...clone.querySelectorAll('*')]; sourceNodes.forEach((node, index) => { const target = cloneNodes[index]; if (!target) return; const computed = getComputedStyle(node); responsiveLayoutProperties.forEach(property => target.style.removeProperty(property)); for (let i = 0; i < computed.length; i++) { const property = computed[i]; if (responsiveLayoutProperties.has(property)) continue; target.style.setProperty(property, computed.getPropertyValue(property), computed.getPropertyPriority(property)); } }); return clone; } function createClone(source, { width = 720, copyComputedStyles = false, prepare, marker = 'generic' } = {}) { if (!source) throw new Error('未找到长图导出内容'); const clone = source.cloneNode(true); if (copyComputedStyles) inlineComputedStyles(source, clone); clone.querySelectorAll('*').forEach(node => { node.style.animation = 'none'; node.style.transition = 'none'; node.style.transform = 'none'; }); Object.assign(clone.style, { position: 'fixed', left: '-10000px', top: '0', width: `${Math.max(480, Number(width) || 720)}px`, height: 'auto', minHeight: '0', maxHeight: 'none', overflow: 'visible', display: 'block', transform: 'none', contain: 'none', zIndex: '2147483647', pointerEvents: 'none', boxSizing: 'border-box' }); clone.dataset.longImageExportClone = marker; prepare?.(clone, source); return clone; } async function settleLayout() { if (document.fonts?.ready) { await Promise.race([document.fonts.ready.catch(() => undefined), waitFor(500)]); } await Promise.race([ new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))), waitFor(120) ]); } function measure(clone) { const captureWidth = Math.ceil(Math.max(clone.getBoundingClientRect().width, clone.scrollWidth)); const captureHeight = Math.ceil(Math.max(clone.getBoundingClientRect().height, clone.scrollHeight)) + 2; const safeScale = Math.min( 2, 15000 / Math.max(captureWidth, 1), 24000 / Math.max(captureHeight, 1), Math.sqrt(40000000 / Math.max(captureWidth * captureHeight, 1)) ); return { width: captureWidth, height: captureHeight, scale: Math.max(0.25, Math.floor(safeScale * 100) / 100) }; } function canvasToBlob(canvas, timeout = 10000) { return new Promise((resolve, reject) => { let settled = false; const timer = setTimeout(() => { if (settled) return; settled = true; reject(new Error('PNG 编码超时')); }, timeout); canvas.toBlob(result => { if (settled) return; settled = true; clearTimeout(timer); if (result) resolve(result); else reject(new Error('PNG 编码失败')); }, 'image/png'); }); } async function run(options = {}) { if (activeExport) { Toast.fire({ icon: 'info', title: '已有长图正在生成' }); return activeExport; } const task = (async () => { const { source, filename = `WHUT_长图_${Date.now()}.png`, width = 720, cloneFactory, copyComputedStyles = false, prepareClone, marker = 'generic', backgroundColor, successMessage = '长图已生成', errorTitle = '长图生成失败' } = options; let clone = null; AppDialog.fire({ title: '正在生成长图', text: '正在排版并渲染,请稍候...', allowOutsideClick: false, didOpen: () => AppDialog.showLoading() }); const started = performance.now(); try { clone = cloneFactory ? cloneFactory(source) : createClone(source, { width, copyComputedStyles, prepare: prepareClone, marker }); if (!clone) throw new Error('未能创建长图导出副本'); if (!clone.dataset.longImageExportClone) clone.dataset.longImageExportClone = marker; document.body.appendChild(clone); await settleLayout(); const size = measure(clone); const computedBackground = backgroundColor || getComputedStyle(source || clone).backgroundColor; const canvasBackground = isTransparent(computedBackground) ? EXPORT_THEME.bg : computedBackground; Diagnostics.info('image-export', '开始渲染长图', { marker, filename, width: size.width, height: size.height, scale: size.scale }); if (typeof html2canvas !== 'function') throw new Error('html2canvas 尚未加载'); const canvas = await html2canvas(clone, { scale: size.scale, backgroundColor: canvasBackground, useCORS: true, logging: false, width: size.width, height: size.height, windowWidth: Math.max(document.documentElement.clientWidth, size.width), windowHeight: Math.max(document.documentElement.clientHeight, size.height), scrollX: 0, scrollY: 0 }); if (!canvas.width || !canvas.height) throw new Error('生成的画布为空'); const blob = await canvasToBlob(canvas); FileDownload.saveBlob(blob, filename); Diagnostics.info('image-export', '长图导出完成', { marker, filename, canvasWidth: canvas.width, canvasHeight: canvas.height, bytes: blob.size, durationMs: Math.round(performance.now() - started) }); AppDialog.close(); Toast.fire({ icon: 'success', title: successMessage }); return { filename, width: canvas.width, height: canvas.height, bytes: blob.size }; } catch (error) { Diagnostics.error('image-export', '长图导出失败', { marker, filename, error }); AppDialog.close(); AppDialog.fire(errorTitle, error?.message || '请稍后重试', 'error'); return null; } finally { clone?.remove(); } })(); activeExport = task; try { return await task; } finally { if (activeExport === task) activeExport = null; } } return Object.freeze({ run, createClone }); })(); const ContextualExport = { toExcel: () => { if (!state.filteredData.length) return AppDialog.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'); }, createImageReport: () => { const report = document.createElement('section'); report.setAttribute('aria-label', 'WHUT 成绩分析报告'); report.style.cssText = `width:720px;height:auto;overflow:visible;color:${EXPORT_THEME.textMain};background:${EXPORT_THEME.bg};padding:28px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;box-sizing:border-box;`; const rows = state.filteredData.map(c => ` ${escapeHTML(c.XNXQDM)} ${escapeHTML(c.KCM)} ${escapeHTML(formatCreditValue(c._credit))} ${escapeHTML(c._scoreText)} `).join(''); report.innerHTML = `

WHUT 成绩分析报告

生成时间 ${escapeHTML(new Date().toLocaleString('zh-CN'))}
课程
${escapeHTML(state.stats.count)}
总学分
${escapeHTML(state.stats.totalCredit)}
必修绩点
${escapeHTML(state.stats.compulsoryGPA)}
全科绩点
${escapeHTML(state.stats.avgGPA)}
${rows}
学期课程学分成绩
`; return report; }, toImage: async () => { if (!state.filteredData.length) return AppDialog.fire('提示', '没有数据可导出', 'info'); return LongImageExport.run({ source: uiGet('h-list'), filename: 'WHUT_成绩长图.png', width: 720, marker: 'grades', cloneFactory: () => LongImageExport.createClone(ContextualExport.createImageReport(), { width: 720, marker: 'grades' }), backgroundColor: EXPORT_THEME.bg, successMessage: '成绩长图已生成', errorTitle: '成绩长图生成失败' }); } }; // ======================================================================== // 业务模块:GPA 查询 // 职责:查询学期/学年 GPA、排名、计算课程明细与必修课最低绩点。 // ======================================================================== const GPAModule = { isLoaded: false, isLoading: false, loadPromise: null, cache: null, currentSemesters: [], detailMode: 'all', detailCache: new Map(), detailPromises: new Map(), init: async () => { if (GPAModule.isLoaded) return GPAModule.cache; if (GPAModule.loadPromise) return GPAModule.loadPromise; const task = (async () => { GPAModule.isLoading = true; const container = uiGet('gpa-content'); container.innerHTML = UI.state('loading', '正在加载综合 GPA', '系统将汇总全学程、完整学年与各学期数据'); try { const userInfo = await API.getUserInfo(); const homeData = await API.requestJSON({ method: 'GET', url: 'https://jwxt.whut.edu.cn/jwapp/sys/homeapp/api/home/currentUser.do', retries: 1, dedupeKey: 'current-user-home' }); const currentSem = homeData.datas.welcomeInfo.xnxqdm; const defaultSems = GPAModule.calcSemesters(userInfo.userId, currentSem); const sems = GPAModule.getActiveSemesters(defaultSems); GPAModule.currentSemesters = sems; const overallReq = API.requestFormJSON({ url: 'https://jwxt.whut.edu.cn/jwapp/sys/cjgl/modules/xsgpajs/cxxsqxcpjjd.do', data: { querySetting: '[]', '*order': '-XZNJ,+YXDM,+ZYDM,+XH', pageSize: 20, pageNumber: 1 }, retries: 1, dedupeKey: 'gpa-overall' }); const semReqs = sems.map(sem => GPAModule.fetchSemesterGPA(sem) .catch(error => ({ sem, data: null, error: error?.message || '学期 GPA 请求失败' }))); const [overallResponse, semResponses] = await Promise.all([overallReq, Promise.all(semReqs)]); const overallData = overallResponse.datas?.cxxsqxcpjjd?.rows?.[0] || null; // 学年 GPA 只在第一、第二学期都返回有效绩点后请求,避免展示未来或不完整学年。 const academicYears = GPAModule.calcAcademicYears(semResponses); const [rawYearResponses, yearDetailResponses] = await Promise.all([ Promise.all(academicYears.map(year => GPAModule.fetchAcademicYearGPA(year) .catch(error => ({ year, data: null, error: error?.message || '学年 GPA 请求失败' })))), Promise.all(academicYears.map(year => GPAModule.fetchCourseDetails('year', year, userInfo.userId) .catch(error => ({ scope: 'year', period: year, rows: null, error: error?.message || '课程明细加载失败' })))) ]); const yearResponses = rawYearResponses.map((result, index) => { if (!result?.data) return result; const details = yearDetailResponses[index]; if (details?.error) { Diagnostics.warn('gpa-detail', '学年课程明细预加载失败', { year: result.year, error: details.error }); return { ...result, detailError: details.error }; } return { ...result, minimumCompulsoryGPA: GPAModule.getMinimumCompulsoryGPA(details?.rows) }; }); GPAModule.cache = { overall: overallData, years: yearResponses, semesters: semResponses }; GPAModule.render(overallData, yearResponses, semResponses, container); GPAModule.isLoaded = true; Diagnostics.info('gpa', 'GPA 数据加载完成', { overall: Boolean(overallData), academicYears: yearResponses.filter(item => item?.data).length, semesters: semResponses.filter(item => item?.data).length, failedYears: yearResponses.filter(item => item?.error).length, failedYearDetails: yearResponses.filter(item => item?.detailError).length, failedSemesters: semResponses.filter(item => item?.error).length }); return GPAModule.cache; } catch (e) { Diagnostics.error('gpa', 'GPA 数据加载失败', e); container.innerHTML = UI.state('error', '综合 GPA 加载异常', e.message || '请稍后重试'); return null; } })(); GPAModule.loadPromise = task; try { return await task; } finally { if (GPAModule.loadPromise === task) GPAModule.loadPromise = null; GPAModule.isLoading = false; } }, 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(); }, hasSemesterGPAData: (data) => { if (!data || typeof data !== 'object') return false; return ['PJXFJD', 'BXKPJXFJD'].some(key => { const value = data[key]; return value !== undefined && value !== null && String(value).trim() !== ''; }); }, calcAcademicYears: (semesterResponses = []) => { const termsByYear = new Map(); (Array.isArray(semesterResponses) ? semesterResponses : []).forEach(item => { if (!GPAModule.hasSemesterGPAData(item?.data)) return; const match = String(item?.sem || '').match(/^(\d{4}-\d{4})-([12])$/); if (!match) return; const terms = termsByYear.get(match[1]) || new Set(); terms.add(match[2]); termsByYear.set(match[1], terms); }); return Array.from(termsByYear.entries()) .filter(([, terms]) => terms.has('1') && terms.has('2')) .map(([year]) => year) .sort((a, b) => b.localeCompare(a)); }, fetchSemesterGPA: async (sem) => { const response = await API.requestFormJSON({ url: 'https://jwxt.whut.edu.cn/jwapp/sys/cjgl/modules/xsgpajs/cxxsxqpjjd.do', data: { 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 }, retries: 1, dedupeKey: `gpa-semester:${sem}` }); return { sem, data: response?.datas?.cxxsxqpjjd?.rows?.[0] || null }; }, fetchAcademicYearGPA: async (year) => { const order = '-XNDM,-XZNJ,+YXDM,+ZYDM,+XH'; const querySetting = [ { name: 'XNDM', caption: '学年', linkOpt: 'AND', builderList: 'cbl_m_List', builder: 'm_value_equal', value: year, value_display: year }, { name: '*order', value: order, linkOpt: 'AND', builder: 'm_value_equal' } ]; const response = await API.requestFormJSON({ url: 'https://jwxt.whut.edu.cn/jwapp/sys/cjgl/modules/xsgpajs/cxxsxnpjjd.do', data: { querySetting: JSON.stringify(querySetting), '*order': order, pageSize: 20, pageNumber: 1 }, retries: 1, dedupeKey: `gpa-year:${year}` }); return { year, data: response?.datas?.cxxsxnpjjd?.rows?.[0] || null }; }, getCourseDetailConfig: (scope, period, userId) => { const baseURL = 'https://jwxt.whut.edu.cn/jwapp/sys/cjgl/modules/xsgpajs'; if (scope === 'overall') { return { key: 'qxckctjmx', url: `${baseURL}/qxckctjmx.do`, params: { type: 1, XH: userId } }; } if (scope === 'year' && /^\d{4}-\d{4}$/.test(String(period || ''))) { return { key: 'xnkctjmx', url: `${baseURL}/xnkctjmx.do`, params: { type: 3, XH: userId, XNDM: period } }; } if (scope === 'semester' && GPAModule.isValidSemester(period)) { return { key: 'xqkctjmx', url: `${baseURL}/xqkctjmx.do`, params: { type: 2, XH: userId, XNXQDM: period } }; } throw new Error('GPA 课程明细范围无效'); }, fetchCourseDetails: async (scope, period = '', knownUserId = '') => { const cacheKey = `${scope}:${period || 'all'}`; if (GPAModule.detailCache.has(cacheKey)) return GPAModule.detailCache.get(cacheKey); if (GPAModule.detailPromises.has(cacheKey)) return GPAModule.detailPromises.get(cacheKey); const task = (async () => { const userId = knownUserId || (await API.getUserInfo()).userId; const config = GPAModule.getCourseDetailConfig(scope, period, userId); const requestedPageSize = 100; const requestPage = async (pageNumber, pageSize = requestedPageSize) => { const response = await API.requestFormJSON({ url: config.url, data: { ...config.params, pageSize, pageNumber }, retries: 1, dedupeKey: `gpa-detail:${cacheKey}:${pageNumber}:${pageSize}` }); if (String(response?.code ?? '0') !== '0') throw new Error(`课程明细接口返回错误(${response?.code})`); const dataset = response?.datas?.[config.key]; if (!dataset || !Array.isArray(dataset.rows)) throw new Error('课程明细接口未返回有效列表'); return dataset; }; const first = await requestPage(1); const totalSize = Math.max(0, Number(first.totalSize) || first.rows.length); const reportedPageSize = Math.max(1, Number(first.pageSize) || requestedPageSize); const pageSize = first.rows.length > 0 && first.rows.length < totalSize ? Math.min(reportedPageSize, first.rows.length) : reportedPageSize; const pageCount = Math.min(50, Math.ceil(totalSize / pageSize)); const remaining = pageCount > 1 ? await Promise.all(Array.from({ length: pageCount - 1 }, (_, index) => requestPage(index + 2, pageSize))) : []; const seen = new Set(); const rows = [first, ...remaining].flatMap(page => page.rows).filter(course => { const key = String(course?.WID || [course?.XNXQDM, course?.KCH, course?.CXCKDM, course?.KCM].join('|')); if (seen.has(key)) return false; seen.add(key); return true; }); const result = { scope, period, rows, totalSize }; GPAModule.detailCache.set(cacheKey, result); Diagnostics.info('gpa-detail', 'GPA 课程明细加载完成', { scope, period, rows: rows.length, totalSize }); return result; })(); GPAModule.detailPromises.set(cacheKey, task); try { return await task; } finally { if (GPAModule.detailPromises.get(cacheKey) === task) GPAModule.detailPromises.delete(cacheKey); } }, isCompulsoryDetailCourse: course => { const schemeCode = normalizeCourseTypeCode(course?.FAKCXZDM); if (COMPULSORY_GPA_TYPE_CODES.has(schemeCode)) return true; if (ELECTIVE_GPA_TYPE_CODES.has(schemeCode)) return false; const schemeName = normalizeCourseTypeName(course?.FAKCXZDM_DISPLAY); if (ELECTIVE_GPA_TYPE_NAMES.some(name => schemeName === name || schemeName.includes(name))) return false; if (COMPULSORY_GPA_TYPE_NAMES.some(name => schemeName === name || schemeName.includes(name))) return true; return isCompulsoryCourseForGPA(course); }, getDetailCourseGPA: course => { for (const key of ['SCTGCJ', 'ZCJ']) { const raw = course?.[key]; if (!hasScoreValue(raw)) continue; const numericGPA = calcWhutGPAFromScore(raw); if (numericGPA !== null) return numericGPA; const gradeGPA = gradeTextToGPA(raw); if (gradeGPA !== null) return gradeGPA; } return null; }, getMinimumCompulsoryGPA: rows => { const values = (Array.isArray(rows) ? rows : []) .filter(GPAModule.isCompulsoryDetailCourse) .map(GPAModule.getDetailCourseGPA) .filter(Number.isFinite); return values.length ? Math.min(...values) : null; }, formatGPAValue: value => hasScoreValue(value) && Number.isFinite(Number(value)) ? Number(value).toFixed(3) : '-', getSavedSemesters: () => { try { const saved = JSON.parse(safeStorage.get('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; }, getManagerSemesters: () => { const candidates = [ GPAModule.getSavedSemesters(), GPAModule.currentSemesters, (GPAModule.cache?.semesters || []).map(item => item.sem), Array.from(state.semesters || []) ]; for (const candidate of candidates) { const valid = Array.from(new Set((candidate || []).filter(GPAModule.isValidSemester))); if (valid.length) return valid; } return ['']; }, isValidSemester: (sem) => /^\d{4}-\d{4}-[1-2]$/.test(String(sem || '').trim()), reload: async () => { const activeLoad = GPAModule.loadPromise; if (activeLoad) await activeLoad.catch(() => null); GPAModule.isLoaded = false; GPAModule.cache = null; GPAModule.detailCache.clear(); GPAModule.detailPromises.clear(); return GPAModule.init(); }, openSemesterManager: async () => { // 直接使用本地缓存打开面板,不让身份与学期网络请求阻塞首次点击。 const sems = GPAModule.getManagerSemesters(); const renderRows = (list) => list.map(sem => `
`).join(''); const result = await AppDialog.fire({ title: 'GPA 学期管理', unsafeHTML: `
格式示例: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.innerHTML = ``; list.appendChild(wrap); wrap.querySelector('.gpa-sem-input')?.focus(); }; 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((AppDialog.getCurrentDialog()?.querySelectorAll('.gpa-sem-input') || [])).map(i => i.value.trim()).filter(Boolean); const unique = Array.from(new Set(vals)); if (!unique.length) { AppDialog.showValidationMessage('请至少保留一个学期'); return false; } const invalid = unique.find(v => !GPAModule.isValidSemester(v)); if (invalid) { AppDialog.showValidationMessage(`学期格式不正确:${invalid}`); return false; } return unique; } }); if (result.isDenied) { const confirm = await AppDialog.fire({ title: '恢复自动学期?', text: '将删除本地保存的自定义学期列表。', icon: 'warning', showCancelButton: true, confirmButtonText: '恢复默认', cancelButtonText: '取消' }); if (!confirm.isConfirmed) return; safeStorage.remove('whut_gpa_semesters'); Toast.fire({ icon: 'success', title: '已恢复自动学期,正在刷新 GPA' }); void GPAModule.reload(); return; } if (!result.isConfirmed) return; safeStorage.set('whut_gpa_semesters', JSON.stringify(result.value)); Toast.fire({ icon: 'success', title: 'GPA 学期列表已保存,正在刷新' }); void GPAModule.reload(); }, courseDetailRows: (details, compulsory) => { const allRows = Array.isArray(details?.rows) ? details.rows : []; return (compulsory ? allRows.filter(GPAModule.isCompulsoryDetailCourse) : allRows) .slice() .sort((a, b) => String(b?.XNXQDM || '').localeCompare(String(a?.XNXQDM || '')) || String(a?.KCM || '').localeCompare(String(b?.KCM || ''), 'zh-CN')); }, courseDetailExportRows: (details, compulsory) => GPAModule.courseDetailRows(details, compulsory).map(course => { const courseGPA = GPAModule.getDetailCourseGPA(course); return { '学期': course?.XNXQDM_DISPLAY || course?.XNXQDM || '-', '课程': course?.KCM || '-', '课程代码': course?.KCH || '-', '方案课程性质': course?.FAKCXZDM_DISPLAY || course?.KCXZDM_DISPLAY || course?.FAKCXZDM || course?.KCXZDM || '-', '修读': course?.CXCKDM_DISPLAY || course?.CXCKDM || '-', '首次通过成绩': hasScoreValue(course?.SCTGCJ) ? course.SCTGCJ : '-', '总成绩': hasScoreValue(course?.ZCJ) ? course.ZCJ : '-', '课程绩点': Number.isFinite(courseGPA) ? Number(GPAModule.formatGPAValue(courseGPA)) : '-' }; }), renderCourseDetails: (details, compulsory, metricScore) => { const rows = GPAModule.courseDetailRows(details, compulsory); const display = value => escapeHTML(hasScoreValue(value) ? value : '-'); const body = rows.map(course => { const nature = course?.FAKCXZDM_DISPLAY || course?.KCXZDM_DISPLAY || course?.FAKCXZDM || course?.KCXZDM || '-'; const retake = course?.CXCKDM_DISPLAY || course?.CXCKDM || '-'; const semester = course?.XNXQDM_DISPLAY || course?.XNXQDM || '-'; const courseGPA = GPAModule.getDetailCourseGPA(course); return ` ${display(semester)} ${display(course?.KCM)}${display(course?.KCH)} ${display(nature)} ${display(retake)} ${display(course?.SCTGCJ)} ${display(course?.ZCJ)} ${escapeHTML(GPAModule.formatGPAValue(courseGPA))} `; }).join(''); return `
当前绩点 ${display(metricScore)} ${compulsory ? '必修课程' : '计入课程'} ${rows.length}

课程范围来自教务系统 GPA 明细接口。课程绩点优先按“首次通过成绩”换算;接口未返回课程学分,因此此处用于核对参与计算的课程,不在本地反推加权平均值。

${rows.length ? `
${body}
学期课程方案课程性质修读首次通过成绩总成绩课程绩点
` : UI.state('empty', '暂无匹配的计算课程', compulsory ? '接口明细中没有识别到必修课程' : '教务系统未返回课程记录', true)}`; }, openCourseDetails: async trigger => { if (!trigger || trigger.disabled) return; const scope = trigger.dataset.gpaScope; const period = trigger.dataset.gpaPeriod || ''; const compulsory = trigger.dataset.gpaCompulsory === '1'; const metricScore = trigger.dataset.gpaScore || '-'; const scopeLabel = scope === 'year' ? `${period} 学年` : (scope === 'semester' ? `${period} 学期` : '全学程'); const cacheKey = `${scope}:${period || 'all'}`; trigger.disabled = true; trigger.setAttribute('aria-busy', 'true'); if (!GPAModule.detailCache.has(cacheKey)) Toast.fire({ icon: 'info', title: `正在加载${scopeLabel}课程明细` }); try { const details = await GPAModule.fetchCourseDetails(scope, period); await AppDialog.details({ title: `${scopeLabel}${compulsory ? '必修课' : ''} GPA 课程明细`, html: GPAModule.renderCourseDetails(details, compulsory, metricScore), exportName: `WHUT_${scopeLabel}_${compulsory ? '必修' : '全部'}GPA课程明细`, exportRows: GPAModule.courseDetailExportRows(details, compulsory) }); } catch (error) { Diagnostics.error('gpa-detail', 'GPA 课程明细加载失败', { scope, period, error }); await AppDialog.fire('课程明细加载失败', error?.message || '请稍后重试', 'error'); } finally { if (trigger.isConnected) { trigger.disabled = false; trigger.removeAttribute('aria-busy'); } } }, bindViewControls: (container) => { if (!container) return; const segmentedRoot = container.querySelector('.gpa-segmented'); container._gpaSegmented = SegmentedControl.enhance(segmentedRoot, { itemSelector: '.gpa-segment-button', onChange: mode => GPAModule.applyDetailMode(container, mode) }); if (container.dataset.gpaControlsBound === '1') return; container.dataset.gpaControlsBound = '1'; container.addEventListener('click', event => { const detailTrigger = event.target.closest('.gpa-detail-trigger'); if (detailTrigger && container.contains(detailTrigger)) { void GPAModule.openCourseDetails(detailTrigger); } }); }, applyDetailMode: (container, requestedMode) => { const mode = ['all', 'year', 'semester'].includes(requestedMode) ? requestedMode : 'all'; GPAModule.detailMode = mode; container.dataset.gpaMode = mode; container._gpaSegmented?.select(mode, { emit: false }); container.querySelectorAll('.gpa-section').forEach(section => { const show = mode === 'all' || section.dataset.gpaSection === mode; const wasHidden = section.hidden; section.hidden = !show; section.classList.remove('is-entering'); if (show && wasHidden) requestAnimationFrame(() => section.classList.add('is-entering')); }); }, render: (overall, yearResponses, semResponses, container) => { const value = val => escapeHTML(val ?? '-'); const rank = (label, current, total) => ` ${escapeHTML(label)} ${value(current)}${total !== undefined && total !== null && String(total).trim() !== '' ? `/ ${value(total)}` : ''}`; const metric = (label, score, data, compulsory = false, detail = null) => { const className = compulsory ? 'gpa-flat-metric is-compulsory' : 'gpa-flat-metric'; const classRank = compulsory ? data?.BXKPJXFJDBJPM : data?.PJXFJDBJPM; const majorRank = compulsory ? data?.BXKPJXFJDZYPM : data?.PJXFJDZYPM; const hasScore = score !== undefined && score !== null && String(score).trim() !== ''; const scoreHTML = detail && hasScore ? `` : `${value(score)}`; return `
${escapeHTML(label)}
${scoreHTML}
${rank('班级', classRank, data?.BJZRS)}${rank('专业', majorRank, data?.ZYZRS)}
`; }; const years = Array.isArray(yearResponses) ? yearResponses : []; const semesters = Array.isArray(semResponses) ? semResponses : []; let html = overall ? `
${iconSVG('trophy')}全学程概览
${metric('必修课平均学分绩点', overall.BXKPJXFJD, overall, true, { scope: 'overall' })} ${metric('平均学分绩点', overall.PJXFJD, overall, false, { scope: 'overall' })}
` : `
${UI.state('empty', '暂无全学程 GPA 数据', '', true)}
`; html += `

${iconSVG('calendar')}完整学年${years.length}

`; if (!years.length) { html += `
${UI.state('empty', '暂无完整学年 GPA', '第一、第二学期均产生绩点后才会显示学年成绩', true)}
`; } years.forEach(res => { if (!res.data) { html += `
${iconSVG('calendar')} ${value(res.year)}${res.error ? '加载失败' : '暂无数据'}
`; return; } html += `
${iconSVG('calendar')}${value(res.data.XNDM_DISPLAY || res.data.XNDM || res.year)}
${metric('学年必修课平均学分绩点', res.data.BXKPJXFJD, res.data, true, { scope: 'year', period: res.year })} ${metric('学年平均学分绩点', res.data.PJXFJD, res.data, false, { scope: 'year', period: res.year })}
必修课学年最低绩点 ${escapeHTML(GPAModule.formatGPAValue(res.minimumCompulsoryGPA))}
`; }); html += `

${iconSVG('calendar')}各学期明细${semesters.length}

`; semesters.forEach(res => { if (!res.data) { html += `
${iconSVG('pin')} ${value(res.sem)}${res.error ? '加载失败' : '暂无数据'}
`; return; } html += `
${iconSVG('pin')}${value(res.sem)}
${metric('必修课平均学分绩点', res.data.BXKPJXFJD, res.data, true, { scope: 'semester', period: res.sem })} ${metric('平均学分绩点', res.data.PJXFJD, res.data, false, { scope: 'semester', period: res.sem })}
`; }); html += '
'; container.innerHTML = html; GPAModule.bindViewControls(container); GPAModule.applyDetailMode(container, GPAModule.detailMode); }, exportToExcel: () => { if (!GPAModule.cache) return AppDialog.fire('提示', '请先等待 GPA 数据加载完成', 'warning'); const rawRows = []; if (GPAModule.cache.overall) rawRows.push({ ...GPAModule.cache.overall, XNXQDM: '全学程' }); (GPAModule.cache.years || []).forEach(item => { if (item.data) rawRows.push({ ...item.data, XNXQDM: `${item.year}(学年)` }); }); (GPAModule.cache.semesters || []).forEach(item => { if (item.data) rawRows.push({ ...item.data, XNXQDM: item.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'); }, createImageClone: (source) => { return LongImageExport.createClone(source, { width: 720, copyComputedStyles: true, marker: 'gpa', prepare: clone => { clone.querySelector('.gpa-segmented')?.remove(); clone.querySelectorAll('.gpa-section').forEach(section => { section.hidden = false; section.style.setProperty('display', 'block', 'important'); section.classList.remove('is-entering'); }); clone.querySelectorAll('.gpa-card, .gpa-section, .gpa-flat-metric').forEach(node => { node.style.width = 'auto'; node.style.height = 'auto'; node.style.minWidth = '0'; node.style.minHeight = '0'; node.style.maxWidth = 'none'; node.style.maxHeight = 'none'; node.style.boxSizing = 'border-box'; }); clone.querySelectorAll('.gpa-flat-grid').forEach(grid => { grid.style.width = '100%'; grid.style.height = 'auto'; grid.style.gridTemplateColumns = 'repeat(2, minmax(0, 1fr))'; grid.style.alignItems = 'start'; }); clone.querySelectorAll('.gpa-flat-row').forEach(row => { row.style.width = '100%'; row.style.height = 'auto'; row.style.flexWrap = 'nowrap'; }); clone.querySelectorAll('.gpa-flat-value').forEach(value => { value.style.flex = '0 0 86px'; value.style.minWidth = '86px'; }); clone.querySelectorAll('.gpa-flat-ranks').forEach(ranks => { ranks.style.flex = '1 1 auto'; ranks.style.minWidth = '0'; ranks.style.flexWrap = 'nowrap'; }); clone.querySelectorAll('.gpa-flat-ranks span').forEach(rank => { rank.style.flex = '0 0 auto'; rank.style.whiteSpace = 'nowrap'; }); } }); }, exportToImage: async () => { const container = uiGet('gpa-content'); if (!GPAModule.isLoaded || !container) return AppDialog.fire('提示', '请先等待数据加载', 'warning'); return LongImageExport.run({ source: container, filename: 'WHUT_GPA面板报告.png', width: 720, marker: 'gpa', cloneFactory: source => GPAModule.createImageClone(source), backgroundColor: getComputedStyle(container).backgroundColor || EXPORT_THEME.bg, successMessage: 'GPA 长图已生成', errorTitle: 'GPA 长图生成失败' }); } }; // ======================================================================== // 业务模块:一键评教 // 职责:加载待评与历史任务、管理评分配置并串行提交评价。 // ======================================================================== const EvalModule = { init: async () => { if (state.eval.isLoaded) { EvalModule.renderGroups(state.eval.courses); EvalModule.setStatus(`已加载 · ${state.eval.termCode || '当前学期'} · 待评 ${state.eval.pendingCourses.filter(course => !course._completed).length} · ${state.eval.completedLoaded ? `已完成 ${state.eval.completedCourses.length}` : '已完成未加载'}`, 'ready'); return state.eval.courses; } return EvalModule.loadData(); }, setStatus: (msg, tone = 'idle') => { const el = uiGet('pj-status'); const row = uiGet('pj-status-row'); if (el) el.textContent = String(msg || ''); if (row) row.dataset.state = tone; }, requestJSON: async (url, data, timeout = 12000) => { const res = await API.requestFormJSON({ url, data, timeout }); if (!res || (res.code !== undefined && String(res.code) !== '0')) { throw new Error(res?.msg || res?.message || '评教接口返回异常'); } return res; }, typeCode: type => String(type?.PJLXDM || type?.DM || '01').trim() || '01', typeName: type => String(type?.PJLXMC || type?.MC || '评教任务').trim() || '评教任务', taskKey: course => { const anchor = course?.JXBID || course?.GROUPNO || course?.WID || [course?.KCH, course?.KXH, course?.XM || course?.JSXM].map(value => String(value ?? '').trim()).join(':'); return [course?.XNXQDM || course?.XNXQMC, course?.PJLXDM, anchor, course?.XUH] .map(value => String(value ?? '').trim()).join('|'); }, scoreSummary: course => { const rows = Array.isArray(course?.DF) ? course.DF : []; const values = rows.map(item => parseFloat(item?.score ?? item?.DF)).filter(Number.isFinite); if (values.length === 1) return `${values[0].toFixed(1).replace(/\.0$/, '')} 分`; if (values.length > 1) return `${values.length} 位教师`; const direct = parseFloat(course?.DF); return Number.isFinite(direct) ? `${direct.toFixed(1).replace(/\.0$/, '')} 分` : ''; }, normalizeSemesterScope(semesters, fallback = '') { const values = Array.isArray(semesters) ? semesters : []; const result = []; [...values, fallback].forEach(value => { const term = String(value || '').trim(); if (!/^\d{4}-\d{4}-[12]$/.test(term) || result.includes(term)) return; result.push(term); }); return result; }, async resolveAllCompletedSemesters(currentTerm = state.eval.termCode) { if (!state.rawData.length && !state.isFetching) await proactiveFetch(); if (!state.rawData.length && state.isFetching) { for (let attempt = 0; attempt < 20 && state.isFetching && !state.rawData.length; attempt++) { await waitFor(100); } } const semesters = new Set([ ...Array.from(state.semesters || []), ...state.rawData.map(course => course?.XNXQDM || course?._semester).filter(Boolean) ]); if (!semesters.size) { try { const userInfo = await API.getUserInfo(); GPAModule.calcSemesters(String(userInfo?.userId || state.userId || ''), currentTerm) .forEach(term => semesters.add(term)); GPAModule.getSavedSemesters().forEach(term => semesters.add(term)); } catch (error) { Diagnostics.warn('evaluation', '无法从身份信息推导历史评教学期', error); } } if (currentTerm) semesters.add(currentTerm); return EvalModule.normalizeSemesterScope(Array.from(semesters) .sort((left, right) => right.localeCompare(left, 'zh-CN', { numeric: true })), currentTerm); }, buildCompletedQuery(semesters) { const normalized = EvalModule.normalizeSemesterScope(semesters); const value = (normalized.length ? normalized : EvalModule.normalizeSemesterScope([], state.eval.termCode)).join(','); return JSON.stringify([{ name: 'XNXQDM', builder: 'm_value_equal', linkOpt: 'AND', value }]); }, normalizePendingCourse(course, type, term) { return { ...course, XNXQDM: course?.XNXQDM || term, XNXQMC: course?.XNXQMC || course?.XNXQDM || term, PJLXDM: EvalModule.typeCode(type), PJLXMC: EvalModule.typeName(type), _evalSource: 'pending', _completed: course?.BPJSSFYPG === '1' }; }, normalizeCompletedCourse(course, type) { return { ...course, PJLXDM: EvalModule.typeCode(type), PJLXMC: EvalModule.typeName(type), _evalSource: 'completed', _completed: true }; }, dedupeCourses(courses) { const map = new Map(); (Array.isArray(courses) ? courses : []).forEach(course => { const key = EvalModule.taskKey(course); const previous = map.get(key); if (!previous || course?._completed || !previous?._completed) map.set(key, course); }); return Array.from(map.values()); }, mergeCourses(pendingCourses, completedCourses) { return EvalModule.dedupeCourses([...(pendingCourses || []), ...(completedCourses || [])]); }, async fetchCompletedForSemesters(types, semesters, { requireAny = false } = {}) { const normalized = EvalModule.normalizeSemesterScope(semesters); const scope = normalized.length ? normalized : EvalModule.normalizeSemesterScope([], state.eval.termCode); const querySetting = EvalModule.buildCompletedQuery(scope); const results = await Promise.allSettled((types || []).map(async type => { const code = EvalModule.typeCode(type); const response = await EvalModule.requestJSON( '/jwapp/sys/pjapp/api/wdpj/getYpwj.do', `PJLXDM=${encodeURIComponent(code)}&querySetting=${encodeURIComponent(querySetting)}` ); const rows = Array.isArray(response?.datas?.getYpwj) ? response.datas.getYpwj : []; return { code, courses: rows.map(course => EvalModule.normalizeCompletedCourse(course, type)) }; })); const fulfilled = results.filter(result => result.status === 'fulfilled').map(result => result.value); const warnings = results.filter(result => result.status === 'rejected') .map(result => result.reason?.message || '部分已完成评教记录加载失败'); if (requireAny && !fulfilled.length) { throw results.find(result => result.status === 'rejected')?.reason || new Error('已完成评教记录加载失败'); } return { semesters: scope, courses: EvalModule.dedupeCourses(fulfilled.flatMap(result => result.courses)), succeededCodes: new Set(fulfilled.map(result => result.code)), warnings }; }, renderLoading: () => { const listArea = uiGet('pj-list-area'); if (!listArea) return; listArea.innerHTML = `
${Array.from({ length: 3 }, () => '
').join('')}
`; }, async loadData({ force = false, includeCompleted = state.eval.completedLoaded, notify = force } = {}) { if (state.eval.isLoading && state.eval.loadPromise) return state.eval.loadPromise; if (state.eval.isLoaded && !force) { EvalModule.renderGroups(state.eval.courses); return state.eval.courses; } const previousCourses = state.eval.courses; const previouslyLoaded = state.eval.isLoaded; const listArea = uiGet('pj-list-area'); const refreshBtn = uiGet('pj-refresh-btn'); const runBtn = uiGet('pj-run-btn'); state.eval.isLoading = true; state.eval.lastError = ''; listArea?.setAttribute('aria-busy', 'true'); EvalModule.setStatus(force ? '正在刷新评教任务…' : '正在获取评教任务…', 'loading'); ButtonControl.setLoading(refreshBtn, true, '加载中'); if (runBtn) runBtn.disabled = true; if (!previouslyLoaded) EvalModule.renderLoading(); state.eval.loadPromise = (async () => { try { const termRes = await EvalModule.requestJSON('/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) throw new Error('未获取到当前学期信息'); const lxRes = await EvalModule.requestJSON('/jwapp/sys/pjapp/api/wdpj/getPjlx.do', `XNXQDM=${encodeURIComponent(term)}`); let types = Array.isArray(lxRes?.datas?.getPjlx) ? lxRes.datas.getPjlx : []; if (!types.length) types = [{ PJLXDM: '01', PJLXMC: '默认评教' }]; const sameTerm = state.eval.termCode === term; const shouldLoadCompleted = Boolean(includeCompleted); const completedSemesters = shouldLoadCompleted ? await EvalModule.resolveAllCompletedSemesters(term) : (sameTerm && state.eval.completedLoaded ? state.eval.completedSemesters : []); const [pendingResults, completedBatch] = await Promise.all([ Promise.allSettled(types.map(async type => { const code = EvalModule.typeCode(type); const pendingQuery = JSON.stringify([ { name: 'PJLXDM', value: code, builder: 'equal', linkOpt: 'AND' }, { name: 'XNXQDM', value: term, builder: 'm_value_equal', linkOpt: 'AND' } ]); const response = await EvalModule.requestJSON( '/jwapp/sys/pjapp/api/wdpj/getDpwj.do', `PJLXDM=${encodeURIComponent(code)}&querySetting=${encodeURIComponent(pendingQuery)}` ); const rows = Array.isArray(response?.datas?.getDpwj) ? response.datas.getDpwj : []; return { code, courses: rows.map(course => EvalModule.normalizePendingCourse(course, type, term)) }; })), shouldLoadCompleted ? EvalModule.fetchCompletedForSemesters(types, completedSemesters) : Promise.resolve({ semesters: [], courses: [], succeededCodes: new Set(), warnings: [] }) ]); const pendingBatches = pendingResults.filter(result => result.status === 'fulfilled').map(result => result.value); const pendingWarnings = pendingResults.filter(result => result.status === 'rejected') .map(result => result.reason?.message || '部分待评任务加载失败'); if (!pendingBatches.length && (!shouldLoadCompleted || !completedBatch.succeededCodes.size)) { throw pendingResults.find(result => result.status === 'rejected')?.reason || new Error('评教任务接口请求失败'); } const activeTypeCodes = new Set(types.map(EvalModule.typeCode)); const pendingSucceeded = new Set(pendingBatches.map(result => result.code)); const preservedPending = sameTerm ? state.eval.pendingCourses.filter(course => activeTypeCodes.has(EvalModule.typeCode(course)) && !pendingSucceeded.has(EvalModule.typeCode(course))) : []; const pendingCourses = EvalModule.dedupeCourses([ ...preservedPending, ...pendingBatches.flatMap(result => result.courses) ]); const preservedCompleted = sameTerm && state.eval.completedLoaded ? state.eval.completedCourses.filter(course => activeTypeCodes.has(EvalModule.typeCode(course)) && (!shouldLoadCompleted || !completedBatch.succeededCodes.has(EvalModule.typeCode(course)))) : []; const completedCourses = shouldLoadCompleted ? EvalModule.dedupeCourses([...preservedCompleted, ...completedBatch.courses]) : EvalModule.dedupeCourses(preservedCompleted); const completedLoaded = shouldLoadCompleted ? completedBatch.succeededCodes.size > 0 || (sameTerm && state.eval.completedLoaded) : sameTerm && state.eval.completedLoaded; const allCourses = EvalModule.mergeCourses(pendingCourses, completedCourses); const softWarnings = [...pendingWarnings, ...(shouldLoadCompleted ? completedBatch.warnings : [])]; state.eval.termCode = term; state.eval.types = types; state.eval.pendingCourses = pendingCourses; state.eval.completedCourses = completedCourses; state.eval.completedSemesters = completedLoaded ? completedSemesters : []; state.eval.completedLoaded = completedLoaded; state.eval.courses = allCourses; if (force) state.eval.detailCache.clear(); state.eval.isLoaded = true; Diagnostics.info('evaluation', '评教任务加载完成', { term, types: types.length, pending: pendingCourses.filter(course => !course._completed).length, completed: completedCourses.length, completedLoaded, warnings: softWarnings.length }); EvalModule.renderGroups(allCourses); const completedStatus = completedLoaded ? `已完成 ${completedCourses.length}` : '已完成未加载'; EvalModule.setStatus(`已加载 · ${term} · 待评 ${allCourses.filter(course => !course._completed).length} · ${completedStatus}${softWarnings.length ? ' · 部分记录待重试' : ''}`, softWarnings.length ? 'warning' : 'ready'); if (force && notify) Toast.fire({ icon: softWarnings.length ? 'warning' : 'success', title: softWarnings.length ? '评教任务已刷新,部分历史记录暂缺' : '评教任务已刷新' }); return allCourses; } catch (e) { state.eval.lastError = e?.message || '未知错误'; Diagnostics.error('evaluation', '评教任务加载失败', { retainedPreviousData: previouslyLoaded, error: e }); EvalModule.setStatus(`加载失败 · ${state.eval.lastError}`, 'error'); if (previouslyLoaded) { state.eval.courses = previousCourses; state.eval.isLoaded = true; EvalModule.renderGroups(previousCourses); Toast.fire({ icon: 'error', title: '刷新失败,已保留原列表', text: state.eval.lastError }); } else { state.eval.isLoaded = false; if (listArea) listArea.innerHTML = UI.state('error', '评教任务加载失败', `${state.eval.lastError};可点击下方刷新重试`); } return previousCourses; } finally { state.eval.isLoading = false; state.eval.loadPromise = null; listArea?.setAttribute('aria-busy', 'false'); ButtonControl.setLoading(refreshBtn, false); EvalModule.syncSelectionState(); } })(); return state.eval.loadPromise; }, async loadCompletedHistory(button) { if (state.eval.isLoadingHistory && state.eval.historyPromise) return state.eval.historyPromise; if (state.eval.isLoading || state.eval.isRunning) return state.eval.courses; if (!state.eval.isLoaded) { await EvalModule.loadData(); if (!state.eval.isLoaded) return state.eval.courses; } const listArea = uiGet('pj-list-area'); const refreshBtn = uiGet('pj-refresh-btn'); const runBtn = uiGet('pj-run-btn'); const previousScrollTop = listArea?.scrollTop || 0; state.eval.isLoadingHistory = true; listArea?.setAttribute('aria-busy', 'true'); ButtonControl.setLoading(button, true, '加载中'); if (refreshBtn) refreshBtn.disabled = true; if (runBtn) runBtn.disabled = true; EvalModule.setStatus('正在加载全部已完成评教…', 'loading'); state.eval.historyPromise = (async () => { try { const semesters = await EvalModule.resolveAllCompletedSemesters(state.eval.termCode); const batch = await EvalModule.fetchCompletedForSemesters(state.eval.types, semesters, { requireAny: true }); const completedCourses = EvalModule.dedupeCourses([ ...state.eval.completedCourses.filter(course => !batch.succeededCodes.has(EvalModule.typeCode(course))), ...batch.courses ]); state.eval.completedSemesters = semesters; state.eval.completedCourses = completedCourses; state.eval.completedLoaded = true; state.eval.courses = EvalModule.mergeCourses(state.eval.pendingCourses, completedCourses); state.eval.isLoadingHistory = false; EvalModule.renderGroups(state.eval.courses, { openCompleted: true }); if (listArea) listArea.scrollTop = previousScrollTop; requestAnimationFrame(() => uiGet('pj-load-completed')?.focus({ preventScroll: true })); const warningText = batch.warnings.length ? ' · 部分类型待重试' : ''; EvalModule.setStatus(`已加载全部历史 · ${semesters.length} 个学期 · 已完成 ${completedCourses.length}${warningText}`, batch.warnings.length ? 'warning' : 'ready'); Toast.fire({ icon: batch.warnings.length ? 'warning' : 'success', title: `已加载 ${completedCourses.length} 条已完成评教`, text: batch.warnings.length ? '部分评教类型可点击重新加载重试' : '' }); return state.eval.courses; } catch (error) { EvalModule.setStatus(`历史记录加载失败 · ${error?.message || '请稍后重试'}`, 'error'); Toast.fire({ icon: 'error', title: '已完成评教加载失败', text: error?.message || '请稍后重试' }); return state.eval.courses; } finally { state.eval.isLoadingHistory = false; state.eval.historyPromise = null; listArea?.setAttribute('aria-busy', 'false'); if (button?.isConnected) ButtonControl.setLoading(button, false); if (refreshBtn) refreshBtn.disabled = false; EvalModule.syncSelectionState(); } })(); return state.eval.historyPromise; }, renderGroups(courses, { openCompleted = false } = {}) { const listArea = uiGet('pj-list-area'); if (!listArea) return; listArea.replaceChildren(); const groups = { ing: [], done: [], wait: [], end: [] }; const counts = { ing: 0, done: 0, wait: 0, end: 0 }; const now = new Date(); courses.forEach((c, idx) => { c._idx = idx; const parseDate = value => { if (!value) return null; const date = new Date(String(value).replace(/-/g, '/')); return Number.isNaN(date.getTime()) ? null : date; }; const start = parseDate(c.KSSJ); const end = parseDate(c.JSSJ); if (c._completed || c._evalSource === 'completed' || c.BPJSSFYPG === '1') c._status = 'done'; else if (start && now < start) c._status = 'wait'; else if (end && now > end) c._status = 'end'; else c._status = 'ing'; counts[c._status]++; groups[c._status].push(c); }); const summary = document.createElement('section'); summary.className = 'eval-summary'; summary.setAttribute('aria-label', '评教任务概览'); summary.innerHTML = `
CURRENT + HISTORY ${escapeHTML(state.eval.termCode || '当前学期')} ${state.eval.completedLoaded ? `待评任务来自当前学期,已完成记录覆盖 ${state.eval.completedSemesters.length} 个学期` : '待评任务已同步;已完成记录等待手动加载'}
${counts.ing}待评 ${counts.done}已完成 ${counts.wait}未开始 ${counts.end}已结束
`; listArea.appendChild(summary); if (courses.length === 0) { const empty = document.createElement('div'); empty.innerHTML = UI.state('empty', '当前暂无评教任务', '新任务开放后可在此统一处理', true); listArea.appendChild(empty.firstElementChild); } const createGrp = ({ title, courses: groupCourses, isOpen, canCheck, tone }) => { const g = document.createElement('div'); g.className = 'pj-group'; const cbHtml = canCheck ? '' : ''; g.innerHTML = `
${cbHtml}
`; const header = g.querySelector('.pj-group-header'); const toggleGroup = () => { const content = g.querySelector('.pj-group-content'); const show = content.classList.contains('is-collapsed'); content.classList.toggle('is-collapsed', !show); header.setAttribute('aria-expanded', String(show)); }; header.addEventListener('click', toggleGroup); const items = g.querySelector('.pj-group-items'); groupCourses.forEach(course => items.appendChild(createItem(course))); if (canCheck) { const groupBox = g.querySelector('.pj-grp-cb'); const itemBoxes = Array.from(g.querySelectorAll('.pj-item-cb:not([disabled])')); const syncGroupBox = () => { const selected = itemBoxes.filter(box => box.checked).length; groupBox.checked = itemBoxes.length > 0 && selected === itemBoxes.length; groupBox.indeterminate = selected > 0 && selected < itemBoxes.length; EvalModule.syncSelectionState(); }; groupBox.addEventListener('change', () => { itemBoxes.forEach(box => { box.checked = groupBox.checked; }); syncGroupBox(); }); itemBoxes.forEach(box => box.addEventListener('change', syncGroupBox)); syncGroupBox(); } listArea.appendChild(g); }; const createItem = (c) => { let badge = '', disabled = true, checked = false; const scoreText = EvalModule.scoreSummary(c); if (c._status === 'done') { badge = `已完成${scoreText ? ` · ${escapeHTML(scoreText)}` : ''}`; } else if (c._status === 'ing') { badge = `进行中`; disabled = false; checked = true; } else if (c._status === 'wait') { badge = `未开始`; } else { badge = `已结束`; } const title = c.KCM || c.KCH || '未命名课程'; const teacher = c.XM || c.JSXM || '教师信息待同步'; const semester = c.XNXQMC || c.XNXQDM || ''; const selector = disabled ? `` : ``; const detailAction = c._status === 'done' ? `` : ''; const div = document.createElement('div'); div.className = 'pj-item'; if (c._status === 'done' && semester && semester !== state.eval.termCode) div.classList.add('is-history'); div.dataset.status = c._status; div.innerHTML = `${selector}
${escapeHTML(title)}
${escapeHTML(teacher)}${c.PJLXMC ? `${escapeHTML(c.PJLXMC)}` : ''}${semester ? `${escapeHTML(semester)}` : ''}
${badge}${detailAction}
`; div.querySelector('.pj-detail-btn')?.addEventListener('click', event => EvalModule.openCompletedDetail(c._idx, event.currentTarget)); return div; }; const appendHistoryLoader = () => { const loaded = state.eval.completedLoaded; const semesters = loaded ? EvalModule.normalizeSemesterScope(state.eval.completedSemesters) : []; const first = semesters[0] || state.eval.termCode || '当前学期'; const last = semesters.at(-1) || first; const loader = document.createElement('section'); loader.className = 'eval-history-loader'; loader.setAttribute('aria-label', '加载已完成评教历史'); loader.innerHTML = `
COMPLETED HISTORY ${loaded ? `已完成评教 · ${counts.done} 条` : '已完成评教 · 未加载'} ${loaded ? `已加载 ${semesters.length} 个学期 · ${escapeHTML(first)}${first !== last ? ` 至 ${escapeHTML(last)}` : ''}` : '点击后一次加载全部有成绩学期的已完成评教记录'}
`; loader.querySelector('#pj-load-completed')?.addEventListener('click', event => EvalModule.loadCompletedHistory(event.currentTarget)); listArea.appendChild(loader); }; if (groups.ing.length) createGrp({ title: '待评任务', courses: groups.ing, isOpen: true, canCheck: true, tone: 'is-active' }); if (groups.done.length) createGrp({ title: '已完成', courses: groups.done, isOpen: openCompleted || !groups.ing.length, canCheck: false, tone: 'is-done' }); appendHistoryLoader(); if (groups.wait.length) createGrp({ title: '尚未开始', courses: groups.wait, isOpen: false, canCheck: false, tone: 'is-wait' }); if (groups.end.length) createGrp({ title: '已经结束', courses: groups.end, isOpen: false, canCheck: false, tone: 'is-end' }); EvalModule.syncSelectionState(); }, normalizeCompletedDetail(course, node) { const questions = Array.isArray(node?.questionList) ? node.questionList : []; const teacherRows = Array.isArray(node?.teachers) && node.teachers.length ? node.teachers : [{ XM: course?.XM, KCM: course?.KCM, DF: EvalModule.scoreSummary(course), DA: [] }]; const parseAnswerPayload = answer => { if (!answer) return null; if (answer.DA && typeof answer.DA === 'object') return answer.DA; for (const value of [answer.DA, answer.DAStr]) { if (typeof value !== 'string' || !value.trim()) continue; try { return JSON.parse(value); } catch (e) { return value.trim(); } } return null; }; const teachers = teacherRows.map(teacher => { const answers = Array.isArray(teacher?.DA) ? teacher.DA : []; const answerMap = new Map(answers.map(answer => [String(answer?.TMID || ''), answer])); return { name: teacher?.XM || course?.XM || course?.JSXM || '教师信息待同步', courseName: teacher?.KCM || course?.KCM || '未命名课程', score: teacher?.DF ?? '', answers: questions.map((question, index) => { const answer = answerMap.get(String(question?.TMID || '')) || null; const payload = parseAnswerPayload(answer); const options = Array.isArray(question?.questionOptions) ? question.questionOptions : []; const selectedId = answer?.XXID || (payload && typeof payload === 'object' ? payload.TMXXID : ''); const selected = options.find(option => String(option?.WID || '') === String(selectedId || '')); let answerText = ''; if (String(question?.TX) === '02') { answerText = typeof payload === 'string' ? payload : typeof answer?.DA === 'string' ? answer.DA : typeof answer?.DAStr === 'string' && !/^\s*[\[{]/.test(answer.DAStr) ? answer.DAStr : ''; } else { answerText = selected?.MC || (selectedId ? '已选择(选项文本未返回)' : ''); } return { index: index + 1, type: String(question?.TX || ''), question: question?.MC || question?.TMNR || question?.TMMC || `第 ${index + 1} 题`, answer: answerText || '未填写', score: answer?.DF, maxScore: question?.ZF }; }) }; }); return { questionnaire: node?.WJMC || course?.MC || course?.PJLXMC || '学生评教', deadline: node?.JZRQ || course?.JSSJ || '', teachers }; }, renderCompletedDetail(course, detail) { const courseName = course?.KCM || course?.KCH || '未命名课程'; const meta = [course?.XNXQMC || course?.XNXQDM, course?.PJLBMC, detail?.questionnaire].filter(Boolean); const teachers = Array.isArray(detail?.teachers) ? detail.teachers : []; const teacherHtml = teachers.map((teacher, teacherIndex) => { const numericScore = parseFloat(teacher.score); const score = Number.isFinite(numericScore) ? numericScore.toFixed(1).replace(/\.0$/, '') : String(teacher.score || '-'); const answers = teacher.answers.map(item => { const points = Number.isFinite(parseFloat(item.score)) ? `${escapeHTML(parseFloat(item.score).toFixed(1).replace(/\.0$/, ''))} 分` : ''; return `
  • ${item.index}

    ${escapeHTML(item.question)}

    ${escapeHTML(item.answer)}${points}
  • `; }).join(''); return `
    教师 ${teacherIndex + 1}

    ${escapeHTML(teacher.name)}

    ${escapeHTML(score)}总分
      ${answers || '
    1. 该问卷未返回题目明细
    2. '}
    `; }).join(''); return `
    已完成评教

    ${escapeHTML(courseName)}

    ${meta.map(escapeHTML).join(' · ')}

    ${escapeHTML(EvalModule.scoreSummary(course) || `${teachers.length} 位教师`)}
    ${detail?.deadline ? `
    问卷截止:${escapeHTML(detail.deadline)}
    ` : ''} ${teacherHtml || '
    该任务未返回教师作答详情
    '}
    `; }, async openCompletedDetail(index, button) { const course = state.eval.courses[Number(index)]; if (!course) return Toast.fire({ icon: 'error', title: '评教记录已失效,请刷新列表' }); const cacheKey = `${course.XNXQDM || course.XNXQMC || state.eval.termCode}|${EvalModule.taskKey(course)}`; ButtonControl.setLoading(button, true, '读取中'); try { let detail = state.eval.detailCache.get(cacheKey); if (!detail) { const query = new URLSearchParams({ GROUPNO: course.GROUPNO || '', PJLXDM: course.PJLXDM || '01', XUH: course.XUH || 1 }).toString(); const response = await EvalModule.requestJSON('/jwapp/sys/pjapp/api/wdpj/getWjtxxYp.do', query, 15000); const node = response?.datas?.getWjtxxYp; if (!node || typeof node !== 'object') throw new Error('已评问卷详情为空'); detail = EvalModule.normalizeCompletedDetail(course, node); state.eval.detailCache.set(cacheKey, detail); } ButtonControl.setLoading(button, false); return AppDialog.fire({ eyebrow: 'EVALUATION DETAILS', title: '评教填写详情', unsafeHTML: EvalModule.renderCompletedDetail(course, detail), width: 'min(720px, calc(100vw - 32px))', showCloseButton: true, confirmButtonText: '关闭' }); } catch (error) { ButtonControl.setLoading(button, false); return AppDialog.fire({ icon: 'error', title: '评教详情读取失败', text: error?.message || '请稍后重试' }); } }, syncSelectionState() { const button = uiGet('pj-run-btn'); if (!button || state.eval.isRunning) return; const selectable = uiQueryAll('#whut-helper-panel .pj-item-cb:not([disabled])').length; const selected = uiQueryAll('#whut-helper-panel .pj-item-cb:checked').length; button.disabled = state.eval.isLoading || state.eval.isLoadingHistory || selectable === 0; button.innerHTML = selected > 0 ? `${iconSVG('rocket')}开始一键评教${selected}` : `${iconSVG('rocket')}开始一键评教`; button.setAttribute('aria-label', selected > 0 ? `开始评教,已选择 ${selected} 门课程` : '开始一键评教'); }, async runSelected() { if (state.eval.isRunning || state.eval.isLoading || state.eval.isLoadingHistory) return; const cbs = uiQueryAll('#whut-helper-panel .pj-item-cb:checked'); if (cbs.length === 0) return Toast.fire({ icon: 'info', title: '请先勾选需要评教的课程' }); state.eval.isRunning = true; const btn = uiGet('pj-run-btn'); const refreshBtn = uiGet('pj-refresh-btn'); const configBtn = uiGet('pj-config-toggle'); ButtonControl.setLoading(btn, true, `正在处理 0/${cbs.length}`); if (refreshBtn) refreshBtn.disabled = true; if (configBtn) configBtn.disabled = true; EvalModule.setStatus(`正在提交评教 · 0/${cbs.length}`, 'loading'); let success = 0; let failed = 0; try { for (let i = 0; i < cbs.length; i++) { const idx = cbs[i].value; const c = state.eval.courses[idx]; const logEl = uiGet(`pj-log-${idx}`); const busyLabel = btn?.querySelector('span:last-child'); if (busyLabel) busyLabel.textContent = `正在处理 ${i + 1}/${cbs.length}`; EvalModule.setStatus(`正在提交评教 · ${i + 1}/${cbs.length}`, 'loading'); if (logEl) logEl.textContent = '读取题目…'; try { if (!c) throw new Error('课程数据已失效,请刷新列表'); const query = new URLSearchParams({ GROUPNO: c.GROUPNO || '', PJLXDM: c.PJLXDM || '01', XUH: c.XUH || 1, JXBID: c.JXBID || '', KCH: c.KCH || '' }).toString(); const wjRes = await EvalModule.requestJSON('/jwapp/sys/pjapp/api/wdpj/getWjtxxx.do', query, 15000); const wjData = wjRes?.datas?.getWjtxxx; if (!wjData || !Array.isArray(wjData.teachers) || !wjData.teachers.length) throw new Error('该课程暂无可提交的问卷'); const tInfo = wjData.teachers[0]; const daArray = []; const questions = Array.isArray(wjData.questionList) ? wjData.questionList : []; questions.forEach(q => { const questionOptions = Array.isArray(q.questionOptions) ? q.questionOptions : []; if (q.TX === '01' && questionOptions.length > 0) { const opts = [...questionOptions].sort((a, b) => toNum(b.FZ) - toNum(a.FZ)); let sel = opts[0]; if (state.eval.config.randomScore && Math.random() > 0.9 && opts.length > 1) sel = opts[1]; const answer = { TMXXID: sel.WID, FJXX: '' }; daArray.push({ DA: answer, XXID: sel.WID, DAStr: JSON.stringify(answer), 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' }); } }); if (!daArray.length) throw new Error('问卷没有可填写的题目'); if (logEl) logEl.textContent = '提交中…'; 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 payloadText = `requestParamStr=${encodeURIComponent(JSON.stringify(payload))}`; await EvalModule.requestJSON('/jwapp/sys/pjapp/api/wdpj/calculateQuestionnaireAnswerScore.do', payloadText, 15000); await EvalModule.requestJSON('/jwapp/sys/pjapp/api/wdpj/commitQuestionnaireAnswer.do', payloadText, 15000); if (logEl) logEl.innerHTML = `${iconSVG('check')} 提交成功`; success++; } catch (e) { failed++; if (logEl) logEl.innerHTML = `${iconSVG('close')} ${escapeHTML(e?.message || '提交失败')}`; } if (i < cbs.length - 1) await waitFor(state.eval.config.delay); } } finally { state.eval.isRunning = false; ButtonControl.setLoading(btn, false); if (refreshBtn) refreshBtn.disabled = false; if (configBtn) configBtn.disabled = false; EvalModule.syncSelectionState(); EvalModule.setStatus(`任务结束 · 成功 ${success} 门${failed ? ` · 失败 ${failed} 门` : ''}`, failed ? 'error' : 'ready'); } if (success > 0) { EvalModule.setStatus('提交完成,正在刷新待评与已完成列表…', 'loading'); await waitFor(350); await EvalModule.loadData({ force: true, includeCompleted: true, notify: false }); } AppDialog.fire({ icon: failed ? 'warning' : 'success', title: '评教任务结束', text: `成功提交 ${success} / ${cbs.length} 门课程${failed ? `,${failed} 门需要重试` : ''}${success ? ';列表已自动刷新' : ''}` }); } }; // ======================================================================== // 业务模块:课表查询(本人及教室整学期接口,移动接口用于列表和备用单周查询) // ======================================================================== // KSJC / JSJC 使用包含中课、晚课的排序序号;移动 sectionStart / sectionEnd 是普通第 1–13 节。 // 冲突检测与网格定位统一为排序序号的集合,跨区间时不能把移动端不存在的中课/晚课填入。 const SCHEDULE_PERIODS = Object.freeze([ { start: 1, end: 2, title: '第一大节', label: '第 1–2 节' }, { start: 3, end: 5, title: '第二大节', label: '第 3–5 节' }, { start: 6, end: 7, title: '中课', label: '中课 1–2' }, { start: 8, end: 10, title: '第三大节', label: '第 6–8 节' }, { start: 11, end: 12, title: '第四大节', label: '第 9–10 节' }, { start: 13, end: 13, title: '晚课', label: '晚课' }, { start: 14, end: 16, title: '第五大节', label: '第 11–13 节' } ]); // 课表请求统一串行;普通翻周和重试也遵守间隔,避免接口因频率过高拒绝请求。 const ScheduleRequests = { tail: Promise.resolve(), completedAt: 0, minGapMs: 1500, retryDelayMs: 2000, retries: 3, cancelled() { return Object.assign(new Error('查询条件已更改'), { code: 'ABORTED', retryable: false }); }, async pause(ms, isCurrent) { const end = Date.now() + ms; while (Date.now() < end) { if (!isCurrent()) throw ScheduleRequests.cancelled(); await waitFor(Math.min(200, end - Date.now())); } if (!isCurrent()) throw ScheduleRequests.cancelled(); }, run(task, { isCurrent = () => true, onRetry = () => {} } = {}) { const run = async () => { for (let attempt = 0; ; attempt++) { await ScheduleRequests.pause(Math.max(0, ScheduleRequests.completedAt + ScheduleRequests.minGapMs - Date.now()), isCurrent); try { try { return await task(); } finally { ScheduleRequests.completedAt = Date.now(); } } catch (error) { if (!isCurrent()) throw ScheduleRequests.cancelled(); if (attempt >= ScheduleRequests.retries || error?.retryable === false || ['ABORTED', 'MOBILE_AUTH', 'AUTH_HTML'].includes(error?.code) || [401, 403, 404, 405].includes(error?.status)) throw error; onRetry(attempt + 1); await ScheduleRequests.pause(ScheduleRequests.retryDelayMs * (2 ** attempt), isCurrent); } } }; const result = ScheduleRequests.tail.then(run); ScheduleRequests.tail = result.then(() => {}, () => {}); return result; } }; const ScheduleModule = { state: { initialized: false, mode: 'student', generation: 0, busy: false, page: 1, pageSize: 20, total: 0, rows: [], courses: [], selected: null, calendar: null, currentTerm: '', currentCalendar: null, result: null, collapsed: true, studentUnlocked: false, secretClicks: 0, secretClickedAt: 0, lastWeek: 1, sourceNotice: '', weekCache: new Map(), weekRequests: new Map(), termCache: new Map(), termRequests: new Map(), failedTermSources: new Set() }, modes: { student: '学生课程表', school: '全校课程表', classroom: '教室课程表' }, days: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'], async init() { const s = ScheduleModule.state; if (s.initialized) return; s.initialized = true; AppResources.add(() => { ++s.generation; s.scrollSync?.destroy(); }); const field = (id, label, placeholder = '') => ``; uiGet('schedule-content').innerHTML = `
    ${Object.entries(ScheduleModule.modes).map(([key, label]) => ``).join('')}

    正在读取当前学期...

    ${DisclosureControl.actions({ resetId: 'schedule-reset', toggleId: 'schedule-toggle', controlsId: 'schedule-controls', toggleClass: 'schedule-filter-toggle' })}
    `; SelectControl.initAll(uiGet('schedule-content')); ButtonControl.enhanceAll(uiGet('schedule-content')); const segmented = SegmentedControl.enhance(uiGet('schedule-modes'), { onChange: mode => ScheduleModule.changeMode(mode) }); AppResources.add(() => segmented.destroy()); segmented.select(s.mode, { emit: false }); ScheduleModule.syncModeFields(); ScheduleModule.syncTermOptions(); s.disclosure = DisclosureControl.enhance(uiGet('schedule-toggle'), uiGet('schedule-controls'), { collapsed: s.collapsed, onChange: collapsed => { s.collapsed = collapsed; } }); AppResources.add(s.disclosure.destroy); uiGet('schedule-form').addEventListener('submit', event => { event.preventDefault(); ScheduleModule.query(1, s.selected, { refresh: true }); }); uiGet('schedule-reset').addEventListener('click', () => ScheduleModule.resetFilters()); uiGet('schedule-term').addEventListener('change', () => { ScheduleModule.invalidate(); ScheduleModule.syncCustomTerm(); ScheduleModule.syncFullTermOption(); if (uiGet('schedule-term').value === 'custom') { uiGet('schedule-term-custom').focus(); ScheduleModule.setStatus('请输入自定义学期,点击“查询”加载。'); } else { const current = ScheduleModule.currentCalendar(); ScheduleModule.setWeeks(current?.maxWeekNum || 20, ScheduleModule.readTerm() === current?.xnxqdm ? current.todayWeekNum : 1); ScheduleModule.query(); } }); uiGet('schedule-week').addEventListener('change', () => ScheduleModule.query(1, s.selected)); uiGet('schedule-form').addEventListener('input', event => { if (event.target.tagName !== 'INPUT') return; ScheduleModule.invalidate(); ScheduleModule.syncFullTermOption(); ScheduleModule.setStatus('查询条件已更改,点击“查询”加载。'); }); uiGet('schedule-arranged').addEventListener('change', () => ScheduleModule.query()); uiGet('schedule-results').addEventListener('click', event => { const trigger = event.target.closest('[data-schedule-detail]'); if (trigger) void ScheduleModule.openCourseDetails(Number(trigger.dataset.scheduleDetail)); }); const generation = ++s.generation; ScheduleModule.setBusy(true); try { const user = await API.getUserInfo({ force: true }); if (generation !== s.generation) return; uiGet('schedule-student').value = user.userId || ''; s.calendar = ScheduleModule.calendarFromUserInfo(user) || await ScheduleModule.fetchCalendar('', generation); if (generation !== s.generation) return; s.currentTerm = s.calendar.xnxqdm; s.currentCalendar = { ...s.calendar }; ScheduleModule.syncTermOptions(s.currentTerm); s.lastWeek = s.calendar.todayWeekNum; ScheduleModule.setWeeks(s.calendar.maxWeekNum, s.calendar.todayWeekNum); await ScheduleModule.query(); } catch (error) { if (generation === s.generation) ScheduleModule.showError(error); } finally { if (generation === s.generation) ScheduleModule.setBusy(false); } }, calendarFromUserInfo(user) { const welcome = user?.welcomeInfo; if (!/^\d{4}-\d{4}-[12]$/.test(String(welcome?.xnxqdm || ''))) return null; const week = clampValue(Math.floor(Number(welcome.classWeek)) || 1, 1, 60); return { xnxqdm: welcome.xnxqdm, todayWeekNum: week, maxWeekNum: Math.max(20, week), provisional: true, maxWeekKnown: false }; }, currentCalendar() { const s = ScheduleModule.state; const current = ScheduleModule.calendarFromUserInfo(state.userInfo) || s.currentCalendar; const known = s.calendar?.xnxqdm === current?.xnxqdm ? s.calendar : s.currentCalendar; return current && { ...current, ...(known?.xnxqdm === current.xnxqdm ? { maxWeekNum: known.maxWeekNum } : {}) }; }, termOptions(semesters = state.semesters, currentTerm = ScheduleModule.state.currentTerm) { return [...new Set([...semesters, currentTerm].map(value => String(value || '').trim()))].filter(value => { const match = value.match(/^(\d{4})-(\d{4})-([12])$/); return match && Number(match[2]) === Number(match[1]) + 1; }).sort((a, b) => b.localeCompare(a)); }, syncTermOptions(selected = uiGet('schedule-term')?.value) { const select = uiGet('schedule-term'); if (!select) return; const s = ScheduleModule.state; const terms = ScheduleModule.termOptions(); select.innerHTML = terms.map(term => ``).join('') + (s.studentUnlocked ? '' : ''); select.value = terms.includes(selected) || selected === 'custom' && s.studentUnlocked ? selected : (terms.includes(s.currentTerm) ? s.currentTerm : terms[0] || ''); SelectControl.refresh(select); ScheduleModule.syncCustomTerm(); }, syncCustomTerm() { const input = uiGet('schedule-term-custom'); if (!input) return; const custom = ScheduleModule.state.studentUnlocked && uiGet('schedule-term').value === 'custom'; input.hidden = !custom; input.disabled = !custom; }, readTerm() { const term = uiGet('schedule-term')?.value.trim() || ''; return term === 'custom' && ScheduleModule.state.studentUnlocked ? uiGet('schedule-term-custom').value.trim() : term; }, resetFilters() { ['course', 'code', 'teacher', 'room', 'term-custom'].forEach(id => { uiGet(`schedule-${id}`).value = ''; }); uiGet('schedule-student').value = state.userId || ''; SelectControl.setValue('schedule-arranged', ''); const current = ScheduleModule.currentCalendar(); if (current) { ScheduleModule.state.currentTerm = current.xnxqdm; ScheduleModule.syncTermOptions(current.xnxqdm); ScheduleModule.setWeeks(current.maxWeekNum, current.todayWeekNum); } ScheduleModule.query(); }, async fetchCalendar(termCode, generation, fallback = null) { if (termCode) { try { return await ScheduleModule.request(async () => { const response = await API.requestFormJSON({ url: '/jwapp/sys/kcbcxby/modules/xskcb/cxxljc.do', data: { XN: termCode.slice(0, -2), XQ: termCode.slice(-1) }, timeout: 15000 }); const row = ScheduleModule.desktopNode(response, 'cxxljc').rows[0]; const max = Number(row?.ZZC ?? row?.ZJXZC); if (!Number.isInteger(max) || max < 1 || max > 60) throw new Error('校历未返回有效周数'); if (row.XN && `${row.XN}-${row.XQ}` !== termCode) throw new Error('校历学期不匹配'); const welcome = ScheduleModule.calendarFromUserInfo(state.userInfo); return { xnxqdm: termCode, maxWeekNum: max, todayWeekNum: welcome?.xnxqdm === termCode ? clampValue(welcome.todayWeekNum, 1, max) : 1, maxWeekKnown: true }; }, generation); } catch (error) { if (generation !== ScheduleModule.state.generation) throw error; // 已有桌面首页的学期和当前周时,不因移动校历认证失败而阻断本人课表。 if (fallback?.xnxqdm === termCode) return { ...fallback, provisional: false }; } } const response = await ScheduleModule.request(() => MobileAPI.request('/common/school/time', termCode ? { xnxqdm: termCode, isNxg: false } : {}), generation); return ScheduleModule.calendarData(response); }, calendarData(response) { const data = response?.data || response; if (!/^\d{4}-\d{4}-[12]$/.test(String(data?.xnxqdm || ''))) throw new Error('未能获取学年学期,请填写学期后重试'); return { ...data, maxWeekNum: clampValue(Number(data.maxWeekNum) || 20, 1, 60), todayWeekNum: Math.max(1, Number(data.todayWeekNum) || 1), maxWeekKnown: Number.isInteger(Number(data.maxWeekNum)) && Number(data.maxWeekNum) > 0 }; }, setWeeks(max, selected) { const select = uiGet('schedule-week'); select.innerHTML = '' + Array.from({ length: max }, (_, index) => ``).join(''); select.value = selected === 'all' ? 'all' : String(clampValue(Number(selected) || 1, 1, max)); SelectControl.refresh(select); ScheduleModule.syncFullTermOption(); }, setCollapsed(collapsed) { ScheduleModule.state.disclosure?.setCollapsed(collapsed); }, revealStudentOnClick(now = Date.now()) { const s = ScheduleModule.state; if (s.studentUnlocked) return; s.secretClicks = now - s.secretClickedAt <= 1500 ? s.secretClicks + 1 : 1; s.secretClickedAt = now; if (s.secretClicks < 5) return; s.studentUnlocked = true; ScheduleModule.syncModeFields(); ScheduleModule.syncTermOptions(); ScheduleModule.syncFullTermOption(); if (s.initialized) { ScheduleModule.setCollapsed(false); if (s.mode === 'student') uiGet('schedule-student').focus(); Toast.fire({ icon: 'success', title: '已解锁自定义学期、学号查询与逐周整学期查询' }); } }, syncModeFields() { const s = ScheduleModule.state; uiGet('schedule-content')?.querySelectorAll('[data-schedule-mode]').forEach(el => { el.hidden = el.dataset.scheduleMode !== s.mode || (s.mode === 'student' && !s.studentUnlocked); }); const showSearch = s.mode !== 'student' || s.studentUnlocked; const searchRow = uiGet('schedule-search-row'); if (searchRow) searchRow.hidden = !showSearch; const defaultQuery = uiGet('schedule-default-query'); if (defaultQuery) defaultQuery.hidden = showSearch; const target = uiGet(showSearch ? 'schedule-search-actions' : 'schedule-default-query'); const queryButton = uiGet('schedule-query'); if (target && queryButton && queryButton.parentElement !== target) target.appendChild(queryButton); }, termSourceKey(query, selected) { if (ScheduleModule.isOwnStudent(query)) return JSON.stringify([state.userId, query.termCode, 'own']); if (query.mode === 'classroom') return JSON.stringify([state.userId, query.termCode, 'classroom', selected?.classroomNo || '']); return ''; }, fullTermAvailable(query, selected) { const key = ScheduleModule.termSourceKey(query, selected); return ScheduleModule.state.studentUnlocked && ScheduleModule.state.calendar?.maxWeekKnown !== false || !!key && !ScheduleModule.state.failedTermSources.has(key); }, syncFullTermOption(query = null, selected = ScheduleModule.state.selected) { const select = uiGet('schedule-week'); if (!select) return; const s = ScheduleModule.state; query ||= { mode: s.mode, termCode: ScheduleModule.readTerm(), studentId: uiGet('schedule-student')?.value.trim() }; const allowed = ScheduleModule.fullTermAvailable(query, selected); const option = select.querySelector('option[value="all"]'); if (option) { option.disabled = !allowed; option.textContent = allowed ? '完整学期' : '完整学期(暂不可用)'; } if (!allowed && select.value === 'all') { select.value = String(clampValue(s.lastWeek, 1, s.calendar?.maxWeekNum || 20)); if (query.week === 'all') query.week = Number(select.value); } SelectControl.refresh(select); }, request(task, generation = ScheduleModule.state.generation) { return ScheduleRequests.run(task, { isCurrent: () => generation === ScheduleModule.state.generation, onRetry: attempt => ScheduleModule.setStatus(`课表读取失败,正在等待第 ${attempt}/3 次重试...`) }); }, cachedRequest(cache, pending, key, loader, { refresh = false, generation = ScheduleModule.state.generation, ttl = Infinity } = {}) { const cached = cache.get(key); if (!refresh && cached && Date.now() - cached.time < ttl) return Promise.resolve(cached.rows); if (refresh) cache.delete(key); if (pending.has(key)) { const entry = pending.get(key); entry.generation = generation; return entry.promise; } const entry = { generation }; const request = task => ScheduleRequests.run(task, { isCurrent: () => entry.generation === ScheduleModule.state.generation, onRetry: attempt => ScheduleModule.setStatus(`课表读取失败,正在等待第 ${attempt}/3 次重试...`) }); entry.promise = loader(request).then(rows => { cache.set(key, { rows, time: Date.now() }); while (cache.size > 300) cache.delete(cache.keys().next().value); return rows; }).finally(() => { if (pending.get(key) === entry) pending.delete(key); }); pending.set(key, entry); return entry.promise; }, setStatus(text) { SummaryTags.set(uiGet('schedule-status'), Array.isArray(text) ? text : [text]); }, setBusy(busy) { ScheduleModule.state.busy = busy; ButtonControl.setLoading(uiGet('schedule-query'), busy, '查询中...'); uiGet('schedule-results')?.setAttribute('aria-busy', String(busy)); }, invalidate() { const s = ScheduleModule.state; ++s.generation; s.scrollSync?.destroy(); s.scrollSync = null; s.selected = null; s.result = null; s.courses = []; s.rows = []; ScheduleModule.setBusy(false); uiGet('schedule-results').innerHTML = ''; uiGet('schedule-result-actions').innerHTML = ''; }, changeMode(mode) { if (!Object.hasOwn(ScheduleModule.modes, mode)) return; ScheduleModule.invalidate(); ScheduleModule.state.mode = mode; ScheduleModule.syncModeFields(); ScheduleModule.syncFullTermOption(); ScheduleModule.query(); }, readQuery() { const value = id => uiGet(`schedule-${id}`).value.trim(); const termCode = ScheduleModule.readTerm(); const match = termCode.match(/^(\d{4})-(\d{4})-([12])$/); if (!match || Number(match[2]) !== Number(match[1]) + 1) throw new Error('学期格式应为相邻学年,例如 2026-2027-1'); if (!ScheduleModule.state.studentUnlocked && !ScheduleModule.termOptions().includes(termCode)) throw new Error('请选择列表中的学期'); const week = value('week') === 'all' ? 'all' : Number(value('week')); if (week !== 'all' && (!Number.isInteger(week) || week < 1 || week > 60)) throw new Error('请选择有效周次'); const mode = ScheduleModule.state.mode; const studentId = value('student'); if (mode === 'student' && !/^\d{5,20}$/.test(studentId)) throw new Error(ScheduleModule.state.studentUnlocked ? '请输入有效学号' : '未获取到当前账号学号,请重新登录教务系统后重试'); return { mode, termCode, week, studentId, courseName: value('course'), courseCode: value('code'), teacherName: value('teacher'), classroomName: value('room'), scheduleStatus: value('arranged') }; }, listRequest(query, pageNumber) { const shared = { pageNumber, pageSize: ScheduleModule.state.pageSize, termCode: query.termCode }; if (query.mode === 'school') return { path: '/allSchedule/allScheduleList', params: { ...shared, publicElectiveCourses: '', courseCode: query.courseCode, courseName: query.courseName, teachingAcademy: '', teacherName: query.teacherName, campusNo: '', publicElectiveCoursesType: '', classroomType: '', sectionStart: '', sectionEnd: '', week: '', weekday: '' } }; return { path: '/allSchedule/classroomScheduleList', params: { ...shared, campusNo: '', buildingNo: '', classroomName: query.classroomName, scheduleStatus: query.scheduleStatus } }; }, detailRequest(query, selected) { if (!Number.isInteger(query.week) || query.week < 1 || query.week > 60) throw new Error('课表明细需指定具体周次'); const params = { termCode: query.termCode, week: query.week }; if (query.mode === 'student') return { path: '/allSchedule/studentScheduleDetailList', params: { ...params, studentId: query.studentId } }; if (query.mode === 'school' && selected?.teachClassCode) return { path: '/allSchedule/allScheduleDetailList', params: { ...params, teachClassId: selected.teachClassCode } }; if (query.mode === 'classroom' && selected?.classroomNo) return { path: '/allSchedule/classroomScheduleDetailList', params: { ...params, classroomCode: selected.classroomNo } }; throw new Error('缺少课表标识,请重新选择课程或教室'); }, isOwnStudent(query) { return query.mode === 'student' && sameText(query.studentId, state.userId); }, parseTeachingWeeks(row) { const mask = String(row.SKZC ?? '').trim(); if (/^[01]+$/.test(mask)) return Array.from(mask, (bit, index) => bit === '1' ? index + 1 : 0).filter(Boolean); // 旧记录未返回位串时允许使用明确的周次说明,无法识别则交给移动课表兜底。 const description = String(row.ZCMC ?? '').normalize('NFKC').trim(); const text = description.replace(/第|周|\s/g, '').replace(/[,,、;;]/g, ',').replace(/[~~—–至]/g, '-'); if (!text) throw new Error('课表记录缺少上课周次'); const weeks = []; for (const part of text.split(',')) { const match = part.match(/^(\d{1,2})(?:-(\d{1,2}))?(?:\(?([单双])\)?)?$/); if (!match) throw new Error('课表周次说明无法识别'); const start = Number(match[1]), end = Number(match[2] || match[1]); if (start < 1 || end < start || end > 60) throw new Error('课表周次超出有效范围'); for (let week = start; week <= end; week++) if (!match[3] || week % 2 === (match[3] === '单' ? 1 : 0)) weeks.push(week); } return [...new Set(weeks)].sort((a, b) => a - b); }, normalizeOwnTerm(response, termCode) { return ScheduleModule.normalizeDesktopTerm(response, 'cxxszhxqkb', termCode); }, desktopNode(response, name, full = true) { const node = response?.datas?.[name]; if (String(response?.code) !== '0' || !Array.isArray(node?.rows)) throw new Error(response?.msg || '课表接口返回异常'); if (node.extParams?.code != null && String(node.extParams.code) !== '1') throw new Error(node.extParams.msg || '课表查询失败'); // totalSize=-1 是该接口的正常返回值,不表示缺少记录。 if (full && Number(node.totalSize) > node.rows.length) throw new Error('学期课表返回不完整'); return node; }, normalizeDesktopTerm(response, name, termCode, classroom = null) { const node = ScheduleModule.desktopNode(response, name); const rows = node.rows.map(row => { if (row.XNXQDM && row.XNXQDM !== termCode) throw new Error('课表返回的学期不匹配'); if (classroom && row.JASDM && !sameText(row.JASDM, classroom.classroomNo)) throw new Error('课表返回的教室不匹配'); const weeks = ScheduleModule.parseTeachingWeeks(row); return { courseName: row.KCM, courseCode: row.KCH, courseNumber: row.KXH, teachClassCode: row.JXBID, classroomNo: row.JASDM || classroom?.classroomNo, classroomName: row.JASMC || classroom?.classroomName, teacherNames: row.SKJS, courseClass: row.SKBJ, weekday: hasScoreValue(row.SKXQ) ? (Number(row.SKXQ) === 0 ? 7 : Number(row.SKXQ)) : NaN, sectionStart: Number(row.KSJC), sectionEnd: Number(row.JSJC), sectionSystem: 'desktop', startTime: row.KSSJ, endTime: row.JSSJ, JQXX: row.JQXX, week: null, weeks, termCode, credit: row.XF, _source: classroom ? 'classroom-term' : 'own-term' }; }).filter(row => row.weeks.length); return ScheduleModule.aggregateWeeks(rows); }, async fetchOwnTerm(query, options = {}) { if (!ScheduleModule.isOwnStudent(query)) throw new Error('本人课表接口仅用于当前登录账号'); const s = ScheduleModule.state; const key = ScheduleModule.termSourceKey(query); return ScheduleModule.cachedRequest(s.termCache, s.termRequests, key, request => request(async () => { const response = await API.requestFormJSON({ url: '/jwapp/sys/wdkbby/modules/xskcb/cxxszhxqkb.do', data: { XNXQDM: query.termCode }, timeout: 15000 }); return ScheduleModule.normalizeOwnTerm(response, query.termCode); }), options); }, async fetchClassroomTerm(query, selected, options = {}) { if (!selected?.classroomNo) throw new Error('缺少教室代码'); const s = ScheduleModule.state; const key = ScheduleModule.termSourceKey(query, selected); return ScheduleModule.cachedRequest(s.termCache, s.termRequests, key, async request => { const all = []; // 官方页面同时读取常规课表和临时安排;均不传 SKZC,一次覆盖整学期。 for (const name of ['jaskcb', 'jasdkkc']) { const rows = await request(async () => { const response = await API.requestFormJSON({ url: `/jwapp/sys/kcbcxby/modules/jskcb/${name}.do`, data: { XNXQDM: query.termCode, JASDM: selected.classroomNo }, timeout: 15000 }); return ScheduleModule.normalizeDesktopTerm(response, name, query.termCode, selected); }); all.push(...rows); } return ScheduleModule.aggregateWeeks(all); }, options); }, async fetchWeek(query, selected, options = {}) { const s = ScheduleModule.state; const request = ScheduleModule.detailRequest(query, selected); const key = JSON.stringify([state.userId, request.path, request.params]); return ScheduleModule.cachedRequest(s.weekCache, s.weekRequests, key, run => run(async () => { const response = await MobileAPI.request(request.path, request.params); if (!Array.isArray(response.data)) throw new Error('课表明细格式异常'); return ScheduleModule.normalizeCourses(response.data, query.week); }), { ...options, ttl: 300000 }); }, async loadDetails(query, selected, { generation = ScheduleModule.state.generation, refresh = false, onProgress = () => {} } = {}) { const s = ScheduleModule.state; const key = ScheduleModule.termSourceKey(query, selected); const direct = ScheduleModule.isOwnStudent(query) || query.mode === 'classroom' && !!selected?.classroomNo; if (direct && (refresh || !s.failedTermSources.has(key))) { try { const courses = await (query.mode === 'classroom' ? ScheduleModule.fetchClassroomTerm(query, selected, { refresh, generation }) : ScheduleModule.fetchOwnTerm(query, { refresh, generation })); if (generation !== s.generation) return null; s.failedTermSources.delete(key); ScheduleModule.syncFullTermOption(query, selected); return query.week === 'all' ? courses : courses.filter(course => course.weeks.includes(query.week)).map(course => ({ ...course, week: query.week, weeks: [query.week] })); } catch (error) { if (generation !== s.generation) return null; if (error?.code === 'ABORTED') throw error; s.failedTermSources.add(key); s.termCache.delete(key); Diagnostics.warn('schedule', '整学期课表读取失败,改用移动课表', error); } } if (direct && s.failedTermSources.has(key)) { if (query.week === 'all' && !s.studentUnlocked) query.week = clampValue(s.lastWeek, 1, s.calendar?.maxWeekNum || 20); if (uiGet('schedule-week')) SelectControl.setValue('schedule-week', String(query.week)); ScheduleModule.syncFullTermOption(query, selected); s.sourceNotice = '整学期暂不可用,当前使用备用课表'; } if (query.week === 'all' && (!s.studentUnlocked || s.calendar?.maxWeekKnown === false)) throw new Error('当前课表不支持直接查询完整学期,请选择单周'); // 彩蛋开启后才允许逐周拼接;串行低频读取,每周失败最多重试三次。 const weeks = query.week === 'all' ? Array.from({ length: s.calendar.maxWeekNum }, (_, index) => index + 1) : [query.week]; const rows = []; for (const week of weeks) { if (generation !== s.generation) return null; try { rows.push(await ScheduleModule.fetchWeek({ ...query, week }, selected, { refresh, generation })); } catch (error) { if (generation !== s.generation) return null; throw new Error(`第 ${week} 周读取失败:${error.message || '请重试'}`); } if (generation === s.generation) onProgress(rows.length, weeks.length); } if (generation !== s.generation) return null; const all = rows.flat(); return query.week === 'all' ? ScheduleModule.aggregateWeeks(all) : all; }, async query(page = 1, selected = null, { refresh = false } = {}) { const s = ScheduleModule.state; const generation = ++s.generation; s.scrollSync?.destroy(); s.scrollSync = null; s.selected = selected; s.result = null; s.courses = []; s.sourceNotice = ''; ScheduleModule.setBusy(true); uiGet('schedule-result-actions').innerHTML = ''; uiGet('schedule-results').innerHTML = UI.state('loading', '正在查询课表', '请保持教务系统登录状态'); ScheduleModule.setStatus('正在查询...'); try { if (s.mode === 'student' && !uiGet('schedule-student').value.trim()) { const user = await API.getUserInfo(); if (generation !== s.generation) return; uiGet('schedule-student').value = user.userId || ''; } const query = ScheduleModule.readQuery(); ScheduleModule.syncFullTermOption(query, selected); if (query.week !== 'all') s.lastWeek = query.week; if (!s.calendar || s.calendar.xnxqdm !== query.termCode || s.calendar.provisional) { const calendar = await ScheduleModule.fetchCalendar(query.termCode, generation, s.calendar); if (generation !== s.generation) return; s.calendar = calendar; if (s.calendar.xnxqdm !== query.termCode) throw new Error('教务系统未返回所选学期的校历'); ScheduleModule.setWeeks(s.calendar.maxWeekNum, query.week); query.week = uiGet('schedule-week').value === 'all' ? 'all' : Number(uiGet('schedule-week').value); if (query.week !== 'all') s.lastWeek = query.week; } const detail = query.mode === 'student' || !!selected; if (detail) { const courses = await ScheduleModule.loadDetails(query, selected, { generation, refresh, onProgress: (done, total) => { if (query.week === 'all') ScheduleModule.setStatus(`${query.termCode} · 完整学期 · 已读取 ${done}/${total} 周`); } }); if (generation !== s.generation) return; s.result = { ...query, selected, detail }; s.courses = courses; ScheduleModule.renderWeek(); } else { const request = ScheduleModule.listRequest(query, page); const response = await ScheduleModule.request(async () => { const result = await MobileAPI.request(request.path, request.params); if (!Array.isArray(result.data?.rows)) throw new Error('课表列表格式异常'); return result; }, generation); if (generation !== s.generation) return; if (!Array.isArray(response.data?.rows)) throw new Error('课表列表格式异常'); s.result = { ...query, selected, detail }; s.rows = response.data.rows; s.total = Math.max(0, Number(response.data.total) || 0); s.page = page; ScheduleModule.renderList(); } } catch (error) { if (generation === s.generation) { s.result = null; ScheduleModule.showError(error); } } finally { if (generation === s.generation) ScheduleModule.setBusy(false); } }, showError(error) { ScheduleModule.setStatus(error?.message || '查询失败,请重试'); uiGet('schedule-results').innerHTML = UI.state('error', '课表查询失败', error?.message || '检查登录状态后点击查询重试'); Diagnostics.warn('schedule', '课表查询失败', error); }, renderList() { const s = ScheduleModule.state; const pages = Math.max(1, Math.ceil(s.total / s.pageSize)); const classroom = s.result.mode === 'classroom'; ScheduleModule.setStatus([s.result.termCode, ScheduleModule.modes[s.result.mode], `共 ${s.total} 条`, `第 ${s.page}/${pages} 页`]); const list = s.rows.map((row, index) => { const title = classroom ? row.classroomName : row.courseName; const meta = classroom ? [row.campusName, row.buildingName, row.classroomNo, String(row.scheduleStatus) === '1' ? '已排课' : '未排课'] : [row.courseCode, row.courseNumber ? `课序号 ${row.courseNumber}` : '', row.teachers, row.teachingAcademyName]; return ``; }).join(''); uiGet('schedule-results').innerHTML = `
    ${list || UI.state('empty', '暂无匹配结果', '调整学期或搜索条件后重试')}
    ${s.page} / ${pages}
    `; uiGet('schedule-results').querySelectorAll('[data-schedule-row]').forEach(button => button.addEventListener('click', () => ScheduleModule.query(s.page, s.rows[Number(button.dataset.scheduleRow)]))); uiGet('schedule-prev').addEventListener('click', () => ScheduleModule.query(s.page - 1)); uiGet('schedule-next').addEventListener('click', () => ScheduleModule.query(s.page + 1)); ButtonControl.enhanceAll(uiGet('schedule-results')); }, normalizeCourses(rows, week) { const seen = new Set(); return rows.filter(row => row && (!row.week || Number(row.week) === Number(week))).map(row => ({ ...row, sectionSystem: 'mobile', week: Number(week), weeks: [Number(week)], weekday: hasScoreValue(row.weekday) ? (Number(row.weekday) === 0 ? 7 : Number(row.weekday)) : NaN, sectionStart: Number(row.sectionStart), sectionEnd: Number(row.sectionEnd) })).filter(row => { const key = ScheduleModule.meetingKey(row); if (seen.has(key)) return false; seen.add(key); return true; }).sort((a, b) => a.weekday - b.weekday || a.sectionStart - b.sectionStart); }, meetingKey(row) { return JSON.stringify([row.courseCode, row.courseName, row.courseNumber, row.teachClassCode, row.weekday, row.sectionSystem, row.sectionStart, row.sectionEnd, row.classroomNo, row.classroomName, row.place, row.teacherNames, row.courseClass, row.courseAdjustType, row.startTime, row.endTime, row.JQXX]); }, aggregateWeeks(rows) { const groups = new Map(); rows.forEach(row => { const key = ScheduleModule.meetingKey(row); if (!groups.has(key)) groups.set(key, { ...row, week: null, weeks: [] }); groups.get(key).weeks.push(...(row.weeks || [row.week])); }); return [...groups.values()].map(row => ({ ...row, weeks: [...new Set(row.weeks)].sort((a, b) => a - b) })) .sort((a, b) => a.weekday - b.weekday || a.sectionStart - b.sectionStart); }, weekLabel(week) { return week === 'all' ? '完整学期' : `第 ${week} 周`; }, formatWeeks(weeks) { const sorted = [...new Set(weeks || [])].filter(week => Number.isInteger(week) && week > 0).sort((a, b) => a - b); const ranges = []; for (let i = 0; i < sorted.length; i++) { const start = sorted[i]; while (i + 1 < sorted.length && sorted[i + 1] === sorted[i] + 1) i++; ranges.push(start === sorted[i] ? String(start) : `${start}–${sorted[i]}`); } return ranges.length ? `第 ${ranges.join('、')} 周` : '周次待定'; }, sectionLabel(section) { if (!Number.isInteger(section) || section < 1 || section > 16) return ''; if (section <= 5) return `第${section}节`; if (section <= 7) return `中课${section - 5}`; if (section <= 12) return `第${section - 2}节`; if (section === 13) return '晚课'; return `第${section - 3}节`; }, formatSections(start, end, system = 'desktop') { if (system === 'mobile') { if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1 || end > 13 || end < start) return '节次待定'; return start === end ? `第${start}节` : `第 ${start}–${end} 节`; } const first = ScheduleModule.sectionLabel(start), last = ScheduleModule.sectionLabel(end); if (!first || !last || end < start) return '节次待定'; if (start === end) return first; const numericFirst = first.match(/^第(\d+)节$/), numericLast = last.match(/^第(\d+)节$/); // 跨中课/晚课的安排保留端点名称,不把教务排序序号当作显示节次。 const sameBlock = SCHEDULE_PERIODS.some(period => start >= period.start && end <= period.end); if (sameBlock && numericFirst && numericLast) return `第 ${numericFirst[1]}–${numericLast[1]} 节`; if (start === 6 && end === 7) return '中课 1–2'; return `${first}–${last}`; }, sectionSlots(course) { const { sectionStart: start, sectionEnd: end, sectionSystem = 'desktop' } = course; const max = sectionSystem === 'mobile' ? 13 : 16; if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1 || end > max || end < start) return []; return Array.from({ length: end - start + 1 }, (_, index) => { const section = start + index; return sectionSystem === 'mobile' ? (section <= 5 ? section : section <= 10 ? section + 2 : section + 3) : section; }); }, coursePeriods(course) { const slots = ScheduleModule.sectionSlots(course); return SCHEDULE_PERIODS.filter(period => slots.some(slot => slot >= period.start && slot <= period.end)); }, hasConflict(course, courses) { const weeks = course.weeks || [course.week]; const slots = ScheduleModule.sectionSlots(course); return courses.some(other => other !== course && other.weekday === course.weekday && ScheduleModule.sectionSlots(other).some(slot => slots.includes(slot)) && (other.weeks || [other.week]).some(week => weeks.includes(week))); }, courseColor(course) { const palette = ['#548ac6', '#479a85', '#ad7c40', '#9771be', '#bc718b', '#5b91a6', '#b97b62']; const identity = String(course.courseCode || course.courseName || ''); const hash = Array.from(identity).reduce((value, char) => (value * 31 + char.charCodeAt(0)) >>> 0, 0); return palette[hash % palette.length]; }, courseCard(course, courses, fullTerm = false) { const conflict = ScheduleModule.hasConflict(course, courses); const section = ScheduleModule.formatSections(course.sectionStart, course.sectionEnd, course.sectionSystem); const meta = [course.teacherNames, course.classroomName || course.place]; const jqxx = ScheduleModule.detailText(course.JQXX); return `
    ${escapeHTML(course.courseName || '未命名课程')}${meta.filter(Boolean).map(value => `${escapeHTML(value)}`).join('')}${fullTerm ? `${escapeHTML(ScheduleModule.formatWeeks(course.weeks))}` : ''}${conflict ? '时段重叠' : ''}${jqxx ? `` : ''}
    `; }, detailText(value) { if (value === undefined || value === null) return ''; if (typeof value === 'object') return Object.values(value).some(item => ScheduleModule.detailText(item)) ? JSON.stringify(value, null, 2) : ''; return String(value).trim(); }, courseDetailsHTML(course) { const labels = [course.courseCode, course.courseClass, course.teacherNames, course.classroomName || course.place, ScheduleModule.days[course.weekday - 1], ScheduleModule.formatSections(course.sectionStart, course.sectionEnd, course.sectionSystem), course.startTime && course.endTime ? `${course.startTime}–${course.endTime}` : '', ScheduleModule.formatWeeks(course.weeks || [course.week])].filter(Boolean); return `
    ${SummaryTags.render(labels)}
    ${escapeHTML(ScheduleModule.detailText(course.JQXX))}
    `; }, openCourseDetails(index) { const course = ScheduleModule.state.courses[index]; if (!course || !ScheduleModule.detailText(course.JQXX)) return; return AppDialog.details({ title: `${course.courseName || '课程'} · 详情`, html: ScheduleModule.courseDetailsHTML(course), width: 520 }); }, visiblePeriods(courses) { return SCHEDULE_PERIODS.filter(period => ![6, 13].includes(period.start) || courses.some(course => course.weekday >= 1 && course.weekday <= 7 && ScheduleModule.coursePeriods(course).includes(period))); }, gridHTML(courses, fullTerm) { const heads = `
    节次 / 星期
    ${ScheduleModule.days.map(day => `
    ${day}
    `).join('')}`; const rows = ScheduleModule.visiblePeriods(courses).map(period => `
    ${period.title}${period.label}
    ${ScheduleModule.days.map((day, index) => { const matches = courses.filter(course => course.weekday === index + 1 && ScheduleModule.coursePeriods(course).includes(period)); return `
    ${matches.map(course => ScheduleModule.courseCard(course, courses, fullTerm)).join('')}
    `; }).join('')}`); // 使用真实行语义,display:contents 让行参与同一网格布局。 return `
    ${heads}
    ${rows.map(row => `
    ${row}
    `).join('')}
    `; }, renderWeek() { const s = ScheduleModule.state; const query = s.result; const title = query.mode === 'student' ? (ScheduleModule.isOwnStudent(query) ? '' : `学号 ${query.studentId}`) : (query.selected.courseName || query.selected.classroomName); const fullTerm = query.week === 'all'; const weekLabel = ScheduleModule.weekLabel(query.week); ScheduleModule.setStatus([query.termCode, weekLabel, title, `${s.courses.length} 条安排`, s.sourceNotice].filter(Boolean)); const unscheduled = s.courses.filter(course => !Number.isInteger(course.weekday) || course.weekday < 1 || course.weekday > 7 || !ScheduleModule.coursePeriods(course).length); uiGet('schedule-result-actions').innerHTML = `${query.mode !== 'student' ? `` : ''}${!fullTerm ? `` : ''}`; uiGet('schedule-results').innerHTML = `${!s.courses.length ? UI.state('empty', fullTerm ? '本学期暂无课程安排' : '本周暂无课程安排', '可切换学期或周次查看') : ''}${ScheduleModule.gridHTML(s.courses, fullTerm)}${unscheduled.length ? `
    时间待定或未识别${unscheduled.map(course => ScheduleModule.courseCard(course, s.courses, fullTerm)).join('')}
    ` : ''}`; const shell = uiGet('schedule-results'); s.scrollSync?.destroy(); s.scrollSync = HorizontalScrollControl.enhance(shell.querySelector('.scroll-sync-top'), shell.querySelector('.schedule-week-scroll'), shell.querySelector('.schedule-week')); uiGet('schedule-back')?.addEventListener('click', () => ScheduleModule.query(s.page)); const changeWeek = offset => { SelectControl.setValue('schedule-week', String(query.week + offset)); ScheduleModule.query(s.page, s.selected); }; uiGet('schedule-week-prev')?.addEventListener('click', () => changeWeek(-1)); uiGet('schedule-week-next')?.addEventListener('click', () => changeWeek(1)); uiGet('schedule-export').addEventListener('click', () => { exportToBrowser(s.courses.map(course => ({ '学年学期': query.termCode, '周次': fullTerm ? ScheduleModule.formatWeeks(course.weeks) : query.week, '星期': ScheduleModule.days[course.weekday - 1] || '待定', '节次': ScheduleModule.formatSections(course.sectionStart, course.sectionEnd, course.sectionSystem), '课程名称': course.courseName, '课程代码': course.courseCode, '课序号': course.courseNumber, '教师': course.teacherNames, '教室': course.classroomName || course.place, '班级': course.courseClass, '详细信息': ScheduleModule.detailText(course.JQXX) })), `WHUT_${ScheduleModule.modes[query.mode]}_${query.termCode}_${weekLabel.replace(/ /g, '')}`, 'xlsx'); }); ButtonControl.enhanceAll(uiGet('schedule-result-actions')); } }; // ======================================================================== // 业务模块:个人学业监测 // 职责:汇总培养方案完成度、课程分类、预警信息与综合表现导出。 // ======================================================================== const MonitorModule = { init: async () => { if (state.monitor.isLoaded) { MonitorModule.render(); MonitorModule.setStatus(`数据已同步${state.monitor.overview.calculatedTime ? `:${state.monitor.overview.calculatedTime}` : ''}`); return; } if (state.monitor.isLoading) { state.monitor.renderAfterLoad = true; MonitorModule.setStatus('正在从服务器同步学业监测数据...'); return; } await MonitorModule.loadData(); }, setStatus: (msg) => { const el = uiGet('monitor-status'); if (el) el.textContent = String(msg || ''); }, setBusy: (busy, text) => { const refreshBtn = uiGet('btn-monitor-refresh'); const exportBtn = uiGet('btn-monitor-export'); if (refreshBtn) ButtonControl.setLoading(refreshBtn, busy, text || '处理中...'); if (exportBtn) exportBtn.disabled = busy; }, request: async (url, params = {}, opts = {}) => { const requestOptions = { url, data: compactParams(params), responseType: opts.blob ? 'blob' : undefined, timeout: opts.timeout || (opts.blob ? 30000 : 15000), retries: opts.retries || 0, dedupeKey: opts.dedupeKey }; if (opts.blob) { const res = await API.requestForm(requestOptions); if (!res.response) throw new Error('空文件流'); return res.response; } const json = await API.requestFormJSON(requestOptions); if (!json || json.code === '404') throw new Error('接口不存在'); if (json.code !== undefined && String(json.code) !== '0') throw new Error(json.msg || json.message || `接口返回异常:${json.code}`); return json; }, pick: (obj, keys) => { 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); }, 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 { 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; 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 = uiGet('monitor-content'); if (content && render) content.innerHTML = UI.state('loading', '正在同步学业监测', '培养方案数据可能需要数秒完成'); } 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 = { normalized, studyParams: candidateParams }; if (MonitorModule.hasMonitorContent(normalized)) break; } catch (e) { lastError = e; } } if (!selected) throw lastError || new Error('学业监测数据请求失败'); const { normalized, studyParams: selectedStudyParams } = selected; Object.assign(state.monitor, normalized, { isLoaded: true, studyParams: selectedStudyParams }); Diagnostics.info('monitor', '学业监测数据加载完成', { categories: state.monitor.categories.length, warnings: state.monitor.warnings.length, scoreFallbacks: state.monitor.scoreFallbacks.length, requiredCredit: state.monitor.overview.required, earnedCredit: state.monitor.overview.earned }); applyScoreFallbacks(); if (render) MonitorModule.render(); MonitorModule.setStatus(`数据已同步${state.monitor.overview.calculatedTime ? `:${state.monitor.overview.calculatedTime}` : ''}`); } catch (e) { Diagnostics.error('monitor', '学业监测数据加载失败', e); if (render) uiGet('monitor-content').innerHTML = UI.state('error', '学业监测加载异常', 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 ? `:${state.monitor.overview.calculatedTime}` : ''}`); } } }, courseState: (course) => { const raw = course?.raw || {}; const statusText = [raw.ZT_DISPLAY, raw.SFJG_DISPLAY, raw.statusName] .filter(value => value !== undefined && value !== null && value !== '') .join(' ').trim(); const hasScore = (hasScoreValue(course?.scoreText) && course.scoreText !== '-') || hasScoreValue(course?.score); if (/已选课|已选未修|正在修读|修读中/.test(statusText) && !hasScore) return 'selected'; 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 directRowsUnique = dedupeMonitorCourses(directRows, false, true); const rows = dedupeMonitorCourses(directRowsUnique.concat(children.flatMap(c => c.rows || [])), false, true); 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) + directRowsUnique.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 }; }, 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' || state === 'selected') 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 = dedupeMonitorCourses(group.rows || [], false, true); 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 = state === 'selected' ? '已选课' : (course.scoreText ?? course.score ?? '-'); const stateSymbol = iconSVG(pass ? 'check' : (state === 'selected' ? 'clock' : (state === 'pending' ? 'minus' : 'close'))); return `
    ${escapeHTML(courseLabel)}
    ${stateSymbol} ${escapeHTML(scoreDisplay)} ${escapeHTML(formatCreditValue(course.credit))} ${escapeHTML(replacedLabel)} ${escapeHTML(formatCreditValue(course.replacedCourseCredit))} `; }).join(''); }, renderMonitorGroups: (categories) => { if (!categories.length) return '
    暂无培养方案模块数据
    '; return `
    ${categories.map((group, index) => { const open = state.monitor.openGroups.has(group.id); const pct = group.required ? Math.min(100, Math.round((group.earned / group.required) * 100)) : 0; const bodyId = `monitor-group-body-${index}`; return `
    ${open ? `
    ${MonitorModule.renderCourseRows(group)}
    课程名[课程号]是否通过成绩学分替代课程名[课程号]学分
    ` : ''}
    `; }).join('')}
    `; }, render: () => { const container = uiGet('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 ? '达标/可继续' : '需关注'}
    总学分进度:${formatCreditValue(overview.earned)} / ${overview.required ? formatCreditValue(overview.required) : '-'} 学分${pct}%
    ${iconSVG('academic')}培养方案模块
    ${MonitorModule.renderMonitorGroups(categories || [])}
    ${iconSVG('warning')}预警中心
    ${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) { AppDialog.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 = AppResources.repeat(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 ? `:${String(data.ZXJD)}` : ''}`); if (data.ZXBZ === '1' || data.ZXBZ === 1 || data.finished === true || data.finished === 'true') { AppResources.clearRepeat(timer); resolve(); } else if (count > 80) { AppResources.clearRepeat(timer); reject(new Error('后台同步超时,请稍后重试')); } } catch (e) { AppResources.clearRepeat(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 }); FileDownload.saveBlob(blob, '学业综合表现.xls'); Toast.fire({ icon: 'success', title: '学业综合表现已导出' }); MonitorModule.setStatus('导出完成'); } catch (e) { AppDialog.fire('导出失败', e.message, 'error'); } finally { MonitorModule.setBusy(false); } }, patchHiddenScores: async () => { if (!state.rawData.some(needsScoreFallback)) return; return runHiddenScoreBackfill(); } }; // ======================================================================== // 服务模块:数据导出 // 职责:将当前业务数据导出为 XLSX、CSV 或图片。 // ======================================================================== 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 AppDialog.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;' }); FileDownload.saveBlob(blob, `${filename}.csv`); } } // ======================================================================== // 模块:使用帮助 // 职责:提供功能教程、使用提示与开发者信息。 // ======================================================================== const HelpModule = Object.freeze({ open() { return AppDialog.fire({ title: '使用帮助', eyebrow: 'WHUT教务系统小助手使用指南', width: 680, showConfirmButton: false, showCloseButton: true, unsafeHTML: `

    WHUT 教务系统小助手用于集中查看学业进度、课程成绩与 GPA,并辅助下载官方证明、完成评教和导出数据。请先登录教务系统,再使用需要读取数据的功能。

    ${iconSVG('rocket')}快速开始

    1. 登录武汉理工大学教务系统,点击页面悬浮球打开助手。
    2. 从顶部选择“学业监测”“成绩分析”“课表查询”“GPA 查询”“官方证明”或“一键评教”。
    3. 等待数据加载完成后再筛选、查看明细、提交操作或导出结果。
    4. 若数据为空,先确认原教务模块可以正常访问,再手动同步或刷新页面。

    ${iconSVG('academic')}功能说明

    学业监测汇总培养方案完成情况与预警信息,可手动同步并导出综合表现。
    成绩分析筛选默认收起,摘要旁可直接重置;学期与课程性质支持多选。点击必修或全科绩点可查看计入课程,支持列表及长图导出。
    课表查询学期下拉框包含成绩学期与当前学期,默认查询本人当前周;筛选默认收起,重置恢复当前学期和周次。连点顶部“课表查询”五次可解锁自定义学期和学号。支持详情弹窗、同步横向滚动及 Excel 导出。
    GPA 查询查看累计及学年 GPA;点击绩点可展开课程明细,也可导出表格或长图。
    官方证明读取教务系统提供的证明材料,并按页面提示生成或下载官方 PDF。
    一键评教先检查评分策略、评语与提交间隔;确认无误后再执行自动评教。
    主题与诊断右上角可切换主题或打开诊断日志;点击标题下的版本号会立即检查更新。

    使用提示:可拖动悬浮球和面板标题栏调整位置,拖动面板边缘调整大小。导出或批量操作期间请保持页面打开;遇到异常时,可从右上角齿轮入口导出诊断日志。

    ${iconSVG('info')}开发者信息

    ` }); } }); // ======================================================================== // 服务模块:脚本更新 // 职责:以远端元数据为版本真源,校验发布清单并呈现安全安装入口。 // ======================================================================== function currentScriptVersion() { return String(globalThis.GM_info?.script?.version || globalThis.GM?.info?.script?.version || SCRIPT_VERSION).trim(); } /** 严格比较 MAJOR.MINOR.PATCH;左侧较新、相同、较旧时依次返回 1、0、-1。 */ function compareVersion(left, right) { const a = String(left || '').trim(); const b = String(right || '').trim(); if (!UPDATE_VERSION_PATTERN.test(a) || !UPDATE_VERSION_PATTERN.test(b)) { throw new TypeError('版本必须使用 MAJOR.MINOR.PATCH 格式。'); } const av = a.split('.').map(BigInt); const bv = b.split('.').map(BigInt); for (let index = 0; index < 3; index++) { if (av[index] !== bv[index]) return av[index] > bv[index] ? 1 : -1; } return 0; } /** 更新产物只接受固定发布站点上的 HTTPS 地址和预期扩展名。 */ function trustedUpdateURL(value, label, suffix = '') { let url; try { url = new URL(String(value || '')); } catch { throw new TypeError(`${label}格式错误。`); } if (url.protocol !== 'https:' || url.origin !== SCRIPT_UPDATE_ORIGIN || url.username || url.password) { throw new TypeError(`${label}必须指向 ${SCRIPT_UPDATE_ORIGIN}。`); } if (suffix && !url.pathname.endsWith(suffix)) throw new TypeError(`${label}必须以 ${suffix} 结尾。`); return url.href; } function sameUpdateArtifact(left, right) { const normalize = value => { const url = new URL(value); url.search = ''; url.hash = ''; return url.href; }; return normalize(left) === normalize(right); } function requireExpectedArtifact(actual, expected, label) { if (!sameUpdateArtifact(actual, expected)) throw new Error(`${label}与预期发布地址不一致。`); return actual; } /** 复用统一请求层读取文本;不会执行远端内容,并校验最终重定向地址。 */ async function requestUpdateText(url, { label, suffix, dedupeKey }) { const requestedURL = trustedUpdateURL(url, label, suffix); const target = new URL(requestedURL); target.searchParams.set('_update_check', String(Date.now())); const response = await API.request({ method: 'GET', url: target.href, anonymous: true, headers: { Accept: 'text/javascript, application/json, text/plain;q=0.8', 'Cache-Control': 'no-cache', Pragma: 'no-cache' }, timeout: 12000, retries: 1, dedupeKey }); const finalURL = trustedUpdateURL(response?.finalUrl || response?.finalURL || target.href, `${label}最终地址`, suffix); requireExpectedArtifact(finalURL, requestedURL, `${label}最终地址`); const text = String(response?.responseText ?? (typeof response?.response === 'string' ? response.response : '')); if (!text.trim()) throw new Error(`${label}返回空内容。`); return text; } function metadataValues(header, name) { const pattern = new RegExp(`^// @${name}\\s+(.+?)\\s*$`, 'gm'); return [...header.matchAll(pattern)].map(match => match[1].trim()); } /** 只读取完整 UserScript 头中的唯一版本与发布地址。 */ function parseUpdateMetadata(source) { const text = String(source || ''); if (!text.startsWith('// ==UserScript== // @name WHUT教务系统小助手 // @namespace http://tampermonkey.net/ // @version 4.0.0.1 // @description 支持学生/全校/教室课表、学业监测、GPA 课程明细、官方证明、一键评教和自动更新。 // @author 毫厘 // @match *://jwxt.whut.edu.cn/jwapp/sys/* // @match *://jwxt.whut.edu.cn/jwmobile/* // @homepageURL https://scriptcat.org/zh-CN/script-show-page/6588 // @supportURL https://scriptcat.org/zh-CN/script-show-page/6588 // @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://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js // @grant GM_xmlhttpRequest // @grant GM_info // @grant GM_openInTab // @noframes // @connect jwxt.whut.edu.cn // @connect home.haoli.site // @run-at document-start // @license MIT // ==/UserScript=='); if (endIndex < 0) throw new Error('远端 UserScript 元数据头不完整。'); const header = text.slice(0, endIndex + '// ==/UserScript=='.length); const requireSingle = name => { const values = metadataValues(header, name); if (values.length !== 1) throw new Error(`远端元数据必须包含一个 @${name}。`); return values[0]; }; const version = requireSingle('version'); compareVersion(version, version); const updateURL = trustedUpdateURL(requireSingle('updateURL'), '元数据中的 @updateURL', '.meta.js'); const downloadURL = trustedUpdateURL(requireSingle('downloadURL'), '元数据中的 @downloadURL', '.user.js'); return { version, updateURL, downloadURL }; } /** latest.json 只承担结构化更新说明,版本判定始终以 meta.js 为准。 */ function parseUpdateManifest(source, expectedVersion) { let manifest; try { manifest = JSON.parse(String(source || '')); } catch { throw new Error('更新说明不是有效 JSON。'); } if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) throw new Error('更新说明结构无效。'); if (manifest.schemaVersion !== 1 || String(manifest.scriptId || '') !== SCRIPT_ID) { throw new Error('更新说明与当前脚本不匹配。'); } const version = String(manifest.version || '').trim(); compareVersion(version, version); if (version !== expectedVersion) throw new Error('更新说明版本与元数据不一致。'); const expectedURLs = [ ['updateUrl', SCRIPT_UPDATE.metaURL, '.meta.js'], ['downloadUrl', SCRIPT_UPDATE.downloadURL, '.user.js'], ['latestUrl', SCRIPT_UPDATE.infoURL, '.latest.json'] ]; for (const [field, expected, suffix] of expectedURLs) { const actual = trustedUpdateURL(manifest[field], `更新说明 ${field}`, suffix); requireExpectedArtifact(actual, expected, `更新说明 ${field}`); } if (!Array.isArray(manifest.notes)) throw new Error('更新说明缺少 notes 数组。'); return manifest.notes.map(note => String(note).trim()).filter(Boolean).slice(0, 3); } function openUpdatePage(url) { const target = trustedUpdateURL(url || SCRIPT_UPDATE.downloadURL, '安装地址', '.user.js'); requireExpectedArtifact(target, SCRIPT_UPDATE.downloadURL, '安装地址'); if (typeof GM_openInTab === 'function') GM_openInTab(target, { active: true, insert: true, setParent: true }); else window.open(target, '_blank', 'noopener,noreferrer'); } const UpdateModule = { getContainer: () => uiGet('update-container') || uiGet('update-container-modal'), updateHeaderButton: () => { const btn = uiGet('h-update'); if (!btn) return; btn.classList.toggle('has-update', !!state.update.hasNew); btn.classList.toggle('is-checking', !!state.update.isChecking); const label = btn.querySelector('.header-update-label'); if (label) label.textContent = state.update.isChecking ? '检查中' : (state.update.hasNew ? '有新版' : '检查更新'); }, autoCheck: () => { const interval = 6 * 60 * 60 * 1000; const last = parseInt(safeStorage.get('whut_update_last_check', '0'), 10); if (Date.now() - last < interval) return; UpdateModule.check(false); }, showNotice: (message, type = 'info') => { Toast.fire({ icon: type, title: message, timer: 3200 }); }, handleClick: (e) => { const checkBtn = e.target.closest('#btn-update-check'); const openBtn = e.target.closest('#btn-update-open'); const logBtn = e.target.closest('#btn-update-log'); if (checkBtn) UpdateModule.check(true); if (openBtn) { try { openUpdatePage(openBtn.dataset.url); } catch (error) { Diagnostics.warn('update', '安装地址校验失败', error); UpdateModule.showNotice(error.message || '安装地址无效', 'error'); } } if (logBtn) window.open(SCRIPT_UPDATE.projectURL, '_blank', 'noopener,noreferrer'); }, openPanel: (checkNow = false) => { AppDialog.fire({ title: '脚本更新', unsafeHTML: '
    ', width: 620, showConfirmButton: false, showCloseButton: true, didOpen: (modal) => { const container = modal.querySelector('#update-container-modal'); if (container) container.addEventListener('click', UpdateModule.handleClick); UpdateModule.render(); if (checkNow) UpdateModule.check(true); else if (!state.update.isLoaded && !state.update.isChecking) UpdateModule.check(true); } }); }, check: async (manual = false) => { if (state.update.isChecking) return; state.update.isChecking = true; state.update.error = ''; UpdateModule.render(); try { const script = globalThis.GM_info?.script || globalThis.GM?.info?.script || {}; const currentVersion = currentScriptVersion(); compareVersion(currentVersion, currentVersion); const metadataURL = trustedUpdateURL(script.updateURL || SCRIPT_UPDATE.metaURL, '元数据地址', '.meta.js'); requireExpectedArtifact(metadataURL, SCRIPT_UPDATE.metaURL, '元数据地址'); const metadataText = await requestUpdateText(metadataURL, { label: '版本元数据', suffix: '.meta.js', dedupeKey: 'script-update-meta' }); const metadata = parseUpdateMetadata(metadataText); requireExpectedArtifact(metadata.updateURL, SCRIPT_UPDATE.metaURL, '元数据中的 @updateURL'); requireExpectedArtifact(metadata.downloadURL, SCRIPT_UPDATE.downloadURL, '元数据中的 @downloadURL'); let notes = []; try { const latestText = await requestUpdateText(SCRIPT_UPDATE.infoURL, { label: '更新说明', suffix: '.latest.json', dedupeKey: 'script-update-latest' }); notes = parseUpdateManifest(latestText, metadata.version); } catch (error) { if (error?.code === 'ABORTED') throw error; Diagnostics.warn('update', '更新说明读取失败,继续使用元数据版本', error); } const info = { version: metadata.version, notes, downloadURL: metadata.downloadURL }; const hasNew = compareVersion(metadata.version, currentVersion) > 0; state.update.info = info; state.update.isLoaded = true; state.update.hasNew = hasNew; UpdateModule.render(); UpdateModule.updateHeaderButton(); safeStorage.set('whut_update_last_check', Date.now()); if (!manual && hasNew) { const notifyKey = `whut_update_notified_${info.version}`; if (safeStorage.get(notifyKey, '0') !== '1') { UpdateModule.showNotice(`发现脚本新版本 ${info.version}`, 'info'); safeStorage.set(notifyKey, '1'); } } } catch (e) { Diagnostics.warn('update', '脚本更新检查失败', e); state.update.error = e.message || '更新检查失败'; state.update.info = null; state.update.isLoaded = false; state.update.hasNew = false; UpdateModule.updateHeaderButton(); UpdateModule.render(); } finally { state.update.isChecking = false; UpdateModule.render(); } }, render: () => { const container = UpdateModule.getContainer(); if (!container) return; const current = currentScriptVersion(); const info = state.update.info || {}; const latest = info.version || '-'; const hasInfo = !!info.version; const versionOrder = hasInfo ? compareVersion(latest, current) : 0; const hasNew = !state.update.error && hasInfo && versionOrder > 0; const isAhead = !state.update.error && hasInfo && versionOrder < 0; state.update.hasNew = hasNew; UpdateModule.updateHeaderButton(); const statusText = state.update.isChecking ? '正在检查更新...' : (state.update.error ? `检查失败:${state.update.error}` : (hasInfo ? (hasNew ? `发现新版本 ${latest}` : (isAhead ? `当前版本 ${current} 高于发布版本 ${latest}` : '当前已是最新版本')) : '尚未检查')); const statusClass = state.update.error ? 'is-error' : (hasNew ? 'is-new' : 'is-current'); const notes = Array.isArray(info.notes) ? info.notes : []; const downloadURL = info.downloadURL || SCRIPT_UPDATE.downloadURL; container.innerHTML = `

    ${iconSVG('refresh')}脚本更新

    通过 meta.js 判断最新版本,并从 latest.json 读取更新说明;安装地址经校验后交由用户脚本管理器确认。

    当前版本${escapeHTML(current)}
    最新版本${escapeHTML(latest)}
    ${escapeHTML(statusText)}
    ${notes.length ? `
    更新内容
      ${notes.map(n => `
    • ${escapeHTML(n)}
    • `).join('')}
    ` : ''}
    `; } }; // ======================================================================== // 业务模块:官方证明 // 职责:加载可打印材料,并安全生成和下载教务系统 PDF。 // ======================================================================== const PrintModule = { init: async () => { if (state.isPrintLoading || state.isPrintLoaded) return; state.isPrintLoading = true; const container = uiGet('print-container'); container.innerHTML = UI.state('loading', '正在获取官方证明', '可下载材料将按照类别分组'); try { await API.getUserInfo(); const res = await OfficialDocumentService.list(); if (res.success && res.datas) { PrintModule.render(res.datas, container); state.isPrintLoaded = true; } else container.innerHTML = UI.state('warning', '官方证明列表暂未返回', '当前账号可能尚未配置可打印材料'); } catch(e) { container.innerHTML = UI.state('error', '官方证明加载异常', e.message || '稍后重试'); } finally { state.isPrintLoading = false; } }, render: (categories, container) => { if (!Array.isArray(categories) || !categories.length) { container.innerHTML = UI.state('empty', '暂无可打印材料', '后续开放的证明会自动显示在这里'); return; } let html = ''; categories.forEach(group => { html += `

    ${iconSVG('folder')}${escapeHTML(group.CDMC)}

    `; }); container.innerHTML = html; }, downloadPDF: async (wid, cdmc) => { try { AppDialog.fire({ title: '正在生成 PDF', text: '后台渲染中,请稍候...', allowOutsideClick: false, didOpen: () => AppDialog.showLoading() }); const blob = await OfficialDocumentService.fetchPDF(wid); FileDownload.saveBlob(blob, `${state.userName}-${state.userId}-${cdmc}.pdf`); AppDialog.close(); Toast.fire({ icon: 'success', title: 'PDF 下载成功' }); } catch (e) { Diagnostics.error('print', '官方证明下载失败', { report: cdmc, error: e }); AppDialog.close(); AppDialog.fire('错误', e.message, 'error'); } } }; // ======================================================================== // 模块:应用注册与生命周期 // 职责:集中注册业务模块、按需加载视图,并在页面退出时释放请求与 UI 资源。 // ======================================================================== const VIEW_MODULES = Object.freeze({ schedule: ScheduleModule, gpa: GPAModule, evaluation: EvalModule, monitor: MonitorModule, print: PrintModule }); const VIEW_LOADERS = Object.freeze({ 'view-schedule': () => VIEW_MODULES.schedule.init(), 'view-gpa': () => VIEW_MODULES.gpa.init(), 'view-eval': () => VIEW_MODULES.evaluation.init(), 'view-monitor': () => VIEW_MODULES.monitor.init(), 'view-print': () => { if (!state.isPrintLoaded) VIEW_MODULES.print.init(); } }); const AppLifecycle = Object.freeze({ start: () => initUI(), destroy(reason = 'pagehide') { API.abortAll(reason); AppDialog.close(); SelectControl.destroyAll(); GradeResponseInterceptor.destroy(); AppResources.destroy(); AppEvents.destroy(); UIKit.destroy(); } }); const startApp = () => AppLifecycle.start(); if (document.readyState === 'loading') AppEvents.listen(document, 'DOMContentLoaded', startApp, { once: true }); else startApp(); AppEvents.listen(window, 'pagehide', event => { if (!event.persisted) AppLifecycle.destroy('pagehide'); }); // ==================== 窗口自由缩放补丁 ==================== // 修复版 v1.0.1:标准空格、独立缩放触点、最大化时隐藏触点。 { const SIZE_KEY = 'whut_panel_custom_size_v1'; const storedSize = safeStorage.getJSON(SIZE_KEY, {}); let preferred = { width: Number(storedSize.width) > 0 ? Number(storedSize.width) : PANEL_LAYOUT.width, height: Number(storedSize.height) > 0 ? Number(storedSize.height) : null }; let maximized = false; let restorePosition = null; let dragSession = null; let frame = 0; const bounds = () => { const v = window.visualViewport; const margin = PANEL_LAYOUT.viewportMargin; const width = Math.max(1, (v?.width || window.innerWidth) - margin * 2); const height = Math.max(1, (v?.height || window.innerHeight) - margin * 2); const left = (v?.offsetLeft || 0) + margin; const top = (v?.offsetTop || 0) + margin; return { left, top, width, height, right: left + width, bottom: top + height, minWidth: Math.min(360, width), minHeight: Math.min(360, height) }; }; const rememberSize = () => safeStorage.setJSON(SIZE_KEY, preferred); const px = value => /^-?\d+(?:\.\d+)?px$/.test(value || '') ? Number.parseFloat(value) : NaN; function applyRect(panel, r) { // 行内 important 覆盖原脚本移动端媒体查询中的固定尺寸。 const values = { width: r.width, height: r.height, left: r.left, top: r.top }; Object.entries(values).forEach(([key, value]) => panel.style.setProperty(key, `${value}px`, 'important')); ['min-width', 'min-height'].forEach(key => panel.style.setProperty(key, '0px', 'important')); ['max-width', 'max-height'].forEach(key => panel.style.setProperty(key, 'none', 'important')); panel.style.right = panel.style.bottom = 'auto'; panel.dataset.windowCompact = String(r.width < 480); } function resizeRect(start, dx, dy, direction, limit) { let { left, top, width, height } = start; const right = left + width; const bottom = top + height; if (direction.includes('e')) width = clampValue(width + dx, limit.minWidth, limit.right - left); if (direction.includes('s')) height = clampValue(height + dy, limit.minHeight, limit.bottom - top); if (direction.includes('w')) { left = clampValue(left + dx, limit.left, right - limit.minWidth); width = right - left; } if (direction.includes('n')) { top = clampValue(top + dy, limit.top, bottom - limit.minHeight); height = bottom - top; } return { left, top, width, height }; } // 原函数的所有调用点(打开、收起、移动、屏幕旋转)统一使用自定义尺寸。 clampPanelToViewport = function(panel) { if (!panel) return; const b = bounds(); const width = maximized ? b.width : clampValue(preferred.width, b.minWidth, b.width); const height = maximized ? b.height : clampValue(preferred.height || window.innerHeight * PANEL_LAYOUT.heightRatio, b.minHeight, b.height); let left = px(panel.style.left); let top = px(panel.style.top); if (!Number.isFinite(left)) left = b.left + (b.width - width) / 2; if (!Number.isFinite(top)) top = b.top + (b.height - height) / 2; applyRect(panel, { width, height, left: maximized ? b.left : clampValue(left, b.left, b.right - width), top: maximized ? b.top : clampValue(top, b.top, b.bottom - height) }); }; function installWindowResize() { const panel = uiGet('whut-helper-panel'); if (!panel || panel.dataset.resizableWindow === '1') return; panel.dataset.resizableWindow = '1'; const controller = new AbortController(); const listen = (target, type, handler, options = {}) => target?.addEventListener(type, handler, { ...options, signal: controller.signal }); const style = document.createElement('style'); style.textContent = ` #whut-helper-panel[data-resizable-window] { resize:none; } #whut-helper-panel .whut-resize-handle { position:absolute; z-index:15; padding:0; margin:0; min-width:0; min-height:0; border:0; border-radius:0; background:transparent; color:var(--uikit-text-muted); touch-action:none; user-select:none; -webkit-user-select:none; } #whut-helper-panel .whut-resize-handle:focus-visible { outline:2px solid var(--uikit-brand); outline-offset:-3px; } #whut-helper-panel .whut-resize-handle[data-edge=n] { top:0; left:22px; right:22px; height:8px; cursor:ns-resize; } #whut-helper-panel .whut-resize-handle[data-edge=s] { bottom:0; left:22px; right:36px; height:8px; cursor:ns-resize; } #whut-helper-panel .whut-resize-handle[data-edge=w] { left:0; top:22px; bottom:22px; width:8px; cursor:ew-resize; } #whut-helper-panel .whut-resize-handle[data-edge=e] { right:0; top:22px; bottom:36px; width:8px; cursor:ew-resize; } #whut-helper-panel .whut-resize-handle[data-edge=nw] { top:0; left:0; width:18px; height:18px; cursor:nwse-resize; } #whut-helper-panel .whut-resize-handle[data-edge=ne] { top:0; right:0; width:18px; height:18px; cursor:nesw-resize; } #whut-helper-panel .whut-resize-handle[data-edge=sw] { bottom:0; left:0; width:22px; height:22px; cursor:nesw-resize; } #whut-helper-panel .whut-resize-handle[data-edge=se] { bottom:0; right:0; width:36px; height:36px; cursor:nwse-resize; } #whut-helper-panel .whut-resize-handle[data-edge=se]::after { content:''; position:absolute; right:6px; bottom:6px; width:16px; height:16px; background:repeating-linear-gradient(135deg,transparent 0 4px,currentColor 4px 6px); clip-path:polygon(100% 0,100% 100%,0 100%); opacity:.65; } #whut-helper-panel.is-resizing, #whut-helper-panel.is-resizing * { user-select:none !important; -webkit-user-select:none !important; transition:none !important; animation:none !important; } #whut-helper-panel.is-window-maximized .whut-resize-handle { display:none; } #whut-helper-panel.is-window-maximized .helper-header { cursor:default; } #whut-helper-panel .header-top { flex-wrap:wrap; } #whut-helper-panel .header-actions { margin-left:auto; } #whut-helper-panel .header-help { width:32px; padding:0; } #whut-helper-panel[data-window-compact=true] .header-name { font-size:14px; } #whut-helper-panel[data-window-compact=true] .helper-footer { grid-template-columns:repeat(2,minmax(0,1fr)); } #whut-helper-panel[data-window-compact=true] .monitor-toolbar-row { flex-wrap:wrap; } #whut-helper-panel[data-window-compact=true] .monitor-group-header { grid-template-columns:18px minmax(0,1fr) auto; } #whut-helper-panel[data-window-compact=true] .monitor-group-credit { grid-column:2/4; grid-row:2; } #whut-helper-panel[data-window-compact=true] .gpa-flat-grid { grid-template-columns:1fr; } #whut-helper-panel[data-window-compact=true] .gpa-flat-metric + .gpa-flat-metric { border-left:0; border-top:1px solid var(--uikit-border); padding:12px 2px 2px; } #whut-helper-panel .helper-toolbar { max-height:36%; overflow:auto; } @media (pointer:coarse) { #whut-helper-panel .whut-resize-handle[data-edge=n], #whut-helper-panel .whut-resize-handle[data-edge=s] { height:12px; } #whut-helper-panel .whut-resize-handle[data-edge=e], #whut-helper-panel .whut-resize-handle[data-edge=w] { width:12px; } } `; UIKitRuntime.shadow.appendChild(style); function finish(cancel = false) { if (!dragSession) return; const d = dragSession; dragSession = null; cancelAnimationFrame(frame); frame = 0; applyRect(panel, cancel ? d.start : d.next); panel.classList.remove('is-resizing'); try { d.handle.releasePointerCapture(d.pointerId); } catch (_) { /* 已取消的指针 */ } if (!cancel) { preferred = { width: d.next.width, height: d.next.height }; rememberSize(); savePanelState(panel); } } function toggleMaximize() { finish(); SelectControl.closeAll(); if (!maximized) { restorePosition = { left: panel.style.left, top: panel.style.top }; } else if (restorePosition) { panel.style.left = restorePosition.left; panel.style.top = restorePosition.top; } maximized = !maximized; panel.classList.toggle('is-window-maximized', maximized); handles.forEach(handle => { handle.hidden = maximized; handle.disabled = maximized; }); clampPanelToViewport(panel); savePanelState(panel); } const header = panel.querySelector('#panel-drag-handle'); const isHeaderControl = event => event.target.closest('button,input,select,textarea,a,.helper-tabs'); listen(header, 'dblclick', event => { if (!isHeaderControl(event)) toggleMaximize(); }); listen(header, 'pointerdown', event => { // 避免点击按钮里的 SVG 时触发旧版标题栏拖拽;最大化时固定位置。 if (maximized || isHeaderControl(event)) event.stopPropagation(); }, { capture: true }); const names = { n:'上边', s:'下边', e:'右边', w:'左边', nw:'左上角', ne:'右上角', sw:'左下角', se:'右下角' }; const handles = []; Object.entries(names).forEach(([direction, name]) => { const handle = document.createElement('button'); handle.type = 'button'; handle.className = 'whut-resize-handle'; handle.dataset.edge = direction; // 定位与触摸行为直接作用于触点,避免附加样式未生效时挤占内容。 const edges = { n: { top:'0', left:'22px', right:'22px', height:'12px', cursor:'ns-resize' }, s: { bottom:'0', left:'22px', right:'36px', height:'12px', cursor:'ns-resize' }, w: { left:'0', top:'22px', bottom:'22px', width:'12px', cursor:'ew-resize' }, e: { right:'0', top:'22px', bottom:'36px', width:'12px', cursor:'ew-resize' }, nw: { top:'0', left:'0', width:'22px', height:'22px', cursor:'nwse-resize' }, ne: { top:'0', right:'0', width:'22px', height:'22px', cursor:'nesw-resize' }, sw: { bottom:'0', left:'0', width:'22px', height:'22px', cursor:'nesw-resize' }, se: { bottom:'0', right:'0', width:'36px', height:'36px', cursor:'nwse-resize' } }; Object.entries({ position:'absolute', inset:'auto', 'z-index':'15', width:'auto', height:'auto', 'box-sizing':'border-box', 'min-width':'0', 'min-height':'0', 'max-width':'none', 'max-height':'none', margin:'0', padding:'0', border:'0', 'border-radius':'0', background:'transparent', 'box-shadow':'none', appearance:'none', '-webkit-appearance':'none', 'touch-action':'none', 'user-select':'none', '-webkit-user-select':'none', 'pointer-events':'auto', ...edges[direction] }).forEach(([key, value]) => handle.style.setProperty(key, value, 'important')); handle.title = `拖动${name}调整窗口大小`; handle.setAttribute('aria-label', `调整${name}大小(方向键微调)`); panel.appendChild(handle); handles.push(handle); listen(handle, 'pointerdown', event => { if (maximized || dragSession || event.isPrimary === false || event.button !== 0) return; event.preventDefault(); event.stopPropagation(); SelectControl.closeAll(); clampPanelToViewport(panel); panel.classList.add('is-resizing'); const r = panel.getBoundingClientRect(); const start = { left:r.left, top:r.top, width:r.width, height:r.height }; dragSession = { handle, pointerId:event.pointerId, x:event.clientX, y:event.clientY, direction, start, next:start, limit:bounds() }; try { handle.setPointerCapture(event.pointerId); } catch (_) { /* document 监听仍可结束拖动 */ } }); listen(handle, 'lostpointercapture', () => finish(true)); listen(handle, 'keydown', event => { if (maximized || !['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(event.key)) return; event.preventDefault(); event.stopPropagation(); clampPanelToViewport(panel); const step = event.shiftKey ? 40 : 10; const r = panel.getBoundingClientRect(); const next = resizeRect(r, event.key === 'ArrowLeft' ? -step : event.key === 'ArrowRight' ? step : 0, event.key === 'ArrowUp' ? -step : event.key === 'ArrowDown' ? step : 0, direction, bounds()); applyRect(panel, next); preferred = { width:next.width, height:next.height }; rememberSize(); savePanelState(panel); }); }); listen(document, 'pointermove', event => { const d = dragSession; if (!d || d.pointerId !== event.pointerId) return; event.preventDefault(); d.next = resizeRect(d.start, event.clientX - d.x, event.clientY - d.y, d.direction, d.limit); if (!frame) frame = requestAnimationFrame(() => { frame = 0; if (dragSession) applyRect(panel, dragSession.next); }); }, { capture: true, passive: false }); listen(document, 'pointerup', event => { const d = dragSession; if (!d || d.pointerId !== event.pointerId) return; d.next = resizeRect(d.start, event.clientX - d.x, event.clientY - d.y, d.direction, d.limit); finish(); }, { capture: true }); listen(document, 'pointercancel', event => { if (dragSession?.pointerId === event.pointerId) finish(true); }, { capture: true }); listen(document, 'keydown', event => { if (event.key !== 'Escape' || !dragSession) return; event.preventDefault(); event.stopPropagation(); finish(true); }, { capture: true }); listen(window, 'blur', () => finish(true)); const fit = () => { finish(true); clampPanelToViewport(panel); }; listen(window, 'resize', fit); listen(window.visualViewport, 'resize', fit); listen(window.visualViewport, 'scroll', fit); clampPanelToViewport(panel); AppResources.add(() => { finish(true); controller.abort(); style.remove(); handles.forEach(handle => handle.remove()); }); } installWindowResize(); if (document.readyState === 'loading') { AppEvents.listen(document, 'DOMContentLoaded', installWindowResize, { once:true }); } } // ==================== 窗口缩放补丁结束 ==================== })();