// ==UserScript== // @name WHUT教务系统小助手 // @namespace http://tampermonkey.net/ // @version 3.8.4 // @description Shadow DOM 隔离的现代教务助手:支持成绩回填与分析、学业监测、GPA、官方证明、一键评教、主题切换和自动更新。 // @author 毫厘 // @match *://jwxt.whut.edu.cn/jwapp/sys/* // @match *://jwxt.whut.edu.cn/jwmobile/index#/kwApp/cjcx/* // @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 // @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.1'; 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 layouts = []; 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] })) }; layouts.push(layout); 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, layouts, 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'; const TRANSCRIPT_PARSER = globalThis.WHUTTranscriptParser || null; // ================= 🎨 模块化设计系统 ================= const THEME = Object.freeze({ primary: "#1e88e5", primaryHover: "#1565c0", accent: "#87b0f9", deepBlue: "#1565c0", bg: "rgb(239, 241, 245)", panelBg: "rgb(250, 251, 253)", border: "rgb(211, 215, 225)", textMain: "rgb(76, 79, 105)", textSub: "rgb(108, 111, 133)", success: "#10b981", warning: "#f59e0b", danger: "#ef4444", info: "#334155" }); const PANEL_LAYOUT = Object.freeze({ width: 680, heightRatio: 0.82, viewportMargin: 12 }); const STYLE_MODULES = Object.freeze({ tokensAndReset: ` :host { all: initial; --uikit-brand: #1e88e5; --uikit-brand-rgb: 30 136 229; --uikit-brand-2: #87b0f9; --uikit-brand-soft: rgb(30 136 229 / 10%); --uikit-brand-text: #1565c0; --uikit-success: #10b981; --uikit-warning: #f59e0b; --uikit-danger: #ef4444; --uikit-info: rgb(76 79 105); --uikit-bg: rgb(239 241 245); --uikit-bg-muted: rgb(230 233 239); --uikit-bg-elevated: rgb(250 251 253); --uikit-border: rgb(211 215 225); --uikit-border-strong: rgb(188 194 207); --uikit-text: rgb(76 79 105); --uikit-text-muted: rgb(108 111 133); --uikit-text-faint: rgb(140 143 161); --uikit-overlay: rgb(30 30 46 / 42%); --uikit-radius-xs: 4px; --uikit-radius-sm: 6px; --uikit-radius-md: 10px; --uikit-radius-lg: 14px; --uikit-radius-pill: 999px; --uikit-shadow-sm: 0 2px 8px rgb(0 0 0 / 5%); --uikit-shadow-md: 0 10px 25px rgb(30 136 229 / 15%); --uikit-shadow-lg: 0 22px 58px rgb(30 30 46 / 18%); --uikit-duration-instant: 150ms; --uikit-duration-fast: 300ms; --uikit-duration-normal: 500ms; --uikit-duration-theme: 800ms; --uikit-ease-standard: cubic-bezier(.19, 1, .22, 1); --uikit-ease-emphasized: cubic-bezier(.34, 1.56, .64, 1.1); --uikit-z-panel: 10; --uikit-z-fab: 20; --uikit-z-progress: 30; --uikit-z-tooltip: 80; --uikit-z-menu: 90; --uikit-z-dialog: 100; --uikit-z-toast: 110; --whut-primary: var(--uikit-brand); --whut-primary-hover: #1565c0; --whut-primary-soft: var(--uikit-brand-soft); --whut-primary-softer: rgb(30 136 229 / 5%); --whut-accent: var(--uikit-brand-2); --whut-deep-blue: var(--uikit-brand-text); --whut-bg: var(--uikit-bg-muted); --whut-surface: var(--uikit-bg); --whut-surface-raised: var(--uikit-bg-elevated); --whut-surface-muted: var(--uikit-bg-muted); --whut-border: var(--uikit-border); --whut-border-strong: var(--uikit-border-strong); --whut-text: var(--uikit-text); --whut-text-secondary: var(--uikit-text-muted); --whut-text-muted: var(--uikit-text-faint); --whut-success: var(--uikit-success); --whut-success-soft: rgb(16 185 129 / 10%); --whut-warning: var(--uikit-warning); --whut-warning-soft: rgb(245 158 11 / 10%); --whut-danger: var(--uikit-danger); --whut-danger-soft: rgb(239 68 68 / 9%); --whut-info: var(--uikit-info); --whut-shadow-xs: var(--uikit-shadow-sm); --whut-shadow-sm: var(--uikit-shadow-sm); --whut-shadow-md: var(--uikit-shadow-md); --whut-shadow-lg: var(--uikit-shadow-lg); --whut-radius-sm: var(--uikit-radius-sm); --whut-radius-md: var(--uikit-radius-md); --whut-radius-lg: var(--uikit-radius-lg); --whut-radius-xl: var(--uikit-radius-lg); --whut-ease: var(--uikit-ease-standard); --whut-fast: var(--uikit-duration-fast); --whut-normal: var(--uikit-duration-normal); font-family: Inter, "SF Pro Text", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; font-size: 14px; line-height: 1.5; color: var(--uikit-text); -webkit-text-size-adjust: 100%; } :host([data-theme='dark']) { --uikit-brand: #87b0f9; --uikit-brand-rgb: 135 176 249; --uikit-brand-2: #1e88e5; --uikit-brand-soft: rgb(135 176 249 / 14%); --uikit-brand-text: #87b0f9; --uikit-bg: rgb(30 30 46); --uikit-bg-muted: rgb(24 24 37); --uikit-bg-elevated: rgb(36 36 56); --uikit-border: rgb(49 50 68); --uikit-border-strong: rgb(65 69 89); --uikit-text: rgb(198 208 245); --uikit-text-muted: rgb(161 168 201); --uikit-text-faint: rgb(131 139 167); --uikit-overlay: rgb(0 0 0 / 58%); --uikit-shadow-sm: 0 1px 3px rgb(0 0 0 / 42%); --uikit-shadow-md: 0 10px 25px rgb(137 220 235 / 15%); --uikit-shadow-lg: 0 20px 50px rgb(0 0 0 / 72%); --whut-primary-hover: #74a5ec; --whut-primary-softer: rgb(135 176 249 / 9%); --whut-success-soft: rgb(16 185 129 / 15%); --whut-warning-soft: rgb(245 158 11 / 15%); --whut-danger-soft: rgb(239 68 68 / 14%); } *, *::before, *::after { box-sizing: border-box; } [hidden] { display: none !important; } button, input, select, textarea { font: inherit; } button { -webkit-tap-highlight-color: transparent; } .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, #whut-helper-panel *, #whut-toast-stack, #whut-toast-stack * { box-sizing: border-box; } #whut-helper-panel:focus { outline: none; } #whut-helper-panel button, #whut-helper-panel input, #whut-helper-panel select, #whut-helper-panel textarea, .uikit-dialog-backdrop button, .uikit-dialog-backdrop input, .uikit-dialog-backdrop select, .uikit-dialog-backdrop textarea { font: inherit; } #whut-helper-panel button, .uikit-dialog-backdrop button { -webkit-tap-highlight-color: transparent; } #whut-helper-panel ::selection { color: white; background: var(--whut-primary); } #whut-helper-panel .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 rgb(var(--uikit-brand-rgb) / 30%); 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; } *, *::before, *::after { scrollbar-width: thin; scrollbar-color: rgb(var(--uikit-brand-rgb) / 32%) transparent; } *::-webkit-scrollbar { width: 8px; height: 8px; } *::-webkit-scrollbar-track { background: transparent; } *::-webkit-scrollbar-thumb { background: rgb(var(--uikit-brand-rgb) / 28%); border: 2px solid transparent; border-radius: 999px; background-clip: padding-box; transition: background-color var(--uikit-duration-fast) var(--uikit-ease-standard); } *::-webkit-scrollbar-thumb:hover { background-color: rgb(var(--uikit-brand-rgb) / 56%); } *::-webkit-scrollbar-corner { background: transparent; } `, shell: ` #whut-fab { position: fixed; width: 60px; height: 60px; z-index: 100000; display: flex; align-items: center; justify-content: center; color: white; border: 1px solid rgba(255, 255, 255, .72); border-radius: 20px; cursor: grab; user-select: none; background: linear-gradient(145deg, #2196f3 0%, #1976d2 55%, #60a5fa 100%); box-shadow: 0 14px 34px rgba(30, 136, 229, .28), inset 0 1px 0 rgba(255, 255, 255, .34); transition: transform var(--whut-normal) var(--whut-ease), box-shadow var(--whut-normal) var(--whut-ease), border-radius var(--whut-normal) var(--whut-ease); isolation: isolate; touch-action: none; } #whut-fab::before { content: ""; position: absolute; inset: -5px; z-index: -1; border: 1px solid rgba(30, 136, 229, .22); border-radius: 24px; opacity: 0; transform: scale(.88); transition: opacity var(--whut-fast), transform var(--whut-normal) var(--whut-ease); } #whut-fab:hover { transform: translateY(-4px) scale(1.03); border-radius: 22px; box-shadow: 0 20px 42px rgba(30, 136, 229, .34); } #whut-fab:hover::before { opacity: 1; transform: scale(1); } #whut-fab:active { cursor: grabbing; transform: translateY(-1px) scale(.96); } .whut-fab-glyph { font-size: 27px; line-height: 1; filter: drop-shadow(0 2px 3px rgba(17, 55, 98, .18)); } .whut-fab-pulse { position: absolute; right: 7px; top: 7px; width: 8px; height: 8px; border-radius: 50%; background: #86efac; box-shadow: 0 0 0 3px rgba(255, 255, 255, .7); } #whut-helper-panel { display: none; position: fixed; width: ${PANEL_LAYOUT.width}px; min-width: 440px; max-width: calc(100vw - ${PANEL_LAYOUT.viewportMargin * 2}px); height: 88vh; min-height: 460px; max-height: calc(100vh - ${PANEL_LAYOUT.viewportMargin * 2}px); z-index: 99999; flex-direction: column; overflow: hidden; color: var(--whut-text); font-size: 13px; line-height: 1.5; background: radial-gradient(circle at 8% -10%, rgba(135, 176, 249, .22), transparent 34%), linear-gradient(180deg, rgba(255, 255, 255, .98), rgba(244, 247, 251, .97)); border: 1px solid rgba(198, 213, 229, .88); border-radius: var(--whut-radius-xl); box-shadow: var(--whut-shadow-lg), inset 0 1px 0 rgba(255, 255, 255, .9); backdrop-filter: blur(22px) saturate(1.15); -webkit-backdrop-filter: blur(22px) saturate(1.15); transform-origin: top right; } #whut-helper-panel.open { display: flex; animation: whutPanelIn 320ms var(--whut-ease) both; } #whut-helper-panel.is-dragging { opacity: .96; box-shadow: 0 30px 80px rgba(30, 64, 104, .22); } .helper-header { flex: 0 0 auto; padding: 17px 18px 0; cursor: grab; user-select: none; background: linear-gradient(180deg, rgba(255, 255, 255, .88), rgba(248, 250, 252, .76)); border-bottom: 1px solid rgba(217, 227, 239, .88); touch-action: none; } .helper-header:active { cursor: grabbing; } .header-top { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 14px; pointer-events: none; } .header-title { display: flex; align-items: center; min-width: 0; gap: 11px; color: var(--whut-text); } .header-logo { width: 38px; height: 38px; flex: 0 0 38px; display: inline-flex; align-items: center; justify-content: center; border-radius: 13px; font-size: 20px; background: linear-gradient(145deg, #eaf5ff, #dceeff); border: 1px solid #c9e2f8; box-shadow: inset 0 1px 0 white, var(--whut-shadow-xs); } .header-brand-copy { display: flex; flex-direction: column; min-width: 0; gap: 4px; } .header-name { overflow: hidden; color: var(--whut-text); font-size: 17px; font-weight: 850; letter-spacing: -.02em; text-overflow: ellipsis; white-space: nowrap; } .header-meta { display: flex; align-items: center; gap: 6px; } .header-version, .header-author { display: inline-flex; align-items: center; min-height: 21px; padding: 2px 8px; border-radius: 999px; color: var(--whut-primary); font-size: 10px; font-weight: 850; letter-spacing: .02em; background: var(--whut-primary-soft); border: 1px solid #cfe7fb; } .header-author { color: var(--whut-deep-blue); background: #eef3fb; border-color: #d7e1ef; } .header-actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; pointer-events: auto; touch-action: auto; } .header-action { min-width: 38px; height: 38px; padding: 0 11px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; color: var(--whut-text-secondary); font-size: 13px; line-height: 1; font-weight: 750; cursor: pointer; background: rgba(255, 255, 255, .86); border: 1px solid var(--whut-border); border-radius: 12px; box-shadow: var(--whut-shadow-xs); transition: color var(--whut-fast), background var(--whut-fast), border-color var(--whut-fast), transform var(--whut-normal) var(--whut-ease), box-shadow var(--whut-normal) var(--whut-ease); } .header-action:hover { color: var(--whut-primary); background: white; border-color: #aad4f5; transform: translateY(-2px); box-shadow: var(--whut-shadow-sm); } .header-action:active { transform: translateY(0) scale(.96); } .header-update { min-width: 92px; } .header-update.has-update { color: #15803d; background: var(--whut-success-soft); border-color: #a7e8bd; } .header-update.is-checking > span:first-child { display: inline-block; animation: whutSpin .8s linear infinite; } .header-close { width: 38px; padding: 0; font-size: 18px; font-weight: 500; } .header-close:hover { color: var(--whut-danger); background: var(--whut-danger-soft); border-color: #fecdd3; } `, navigation: ` .helper-tabs { display: grid; grid-template-columns: repeat(5, minmax(max-content, 1fr)); gap: 6px; padding: 0 0 12px; overflow-x: auto; pointer-events: auto; scrollbar-width: none; scroll-snap-type: x proximity; touch-action: pan-x; } .helper-tabs::-webkit-scrollbar { display: none; } .helper-tab { position: relative; min-height: 41px; padding: 8px 10px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; color: var(--whut-text-secondary); font-size: 12px; font-weight: 750; white-space: nowrap; cursor: pointer; background: transparent; border: 1px solid transparent; border-radius: 12px; transition: color var(--whut-fast), background var(--whut-fast), border-color var(--whut-fast), transform var(--whut-normal) var(--whut-ease), box-shadow var(--whut-normal) var(--whut-ease); scroll-snap-align: center; } .helper-tab::after { content: ""; position: absolute; left: 25%; right: 25%; bottom: 3px; height: 2px; border-radius: 999px; background: var(--whut-primary); opacity: 0; transform: scaleX(.4); transition: opacity var(--whut-fast), transform var(--whut-normal) var(--whut-ease); } .helper-tab:hover { color: var(--whut-primary); background: rgba(30, 136, 229, .07); transform: translateY(-1px); } .helper-tab.active { color: var(--whut-primary); background: var(--whut-primary-soft); border-color: #c8e4fa; box-shadow: 0 5px 14px rgba(30, 136, 229, .10); } .helper-tab.active::after { opacity: 1; transform: scaleX(1); } .helper-tab-icon { font-size: 14px; line-height: 1; } .tab-view { display: none; flex: 1 1 auto; min-height: 0; flex-direction: column; overflow: hidden; } .tab-view.active { display: flex; animation: whutViewIn 240ms var(--whut-ease) both; } `, controls: ` .helper-toolbar { flex: 0 0 auto; padding: 14px 18px; background: rgba(248, 250, 252, .82); border-bottom: 1px solid var(--whut-border); box-shadow: 0 5px 16px rgba(30, 64, 104, .035); } .search-box { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; margin-bottom: 10px; } .filter-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-bottom: 10px; align-items: center; } .action-row { display: flex; gap: 10px; } .action-row > .btn-modern, .btn-flex { flex: 1 1 0; } .search-input, .filter-select, .semester-filter-button, .eval-field textarea, .eval-field select, .eval-field input, .uikit-dialog .gpa-sem-input { width: 100%; color: var(--whut-text); background: rgba(255, 255, 255, .94); border: 1px solid var(--whut-border); border-radius: var(--whut-radius-md); outline: none; box-shadow: inset 0 1px 0 white, var(--whut-shadow-xs); transition: border-color var(--whut-fast), box-shadow var(--whut-fast), background var(--whut-fast); } .search-input { min-width: 0; height: 42px; padding: 9px 13px 9px 39px; font-size: 13px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m20 20-3.5-3.5'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: 13px center; } .search-input::placeholder, .eval-field textarea::placeholder { color: var(--whut-text-muted); } .filter-select { height: 42px; padding: 0 36px 0 12px; font-size: 12px; font-weight: 650; cursor: pointer; appearance: none; background-image: linear-gradient(45deg, transparent 50%, var(--whut-text-secondary) 50%), linear-gradient(135deg, var(--whut-text-secondary) 50%, transparent 50%); background-position: calc(100% - 16px) 18px, calc(100% - 11px) 18px; background-size: 5px 5px; background-repeat: no-repeat; } .search-input:hover, .filter-select:hover, .semester-filter-button:hover, .eval-field textarea:hover, .eval-field select:hover, .eval-field input:hover, .uikit-dialog .gpa-sem-input:hover { border-color: var(--whut-border-strong); } .search-input:focus, .filter-select:focus, .semester-filter-button:focus, .eval-field textarea:focus, .eval-field select:focus, .eval-field input:focus, .uikit-dialog .gpa-sem-input:focus { background-color: white; border-color: #72baf0; box-shadow: 0 0 0 4px rgba(30, 136, 229, .12), inset 0 1px 0 white; } .semester-filter { position: relative; width: 100%; min-width: 0; } .semester-filter-button { position: relative; height: 42px; padding: 0 36px 0 12px; display: flex; align-items: center; justify-content: flex-start; gap: 8px; font-size: 12px; font-weight: 650; text-align: left; cursor: pointer; } .semester-filter-chevron { position: absolute; top: 50%; right: 12px; transform: translateY(-50%); } .semester-filter-chevron { color: var(--whut-text-muted); transition: transform var(--whut-normal) var(--whut-ease); } .semester-filter.open .semester-filter-chevron { transform: translateY(-50%) rotate(180deg); } .semester-filter-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .semester-menu { display: none; position: absolute; left: 0; right: 0; top: calc(100% + 8px); z-index: 100001; max-height: 290px; padding: 10px; overflow-y: auto; background: rgba(255, 255, 255, .98); border: 1px solid var(--whut-border); border-radius: 16px; box-shadow: var(--whut-shadow-md); transform-origin: top center; } .semester-filter.open .semester-menu { display: block; animation: whutMenuIn 180ms var(--whut-ease) both; } .semester-actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; padding-bottom: 9px; margin-bottom: 5px; border-bottom: 1px solid var(--whut-border); } .semester-action { min-height: 30px; padding: 5px 7px; color: var(--whut-text-secondary); font-size: 11px; font-weight: 700; cursor: pointer; background: var(--whut-surface-muted); border: 1px solid var(--whut-border); border-radius: 9px; transition: all var(--whut-fast); } .semester-action:hover { color: var(--whut-primary); background: var(--whut-primary-soft); border-color: #c8e4fa; } .semester-option { display: flex; align-items: center; gap: 9px; min-height: 34px; padding: 7px 8px; color: var(--whut-text); font-size: 12px; cursor: pointer; border-radius: 9px; transition: background var(--whut-fast); } .semester-option:hover { background: var(--whut-primary-softer); } .semester-option input, .pj-grp-cb, .pj-item-cb { width: 16px; height: 16px; margin: 0; accent-color: var(--whut-primary); cursor: pointer; } .btn-modern, .uikit-dialog .btn-modern { min-height: 40px; padding: 9px 15px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; color: white; font-size: 12px; font-weight: 800; line-height: 1.2; cursor: pointer; background: linear-gradient(135deg, #2495ed, var(--whut-primary)); border: 1px solid transparent; border-radius: 12px; box-shadow: 0 7px 16px rgba(30, 136, 229, .18); transition: color var(--whut-fast), background var(--whut-fast), border-color var(--whut-fast), transform var(--whut-normal) var(--whut-ease), box-shadow var(--whut-normal) var(--whut-ease), opacity var(--whut-fast); } .btn-modern:hover, .uikit-dialog .btn-modern:hover { background: linear-gradient(135deg, var(--whut-primary), var(--whut-primary-hover)); transform: translateY(-2px); box-shadow: 0 11px 22px rgba(30, 136, 229, .24); } .btn-modern:active, .uikit-dialog .btn-modern:active { transform: translateY(0) scale(.975); } .btn-modern:disabled, .uikit-dialog .btn-modern:disabled { opacity: .55; cursor: wait; transform: none; box-shadow: none; } .btn-spinner { width: 14px; height: 14px; flex: 0 0 14px; border: 2px solid rgba(255, 255, 255, .46); border-top-color: currentColor; border-radius: 50%; animation: whutSpin .72s linear infinite; } .btn-outline .btn-spinner { border-color: rgba(30, 136, 229, .22); border-top-color: var(--whut-primary); } .btn-outline, .uikit-dialog .btn-outline { color: var(--whut-text-secondary); background: rgba(255, 255, 255, .92); border-color: var(--whut-border); box-shadow: var(--whut-shadow-xs); } .btn-outline:hover, .uikit-dialog .btn-outline:hover { color: var(--whut-primary); background: var(--whut-primary-softer); border-color: #acd5f4; } .btn-success { background: linear-gradient(135deg, #22c55e, #16a34a); box-shadow: 0 7px 16px rgba(22, 163, 74, .18); } .btn-success:hover { background: linear-gradient(135deg, #16a34a, #15803d); box-shadow: 0 11px 22px rgba(22, 163, 74, .22); } .btn-compact { min-height: 34px; padding: 7px 11px; font-size: 11px; border-radius: 10px; } .btn-block { width: 100%; } .field-label { display: block; margin: 0 0 6px; color: var(--whut-text-secondary); font-size: 11px; font-weight: 800; } `, statesAndContent: ` .helper-content, .monitor-container, .gpa-container, .export-container { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 16px 18px; background: var(--whut-bg); } .ui-state { min-height: 190px; padding: 28px 18px; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--whut-text-secondary); text-align: center; } .ui-state.compact { min-height: 92px; padding: 18px 12px; } .ui-state-icon { width: 44px; height: 44px; margin-bottom: 12px; display: inline-flex; align-items: center; justify-content: center; color: var(--whut-primary); font-size: 21px; background: var(--whut-primary-soft); border: 1px solid #d3e9fb; border-radius: 15px; } .ui-state-spinner { width: 21px; height: 21px; border: 2.5px solid #bdddf5; border-top-color: var(--whut-primary); border-radius: 50%; animation: whutSpin .75s linear infinite; } .ui-state-title { color: var(--whut-text); font-size: 13px; font-weight: 800; } .ui-state-detail { max-width: 430px; margin-top: 5px; color: var(--whut-text-secondary); font-size: 11px; line-height: 1.65; } .ui-state.error .ui-state-icon { color: var(--whut-danger); background: var(--whut-danger-soft); border-color: #fecdd3; } .ui-state.warning .ui-state-icon { color: var(--whut-warning); background: var(--whut-warning-soft); border-color: #fed7aa; } .ui-state.empty .ui-state-icon { color: var(--whut-text-muted); background: #f1f5f9; border-color: #e2e8f0; } .text-muted { color: var(--whut-text-muted) !important; } .text-success { color: var(--whut-success) !important; } .text-danger { color: var(--whut-danger) !important; } .section-heading { margin: 18px 2px 10px; display: flex; align-items: center; gap: 8px; color: var(--whut-text); font-size: 13px; font-weight: 850; } .section-heading::after { content: ""; flex: 1; height: 1px; background: linear-gradient(90deg, var(--whut-border), transparent); } `, analysis: ` .course-item { position: relative; margin-bottom: 11px; overflow: hidden; background: var(--whut-surface-raised); border: 1px solid var(--whut-border); border-radius: var(--whut-radius-lg); box-shadow: var(--whut-shadow-sm); transition: border-color var(--whut-fast), background var(--whut-fast), transform var(--whut-normal) var(--whut-ease), box-shadow var(--whut-normal) var(--whut-ease); } .course-item::before { content: ""; position: absolute; left: 0; top: 14px; bottom: 14px; width: 3px; border-radius: 0 3px 3px 0; background: transparent; transition: background var(--whut-fast); } .course-item:hover, .course-item:focus-within { background: white; border-color: #c4ddf1; transform: translateY(-3px); box-shadow: 0 13px 28px rgba(30, 64, 104, .11); } .course-item:hover::before, .course-item:focus-within::before { background: linear-gradient(180deg, var(--whut-primary), var(--whut-accent)); } .course-summary { width: 100%; min-height: 86px; padding: 15px 17px; display: flex; align-items: center; justify-content: space-between; gap: 14px; color: inherit; text-align: left; cursor: pointer; background: transparent; border: 0; } .course-main { flex: 1 1 auto; min-width: 0; } .course-title { display: block; margin-bottom: 8px; overflow: hidden; color: var(--whut-text); font-size: 14px; font-weight: 780; letter-spacing: -.01em; 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(--whut-text-secondary); font-weight: 650; background: #f1f5f9; border: 1px solid #e7edf4; border-radius: 999px; } .tag-credit { color: #0b74bb; background: #eaf6ff; border-color: #d1eafd; } .tag-compulsory { color: #be185d; font-weight: 750; background: #fdf2f8; border-color: #fbcfe8; } .tag-source-monitor { color: #15803d; background: #ecfdf3; border-color: #bbf7d0; } .tag-source-transcript { color: #1d4ed8; background: #eff6ff; border-color: #bfdbfe; } .course-score { flex: 0 0 auto; min-width: 72px; color: var(--whut-text-muted); font-size: 19px; font-weight: 850; letter-spacing: -.02em; text-align: right; } .score-high { color: var(--whut-success); } .score-med { color: var(--whut-warning); } .score-low { color: var(--whut-danger); } .score-none { color: #b4c1d0; font-size: 13px; font-weight: 700; } .helper-footer { flex: 0 0 auto; padding: 11px 18px 12px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; pointer-events: none; background: rgba(255, 255, 255, .94); border-top: 1px solid var(--whut-border); box-shadow: 0 -8px 24px rgba(30, 64, 104, .06); } .footer-metric { min-width: 0; padding: 7px 9px; display: flex; align-items: center; justify-content: space-between; gap: 7px; background: #f8fafc; border: 1px solid #e8eef5; border-radius: 11px; } .footer-label { overflow: hidden; color: var(--whut-text-secondary); font-size: 10px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } .footer-value { display: inline-flex; align-items: baseline; gap: 3px; color: var(--whut-text); font-size: 14px; font-weight: 900; letter-spacing: -.02em; } .footer-value small { color: var(--whut-text-muted); font-size: 9px; font-weight: 700; } .footer-metric.is-deep .footer-value { color: var(--whut-deep-blue); font-size: 17px; } .footer-metric.is-primary .footer-value { color: var(--whut-primary); font-size: 17px; } .detail-panel { display: none; padding: 15px 16px 16px; background: #f7fafe; border-top: 1px solid var(--whut-border); } .detail-panel.open { display: block; animation: whutDetailIn 220ms var(--whut-ease) both; } .compare-table { width: 100%; margin-bottom: 13px; overflow: hidden; color: var(--whut-text); font-size: 11px; text-align: center; background: white; border: 1px solid var(--whut-border); border-radius: 12px; border-collapse: separate; border-spacing: 0; } .compare-table th { padding: 9px 6px; color: var(--whut-text-secondary); font-weight: 750; background: var(--whut-primary-softer); border-bottom: 1px solid var(--whut-border); } .compare-table td { padding: 10px 6px; font-weight: 650; } .val-rank { color: var(--whut-primary); font-weight: 850; } .detail-section-head { margin: 12px 0 8px; display: flex; align-items: center; gap: 8px; color: var(--whut-text-secondary); font-size: 11px; font-weight: 800; } .detail-section-head::after { content: ""; flex: 1; height: 1px; background: var(--whut-border); } .dist-container { padding: 3px 1px 1px; } .dist-row { display: flex; align-items: center; gap: 9px; min-height: 25px; color: var(--whut-text-secondary); font-size: 11px; } .dist-name { width: 38px; text-align: right; } .dist-track { flex: 1; height: 7px; overflow: hidden; background: #e6edf5; border-radius: 999px; } .dist-fill { height: 100%; background: linear-gradient(90deg, #5eb2ef, var(--whut-primary)); border-radius: 999px; transition: width 420ms var(--whut-ease); } .dist-num { width: 30px; font-weight: 750; } .table-empty { padding: 22px 12px !important; color: var(--whut-text-muted) !important; text-align: center !important; } `, gpaAndPrint: ` .export-card, .gpa-card { margin-bottom: 14px; padding: 18px; background: var(--whut-surface-raised); border: 1px solid var(--whut-border); border-radius: var(--whut-radius-lg); box-shadow: var(--whut-shadow-sm); } .gpa-card.is-overall { position: relative; overflow: hidden; border-color: #b9dcf6; box-shadow: 0 14px 32px rgba(30, 136, 229, .11); } .gpa-card.is-overall::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 4px; background: linear-gradient(90deg, var(--whut-primary), var(--whut-accent)); } .gpa-card.is-semester { padding: 15px; } .gpa-card.is-empty { padding: 13px 15px; } .gpa-card-title { margin-bottom: 14px; padding-bottom: 10px; display: flex; align-items: center; gap: 8px; color: var(--whut-text); font-size: 15px; font-weight: 850; border-bottom: 1px solid var(--whut-border); } .gpa-card-title.is-compact { margin: 0; padding: 0; justify-content: space-between; font-size: 13px; border: 0; } .gpa-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 11px; margin-bottom: 0; } .gpa-item { min-width: 0; padding: 13px; display: flex; flex-direction: column; background: linear-gradient(145deg, #f8fbff, #eef6fd); border: 1px solid #dceaf6; border-radius: 13px; } .gpa-item-label { margin-bottom: 5px; color: var(--whut-text-secondary); font-size: 10px; font-weight: 700; } .gpa-item-value { color: var(--whut-primary); font-size: 20px; font-weight: 900; letter-spacing: -.03em; } .gpa-item-value.accent { color: #db2777; } .gpa-item-value.compulsory-gpa-value { color: var(--whut-deep-blue); } .gpa-value-row { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; } .gpa-value-row .gpa-item-value { font-size: 16px; } .gpa-inline-rank { color: var(--whut-text-secondary); font-size: 10px; } .gpa-inline-rank b { color: var(--whut-text); } .gpa-ranks { margin-top: 8px; padding: 6px 8px; display: flex; flex-wrap: wrap; gap: 10px; color: var(--whut-text-secondary); font-size: 10px; background: rgba(255, 255, 255, .72); border-radius: 9px; } .gpa-ranks b { color: var(--whut-text); } .gpa-sem-row { display: flex; gap: 8px; margin-bottom: 8px; } .uikit-dialog .gpa-sem-input { flex: 1; min-width: 0; height: 40px; padding: 8px 11px; } .gpa-sem-help { margin-bottom: 11px; color: var(--whut-text-secondary); font-size: 11px; line-height: 1.65; text-align: left; } .export-card h3 { margin: 0 0 11px; display: flex; align-items: center; gap: 8px; color: var(--whut-text); font-size: 14px; font-weight: 850; } .export-card p { margin: 0 0 15px; color: var(--whut-text-secondary); font-size: 12px; line-height: 1.65; } .print-list { display: flex; flex-direction: column; gap: 8px; margin-top: 9px; } .print-row { padding: 11px 12px; display: flex; align-items: center; justify-content: space-between; gap: 12px; background: var(--whut-surface-muted); border: 1px solid var(--whut-border); border-radius: 13px; transition: background var(--whut-fast), border-color var(--whut-fast), transform var(--whut-normal) var(--whut-ease), box-shadow var(--whut-normal) var(--whut-ease); } .print-row:hover { background: white; border-color: #b9d9f1; transform: translateY(-2px); box-shadow: var(--whut-shadow-sm); } .print-name { min-width: 0; overflow: hidden; color: var(--whut-text); font-size: 12px; 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 rgb(16 185 129 / 10%); } .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 rgb(239 68 68 / 10%); } .eval-status-row[data-state='warning'] .eval-status-dot { background: var(--uikit-warning); box-shadow: 0 0 0 4px rgb(245 158 11 / 10%); } .eval-status-row[data-state='idle'] .eval-status-dot { background: var(--uikit-text-faint); box-shadow: 0 0 0 4px rgb(108 111 133 / 9%); } .eval-status-actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; } .eval-status-note { color: var(--whut-text-muted); font-size: 10px; white-space: nowrap; } .eval-config-card { padding: 14px; display: grid; gap: 12px; background: var(--uikit-bg-elevated); border: 1px solid var(--whut-border); border-radius: var(--uikit-radius-md); box-shadow: var(--whut-shadow-sm); animation: whutDetailIn 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: 68px; padding: 10px 12px; resize: vertical; font-size: 12px; line-height: 1.55; } .eval-field select, .eval-field input { height: 40px; padding: 8px 11px; font-size: 12px; } .eval-config-actions { display: flex; justify-content: flex-end; gap: 9px; } .eval-footer { display: flex; gap: 10px; border-top: 1px solid var(--whut-border); border-bottom: 0; } #pj-list-area { padding: 14px 18px !important; background: var(--whut-bg) !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); background: var(--uikit-bg-elevated); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); box-shadow: var(--uikit-shadow-sm); } .eval-summary-copy { min-width: 0; display: grid; gap: 2px; } .eval-summary-eyebrow { color: var(--uikit-brand); font-size: 9px; 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: 10px; } .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; background: var(--uikit-bg-elevated); border: 1px solid var(--whut-border); border-radius: var(--uikit-radius-md); box-shadow: var(--whut-shadow-sm); transition: border-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); } #pj-list-area > .pj-group:hover { border-color: rgb(var(--uikit-brand-rgb) / 44%); box-shadow: 0 8px 20px rgb(var(--uikit-brand-rgb) / 10%); transform: translateY(-1px); } .pj-group-head { min-height: 46px; display: flex; align-items: stretch; background: var(--uikit-bg-elevated); border-bottom: 1px solid var(--whut-border); } .pj-group-select { padding: 0 0 0 13px; display: inline-flex; align-items: center; gap: 6px; color: var(--uikit-text-muted); font-size: 9px; 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(--whut-text); font-size: 12px; font-weight: 820; text-align: left; cursor: pointer; background: transparent; border: 0; transition: background var(--uikit-duration-fast) var(--uikit-ease-standard), color var(--uikit-duration-fast) var(--uikit-ease-standard); } .pj-group-header:hover { color: var(--whut-primary); background: var(--whut-primary-softer); } .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 rgb(108 111 133 / 8%); } .pj-group-mark.is-active { background: var(--uikit-success); box-shadow: 0 0 0 4px rgb(16 185 129 / 10%); } .pj-group-mark.is-done { background: var(--uikit-brand); box-shadow: 0 0 0 4px rgb(var(--uikit-brand-rgb) / 12%); } .pj-group-mark.is-wait { background: var(--uikit-warning); box-shadow: 0 0 0 4px rgb(245 158 11 / 10%); } .pj-group-mark.is-end { background: var(--uikit-danger); box-shadow: 0 0 0 4px rgb(239 68 68 / 9%); } .pj-group-title { flex: 1; } .pj-arrow { color: var(--whut-text-muted); 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; background: var(--uikit-bg-elevated); border-bottom: 1px solid var(--uikit-border); transition: background var(--uikit-duration-fast) var(--uikit-ease-standard), transform var(--uikit-duration-fast) var(--uikit-ease-standard); } .pj-item:hover { background: var(--uikit-brand-soft); transform: translateX(2px); } .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(--whut-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(--whut-text-secondary); 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: 9px; font-weight: 850; border-radius: 999px; } .pj-badge.ing { color: var(--uikit-success); background: rgb(16 185 129 / 13%); } .pj-badge.done { color: var(--uikit-brand); background: var(--uikit-brand-soft); } .pj-badge.wait { color: var(--uikit-warning); background: rgb(245 158 11 / 12%); } .pj-badge.end { color: var(--uikit-danger); background: rgb(239 68 68 / 11%); } .pj-log { margin-top: 3px; color: var(--whut-warning); font-size: 9px; 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: rgb(245 158 11 / 10%); border-color: rgb(245 158 11 / 24%); } .pj-detail-btn { min-height: 25px; padding: 4px 8px; color: var(--uikit-brand-text); font-size: 9px; font-weight: 800; cursor: pointer; background: var(--uikit-brand-soft); border: 1px solid rgb(var(--uikit-brand-rgb) / 22%); border-radius: var(--uikit-radius-pill); transition: color var(--uikit-duration-fast) var(--uikit-ease-standard), background-color var(--uikit-duration-fast) var(--uikit-ease-standard), border-color var(--uikit-duration-fast) var(--uikit-ease-standard), transform var(--uikit-duration-fast) var(--uikit-ease-emphasized), box-shadow var(--uikit-duration-fast) var(--uikit-ease-standard); } .pj-detail-btn:hover { color: #fff; background: var(--uikit-brand); border-color: var(--uikit-brand); transform: translateY(-1px); box-shadow: 0 5px 12px rgb(var(--uikit-brand-rgb) / 18%); } .pj-detail-btn:active { transform: scale(.96); } .pj-detail-btn:disabled { cursor: wait; opacity: .68; transform: none; box-shadow: 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; background: linear-gradient(135deg, var(--uikit-bg-elevated), var(--uikit-brand-soft)); border: 1px solid rgb(var(--uikit-brand-rgb) / 20%); border-radius: var(--uikit-radius-md); box-shadow: var(--uikit-shadow-sm); transition: border-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-emphasized); } .eval-history-loader:hover { border-color: rgb(var(--uikit-brand-rgb) / 38%); box-shadow: 0 8px 20px rgb(var(--uikit-brand-rgb) / 9%); transform: translateY(-1px); } .eval-history-copy { min-width: 0; display: grid; gap: 3px; } .eval-history-kicker { color: var(--uikit-brand); font-size: 9px; 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: 10px; 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 { background-image: linear-gradient(90deg, rgb(var(--uikit-brand-rgb) / 4%), transparent 42%); } .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: linear-gradient(135deg, var(--uikit-brand-soft), var(--uikit-bg-muted)); border: 1px solid rgb(var(--uikit-brand-rgb) / 22%); border-radius: var(--uikit-radius-md); } .eval-detail-kicker { color: var(--uikit-brand); font-size: 9px; 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: 10px; } .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: 10px; 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: 8px; 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: 620px) { .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%); } } `, monitor: ` .monitor-toolbar-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .monitor-status { min-width: 0; overflow: hidden; color: var(--whut-text-secondary); font-size: 11px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; } .monitor-actions { display: flex; gap: 8px; flex: 0 0 auto; } .monitor-overview { position: relative; margin-bottom: 15px; padding: 17px; overflow: hidden; background: linear-gradient(145deg, white, #f5faff); border: 1px solid #cfe5f7; border-radius: 18px; box-shadow: var(--whut-shadow-sm); } .monitor-overview::after { content: ""; position: absolute; width: 150px; height: 150px; right: -58px; top: -74px; border-radius: 50%; background: rgba(135, 176, 249, .18); pointer-events: none; } .monitor-overview-top { position: relative; z-index: 1; margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between; gap: 12px; } .monitor-overview-label { margin-bottom: 3px; color: var(--whut-text-secondary); font-size: 11px; font-weight: 700; } .monitor-overview-value { color: var(--whut-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-radius: 999px; } .monitor-status-pill.pass, .monitor-group-badge.pass { color: #15803d; background: #dcfce7; border: 1px solid #bbf7d0; } .monitor-status-pill.warn, .monitor-group-badge.warn { color: #be123c; background: #ffe4e6; border: 1px solid #fecdd3; } .monitor-progress-track { height: 10px; margin-bottom: 8px; overflow: hidden; background: #e1eaf4; border-radius: 999px; box-shadow: inset 0 1px 2px rgba(30, 64, 104, .08); } .monitor-progress-fill { height: 100%; background: linear-gradient(90deg, var(--whut-primary), #4da8ee, var(--whut-accent)); border-radius: inherit; box-shadow: 0 0 12px rgba(30, 136, 229, .24); transition: width 520ms var(--whut-ease); } .monitor-progress-meta { display: flex; justify-content: space-between; gap: 12px; color: var(--whut-text-secondary); font-size: 11px; } .monitor-progress-meta b { color: var(--whut-primary); font-size: 12px; } .monitor-section-title { margin: 17px 2px 9px; display: flex; align-items: center; gap: 8px; color: var(--whut-text); font-size: 12px; font-weight: 850; } .monitor-section-title::after { content: ""; flex: 1; height: 1px; background: linear-gradient(90deg, var(--whut-border), transparent); } .monitor-groups { overflow: hidden; background: white; border: 1px solid var(--whut-border); border-radius: 17px; box-shadow: var(--whut-shadow-sm); } .monitor-group { border-bottom: 1px solid var(--whut-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(--whut-text); text-align: left; cursor: pointer; background: white; border: 0; transition: color var(--whut-fast), background var(--whut-fast); } .monitor-group-header:hover { color: var(--whut-primary); background: #f7fbff; } .monitor-group-arrow { color: var(--whut-text-muted); font-size: 10px; transition: transform var(--whut-normal) var(--whut-ease); } .monitor-group-title { font-size: 12px; font-weight: 850; } .monitor-group-credit { overflow: hidden; color: var(--whut-success); font-size: 11px; font-weight: 750; text-overflow: ellipsis; white-space: nowrap; } .monitor-group-badge { justify-self: end; } .monitor-group-body { overflow-x: auto; border-top: 1px solid var(--whut-border); animation: whutDetailIn 220ms var(--whut-ease) both; } .monitor-course-table { width: 100%; min-width: 690px; color: var(--whut-text); font-size: 11px; border-collapse: separate; border-spacing: 0; } .monitor-course-table th { padding: 10px 11px; color: var(--whut-text-secondary); font-weight: 800; text-align: left; white-space: nowrap; background: #f3f8fd; border-bottom: 1px solid var(--whut-border); } .monitor-course-table td { padding: 10px 11px; vertical-align: middle; border-top: 1px solid #edf2f7; } .monitor-course-table tbody tr:first-child td { border-top: 0; } .monitor-course-table tbody tr:hover td { background: #f8fbff; } .monitor-course-row.pending td { color: var(--whut-text-muted); background: #fafbfd; } .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: white; font-size: 11px; font-weight: 900; border-radius: 50%; } .monitor-pass-dot.pass { background: var(--whut-success); box-shadow: 0 3px 8px rgba(22, 163, 74, .18); } .monitor-pass-dot.fail { background: var(--whut-danger); } .monitor-pass-dot.pending { color: var(--whut-text-secondary); background: #dbe4ee; } .monitor-warning { margin-bottom: 9px; padding: 11px 12px; color: #9f1239; font-size: 11px; line-height: 1.6; background: var(--whut-danger-soft); border: 1px solid #fecdd3; border-radius: 13px; } .monitor-warning.is-ok { color: #166534; background: var(--whut-success-soft); border-color: #bbf7d0; } .monitor-empty { padding: 30px 12px; color: var(--whut-text-muted); text-align: center; } `, updateFeedbackAndModal: ` #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; } .update-card { color: var(--whut-text); } .update-card-intro { margin: 0 0 13px; color: var(--whut-text-secondary); font-size: 12px; line-height: 1.7; } .update-version-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 11px; margin-bottom: 12px; } .update-status { margin-bottom: 12px; font-size: 11px; font-weight: 850; } .update-status.is-new { color: var(--whut-success); } .update-status.is-current { color: var(--whut-primary); } .update-status.is-error { color: var(--whut-danger); } .update-notes { margin-bottom: 13px; padding: 12px 14px; background: white; border: 1px solid var(--whut-border); border-radius: 14px; } .update-notes-title { margin-bottom: 7px; color: var(--whut-text); font-weight: 850; } .update-notes ul { margin: 0; padding-left: 20px; color: var(--whut-text-secondary); line-height: 1.75; } .update-notes li { display: list-item; margin: 3px 0; text-align: left !important; } #whut-toast-stack { position: fixed; left: 18px; bottom: 18px; z-index: 1000002; width: min(380px, calc(100vw - 36px)); display: flex; flex-direction: column-reverse; gap: 9px; pointer-events: none; } .whut-helper-toast, .whut-update-toast { position: relative; width: 100%; min-height: 58px; padding: 11px 38px 12px 13px; display: grid; grid-template-columns: 32px minmax(0, 1fr); align-items: start; gap: 10px; color: var(--whut-text); background: rgba(255, 255, 255, .97); border: 1px solid var(--whut-border); border-radius: 15px; box-shadow: 0 15px 38px rgba(30, 64, 104, .18); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); pointer-events: auto; overflow: hidden; animation: whutToastIn 260ms var(--whut-ease) both; } .whut-helper-toast::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--whut-info); } .whut-helper-toast.success::before { background: var(--whut-success); } .whut-helper-toast.warning::before { background: var(--whut-warning); } .whut-helper-toast.error::before { background: var(--whut-danger); } .whut-helper-toast-icon { width: 32px; height: 32px; display: inline-flex; align-items: center; justify-content: center; font-size: 15px; background: #eef7fd; border-radius: 10px; } .whut-helper-toast.success .whut-helper-toast-icon { background: var(--whut-success-soft); } .whut-helper-toast.warning .whut-helper-toast-icon { background: var(--whut-warning-soft); } .whut-helper-toast.error .whut-helper-toast-icon { background: var(--whut-danger-soft); } .whut-helper-toast-body { min-width: 0; padding-top: 1px; display: flex; flex-direction: column; } .whut-helper-toast-title { overflow-wrap: anywhere; color: var(--whut-text); font-size: 12px; font-weight: 850; line-height: 1.45; } .whut-helper-toast-text { margin-top: 3px; overflow-wrap: anywhere; color: var(--whut-text-secondary); font-size: 10px; font-weight: 600; line-height: 1.55; } .whut-helper-toast-close { position: absolute; right: 7px; top: 7px; width: 25px; height: 25px; padding: 0; display: inline-flex; align-items: center; justify-content: center; color: var(--whut-text-muted); font-size: 14px; cursor: pointer; background: transparent; border: 0; border-radius: 8px; } .whut-helper-toast-close:hover { color: var(--whut-text); background: #eef2f7; } .whut-helper-toast-progress { position: absolute; left: 4px; right: 0; bottom: 0; height: 2px; background: rgba(2, 132, 199, .2); transform-origin: left; animation: whutToastProgress var(--whut-toast-duration, 3000ms) linear both; } .whut-helper-toast.success .whut-helper-toast-progress { background: rgba(22, 163, 74, .25); } .whut-helper-toast.warning .whut-helper-toast-progress { background: rgba(217, 119, 6, .25); } .whut-helper-toast.error .whut-helper-toast-progress { background: rgba(220, 38, 38, .25); } .whut-helper-toast.is-leaving { animation: whutToastOut 180ms ease both; } `, uikitConformance: ` /* Tampermonkey UIKit 1.0 基线:统一组件密度、层级、主题与交互状态。 */ #whut-fab { width: 56px; height: 56px; z-index: var(--uikit-z-fab); color: #fff; border: 0; border-radius: 50%; background: linear-gradient(135deg, var(--uikit-brand-2), var(--uikit-brand)); box-shadow: var(--uikit-shadow-md); transition: transform var(--uikit-duration-fast) var(--uikit-ease-emphasized), box-shadow var(--uikit-duration-fast) var(--uikit-ease-standard), background-color var(--uikit-duration-theme) var(--uikit-ease-standard); } #whut-fab::before { display: none; } #whut-fab:hover { border-radius: 50%; transform: translateY(-2px) scale(1.06); box-shadow: var(--uikit-shadow-md); } #whut-fab:active { transform: scale(.96); } .whut-fab-glyph { font-size: 24px; filter: none; } .whut-fab-pulse { right: 5px; top: 5px; width: 9px; height: 9px; background: var(--uikit-success); box-shadow: 0 0 0 3px var(--uikit-bg); } #whut-helper-panel { z-index: var(--uikit-z-panel); width: ${PANEL_LAYOUT.width}px; min-width: 440px; height: 82vh; min-height: 480px; color: var(--uikit-text); font-size: 14px; background: var(--uikit-bg); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-lg); box-shadow: var(--uikit-shadow-lg); backdrop-filter: none; -webkit-backdrop-filter: none; transform-origin: right bottom; transition: background-color var(--uikit-duration-theme) var(--uikit-ease-standard), color var(--uikit-duration-theme) var(--uikit-ease-standard), border-color var(--uikit-duration-theme) var(--uikit-ease-standard), box-shadow var(--uikit-duration-theme) var(--uikit-ease-standard); } #whut-helper-panel.open { 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 { padding: 0; background: var(--uikit-bg); border-bottom: 1px solid var(--uikit-border); cursor: grab; } .header-top { min-height: 58px; margin: 0; padding: 8px 10px 8px 16px; gap: 12px; background: linear-gradient(135deg, var(--uikit-bg-muted), var(--uikit-bg-elevated)); border-bottom: 1px solid var(--uikit-border); } .header-title { gap: 10px; } .header-logo { width: 36px; height: 36px; flex-basis: 36px; border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); background: var(--uikit-brand-soft); box-shadow: var(--uikit-shadow-sm); font-size: 19px; } .header-brand-copy { gap: 2px; } .header-name { color: var(--uikit-text); font-size: 16px; font-weight: 800; letter-spacing: .01em; } .header-meta { gap: 5px; } .header-version, .header-author { min-height: 18px; padding: 1px 6px; color: var(--uikit-brand-text); font-size: 9px; font-weight: 800; background: var(--uikit-brand-soft); border: 0; border-radius: var(--uikit-radius-pill); } .header-author { color: var(--uikit-text-muted); background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); } .header-actions { gap: 5px; } .header-action { min-width: 34px; height: 34px; padding: 0 9px; color: var(--uikit-text-muted); background: transparent; border: 1px solid transparent; border-radius: var(--uikit-radius-pill); box-shadow: none; font-size: 12px; transition: background var(--uikit-duration-fast) var(--uikit-ease-standard), color var(--uikit-duration-fast) var(--uikit-ease-standard), border-color var(--uikit-duration-fast) var(--uikit-ease-standard), transform var(--uikit-duration-fast) var(--uikit-ease-emphasized), box-shadow var(--uikit-duration-fast) var(--uikit-ease-standard); } .header-action:hover { color: var(--uikit-text); background: var(--uikit-bg-muted); border-color: transparent; transform: translateY(-1px); box-shadow: var(--uikit-shadow-sm); } .header-action:active { transform: scale(.94); } .header-theme { width: 34px; padding: 0; font-size: 15px; } .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); border-color: var(--uikit-brand); background: var(--uikit-brand-soft); } .header-update.has-update { color: var(--uikit-success); background: rgb(16 185 129 / 10%); border-color: rgb(16 185 129 / 32%); } .header-close { width: 34px; padding: 0; font-size: 18px; } .header-close:hover { color: #fff; background: var(--uikit-danger); border-color: var(--uikit-danger); transform: rotate(90deg); } .helper-tabs { display: flex; gap: 0; padding: 0; overflow-x: auto; background: var(--uikit-bg); border: 0; scroll-snap-type: x proximity; scrollbar-width: thin; } .helper-tabs::-webkit-scrollbar { display: block; height: 8px; } .helper-tab { position: relative; min-width: 104px; min-height: 44px; flex: 1 0 auto; padding: 10px 9px; gap: 5px; color: var(--uikit-text-muted); font-size: 12px; font-weight: 700; background: transparent; border: 0; border-radius: 0; box-shadow: none; } .helper-tab::after { left: 10px; right: 10px; bottom: 0; height: 3px; opacity: 0; transform: scaleX(.5); background: linear-gradient(90deg, var(--uikit-brand-2), var(--uikit-brand)); border-radius: 3px 3px 0 0; } .helper-tab:hover { color: var(--uikit-text); background: var(--uikit-brand-soft); transform: none; } .helper-tab.active { color: var(--uikit-brand); background: transparent; border: 0; box-shadow: none; } .helper-tab.active::after { opacity: 1; transform: scaleX(1); } .tab-view.active { animation: uikitViewIn var(--uikit-duration-fast) var(--uikit-ease-standard) both; } .helper-toolbar { padding: 12px 16px; background: var(--uikit-bg); border-bottom: 1px solid var(--uikit-border); box-shadow: none; } .search-box { gap: 8px; margin-bottom: 9px; } .filter-row { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-bottom: 9px; } .action-row { gap: 8px; } .search-input, .filter-select, .semester-filter-button, .eval-field textarea, .eval-field select, .eval-field input, .uikit-dialog .gpa-sem-input { color: var(--uikit-text); background-color: var(--uikit-bg-elevated); border: 1px solid var(--uikit-border-strong); border-radius: var(--uikit-radius-sm); box-shadow: none; transition: color var(--uikit-duration-fast) var(--uikit-ease-standard), border-color var(--uikit-duration-fast) var(--uikit-ease-standard), box-shadow var(--uikit-duration-fast) var(--uikit-ease-standard), background-color var(--uikit-duration-fast) var(--uikit-ease-standard), transform var(--uikit-duration-fast) var(--uikit-ease-standard); } .search-input { height: 36px; padding: 7px 34px 7px 34px; background-position: 10px center; } .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, .semester-filter-button { width: 100%; height: 38px; min-height: 38px; padding: 0 36px 0 12px; color: var(--uikit-text); font-size: 12px; font-weight: 650; line-height: 1; text-align: left; background-color: var(--uikit-bg-elevated); border: 1px solid var(--uikit-border-strong); border-radius: var(--uikit-radius-sm); box-shadow: var(--uikit-shadow-sm); transition: color var(--uikit-duration-fast) var(--uikit-ease-standard), border-color var(--uikit-duration-fast) var(--uikit-ease-standard), box-shadow var(--uikit-duration-fast) var(--uikit-ease-standard), background-color var(--uikit-duration-fast) var(--uikit-ease-standard), transform var(--uikit-duration-fast) var(--uikit-ease-emphasized); } .semester-filter { width: 100%; } .ui-select-trigger, .semester-filter-button { position: relative; display: flex; align-items: center; justify-content: flex-start; gap: 8px; cursor: pointer; } .ui-select-value, .semester-filter-label { min-width: 0; overflow: hidden; flex: 1; text-overflow: ellipsis; white-space: nowrap; } .ui-select-chevron, .semester-filter-chevron { position: absolute; top: 50%; right: 12px; color: var(--uikit-text-muted); transform: translateY(-50%); transition: transform var(--uikit-duration-fast) var(--uikit-ease-emphasized), color var(--uikit-duration-fast) var(--uikit-ease-standard); } .ui-select.is-open .ui-select-chevron, .semester-filter.open .semester-filter-chevron { transform: translateY(-50%) rotate(180deg); } .search-input:hover, .ui-select-trigger:hover, .semester-filter-button:hover, .eval-field textarea:hover, .eval-field select:hover, .eval-field input:hover, .uikit-dialog .gpa-sem-input:hover { border-color: var(--uikit-brand); background-color: var(--uikit-bg-elevated); } .ui-select-trigger:hover, .semester-filter-button:hover { color: var(--uikit-brand-text); transform: translateY(-1px); box-shadow: 0 5px 14px rgb(var(--uikit-brand-rgb) / 11%); } .search-input:focus, .ui-select-trigger:focus, .semester-filter-button:focus, .eval-field textarea:focus, .eval-field select:focus, .eval-field input:focus, .uikit-dialog .gpa-sem-input:focus { background: var(--uikit-bg-elevated); border-color: var(--uikit-brand); box-shadow: 0 0 0 3px rgb(var(--uikit-brand-rgb) / 20%); } .ui-select.is-open .ui-select-trigger, .semester-filter.open .semester-filter-button { color: var(--uikit-brand-text); background: var(--uikit-brand-soft); border-color: var(--uikit-brand); box-shadow: 0 0 0 3px rgb(var(--uikit-brand-rgb) / 14%); } .ui-select-trigger:disabled { color: var(--uikit-text-faint); cursor: not-allowed; opacity: .58; transform: none; box-shadow: none; } .ui-select-menu { display: none; position: fixed; z-index: var(--uikit-z-menu); max-height: min(320px, calc(100vh - 24px)); padding: 6px; 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.is-open { display: grid; gap: 2px; animation: whutMenuIn var(--uikit-duration-fast) var(--uikit-ease-emphasized) both; } .ui-select-option { width: 100%; min-height: 35px; padding: 7px 10px; display: flex; align-items: center; gap: 9px; color: var(--uikit-text); font-size: 12px; font-weight: 620; line-height: 1.35; text-align: left; cursor: pointer; background: transparent; border: 1px solid transparent; border-radius: var(--uikit-radius-sm); transition: color var(--uikit-duration-fast) var(--uikit-ease-standard), background-color var(--uikit-duration-fast) var(--uikit-ease-standard), border-color var(--uikit-duration-fast) var(--uikit-ease-standard), transform var(--uikit-duration-fast) var(--uikit-ease-emphasized); } .ui-select-option:hover, .ui-select-option.is-active { color: var(--uikit-brand-text); background: var(--uikit-brand-soft); border-color: rgb(var(--uikit-brand-rgb) / 20%); transform: translateX(2px); } .ui-select-option[aria-selected='true'] { color: var(--uikit-brand-text); font-weight: 780; background: var(--uikit-brand-soft); } .ui-select-option:disabled { color: var(--uikit-text-faint); cursor: not-allowed; opacity: .52; transform: none; } .ui-select-option-check { width: 17px; flex: 0 0 17px; color: transparent; font-size: 12px; 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; } .semester-menu { top: calc(100% + 6px); padding: 6px; background: var(--uikit-bg-elevated); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); box-shadow: var(--uikit-shadow-lg); } .semester-menu.is-portaled { display: block; position: fixed; right: auto; z-index: var(--uikit-z-menu); overflow-y: auto; } .semester-action { min-height: 30px; color: var(--uikit-text-muted); background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-sm); } .semester-action:hover, .semester-option:hover { color: var(--uikit-brand-text); background: var(--uikit-brand-soft); border-color: var(--uikit-brand); } .semester-option { min-height: 34px; color: var(--uikit-text); border-radius: var(--uikit-radius-sm); } .semester-option input, .pj-grp-cb, .pj-item-cb { transition: transform var(--uikit-duration-fast) var(--uikit-ease-emphasized), opacity var(--uikit-duration-fast) var(--uikit-ease-standard); } .semester-option input:hover, .pj-grp-cb:hover, .pj-item-cb:not(:disabled):hover { transform: scale(1.1); } .semester-option input:active, .pj-grp-cb:active, .pj-item-cb:not(:disabled):active { transform: scale(.92); } .btn-modern, .uikit-dialog .btn-modern { min-height: 34px; padding: 7px 13px; color: #fff; font-size: 12px; font-weight: 750; background: linear-gradient(135deg, var(--uikit-brand-2), var(--uikit-brand)); border: 1px solid transparent; border-radius: var(--uikit-radius-sm); box-shadow: var(--uikit-shadow-sm); position: relative; overflow: hidden; isolation: isolate; transform: translateZ(0); backface-visibility: hidden; transition: transform var(--uikit-duration-fast) var(--uikit-ease-emphasized), box-shadow var(--uikit-duration-fast) var(--uikit-ease-standard), opacity var(--uikit-duration-fast) var(--uikit-ease-standard), color var(--uikit-duration-fast) var(--uikit-ease-standard), background-color var(--uikit-duration-fast) var(--uikit-ease-standard); } .btn-modern::before, .uikit-dialog .btn-modern::before { content: ''; position: absolute; inset: 0; background: linear-gradient(100deg, transparent 20%, rgb(255 255 255 / 22%) 50%, transparent 80%); transform: translateX(-115%); transition: transform 600ms var(--uikit-ease-standard); pointer-events: none; } .btn-modern:hover::before, .uikit-dialog .btn-modern:hover::before { transform: translateX(115%); } .btn-modern:hover, .uikit-dialog .btn-modern:hover { background: linear-gradient(135deg, var(--uikit-brand-2), var(--uikit-brand)); transform: translateY(-2px) translateZ(0); box-shadow: 0 6px 16px rgb(var(--uikit-brand-rgb) / 22%); } .btn-modern:active, .uikit-dialog .btn-modern:active { transform: translateY(0) scale(.98); } .btn-modern:disabled, .btn-modern.is-loading, .uikit-dialog .btn-modern:disabled { opacity: .52; cursor: not-allowed; transform: none; box-shadow: none; } .btn-outline, .uikit-dialog .btn-outline { color: var(--uikit-text); background: var(--uikit-bg); border-color: var(--uikit-border-strong); box-shadow: none; } .btn-outline:hover, .uikit-dialog .btn-outline:hover { color: var(--uikit-brand-text); background: var(--uikit-brand-soft); border-color: var(--uikit-brand); } .btn-success { background: linear-gradient(135deg, #34d399, var(--uikit-success)); } .helper-content, .monitor-container, .gpa-container, .export-container, #pj-list-area { background: var(--uikit-bg-muted) !important; } .helper-content { padding: 14px 16px; } .monitor-container, .gpa-container, .export-container { padding: 16px; } .ui-state { min-height: 180px; color: var(--uikit-text-muted); } .ui-state-icon { color: var(--uikit-brand); background: var(--uikit-brand-soft); border-color: var(--uikit-border); } .ui-state.empty .ui-state-icon { color: var(--uikit-text-muted); background: var(--uikit-bg); border-color: var(--uikit-border); } .ui-state.warning .ui-state-icon { color: var(--uikit-warning); background: rgb(245 158 11 / 12%); border-color: rgb(245 158 11 / 24%); } .ui-state.error .ui-state-icon { color: var(--uikit-danger); background: rgb(239 68 68 / 10%); border-color: rgb(239 68 68 / 24%); } .ui-state-title { color: var(--uikit-text); } .ui-state-detail { color: var(--uikit-text-muted); } .course-item, .export-card, .gpa-card, .monitor-overview, .monitor-groups, #pj-list-area > .pj-group, .eval-config-card, .eval-summary { 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 { margin-bottom: 9px; } .course-item:hover { border-color: var(--uikit-brand); transform: translateY(-2px); box-shadow: 0 8px 20px rgb(var(--uikit-brand-rgb) / 10%); } .course-summary { min-height: 78px; padding: 12px 14px; background: var(--uikit-bg-elevated); } .course-summary:hover { background: var(--uikit-brand-soft); } .course-title { color: var(--uikit-text); font-size: 13px; font-weight: 800; } .course-score { color: var(--uikit-brand); font-size: 18px; font-variant-numeric: tabular-nums; } .tag { color: var(--uikit-text-muted); background: var(--uikit-bg-muted); border-color: 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: #be185d; background: rgb(236 72 153 / 10%); border-color: transparent; } .tag-source-monitor { color: var(--uikit-success); background: rgb(16 185 129 / 12%); border-color: rgb(16 185 129 / 22%); } .tag-source-transcript { color: var(--uikit-brand-text); background: var(--uikit-brand-soft); border-color: transparent; } .score-high { color: var(--uikit-success); } .score-med { color: #d97706; } .score-low { color: var(--uikit-danger); } .score-none { color: var(--uikit-text-faint); } .detail-panel { background: var(--uikit-bg-muted); border-top-color: var(--uikit-border); } .compare-table { background: var(--uikit-bg); border-color: var(--uikit-border); border-radius: var(--uikit-radius-md); } .compare-table th { color: var(--uikit-text-muted); background: var(--uikit-brand-soft); } .compare-table td { color: var(--uikit-text); border-color: var(--uikit-border); } .dist-track { background: var(--uikit-border); } .dist-fill { background: linear-gradient(90deg, var(--uikit-brand-2), var(--uikit-brand)); } .helper-footer { padding: 10px 14px; background: var(--uikit-bg-elevated); border-top-color: var(--uikit-border); box-shadow: none; } .footer-metric { padding: 8px 10px; background: var(--uikit-bg-muted); border-color: var(--uikit-border); border-radius: var(--uikit-radius-sm); } .footer-label { color: var(--uikit-text-faint); } .footer-value { color: var(--uikit-text); font-variant-numeric: tabular-nums; } .footer-metric.is-deep .footer-value { color: var(--uikit-brand-text); } .footer-metric.is-primary .footer-value { color: var(--uikit-brand); } .export-card, .gpa-card { padding: 14px; margin-bottom: 12px; } .export-card h3, .gpa-card-title { color: var(--uikit-text); } .export-card p { color: var(--uikit-text-muted); } .print-row { background: var(--uikit-bg-elevated); border-color: var(--uikit-border); border-radius: var(--uikit-radius-md); } .print-row:hover { background: var(--uikit-brand-soft); border-color: var(--uikit-brand); transform: translateY(-2px); box-shadow: 0 8px 20px rgb(var(--uikit-brand-rgb) / 10%); } .print-name { color: var(--uikit-text); } .gpa-item { background: var(--uikit-bg-muted); border-color: var(--uikit-border); } .gpa-item-value { color: var(--uikit-brand); } .gpa-item-value.accent, .gpa-item-value.compulsory-gpa-value { color: var(--uikit-brand-text); } .gpa-ranks { color: var(--uikit-text-muted); background: var(--uikit-bg-muted); } .eval-config-card { padding: 12px; } .pj-group-head, .pj-group-header, .pj-group-content, .pj-item { color: var(--uikit-text); background: var(--uikit-bg-elevated); border-color: var(--uikit-border); } .pj-group-header:hover, .pj-item:hover { color: var(--uikit-brand-text); background: var(--uikit-brand-soft); } .pj-badge.ing { color: var(--uikit-success); background: rgb(16 185 129 / 14%); } .pj-badge.done { color: var(--uikit-brand-text); background: var(--uikit-brand-soft); } .pj-badge.wait { color: var(--uikit-text-muted); background: var(--uikit-border); } .pj-badge.end { color: var(--uikit-danger); background: rgb(239 68 68 / 12%); } .monitor-overview { background: var(--uikit-bg-elevated); border-color: var(--uikit-border); border-radius: var(--uikit-radius-md); } .monitor-overview::after { display: none; } .monitor-overview-value, .monitor-section-title, .monitor-group-title { color: var(--uikit-text); } .monitor-status-pill.pass, .monitor-group-badge.pass { color: var(--uikit-success); background: rgb(16 185 129 / 12%); border-color: rgb(16 185 129 / 25%); } .monitor-status-pill.warn, .monitor-group-badge.warn { color: var(--uikit-danger); background: rgb(239 68 68 / 11%); border-color: rgb(239 68 68 / 25%); } .monitor-progress-track { background: var(--uikit-border); } .monitor-progress-fill { background: linear-gradient(90deg, var(--uikit-brand-2), var(--uikit-brand)); box-shadow: none; } .monitor-progress-meta, .monitor-status { color: var(--uikit-text-muted); } .monitor-groups { background: var(--uikit-bg-elevated); } .monitor-group-header { color: var(--uikit-text); background: var(--uikit-bg-elevated); } .monitor-group-header:hover { color: var(--uikit-brand-text); background: var(--uikit-brand-soft); } .monitor-group-credit { color: var(--uikit-success); } .monitor-course-table { color: var(--uikit-text); } .monitor-course-table th { color: var(--uikit-text-muted); background: var(--uikit-bg-muted); border-color: var(--uikit-border); } .monitor-course-table td { border-color: var(--uikit-border); } .monitor-course-table tbody tr:hover td { background: var(--uikit-brand-soft); } .monitor-course-row.pending td { color: var(--uikit-text-faint); background: var(--uikit-bg-muted); } .monitor-warning { color: var(--uikit-danger); background: rgb(239 68 68 / 9%); border-color: rgb(239 68 68 / 24%); } .monitor-warning.is-ok { color: var(--uikit-success); background: rgb(16 185 129 / 9%); border-color: rgb(16 185 129 / 24%); } #update-container, #update-container-modal, .update-card, .update-card p, .update-card h3, .update-notes, .update-notes *, #update-container-modal * { text-align: left !important; } .update-card, .update-notes { color: var(--uikit-text); } .update-card-intro, .update-notes ul { color: var(--uikit-text-muted); } .update-notes { background: var(--uikit-bg-muted); border-color: var(--uikit-border); border-radius: var(--uikit-radius-md); } #whut-toast-stack { top: 18px; right: auto; bottom: auto; left: 50%; width: min(430px, calc(100vw - 24px)); display: grid; justify-items: center; gap: 8px; transform: translateX(-50%); z-index: var(--uikit-z-toast); } .whut-helper-toast { width: auto; max-width: 100%; min-height: 38px; padding: 9px 34px 9px 13px; display: flex; align-items: center; gap: 8px; color: #fff; background: linear-gradient(135deg, rgb(var(--uikit-brand-rgb) / 96%), var(--uikit-brand)); border: 0; border-radius: var(--uikit-radius-pill); box-shadow: 0 10px 28px rgb(var(--uikit-brand-rgb) / 22%); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); animation: uikitToastIn var(--uikit-duration-normal) var(--uikit-ease-emphasized) both; } .whut-helper-toast::before, .whut-helper-toast-progress { display: none; } .whut-helper-toast.success { background: rgb(5 150 105 / 95%); } .whut-helper-toast.warning { background: rgb(217 119 6 / 95%); } .whut-helper-toast.error { background: rgb(220 38 38 / 95%); } .whut-helper-toast-icon { width: auto; height: auto; padding: 0; color: inherit; background: transparent !important; } .whut-helper-toast-title { color: #fff; font-size: 12px; font-weight: 700; } .whut-helper-toast-text { color: rgb(255 255 255 / 82%); font-size: 10px; } .whut-helper-toast-close { top: 50%; right: 6px; color: rgb(255 255 255 / 72%); 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) ease 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); 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); font-size: 10px; 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: 12px; line-height: 1.65; } .uikit-dialog__content > p { margin: 0; white-space: pre-wrap; } .uikit-dialog__validation { margin-top: 9px; padding: 7px 9px; color: var(--uikit-danger); background: rgb(239 68 68 / 9%); 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: whutSpin .75s linear infinite; } :host([data-theme='dark']) .header-logo, :host([data-theme='dark']) .semester-menu, :host([data-theme='dark']) .course-item, :host([data-theme='dark']) .export-card, :host([data-theme='dark']) .gpa-card, :host([data-theme='dark']) .monitor-overview, :host([data-theme='dark']) .monitor-groups, :host([data-theme='dark']) .eval-config-card, :host([data-theme='dark']) .eval-summary, :host([data-theme='dark']) #pj-list-area > .pj-group { color: var(--uikit-text); background: var(--uikit-bg-elevated); } @keyframes uikitPanelIn { from { opacity: 0; transform: translateY(12px) scale(.96); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes uikitViewIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } @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); } } `, responsiveAndMotion: ` @media (max-width: 720px) { #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: 11px; } .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: 560px) { .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-grid, .update-version-grid { grid-template-columns: 1fr; } .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%; } } @media (max-width: 390px) { .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; } #pj-list-area { padding: 12px 10px !important; } .eval-summary-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (prefers-reduced-motion: reduce) { #whut-helper-panel *, #whut-fab, #whut-toast-stack *, .uikit-dialog-backdrop * { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } } @keyframes whutPanelIn { from { opacity: 0; transform: translateY(10px) scale(.975); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes whutViewIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } } @keyframes whutDetailIn { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: translateY(0); } } @keyframes whutMenuIn { from { opacity: 0; transform: translateY(-6px) scale(.985); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes whutSpin { to { transform: rotate(360deg); } } @keyframes whutToastIn { from { opacity: 0; transform: translateX(-16px) scale(.98); } to { opacity: 1; transform: translateX(0) scale(1); } } @keyframes whutToastOut { to { opacity: 0; transform: translateX(-18px) scale(.98); } } @keyframes whutToastProgress { from { transform: scaleX(1); } to { transform: scaleX(0); } } ` }); const APP_STYLE = Object.values(STYLE_MODULES).join('\n'); // ================= UIKit Core:单一 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, setTheme, cycleTheme, getTheme: () => themeMode, getResolvedTheme: () => resolveTheme(), getById: id => (ensure(), shadow.getElementById(id)), query: selector => (ensure(), shadow.querySelector(selector)), queryAll: selector => (ensure(), Array.from(shadow.querySelectorAll(selector))), get shadow() { ensure(); return shadow; }, get root() { ensure(); return root; }, get layers() { ensure(); return layers; }, destroy }; return api; })(); const uiGet = id => UIKitRuntime.getById(id); const uiQuery = selector => UIKitRuntime.query(selector); const uiQueryAll = selector => UIKitRuntime.queryAll(selector); // ================= UIKit Dialog:焦点锁定、恢复与异步校验 ================= const AppDialog = (() => { let current = null; let queue = Promise.resolve(); 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; 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.textContent = '×'; 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) => { const button = document.createElement('button'); button.type = 'button'; button.className = `btn-modern ${className}`.trim(); button.textContent = label; return button; }; 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 }; }); 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); AppDialog._showValidationMessage = showValidation; return resultPromise; } const api = { 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) { AppDialog._showValidationMessage?.(message); }, getCurrentDialog: () => current?.dialog || null, _showValidationMessage: null }; return api; })(); // ================= 🧠 统一状态与全局接口 ================= function showHelperToast({ icon = 'info', title = '', text = '', timer = 3000 } = {}) { const type = ['success', 'error', 'warning', 'info'].includes(icon) ? icon : 'info'; const iconMap = { success: '✅', error: '❌', warning: '⚠️', 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 { element: duplicate, close: () => duplicate.querySelector('.whut-helper-toast-close')?.click(), update: () => {} }; } 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; toast.style.setProperty('--whut-toast-duration', `${duration}ms`); const iconNode = document.createElement('span'); iconNode.className = 'whut-helper-toast-icon'; iconNode.setAttribute('aria-hidden', 'true'); iconNode.textContent = 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); let textNode = null; if (title && text) { 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.textContent = '×'; const progress = document.createElement('span'); progress.className = 'whut-helper-toast-progress'; progress.setAttribute('aria-hidden', 'true'); toast.append(iconNode, body, closeButton, progress); 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(); return { element: toast, close: dismiss, update(message, nextType = type) { titleNode.textContent = String(message || ''); toast.className = `whut-helper-toast ${['success', 'error', 'warning', 'info'].includes(nextType) ? nextType : type}`; } }; } const Toast = { fire: (opts = {}) => showHelperToast(opts) }; const UIKit = Object.freeze({ ensure: () => UIKitRuntime.ensure(APP_STYLE), mount: (layer, node) => UIKitRuntime.mount(layer, node), query: selector => UIKitRuntime.query(selector), toast: options => showHelperToast(options), dialog: AppDialog, setTheme: mode => UIKitRuntime.setTheme(mode), cycleTheme: () => UIKitRuntime.cycleTheme(), getTheme: () => UIKitRuntime.getTheme(), destroy: () => UIKitRuntime.destroy() }); const EVAL_DEFAULT_CONFIG = { comment: "老师讲课重点突出,条理清晰,内容丰富,受益匪浅。", delay: 1500, randomScore: false }; const SCRIPT_UPDATE = { infoURL: 'https://home.haoli.site/assets/scripts/whut-jw-helper/whut-jw-helper.latest.json', downloadURL: 'https://home.haoli.site/assets/scripts/whut-jw-helper/whut-jw-helper.user.js', projectURL: 'https://scriptcat.org/zh-CN/script-show-page/6588' }; const SCRIPT_VERSION = '3.8.4'; const readStorage = (key, fallback = null) => { try { const value = localStorage.getItem(key); return value === null ? fallback : value; } catch (e) { return fallback; } }; let state = { userId: null, userName: null, dataMap: new Map(), rawData: [], filteredData: [], filters: { search: '', semesters: new Set(), semesterAll: true, type: 'all', sort: 'semester_desc' }, semesters: new Set(), types: new Set(), isFetching: false, isPrintLoaded: false, 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: [], raw: null, scoreFallbacks: [], openGroups: new Set(), openGroupsTouched: false }, transcript: { isLoading: false, isLoaded: false, promise: null, report: null, records: [], rows: [], error: '', lastAttempt: 0 }, 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: readStorage('whut_pj_comment', EVAL_DEFAULT_CONFIG.comment) || EVAL_DEFAULT_CONFIG.comment, delay: parseInt(readStorage('whut_pj_delay', ''), 10) || EVAL_DEFAULT_CONFIG.delay, randomScore: readStorage('whut_pj_random', String(EVAL_DEFAULT_CONFIG.randomScore)) !== 'false' } } }; const debounce = (func, wait) => { let timeout; return function(...args) { clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, args), wait); }; }; const escapeHTML = (val) => String(val ?? '').replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); const safeStorage = Object.freeze({ get(key, fallback = null) { return readStorage(key, 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(readStorage(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 UI = Object.freeze({ state(type, title, detail = '', compact = false) { const normalized = ['loading', 'empty', 'error', 'warning', 'success'].includes(type) ? type : 'empty'; const icons = { empty: '◇', error: '!', warning: '!', success: '✓' }; const icon = normalized === 'loading' ? '' : escapeHTML(icons[normalized] || '◇'); const role = normalized === 'error' ? 'alert' : 'status'; return `
${escapeHTML(title)} ${detail ? `${escapeHTML(detail)}` : ''}
`; }, setButtonBusy(button, busy, busyLabel = '处理中...') { if (!button) return; if (busy) { if (!button.dataset.idleHtml) button.dataset.idleHtml = button.innerHTML; button.disabled = true; button.setAttribute('aria-busy', 'true'); button.innerHTML = `${escapeHTML(busyLabel)}`; } else { button.disabled = false; button.removeAttribute('aria-busy'); if (button.dataset.idleHtml) button.innerHTML = button.dataset.idleHtml; delete button.dataset.idleHtml; } } }); const toNum = (val, fallback = 0) => { const n = parseFloat(val); return Number.isFinite(n) ? n : fallback; }; const compactParams = (obj) => Object.fromEntries(Object.entries(obj || {}).filter(([, v]) => v !== undefined && v !== null && v !== '')); const API = { req: (opts) => new Promise((resolve, reject) => GM_xmlhttpRequest({ ...opts, onload: resolve, onerror: reject })), postJSON: async (url, dataStr, options = {}) => { const timeout = Math.max(1000, Number(options.timeout) || 15000); const controller = typeof AbortController === 'function' ? new AbortController() : null; const timer = controller ? setTimeout(() => controller.abort(), timeout) : 0; try { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest' }, body: dataStr, signal: controller?.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const text = await res.text(); if (!text || /^\s* { const body = data instanceof URLSearchParams ? data : new URLSearchParams(compactParams(data)); const res = await API.req({ method: 'POST', url, headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest' }, data: body.toString() }); return JSON.parse(res.responseText); }, getUserInfo: async () => { if (state.userId && state.userName) return { userId: state.userId, userName: state.userName }; const res = await API.req({ method: 'GET', url: 'https://jwxt.whut.edu.cn/jwapp/sys/homeapp/api/home/currentUser.do' }); const data = JSON.parse(res.responseText); if (data.code === "0" && data.datas) { state.userId = data.datas.userId; state.userName = data.datas.userName; return data.datas; } throw new Error("未能获取身份信息,请确认已登录教务系统"); } }; // ================= 统一 Select:替代系统原生弹出层,完整支持主题、键盘与浮层定位 ================= const CustomSelect = (() => { const instances = new Map(); let active = null; let sequence = 0; let globalsBound = false; 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 sync(instance) { if (!instance) return; const option = instance.select.options[instance.select.selectedIndex] || instance.select.options[0]; instance.value.textContent = option?.textContent?.trim() || instance.select.getAttribute('placeholder') || '请选择'; instance.trigger.disabled = instance.select.disabled; instance.trigger.setAttribute('aria-disabled', String(instance.select.disabled)); Array.from(instance.menu.querySelectorAll('.ui-select-option')).forEach(button => { const selected = Number(button.dataset.index) === instance.select.selectedIndex; button.setAttribute('aria-selected', String(selected)); if (selected && !instance.menu.contains(UIKitRuntime.shadow.activeElement)) markActive(instance, button); }); } function buildMenu(instance) { const fragment = document.createDocumentFragment(); 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 => Number(button.dataset.index) === instance.select.selectedIndex); 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.select.selectedIndex !== index; instance.select.selectedIndex = index; sync(instance); 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') || '选项'); 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, 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', event => { if (!active) return; const path = event.composedPath(); if (!path.includes(active.wrapper) && !path.includes(active.menu)) close(active); }, true); window.addEventListener('resize', debounce(() => active && position(active), 60)); window.addEventListener('scroll', event => { if (!active || event.composedPath?.().includes(active.menu)) return; position(active); }, true); } 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; 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 }); }, hasOpen: () => !!active }); })(); // ================= 🚀 初始化 UI 框架 ================= 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教务系统小助手 v${SCRIPT_VERSION}毫厘制作
⏳ 点击加载个人学业监测数据...
${UI.state('loading', '正在加载学业监测', '培养方案与预警信息将自动同步')}
`; UIKit.mount('panel', panel); const isFabDragging = makeDraggable(fab, fab, 'whut_fab_pos'); makeDraggable(panel, uiGet('panel-drag-handle'), 'whut_panel_pos'); clampFabToViewport(fab); CustomSelect.initAll(panel); bindUIEvents(fab, panel, isFabDragging); if (state.rawData.length) { updateFilterUI(); applyFilters(); } else proactiveFetch(); MonitorModule.init(); setTimeout(() => 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); setTimeout(() => panel.focus({ preventScroll: true }), 60); } function closePanelToFab(fab, panel) { CustomSelect.closeAll(); panel.querySelector('#semester-filter')?._closeSemesterMenu?.(); 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; handle.addEventListener('pointerdown', (e) => { if (e.button !== undefined && e.button !== 0) return; if (e.target.closest('.header-close') || e.target.closest('.helper-tabs') || e.target.tagName.toLowerCase() === 'button' || e.target.tagName.toLowerCase() === 'textarea' || e.target.tagName.toLowerCase() === 'input' || e.target.tagName.toLowerCase() === 'select') return; 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); }); 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); } setTimeout(() => isDragging = false, 50); } return () => isDragging; } 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) { if (targetId === 'view-print' && !state.isPrintLoaded) PrintModule.init(); if (targetId === 'view-gpa') GPAModule.init(); if (targetId === 'view-monitor') MonitorModule.init(); if (targetId === 'view-eval') EvalModule.init(); } function activateHelperTab(tab, { focus = false } = {}) { if (!tab) return; CustomSelect.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 bindUIEvents(fab, panel, isFabDragging) { fab.addEventListener('click', () => { if (!isFabDragging()) openPanelFromFab(fab, panel); }); const syncThemeButton = () => { const button = uiGet('h-theme'); if (!button) return; const mode = UIKit.getTheme(); const config = { auto: { icon: '◐', label: '跟随系统' }, light: { icon: '☀', label: '浅色' }, dark: { icon: '☾', label: '深色' } }[mode]; button.querySelector('.header-theme-icon').textContent = config.icon; button.title = `主题:${config.label};点击切换`; button.setAttribute('aria-label', `切换主题,当前${config.label}`); }; 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-update').addEventListener('click', () => UpdateModule.openPanel()); uiGet('h-close').addEventListener('click', () => closePanelToFab(fab, panel)); panel.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; const semester = panel.querySelector('#semester-filter.open'); if (semester) { semester._closeSemesterMenu?.(); return; } closePanelToFab(fab, panel); }); window.addEventListener('resize', debounce(() => { if (panel.classList.contains('open')) { clampPanelToViewport(panel); savePanelState(panel); } else { fab.style.display = 'flex'; clampFabToViewport(fab); saveFabState(fab); } }, 120)); uiQueryAll('.helper-tab').forEach(tab => { tab.addEventListener('click', (e) => activateHelperTab(e.currentTarget)); tab.addEventListener('keydown', handleHelperTabKeydown); }); const debouncedFilter = debounce(() => applyFilters(), 300); uiGet('h-search').addEventListener('input', (e) => { state.filters.search = e.target.value.trim(); debouncedFilter(); }); uiGet('h-type').addEventListener('change', (e) => { state.filters.type = e.target.value.trim(); applyFilters(); }); uiGet('h-sort').addEventListener('change', (e) => { state.filters.sort = e.target.value.trim(); applyFilters(); }); bindSemesterFilterEvents(); uiGet('h-reset').addEventListener('click', () => { state.filters = { search: '', semesters: new Set(state.semesters), semesterAll: true, type: 'all', sort: 'semester_desc' }; uiGet('h-search').value = ''; uiGet('h-type').value = 'all'; uiGet('h-sort').value = 'semester_desc'; CustomSelect.sync('h-type'); CustomSelect.sync('h-sort'); updateFilterUI(); applyFilters(); }); uiGet('btn-exp-cur-xls').addEventListener('click', ContextualExport.toExcel); uiGet('btn-exp-cur-img').addEventListener('click', ContextualExport.toImage); 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); uiGet('btn-monitor-refresh').addEventListener('click', MonitorModule.refresh); uiGet('btn-monitor-export').addEventListener('click', MonitorModule.exportReport); uiGet('print-container').addEventListener('click', (e) => { const btn = e.target.closest('.btn-dl-pdf'); if (btn) PrintModule.downloadPDF(btn.dataset.wid, btn.dataset.cdmc); }); 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 }); }); }); // 评教配置面板 const fillEvalConfigForm = () => { uiGet('pj-cfg-comment').value = state.eval.config.comment; CustomSelect.setValue('pj-cfg-random', state.eval.config.randomScore ? 'true' : 'false'); uiGet('pj-cfg-delay').value = state.eval.config.delay; }; 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 bindSemesterFilterEvents() { const wrap = uiGet('semester-filter'); const toggle = uiGet('h-semester-toggle'); const menu = uiGet('h-semester-menu'); const positionMenu = () => { if (!menu.classList.contains('is-portaled')) return; const rect = toggle.getBoundingClientRect(); const margin = 12; const width = Math.min(Math.max(rect.width, 220), window.innerWidth - margin * 2); const below = Math.max(120, window.innerHeight - rect.bottom - margin); const above = Math.max(120, rect.top - margin); const openAbove = below < 190 && above > below; const maxHeight = Math.min(290, openAbove ? above : below); const left = clampValue(rect.left, margin, window.innerWidth - width - margin); menu.style.width = `${Math.round(width)}px`; menu.style.left = `${Math.round(left)}px`; menu.style.right = 'auto'; menu.style.maxHeight = `${Math.round(maxHeight)}px`; menu.style.top = openAbove ? 'auto' : `${Math.round(rect.bottom + 6)}px`; menu.style.bottom = openAbove ? `${Math.round(window.innerHeight - rect.top + 6)}px` : 'auto'; }; const setOpen = (open) => { wrap.classList.toggle('open', open); toggle.setAttribute('aria-expanded', String(open)); if (open) { menu.classList.add('is-portaled'); UIKit.mount('menu', menu); positionMenu(); requestAnimationFrame(() => menu.querySelector('button, input')?.focus({ preventScroll: true })); } else { menu.classList.remove('is-portaled'); menu.removeAttribute('style'); wrap.appendChild(menu); } }; wrap._closeSemesterMenu = () => setOpen(false); toggle.addEventListener('click', (e) => { e.stopPropagation(); setOpen(!wrap.classList.contains('open')); }); menu.addEventListener('click', (e) => e.stopPropagation()); menu.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; e.preventDefault(); setOpen(false); toggle.focus({ preventScroll: true }); }); document.addEventListener('click', (e) => { if (!e.composedPath().includes(wrap)) setOpen(false); }); window.addEventListener('resize', debounce(positionMenu, 80)); menu.addEventListener('change', (e) => { const target = e.target; if (!target.matches('input[type="checkbox"]')) return; const allSemesters = Array.from(state.semesters); if (target.dataset.role === 'all') { state.filters.semesters = target.checked ? new Set(allSemesters) : new Set(); } else { const value = target.value; if (target.checked) state.filters.semesters.add(value); else state.filters.semesters.delete(value); } state.filters.semesterAll = state.filters.semesters.size === allSemesters.length && allSemesters.length > 0; renderSemesterMenu(); applyFilters(); }); menu.addEventListener('click', (e) => { const action = e.target.dataset.semAction; if (!action) return; const allSemesters = Array.from(state.semesters); if (action === 'all') state.filters.semesters = new Set(allSemesters); if (action === 'invert') state.filters.semesters = new Set(allSemesters.filter(s => !state.filters.semesters.has(s))); if (action === 'clear') state.filters.semesters = new Set(); state.filters.semesterAll = state.filters.semesters.size === allSemesters.length && allSemesters.length > 0; renderSemesterMenu(); applyFilters(); }); } // ================= 1. 核心数据引擎与分析 ================= const originalOpen = XMLHttpRequest.prototype.open; const originalSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function(method, url) { this._url = url; return originalOpen.apply(this, arguments); }; XMLHttpRequest.prototype.send = function(body) { this.addEventListener('load', function() { if (this._url && this._url.includes('xscjcx.do') && this.responseText && !state.isFetching) { try { const res = JSON.parse(this.responseText); if (res.datas && res.datas.xscjcx) processDataPart(res.datas.xscjcx.rows || [], res.datas.xscjcx.totalSize || 0); } catch (e) {} } }); return originalSend.apply(this, arguments); }; async function proactiveFetch() { if (state.isFetching || state.rawData.length > 0) return; state.isFetching = true; const listEl = uiGet('h-list'); try { const res = await fetch('/jwapp/sys/cjcx/modules/cjcx/xscjcx.do', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest' }, body: new URLSearchParams({ 'querySetting': JSON.stringify([{ name: "SFYX", value: "1", linkOpt: "AND", builder: "m_value_equal" }]), '*json': '1', 'pageSize': '1000', 'pageNumber': '1', 'order': '-XNXQDM,-KCH,-KXH' }) }).then(r => r.json()); if (res.datas?.xscjcx?.rows) processDataPart(res.datas.xscjcx.rows, res.datas.xscjcx.totalSize || 0); else if(listEl) listEl.innerHTML = UI.state('warning', '成绩数据暂未返回', '请确认登录状态,或进入教务系统“成绩查询”页面后重试'); } catch(e) { if(listEl) listEl.innerHTML = UI.state('error', '成绩数据加载异常', '检查网络状态后重新打开成绩分析模块'); } finally { state.isFetching = false; } } function renderSemesterMenu() { const menu = uiGet('h-semester-menu'); const label = uiGet('h-semester-label'); if (!menu || !label) return; const semesters = Array.from(state.semesters); const selected = state.filters.semesters; const allChecked = semesters.length > 0 && selected.size === semesters.length; const noneChecked = selected.size === 0; const checkedAttr = (flag) => flag ? 'checked' : ''; menu.innerHTML = `
${semesters.map(v => ``).join('')} `; const allBox = menu.querySelector('input[data-role="all"]'); if (allBox) allBox.indeterminate = !allChecked && !noneChecked; state.filters.semesterAll = allChecked; label.textContent = allChecked ? '全部学期' : (noneChecked ? '未选择学期' : `已选 ${selected.size}/${semesters.length} 学期`); } function isHiddenScoreText(item) { const text = typeof item === 'object' ? item?._scoreText : item; return String(text ?? '').trim() === '未评教'; } function sameText(a, b) { const sa = String(a ?? '').trim(); const sb = String(b ?? '').trim(); return !!sa && !!sb && sa === sb; } function hasScoreValue(val) { return val !== undefined && val !== null && String(val).trim() !== ''; } function isNumericScore(val) { return /^-?\d+(\.\d+)?$/.test(String(val ?? '').trim()); } function pickScoreField(courseObj, keys) { for (const key of keys) { const val = courseObj?.[key]; if (hasScoreValue(val)) return String(val).trim(); } return ''; } function cleanCourseScore(courseObj) { return pickScoreField(courseObj, ['XSZCJ', 'ZCJ', 'CJ', 'scoreView', 'score', 'scoreValue', 'point', 'grade', 'gradeText']) || '-'; } function calcWhutGPAFromScore(score) { const scoreNum = parseFloat(score); if (!Number.isFinite(scoreNum)) return null; if (scoreNum < 60) return 0; return Math.min(5, (scoreNum - 50) / 10); } function formatCreditValue(val) { const n = parseFloat(val); if (!Number.isFinite(n)) return '-'; return n.toFixed(2).replace(/\.?0+$/, ''); } function gradeTextToGPA(text) { const s = String(text ?? '').trim(); if (!s) return null; if (/不及格|不通过|不合格|Fail/i.test(s)) return 0; if (/优秀|Excellent/i.test(s)) return 4.5; if (/良好|Good/i.test(s)) return 3.5; if (/中等|Average/i.test(s)) return 2.5; if (/及格/i.test(s)) return 1.5; if (/通过|合格|Pass/i.test(s)) return 3; return null; } function pickCourseGPA(courseObj, scoreNum, scoreText) { const rawGPA = toNum(courseObj?.XFJD, NaN); if (Number.isFinite(rawGPA) && rawGPA >= 0) return rawGPA; const scoreGPA = calcWhutGPAFromScore(scoreNum); if (scoreGPA !== null) return scoreGPA; return gradeTextToGPA([scoreText, courseObj?.XSZCJMC, courseObj?.DJCJ_DISPLAY].filter(Boolean).join(' ')); } function isPendingScoreForGPA(course) { 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; if (Number.isFinite(course?._score)) return true; return !isHiddenScoreText(scoreText); } 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})` : gradeText; } if (cleanScore !== '-') { if (isHiddenScoreText(cleanScore) && hasScoreValue(numericScore)) return numericScore; return !isNumericScore(cleanScore) && hasScoreValue(numericScore) && isNumericScore(numericScore) ? `${cleanScore}(${numericScore})` : cleanScore; } if (isHiddenScoreText(gradeText) && hasScoreValue(numericScore)) return numericScore; if (hasScoreValue(gradeText)) return gradeText; if (hasScoreValue(courseObj?.ZCJ)) return String(courseObj.ZCJ).trim(); return '-'; } function pickAnyField(obj, keys) { for (const key of keys) { const val = obj?.[key]; if (val !== undefined && val !== null && val !== '') return val; } return ''; } function monitorChildArrays(node) { return ['children', 'CHILDREN', 'childList', 'childNodes', 'nodes', 'items', 'data', 'rows', 'list', 'checkCourseVOS', 'courseList', 'courses', 'courseVOS'] .flatMap(key => Array.isArray(node?.[key]) ? [{ key, rows: node[key] }] : []); } function looksLikeMonitorCourseRow(row) { if (!row || typeof row !== 'object') return false; const hasCourseName = hasScoreValue(pickAnyField(row, ['KCH', 'KCM', 'KCDM', 'KCMC', 'KCBH', 'courseId', 'courseCode', 'courseNo', 'courseName', 'courseTitle'])); const hasCourseMetric = hasScoreValue(pickAnyField(row, ['XF', 'KCXF', 'credit', 'credits', 'courseCredit', 'score', 'scoreView', 'CJ', 'ZCJ', 'XSZCJ'])); const hasCategoryChildren = monitorChildArrays(row).some(({ rows }) => rows.some(child => child && typeof child === 'object' && !looksLikeMonitorCourseRowShallow(child))); return hasCourseName && hasCourseMetric && !hasCategoryChildren; } function looksLikeMonitorCourseRowShallow(row) { if (!row || typeof row !== 'object') return false; const hasCourseName = hasScoreValue(pickAnyField(row, ['KCH', 'KCM', 'KCDM', 'KCMC', 'KCBH', 'courseId', 'courseCode', 'courseNo', 'courseName', 'courseTitle'])); const hasCourseMetric = hasScoreValue(pickAnyField(row, ['XF', 'KCXF', 'credit', 'credits', 'courseCredit', 'score', 'scoreView', 'CJ', 'ZCJ', 'XSZCJ'])); return hasCourseName && hasCourseMetric; } function collectMonitorCourseRows(node, moduleName = '') { const rows = []; const visit = (node, moduleName = '') => { if (!node) return; if (Array.isArray(node)) { node.forEach(item => visit(item, moduleName)); return; } if (typeof node !== 'object') return; const currModule = node.name || node.MC || node.courseCategoryName || node.moduleName || moduleName; const arrays = monitorChildArrays(node); if (looksLikeMonitorCourseRow(node)) rows.push(normalizeMonitorCourse(node, currModule)); arrays.forEach(({ rows: childRows }) => childRows.forEach(child => visit(child, currModule))); if (!arrays.length && !looksLikeMonitorCourseRowShallow(node)) Object.values(node).forEach(val => { if (Array.isArray(val) || (val && typeof val === 'object')) visit(val, currModule); }); }; visit(node, moduleName); return rows; } function 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) && (r.scoreText !== undefined && r.scoreText !== null && r.scoreText !== '')); } function normalizeMonitorCourse(r, moduleName = '') { const isGradeRow = r && (r.KCH !== undefined || r.KCM !== undefined || r.CJ !== undefined || r.XF !== undefined); const statusText = String(r?.SFJG_DISPLAY || r?.ZT_DISPLAY || r?.statusName || '').trim(); const passed = isGradeRow ? (r.SFJG === '1' || r.ZT === '01' || /通过|合格|是/.test(statusText)) : !(r.passed === false || r.passed === 'false' || r.status === '02' || r.status === '0'); const courseId = pickAnyField(r, ['KCH', 'KCDM', 'KCBH', 'courseId', 'courseCode', 'courseNo', 'code']); const courseName = pickAnyField(r, ['KCM', 'KCMC', 'courseName', 'courseTitle', 'name', 'text']); const semester = pickAnyField(r, ['CJXNXQDM', 'XKXNXQDM', 'JHXNXQDM', 'XNXQDM', 'pointSchoolYearTermCode', 'courseSelectionSchoolYearTermCode', 'schoolYearTermCode', 'termCode']); const scoreText = cleanCourseScore(r); const score = pickNumericCourseScore(r) || (scoreText !== '-' ? scoreText : ''); return { courseId, courseName, semester, scoreText, score, credit: pickAnyField(r, ['XF', 'KCXF', 'credit', 'credits', 'courseCredit']), nature: pickAnyField(r, ['KCXZDM_DISPLAY', 'KCXZDM', 'courseNature', 'nature', 'categoryName']), passed, replacedCourseId: pickAnyField(r, ['TDKCH', 'replacedCourseId', 'replaceCourseId']), replacedCourseName: pickAnyField(r, ['TDKCM', 'replacedCourseName', 'replaceCourseName']), replacedCourseCredit: pickAnyField(r, ['TDKCXF', 'replacedCourseCredit', 'replaceCourseCredit']), moduleName, raw: r || {} }; } function findScoreFallback(course) { const fallbacks = state.monitor.scoreFallbacks || []; return fallbacks.find(r => sameText(r.courseId, course.KCH) && sameText(r.semester, course.XNXQDM)) || fallbacks.find(r => sameText(r.courseName, course.KCM) && sameText(r.semester, course.XNXQDM)) || fallbacks.find(r => sameText(r.courseId, course.KCH)) || fallbacks.find(r => sameText(r.courseName, course.KCM)); } function applyScoreFallbacks() { if (!state.rawData.length || !state.monitor.scoreFallbacks.length) return false; let changed = false; state.rawData.forEach(c => { if (!isHiddenScoreText(c)) return; const fb = findScoreFallback(c); if (!fb) return; const formattedScore = formatCourseScoreDisplay(fb.raw || fb); const scoreText = formattedScore !== '-' ? formattedScore : (fb.scoreText ?? fb.score); if (!hasScoreValue(scoreText) || scoreText === '-' || isHiddenScoreText(scoreText)) return; const scoreNum = parseFloat(fb.score ?? fb.scoreText); c._scoreText = String(scoreText); c.XSZCJMC = String(scoreText); if (Number.isFinite(scoreNum)) { c._score = scoreNum; c.ZCJ = scoreNum; const fixedGPA = pickCourseGPA(c, scoreNum, scoreText); if (fixedGPA !== null && (!Number.isFinite(c._gpa) || c._gpa < 0)) { c._gpa = fixedGPA; c.XFJD = fixedGPA; } } c._scoreFixedByMonitor = true; changed = true; }); if (changed) { applyFilters(); Toast.fire({ icon: 'success', title: '已从学业监测回填隐藏成绩' }); } return changed; } // ================= 官方成绩单 PDF 最后兜底 ================= // 固定顺序:成绩接口原值 -> 学业监测回填 -> 官方成绩单回填。 // 成绩单只接受“课程名 + 学期 + 学分”全部一致的高可信度记录,冲突或缺项继续保留“未评教”。 const TRANSCRIPT_GRADE_NUMERIC = Object.freeze({ '优秀': 95, '优': 95, '良好': 85, '良': 85, '中等': 75, '中': 75, '及格': 65, '不及格': 50 }); 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: `${score}(${numeric})`, numeric }; } const numeric = parseFloat(score); return { raw: score, display: score, numeric: Number.isFinite(numeric) ? numeric : null }; } const TranscriptScoreModule = { defaultReportWid: '4c89798c0b0f448c8af0004de860d918', workerURL: 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js', sleep: (ms) => new Promise(resolve => setTimeout(resolve, ms)), findReport: async () => { if (state.transcript.report?.wid) return state.transcript.report; let selected = null; try { const res = await API.req({ method: 'POST', url: 'https://jwxt.whut.edu.cn/jwapp/sys/zmdynxd/zmdy/queryAllZmdy.do', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest' } }); const json = JSON.parse(res.responseText || '{}'); 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) { console.warn('[WHUT Helper] 成绩单列表读取异常,使用默认成绩单模板:', error); } state.transcript.report = selected || { wid: TranscriptScoreModule.defaultReportWid, name: '中文成绩单', parentName: '' }; return state.transcript.report; }, fetchPdfBlob: async (wid) => { await API.req({ method: 'POST', url: 'https://jwxt.whut.edu.cn/jwapp/sys/zmdynxd/zmdy/printBefore.do', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest' }, data: new URLSearchParams({ wid }).toString() }); let lastError = '成绩单 PDF 尚未生成'; for (let attempt = 0; attempt < 4; attempt++) { if (attempt) await TranscriptScoreModule.sleep(500 * attempt); const res = await API.req({ method: 'GET', url: `https://jwxt.whut.edu.cn/jwapp/sys/zmdynxd/zmdy/printZm.do?&wid=${encodeURIComponent(wid)}&_=${Date.now()}`, headers: { Accept: 'application/pdf,*/*;q=0.8', 'Cache-Control': 'no-cache', Pragma: 'no-cache' }, responseType: 'blob' }); const raw = res.response; const blob = raw instanceof Blob ? raw : new Blob([raw || res.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|登录页面|
{ 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 TranscriptScoreModule.fetchPdfBlob(report.wid); const rows = await TranscriptScoreModule.extractRows(blob); const parsed = TRANSCRIPT_PARSER.parseRows(rows); state.transcript.rows = rows; state.transcript.records = parsed.records || []; state.transcript.error = ''; state.transcript.isLoaded = true; return state.transcript.records; })().catch(error => { state.transcript.error = error?.message || String(error); console.warn('[WHUT Helper] 官方成绩单兜底读取异常:', 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; const score = normalizeTranscriptScore(match.score); if (!score || isHiddenScoreText(score.raw)) return false; course._scoreText = score.display; course.XSZCJMC = score.display; if (Number.isFinite(score.numeric)) { course._score = score.numeric; course.ZCJ = score.numeric; } 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._transcriptMatch = { source: 'transcript', confidence: match.confidence, reason: match.reason, rawScore: score.raw, page: match.record?.page, columnIndex: match.record?.columnIndex }; return true; }, apply: async () => { const targets = state.rawData.filter(isHiddenScoreText); if (!targets.length) return 0; const records = await TranscriptScoreModule.loadRecords(); if (!records.length) return 0; let changed = 0; targets.forEach(course => { const match = TRANSCRIPT_PARSER.matchCourse(course, records); if (TranscriptScoreModule.patchCourse(course, match)) changed++; }); if (changed) { applyFilters(); Toast.fire({ icon: 'success', title: `已从官方成绩单回填 ${changed} 门成绩` }); } return changed; } }; let hiddenScoreBackfillPromise = null; function waitForMonitorLoad(timeout = 30000) { if (!state.monitor.isLoading) return Promise.resolve(); return new Promise(resolve => { const startedAt = Date.now(); const timer = setInterval(() => { if (!state.monitor.isLoading || Date.now() - startedAt >= timeout) { clearInterval(timer); resolve(); } }, 100); }); } function runHiddenScoreBackfill() { if (hiddenScoreBackfillPromise) return hiddenScoreBackfillPromise; hiddenScoreBackfillPromise = (async () => { // 始终先使用 3.6.10 原有的学业监测逻辑;只对剩余“未评教”读取成绩单。 await Promise.resolve(); if (!state.rawData.some(isHiddenScoreText)) 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(isHiddenScoreText)) return 0; return TranscriptScoreModule.apply(); })().catch(error => { console.warn('[WHUT Helper] 隐藏成绩回填异常:', 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) { if (!rows || rows.length === 0) return; rows.forEach(r => { if (r.SFYX !== '1') return; state.dataMap.set(`${r.XNXQDM}-${r.KCH}`, normalizeGradeCourseRow(r)); }); state.rawData = Array.from(state.dataMap.values()); state.rawData.forEach((item, index) => item._uid = index); state.semesters = new Set(state.rawData.map(r => r.XNXQDM).filter(Boolean).sort().reverse()); state.types = new Set(state.rawData.map(r => r.KCXZDM_DISPLAY).filter(Boolean).sort()); updateFilterUI(); applyFilters(); if (state.rawData.some(isHiddenScoreText)) setTimeout(() => MonitorModule.patchHiddenScores(), 0); } function updateFilterUI() { if (state.filters.semesterAll) state.filters.semesters = new Set(state.semesters); else state.filters.semesters = new Set(Array.from(state.filters.semesters).filter(v => state.semesters.has(v))); const typeEl = uiGet('h-type'); if (!typeEl) return; const buildOpts = (set, def) => `` + Array.from(set).map(v => ``).join(''); const currentType = state.filters.type || 'all'; typeEl.innerHTML = buildOpts(state.types, "全部性质"); typeEl.value = state.types.has(currentType) ? currentType : 'all'; state.filters.type = typeEl.value; CustomSelect.refresh(typeEl); renderSemesterMenu(); } function applyFilters() { const { search, semesters, type, sort } = state.filters; let res = state.rawData.filter(item => (!search || item._name.includes(search) || (item.KCH && item.KCH.includes(search))) && (semesters.size === 0 ? false : semesters.has(item._semester)) && (type === 'all' || item.KCXZDM_DISPLAY === type)); const gpaSortValue = (item, emptyValue) => Number.isFinite(item?._gpa) ? item._gpa : emptyValue; res.sort((a, b) => { switch(sort) { case 'gpa_desc': return gpaSortValue(b, -Infinity) - gpaSortValue(a, -Infinity); case 'gpa_asc': return gpaSortValue(a, Infinity) - gpaSortValue(b, Infinity); case 'credit_desc': return b._credit - a._credit; case 'credit_asc': return a._credit - b._credit; default: return a._semester !== b._semester ? b._semester.localeCompare(a._semester) : a._name.localeCompare(b._name, 'zh'); } }); state.filteredData = res; if (uiGet('h-list')) renderList(); if (uiGet('h-footer')) renderFooter(); } 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'; let scoreCls = 'score-none'; if (Number.isFinite(c._score)) { if (c._score >= 90) scoreCls = 'score-high'; else if (c._score < 60) scoreCls = 'score-low'; else scoreCls = 'score-med'; } const fixedTag = c._scoreFixedByMonitor ? '监测回填' : (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; let gpaCredTotal = 0, tGPA = 0; let gpaCredComp = 0, cGPA = 0; state.filteredData.forEach(c => { if (c.SFYX !== '1' || c._credit <= 0) return; tCred += c._credit; count++; }); getBestCoursesForGPA(state.filteredData).forEach(c => { gpaCredTotal += c._credit; tGPA += c._gpa * c._credit; if (c._isCompulsory) { gpaCredComp += c._credit; cGPA += c._gpa * c._credit; } }); footer.innerHTML = ` `; state.stats = { count, totalCredit: formatCreditValue(tCred), avgGPA: gpaCredTotal ? (tGPA/gpaCredTotal).toFixed(3) : '-', compulsoryGPA: gpaCredComp ? (cGPA/gpaCredComp).toFixed(3) : '-' }; } async function toggleDetail(c) { const p = 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, dt) => fetch("/jwapp/sys/cjcx/modules/cjcx/" + ep, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, body: new URLSearchParams(dt) }).then(r => r.json()); const [cRank, cStat, cDist] = await Promise.all([ req('jxbxspmcx.do', { ...basePms, XH: state.userId }), req('jxbcjtjcx.do', basePms), req('jxbcjfbcx.do', { ...basePms, '*order': '+DJDM' }) ]); const rankRows = cRank.datas?.jxbxspmcx?.rows || []; const r1 = rankRows.find(r => r.XH === state.userId) || rankRows[0] || {}; const s1 = (cStat.datas?.jxbcjtjcx?.rows || [])[0] || {}; const d1 = cDist.datas?.jxbcjfbcx?.rows || []; const pmText = r1.PM ? `${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 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'); }, toImage: async () => { if (!state.filteredData.length) return AppDialog.fire('提示', '没有数据可导出', 'info'); AppDialog.fire({ title: '生成中...', allowOutsideClick: false, didOpen: () => AppDialog.showLoading() }); const clone = uiGet('h-list').cloneNode(true); clone.style.cssText = `position:fixed; top:-9999px; left:-9999px; width:600px; height:auto; overflow:visible; background:white; padding:30px; font-family:sans-serif; box-sizing:border-box;`; let th = ``; state.filteredData.forEach(c => th += ``); th += `
学期课程学分成绩
${escapeHTML(c.XNXQDM)}${escapeHTML(c.KCM)}${escapeHTML(formatCreditValue(c._credit))}${escapeHTML(c._scoreText)}
`; clone.innerHTML = `

WHUT 成绩分析报告

总课程
${escapeHTML(state.stats.count)}
总学分
${escapeHTML(state.stats.totalCredit)}
必修绩点
${escapeHTML(state.stats.compulsoryGPA)}
全科绩点
${escapeHTML(state.stats.avgGPA)}
${th}`; UIKit.mount('panel', clone); try { const canvas = await html2canvas(clone, { scale: 2, useCORS: true }); canvas.toBlob(b => { const l = document.createElement('a'); l.href = URL.createObjectURL(b); l.download = `WHUT_成绩长图.png`; l.click(); AppDialog.close(); }); } catch (e) { AppDialog.close(); AppDialog.fire('错误', '图片生成失败', 'error'); } finally { clone.remove(); } } }; // ================= 3. GPA模块 ================= const GPAModule = { isLoaded: false, isLoading: false, cache: null, currentSemesters: [], init: async () => { if (GPAModule.isLoaded || GPAModule.isLoading) return; GPAModule.isLoading = true; const container = uiGet('gpa-content'); container.innerHTML = UI.state('loading', '正在加载综合 GPA', '系统将汇总全学程与各学期数据'); try { const userInfo = await API.getUserInfo(); const homeRes = await API.req({ method: 'GET', url: 'https://jwxt.whut.edu.cn/jwapp/sys/homeapp/api/home/currentUser.do' }); const currentSem = JSON.parse(homeRes.responseText).datas.welcomeInfo.xnxqdm; const defaultSems = GPAModule.calcSemesters(userInfo.userId, currentSem); const sems = GPAModule.getActiveSemesters(defaultSems); GPAModule.currentSemesters = sems; const overallReq = API.req({ method: 'POST', url: 'https://jwxt.whut.edu.cn/jwapp/sys/cjgl/modules/xsgpajs/cxxsqxcpjjd.do', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, data: new URLSearchParams({ 'querySetting': '[]', '*order': '-XZNJ,+YXDM,+ZYDM,+XH', pageSize: 20, pageNumber: 1 }).toString() }); const semReqs = sems.map(sem => API.req({ method: 'POST', url: 'https://jwxt.whut.edu.cn/jwapp/sys/cjgl/modules/xsgpajs/cxxsxqpjjd.do', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, data: new URLSearchParams({ querySetting: JSON.stringify([{ name: "XNXQDM", linkOpt: "AND", builderList: "cbl_m_List", builder: "m_value_equal", value: sem }]), '*order': '-XNXQDM,-XZNJ,+YXDM,+ZYDM,+XH', pageSize: 20, pageNumber: 1 }).toString() }).then(r => ({ sem, data: JSON.parse(r.responseText)?.datas?.cxxsxqpjjd?.rows?.[0] || null }))); const [overallRes, ...semResponses] = await Promise.all([overallReq, ...semReqs]); const overallData = JSON.parse(overallRes.responseText).datas?.cxxsqxcpjjd?.rows?.[0] || null; GPAModule.cache = { overall: overallData, semesters: semResponses }; GPAModule.render(overallData, semResponses, container); GPAModule.isLoaded = true; } catch (e) { container.innerHTML = UI.state('error', '综合 GPA 加载异常', e.message || '请稍后重试'); } finally { 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(); }, 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; }, isValidSemester: (sem) => /^\d{4}-\d{4}-[1-2]$/.test(String(sem || '').trim()), reload: async () => { GPAModule.isLoaded = false; GPAModule.cache = null; await GPAModule.init(); }, openSemesterManager: async () => { const userInfo = await API.getUserInfo(); const homeRes = await API.req({ method: 'GET', url: 'https://jwxt.whut.edu.cn/jwapp/sys/homeapp/api/home/currentUser.do' }); const currentSem = JSON.parse(homeRes.responseText).datas.welcomeInfo.xnxqdm; const defaultSems = GPAModule.calcSemesters(userInfo.userId, currentSem); const savedSems = GPAModule.getSavedSemesters(); const sems = (savedSems.length ? savedSems : (GPAModule.currentSemesters.length ? GPAModule.currentSemesters : defaultSems)); const renderRows = (list) => list.map(sem => `
`).join(''); const result = await 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: '已恢复自动学期' }); await GPAModule.reload(); return; } if (!result.isConfirmed) return; const confirm = await AppDialog.fire({ title: '保存自定义学期?', text: '保存后会重新加载 GPA 数据。', icon: 'question', showCancelButton: true, confirmButtonText: '保存并重载', cancelButtonText: '取消' }); if (!confirm.isConfirmed) return; safeStorage.set('whut_gpa_semesters', JSON.stringify(result.value)); Toast.fire({ icon: 'success', title: 'GPA 学期列表已保存' }); await GPAModule.reload(); }, render: (overall, semResponses, container) => { const value = val => escapeHTML(val ?? '-'); let html = overall ? `
🏆 全学程综合 GPA
平均学分绩点 ${value(overall.PJXFJD)}
班排 ${value(overall.PJXFJDBJPM)} / ${value(overall.BJZRS)}专排 ${value(overall.PJXFJDZYPM)} / ${value(overall.ZYZRS)}
必修课平均学分绩点 ${value(overall.BXKPJXFJD)}
班排 ${value(overall.BXKPJXFJDBJPM)} / ${value(overall.BJZRS)}专排 ${value(overall.BXKPJXFJDZYPM)} / ${value(overall.ZYZRS)}
` : `
${UI.state('empty', '暂无全学程 GPA 数据', '', true)}
`; html += '

📅 各学期明细

'; semResponses.forEach(res => { if (!res.data) { html += `
📍 ${value(res.sem)}暂无数据
`; return; } html += `
📍 ${value(res.sem)}
平均绩点 / 班排 / 专排
${value(res.data.PJXFJD)}No.${value(res.data.PJXFJDBJPM)} / No.${value(res.data.PJXFJDZYPM)}
必修绩点 / 班排 / 专排
${value(res.data.BXKPJXFJD)}No.${value(res.data.BXKPJXFJDBJPM)} / No.${value(res.data.BXKPJXFJDZYPM)}
`; }); container.innerHTML = html; }, exportToExcel: () => { if (!GPAModule.cache) return AppDialog.fire('提示', '请先等待 GPA 数据加载完成', 'warning'); let rawRows = []; if (GPAModule.cache.overall) rawRows.push({ ...GPAModule.cache.overall, XNXQDM: "全学程" }); GPAModule.cache.semesters.forEach(s => { if (s.data) rawRows.push({ ...s.data, XNXQDM: s.sem }); }); exportToBrowser(rawRows.map(row => Object.fromEntries(MAPPING_GPA_NEW.map(m => [m.label, row[m.key] ?? row[m.fallback] ?? '']))), `WHUT_综合GPA与学期排名_${new Date().getTime()}`, 'xlsx'); }, exportToImage: async () => { const container = uiGet('gpa-content'); if (!GPAModule.isLoaded || !container) return AppDialog.fire('提示', '请先等待数据加载', 'warning'); AppDialog.fire({ title: '生成中...', allowOutsideClick: false, didOpen: () => AppDialog.showLoading() }); let clone = null; try { clone = container.cloneNode(true); clone.style.cssText = `position:fixed; top:-9999px; left:-9999px; width:500px; height:auto; overflow:visible; background:${THEME.bg}; padding:20px; box-sizing:border-box;`; UIKit.mount('panel', clone); const canvas = await html2canvas(clone, { scale: 2, backgroundColor: THEME.bg, useCORS: true }); canvas.toBlob(b => { const l = document.createElement('a'); l.href = URL.createObjectURL(b); l.download = `WHUT_GPA面板报告.png`; l.click(); AppDialog.close(); }); } catch (e) { AppDialog.close(); AppDialog.fire('错误', '图片生成失败', 'error'); } finally { clone?.remove(); } } }; // ================= 4. 一键评教模块 (新融合) ================= 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.postJSON(url, data, { timeout, throwOnError: true }); 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 new Promise(resolve => setTimeout(resolve, 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) {} } 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'); UI.setButtonBusy(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; 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 || '未知错误'; 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'); UI.setButtonBusy(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'); UI.setButtonBusy(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) UI.setButtonBusy(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)}`; UI.setButtonBusy(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); } UI.setButtonBusy(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) { UI.setButtonBusy(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 ? `🚀 开始一键评教 ${selected}` : '🚀 开始一键评教'; 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'); UI.setButtonBusy(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 = '✓ 提交成功'; success++; } catch (e) { failed++; if (logEl) logEl.innerHTML = `✕ ${escapeHTML(e?.message || '提交失败')}`; } if (i < cbs.length - 1) await new Promise(resolve => setTimeout(resolve, state.eval.config.delay)); } } finally { state.eval.isRunning = false; UI.setButtonBusy(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 new Promise(resolve => setTimeout(resolve, 350)); await EvalModule.loadData({ force: true, includeCompleted: true, notify: false }); } AppDialog.fire({ icon: failed ? 'warning' : 'success', title: '评教任务结束', text: `成功提交 ${success} / ${cbs.length} 门课程${failed ? `,${failed} 门需要重试` : ''}${success ? ';列表已自动刷新' : ''}` }); } }; // ================= 5. 个人学业监测报告模块 ================= const MonitorModule = { init: async () => { if (state.monitor.isLoaded) { MonitorModule.render(); MonitorModule.setStatus(`✅ 数据已同步${state.monitor.overview.calculatedTime ? `:${escapeHTML(state.monitor.overview.calculatedTime)}` : ''}`); return; } if (state.monitor.isLoading) { state.monitor.renderAfterLoad = true; MonitorModule.setStatus('🔄 正在从服务器同步学业监测数据...'); return; } await MonitorModule.loadData(); }, setStatus: (msg) => { const el = 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) UI.setButtonBusy(refreshBtn, busy, text || '处理中...'); if (exportBtn) exportBtn.disabled = busy; }, request: async (url, params = {}, opts = {}) => { const res = await API.req({ method: 'POST', url, headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest' }, data: new URLSearchParams(compactParams(params)).toString(), responseType: opts.blob ? 'blob' : undefined }); if (res.status && res.status >= 400) throw new Error(`HTTP ${res.status}`); if (opts.blob) { if (!res.response) throw new Error('空文件流'); return res.response; } const text = res.responseText || ''; if (!text || /^\s* { for (const key of keys) if (obj && obj[key] !== undefined && obj[key] !== null && obj[key] !== '') return obj[key]; return ''; }, pickNum: (obj, keys, fallback = 0) => { for (const key of keys) { const n = parseFloat(obj?.[key]); if (Number.isFinite(n)) return n; } return fallback; }, unwrap: (res, key) => { const datas = res?.datas ?? res?.data ?? res; if (!datas) return {}; let node = datas[key] ?? datas; if (!node && typeof datas === 'object') node = Object.values(datas).find(v => v && (v.rows || v.data || v.fanbx || Array.isArray(v))); if (node?.rows?.length === 1) { const row = node.rows[0]; const structuralKeys = ['fanbx', 'FANBX', 'fanBx', 'fawbx', 'fawbxMap', 'children', 'CHILDREN', 'childList', 'moduleList', 'categoryList', 'courseCategoryList', 'checkCourseVOS', 'courseList', 'data']; if (row && typeof row === 'object' && structuralKeys.some(k => row[k] !== undefined)) node = row; } return node || {}; }, rowsOf: (node) => { if (Array.isArray(node)) return node; if (Array.isArray(node?.rows)) return node.rows; if (Array.isArray(node?.data)) return node.data; if (Array.isArray(node?.list)) return node.list; return node && typeof node === 'object' ? [node] : []; }, pickBynf: (...values) => { for (const val of values) { if (val === undefined || val === null || val === '') continue; const text = String(val).trim(); if (text === '*') return '*'; return text.match(/\d{4}/)?.[0] || text; } return ''; }, isNewPlanCode: (plan) => { const pyfadm = String(plan?.PYFADM || plan?.value || '').trim(); return !!pyfadm && !/^\d+$/.test(pyfadm); }, buildParams: (userInfo, basicRow = {}) => { const guessedBynf = MonitorModule.guessGraduationYear(state.userId || userInfo?.userId); const archiveBynf = MonitorModule.pickBynf( MonitorModule.pick(basicRow, ['BYNF', 'YJBYNF', 'YJBYRQ', 'BYYF']), MonitorModule.isNewPlanCode(basicRow) ? '*' : guessedBynf ); return compactParams({ XH: state.userId || userInfo?.userId, XSBH: state.userId || userInfo?.userId, PYFADM: MonitorModule.pick(basicRow, ['PYFADM', 'PYFADM_DISPLAY', 'PYFAH']), BYNF: archiveBynf, XZNJ: MonitorModule.pick(basicRow, ['XZNJ', 'NJDM', 'NJ']) }); }, guessGraduationYear: (userId) => { const id = String(userId || ''); const gradeStr = id.length === 13 ? id.substring(3, 5) : (id.length === 10 ? id.substring(2, 4) : ''); const startYear = parseInt(`20${gradeStr}`, 10); return Number.isFinite(startYear) ? String(startYear + 4) : ''; }, buildStudyParams: (userInfo, studentInfo = {}) => { const queryNode = MonitorModule.unwrap(studentInfo, 'queryXsjbxx'); const jbxx = queryNode.jbxx || {}; const plans = Array.isArray(queryNode.faArr) ? queryNode.faArr : []; const plan = plans.find(p => p.XDLXDM === '01') || plans[0] || {}; const bynf = MonitorModule.pickBynf( plan.BYNF, plan.bynf, MonitorModule.isNewPlanCode(plan) ? '*' : '', jbxx.BYNF, jbxx.YJBYNF, jbxx.YJBYRQ, MonitorModule.guessGraduationYear(state.userId || userInfo?.userId) ); return { student: jbxx, plan, basicRes: studentInfo, base: compactParams({ from: 'xyjctjapp', XH: state.userId || userInfo?.userId, PYFADM: plan.PYFADM || plan.value, BYNF: bynf, pageType: 2, hasByjSearch: false }), xy: compactParams({ fromPage: 'grxyjcbg', from: 'xyjctjapp', XH: state.userId || userInfo?.userId, PYFADM: plan.PYFADM || plan.value, BYNF: bynf, pageType: 2, hasByjSearch: false, SCLX: '04', XDLX: plan.XDLXDM || '01' }) }; }, getStudyContext: async (userInfo) => { if (state.monitor.studyParams?.base?.PYFADM) return state.monitor.studyParams; const userId = state.userId || userInfo?.userId; const seedCandidates = Array.from(new Set([MonitorModule.guessGraduationYear(userId), '*', ''])); let studyParams = null; let lastError = null; for (const seedBynf of seedCandidates) { const seedParams = compactParams({ from: 'xyjctjapp', XH: userId, BYNF: seedBynf, pageType: 2, hasByjSearch: false }); try { const basicRes = await MonitorModule.request('/jwapp/sys/byshapp/api/grbg/queryXsjbxx.do', seedParams); const nextParams = MonitorModule.buildStudyParams(userInfo, basicRes); if (nextParams.base.PYFADM) { studyParams = nextParams; break; } lastError = new Error('未获取到培养方案代码 PYFADM'); } catch (e) { lastError = e; } } if (!studyParams) throw lastError || new Error('未获取到培养方案代码 PYFADM'); state.monitor.studyParams = studyParams; state.monitor.student = studyParams.student; state.monitor.plan = studyParams.plan; return studyParams; }, withBynf: (studyParams, bynf) => ({ ...studyParams, base: compactParams({ ...studyParams.base, BYNF: bynf }), xy: compactParams({ ...studyParams.xy, BYNF: bynf }) }), studyParamVariants: (studyParams, userInfo) => { const currentBynf = studyParams?.xy?.BYNF ?? studyParams?.base?.BYNF ?? ''; const guessedBynf = MonitorModule.guessGraduationYear(state.userId || userInfo?.userId); return Array.from(new Set([currentBynf, '*', guessedBynf, ''])) .map(bynf => MonitorModule.withBynf(studyParams, bynf)); }, hasMonitorContent: (normalized) => { return !!( normalized?.categories?.length || normalized?.scoreFallbacks?.length || toNum(normalized?.overview?.required, 0) > 0 || toNum(normalized?.overview?.earned, 0) > 0 ); }, loadData: async ({ force = false, silent = false, render = true } = {}) => { if (state.monitor.isLoading) return; if (state.monitor.isLoaded && !force) { applyScoreFallbacks(); if (render) MonitorModule.render(); return; } state.monitor.isLoading = true; if (!silent) { MonitorModule.setStatus('🔄 正在从服务器同步学业监测数据...'); const content = 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 = { xyRes, normalized, studyParams: candidateParams }; if (MonitorModule.hasMonitorContent(normalized)) break; } catch (e) { lastError = e; } } if (!selected) throw lastError || new Error('学业监测数据请求失败'); const { xyRes, normalized, studyParams: selectedStudyParams } = selected; Object.assign(state.monitor, normalized, { isLoaded: true, raw: { xyRes, basicRes: selectedStudyParams.basicRes }, studyParams: selectedStudyParams, student: selectedStudyParams.student, plan: selectedStudyParams.plan }); applyScoreFallbacks(); if (render) MonitorModule.render(); MonitorModule.setStatus(`✅ 数据已同步${state.monitor.overview.calculatedTime ? `:${escapeHTML(state.monitor.overview.calculatedTime)}` : ''}`); } catch (e) { if (render) 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 ? `:${escapeHTML(state.monitor.overview.calculatedTime)}` : ''}`); } } }, fmtCredit: (val) => { return formatCreditValue(val); }, courseState: (course) => { const raw = course?.raw || {}; const statusText = String(raw.SFJG_DISPLAY || raw.ZT_DISPLAY || raw.statusName || '').trim(); const hasScore = hasScoreValue(course?.scoreText) && course.scoreText !== '-' || hasScoreValue(course?.score); if (/未修读|未获得|未选|待修/.test(statusText) && !hasScore) return 'pending'; if ((course?.passed === false || course?.passed === 'false') && !hasScore) return 'pending'; if (raw.SFJG === '0' || raw.ZT === '02' || raw.status === '02' || raw.status === '0') return 'fail'; if (/不及格|不通过|未通过|不合格|否|失败/.test(statusText)) return 'fail'; const scoreNum = parseFloat(course?.score ?? course?.scoreText); if (Number.isFinite(scoreNum) && scoreNum >= 0 && scoreNum < 60) return 'fail'; return 'pass'; }, coursePassed: (course) => { return MonitorModule.courseState(course) === 'pass'; }, categoryNodesOf: (node) => { if (!node) return []; if (Array.isArray(node)) return node.filter(item => item && typeof item === 'object' && !looksLikeMonitorCourseRow(item)); const keys = ['children', 'CHILDREN', 'childList', 'childNodes', 'nodes', 'items', 'moduleList', 'categoryList', 'courseCategoryList', 'kcmkList', 'treeData', 'data', 'rows', 'list']; const nodes = []; keys.forEach(key => { if (!Array.isArray(node?.[key])) return; node[key].forEach(item => { if (item && typeof item === 'object' && !looksLikeMonitorCourseRow(item)) nodes.push(item); }); }); return nodes; }, normalizeCategory: (node, index = 0, path = 'cat') => { const name = MonitorModule.pick(node, ['MC', 'FLMC', 'KCLBMC', 'KCMKMC', 'courseCategoryName', 'moduleName', 'name', 'text']) || '未命名模块'; const id = String(node?.id || `${path}-${index}`); const childNodes = MonitorModule.categoryNodesOf(node); const children = childNodes.map((child, idx) => MonitorModule.normalizeCategory(child, idx, id)); const directRows = []; monitorChildArrays(node).forEach(({ rows }) => { rows.forEach(row => { if (looksLikeMonitorCourseRow(row)) directRows.push(normalizeMonitorCourse(row, name)); }); }); const 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 }; }, isPassed: (row) => { const text = String(MonitorModule.pick(row, ['SFTG_DISPLAY', 'BYJ_DISPLAY', 'SFJG_DISPLAY', 'JG_DISPLAY', 'ZT_DISPLAY', 'statusName']) || '').trim(); const flag = MonitorModule.pick(row, ['SFTG', 'BYJ', 'SFJG', 'JG', 'passed', 'isPassed']); if (flag === false || flag === 'false' || flag === 0 || flag === '0') return false; if (/不及格|不通过|未通过|不合格|否|失败/.test(text)) return false; if (flag === true || flag === 'true' || flag === 1 || flag === '1') return true; if (/通过|合格|达标|符合|正常|是/.test(text)) return true; return true; }, normalize: (xyRes, basicRes) => { let xyNode = MonitorModule.unwrap(xyRes, 'queryXyzhbx'); if (Array.isArray(xyNode)) xyNode = { data: xyNode }; if (xyNode?.rows && !xyNode.data) xyNode.data = xyNode.rows; const fanbx = xyNode.fanbx || xyNode.FANBX || xyNode.fanBx || xyNode.fanbxMap || xyNode.fanbxTree || xyNode.programTree || xyNode.schemeTree || {}; let categoryNodes = MonitorModule.categoryNodesOf(fanbx); if (!categoryNodes.length) categoryNodes = MonitorModule.categoryNodesOf(xyNode); let categories = categoryNodes.map((node, index) => MonitorModule.normalizeCategory(node, index)); const fawbxRows = dedupeMonitorCourses([ ...collectMonitorCourseRows(xyNode.fawbx, '方案外课程'), ...collectMonitorCourseRows(xyNode.fawbxMap, '方案外课程') ]).filter(r => r.courseId || r.courseName); if (fawbxRows.length) { categories.push({ id: 'fawbx-group', name: '方案外/额外课程', required: 0, earned: fawbxRows.filter(r => MonitorModule.coursePassed(r)).reduce((sum, r) => sum + toNum(r.credit), 0), rows: fawbxRows, children: [], passed: true }); } const scoreFallbacks = normalizeScoreFallbackRows(xyNode); if (!categories.length && scoreFallbacks.length) { const grouped = new Map(); scoreFallbacks.forEach(course => { const key = course.moduleName || course.nature || '其他课程'; const curr = grouped.get(key) || { id: `fallback-${grouped.size}`, name: key, required: 0, earned: 0, rows: [], children: [], passed: true }; curr.rows.push(course); curr.earned += toNum(course.credit); curr.passed = curr.passed && MonitorModule.coursePassed(course); grouped.set(key, curr); }); categories = Array.from(grouped.values()); } const basicNode = MonitorModule.unwrap(basicRes, 'queryXsjbxx'); const required = MonitorModule.pickNum(fanbx, ['creditsRequired', 'YQXF', 'YQZXF', 'ZXF', 'ZDZXF', 'XFYQ', 'requiredCredit', 'requireCredit', 'creditRequire', 'BYXF', 'minCredit', 'minimumCredit', 'requiredCredits', 'planCredit', 'totalCreditRequired'], categories.reduce((sum, c) => sum + c.required, 0)); const earned = MonitorModule.pickNum(fanbx, ['creditsEarned', 'creditsSelection', 'YXXF', 'HDXF', 'YHXF', 'TGXF', 'WCXF', 'earnedCredit', 'earnedCredits', 'completedCredit', 'actualCredit', 'gotCredit', 'totalCreditEarned'], categories.reduce((sum, c) => sum + c.earned, 0)); const warnings = MonitorModule.buildWarnings(scoreFallbacks, xyNode, basicNode); if (!state.monitor.openGroupsTouched) { state.monitor.openGroups = new Set(); } return { overview: { required, earned, passed: (!required || earned + 1e-6 >= required) && warnings.every(w => w.type !== 'danger'), calculatedTime: MonitorModule.pick(xyNode, ['calculatedTime', 'CZSJ', 'GXSJ', 'JSSJ']) || '' }, categories, warnings, scoreFallbacks }; }, buildWarnings: (courses, xyNode, basicNode) => { const warnings = []; const seen = new Set(); courses.forEach(course => { const state = MonitorModule.courseState(course); if (state === 'pass' || state === 'pending') return; const key = `${course.courseId || course.courseName}-${course.semester || ''}`; if (seen.has(key)) return; seen.add(key); warnings.push({ type: 'danger', title: course.courseName || course.courseId || '未命名课程', text: `${course.semester || '-'} ${course.scoreText ?? course.score ?? '未通过'}` }); }); (Array.isArray(xyNode?.fjzbbx) ? xyNode.fjzbbx : []).forEach(item => { const targets = Array.isArray(item.targets) ? item.targets : []; const failedTargets = targets.filter(t => t?.passed === false || t?.passed === 'false'); if (!(item.passed === false || item.passed === 'false' || failedTargets.length)) return; const detail = (failedTargets.length ? failedTargets : targets).map(t => { const result = t?.result !== undefined && t?.result !== null ? `,当前值 ${t.result}` : ''; return `${t?.name || item.name || '附加指标'}${result}`; }).join(';'); warnings.push({ type: 'danger', title: item.name || '附加指标提醒', text: detail || '未达标' }); }); const disciplineKeys = ['CFXX', 'CFZT', 'CFQK', 'CFCJ', 'SFCF', 'SFYCF', 'XSCFQK', 'punishment', 'disciplineStatus']; [basicNode?.jbxx || {}, basicNode || {}].forEach(row => disciplineKeys.forEach(k => { const val = row?.[k]; if (val !== undefined && val !== null && val !== '' && !/^(0|否|无|正常|false)$/i.test(String(val))) { warnings.push({ type: 'danger', title: '处分状态提醒', text: `${k}: ${val}` }); } })); if (!warnings.length) warnings.push({ type: 'ok', title: '暂无预警', text: '未发现不及格课程或附加指标异常。' }); return warnings; }, renderCourseRows: (group) => { const rows = 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 = course.scoreText ?? course.score ?? '-'; return `
    ${escapeHTML(courseLabel)}
    ${pass ? '✓' : (state === 'pending' ? '-' : '×')} ${escapeHTML(scoreDisplay)} ${escapeHTML(MonitorModule.fmtCredit(course.credit))} ${escapeHTML(replacedLabel)} ${escapeHTML(MonitorModule.fmtCredit(course.replacedCourseCredit))} `; }).join(''); }, renderMonitorGroups: (categories) => { if (!categories.length) return '
    暂无培养方案模块数据
    '; return `
    ${categories.map((group, 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 ? '达标/可继续' : '需关注'}
    总学分进度:${MonitorModule.fmtCredit(overview.earned)} / ${overview.required ? MonitorModule.fmtCredit(overview.required) : '-'} 学分${pct}%
    📌 培养方案模块
    ${MonitorModule.renderMonitorGroups(categories || [])}
    🚨 预警中心
    ${warningsHtml} `; }, refresh: async () => { if (state.monitor.isRefreshing) return; state.monitor.isRefreshing = true; MonitorModule.setBusy(true, '⏳ 同步中...'); MonitorModule.setStatus('⏳ 正在触发服务器重新计算...'); try { const userInfo = await API.getUserInfo(); const studyParams = await MonitorModule.getStudyContext(userInfo); const res = await MonitorModule.request('/jwapp/sys/byshapp/api/grbg/refresh.do', studyParams.xy); const node = MonitorModule.unwrap(res, 'refresh'); const key = node?.ZXJDKEY || node?.refresh || res?.datas?.refresh || res?.ZXJDKEY; if (!key) throw new Error('未获取到同步任务标识 ZXJDKEY'); await MonitorModule.pollProcess(key); state.monitor.isLoaded = false; await MonitorModule.loadData({ force: true }); Toast.fire({ icon: 'success', title: '学业监测数据已同步' }); } catch (e) { 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 = setInterval(async () => { try { count++; const res = await MonitorModule.request('/jwapp/sys/jwpubapp/api/zxjd/queryProcess.do', { ZXJDKEY: key }); const node = MonitorModule.unwrap(res, 'queryProcess'); const data = MonitorModule.rowsOf(node)[0] || node || {}; MonitorModule.setStatus(`⏳ 后台计算中${data.ZXJD ? `:${String(data.ZXJD)}` : ''}`); if (data.ZXBZ === '1' || data.ZXBZ === 1 || data.finished === true || data.finished === 'true') { clearInterval(timer); resolve(); } else if (count > 80) { clearInterval(timer); reject(new Error('后台同步超时,请稍后重试')); } } catch (e) { clearInterval(timer); reject(e); } }, 1500); }), exportReport: async () => { try { MonitorModule.setBusy(true); MonitorModule.setStatus('📥 正在生成学业综合表现文件...'); const userInfo = await API.getUserInfo(); const studyParams = await MonitorModule.getStudyContext(userInfo); const blob = await MonitorModule.request('/jwapp/sys/byshapp/api/grbg/exportFanbxkcxx.do', studyParams.xy, { blob: true }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = '学业综合表现.xls'; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1000); Toast.fire({ icon: 'success', title: '学业综合表现已导出' }); MonitorModule.setStatus('✅ 导出完成'); } catch (e) { AppDialog.fire('导出失败', e.message, 'error'); } finally { MonitorModule.setBusy(false); } }, patchHiddenScores: async () => { if (!state.rawData.some(isHiddenScoreText)) return; return runHiddenScoreBackfill(); } }; // ================= 6. 通用导出工具 ================= const MAPPING_GPA_NEW = [ { label: "学年学期", key: "XNXQDM" }, { label: "学号", key: "XH" }, { label: "姓名", key: "XM" }, { label: "性别", key: "XBDM_DISPLAY", fallback: "XBDM" }, { label: "年级", key: "XZNJ_DISPLAY", fallback: "XZNJ" }, { label: "院系", key: "YXDM_DISPLAY", fallback: "YXDM" }, { label: "专业", key: "ZYDM_DISPLAY", fallback: "ZYDM" }, { label: "班级", key: "BJDM_DISPLAY", fallback: "BJMC" }, { label: "班级总人数", key: "BJZRS", fallback: "BY3R3ZRS" }, { label: "专业总人数", key: "ZYZRS" }, { label: "必修课首次成绩平均学分绩点", key: "BXKPJXFJD" }, { label: "必修课首次成绩平均学分绩点班级排名", key: "BXKPJXFJDBJPM" }, { label: "必修课首次成绩平均学分绩点专业排名", key: "BXKPJXFJDZYPM" }, { label: "平均学分绩点", key: "PJXFJD" }, { label: "平均学分绩点班级排名", key: "PJXFJDBJPM" }, { label: "平均学分绩点专业排名", key: "PJXFJDZYPM" }, { label: "操作时间", key: "CZSJ" }, { label: "平均每学期获得学分数", key: "PJMXQHDXF" } ]; function exportToBrowser(data, filename, format) { if (!data || data.length === 0) return 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;' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `${filename}.csv`; link.click(); } } // ================= 7. 脚本更新模块 ================= function currentScriptVersion() { return (typeof GM_info !== 'undefined' && GM_info?.script?.version) || SCRIPT_VERSION; } function compareVersion(a, b) { const pa = String(a || '0').split(/[^\d]+/).filter(Boolean).map(Number); const pb = String(b || '0').split(/[^\d]+/).filter(Boolean).map(Number); const len = Math.max(pa.length, pb.length, 1); for (let i = 0; i < len; i++) { const av = Number.isFinite(pa[i]) ? pa[i] : 0; const bv = Number.isFinite(pb[i]) ? pb[i] : 0; if (av !== bv) return av > bv ? 1 : -1; } return 0; } 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, { auto: true }); }, 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) window.open(openBtn.dataset.url || SCRIPT_UPDATE.downloadURL, '_blank'); if (logBtn) window.open(logBtn.dataset.url || SCRIPT_UPDATE.projectURL, '_blank'); }, openPanel: () => { 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 (!state.update.isLoaded && !state.update.isChecking) UpdateModule.check(false); } }); }, init: () => { UpdateModule.render(); if (!state.update.isLoaded && !state.update.isChecking) UpdateModule.check(false); }, check: async (manual = false, opts = {}) => { if (state.update.isChecking) return; state.update.isChecking = true; state.update.error = ''; UpdateModule.render(); try { const res = await API.req({ method: 'GET', url: `${SCRIPT_UPDATE.infoURL}?t=${Date.now()}`, headers: { 'Cache-Control': 'no-cache', 'Pragma': 'no-cache' } }); const info = JSON.parse(res.responseText || '{}'); if (!info.version) throw new Error('更新清单缺少 version 字段'); const hasNew = compareVersion(info.version, currentScriptVersion()) > 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) { state.update.error = e.message || '更新检查失败'; 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 hasNew = hasInfo && compareVersion(latest, current) > 0; state.update.hasNew = hasNew; UpdateModule.updateHeaderButton(); const statusText = state.update.isChecking ? '正在检查更新...' : (state.update.error ? `检查失败:${state.update.error}` : (hasInfo ? (hasNew ? `发现新版本 ${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; const changelogURL = info.changelogURL || info.projectURL || SCRIPT_UPDATE.projectURL; container.innerHTML = `

    ↻ 脚本更新

    通过 home.haoli.site 的版本清单检查新版;点击更新后会直接打开自建 user.js 文件,并交由用户脚本管理器确认安装。

    当前版本${escapeHTML(current)}
    最新版本${escapeHTML(latest)}
    ${escapeHTML(statusText)}
    ${notes.length ? `
    更新内容
      ${notes.map(n => `
    • ${escapeHTML(n)}
    • `).join('')}
    ` : ''}
    `; } }; // ================= 6. 官方证明下载模块 ================= 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 = JSON.parse((await API.req({ method: 'POST', url: 'https://jwxt.whut.edu.cn/jwapp/sys/zmdynxd/zmdy/queryAllZmdy.do', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } })).responseText); if (res.success && res.datas) { PrintModule.render(res.datas, container); state.isPrintLoaded = true; } else container.innerHTML = 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 += `

    📁 ${escapeHTML(group.CDMC)}

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