// ==UserScript==
// @name WHUT教务系统小助手
// @namespace http://tampermonkey.net/
// @version 4.0.0
// @description 支持学生/全校/教室课表、学业监测、GPA 课程明细、官方证明、一键评教和自动更新。
// @author 毫厘
// @match *://jwxt.whut.edu.cn/jwapp/sys/*
// @match *://jwxt.whut.edu.cn/jwmobile/*
// @homepageURL https://scriptcat.org/zh-CN/script-show-page/6588
// @supportURL https://scriptcat.org/zh-CN/script-show-page/6588
// @require https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js
// @require https://cdn.sheetjs.com/xlsx-latest/package/dist/xlsx.full.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js
// @grant GM_xmlhttpRequest
// @grant GM_info
// @grant GM_openInTab
// @noframes
// @connect jwxt.whut.edu.cn
// @connect home.haoli.site
// @run-at document-start
// @license MIT
// ==/UserScript==
// ============================================================================
// 模块:成绩单结构化解析器
// 职责:解析官方成绩单文本,并将课程名称、性质、学分和成绩归一化。
// ============================================================================
(function (root, factory) {
'use strict';
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.WHUTTranscriptParser = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
const PARSER_VERSION = '2.0.2';
const HEADER_TOP = ['课程', '课程', '学', '成'];
const HEADER_BOTTOM = ['名称', '性质', '分', '绩'];
const SCORE_PATTERN = /^(?:100(?:\.0+)?|\d{1,2}(?:\.\d+)?|优秀|良好|中等|及格|不及格|合格|不合格|通过|不通过|免修|优|良|中)$/;
function textOf(value) {
return String(value == null ? '' : value).normalize('NFKC').trim();
}
function normalizeText(value) {
return textOf(value)
.replace(/[\s\[\]【】()(){}<>《》·•,,。::;;'"“”‘’_\-—–−]/g, '')
.toLowerCase();
}
function normalizeSemester(value) {
const match = textOf(value).match(/((?:19|20)\d{2})\s*[-–—−]\s*((?:19|20)\d{2})\s*[-–—−]\s*([12])/);
return match ? `${match[1]}-${match[2]}-${match[3]}` : '';
}
function cleanScore(value) {
const score = textOf(value).replace(/\s+/g, '').replace(/分$/, '');
return SCORE_PATTERN.test(score) ? score : '';
}
function numeric(value) {
const number = Number.parseFloat(value);
return Number.isFinite(number) ? number : NaN;
}
function itemCenter(item) {
const center = numeric(item && item.centerX);
if (Number.isFinite(center)) return center;
return numeric(item && item.x) + Math.max(0, numeric(item && item.width) || 0) / 2;
}
function normalizedRows(rows) {
return (Array.isArray(rows) ? rows : []).map((row, rowIndex) => ({
...row,
rowIndex: Number.isInteger(row.rowIndex) ? row.rowIndex : rowIndex,
pageNumber: Number(row.pageNumber || 1),
y: numeric(row.y) || 0,
items: (Array.isArray(row.items) ? row.items : []).map((item, sourceIndex) => ({
...item,
sourceIndex: Number.isInteger(item.sourceIndex) ? item.sourceIndex : sourceIndex,
text: textOf(item.text != null ? item.text : item.str),
x: numeric(item.x != null ? item.x : item.transform && item.transform[4]) || 0,
y: numeric(item.y != null ? item.y : item.transform && item.transform[5]) || 0,
width: Math.max(0, numeric(item.width) || 0),
height: Math.max(1, Math.abs(numeric(item.height) || 10)),
centerX: itemCenter(item)
})).filter(item => item.text).sort((a, b) => a.x - b.x)
})).sort((a, b) => a.pageNumber - b.pageNumber || b.y - a.y);
}
function labelItems(row, allowed) {
return row.items.filter(item => allowed.includes(textOf(item.text).replace(/\s+/g, '')));
}
function sequenceMatches(items, pattern) {
if (items.length < pattern.length || items.length % pattern.length !== 0) return false;
return items.every((item, index) => textOf(item.text).replace(/\s+/g, '') === pattern[index % pattern.length]);
}
function detectLayout(pageRows) {
const rows = normalizedRows(pageRows);
let best = null;
for (const topRow of rows) {
const top = labelItems(topRow, HEADER_TOP);
if (!sequenceMatches(top, HEADER_TOP)) continue;
for (const bottomRow of rows) {
const gap = topRow.y - bottomRow.y;
if (gap <= 0 || gap > 24) continue;
const bottom = labelItems(bottomRow, HEADER_BOTTOM);
if (!sequenceMatches(bottom, HEADER_BOTTOM) || bottom.length !== top.length) continue;
const groupCount = top.length / 4;
if (!best || groupCount > best.groupCount) best = { topRow, bottomRow, top, bottom, groupCount };
}
}
if (!best) return null;
const columns = [];
for (let index = 0; index < best.groupCount; index++) {
const offset = index * 4;
const centers = ['name', 'nature', 'credit', 'score'].map((field, fieldIndex) => ({
field,
center: (itemCenter(best.top[offset + fieldIndex]) + itemCenter(best.bottom[offset + fieldIndex])) / 2
}));
columns.push({ index, fields: Object.fromEntries(centers.map(entry => [entry.field, entry.center])) });
}
const period = columns.length > 1
? columns.slice(1).reduce((sum, column, index) => sum + column.fields.name - columns[index].fields.name, 0) / (columns.length - 1)
: Math.max(180, (columns[0].fields.score - columns[0].fields.name) * 1.65);
const firstFieldSpan = columns[0].fields.nature - columns[0].fields.name;
const tableLeft = columns[0].fields.name - firstFieldSpan;
columns.forEach((column, index) => {
// 多栏成绩单的相邻课程名称单元格紧贴上一栏成绩单元格。
// 以重复表头周期切栏,避免较长的下一栏课程名中心点落入上一栏。
column.left = tableLeft + period * index;
column.right = tableLeft + period * (index + 1);
column.fieldCuts = [
(column.fields.name + column.fields.nature) / 2,
(column.fields.nature + column.fields.credit) / 2,
(column.fields.credit + column.fields.score) / 2
];
});
return {
pageNumber: best.topRow.pageNumber,
headerTopY: best.topRow.y,
headerBottomY: best.bottomRow.y,
columns
};
}
function fieldFor(column, item) {
const center = itemCenter(item);
if (center < column.fieldCuts[0]) return 'name';
if (center < column.fieldCuts[1]) return 'nature';
if (center < column.fieldCuts[2]) return 'credit';
return 'score';
}
function joinItems(items) {
return items.slice().sort((a, b) => a.x - b.x).map(item => item.text).join('').trim();
}
function isFooterOrHeader(text) {
const compact = normalizeText(text);
return !compact
|| /^(课程名称|课程性质|学分|成绩)+$/.test(compact)
|| /毕业应取得总学分|已获得总学分|平均学分绩点|打印日期|本科生成绩单/.test(textOf(text));
}
function parseRows(inputRows) {
const rows = normalizedRows(inputRows);
const pages = new Map();
rows.forEach(row => {
if (!pages.has(row.pageNumber)) pages.set(row.pageNumber, []);
pages.get(row.pageNumber).push(row);
});
const records = [];
const issues = [];
let inheritedSemester = '';
let inheritedGeometry = null;
[...pages.entries()].sort((a, b) => a[0] - b[0]).forEach(([pageNumber, pageRows]) => {
pageRows.sort((a, b) => b.y - a.y);
const detected = detectLayout(pageRows);
const layout = detected || (inheritedGeometry ? {
...inheritedGeometry,
pageNumber,
headerTopY: Number.POSITIVE_INFINITY,
headerBottomY: Number.POSITIVE_INFINITY
} : null);
if (!layout) {
issues.push({ type: 'layout-missing', pageNumber });
return;
}
if (detected) inheritedGeometry = { ...detected, columns: detected.columns.map(column => ({ ...column, fields: { ...column.fields }, fieldCuts: [...column.fieldCuts] })) };
for (const column of layout.columns) {
let currentSemester = inheritedSemester;
const dataRows = pageRows.filter(row => !detected || row.y < layout.headerBottomY - 0.8);
for (const row of dataRows) {
const items = row.items.filter(item => {
const center = itemCenter(item);
return center >= column.left && center < column.right;
});
if (!items.length) continue;
const semester = normalizeSemester(joinItems(items));
if (semester) {
currentSemester = semester;
continue;
}
const fields = { name: [], nature: [], credit: [], score: [] };
items.forEach(item => fields[fieldFor(column, item)].push(item));
const name = joinItems(fields.name);
const nature = joinItems(fields.nature);
const creditText = joinItems(fields.credit);
const scoreText = joinItems(fields.score);
const score = cleanScore(scoreText);
const credit = numeric(creditText);
const combined = joinItems(items);
if (isFooterOrHeader(combined) || !name) continue;
if (!score && !Number.isFinite(credit) && !nature) continue;
const record = {
semester: currentSemester,
name,
nature,
credit: Number.isFinite(credit) ? credit : null,
score,
page: pageNumber,
columnIndex: column.index,
rowIndex: row.rowIndex,
y: row.y,
rawText: combined
};
records.push(record);
if (!score) issues.push({ type: 'score-missing', pageNumber, columnIndex: column.index, rowIndex: row.rowIndex, name });
}
inheritedSemester = currentSemester;
}
});
return { parserVersion: PARSER_VERSION, records, issues };
}
function courseValue(course, keys) {
for (const key of keys) {
const value = course && course[key];
if (value !== undefined && value !== null && value !== '') return value;
}
return '';
}
function matchCourse(course, records) {
const name = normalizeText(courseValue(course, ['KCM', '_name', 'courseName', 'name']));
const semester = normalizeSemester(courseValue(course, ['XNXQDM', '_semester', 'semester']));
const credit = numeric(courseValue(course, ['XF', '_credit', 'credit']));
if (!name) return null;
const candidates = (Array.isArray(records) ? records : [])
.filter(record => normalizeText(record.name) === name)
// 学期在两侧均明确时必须一致,避免同名课程跨学期串分。
.filter(record => !semester || !record.semester || record.semester === semester)
.map(record => {
const semesterMatched = !!semester && !!record.semester && record.semester === semester;
const creditMatched = Number.isFinite(credit) && Number.isFinite(record.credit) && Math.abs(record.credit - credit) < 0.001;
let rank = 100;
if (semesterMatched) rank += 80;
if (creditMatched) rank += 30;
else if (Number.isFinite(credit) && Number.isFinite(record.credit)) rank -= 15;
return { record, score: cleanScore(record.score), rank, semesterMatched, creditMatched };
}).filter(candidate => candidate.score);
if (!candidates.length) return null;
candidates.sort((a, b) => b.rank - a.rank || a.record.page - b.record.page || a.record.columnIndex - b.record.columnIndex || b.record.y - a.record.y);
const bestRank = candidates[0].rank;
const strongest = candidates.filter(candidate => candidate.rank === bestRank);
const scores = [...new Set(strongest.map(candidate => candidate.score))];
if (scores.length !== 1) {
return {
conflict: true,
confidence: 'low',
reason: 'multiple-conflicting-records',
candidates: strongest
};
}
const best = strongest[0];
const confidence = best.semesterMatched && best.creditMatched ? 'high' : 'medium';
return {
conflict: false,
score: best.score,
confidence,
reason: confidence === 'high' ? 'name-semester-credit-exact' : (best.semesterMatched ? 'name-semester-exact' : 'name-only'),
record: best.record,
candidates
};
}
return {
version: PARSER_VERSION,
normalizeText,
normalizeSemester,
cleanScore,
isScoreText: value => !!cleanScore(value),
detectLayout,
parseRows,
matchCourse
};
});
(function() {
'use strict';
// 教务系统会在 iframe 中打开“成绩查询”等业务页面。
// 仅允许顶层页面创建应用 UI,避免每进入一个业务页就多出一个悬浮球。
if (window.top !== window.self) return;
const TRANSCRIPT_PARSER = globalThis.WHUTTranscriptParser || null;
// ========================================================================
// 模块:设计系统
// 职责:集中维护规范令牌、组件样式、业务视图样式与响应式规则。
// ========================================================================
// 导出画布不能解析 Shadow DOM 变量,因此只保留其实际需要的浅色调色板。
const EXPORT_THEME = Object.freeze({
primary: '#1e88e5', deepBlue: '#1565c0', bg: 'rgb(239, 241, 245)',
border: 'rgb(211, 215, 225)', textMain: 'rgb(76, 79, 105)', textSub: 'rgb(108, 111, 133)'
});
const PANEL_LAYOUT = Object.freeze({ width: 680, heightRatio: 0.82, viewportMargin: 12 });
const ICON_PATHS = Object.freeze({
academic: '',
chart: '',
trophy: '',
printer: '',
clipboard: '',
settings: '',
refresh: '',
close: '',
search: '',
download: '',
image: '',
table: '',
rocket: '',
calendar: '',
pin: '',
folder: '',
themeAuto: '',
sun: '',
moon: '',
check: '',
chevronDown: '',
chevronRight: '',
clock: '',
minus: '',
warning: '',
help: '',
info: ''
});
const iconSVG = (name, className = 'uikit-icon', strokeWidth = 2) => ``;
const STYLE_MODULES = Object.freeze({
foundation: `
:host {
all: initial;
color-scheme: light dark;
--uikit-brand: #1e88e5;
--uikit-brand-rgb: 30 136 229;
--uikit-brand-2: #1565c0;
--uikit-brand-hover: #1565c0;
--uikit-brand-soft: rgb(30 136 229 / 12%);
--uikit-brand-soft-hover: rgb(30 136 229 / 18%);
--uikit-brand-text: #1565c0;
--uikit-focus-ring: rgb(30 136 229 / 28%);
--uikit-brand-outline: rgb(30 136 229 / 38%);
--uikit-success: rgb(46 132 104);
--uikit-success-soft: rgb(46 132 104 / 14%);
--uikit-warning: rgb(183 103 11);
--uikit-warning-soft: rgb(183 103 11 / 15%);
--uikit-danger: rgb(190 51 64);
--uikit-danger-hover: rgb(163 38 51);
--uikit-danger-soft: rgb(190 51 64 / 13%);
--uikit-bg: rgb(239 241 245);
--uikit-bg-muted: rgb(230 233 239);
--uikit-bg-elevated: rgb(248 249 252);
--uikit-surface-hover: rgb(222 226 235);
--uikit-surface-active: rgb(214 220 231);
--uikit-border: rgb(205 214 244);
--uikit-border-strong: rgb(188 192 204);
--uikit-text: rgb(76 79 105);
--uikit-text-muted: rgb(108 111 133);
--uikit-text-faint: rgb(93 96 118);
--uikit-overlay: rgb(30 30 46 / 38%);
--uikit-radius-xs: 4px;
--uikit-radius-sm: 6px;
--uikit-radius-md: 10px;
--uikit-radius-lg: 16px;
--uikit-radius-pill: 999px;
--uikit-shadow-sm: 0 1px 3px rgb(0 0 0 / 5%);
--uikit-shadow-md: 0 10px 25px -5px rgb(30 136 229 / 15%);
--uikit-shadow-lg: 0 20px 50px rgb(30 30 46 / 16%);
--uikit-duration-fast: 160ms;
--uikit-duration-interaction: 300ms;
--uikit-duration-normal: 280ms;
--uikit-duration-slow: 520ms;
--uikit-theme-duration: 800ms;
--uikit-ease-standard: cubic-bezier(.2, 0, 0, 1);
--uikit-ease-emphasized: cubic-bezier(.34, 1.56, .64, 1);
--uikit-ease-out-expo: cubic-bezier(.19, 1, .22, 1);
--uikit-ease-out-back: cubic-bezier(.34, 1.56, .64, 1.1);
--uikit-theme-ease: cubic-bezier(.19, 1, .22, 1);
--uikit-space-1: 4px;
--uikit-space-2: 8px;
--uikit-space-3: 12px;
--uikit-space-4: 16px;
--uikit-space-6: 24px;
--uikit-text-2xs: 10px;
--uikit-text-xs: 12px;
--uikit-text-sm: 13px;
--uikit-text-base: 14px;
--uikit-text-lg: 16px;
--uikit-line-tight: 1.35;
--uikit-line-normal: 1.5;
--uikit-line-relaxed: 1.6;
--uikit-control-height-sm: 30px;
--uikit-control-height-md: 36px;
--uikit-row-alt: rgb(235 238 243);
--uikit-scrollbar-size: 9px;
--uikit-scrollbar-thumb: rgb(108 111 133 / 38%);
--uikit-scrollbar-thumb-hover: rgb(76 79 105 / 60%);
--uikit-scrollbar-track: transparent;
--uikit-z-panel: 10;
--uikit-z-fab: 20;
--uikit-z-menu: 90;
--uikit-z-dialog: 100;
--uikit-z-toast: 110;
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
font-size: var(--uikit-text-base);
line-height: var(--uikit-line-normal);
color: var(--uikit-text);
-webkit-text-size-adjust: 100%;
}
:host([data-theme='light']) { color-scheme: light; }
:host([data-theme='dark']) {
color-scheme: dark;
--uikit-brand: #87b0f9;
--uikit-brand-rgb: 135 176 249;
--uikit-brand-2: #74a5ec;
--uikit-brand-hover: #99befc;
--uikit-brand-soft: rgb(135 176 249 / 16%);
--uikit-brand-soft-hover: rgb(135 176 249 / 24%);
--uikit-brand-text: rgb(198 208 245);
--uikit-focus-ring: rgb(135 176 249 / 34%);
--uikit-brand-outline: rgb(135 176 249 / 48%);
--uikit-bg: rgb(30 30 46);
--uikit-bg-muted: rgb(24 24 37);
--uikit-bg-elevated: rgb(36 36 58);
--uikit-surface-hover: rgb(43 44 67);
--uikit-surface-active: rgb(51 53 78);
--uikit-border: rgb(54 58 79);
--uikit-border-strong: rgb(75 82 112);
--uikit-text: rgb(198 208 245);
--uikit-text-muted: rgb(161 168 201);
--uikit-text-faint: rgb(125 133 166);
--uikit-overlay: rgb(0 0 0 / 58%);
--uikit-success-soft: rgb(64 160 120 / 20%);
--uikit-warning-soft: rgb(223 142 29 / 20%);
--uikit-danger-hover: rgb(231 99 110);
--uikit-danger-soft: rgb(210 77 87 / 20%);
--uikit-row-alt: rgb(28 28 43);
--uikit-scrollbar-thumb: rgb(161 168 201 / 34%);
--uikit-scrollbar-thumb-hover: rgb(198 208 245 / 56%);
--uikit-shadow-sm: 0 1px 3px rgb(0 0 0 / 28%);
--uikit-shadow-md: 0 10px 25px -5px rgb(0 0 0 / 46%);
--uikit-shadow-lg: 0 20px 50px rgb(0 0 0 / 64%);
}
*, *::before, *::after { box-sizing: border-box; }
[hidden] { display: none !important; }
[aria-busy='true'], .is-loading { cursor: progress !important; }
button, input, select, textarea { color-scheme: inherit; font: inherit; }
button { -webkit-tap-highlight-color: transparent; }
.uikit-icon { width: 1em; height: 1em; display: inline-block; flex: 0 0 auto; color: currentColor; vertical-align: -.125em; }
.uikit-root { position: fixed; inset: 0; z-index: 2147483647; pointer-events: none; }
.uikit-layer { position: fixed; inset: 0; pointer-events: none; }
.uikit-layer > * { pointer-events: auto; }
.uikit-layer--panel { z-index: var(--uikit-z-panel); }
.uikit-layer--menu { z-index: var(--uikit-z-menu); }
.uikit-layer--dialog { z-index: var(--uikit-z-dialog); }
.uikit-layer--toast { z-index: var(--uikit-z-toast); }
#whut-helper-panel:focus { outline: none; }
#whut-helper-panel ::selection { color: white; background: var(--uikit-brand); }
.whut-sr-only {
position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important;
margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important; border: 0 !important;
}
#whut-helper-panel :focus-visible, #whut-fab:focus-visible, .uikit-dialog-backdrop :focus-visible {
outline: 3px solid var(--uikit-focus-ring);
outline-offset: 2px;
}
#whut-helper-panel input:focus-visible, #whut-helper-panel select:focus-visible,
#whut-helper-panel textarea:focus-visible, .uikit-dialog-backdrop input:focus-visible,
.uikit-dialog-backdrop select:focus-visible, .uikit-dialog-backdrop textarea:focus-visible { outline: none; }
.uikit-root, .uikit-root * {
scrollbar-width: thin;
scrollbar-color: var(--uikit-scrollbar-thumb) var(--uikit-scrollbar-track);
}
.uikit-root::-webkit-scrollbar, .uikit-root *::-webkit-scrollbar {
width: var(--uikit-scrollbar-size); height: var(--uikit-scrollbar-size);
}
.uikit-root::-webkit-scrollbar-track, .uikit-root *::-webkit-scrollbar-track { background: var(--uikit-scrollbar-track); }
.uikit-root::-webkit-scrollbar-thumb, .uikit-root *::-webkit-scrollbar-thumb {
min-width: 28px; min-height: 28px; background: var(--uikit-scrollbar-thumb); border: 2px solid transparent;
border-radius: var(--uikit-radius-pill); background-clip: content-box;
}
.uikit-root::-webkit-scrollbar-thumb:hover, .uikit-root *::-webkit-scrollbar-thumb:hover { background-color: var(--uikit-scrollbar-thumb-hover); }
.uikit-root::-webkit-scrollbar-corner, .uikit-root *::-webkit-scrollbar-corner { background: transparent; }
.uikit-hover-lifted { transform: translateY(-1px); }
.btn-modern.uikit-hover-lifted { transform: translateY(-2px); }
.uikit-hover-bridge {
position: fixed; z-index: calc(var(--uikit-z-toast) + 1); display: none; pointer-events: auto;
background: transparent;
}
`,
surfaces: `
.course-item, .export-card, .gpa-card, .eval-config-card, .eval-summary,
#pj-list-area > .pj-group, .eval-history-loader, .monitor-overview, .monitor-groups {
background: var(--uikit-bg-elevated); border: 1px solid var(--uikit-border);
border-radius: var(--uikit-radius-md); box-shadow: var(--uikit-shadow-sm);
}
.course-item, .gpa-card, #pj-list-area > .pj-group, .eval-history-loader {
transition: background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), border-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), box-shadow var(--uikit-duration-interaction) var(--uikit-ease-out-expo), transform var(--uikit-duration-interaction) var(--uikit-ease-out-back);
}
.gpa-card:hover, .gpa-card.uikit-hover-lifted,
#pj-list-area > .pj-group:hover, #pj-list-area > .pj-group.uikit-hover-lifted,
.eval-history-loader:hover, .eval-history-loader.uikit-hover-lifted {
border-color: var(--uikit-brand-outline); box-shadow: 0 7px 18px rgb(var(--uikit-brand-rgb) / 9%);
}
`,
panelShell: `
#whut-fab {
position: fixed; right: max(30px, env(safe-area-inset-right)); bottom: max(30px, env(safe-area-inset-bottom));
width: 56px; height: 56px; z-index: var(--uikit-z-fab); display: inline-flex; align-items: center; justify-content: center;
color: #fff; font-size: 24px; cursor: grab; touch-action: none; user-select: none; background: linear-gradient(135deg, var(--uikit-brand-2), var(--uikit-brand));
border: 0; border-radius: 50%; box-shadow: var(--uikit-shadow-md); backface-visibility: hidden; will-change: transform, background-color, box-shadow;
transition: transform var(--uikit-duration-interaction) var(--uikit-ease-out-back), box-shadow var(--uikit-duration-interaction) var(--uikit-ease-out-expo), filter var(--uikit-duration-interaction) var(--uikit-ease-out-expo);
}
#whut-fab:hover { transform: scale(1.1); box-shadow: 0 14px 30px rgb(var(--uikit-brand-rgb) / 24%); filter: brightness(1.025); }
#whut-fab:active, #whut-fab.is-dragging { cursor: grabbing; transform: scale(.95); transition-duration: 100ms; }
#whut-fab.is-dragging { box-shadow: 0 18px 38px rgb(var(--uikit-brand-rgb) / 30%); }
.whut-fab-glyph { font-size: 24px; line-height: 1; }
.whut-fab-pulse { position: absolute; right: 5px; top: 5px; width: 9px; height: 9px; border-radius: 50%; background: var(--uikit-success); box-shadow: 0 0 0 3px var(--uikit-bg); }
#whut-helper-panel {
container: whut-panel / inline-size;
display: none; position: fixed; width: ${PANEL_LAYOUT.width}px; min-width: 440px; height: 82vh; min-height: 480px;
max-width: calc(100vw - ${PANEL_LAYOUT.viewportMargin * 2}px); max-height: calc(100dvh - ${PANEL_LAYOUT.viewportMargin * 2}px);
z-index: var(--uikit-z-panel); flex-direction: column; overflow: hidden; color: var(--uikit-text); font-size: var(--uikit-text-base); line-height: var(--uikit-line-normal);
background: var(--uikit-bg); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-lg); box-shadow: var(--uikit-shadow-lg);
transform-origin: right bottom; transition: background-color var(--uikit-theme-duration) var(--uikit-theme-ease), color var(--uikit-theme-duration) var(--uikit-theme-ease), border-color var(--uikit-theme-duration) var(--uikit-theme-ease), box-shadow var(--uikit-theme-duration) var(--uikit-theme-ease);
}
#whut-helper-panel.open { display: flex; animation: uikitPanelIn var(--uikit-duration-normal) var(--uikit-ease-emphasized) both; }
#whut-helper-panel.is-dragging { opacity: .94; box-shadow: var(--uikit-shadow-lg); }
.helper-header {
flex: 0 0 auto; padding: 0; cursor: grab; user-select: none; touch-action: none;
background: var(--uikit-bg); border-bottom: 1px solid var(--uikit-border);
}
.helper-header:active { cursor: grabbing; }
.header-top {
min-height: 58px; margin: 0; padding: var(--uikit-space-2) 10px var(--uikit-space-2) var(--uikit-space-4);
display: flex; align-items: center; justify-content: space-between; gap: var(--uikit-space-3); pointer-events: none;
background: linear-gradient(135deg, var(--uikit-bg-muted), var(--uikit-bg)); border-bottom: 1px solid var(--uikit-border);
}
.header-title { min-width: 0; display: flex; align-items: center; gap: 10px; color: var(--uikit-text); }
.header-logo {
width: 36px; height: 36px; flex: 0 0 36px; display: inline-flex; align-items: center; justify-content: center;
color: var(--uikit-brand-text); font-size: 19px; background: var(--uikit-brand-soft); border: 1px solid var(--uikit-border);
border-radius: var(--uikit-radius-md); box-shadow: var(--uikit-shadow-sm);
}
.header-brand-copy { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.header-name { overflow: hidden; color: var(--uikit-text); font-size: var(--uikit-text-lg); font-weight: 800; letter-spacing: .01em; text-overflow: ellipsis; white-space: nowrap; }
.header-meta { display: flex; align-items: center; gap: 5px; pointer-events: auto; touch-action: auto; }
.header-version, .header-author {
min-height: 18px; padding: 1px 6px; display: inline-flex; align-items: center; color: var(--uikit-brand-text);
font: inherit; font-size: 9px; font-weight: 800; line-height: 1; text-decoration: none; cursor: pointer;
background: var(--uikit-brand-soft); border: 0; border-radius: var(--uikit-radius-pill);
transition: color var(--uikit-duration-fast), background-color var(--uikit-duration-fast), box-shadow var(--uikit-duration-fast), transform var(--uikit-duration-fast);
}
.header-author { color: var(--uikit-text-muted); background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); }
.header-version:hover, .header-version.uikit-hover-lifted,
.header-author:hover, .header-author.uikit-hover-lifted { color: var(--uikit-brand-text); background: var(--uikit-brand-soft-hover); }
.header-version:active, .header-author:active { transform: translateY(0); }
.header-actions { flex: 0 0 auto; display: flex; align-items: center; gap: 5px; pointer-events: auto; touch-action: auto; }
.header-action {
min-width: 32px; height: 32px; padding: 0 8px; display: inline-flex; align-items: center; justify-content: center; gap: 6px;
color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); line-height: 1; font-weight: 750; cursor: pointer;
background: transparent; border: 1px solid transparent; border-radius: var(--uikit-radius-pill); box-shadow: none;
backface-visibility: hidden; will-change: transform, background-color, box-shadow;
transition: background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), border-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), box-shadow var(--uikit-duration-interaction) var(--uikit-ease-out-expo), transform var(--uikit-duration-interaction) var(--uikit-ease-out-back);
}
.header-action:hover { color: var(--uikit-text); background: color-mix(in srgb, currentColor 7%, transparent); transform: scale(1.1); box-shadow: 0 4px 12px rgb(0 0 0 / 9%), inset 0 0 0 1px color-mix(in srgb, currentColor 8%, transparent); }
.header-action:active { background: color-mix(in srgb, currentColor 11%, transparent); transform: scale(.95); box-shadow: none; transition-duration: 100ms; }
.header-theme, .header-diagnostics, .header-help { width: 32px; padding: 0; }
.header-theme { font-size: 18px; }
.header-diagnostics, .header-help { font-size: 15px; }
.header-theme[data-theme-mode='auto'] { color: var(--uikit-text-muted); background: color-mix(in srgb, var(--uikit-text) 7%, transparent); border-color: var(--uikit-border); }
.header-theme[data-theme-mode='light'] { color: var(--uikit-warning); background: var(--uikit-warning-soft); border-color: color-mix(in srgb, var(--uikit-warning) 38%, transparent); }
.header-theme[data-theme-mode='dark'] { color: var(--uikit-brand-text); background: var(--uikit-brand-soft); border-color: var(--uikit-brand-outline); }
.header-update { min-width: 88px; border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-sm); background: var(--uikit-bg); }
.header-update:hover { color: var(--uikit-brand-text); border-color: var(--uikit-brand); background: var(--uikit-brand-soft-hover); }
.header-update.has-update { color: var(--uikit-success); background: var(--uikit-success-soft); border-color: color-mix(in srgb, var(--uikit-success) 38%, transparent); }
.header-update.is-checking > span:first-child { display: inline-block; animation: uikitSpin .8s linear infinite; }
.header-close { width: 32px; padding: 0; color: var(--uikit-danger); font-size: 18px; font-weight: 500; }
.header-close:hover { color: var(--uikit-danger); background: var(--uikit-danger-soft); border-color: color-mix(in srgb, var(--uikit-danger) 35%, transparent); transform: scale(1.1); }
`,
navigation: `
.helper-tabs {
flex: 0 0 auto; display: flex; gap: 0; padding: 0; overflow-x: auto; overflow-y: hidden;
pointer-events: auto; touch-action: pan-x; overscroll-behavior-inline: contain; scroll-snap-type: x proximity;
background: var(--uikit-bg);
}
.helper-tab {
position: relative; min-width: 104px; min-height: 44px; flex: 1 0 auto; padding: var(--uikit-space-2);
display: inline-flex; align-items: center; justify-content: center; gap: var(--uikit-space-1); color: var(--uikit-text-muted);
font-size: var(--uikit-text-xs); font-weight: 700; white-space: nowrap; cursor: pointer; background: transparent; border: 0;
scroll-snap-align: center; will-change: transform;
transition: color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), transform var(--uikit-duration-interaction) var(--uikit-ease-out-back);
}
.helper-tab:hover, .helper-tab.uikit-hover-lifted { color: var(--uikit-text); background: color-mix(in srgb, currentColor 5%, transparent); }
.helper-tab:active { transform: translateY(0) scale(.97); transition-duration: 100ms; }
.helper-tab.active { color: var(--uikit-brand); }
.helper-tab.active::after {
content: ""; position: absolute; right: 8px; bottom: 0; left: 8px; height: 3px;
border-radius: 3px 3px 0 0; background: var(--uikit-brand);
}
.helper-tab-icon { font-size: 14px; line-height: 1; }
.helper-tab-label-short { display: none; }
.tab-view { display: none; flex: 1 1 auto; min-height: 0; flex-direction: column; overflow: hidden; }
.tab-view.active { display: flex; animation: uikitViewIn var(--uikit-duration-fast) var(--uikit-ease-standard) both; }
`,
controls: `
.helper-toolbar { flex: 0 0 auto; padding: var(--uikit-space-3) var(--uikit-space-4); background: var(--uikit-bg); border-bottom: 1px solid var(--uikit-border); }
.analysis-disclosure-bar { display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 8px; padding: 7px var(--uikit-space-4); border-bottom: 1px solid var(--uikit-border); color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); }
.summary-tags { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; min-width: 0; }
.summary-tags .tag { margin: 0; max-width: 100%; overflow-wrap: anywhere; }
.analysis-disclosure-bar .summary-tags { flex: 1; }
.analysis-search-row { flex: 0 0 auto; padding: 10px var(--uikit-space-4) 0; background: var(--uikit-bg); }
.analysis-search-row .search-box { grid-template-columns: minmax(0, 1fr); margin-bottom: 0; }
.disclosure-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 6px; }
.analysis-toolbar .search-box { grid-template-columns: minmax(0, 1fr); }
button.footer-value { padding: 2px 4px; border: 0; border-radius: var(--uikit-radius-sm); background: transparent; font-family: inherit; cursor: pointer; }
button.footer-value:hover { background: var(--uikit-surface-hover); }
button.footer-value:focus-visible { outline: 2px solid var(--uikit-brand); outline-offset: 2px; }
.disclosure-toggle { flex: 0 0 auto; }
.disclosure-toggle[aria-expanded='false'] .uikit-icon { transform: rotate(-90deg); }
.search-box { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--uikit-space-2); margin-bottom: 9px; }
.filter-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: center; gap: var(--uikit-space-2); margin-bottom: 9px; }
.action-row { display: flex; gap: var(--uikit-space-2); }
.action-row > .btn-modern, .btn-flex { flex: 1 1 0; }
.search-input, .eval-field textarea, .eval-field input, .uikit-dialog .gpa-sem-input, .ui-select-trigger {
width: 100%; min-height: var(--uikit-control-height-md); color: var(--uikit-text); background: var(--uikit-bg);
border: 1px solid var(--uikit-border-strong); border-radius: var(--uikit-radius-sm); outline: none;
transition: border-color var(--uikit-duration-fast), box-shadow var(--uikit-duration-fast), background var(--uikit-duration-fast);
}
.ui-search { position: relative; min-width: 0; }
.search-input {
min-width: 0; height: var(--uikit-control-height-md); padding: 7px 36px; font-size: var(--uikit-text-sm);
}
.search-input::-webkit-search-cancel-button { appearance: none; }
.ui-search-icon, .ui-search-clear {
position: absolute; top: 50%; z-index: 1; display: inline-flex; align-items: center; justify-content: center;
color: var(--uikit-text-faint); transform: translateY(-50%);
}
.ui-search-icon { left: 11px; width: 18px; height: 18px; pointer-events: none; }
.ui-search-clear {
right: 6px; width: 28px; height: 28px; padding: 0; cursor: pointer; background: transparent; border: 0;
border-radius: var(--uikit-radius-pill); transition: color var(--uikit-duration-fast), background-color var(--uikit-duration-fast), opacity var(--uikit-duration-fast);
}
.ui-search-clear:hover { color: var(--uikit-text); background: var(--uikit-surface-hover); }
.ui-search-clear[hidden] { display: none; }
.search-input::placeholder, .eval-field textarea::placeholder { color: var(--uikit-text-faint); }
.eval-field input { padding: var(--uikit-space-2) 10px; font-size: var(--uikit-text-xs); }
.search-input:focus, .ui-select-trigger:focus-visible, .eval-field textarea:focus,
.eval-field input:focus, .uikit-dialog .gpa-sem-input:focus {
border-color: var(--uikit-brand); box-shadow: 0 0 0 3px var(--uikit-focus-ring);
}
.search-input:disabled, .eval-field textarea:disabled,
.eval-field input:disabled, .uikit-dialog .gpa-sem-input:disabled { opacity: .56; cursor: not-allowed; }
.ui-select { position: relative; width: 100%; min-width: 0; }
.ui-select-native {
position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important;
overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; clip-path: inset(50%) !important;
white-space: nowrap !important; border: 0 !important; opacity: 0 !important; pointer-events: none !important;
}
.ui-select-trigger {
position: relative; height: var(--uikit-control-height-md); padding: 7px 34px 7px 11px; display: flex; align-items: center; gap: var(--uikit-space-2);
color: var(--uikit-text); font-size: var(--uikit-text-xs); font-weight: 650; line-height: 1; text-align: left; cursor: pointer;
}
.ui-select-value { min-width: 0; overflow: hidden; flex: 1; text-overflow: ellipsis; white-space: nowrap; }
.ui-select-chevron {
position: absolute; top: 50%; right: 11px; color: var(--uikit-text-faint); pointer-events: none; transform: translateY(-50%);
transition: transform var(--uikit-duration-fast), color var(--uikit-duration-fast);
}
.ui-select.is-open .ui-select-chevron { transform: translateY(-50%) rotate(180deg); }
.ui-select.is-open .ui-select-trigger {
color: var(--uikit-brand-text); border-color: var(--uikit-brand); box-shadow: 0 0 0 3px var(--uikit-focus-ring);
}
.ui-select-trigger:disabled { opacity: .56; cursor: not-allowed; }
.ui-select-menu {
display: none; z-index: var(--uikit-z-menu); max-height: min(320px, calc(100vh - 24px)); padding: 5px; overflow-y: auto;
color: var(--uikit-text); background: var(--uikit-bg-elevated); border: 1px solid var(--uikit-border);
border-radius: var(--uikit-radius-md); box-shadow: var(--uikit-shadow-lg); transform-origin: top center;
}
.ui-select-menu { position: fixed; }
.ui-select-menu.is-open { display: grid; gap: 2px; animation: uikitOverlayIn var(--uikit-duration-normal) var(--uikit-ease-standard) both; }
.ui-select-option {
width: 100%; min-height: 34px; padding: 7px 9px; display: flex; align-items: center; gap: var(--uikit-space-2);
color: var(--uikit-text); font-size: var(--uikit-text-xs); font-weight: 650; line-height: var(--uikit-line-tight); text-align: left; cursor: pointer;
background: transparent; border: 0; border-radius: var(--uikit-radius-sm); backface-visibility: hidden; will-change: transform, background-color;
transition: background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), transform var(--uikit-duration-interaction) var(--uikit-ease-out-back);
}
.ui-select-option:hover, .ui-select-option.is-active, .ui-select-option.uikit-hover-lifted { color: var(--uikit-text); background: var(--uikit-surface-hover); }
.ui-select-option:active { transform: scale(.98); transition-duration: 100ms; }
.ui-select-option[aria-selected='true'] { color: var(--uikit-brand-text); font-weight: 800; background: var(--uikit-brand-soft); }
.ui-select-option:disabled { opacity: .42; cursor: not-allowed; transform: none; }
.ui-select-option-check { width: 17px; flex: 0 0 17px; color: transparent; font-size: var(--uikit-text-xs); font-weight: 900; text-align: center; }
.ui-select-option[aria-selected='true'] .ui-select-option-check { color: var(--uikit-brand); }
.ui-select-option-label { min-width: 0; overflow: hidden; flex: 1; text-overflow: ellipsis; white-space: nowrap; }
.ui-select-actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; padding: 2px 2px 7px; margin-bottom: 3px; border-bottom: 1px solid var(--uikit-border); }
.ui-select-action {
min-height: var(--uikit-control-height-sm); padding: 4px 6px; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); font-weight: 750;
cursor: pointer; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-sm);
transition: color var(--uikit-duration-fast), background-color var(--uikit-duration-fast), border-color var(--uikit-duration-fast), transform var(--uikit-duration-fast);
}
.ui-select-action:hover, .ui-select-action.uikit-hover-lifted { color: var(--uikit-text); background: var(--uikit-surface-hover); border-color: var(--uikit-border-strong); }
.ui-select-action:active { background: var(--uikit-surface-active); transform: scale(.98); }
.pj-grp-cb, .pj-item-cb {
width: 16px; height: 16px; margin: 0; accent-color: var(--uikit-brand); cursor: pointer;
transition: transform var(--uikit-duration-fast) var(--uikit-ease-emphasized), opacity var(--uikit-duration-fast) var(--uikit-ease-standard);
}
.pj-grp-cb:hover, .pj-item-cb:not(:disabled):hover { transform: scale(1.1); }
.pj-grp-cb:active, .pj-item-cb:not(:disabled):active { transform: scale(.92); }
.btn-modern, .uikit-dialog .btn-modern {
--uikit-button-bg: var(--uikit-brand); --uikit-button-hover-bg: var(--uikit-brand-hover);
position: relative; min-height: var(--uikit-control-height-md); padding: var(--uikit-space-2) var(--uikit-space-3);
display: inline-flex; align-items: center; justify-content: center; gap: var(--uikit-space-2); overflow: hidden;
color: #fff; font-size: var(--uikit-text-xs); font-weight: 750; line-height: 1.2; cursor: pointer;
background-color: var(--uikit-button-bg); border: 1px solid transparent; border-radius: var(--uikit-radius-sm); box-shadow: var(--uikit-shadow-sm);
backface-visibility: hidden; will-change: transform, background-color, box-shadow;
transition: transform var(--uikit-duration-interaction) var(--uikit-ease-out-back), box-shadow var(--uikit-duration-interaction) var(--uikit-ease-out-expo), opacity var(--uikit-duration-interaction) var(--uikit-ease-out-expo), color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), border-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo);
}
.btn-modern:hover:not(:disabled):not(.is-loading), .btn-modern.uikit-hover-lifted,
.uikit-dialog .btn-modern:hover:not(:disabled):not(.is-loading), .uikit-dialog .btn-modern.uikit-hover-lifted { background-color: var(--uikit-button-hover-bg); box-shadow: 0 4px 10px rgb(0 0 0 / 11%); }
.btn-modern:active:not(:disabled):not(.is-loading), .uikit-dialog .btn-modern:active:not(:disabled):not(.is-loading) { transform: translateY(-1px); box-shadow: var(--uikit-shadow-sm); transition-duration: 100ms; }
.btn-modern:disabled, .btn-modern.is-loading, .uikit-dialog .btn-modern:disabled { opacity: .52; cursor: not-allowed; transform: none; box-shadow: none; filter: none; }
.btn-outline, .uikit-dialog .btn-outline {
--uikit-button-bg: transparent; --uikit-button-hover-bg: color-mix(in srgb, var(--uikit-text) 7%, transparent);
color: var(--uikit-text); border-color: var(--uikit-border-strong); box-shadow: none;
}
.btn-outline:hover:not(:disabled):not(.is-loading), .uikit-dialog .btn-outline:hover:not(:disabled):not(.is-loading) { border-color: var(--uikit-border-strong); box-shadow: none; }
.btn-success { --uikit-button-bg: var(--uikit-success); --uikit-button-hover-bg: color-mix(in srgb, var(--uikit-success) 88%, black); }
.btn-danger { --uikit-button-bg: var(--uikit-danger); --uikit-button-hover-bg: var(--uikit-danger-hover); }
.btn-spinner { width: 14px; height: 14px; flex: 0 0 14px; border: 2px solid rgb(255 255 255 / 46%); border-top-color: currentColor; border-radius: 50%; animation: uikitSpin .72s linear infinite; }
.btn-outline .btn-spinner { border-color: var(--uikit-border); border-top-color: var(--uikit-brand); }
.btn-compact { min-height: 28px; padding: 4px 9px; font-size: var(--uikit-text-xs); }
.btn-block { width: 100%; }
.field-label { display: block; margin: 0 0 6px; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); font-weight: 700; }
`,
feedbackAndLayout: `
.helper-content, .monitor-container, .gpa-container, .export-container {
flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: var(--uikit-space-4); background: var(--uikit-bg-muted);
overscroll-behavior: contain;
}
.ui-state {
min-height: 180px; padding: var(--uikit-space-6) var(--uikit-space-3); display: grid; place-items: center; align-content: center;
color: var(--uikit-text-muted); text-align: center;
}
.ui-state.compact { min-height: 92px; padding: 18px 12px; }
.ui-state-icon { width: 44px; height: 44px; margin-bottom: var(--uikit-space-3); display: inline-flex; align-items: center; justify-content: center; color: var(--uikit-brand); font-size: 21px; background: var(--uikit-brand-soft); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); }
.ui-state-spinner { width: 21px; height: 21px; border: 2px solid var(--uikit-border); border-top-color: var(--uikit-brand); border-radius: 50%; animation: uikitSpin .75s linear infinite; }
.ui-state-title { color: var(--uikit-text); font-size: var(--uikit-text-sm); font-weight: 800; }
.ui-state-detail { max-width: 430px; margin-top: 5px; color: var(--uikit-text-faint); font-size: var(--uikit-text-xs); line-height: var(--uikit-line-relaxed); }
.ui-state.error .ui-state-icon { color: var(--uikit-danger); background: var(--uikit-danger-soft); border-color: color-mix(in srgb, var(--uikit-danger) 28%, transparent); }
.ui-state.warning .ui-state-icon { color: var(--uikit-warning); background: var(--uikit-warning-soft); border-color: color-mix(in srgb, var(--uikit-warning) 28%, transparent); }
.ui-state.empty .ui-state-icon { color: var(--uikit-text-muted); background: var(--uikit-bg); border-color: var(--uikit-border); }
.text-muted { color: var(--uikit-text-faint) !important; }
.text-success { color: var(--uikit-success) !important; }
.text-danger { color: var(--uikit-danger) !important; }
.section-heading { margin: 18px 2px 10px; display: flex; align-items: center; gap: var(--uikit-space-2); color: var(--uikit-text); font-size: var(--uikit-text-sm); font-weight: 800; }
.section-heading::after { content: ""; flex: 1; height: 1px; background: var(--uikit-border); }
`,
gradeAnalysis: `
.course-item {
position: relative; margin-bottom: 9px; overflow: hidden;
}
.course-item::before { content: ""; position: absolute; left: 0; top: 14px; bottom: 14px; width: 3px; border-radius: 0 3px 3px 0; background: transparent; transition: background-color var(--uikit-duration-fast) var(--uikit-ease-standard); }
.course-item:hover, .course-item.uikit-hover-lifted { border-color: var(--uikit-border-strong); box-shadow: 0 6px 14px rgb(0 0 0 / 7%); }
.course-item:focus-within { border-color: var(--uikit-brand); transform: translateY(-1px); box-shadow: 0 0 0 3px var(--uikit-focus-ring); }
.course-item:focus-within::before { background: var(--uikit-brand); }
.course-summary {
width: 100%; min-height: 78px; padding: var(--uikit-space-3) 14px; display: flex; align-items: center; justify-content: space-between; gap: 14px;
color: inherit; text-align: left; cursor: pointer; background: var(--uikit-bg-elevated); border: 0;
transition: background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo);
}
.course-summary:hover { background: var(--uikit-surface-hover); }
.course-main { flex: 1 1 auto; min-width: 0; }
.course-title { display: block; margin-bottom: var(--uikit-space-2); overflow: hidden; color: var(--uikit-text); font-size: var(--uikit-text-sm); font-weight: 800; text-overflow: ellipsis; white-space: nowrap; }
.course-tags { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; font-size: 10px; }
.tag { min-height: 23px; padding: 3px 8px; display: inline-flex; align-items: center; color: var(--uikit-text-muted); font-weight: 650; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-pill); }
.tag-credit { color: var(--uikit-brand-text); background: var(--uikit-brand-soft); border-color: transparent; }
.tag-compulsory { color: var(--uikit-warning); font-weight: 750; background: var(--uikit-warning-soft); border-color: transparent; }
.tag-source-monitor { color: var(--uikit-success); background: var(--uikit-success-soft); border-color: transparent; }
.tag-source-transcript { color: var(--uikit-brand-text); background: var(--uikit-brand-soft); border-color: transparent; }
.course-score { flex: 0 0 auto; min-width: 72px; color: var(--uikit-brand); font-size: 18px; font-weight: 850; text-align: right; font-variant-numeric: tabular-nums; }
.score-high { color: var(--uikit-success); }
.score-med { color: var(--uikit-warning); }
.score-low { color: var(--uikit-danger); }
.score-none { color: var(--uikit-text-faint); font-size: var(--uikit-text-sm); font-weight: 700; }
.helper-footer {
flex: 0 0 auto; padding: 10px 14px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--uikit-space-2);
background: var(--uikit-bg-elevated); border-top: 1px solid var(--uikit-border);
}
.footer-metric { min-width: 0; padding: var(--uikit-space-2) 10px; display: flex; align-items: center; justify-content: space-between; gap: 7px; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-sm); }
.footer-label { overflow: hidden; color: var(--uikit-text-faint); font-size: 10px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.footer-value { display: inline-flex; align-items: baseline; gap: 3px; color: var(--uikit-text); font-size: var(--uikit-text-base); font-weight: 900; font-variant-numeric: tabular-nums; }
.footer-value small { color: var(--uikit-text-faint); font-size: 9px; font-weight: 700; }
.footer-metric.is-deep .footer-value { color: var(--uikit-brand-text); font-size: 17px; }
.footer-metric.is-primary .footer-value { color: var(--uikit-brand); font-size: 17px; }
.detail-panel { display: none; padding: 15px var(--uikit-space-4) var(--uikit-space-4); background: var(--uikit-bg-muted); border-top: 1px solid var(--uikit-border); }
.detail-panel.open { display: block; animation: uikitDetailIn var(--uikit-duration-normal) var(--uikit-ease-standard) both; }
.compare-table { width: 100%; margin-bottom: 13px; overflow: hidden; color: var(--uikit-text); font-size: 11px; text-align: center; background: var(--uikit-bg); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); border-collapse: separate; border-spacing: 0; }
.compare-table th { padding: 9px 6px; color: var(--uikit-text-muted); font-weight: 750; background: var(--uikit-brand-soft); border-bottom: 1px solid var(--uikit-border); }
.compare-table td { padding: 10px 6px; color: var(--uikit-text); font-weight: 650; }
.val-rank { color: var(--uikit-brand-text); font-weight: 850; }
.detail-section-head { margin: 12px 0 8px; display: flex; align-items: center; gap: 8px; color: var(--uikit-text-muted); font-size: 11px; font-weight: 800; }
.detail-section-head::after { content: ""; flex: 1; height: 1px; background: var(--uikit-border); }
.dist-container { padding: 3px 1px 1px; }
.dist-row { display: flex; align-items: center; gap: 9px; min-height: 25px; color: var(--uikit-text-muted); font-size: 11px; }
.dist-name { width: 38px; text-align: right; }
.dist-track { flex: 1; height: 7px; overflow: hidden; background: var(--uikit-border); border-radius: var(--uikit-radius-pill); }
.dist-fill { height: 100%; background: linear-gradient(90deg, var(--uikit-brand-2), var(--uikit-brand)); border-radius: var(--uikit-radius-pill); transition: width var(--uikit-duration-normal) var(--uikit-ease-standard); }
.dist-num { width: 30px; font-weight: 750; }
.table-empty { padding: 22px 12px !important; color: var(--uikit-text-faint) !important; text-align: center !important; }
`,
gpaAndDocuments: `
.export-card, .gpa-card {
margin-bottom: var(--uikit-space-3); padding: 14px;
}
.gpa-card.is-overall { position: relative; overflow: hidden; border-color: var(--uikit-brand-outline); }
.gpa-card.is-overall::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: linear-gradient(90deg, var(--uikit-brand), var(--uikit-brand-2)); }
.gpa-card.is-year { position: relative; overflow: hidden; padding: 15px 17px; }
.gpa-card.is-year::before { content: ""; position: absolute; inset: 12px auto 12px 0; width: 3px; border-radius: 0 3px 3px 0; background: var(--uikit-brand); }
.gpa-card.is-semester { padding: 15px 17px; }
.gpa-card.is-empty { padding: 13px 15px; }
.gpa-card-title { margin-bottom: 14px; padding-bottom: 10px; display: flex; align-items: center; gap: var(--uikit-space-2); color: var(--uikit-text); font-size: var(--uikit-text-sm); font-weight: 800; border-bottom: 1px solid var(--uikit-border); }
.gpa-card-title.is-compact { margin: 0; padding: 0; justify-content: space-between; font-size: var(--uikit-text-xs); border: 0; }
.gpa-container { --gpa-sticky-offset: 16px; }
.gpa-segmented {
position: sticky; top: calc(-1 * var(--gpa-sticky-offset)); z-index: 3; margin: 0 0 14px; padding: 3px; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 3px; background: var(--uikit-bg-muted);
border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); box-shadow: var(--uikit-shadow-sm);
}
.gpa-segment-button {
min-width: 0; min-height: 34px; padding: 6px 10px; overflow: hidden; color: var(--uikit-text-muted);
font-size: var(--uikit-text-xs); font-weight: 750; text-overflow: ellipsis; white-space: nowrap; cursor: pointer;
background: transparent; border: 0; border-radius: var(--uikit-radius-sm);
transition: color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), box-shadow var(--uikit-duration-interaction) var(--uikit-ease-out-expo), transform var(--uikit-duration-interaction) var(--uikit-ease-out-back);
}
.gpa-segment-button:hover:not(:disabled), .gpa-segment-button.uikit-hover-lifted { color: var(--uikit-text); background: var(--uikit-surface-hover); }
.gpa-segment-button:active:not(:disabled) { transform: scale(.97); transition-duration: 100ms; }
.gpa-segment-button.active {
color: var(--uikit-brand-text); background: var(--uikit-bg);
box-shadow: inset 0 0 0 1px var(--uikit-brand-outline), var(--uikit-shadow-sm);
}
.gpa-segment-button:disabled { opacity: .45; cursor: not-allowed; }
.gpa-section[hidden] { display: none !important; }
.gpa-section.is-entering { animation: gpaSectionIn var(--uikit-duration-normal) var(--uikit-ease-standard) both; }
.gpa-section-heading { margin: 18px 2px 10px; display: flex; align-items: center; gap: var(--uikit-space-2); color: var(--uikit-text); font-size: var(--uikit-text-sm); font-weight: 800; }
.gpa-section-heading::after { content: ""; flex: 1; height: 1px; background: var(--uikit-border); }
.gpa-section-count { min-width: 22px; padding: 2px 7px; color: var(--uikit-brand-text); font-size: 10px; text-align: center; background: var(--uikit-brand-soft); border-radius: var(--uikit-radius-pill); }
.gpa-flat-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.gpa-flat-metric { min-width: 0; padding: 2px 18px 3px 2px; }
.gpa-flat-metric + .gpa-flat-metric { padding-right: 2px; padding-left: 18px; border-left: 1px solid var(--uikit-border); }
.gpa-flat-label { display: block; margin-bottom: 6px; color: var(--uikit-text-muted); font-size: 10px; font-weight: 750; }
.gpa-flat-row { display: flex; align-items: center; flex-wrap: wrap; gap: 9px 16px; }
.gpa-flat-value { min-width: 78px; display: block; color: var(--uikit-brand); font-size: 24px; font-weight: 900; line-height: 1.15; letter-spacing: 0; font-variant-numeric: tabular-nums; }
.gpa-detail-trigger {
width: auto; margin: 0; padding: 0; font-family: inherit; text-align: left; cursor: pointer; appearance: none;
background: transparent; border: 0; border-radius: var(--uikit-radius-xs);
transition: color var(--uikit-duration-fast) var(--uikit-ease-standard), background-color var(--uikit-duration-fast) var(--uikit-ease-standard), box-shadow var(--uikit-duration-fast) var(--uikit-ease-standard), transform var(--uikit-duration-fast) var(--uikit-ease-standard);
}
.gpa-detail-trigger:hover, .gpa-detail-trigger.uikit-hover-lifted { color: var(--uikit-brand-text); background: var(--uikit-brand-soft-hover); box-shadow: 0 0 0 3px var(--uikit-brand-soft); }
.gpa-detail-trigger:active { transform: translateY(0) scale(.985); }
.gpa-detail-trigger:focus-visible { outline: 2px solid var(--uikit-brand); outline-offset: 2px; }
.gpa-detail-trigger:disabled { cursor: wait; opacity: .58; transform: none; }
.gpa-flat-metric.is-compulsory .gpa-flat-value { color: var(--uikit-brand-text); }
.gpa-flat-ranks { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; color: var(--uikit-text-muted); font-size: 10px; }
.gpa-flat-ranks span { min-height: 23px; padding: 3px 7px; display: inline-flex; align-items: baseline; gap: 3px; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-pill); }
.gpa-flat-ranks b { color: var(--uikit-text); font-size: 11px; }
.gpa-flat-ranks em { color: var(--uikit-text-faint); font-style: normal; }
.gpa-year-minimum { margin: 10px 2px 0; color: var(--uikit-text-faint); font-size: 10px; line-height: 1.5; }
.gpa-year-minimum b { margin-left: 4px; color: var(--uikit-brand-text); font-size: 11px; font-variant-numeric: tabular-nums; }
.gpa-detail-summary { margin-bottom: 10px; display: flex; flex-wrap: wrap; gap: 6px; }
.gpa-detail-summary span { padding: 3px 8px; color: var(--uikit-text-muted); background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-pill); }
.gpa-detail-note { margin: 0 0 11px; color: var(--uikit-text-faint); font-size: 10px; line-height: 1.55; text-align: left; }
.gpa-detail-table-wrap { max-width: 100%; overflow: auto; border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); }
.gpa-detail-table { width: 100%; min-width: 660px; color: var(--uikit-text); font-size: 11px; line-height: 1.45; border-collapse: collapse; background: var(--uikit-bg-elevated); }
.gpa-detail-table th, .gpa-detail-table td { padding: 8px 9px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--uikit-border); }
.gpa-detail-table th { position: sticky; top: 0; z-index: 1; color: var(--uikit-text-muted); font-size: 10px; white-space: nowrap; background: var(--uikit-bg-muted); }
.gpa-detail-table tbody tr:last-child td { border-bottom: 0; }
.gpa-detail-table tbody tr:nth-child(even) td { background: var(--uikit-row-alt); }
.gpa-detail-table tbody tr:hover td { background: var(--uikit-surface-hover); }
.gpa-detail-table .is-number { text-align: center; white-space: nowrap; font-variant-numeric: tabular-nums; }
.gpa-detail-course { min-width: 168px; font-weight: 750; overflow-wrap: anywhere; }
.gpa-detail-course small { display: block; margin-top: 2px; color: var(--uikit-text-faint); font-size: 10px; font-weight: 600; }
.gpa-sem-row { display: flex; gap: 8px; margin-bottom: 8px; }
.uikit-dialog .gpa-sem-input { flex: 1; min-width: 0; height: var(--uikit-control-height-md); padding: var(--uikit-space-2) 11px; }
.gpa-sem-help { margin-bottom: 11px; color: var(--uikit-text-muted); font-size: 11px; line-height: 1.65; text-align: left; }
.export-card h3 { margin: 0 0 11px; display: flex; align-items: center; gap: var(--uikit-space-2); color: var(--uikit-text); font-size: var(--uikit-text-sm); font-weight: 800; }
.export-card p { margin: 0 0 15px; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); line-height: var(--uikit-line-relaxed); }
.print-list { display: flex; flex-direction: column; gap: 8px; margin-top: 9px; }
.print-row { padding: 11px var(--uikit-space-3); display: flex; align-items: center; justify-content: space-between; gap: var(--uikit-space-3); background: var(--uikit-bg-elevated); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); transition: background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), border-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), transform var(--uikit-duration-interaction) var(--uikit-ease-out-back), box-shadow var(--uikit-duration-interaction) var(--uikit-ease-out-expo); }
.print-row:hover, .print-row.uikit-hover-lifted { background: var(--uikit-surface-hover); border-color: var(--uikit-border-strong); box-shadow: 0 6px 14px rgb(0 0 0 / 7%); }
.print-name { min-width: 0; overflow: hidden; color: var(--uikit-text); font-size: var(--uikit-text-xs); font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
`,
evaluation: `
.eval-toolbar { display: grid; gap: 11px; }
.eval-status-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.eval-status-copy { min-width: 0; display: flex; align-items: center; gap: 8px; }
.eval-status-dot { width: 8px; height: 8px; flex: 0 0 8px; background: var(--uikit-success); border-radius: 50%; box-shadow: 0 0 0 4px var(--uikit-success-soft); }
.eval-status-row[data-state='loading'] .eval-status-dot { background: var(--uikit-brand); box-shadow: 0 0 0 4px var(--uikit-brand-soft); animation: evalStatusPulse 1.25s ease-in-out infinite; }
.eval-status-row[data-state='error'] .eval-status-dot { background: var(--uikit-danger); box-shadow: 0 0 0 4px var(--uikit-danger-soft); }
.eval-status-row[data-state='warning'] .eval-status-dot { background: var(--uikit-warning); box-shadow: 0 0 0 4px var(--uikit-warning-soft); }
.eval-status-row[data-state='idle'] .eval-status-dot { background: var(--uikit-text-faint); box-shadow: 0 0 0 4px var(--uikit-border); }
.eval-status-actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
.eval-status-note { color: var(--uikit-text-faint); font-size: var(--uikit-text-xs); white-space: nowrap; }
.eval-config-card { padding: var(--uikit-space-3); display: grid; gap: var(--uikit-space-3); animation: uikitDetailIn var(--uikit-duration-fast) var(--uikit-ease-standard) both; }
.eval-config-card.collapsed { display: none; }
.eval-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 11px; align-items: end; }
.eval-comment { grid-column: 1 / -1; }
.eval-field { min-width: 0; }
.eval-field textarea { min-height: 82px; padding: var(--uikit-space-2) var(--uikit-space-3); resize: vertical; font-size: var(--uikit-text-xs); line-height: var(--uikit-line-relaxed); }
.eval-field input { height: var(--uikit-control-height-md); padding: var(--uikit-space-2) 11px; font-size: var(--uikit-text-xs); }
.eval-config-actions { display: flex; justify-content: flex-end; gap: 9px; }
.eval-footer { display: flex; gap: 10px; border-top: 1px solid var(--uikit-border); border-bottom: 0; }
#pj-list-area { padding: 14px var(--uikit-space-4) !important; background: var(--uikit-bg-muted) !important; }
.eval-summary { margin-bottom: 12px; padding: 13px 14px; display: grid; grid-template-columns: minmax(150px, 1fr) auto; align-items: center; gap: 14px; color: var(--uikit-text); }
.eval-summary-copy { min-width: 0; display: grid; gap: 2px; }
.eval-summary-eyebrow { color: var(--uikit-brand-text); font-size: 10px; font-weight: 900; letter-spacing: .1em; }
.eval-summary-term { overflow: hidden; color: var(--uikit-text); font-size: 15px; font-weight: 850; text-overflow: ellipsis; white-space: nowrap; }
.eval-summary-detail { color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); }
.eval-summary-stats { display: grid; grid-template-columns: repeat(4, minmax(48px, auto)); gap: 6px; }
.eval-summary-stat { min-height: 48px; padding: 6px 9px; display: grid; place-items: center; align-content: center; color: var(--uikit-text-muted); font-size: 9px; line-height: 1.25; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-sm); }
.eval-summary-stat b { color: var(--uikit-text); font-size: 14px; font-variant-numeric: tabular-nums; }
.eval-summary-stat.is-active b { color: var(--uikit-brand); }
.eval-summary-stat.is-done b { color: var(--uikit-success); }
.eval-loading-shell { display: grid; gap: 10px; padding: 1px; }
.eval-loading-card { height: 74px; overflow: hidden; position: relative; background: var(--uikit-bg-elevated); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); }
.eval-loading-card::after { content: ''; position: absolute; inset: 0; background: linear-gradient(100deg, transparent 20%, rgb(var(--uikit-brand-rgb) / 10%) 48%, transparent 76%); transform: translateX(-100%); animation: evalSkeleton 1.35s var(--uikit-ease-standard) infinite; }
.eval-loading-line { height: 9px; margin: 16px 16px 0; background: var(--uikit-border); border-radius: var(--uikit-radius-pill); }
.eval-loading-line + .eval-loading-line { width: 44%; margin-top: 11px; opacity: .72; }
#pj-list-area > .pj-group { margin-bottom: 11px; overflow: hidden; }
.pj-group-head { min-height: 46px; display: flex; align-items: stretch; background: var(--uikit-bg-elevated); border-bottom: 1px solid var(--uikit-border); }
.pj-group-select { padding: 0 0 0 13px; display: inline-flex; align-items: center; gap: 6px; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); font-weight: 750; cursor: pointer; }
.pj-group-header { min-width: 0; min-height: 45px; padding: 10px 13px; flex: 1; display: flex; align-items: center; gap: 9px; color: var(--uikit-text); font-size: var(--uikit-text-xs); font-weight: 800; text-align: left; cursor: pointer; background: transparent; border: 0; transition: background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), transform var(--uikit-duration-interaction) var(--uikit-ease-out-back); }
.pj-group-header:hover { color: var(--uikit-text); background: var(--uikit-surface-hover); }
.pj-group-header:active { transform: scale(.99); transition-duration: 100ms; }
.pj-group-mark { width: 8px; height: 8px; flex: 0 0 8px; border-radius: 50%; background: var(--uikit-text-faint); box-shadow: 0 0 0 4px var(--uikit-border); }
.pj-group-mark.is-active { background: var(--uikit-success); box-shadow: 0 0 0 4px var(--uikit-success-soft); }
.pj-group-mark.is-done { background: var(--uikit-brand); box-shadow: 0 0 0 4px var(--uikit-brand-soft); }
.pj-group-mark.is-wait { background: var(--uikit-warning); box-shadow: 0 0 0 4px var(--uikit-warning-soft); }
.pj-group-mark.is-end { background: var(--uikit-danger); box-shadow: 0 0 0 4px var(--uikit-danger-soft); }
.pj-group-title { flex: 1; }
.pj-arrow { color: var(--uikit-text-faint); font-size: 15px; transition: transform var(--uikit-duration-fast) var(--uikit-ease-standard); }
.pj-group-header[aria-expanded="false"] .pj-arrow { transform: rotate(-90deg); }
.pj-group-content { display: grid; grid-template-rows: 1fr; opacity: 1; background: var(--uikit-bg-elevated); transition: grid-template-rows var(--uikit-duration-fast) var(--uikit-ease-standard), opacity var(--uikit-duration-fast) var(--uikit-ease-standard); }
.pj-group-content.is-collapsed { grid-template-rows: 0fr; opacity: 0; }
.pj-group-items { min-height: 0; overflow: hidden; }
.pj-item { padding: 12px 13px; display: flex; align-items: center; gap: 10px; color: var(--uikit-text); background: var(--uikit-bg-elevated); border-bottom: 1px solid var(--uikit-border); transition: background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), transform var(--uikit-duration-interaction) var(--uikit-ease-out-back); }
.pj-item:hover, .pj-item.uikit-hover-lifted { color: var(--uikit-text); background: var(--uikit-surface-hover); }
.pj-item:last-child { border-bottom: 0; }
.pj-item-main { flex: 1 1 auto; min-width: 0; line-height: 1.45; }
.pj-item-title { overflow: hidden; color: var(--uikit-text); font-size: 12px; font-weight: 750; text-overflow: ellipsis; white-space: nowrap; }
.pj-item-sub { display: flex; flex-wrap: wrap; gap: 4px 8px; color: var(--uikit-text-muted); font-size: 10px; }
.pj-item-sub span + span::before { content: '·'; margin-right: 8px; color: var(--uikit-text-faint); }
.pj-item-status { flex: 0 0 auto; display: grid; justify-items: end; gap: 4px; text-align: right; }
.pj-badge { min-height: 21px; padding: 3px 8px; display: inline-flex; align-items: center; font-size: 10px; font-weight: 850; border-radius: var(--uikit-radius-pill); }
.pj-badge.ing { color: var(--uikit-success); background: var(--uikit-success-soft); }
.pj-badge.done { color: var(--uikit-brand-text); background: var(--uikit-brand-soft); }
.pj-badge.wait { color: var(--uikit-warning); background: var(--uikit-warning-soft); }
.pj-badge.end { color: var(--uikit-danger); background: var(--uikit-danger-soft); }
.pj-log { margin-top: 3px; color: var(--uikit-warning); font-size: 10px; font-weight: 700; text-align: right; }
.pj-item-cb:disabled { cursor: not-allowed; opacity: .46; }
.pj-item-state-icon { width: 17px; height: 17px; flex: 0 0 17px; display: inline-flex; align-items: center; justify-content: center; color: var(--uikit-text-faint); font-size: 10px; font-weight: 900; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: 50%; }
.pj-item-state-icon.done { color: #fff; background: var(--uikit-success); border-color: var(--uikit-success); }
.pj-item-state-icon.wait { color: var(--uikit-warning); background: var(--uikit-warning-soft); border-color: color-mix(in srgb, var(--uikit-warning) 28%, transparent); }
.pj-detail-btn { min-height: 28px; padding: 3px 6px; color: var(--uikit-brand-text); font-size: var(--uikit-text-xs); font-weight: 800; cursor: pointer; background: transparent; border: 0; border-radius: var(--uikit-radius-sm); transition: color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), transform var(--uikit-duration-interaction) var(--uikit-ease-out-back); }
.pj-detail-btn:hover, .pj-detail-btn.uikit-hover-lifted { color: var(--uikit-brand-text); background: var(--uikit-brand-soft-hover); }
.pj-detail-btn:active { transform: scale(.97); transition-duration: 100ms; }
.pj-detail-btn:disabled { cursor: not-allowed; opacity: .52; transform: none; }
.pj-run-count { min-width: 19px; height: 19px; padding: 0 6px; display: inline-flex; align-items: center; justify-content: center; color: var(--uikit-brand-text); font-size: 10px; font-weight: 900; background: rgb(255 255 255 / 82%); border-radius: var(--uikit-radius-pill); }
.eval-history-loader { margin: 0 0 11px; padding: 13px 14px; display: flex; align-items: center; justify-content: space-between; gap: 14px; }
.eval-history-copy { min-width: 0; display: grid; gap: 3px; }
.eval-history-kicker { color: var(--uikit-brand-text); font-size: 10px; font-weight: 900; letter-spacing: .09em; }
.eval-history-title { color: var(--uikit-text); font-size: 12px; font-weight: 850; }
.eval-history-meta { overflow: hidden; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
.eval-history-btn { min-width: 128px; min-height: 38px; flex: 0 0 auto; }
.pj-item.is-history { box-shadow: inset 3px 0 0 var(--uikit-brand-outline); }
.pj-item-term { padding: 1px 5px; color: var(--uikit-brand-text); background: var(--uikit-brand-soft); border-radius: var(--uikit-radius-pill); }
.pj-item-sub .pj-item-term::before { content: none; margin: 0; }
.eval-detail-sheet { display: grid; gap: 12px; color: var(--uikit-text); }
.eval-detail-overview { padding: 14px; display: flex; align-items: center; justify-content: space-between; gap: 14px; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); }
.eval-detail-kicker { color: var(--uikit-brand-text); font-size: 10px; font-weight: 900; letter-spacing: .1em; text-transform: uppercase; }
.eval-detail-overview h2, .eval-detail-teacher h3 { margin: 3px 0 0; color: var(--uikit-text); }
.eval-detail-overview h2 { font-size: 15px; line-height: 1.35; }
.eval-detail-overview p { margin: 4px 0 0; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); }
.eval-detail-total { flex: 0 0 auto; padding: 7px 10px; color: var(--uikit-success); font-size: 12px; font-weight: 900; white-space: nowrap; background: var(--uikit-bg-elevated); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-sm); }
.eval-detail-deadline { padding: 7px 10px; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); background: var(--uikit-bg-muted); border-radius: var(--uikit-radius-sm); }
.eval-detail-teacher { overflow: hidden; background: var(--uikit-bg-elevated); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); }
.eval-detail-teacher > header { padding: 11px 13px; display: flex; align-items: center; justify-content: space-between; gap: 12px; background: var(--uikit-bg-muted); border-bottom: 1px solid var(--uikit-border); }
.eval-detail-teacher h3 { font-size: 13px; }
.eval-detail-score { display: grid; color: var(--uikit-brand); font-size: 17px; font-weight: 900; line-height: 1; text-align: right; font-variant-numeric: tabular-nums; }
.eval-detail-score small { margin-top: 3px; color: var(--uikit-text-faint); font-size: 10px; font-weight: 700; }
.eval-detail-answers { margin: 0; padding: 0; list-style: none; }
.eval-detail-answer { padding: 10px 13px; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 12px; border-bottom: 1px solid var(--uikit-border); }
.eval-detail-answer:last-child { border-bottom: 0; }
.eval-detail-question { min-width: 0; display: flex; align-items: flex-start; gap: 8px; }
.eval-detail-question > span { width: 20px; height: 20px; flex: 0 0 20px; display: inline-flex; align-items: center; justify-content: center; color: var(--uikit-brand-text); font-size: 9px; font-weight: 900; background: var(--uikit-brand-soft); border-radius: 50%; }
.eval-detail-question p { margin: 0; color: var(--uikit-text); font-size: 11px; line-height: 1.55; }
.eval-detail-result { min-width: 74px; display: grid; justify-items: end; gap: 2px; text-align: right; }
.eval-detail-result strong { color: var(--uikit-success); font-size: 11px; }
.eval-detail-points { color: var(--uikit-text-faint); font-size: 9px; }
.eval-detail-empty { padding: 18px; color: var(--uikit-text-faint); text-align: center; }
@media (max-width: 640px) {
.eval-history-loader { align-items: stretch; flex-direction: column; gap: 10px; }
.eval-history-meta { white-space: normal; }
.eval-history-btn { width: 100%; }
}
@keyframes evalStatusPulse { 50% { opacity: .45; transform: scale(.82); } }
@keyframes evalSkeleton { to { transform: translateX(100%); } }
`,
academicMonitor: `
.monitor-toolbar-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.monitor-status { min-width: 0; overflow: hidden; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
.monitor-actions { display: flex; gap: 8px; flex: 0 0 auto; }
.monitor-overview { margin-bottom: 15px; padding: 16px; overflow: hidden; }
.monitor-overview-top { margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.monitor-overview-label { margin-bottom: 3px; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); font-weight: 700; }
.monitor-overview-value { color: var(--uikit-text); font-size: 19px; font-weight: 900; letter-spacing: -.025em; }
.monitor-status-pill, .monitor-group-badge { min-height: 25px; padding: 4px 9px; display: inline-flex; align-items: center; font-size: 10px; font-weight: 850; white-space: nowrap; border: 1px solid transparent; border-radius: var(--uikit-radius-pill); }
.monitor-status-pill.pass, .monitor-group-badge.pass { color: var(--uikit-success); background: var(--uikit-success-soft); border-color: color-mix(in srgb, var(--uikit-success) 28%, transparent); }
.monitor-status-pill.warn, .monitor-group-badge.warn { color: var(--uikit-danger); background: var(--uikit-danger-soft); border-color: color-mix(in srgb, var(--uikit-danger) 28%, transparent); }
.monitor-progress-track { height: 10px; margin-bottom: 8px; overflow: hidden; background: var(--uikit-border); border-radius: var(--uikit-radius-pill); box-shadow: inset 0 1px 2px rgb(0 0 0 / 7%); }
.monitor-progress-fill { height: 100%; background: linear-gradient(90deg, var(--uikit-brand-2), var(--uikit-brand)); border-radius: inherit; transition: width var(--uikit-duration-slow) var(--uikit-ease-standard); }
.monitor-progress-meta { display: flex; justify-content: space-between; gap: 12px; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); }
.monitor-progress-meta b { color: var(--uikit-brand-text); font-size: 12px; }
.monitor-section-title { margin: 17px 2px 9px; display: flex; align-items: center; gap: 8px; color: var(--uikit-text); font-size: 12px; font-weight: 850; }
.monitor-section-title::after { content: ""; flex: 1; height: 1px; background: var(--uikit-border); }
.monitor-groups { overflow: hidden; }
.monitor-group { border-bottom: 1px solid var(--uikit-border); }
.monitor-group:last-child { border-bottom: 0; }
.monitor-group-header { width: 100%; min-height: 55px; padding: 11px 13px; display: grid; grid-template-columns: 18px minmax(100px, auto) minmax(0, 1fr) auto; align-items: center; gap: 8px; color: var(--uikit-text); text-align: left; cursor: pointer; background: var(--uikit-bg-elevated); border: 0; transition: color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), background-color var(--uikit-duration-interaction) var(--uikit-ease-out-expo), transform var(--uikit-duration-interaction) var(--uikit-ease-out-back); }
.monitor-group-header:hover, .monitor-group-header.uikit-hover-lifted { color: var(--uikit-text); background: var(--uikit-surface-hover); }
.monitor-group-header:active { transform: scale(.99); transition-duration: 100ms; }
.monitor-group-arrow { color: var(--uikit-text-faint); font-size: 10px; transition: transform var(--uikit-duration-interaction) var(--uikit-ease-out-back); }
.monitor-group-header[aria-expanded='true'] .monitor-group-arrow { transform: rotate(90deg); }
.monitor-group-title { font-size: 12px; font-weight: 850; }
.monitor-group-credit { overflow: hidden; font-size: var(--uikit-text-xs); font-weight: 800; text-overflow: ellipsis; white-space: nowrap; transition: color var(--uikit-duration-fast) var(--uikit-ease-standard); }
.monitor-group-credit.pass { color: var(--uikit-success); }
.monitor-group-credit.warn { color: var(--uikit-danger); }
.monitor-group-badge { justify-self: end; }
.monitor-group-body { overflow-x: auto; border-top: 1px solid var(--uikit-border); animation: uikitDetailIn var(--uikit-duration-normal) var(--uikit-ease-standard) both; }
.monitor-course-table { width: 100%; min-width: 690px; color: var(--uikit-text); font-size: var(--uikit-text-xs); border-collapse: separate; border-spacing: 0; }
.monitor-course-table th { padding: 10px 11px; color: var(--uikit-text-muted); font-weight: 800; text-align: left; white-space: nowrap; background: var(--uikit-bg-muted); border-bottom: 1px solid var(--uikit-border); }
.monitor-course-table td { padding: 10px 11px; vertical-align: middle; border-top: 1px solid var(--uikit-border); }
.monitor-course-table tbody tr:first-child td { border-top: 0; }
.monitor-course-table tbody tr:nth-child(even) td { background: var(--uikit-row-alt); }
.monitor-course-table tbody tr:hover td { background: var(--uikit-surface-hover); }
.monitor-course-row.pending td { color: var(--uikit-text-faint); background: var(--uikit-bg-muted); }
.monitor-course-row.selected td { color: var(--uikit-text-muted); background: var(--uikit-brand-soft); }
.monitor-course-name { max-width: 260px; line-height: 1.5; }
.monitor-pass-dot { width: 21px; height: 21px; display: inline-flex; align-items: center; justify-content: center; color: #fff; font-size: var(--uikit-text-xs); font-weight: 900; border-radius: 50%; }
.monitor-pass-dot.pass { background: var(--uikit-success); box-shadow: 0 3px 8px color-mix(in srgb, var(--uikit-success) 20%, transparent); }
.monitor-pass-dot.fail { background: var(--uikit-danger); }
.monitor-pass-dot.pending { color: var(--uikit-text-muted); background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); }
.monitor-pass-dot.selected { color: var(--uikit-brand); background: var(--uikit-brand-soft); border: 1px solid color-mix(in srgb, var(--uikit-brand) 30%, transparent); }
.monitor-warning { margin-bottom: 9px; padding: 11px 12px; color: var(--uikit-danger); font-size: var(--uikit-text-xs); line-height: 1.6; background: var(--uikit-danger-soft); border: 1px solid color-mix(in srgb, var(--uikit-danger) 28%, transparent); border-radius: var(--uikit-radius-sm); }
.monitor-warning.is-ok { color: var(--uikit-success); background: var(--uikit-success-soft); border-color: color-mix(in srgb, var(--uikit-success) 28%, transparent); }
.monitor-empty { padding: 30px 12px; color: var(--uikit-text-faint); text-align: center; }
`,
overlaysAndMaintenance: `
#update-container, #update-container-modal, .update-card, .update-card p, .update-card h3,
.update-notes, .update-notes *, .uikit-dialog #update-container-modal * { text-align: left !important; }
#help-container, #help-container * { text-align: left; }
.help-sheet { display: grid; gap: 14px; color: var(--uikit-text); }
.help-intro { margin: 0; color: var(--uikit-text-muted); font-size: var(--uikit-text-sm); line-height: var(--uikit-line-relaxed); }
.help-section { display: grid; gap: 8px; }
.help-section-title { margin: 0; display: flex; align-items: center; gap: 7px; color: var(--uikit-text); font-size: 13px; font-weight: 900; }
.help-section-title .uikit-icon { color: var(--uikit-brand-text); }
.help-steps { margin: 0; padding-left: 22px; color: var(--uikit-text-muted); line-height: var(--uikit-line-relaxed); }
.help-steps li { margin: 4px 0; padding-left: 2px; }
.help-feature-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.help-feature { min-width: 0; padding: 10px 11px; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-sm); }
.help-feature strong { display: block; margin-bottom: 3px; color: var(--uikit-text); font-size: var(--uikit-text-xs); }
.help-feature span { display: block; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); line-height: 1.55; }
.help-tips { margin: 0; padding: 10px 12px; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); line-height: var(--uikit-line-relaxed); background: var(--uikit-brand-soft); border-radius: var(--uikit-radius-sm); }
.help-developers { margin-top: 2px; padding-top: 14px; display: grid; gap: 9px; border-top: 1px solid var(--uikit-border); }
.help-people { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.help-person { min-width: 0; padding: 10px; display: flex; align-items: center; gap: 10px; color: var(--uikit-text); text-decoration: none; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); transition: border-color var(--uikit-duration-fast), background-color var(--uikit-duration-fast), transform var(--uikit-duration-fast); }
.help-person:hover, .help-person.uikit-hover-lifted { color: var(--uikit-brand-text); background: var(--uikit-brand-soft); border-color: var(--uikit-brand-outline); }
.help-avatar { width: 44px; height: 44px; flex: 0 0 44px; object-fit: cover; background: var(--uikit-bg); border: 1px solid var(--uikit-border); border-radius: 50%; }
.help-person-copy { min-width: 0; display: grid; gap: 2px; }
.help-person-role { color: var(--uikit-text-faint); font-size: 9px; font-weight: 900; letter-spacing: .07em; text-transform: uppercase; }
.help-person-name { overflow: hidden; color: inherit; font-size: var(--uikit-text-sm); font-weight: 850; text-overflow: ellipsis; white-space: nowrap; }
.update-card { color: var(--uikit-text); }
.update-card-intro { margin: 0 0 13px; color: var(--uikit-text-muted); font-size: var(--uikit-text-sm); line-height: var(--uikit-line-relaxed); }
.update-version-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--uikit-space-3); margin-bottom: var(--uikit-space-3); }
.update-version-item { min-width: 0; padding: var(--uikit-space-3); display: flex; flex-direction: column; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-sm); }
.update-version-label { margin-bottom: 5px; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); font-weight: 700; }
.update-version-value { color: var(--uikit-brand); font-size: 20px; font-weight: 900; font-variant-numeric: tabular-nums; }
.update-version-value.accent { color: var(--uikit-brand-text); }
.update-status { margin-bottom: var(--uikit-space-3); font-size: var(--uikit-text-xs); font-weight: 850; }
.update-status.is-new { color: var(--uikit-success); }
.update-status.is-current { color: var(--uikit-brand-text); }
.update-status.is-error { color: var(--uikit-danger); }
.update-notes { margin-bottom: 13px; padding: 12px 14px; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); }
.update-notes-title { margin-bottom: 7px; color: var(--uikit-text); font-weight: 850; }
.update-notes ul { margin: 0; padding-left: 20px; color: var(--uikit-text-muted); line-height: var(--uikit-line-relaxed); }
.update-notes li { display: list-item; margin: 3px 0; text-align: left !important; }
.diagnostics-card { color: var(--uikit-text); text-align: left; }
.diagnostics-intro { margin: 0 0 12px; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); line-height: var(--uikit-line-relaxed); }
.diagnostics-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-bottom: 12px; }
.diagnostics-stat { min-width: 0; padding: 10px; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-sm); }
.diagnostics-stat-label { display: block; margin-bottom: 4px; color: var(--uikit-text-faint); font-size: var(--uikit-text-2xs); font-weight: 750; }
.diagnostics-stat-value { color: var(--uikit-text); font-size: 17px; font-weight: 900; font-variant-numeric: tabular-nums; }
.diagnostics-list { max-height: 260px; overflow-y: auto; margin-bottom: 12px; background: var(--uikit-bg-muted); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-sm); }
.diagnostics-empty { padding: 24px 12px; color: var(--uikit-text-faint); text-align: center; }
.diagnostics-entry { display: grid; grid-template-columns: 58px 52px minmax(0, 1fr); gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--uikit-border); font-size: var(--uikit-text-xs); line-height: var(--uikit-line-normal); }
.diagnostics-entry:last-child { border-bottom: 0; }
.diagnostics-time, .diagnostics-scope { color: var(--uikit-text-faint); font-variant-numeric: tabular-nums; }
.diagnostics-message { min-width: 0; color: var(--uikit-text-muted); overflow-wrap: anywhere; }
.diagnostics-entry.is-warn .diagnostics-message { color: var(--uikit-warning); }
.diagnostics-entry.is-error .diagnostics-message { color: var(--uikit-danger); font-weight: 750; }
.diagnostics-privacy { margin: 0 0 12px; padding: 9px 10px; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); line-height: var(--uikit-line-relaxed); background: var(--uikit-brand-soft); border-radius: var(--uikit-radius-sm); }
#whut-toast-stack {
position: fixed; top: 18px; left: 50%; z-index: var(--uikit-z-toast); width: min(430px, calc(100vw - 24px));
display: grid; justify-items: center; gap: 8px; transform: translateX(-50%); pointer-events: none;
}
.whut-helper-toast {
position: relative; width: auto; max-width: 100%; min-height: 38px; padding: 9px 34px 9px 13px;
display: flex; align-items: center; gap: 8px; overflow: hidden; color: #fff; background: rgb(30 41 59 / 94%);
border: 0; border-radius: var(--uikit-radius-pill); box-shadow: var(--uikit-shadow-md); pointer-events: auto;
animation: uikitToastIn var(--uikit-duration-normal) var(--uikit-ease-emphasized) both;
}
.whut-helper-toast.success { background: color-mix(in srgb, var(--uikit-success) 94%, transparent); }
.whut-helper-toast.warning { background: color-mix(in srgb, var(--uikit-warning) 94%, transparent); }
.whut-helper-toast.error { background: color-mix(in srgb, var(--uikit-danger) 94%, transparent); }
.whut-helper-toast-icon { width: auto; height: auto; display: inline-flex; align-items: center; justify-content: center; color: inherit; font-size: 14px; background: transparent; }
.whut-helper-toast-body { min-width: 0; display: flex; flex-direction: column; }
.whut-helper-toast-title { overflow-wrap: anywhere; color: #fff; font-size: var(--uikit-text-sm); font-weight: 700; line-height: var(--uikit-line-normal); }
.whut-helper-toast-text { margin-top: 2px; overflow-wrap: anywhere; color: rgb(255 255 255 / 82%); font-size: var(--uikit-text-xs); font-weight: 600; line-height: var(--uikit-line-normal); }
.whut-helper-toast-close { position: absolute; top: 50%; right: 6px; width: 25px; height: 25px; padding: 0; display: inline-flex; align-items: center; justify-content: center; color: rgb(255 255 255 / 72%); font-size: 14px; cursor: pointer; background: transparent; border: 0; border-radius: var(--uikit-radius-pill); transform: translateY(-50%); }
.whut-helper-toast-close:hover { color: #fff; background: rgb(255 255 255 / 12%); }
.whut-helper-toast.is-leaving { animation: uikitToastOut var(--uikit-duration-fast) var(--uikit-ease-standard) both; }
.uikit-dialog-backdrop {
position: fixed; inset: 0; z-index: var(--uikit-z-dialog); padding: 20px; display: flex; align-items: center; justify-content: center;
background: var(--uikit-overlay); backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);
opacity: 0; transition: opacity var(--uikit-duration-fast) var(--uikit-ease-standard); pointer-events: auto;
}
.uikit-dialog-backdrop.is-show { opacity: 1; }
.uikit-dialog {
position: relative; width: min(420px, calc(100vw - 32px)); max-height: min(84vh, 760px); overflow: hidden;
display: flex; flex-direction: column; color: var(--uikit-text); background: var(--uikit-bg-elevated);
border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-lg); box-shadow: var(--uikit-shadow-lg);
transform: translateY(8px) scale(.98); transition: transform var(--uikit-duration-normal) var(--uikit-ease-emphasized);
}
.uikit-dialog-backdrop.is-show .uikit-dialog { transform: translateY(0) scale(1); }
.uikit-dialog::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 5px; background: var(--uikit-brand); }
.uikit-dialog.is-danger::before { background: var(--uikit-danger); }
.uikit-dialog__header { padding: 18px 42px 10px 24px; }
.uikit-dialog__eyebrow { color: var(--uikit-brand-text); font-size: var(--uikit-text-xs); font-weight: 900; letter-spacing: .12em; text-transform: uppercase; }
.uikit-dialog.is-danger .uikit-dialog__eyebrow { color: var(--uikit-danger); }
.uikit-dialog__title { margin: 4px 0 0; color: var(--uikit-text); font-size: 18px; font-weight: 850; }
.uikit-dialog__close { position: absolute; top: 9px; right: 9px; }
.uikit-dialog__content { min-height: 0; padding: 4px 24px 16px; overflow: auto; color: var(--uikit-text-muted); font-size: var(--uikit-text-sm); line-height: var(--uikit-line-relaxed); }
.uikit-dialog__content > p { margin: 0; white-space: pre-wrap; }
.uikit-dialog__validation { margin-top: 9px; padding: 7px 9px; color: var(--uikit-danger); background: var(--uikit-danger-soft); border-radius: var(--uikit-radius-sm); }
.uikit-dialog__actions { padding: 11px 15px 15px 24px; display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid var(--uikit-border); background: var(--uikit-bg-muted); }
.uikit-dialog__spinner { width: 22px; height: 22px; margin: 18px auto; border: 2px solid var(--uikit-border); border-top-color: var(--uikit-brand); border-radius: 50%; animation: uikitSpin .75s linear infinite; }
`,
responsiveAndMotion: `
/* 响应式断点与脚本规范一致:md 640、sm 480、xs 360。 */
.helper-header, .helper-toolbar, .helper-content, .monitor-container, .gpa-container, .export-container,
.helper-footer, .uikit-dialog, .uikit-dialog__actions {
transition-property: background-color, color, border-color, box-shadow;
transition-duration: var(--uikit-theme-duration);
transition-timing-function: var(--uikit-theme-ease);
}
@media (max-width: 640px) {
#whut-helper-panel {
min-width: 0; width: calc(100vw - 24px) !important; height: min(72vh, 620px) !important;
max-width: calc(100vw - 24px); max-height: calc(100vh - 24px); border-radius: var(--uikit-radius-lg);
}
#whut-fab { right: 12px; bottom: max(12px, env(safe-area-inset-bottom)); }
.helper-header { padding: 0; }
.header-top { margin: 0; }
.header-logo { width: 34px; height: 34px; flex-basis: 34px; border-radius: var(--uikit-radius-md); }
.header-name { font-size: 15px; }
.helper-tabs { overflow-x: auto; }
.helper-toolbar, .helper-content, .monitor-container, .gpa-container, .export-container { padding-left: 14px; padding-right: 14px; }
#pj-list-area { padding-left: 14px !important; padding-right: 14px !important; }
.monitor-group-header { grid-template-columns: 18px minmax(0, 1fr) auto; }
.monitor-group-credit { grid-column: 2 / 4; grid-row: 2; }
}
@media (max-width: 480px) {
.header-update { min-width: 86px; width: auto; padding: 0 9px; }
.search-box { grid-template-columns: minmax(0, 1fr) 72px; }
.filter-row { grid-template-columns: 1fr; }
.action-row { display: grid; grid-template-columns: 1fr; }
.monitor-toolbar-row, .eval-status-row { align-items: stretch; flex-direction: column; }
.monitor-actions, .eval-status-actions { width: 100%; }
.monitor-actions .btn-modern, .eval-status-actions .btn-modern { flex: 1; }
.eval-status-note { display: none; }
.eval-grid, .gpa-flat-grid, .update-version-grid, .diagnostics-summary { grid-template-columns: 1fr; }
.gpa-flat-metric { padding: 2px 2px 12px; }
.gpa-flat-metric + .gpa-flat-metric { padding: 12px 2px 2px; border-top: 1px solid var(--uikit-border); border-left: 0; }
.eval-comment { grid-column: auto; }
.eval-summary { grid-template-columns: 1fr; }
.eval-summary-stats { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.course-summary { min-height: 82px; padding: 13px 14px; }
.course-score { min-width: 62px; font-size: 17px; }
.helper-footer { grid-template-columns: repeat(2, minmax(0, 1fr)); padding-left: 12px; padding-right: 12px; }
.monitor-overview-value { font-size: 17px; }
.monitor-progress-meta { flex-direction: column; gap: 3px; }
.print-row { align-items: stretch; flex-direction: column; }
.print-row .btn-modern { width: 100%; }
.help-feature-grid, .help-people { grid-template-columns: 1fr; }
}
@media (max-width: 360px) {
.header-logo { display: none; }
.header-name { font-size: 14px; }
.header-actions { gap: 5px; }
.header-action { min-width: 35px; width: 35px; height: 35px; padding: 0; }
.header-update { min-width: 86px; width: auto; padding: 0 9px; }
.helper-toolbar { padding-top: 11px; padding-bottom: 11px; }
.helper-content, .monitor-container, .gpa-container, .export-container { padding: 12px 10px; }
.gpa-container { --gpa-sticky-offset: 12px; }
#pj-list-area { padding: 12px 10px !important; }
.eval-summary-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* 根据面板本身的宽度适配,也覆盖桌面窗口缩放。 */
@container whut-panel (max-width: 640px) {
.helper-tab { min-width: 0; flex: 1 1 0; padding-inline: 4px; }
.helper-tab-label { display: none; }
.helper-tab-label-short { display: inline; }
.helper-toolbar, .helper-content, .monitor-container, .gpa-container, .export-container { padding-inline: 12px; }
.monitor-group-header { grid-template-columns: 18px minmax(0, 1fr) auto; }
.monitor-group-credit { grid-column: 2 / 4; grid-row: 2; }
}
@container whut-panel (max-width: 480px) {
.filter-row, .eval-grid, .gpa-flat-grid { grid-template-columns: minmax(0, 1fr); }
.action-row { display: grid; grid-template-columns: minmax(0, 1fr); }
.monitor-toolbar-row, .eval-status-row { align-items: stretch; flex-direction: column; }
.monitor-actions, .eval-status-actions { width: 100%; flex-wrap: wrap; }
.monitor-actions .btn-modern, .eval-status-actions .btn-modern { flex: 1; }
.gpa-flat-metric + .gpa-flat-metric { border-left: 0; border-top: 1px solid var(--uikit-border); }
.eval-comment { grid-column: auto; }
.eval-summary { grid-template-columns: minmax(0, 1fr); }
.helper-footer { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.analysis-disclosure-bar, .analysis-search-row { padding-inline: 12px; }
}
@container whut-panel (max-width: 420px) {
.helper-tab-icon { display: none; }
.helper-tab { min-height: 44px; font-size: 12px; }
}
@media (pointer: coarse) {
.search-input, .eval-field input, .eval-field textarea, .uikit-dialog .gpa-sem-input { font-size: 16px; }
.ui-select-trigger, .ui-select-option, .gpa-segment-button { min-height: 40px; }
.ui-search-clear, .schedule-detail-link { min-height: 32px; }
.helper-tab, .ui-select-trigger, .schedule-detail-link, .btn-modern { touch-action: manipulation; }
}
@media (max-height: 520px) {
.header-top { min-height: 44px; padding-block: 4px; gap: 6px; }
.header-logo { width: 28px; height: 28px; flex-basis: 28px; }
.header-name { font-size: 14px; }
.helper-tab { min-height: 40px; }
.uikit-dialog__header { padding-top: 10px; }
.uikit-dialog__actions { padding-block: 8px; }
}
@media (prefers-reduced-motion: reduce) {
:host, :host * {
scroll-behavior: auto !important; animation-duration: 1ms !important; animation-iteration-count: 1 !important; transition-duration: 1ms !important;
}
.uikit-hover-lifted { transform: none; }
}
@media (prefers-contrast: more) {
.btn-modern, .header-action, .search-input, .ui-select-trigger,
.eval-field textarea, .eval-field input, .uikit-dialog .gpa-sem-input, #whut-helper-panel, .uikit-dialog,
.course-item, .gpa-card, .gpa-segmented, .export-card, .monitor-overview, .monitor-groups,
.eval-config-card, #pj-list-area > .pj-group { border-width: 2px; }
:focus-visible { outline-width: 4px !important; }
}
@media (forced-colors: active) {
:host { forced-color-adjust: auto; }
.helper-tab.active, .gpa-segment-button.active, .ui-select-option[aria-selected='true'] {
color: HighlightText; background: Highlight; outline: 1px solid Highlight;
}
.btn-modern, .header-action, .search-input, .ui-select-trigger, .ui-select-menu,
.course-item, .gpa-card, .export-card, .monitor-overview, .monitor-groups { border-color: ButtonText; }
.btn-spinner, .ui-state-spinner { border-color: CanvasText; border-top-color: Highlight; }
.monitor-progress-fill, .dist-fill { background: Highlight; }
}
@keyframes uikitPanelIn { from { opacity: 0; transform: translateY(10px) scale(.975); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes uikitViewIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
@keyframes uikitDetailIn { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: translateY(0); } }
@keyframes uikitOverlayIn { from { opacity: 0; transform: translateY(-6px) scale(.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes uikitToastIn { from { opacity: 0; transform: translateY(-8px) scale(.97); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes uikitToastOut { to { opacity: 0; transform: translateY(-8px) scale(.97); } }
@keyframes uikitSpin { to { transform: rotate(360deg); } }
@keyframes gpaSectionIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
`
});
const SCHEDULE_STYLE = `
.schedule-content { display: flex; flex-direction: column; gap: 10px; overflow-x: hidden; container: whut-schedule / inline-size; }
.schedule-content > * { flex-shrink: 0; }
.schedule-summary-bar { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); background: var(--uikit-bg-elevated); }
.schedule-summary-bar .schedule-status { flex: 1; min-width: 0; flex-wrap: nowrap; overflow-x: auto; scrollbar-width: thin; }
.schedule-status .tag { flex: 0 0 auto; max-width: none; white-space: nowrap; }
.schedule-status .tag:only-child { max-width: 100%; white-space: normal; }
.schedule-summary-bar .schedule-actions { flex: 0 0 auto; flex-wrap: nowrap; gap: 5px; }
.schedule-summary-bar .btn-modern { min-height: 28px; padding: 4px 7px; font-size: 11px; white-space: nowrap; }
.schedule-export-short { display: none; }
#schedule-week-prev .uikit-icon, #schedule-back .uikit-icon { transform: rotate(180deg); }
.schedule-controls { display: grid; gap: 9px; container-type: inline-size; }
#schedule-modes { margin: 0; position: relative; top: auto; }
.schedule-form { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
.schedule-search-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 8px; }
.schedule-primary { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); align-items: end; gap: 8px; }
.schedule-form-actions { display: flex; justify-content: flex-end; gap: 6px; padding-bottom: 1px; }
.schedule-form-actions .btn-modern { white-space: nowrap; }
.schedule-field { display: flex; flex-direction: column; gap: 3px; min-width: 0; color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); }
.schedule-field .search-input { width: 100%; min-width: 0; padding-left: 10px; padding-right: 10px; }
.schedule-fields { display: grid; grid-column: 1; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 8px; }
.schedule-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.schedule-status, .schedule-note { color: var(--uikit-text-muted); font-size: var(--uikit-text-xs); overflow-wrap: anywhere; }
.schedule-status { margin: 0; min-height: 1.5em; }
.schedule-result-list { display: grid; gap: 8px; }
.schedule-result { display: flex; flex-direction: column; gap: 5px; align-items: stretch; width: 100%; padding: 12px; text-align: left; color: var(--uikit-text); background: var(--uikit-bg-elevated); border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); cursor: pointer; }
.schedule-result:hover { background: var(--uikit-surface-hover); }
.schedule-result small { color: var(--uikit-text-muted); overflow-wrap: anywhere; }
.schedule-result-title { font-weight: 750; overflow-wrap: anywhere; }
.schedule-pager { justify-content: center; padding: 12px 0; }
.scroll-sync-top { overflow-x: scroll; overflow-y: hidden; height: 14px; max-width: 100%; }
.scroll-sync-top > div { height: 1px; }
.schedule-grid-shell { margin-top: 8px; min-width: 0; max-width: 100%; overflow: hidden; }
.schedule-week-scroll { overflow: auto; max-height: min(900px, 80vh); max-height: min(900px, 80dvh); scrollbar-width: none; border: 1px solid var(--uikit-border); border-radius: var(--uikit-radius-md); isolation: isolate; touch-action: pan-x pan-y pinch-zoom; }
/* 隐藏原生滚动条本体,避免全局 scrollbar-color / width 再启用底部轨道。 */
.uikit-root .schedule-week-scroll { scrollbar-width: none !important; -ms-overflow-style: none; }
.uikit-root .schedule-week-scroll::-webkit-scrollbar { display: none !important; width: 0 !important; height: 0 !important; }
.schedule-week { display: grid; grid-template-columns: 80px repeat(7, minmax(108px, 1fr)); min-width: 836px; background: var(--uikit-bg-elevated); }
.schedule-grid-head, .schedule-period, .schedule-cell { min-width: 0; border-right: 1px solid var(--uikit-border); border-bottom: 1px solid var(--uikit-border); }
.schedule-grid-head { position: sticky; top: 0; z-index: 2; min-height: 34px; display: grid; place-items: center; background: var(--uikit-bg-muted); font-size: var(--uikit-text-xs); font-weight: 750; }
.schedule-grid-corner { left: 0; z-index: 3; }
.schedule-period { position: sticky; left: 0; z-index: 1; padding: 8px 5px; display: grid; place-content: center; gap: 5px; text-align: center; background: var(--uikit-bg-muted); font-size: var(--uikit-text-xs); }
.schedule-period small { color: var(--uikit-text-muted); font-size: 10px; }
.schedule-cell { min-height: 78px; padding: 5px; display: grid; align-content: start; gap: 5px; }
.schedule-course { --schedule-color: var(--uikit-brand); margin: 0; padding: 7px 8px; background: color-mix(in srgb, var(--schedule-color) 13%, var(--uikit-bg-elevated)); border: 1px solid color-mix(in srgb, var(--schedule-color) 38%, var(--uikit-border)); border-radius: var(--uikit-radius-sm); box-shadow: inset 3px 0 var(--schedule-color); overflow-wrap: anywhere; font-size: var(--uikit-text-xs); }
.schedule-course { display: grid; gap: 4px; }
.schedule-course strong { color: var(--uikit-text); font-size: 11px; line-height: 1.4; }
.schedule-course time, .schedule-course small { color: var(--uikit-text-muted); font-size: 10px; line-height: 1.4; }
.schedule-detail-link { justify-self: start; padding: 2px 0; border: 0; border-radius: var(--uikit-radius-sm); background: transparent; color: var(--uikit-brand-text); font-size: 10px; cursor: pointer; }
.schedule-detail-link:hover { text-decoration: underline; }
.schedule-detail-link:focus-visible { outline: 2px solid var(--uikit-brand); outline-offset: 2px; }
.schedule-detail-meta { margin-bottom: 12px; }
.schedule-jqxx { white-space: pre-wrap; overflow-wrap: anywhere; margin: 2px 0 0; color: var(--uikit-text); line-height: 1.7; }
.schedule-course.is-conflict { border-color: var(--uikit-danger); box-shadow: inset 3px 0 var(--uikit-danger); }
.schedule-conflict { color: var(--uikit-danger); font-size: 10px; }
.schedule-unscheduled { margin-top: 8px; display: grid; gap: 6px; }
.course-score { max-width: 42%; overflow-wrap: anywhere; }
@media (max-width: 520px) { .schedule-search-row { grid-template-columns: minmax(0, 1fr); } .schedule-primary { grid-template-columns: repeat(2, minmax(0, 1fr)); } .schedule-arranged-field { grid-column: 1 / -1; } .schedule-filter-toggle span, .schedule-week-step span { display: none; } .schedule-summary-bar { gap: 5px; padding: 6px; } .schedule-summary-bar .schedule-actions { gap: 3px; } }
@container whut-schedule (max-width: 520px) { .schedule-search-row { grid-template-columns: minmax(0, 1fr); } .schedule-primary { grid-template-columns: repeat(2, minmax(0, 1fr)); } .schedule-arranged-field { grid-column: 1 / -1; } }
@container whut-schedule (max-width: 560px) {
.schedule-summary-bar { gap: 5px; padding: 6px; }
.schedule-summary-bar .schedule-actions, .schedule-summary-bar .disclosure-actions { gap: 3px; }
.schedule-filter-toggle span, .schedule-week-step span, .schedule-export-full { display: none; }
.schedule-export-short { display: inline; }
.schedule-summary-bar .btn-modern { padding-inline: 6px; }
.schedule-back-label { display: none; }
.schedule-week { grid-template-columns: 64px repeat(7, minmax(108px, 1fr)); min-width: 820px; }
}
@media (pointer: coarse) {
.schedule-summary-bar .btn-modern { min-height: 34px; min-width: 34px; }
.scroll-sync-top { height: 18px; }
}
`;
const APP_STYLE = Object.values(STYLE_MODULES).join('\n') + SCHEDULE_STYLE;
// ========================================================================
// 模块:UI 运行时
// 职责:管理单一 Host、Shadow Root、主题状态与分层浮层容器。
// ========================================================================
const UIKitRuntime = (() => {
const HOST_ID = 'whut-uikit-host';
const THEME_KEY = 'uikit:whut-jw-helper:theme';
const layerNames = ['panel', 'menu', 'dialog', 'toast'];
let host = null;
let shadow = null;
let root = null;
let layers = null;
let styleNode = null;
let themeMode = 'auto';
let mediaQuery = null;
let mediaListener = null;
const storageGet = (key, fallback) => {
try { return localStorage.getItem(key) || fallback; } catch (e) { return fallback; }
};
const storageSet = (key, value) => {
try { localStorage.setItem(key, value); } catch (e) { /* 页面可能限制存储 */ }
};
const validTheme = value => ['light', 'dark', 'auto'].includes(value) ? value : 'auto';
function resolveTheme() {
if (themeMode !== 'auto') return themeMode;
return mediaQuery?.matches ? 'dark' : 'light';
}
function applyTheme() {
if (!host) return;
host.dataset.themeMode = themeMode;
host.dataset.theme = resolveTheme();
}
function bindThemeListener() {
if (mediaQuery || typeof matchMedia !== 'function') return;
mediaQuery = matchMedia('(prefers-color-scheme: dark)');
mediaListener = () => { if (themeMode === 'auto') applyTheme(); };
if (typeof mediaQuery.addEventListener === 'function') mediaQuery.addEventListener('change', mediaListener);
else mediaQuery.addListener?.(mediaListener);
}
function appendHost() {
const parent = document.documentElement || document.body;
if (parent && host && !host.isConnected) parent.appendChild(host);
}
function createRuntime(cssText = APP_STYLE) {
host = document.createElement('div');
host.id = HOST_ID;
// layout/paint containment 会把 fixed 子元素错误约束在 0×0 Host 内,
// 因此这里只隔离样式,让面板和浮层继续以浏览器视口为定位基准。
host.style.cssText = 'all:initial;position:fixed;inset:0 auto auto 0;width:0;height:0;z-index:2147483647;pointer-events:none;contain:style;';
shadow = host.attachShadow({ mode: 'open' });
styleNode = document.createElement('style');
styleNode.dataset.uikitStyle = 'whut-jw-helper';
styleNode.textContent = cssText;
root = document.createElement('div');
root.className = 'uikit-root';
root.setAttribute('data-uikit-version', '1.0.0');
layers = Object.create(null);
layerNames.forEach(name => {
const layer = document.createElement('div');
layer.className = `uikit-layer uikit-layer--${name}`;
layer.dataset.layer = name;
root.appendChild(layer);
layers[name] = layer;
});
shadow.append(styleNode, root);
appendHost();
bindThemeListener();
themeMode = validTheme(storageGet(THEME_KEY, 'auto'));
applyTheme();
return api;
}
function ensure(cssText = APP_STYLE) {
if (host && shadow && root && layers) {
if (!host.isConnected) appendHost();
if (styleNode && styleNode.textContent !== cssText) styleNode.textContent = cssText;
return api;
}
const existing = document.getElementById(HOST_ID);
if (existing) existing.remove();
return createRuntime(cssText);
}
function setTheme(mode) {
themeMode = validTheme(mode);
storageSet(THEME_KEY, themeMode);
ensure();
applyTheme();
shadow?.dispatchEvent(new CustomEvent('uikit-theme-change', { detail: { mode: themeMode, resolved: resolveTheme() } }));
}
function cycleTheme() {
const modes = ['auto', 'light', 'dark'];
setTheme(modes[(modes.indexOf(themeMode) + 1) % modes.length]);
return themeMode;
}
function mount(layerName, node) {
ensure();
const layer = layers[layerName] || layers.panel;
layer.appendChild(node);
return node;
}
function destroy() {
if (mediaQuery && mediaListener) {
if (typeof mediaQuery.removeEventListener === 'function') mediaQuery.removeEventListener('change', mediaListener);
else mediaQuery.removeListener?.(mediaListener);
}
host?.remove();
host = shadow = root = layers = styleNode = mediaQuery = mediaListener = null;
}
const api = {
ensure,
mount,
cycleTheme,
getTheme: () => themeMode,
getResolvedTheme: () => resolveTheme(),
getById: id => (ensure(), shadow.getElementById(id)),
queryAll: selector => (ensure(), Array.from(shadow.querySelectorAll(selector))),
get shadow() { ensure(); return shadow; },
destroy
};
return api;
})();
const uiGet = id => UIKitRuntime.getById(id);
const uiQueryAll = selector => UIKitRuntime.queryAll(selector);
// ========================================================================
// 组件:按钮
// 职责:统一创建、图标按钮语义、异步锁定及加载文案恢复。
// ========================================================================
const ButtonControl = Object.freeze({
enhance(button) {
if (!button) return null;
if (!button.type) button.type = 'button';
button.classList.add('uikit-button');
if (button.classList.contains('header-action')) {
button.classList.add('uikit-icon-button');
const accessibleName = button.getAttribute('aria-label') || button.title;
if (accessibleName) {
button.title ||= accessibleName;
button.setAttribute('aria-label', accessibleName);
}
}
return button;
},
enhanceAll(root) {
root?.querySelectorAll('button.btn-modern, button.header-action').forEach(button => this.enhance(button));
},
create(label, { className = '', icon = '', title = '', ariaLabel = '' } = {}) {
const button = document.createElement('button');
button.type = 'button';
button.className = `btn-modern ${className}`.trim();
if (icon) button.insertAdjacentHTML('afterbegin', iconSVG(icon));
const text = document.createElement('span');
text.textContent = String(label ?? '');
button.appendChild(text);
if (title) button.title = title;
if (ariaLabel || title) button.setAttribute('aria-label', ariaLabel || title);
return this.enhance(button);
},
setLoading(button, busy, busyLabel = '处理中...') {
if (!button) return;
this.enhance(button);
if (busy) {
if (!button.dataset.idleHtml) {
button.dataset.idleHtml = button.innerHTML;
button.dataset.idleDisabled = String(button.disabled);
}
button.disabled = true;
button.classList.add('is-loading');
button.setAttribute('aria-busy', 'true');
button.replaceChildren();
const spinner = document.createElement('span');
spinner.className = 'btn-spinner';
spinner.setAttribute('aria-hidden', 'true');
const label = document.createElement('span');
label.textContent = String(busyLabel || '处理中...');
button.append(spinner, label);
return;
}
button.classList.remove('is-loading');
button.removeAttribute('aria-busy');
if (button.dataset.idleHtml) button.innerHTML = button.dataset.idleHtml;
button.disabled = button.dataset.idleDisabled === 'true';
delete button.dataset.idleHtml;
delete button.dataset.idleDisabled;
}
});
// ========================================================================
// 组件:对话框
// 职责:提供队列、焦点锁定、焦点恢复、异步确认与表单校验。
// ========================================================================
const AppDialog = (() => {
let current = null;
let queue = Promise.resolve();
let showValidationHandler = null;
const normalizeOptions = args => {
if (typeof args[0] === 'string') return { title: args[0], text: args[1] || '', icon: args[2] || 'info' };
return { ...(args[0] || {}) };
};
function focusableOf(dialog) {
return Array.from(dialog.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'))
.filter(node => !node.hidden && node.getAttribute('aria-hidden') !== 'true');
}
function closeCurrent(result = { isDismissed: true }) {
if (!current) return;
const entry = current;
current = null;
showValidationHandler = null;
clearTimeout(entry.timer);
entry.backdrop.classList.remove('is-show');
setTimeout(() => entry.backdrop.remove(), 170);
entry.previousFocus?.focus?.({ preventScroll: true });
entry.resolve(result);
}
async function open(options) {
UIKitRuntime.ensure();
const previousFocus = UIKitRuntime.shadow.activeElement || document.activeElement;
const backdrop = document.createElement('div');
backdrop.className = 'uikit-dialog-backdrop';
const dialog = document.createElement('section');
dialog.className = `uikit-dialog${options.icon === 'error' || options.tone === 'danger' ? ' is-danger' : ''}`;
dialog.setAttribute('role', options.icon === 'warning' || options.icon === 'error' ? 'alertdialog' : 'dialog');
dialog.setAttribute('aria-modal', 'true');
if (options.width) dialog.style.width = typeof options.width === 'number' ? `${options.width}px` : String(options.width);
const titleId = `uikit-dialog-title-${Date.now().toString(36)}`;
const descId = `uikit-dialog-desc-${Date.now().toString(36)}`;
const header = document.createElement('header');
header.className = 'uikit-dialog__header';
const eyebrow = document.createElement('div');
eyebrow.className = 'uikit-dialog__eyebrow';
eyebrow.textContent = options.eyebrow || ({ success: '操作完成', error: '发生错误', warning: '请注意', question: '请确认' }[options.icon] || 'WHUT 助手');
const title = document.createElement('h2');
title.className = 'uikit-dialog__title';
title.id = titleId;
title.textContent = options.title || '提示';
header.append(eyebrow, title);
dialog.setAttribute('aria-labelledby', titleId);
if (options.showCloseButton) {
const close = document.createElement('button');
close.type = 'button';
close.className = 'header-action uikit-dialog__close';
close.setAttribute('aria-label', '关闭对话框');
close.innerHTML = iconSVG('close');
ButtonControl.enhance(close);
close.addEventListener('click', () => closeCurrent({ isDismissed: true, dismiss: 'close' }));
header.appendChild(close);
}
const content = document.createElement('div');
content.className = 'uikit-dialog__content';
content.id = descId;
dialog.setAttribute('aria-describedby', descId);
if (options.unsafeHTML) content.innerHTML = String(options.unsafeHTML);
else {
const paragraph = document.createElement('p');
paragraph.textContent = options.text || '';
content.appendChild(paragraph);
}
const validation = document.createElement('div');
validation.className = 'uikit-dialog__validation';
validation.setAttribute('role', 'alert');
validation.hidden = true;
content.appendChild(validation);
const actions = document.createElement('footer');
actions.className = 'uikit-dialog__actions';
const makeAction = (label, className) => ButtonControl.create(label, { className });
let cancelButton = null;
let denyButton = null;
let confirmButton = null;
if (options.showCancelButton) {
cancelButton = makeAction(options.cancelButtonText || '取消', 'btn-outline');
cancelButton.addEventListener('click', () => closeCurrent({ isDismissed: true, dismiss: 'cancel' }));
actions.appendChild(cancelButton);
}
if (options.showDenyButton) {
denyButton = makeAction(options.denyButtonText || '稍后', 'btn-outline');
denyButton.addEventListener('click', () => closeCurrent({ isDenied: true, value: false }));
actions.appendChild(denyButton);
}
if (options.showConfirmButton !== false) {
confirmButton = makeAction(options.confirmButtonText || '确定', options.tone === 'danger' ? 'btn-danger' : '');
actions.appendChild(confirmButton);
}
if (actions.children.length) dialog.append(header, content, actions);
else dialog.append(header, content);
backdrop.appendChild(dialog);
UIKitRuntime.mount('dialog', backdrop);
const resultPromise = new Promise(resolve => {
current = { backdrop, dialog, content, validation, actions, confirmButton, cancelButton, denyButton, previousFocus, resolve, timer: 0 };
});
options.didMount?.(dialog);
const showValidation = message => {
validation.textContent = String(message || '请检查输入内容');
validation.hidden = false;
};
if (confirmButton) confirmButton.addEventListener('click', async () => {
validation.hidden = true;
let value = true;
if (typeof options.preConfirm === 'function') {
confirmButton.disabled = true;
confirmButton.classList.add('is-loading');
try { value = await options.preConfirm(); }
catch (error) { showValidation(error?.message || String(error)); value = false; }
finally { confirmButton.disabled = false; confirmButton.classList.remove('is-loading'); }
if (value === false || !validation.hidden) return;
}
closeCurrent({ isConfirmed: true, value });
});
const keyHandler = event => {
if (!current || current.dialog !== dialog) return;
if (event.key === 'Escape' && options.closeOnEscape !== false) {
event.preventDefault();
closeCurrent({ isDismissed: true, dismiss: 'escape' });
return;
}
if (event.key !== 'Tab') return;
const focusable = focusableOf(dialog);
if (!focusable.length) { event.preventDefault(); dialog.focus(); return; }
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && UIKitRuntime.shadow.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && UIKitRuntime.shadow.activeElement === last) { event.preventDefault(); first.focus(); }
};
backdrop.addEventListener('keydown', keyHandler);
backdrop.addEventListener('mousedown', event => {
if (event.target === backdrop && options.allowOutsideClick !== false && options.closeOnBackdrop !== false) {
closeCurrent({ isDismissed: true, dismiss: 'backdrop' });
}
});
requestAnimationFrame(() => {
if (!current || current.dialog !== dialog || !backdrop.isConnected) return;
backdrop.classList.add('is-show');
const initial = options.initialFocus === 'cancel' ? cancelButton : (confirmButton || cancelButton || denyButton || dialog);
if (!initial.hasAttribute('tabindex') && initial === dialog) initial.tabIndex = -1;
initial?.focus?.({ preventScroll: true });
options.didOpen?.(dialog);
});
if (options.timer > 0) current.timer = setTimeout(() => closeCurrent({ isDismissed: true, dismiss: 'timer' }), options.timer);
showValidationHandler = showValidation;
return resultPromise;
}
const api = {
details({ title, html, width = 780, exportRows = null, exportName = title }) {
const rows = Array.isArray(exportRows) ? exportRows.map(row => ({ ...row })) : null;
return api.fire({
title, unsafeHTML: html, width: `min(${width}px, calc(100vw - 32px))`, showCloseButton: true, confirmButtonText: '关闭',
didMount(dialog) {
if (!rows) return;
const button = ButtonControl.create('导出为 Excel', { className: 'btn-outline detail-export-excel', icon: 'table' });
button.disabled = rows.length === 0;
button.addEventListener('click', () => {
dialog.querySelector('.uikit-dialog__validation').hidden = true;
try { exportToBrowser(rows, exportName, 'xlsx'); }
catch (error) { api.showValidationMessage(error?.message || '导出失败,请重试'); }
});
dialog.querySelector('.uikit-dialog__actions').prepend(button);
}
});
},
fire(...args) {
const options = normalizeOptions(args);
const task = () => open(options);
const result = queue.then(task, task);
queue = result.catch(() => ({}));
return result;
},
close: () => closeCurrent({ isDismissed: true, dismiss: 'programmatic' }),
showLoading() {
if (!current) return;
current.content.innerHTML = '
';
Array.from(current.actions.children).forEach(button => { button.disabled = true; });
},
showValidationMessage(message) { showValidationHandler?.(message); },
getCurrentDialog: () => current?.dialog || null
};
return api;
})();
// ========================================================================
// 模块:共享基础设施
// 职责:提供通知、状态、存储、诊断日志和通用数据工具。
// ========================================================================
function showHelperToast({ icon = 'info', title = '', text = '', timer = 3000 } = {}) {
const type = ['success', 'error', 'warning', 'info'].includes(icon) ? icon : 'info';
const iconMap = { success: 'check', error: 'close', warning: 'warning', info: 'info' };
const duration = Math.max(1200, type === 'error' ? Math.max(timer, 5000) : timer);
const dedupeKey = `${type}|${title}|${text}`;
let stack = uiGet('whut-toast-stack');
if (!stack) {
stack = document.createElement('div');
stack.id = 'whut-toast-stack';
stack.setAttribute('aria-live', 'polite');
stack.setAttribute('aria-atomic', 'false');
UIKitRuntime.mount('toast', stack);
}
const duplicate = Array.from(stack.children).find(node => node.dataset.dedupeKey === dedupeKey);
if (duplicate) {
duplicate.classList.remove('is-leaving');
duplicate._uikitRefresh?.();
duplicate.animate?.([{ transform: 'scale(.98)' }, { transform: 'scale(1)' }], { duration: 160, easing: 'ease-out' });
return;
}
while (stack.children.length >= 3) stack.firstElementChild?.remove();
const toast = document.createElement('div');
toast.className = `whut-helper-toast ${type}`;
toast.setAttribute('role', type === 'error' ? 'alert' : 'status');
toast.dataset.dedupeKey = dedupeKey;
const iconNode = document.createElement('span');
iconNode.className = 'whut-helper-toast-icon';
iconNode.setAttribute('aria-hidden', 'true');
iconNode.innerHTML = iconSVG(iconMap[type]);
const body = document.createElement('span');
body.className = 'whut-helper-toast-body';
const titleNode = document.createElement('span');
titleNode.className = 'whut-helper-toast-title';
titleNode.textContent = title || text || '';
body.appendChild(titleNode);
if (title && text) {
const textNode = document.createElement('span');
textNode.className = 'whut-helper-toast-text';
textNode.textContent = text;
body.appendChild(textNode);
}
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'whut-helper-toast-close';
closeButton.setAttribute('aria-label', '关闭通知');
closeButton.innerHTML = iconSVG('close');
toast.append(iconNode, body, closeButton);
let timerId = 0;
const dismiss = () => {
if (!toast.isConnected || toast.classList.contains('is-leaving')) return;
clearTimeout(timerId);
toast.classList.add('is-leaving');
setTimeout(() => {
toast.remove();
if (!stack.children.length) stack.remove();
}, 190);
};
closeButton.addEventListener('click', dismiss);
stack.appendChild(toast);
const scheduleDismiss = () => {
clearTimeout(timerId);
timerId = setTimeout(dismiss, duration);
};
toast._uikitRefresh = scheduleDismiss;
scheduleDismiss();
}
const Toast = Object.freeze({ fire: showHelperToast });
const UIKit = Object.freeze({
ensure: () => UIKitRuntime.ensure(APP_STYLE),
mount: (layer, node) => UIKitRuntime.mount(layer, node),
cycleTheme: () => UIKitRuntime.cycleTheme(),
getTheme: () => UIKitRuntime.getTheme(),
destroy: () => UIKitRuntime.destroy()
});
const EVAL_DEFAULT_CONFIG = {
comment: "老师讲课重点突出,条理清晰,内容丰富,受益匪浅。",
delay: 1500,
randomScore: false
};
const SCRIPT_ID = 'whut-jw-helper';
const SCRIPT_UPDATE_ORIGIN = 'https://home.haoli.site';
const SCRIPT_UPDATE = Object.freeze({
metaURL: `${SCRIPT_UPDATE_ORIGIN}/assets/scripts/${SCRIPT_ID}/${SCRIPT_ID}.meta.js`,
infoURL: `${SCRIPT_UPDATE_ORIGIN}/assets/scripts/${SCRIPT_ID}/${SCRIPT_ID}.latest.json`,
downloadURL: `${SCRIPT_UPDATE_ORIGIN}/assets/scripts/${SCRIPT_ID}/${SCRIPT_ID}.user.js`,
projectURL: 'https://scriptcat.org/zh-CN/script-show-page/6588'
});
const UPDATE_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
const SCRIPT_VERSION = '4.0.0';
// 统一的容错存储适配器必须先于状态初始化创建,避免初始化阶段再保留一层只读包装。
const safeStorage = Object.freeze({
get(key, fallback = null) {
try {
const value = localStorage.getItem(key);
return value === null ? fallback : value;
} catch (e) {
return fallback;
}
},
set(key, value) {
try { localStorage.setItem(key, String(value)); return true; } catch (e) { return false; }
},
remove(key) {
try { localStorage.removeItem(key); return true; } catch (e) { return false; }
},
getJSON(key, fallback = {}) {
try {
const value = JSON.parse(this.get(key, 'null'));
return value && typeof value === 'object' && !Array.isArray(value) ? value : fallback;
} catch (e) {
return fallback;
}
},
setJSON(key, value) {
return this.set(key, JSON.stringify(value));
}
});
const state = {
userId: null, userName: null, userInfo: null, userInfoLoadedAt: 0,
dataMap: new Map(), rawData: [], filteredData: [],
filters: { search: '', semesters: new Set(), semesterAll: true, types: new Set(), typeAll: true, sort: 'semester_desc' },
semesters: new Set(), types: new Set(),
isFetching: false, isPrintLoaded: false, isPrintLoading: false,
stats: { totalCredit: 0, avgGPA: 0, compulsoryGPA: 0, count: 0 },
monitor: {
isLoaded: false, isRefreshing: false, isLoading: false, renderAfterLoad: false,
overview: { required: 0, earned: 0, passed: true, calculatedTime: "" },
categories: [], warnings: [], scoreFallbacks: [], studyParams: null,
openGroups: new Set(), openGroupsTouched: false
},
transcript: {
isLoading: false, isLoaded: false, promise: null,
report: null, records: [], error: '', lastAttempt: 0
},
mobileScores: { loaded: false, promise: null, rows: [], attempted: false },
update: { isLoaded: false, isChecking: false, info: null, error: '', hasNew: false },
eval: {
isLoaded: false, isLoading: false, isLoadingHistory: false, isRunning: false, loadPromise: null, historyPromise: null,
lastError: '', courses: [], pendingCourses: [], completedCourses: [], completedSemesters: [], completedLoaded: false, types: [], termCode: '', detailCache: new Map(),
config: {
comment: safeStorage.get('whut_pj_comment', EVAL_DEFAULT_CONFIG.comment) || EVAL_DEFAULT_CONFIG.comment,
delay: parseInt(safeStorage.get('whut_pj_delay', ''), 10) || EVAL_DEFAULT_CONFIG.delay,
randomScore: safeStorage.get('whut_pj_random', String(EVAL_DEFAULT_CONFIG.randomScore)) !== 'false'
}
}
};
const debounce = (func, wait) => {
let timeout = null;
function debounced(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => {
timeout = null;
func.apply(this, args);
}, wait);
}
debounced.cancel = () => {
clearTimeout(timeout);
timeout = null;
};
return debounced;
};
// 全局事件统一登记,页面退出时可一次释放;面板内部事件随 Shadow Host 移除。
const AppEvents = (() => {
const controller = new AbortController();
function listen(target, type, listener, options = {}) {
if (!target?.addEventListener || controller.signal.aborted) return;
const normalized = typeof options === 'boolean' ? { capture: options } : options;
target.addEventListener(type, listener, { ...normalized, signal: controller.signal });
}
return Object.freeze({ listen, destroy: () => controller.abort() });
})();
const AppResources = (() => {
const cleanups = new Set();
const timeouts = new Set();
const intervals = new Set();
let destroyed = false;
return Object.freeze({
add(cleanup) {
if (typeof cleanup !== 'function') return cleanup;
if (destroyed) {
try { cleanup(); } catch (error) { /* 页面退出时忽略清理异常 */ }
} else cleanups.add(cleanup);
return cleanup;
},
schedule(callback, delay) {
if (destroyed) return null;
const timer = setTimeout(() => {
timeouts.delete(timer);
callback();
}, delay);
timeouts.add(timer);
return timer;
},
repeat(callback, delay) {
if (destroyed) return null;
const timer = setInterval(callback, delay);
intervals.add(timer);
return timer;
},
clearRepeat(timer) {
clearInterval(timer);
intervals.delete(timer);
},
destroy() {
if (destroyed) return;
destroyed = true;
timeouts.forEach(clearTimeout);
intervals.forEach(clearInterval);
[...cleanups].reverse().forEach(cleanup => {
try { cleanup(); } catch (error) { /* 页面退出时忽略清理异常 */ }
});
timeouts.clear();
intervals.clear();
cleanups.clear();
}
});
})();
// ========================================================================
// 组件:稳定悬停上浮
// 职责:以上浮前的固定命中区域驱动动效,并用透明桥接层覆盖位移空隙,
// 避免边缘悬停时 :hover 反复失效造成控件和鼠标指针闪烁。
// ========================================================================
const HoverLiftControl = (() => {
const ACTIVE_CLASS = 'uikit-hover-lifted';
const TARGET_SELECTOR = [
'.header-version', '.header-author', '.helper-tab',
'.ui-select-option:not(:disabled)', '.ui-select-action:not(:disabled)',
'.btn-modern:not(:disabled):not(.is-loading)', '.course-item',
'.gpa-card', '.gpa-segment-button:not(:disabled)', '.gpa-detail-trigger:not(:disabled)',
'.print-row', '#pj-list-area > .pj-group', '.eval-history-loader',
'.pj-item', '.pj-detail-btn:not(:disabled)', '.monitor-group-header:not(:disabled)', '.help-person'
].join(',');
let root = null;
let bridge = null;
let activeTarget = null;
let stableRect = null;
let activePointerId = null;
let controller = null;
let observer = null;
function isEligible(target) {
return target?.isConnected
&& target.matches(TARGET_SELECTOR)
&& !target.matches(':disabled, [aria-disabled="true"], .is-loading');
}
function targetFromEvent(event) {
const origin = event.target instanceof Element ? event.target : null;
if (!origin || origin === bridge) return null;
const target = origin.closest(TARGET_SELECTOR);
return target && root?.contains(target) ? target : null;
}
function pointWithinStableRect(event) {
if (!stableRect) return false;
return event.clientX >= stableRect.left
&& event.clientX <= stableRect.right
&& event.clientY >= stableRect.top
&& event.clientY <= stableRect.bottom + 1;
}
function hideBridge() {
if (bridge) bridge.style.display = 'none';
}
function clear() {
activeTarget?.classList.remove(ACTIVE_CLASS);
activeTarget = null;
stableRect = null;
activePointerId = null;
hideBridge();
}
function showBridge(target, rect, lift) {
if (!bridge || lift <= 0) return hideBridge();
bridge.style.left = `${rect.left}px`;
bridge.style.top = `${rect.bottom - lift}px`;
bridge.style.width = `${rect.width}px`;
bridge.style.height = `${lift + 1}px`;
bridge.style.cursor = getComputedStyle(target).cursor || 'default';
bridge.style.display = 'block';
}
function activate(target, event) {
if (!isEligible(target) || event.pointerType === 'touch') return;
if (activeTarget === target) return;
clear();
const rect = target.getBoundingClientRect();
const reduceMotion = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
const lift = reduceMotion ? 0 : (target.matches('.btn-modern') ? 2 : 1);
activeTarget = target;
stableRect = { left: rect.left, right: rect.right, top: rect.top - lift, bottom: rect.bottom };
activePointerId = event.pointerId;
target.classList.add(ACTIVE_CLASS);
showBridge(target, rect, lift);
}
function handlePointerOver(event) {
const target = targetFromEvent(event);
if (target) activate(target, event);
}
function handlePointerMove(event) {
if (activeTarget) {
if (!isEligible(activeTarget)
|| (activePointerId !== null && event.pointerId !== activePointerId)
|| !pointWithinStableRect(event)) clear();
else return;
}
const target = targetFromEvent(event);
if (target) activate(target, event);
}
function forwardBridgeClick(event) {
if (event.button !== 0 || !isEligible(activeTarget)) return;
if (activeTarget.matches('button, a[href], [role="button"]')) activeTarget.click();
}
function init(eventRoot = UIKitRuntime.shadow) {
if (controller || !eventRoot) return destroy;
root = eventRoot;
controller = new AbortController();
bridge = document.createElement('span');
bridge.className = 'uikit-hover-bridge';
bridge.setAttribute('aria-hidden', 'true');
root.appendChild(bridge);
const options = { capture: true, signal: controller.signal };
root.addEventListener('pointerover', handlePointerOver, options);
root.addEventListener('pointermove', handlePointerMove, options);
document.addEventListener('pointermove', handlePointerMove, options);
document.addEventListener('pointerout', event => { if (!event.relatedTarget) clear(); }, options);
root.addEventListener('scroll', clear, options);
window.addEventListener('resize', clear, options);
window.addEventListener('blur', clear, options);
bridge.addEventListener('click', forwardBridgeClick, { signal: controller.signal });
observer = new MutationObserver(() => {
if (activeTarget && !isEligible(activeTarget)) clear();
});
observer.observe(root, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['class', 'disabled', 'aria-disabled']
});
return destroy;
}
function destroy() {
clear();
observer?.disconnect();
controller?.abort();
bridge?.remove();
root = bridge = controller = observer = null;
}
return Object.freeze({ init, destroy });
})();
const escapeHTML = (val) => String(val ?? '').replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
const FileDownload = Object.freeze({
saveBlob(blob, filename, { revokeDelay = 1500 } = {}) {
if (!(blob instanceof Blob) || !blob.size) throw new Error('下载文件为空');
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = String(filename || `WHUT_导出_${Date.now()}`);
link.style.display = 'none';
document.body.appendChild(link);
link.click();
link.remove();
AppResources.schedule(() => URL.revokeObjectURL(url), Math.max(500, revokeDelay));
return link.download;
},
saveText(text, filename, type = 'application/json;charset=utf-8') {
return this.saveBlob(new Blob([String(text ?? '')], { type }), filename);
}
});
const Diagnostics = (() => {
const SCHEMA = 'whut-helper-diagnostics-v1';
const MAX_ENTRIES = 400;
const startedAt = Date.now();
const entries = [];
let sequence = 0;
const sensitiveKey = /^(?:xh|xsbh|userid|username|xm|name|student|studentid|cookie|authorization|token|session|gs_sessionid|_weu)$/i;
const redactText = value => String(value ?? '')
.replace(/([?&](?:token|ticket|session|wid|xh|xsbh|userid)=)[^\s]+/gi, '$1[REDACTED]')
.replace(/\b(\d{3})\d{5,8}(\d{2})\b/g, '$1******$2')
.replace(/(GS_SESSIONID|_WEU|Authorization|Cookie)\s*[:=]\s*[^;\s]+/gi, '$1=[REDACTED]');
function sanitize(value, depth = 0, key = '') {
if (sensitiveKey.test(String(key || ''))) return '[REDACTED]';
if (value instanceof Error) {
return {
name: redactText(value.name || 'Error'),
message: redactText(value.message || String(value)),
stack: redactText(String(value.stack || '')).split('\n').slice(0, 6).join('\n')
};
}
if (value === null || value === undefined || typeof value === 'boolean' || typeof value === 'number') return value;
if (typeof value === 'string') return redactText(value).slice(0, 1200);
if (depth >= 4) return '[MAX_DEPTH]';
if (Array.isArray(value)) return value.slice(0, 30).map(item => sanitize(item, depth + 1));
if (typeof value === 'object') {
const result = {};
Object.entries(value).slice(0, 40).forEach(([childKey, childValue]) => {
result[childKey] = sanitize(childValue, depth + 1, childKey);
});
return result;
}
return redactText(value);
}
function add(level, scope, message, detail = null) {
const normalizedLevel = ['debug', 'info', 'warn', 'error'].includes(level) ? level : 'info';
const entry = {
id: ++sequence,
time: new Date().toISOString(),
level: normalizedLevel,
scope: redactText(scope || 'app').slice(0, 80),
message: redactText(message || '').slice(0, 500),
detail: detail === null || detail === undefined ? null : sanitize(detail)
};
entries.push(entry);
if (entries.length > MAX_ENTRIES) entries.splice(0, entries.length - MAX_ENTRIES);
if (normalizedLevel === 'warn' || normalizedLevel === 'error') {
const method = normalizedLevel === 'error' ? 'error' : 'warn';
console[method](`[WHUT Helper][${entry.scope}] ${entry.message}`, entry.detail || '');
}
return entry;
}
function counts() {
return entries.reduce((result, entry) => {
result[entry.level] = (result[entry.level] || 0) + 1;
return result;
}, { debug: 0, info: 0, warn: 0, error: 0 });
}
function snapshot() {
return {
schema: SCHEMA,
generatedAt: new Date().toISOString(),
script: {
name: 'WHUT教务系统小助手',
version: SCRIPT_VERSION,
runtimeMs: Date.now() - startedAt
},
environment: {
url: `${location.origin}${location.pathname}`,
userAgent: navigator.userAgent,
language: navigator.language,
theme: UIKitRuntime.getResolvedTheme()
},
state: {
courses: state.rawData.length,
filteredCourses: state.filteredData.length,
semesters: state.semesters.size,
monitorLoaded: state.monitor.isLoaded,
transcriptLoaded: state.transcript.isLoaded,
transcriptRecords: state.transcript.records.length,
gpaLoaded: !!GPAModule?.isLoaded,
evalLoaded: state.eval.isLoaded,
printLoaded: state.isPrintLoaded
},
counts: counts(),
logs: entries.map(entry => sanitize(entry))
};
}
function render(container) {
if (!container) return;
const summary = counts();
const recent = entries.slice(-60).reverse();
container.innerHTML = `
诊断日志用于定位接口、解析和导出异常。仅保留本次页面会话中的最近 ${MAX_ENTRIES} 条记录。
总记录${entries.length}
请求错误${summary.error}
警告${summary.warn}
运行时间${Math.max(1, Math.round((Date.now() - startedAt) / 60000))}m
${recent.length ? recent.map(entry => `
${escapeHTML(entry.time.slice(11, 19))}
${escapeHTML(entry.scope)}
${escapeHTML(entry.message)}
`).join('') : '
当前暂无诊断记录
'}
导出时不会包含 Cookie、请求正文或完整学号;常见身份字段和长数字标识会自动脱敏。
`;
}
function exportLogs() {
const payload = snapshot();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
FileDownload.saveText(JSON.stringify(payload, null, 2), `WHUT_助手诊断_${timestamp}.json`);
add('info', 'diagnostics', '诊断日志已导出', { entries: payload.logs.length });
Toast.fire({ icon: 'success', title: '诊断日志已导出' });
}
function openPanel() {
AppDialog.fire({
title: '诊断与日志',
unsafeHTML: '',
width: 660,
showConfirmButton: false,
showCloseButton: true,
didMount: modal => {
const container = modal.querySelector('#diagnostics-container-modal');
render(container);
container?.addEventListener('click', event => {
const action = event.target.closest('[data-diagnostics-action]')?.dataset.diagnosticsAction;
if (action === 'export') exportLogs();
if (action === 'clear') {
entries.length = 0;
add('info', 'diagnostics', '诊断日志已清空');
render(container);
}
});
}
});
}
return Object.freeze({
debug: (scope, message, detail) => add('debug', scope, message, detail),
info: (scope, message, detail) => add('info', scope, message, detail),
warn: (scope, message, detail) => add('warn', scope, message, detail),
error: (scope, message, detail) => add('error', scope, message, detail),
openPanel
});
})();
Diagnostics.info('lifecycle', '脚本运行环境已初始化', { version: SCRIPT_VERSION });
const UI = Object.freeze({
state(type, title, detail = '', compact = false) {
const normalized = ['loading', 'empty', 'error', 'warning', 'success'].includes(type) ? type : 'empty';
const icons = { empty: 'info', error: 'close', warning: 'warning', success: 'check' };
const icon = normalized === 'loading'
? ''
: iconSVG(icons[normalized] || 'info');
const role = normalized === 'error' ? 'alert' : 'status';
return `
${icon}
${escapeHTML(title)}
${detail ? `${escapeHTML(detail)}` : ''}
`;
}
});
const toNum = (val, fallback = 0) => {
const n = parseFloat(val);
return Number.isFinite(n) ? n : fallback;
};
const compactParams = (obj) => Object.fromEntries(Object.entries(obj || {}).filter(([, v]) => v !== undefined && v !== null && v !== ''));
const waitFor = ms => new Promise(resolve => setTimeout(resolve, Math.max(0, Number(ms) || 0)));
// 数据访问层:统一请求超时、重试、去重、表单编码、解析与取消逻辑。
const API = (() => {
const DEFAULT_TIMEOUT = 15000;
const FORM_HEADERS = Object.freeze({
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest'
});
const activeRequests = new Set();
const inFlight = new Map();
let userInfoPromise = null;
const requestLabel = url => {
try {
const parsed = new URL(String(url || ''), location.href);
return `${parsed.origin === location.origin ? '' : parsed.origin}${parsed.pathname}`;
} catch (error) {
return String(url || '').split('?')[0];
}
};
const makeError = (message, code, detail = {}) => Object.assign(new Error(message), { code, ...detail });
const isRetryable = error => error?.retryable === true || ['NETWORK', 'TIMEOUT', 'HTTP_5XX'].includes(error?.code);
function requestOnce(options, timeout) {
return new Promise((resolve, reject) => {
let settled = false;
let timer = 0;
let handle = null;
const finish = (callback, value) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
if (handle) activeRequests.delete(handle);
callback(value);
};
const requestOptions = {
...options,
onload: response => {
const status = Number(response?.status) || 0;
if (status >= 500) {
finish(reject, makeError(`服务器暂时不可用(HTTP ${status})`, 'HTTP_5XX', { status, retryable: true }));
return;
}
if (status >= 400) {
finish(reject, makeError(`请求失败(HTTP ${status})`, 'HTTP_ERROR', { status, retryable: false }));
return;
}
finish(resolve, response);
},
onerror: response => finish(reject, makeError('网络连接失败', 'NETWORK', { status: Number(response?.status) || 0, retryable: true })),
ontimeout: () => finish(reject, makeError(`请求超时(${Math.round(timeout / 1000)} 秒)`, 'TIMEOUT', { retryable: true })),
onabort: () => finish(reject, makeError('请求已取消', 'ABORTED', { retryable: false }))
};
try {
// Safari 用户脚本管理器需要绝对请求地址;保留请求参数和回调。
handle = GM_xmlhttpRequest({
...requestOptions,
url: new URL(requestOptions.url, window.location.href).href
});
if (handle) activeRequests.add(handle);
timer = setTimeout(() => {
finish(reject, makeError(`请求超时(${Math.round(timeout / 1000)} 秒)`, 'TIMEOUT', { retryable: true }));
try { handle?.abort?.(); } catch (error) { Diagnostics.debug('network', '取消超时请求时发生异常', error); }
}, timeout);
} catch (error) {
finish(reject, makeError(error?.message || '请求初始化失败', 'NETWORK', { retryable: true, cause: error }));
}
});
}
async function request(options = {}) {
const {
timeout: rawTimeout,
retries: rawRetries,
retryDelay: rawRetryDelay,
dedupeKey,
...gmOptions
} = options;
if (!gmOptions.url) throw makeError('请求地址不能为空', 'INVALID_URL', { retryable: false });
const timeout = Math.max(1000, Number(rawTimeout) || DEFAULT_TIMEOUT);
const retries = Math.max(0, Math.min(3, Number(rawRetries) || 0));
const retryDelay = Math.max(100, Number(rawRetryDelay) || 350);
const method = String(gmOptions.method || 'GET').toUpperCase();
const label = requestLabel(gmOptions.url);
const key = dedupeKey ? String(dedupeKey) : '';
if (key && inFlight.has(key)) {
Diagnostics.debug('network', '复用进行中的请求', { method, url: label, key });
return inFlight.get(key);
}
const task = (async () => {
const started = performance.now();
Diagnostics.debug('network', '请求开始', { method, url: label, timeout, retries });
let lastError = null;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const response = await requestOnce(gmOptions, timeout);
Diagnostics.info('network', '请求完成', {
method,
url: label,
status: Number(response?.status) || 0,
attempt: attempt + 1,
durationMs: Math.round(performance.now() - started)
});
return response;
} catch (error) {
lastError = error;
if (attempt >= retries || !isRetryable(error)) break;
const delay = retryDelay * (2 ** attempt);
Diagnostics.warn('network', '请求失败,准备重试', {
method,
url: label,
code: error?.code,
status: error?.status || 0,
attempt: attempt + 1,
retryInMs: delay
});
await waitFor(delay);
}
}
Diagnostics.error('network', '请求失败', {
method,
url: label,
code: lastError?.code || 'UNKNOWN',
status: lastError?.status || 0,
durationMs: Math.round(performance.now() - started),
error: lastError
});
throw lastError || makeError('请求失败', 'UNKNOWN', { retryable: false });
})();
if (key) inFlight.set(key, task);
try {
return await task;
} finally {
if (key && inFlight.get(key) === task) inFlight.delete(key);
}
}
async function requestJSON(options = {}) {
try {
const response = await request(options);
const text = String(response?.responseText ?? (typeof response?.response === 'string' ? response.response : '')).trim();
if (!text) throw makeError('接口返回空内容', 'EMPTY_RESPONSE', { retryable: false });
if (/^]+login|登录页面/i.test(text.slice(0, 2000))) {
throw makeError('登录状态失效或接口返回了网页内容', 'AUTH_HTML', { retryable: false });
}
return JSON.parse(text);
} catch (error) {
const normalized = error?.code
? error
: makeError('接口返回的 JSON 无法解析', 'INVALID_JSON', { retryable: false, cause: error });
if (!['NETWORK', 'TIMEOUT', 'HTTP_5XX', 'HTTP_ERROR', 'ABORTED'].includes(normalized.code)) {
Diagnostics.error('network', '响应解析失败', {
method: String(options.method || 'GET').toUpperCase(),
url: requestLabel(options.url),
code: normalized.code,
error: normalized
});
}
throw normalized;
}
}
function formOptions(options = {}) {
const { method = 'POST', headers = {}, data = '', ...rest } = options;
const encodedData = typeof data === 'string'
? data
: (data instanceof URLSearchParams ? data.toString() : new URLSearchParams(compactParams(data)).toString());
return {
...rest,
method,
headers: { ...FORM_HEADERS, ...headers },
data: encodedData
};
}
const requestForm = options => request(formOptions(options));
const requestFormJSON = options => requestJSON(formOptions(options));
const api = {
request,
requestJSON,
requestForm,
requestFormJSON,
getUserInfo: async ({ force = false } = {}) => {
if (!force && state.userInfo && Date.now() - state.userInfoLoadedAt < 300000) return state.userInfo;
if (userInfoPromise) return userInfoPromise;
userInfoPromise = requestJSON({
method: 'GET',
url: 'https://jwxt.whut.edu.cn/jwapp/sys/homeapp/api/home/currentUser.do',
retries: 1,
dedupeKey: 'current-user'
}).then(data => {
if (String(data?.code) === '0' && data.datas) {
state.userId = data.datas.userId;
state.userName = data.datas.userName;
state.userInfo = data.datas;
state.userInfoLoadedAt = Date.now();
return data.datas;
}
throw makeError('未能获取身份信息,请确认已登录教务系统', 'USER_INFO', { retryable: false });
}).finally(() => { userInfoPromise = null; });
return userInfoPromise;
},
abortAll: reason => {
Diagnostics.info('network', '取消全部活动请求', { reason: reason || 'pagehide', count: activeRequests.size });
Array.from(activeRequests).forEach(handle => {
try { handle?.abort?.(); } catch (error) { Diagnostics.debug('network', '取消请求时发生异常', error); }
});
activeRequests.clear();
}
};
return Object.freeze(api);
})();
// 移动教务服务:仅复用当前账号会话,认证信息只在内存中使用。
const MobileAPI = (() => {
let sessionPromise = null;
let sessionToken = '';
let cancelSessionFrame = null;
const rejectedTokens = new Set();
AppResources.add(() => { cancelSessionFrame?.(); sessionToken = ''; rejectedTokens.clear(); });
const tokenValue = value => {
try { value = decodeURIComponent(String(value || '')).replace(/^"|"$/g, ''); } catch (_) { return ''; }
return /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value) ? value : '';
};
function tokenFromCookies(cookies) {
return String(cookies || '').split(';').map(part => part.trim()).filter(part => part.startsWith('Authorization='))
.map(part => tokenValue(part.slice('Authorization='.length))).find(token => token && !rejectedTokens.has(token)) || '';
}
function authorization() {
// 新建立会话的令牌优先,避免桌面路径下的旧 Cookie 覆盖移动路径的新令牌。
return sessionToken && !rejectedTokens.has(sessionToken) ? sessionToken : tokenFromCookies(document.cookie);
}
function sessionFrame() {
return new Promise(resolve => {
const frame = document.createElement('iframe');
frame.hidden = true;
frame.tabIndex = -1;
frame.setAttribute('aria-hidden', 'true');
frame.title = '建立移动教务会话';
let timer = 0, poll = 0, finished = false;
const finish = token => {
if (finished) return;
finished = true;
clearTimeout(timer); clearInterval(poll);
frame.removeEventListener('load', inspect);
frame.remove();
cancelSessionFrame = null;
resolve(token || '');
};
const inspect = () => {
try {
const url = new URL(frame.contentWindow.location.href);
if (url.origin !== location.origin || !url.pathname.startsWith('/jwmobile/')) return;
const token = tokenFromCookies(frame.contentDocument?.cookie);
if (token) finish(token);
} catch (_) { /* 统一登录跳转期间可能暂时跨域,等待返回移动教务。 */ }
};
cancelSessionFrame = () => finish('');
frame.addEventListener('load', inspect);
timer = setTimeout(() => finish(''), 12000);
poll = setInterval(inspect, 200);
frame.src = '/jwmobile/index';
(document.body || document.documentElement).appendChild(frame);
});
}
async function prepareSession() {
if (sessionPromise) return sessionPromise;
sessionPromise = (async () => {
try {
const response = await API.request({ method: 'GET', url: '/jwmobile/index', timeout: 15000 });
const cookie = String(response.responseHeaders || '').match(/(?:^|\n)set-cookie:\s*Authorization=([^;\r\n]+)/i);
const token = cookie && tokenValue(cookie[1]);
if (token && !rejectedTokens.has(token)) sessionToken = token;
} catch (error) { Diagnostics.debug('mobile-session', '移动入口请求未完成,改用页面建立会话', { code: error?.code }); }
if (!authorization()) {
// 执行官方页面的正常登录跳转,并在 /jwmobile 路径读取路径限定 Cookie。
sessionToken = await sessionFrame();
}
})().finally(() => { sessionPromise = null; });
return sessionPromise;
}
async function request(path, params = {}, { method = 'GET' } = {}) {
if (!/^\/(?:common|score|allSchedule|schedule)\//.test(path)) throw new Error('无效的移动教务接口');
const read = async token => {
const query = new URLSearchParams(params).toString();
const response = await API.requestJSON({
method,
url: `https://jwxt.whut.edu.cn/jwmobile/biz/v410${path}${method === 'GET' && query ? `?${query}` : ''}`,
headers: { Accept: 'application/json', ...(token ? { Authorization: token } : {}), ...(method === 'POST' ? { 'Content-Type': 'application/json' } : {}) },
...(method === 'POST' ? { data: JSON.stringify(params) } : {}),
timeout: 15000
});
if (String(response?.code) !== '200') {
const isAuth = /401|403/.test(String(response?.code)) || /登录|认证|token/i.test(response?.msg || '');
throw Object.assign(new Error(isAuth ? '移动教务认证失败' : (response?.msg || '移动教务返回异常')), { code: isAuth ? 'MOBILE_AUTH' : 'MOBILE_RESPONSE' });
}
return response;
};
if (!authorization()) await prepareSession();
const token = authorization();
try { return await read(token); }
catch (error) {
if (!['AUTH_HTML', 'MOBILE_AUTH'].includes(error.code) && ![401, 403].includes(error.status)) throw error;
if (token) rejectedTokens.add(token);
while (rejectedTokens.size > 8) rejectedTokens.delete(rejectedTokens.values().next().value);
if (sessionToken === token) sessionToken = '';
if (!authorization() || authorization() === token) await prepareSession();
try { return await read(authorization()); }
catch (retryError) {
if (['AUTH_HTML', 'MOBILE_AUTH'].includes(retryError?.code) || [401, 403].includes(retryError?.status)) {
throw Object.assign(new Error('已尝试自动连接移动教务,但认证仍未成功;请重新登录教务系统后重试'), { code: 'MOBILE_AUTH', retryable: false });
}
throw retryError;
}
}
}
return Object.freeze({ request });
})();
// ========================================================================
// 组件:分段控件
// 职责:统一单选语义、选中态和方向键 / Home / End 导航。
// ========================================================================
const SegmentedControl = (() => {
const instances = new WeakMap();
function enhance(root, { itemSelector = '[data-segment-value]', activeClass = 'active', onChange = () => {} } = {}) {
if (!root || instances.has(root)) return instances.get(root) || null;
const items = () => Array.from(root.querySelectorAll(itemSelector)).filter(item => !item.disabled);
const valueOf = item => item.dataset.segmentValue ?? item.dataset.gpaMode ?? '';
const select = (value, { emit = true, focus = false } = {}) => {
const allItems = items();
const target = allItems.find(item => valueOf(item) === String(value)) || allItems[0];
if (!target) return '';
allItems.forEach(item => {
const selected = item === target;
item.classList.toggle(activeClass, selected);
item.setAttribute('aria-checked', String(selected));
item.tabIndex = selected ? 0 : -1;
});
if (focus) target.focus({ preventScroll: true });
if (emit) onChange(valueOf(target), target);
return valueOf(target);
};
const handleClick = event => {
const item = event.target.closest(itemSelector);
if (item && root.contains(item) && !item.disabled) select(valueOf(item));
};
const handleKeydown = event => {
const item = event.target.closest(itemSelector);
if (!item || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
const allItems = items();
if (!allItems.length) return;
event.preventDefault();
let index = Math.max(0, allItems.indexOf(item));
if (event.key === 'Home') index = 0;
else if (event.key === 'End') index = allItems.length - 1;
else index = (index + (event.key === 'ArrowRight' ? 1 : -1) + allItems.length) % allItems.length;
select(valueOf(allItems[index]), { focus: true });
};
root.setAttribute('role', 'radiogroup');
items().forEach(item => item.setAttribute('role', 'radio'));
root.addEventListener('click', handleClick);
root.addEventListener('keydown', handleKeydown);
const instance = Object.freeze({ select, getValue: () => valueOf(items().find(item => item.getAttribute('aria-checked') === 'true') || items()[0]), destroy() {
root.removeEventListener('click', handleClick);
root.removeEventListener('keydown', handleKeydown);
instances.delete(root);
} });
instances.set(root, instance);
return instance;
}
return Object.freeze({ enhance, get: root => instances.get(root) || null });
})();
// ========================================================================
// 组件:搜索框
// 职责:统一延迟检索、清空按钮、Escape 清空和状态同步。
// ========================================================================
const SearchControl = (() => {
const instances = new WeakMap();
function enhance(input, { onInput = () => {}, delay = 300 } = {}) {
if (!input || instances.has(input)) return instances.get(input) || null;
const clearButton = input.closest('.ui-search')?.querySelector('.ui-search-clear');
const emit = debounce(() => onInput(input.value.trim(), input), Math.max(0, delay));
const sync = () => { if (clearButton) clearButton.hidden = !input.value; };
const handleInput = () => { sync(); emit(); };
const clear = ({ focus = true, immediate = true } = {}) => {
if (!input.value) {
if (focus) input.focus({ preventScroll: true });
return;
}
input.value = '';
sync();
emit.cancel();
if (immediate) onInput('', input);
if (focus) input.focus({ preventScroll: true });
};
const handleKeydown = event => {
if (event.key !== 'Escape' || !input.value) return;
event.preventDefault();
event.stopPropagation();
clear();
};
const handleClear = () => clear();
input.addEventListener('input', handleInput);
input.addEventListener('keydown', handleKeydown);
clearButton?.addEventListener('click', handleClear);
sync();
const instance = Object.freeze({
clear,
setValue(value, { emit: shouldEmit = false } = {}) {
input.value = String(value ?? '');
sync();
if (shouldEmit) onInput(input.value.trim(), input);
},
destroy() {
emit.cancel();
input.removeEventListener('input', handleInput);
input.removeEventListener('keydown', handleKeydown);
clearButton?.removeEventListener('click', handleClear);
instances.delete(input);
}
});
instances.set(input, instance);
return instance;
}
return Object.freeze({ enhance, get: input => instances.get(input) || null });
})();
// ========================================================================
// 组件:折叠区域,复用焦点处理、展开状态与按钮文案。
// ========================================================================
const DisclosureControl = Object.freeze({
actions({ resetId, toggleId, controlsId, label = '查询条件', toggleClass = '' }) {
return ``;
},
enhance(button, content, { label = '查询条件', collapsed = true, onChange = () => {} } = {}) {
const setCollapsed = value => {
const next = Boolean(value);
if (next) {
if (content.contains(content.getRootNode().activeElement)) button.focus();
SelectControl.closeAll();
}
content.hidden = next;
button.setAttribute('aria-expanded', String(!next));
button.setAttribute('aria-controls', content.id);
button.setAttribute('aria-label', `${next ? '展开' : '收起'}${label}`);
button.querySelector('span').textContent = next ? '展开' : '收起';
onChange(next);
};
const toggle = () => setCollapsed(!content.hidden);
button.classList.add('disclosure-toggle');
button.addEventListener('click', toggle);
setCollapsed(collapsed);
return { setCollapsed, destroy: () => button.removeEventListener('click', toggle) };
}
});
// 组件:上下横向滚动条同步,尺寸变化时重新测量,销毁时释放监听。
const HorizontalScrollControl = Object.freeze({
enhance(top, viewport, content) {
const fromTop = () => { if (viewport.scrollLeft !== top.scrollLeft) viewport.scrollLeft = top.scrollLeft; };
const fromViewport = () => { if (top.scrollLeft !== viewport.scrollLeft) top.scrollLeft = viewport.scrollLeft; };
const measure = () => {
top.style.width = `${viewport.clientWidth}px`;
top.firstElementChild.style.width = `${viewport.scrollWidth}px`;
top.hidden = viewport.scrollWidth <= viewport.clientWidth + 1;
fromViewport();
};
top.addEventListener('scroll', fromTop, { passive: true });
viewport.addEventListener('scroll', fromViewport, { passive: true });
const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(measure) : null;
observer?.observe(viewport); observer?.observe(content);
window.addEventListener('resize', measure);
measure();
return { destroy() {
observer?.disconnect();
top.removeEventListener('scroll', fromTop);
viewport.removeEventListener('scroll', fromViewport);
window.removeEventListener('resize', measure);
} };
}
});
// 组件:选择器(单选 / 多选),统一浮层、定位、键盘导航与关闭逻辑。
const SelectControl = (() => {
const instances = new Map();
let active = null;
let sequence = 0;
let globalsBound = false;
const handleDocumentPointerDown = event => {
if (!active) return;
const path = event.composedPath();
if (!path.includes(active.wrapper) && !path.includes(active.menu)) close(active);
};
const handleWindowResize = debounce(() => active && position(active), 60);
const handleWindowScroll = event => {
if (!active || event.composedPath?.().includes(active.menu)) return;
position(active);
};
const resolve = target => {
if (!target) return null;
if (typeof target === 'string') return instances.get(uiGet(target)) || null;
return instances.get(target) || null;
};
const enabledOptions = instance => Array.from(instance.menu.querySelectorAll('.ui-select-option:not(:disabled)'));
function markActive(instance, optionButton) {
instance.menu.querySelectorAll('.ui-select-option.is-active').forEach(node => node.classList.remove('is-active'));
if (!optionButton) {
instance.trigger.removeAttribute('aria-activedescendant');
return;
}
optionButton.classList.add('is-active');
instance.trigger.setAttribute('aria-activedescendant', optionButton.id);
}
function selectedOptions(instance) {
return Array.from(instance.select.options).filter(option => option.selected);
}
function selectionLabel(instance) {
const options = Array.from(instance.select.options);
const selected = selectedOptions(instance);
if (!instance.multiple) {
return selected[0]?.textContent?.trim() || instance.select.dataset.placeholder || '请选择';
}
if (!selected.length) return instance.select.dataset.emptyLabel || '未选择';
if (selected.length === options.length) return instance.select.dataset.allLabel || '全部';
return instance.select.dataset.selectionLabel || `已选 ${selected.length}/${options.length} ${instance.select.dataset.itemLabel || '项'}`;
}
function sync(instance) {
if (!instance) return;
instance.value.textContent = selectionLabel(instance);
instance.trigger.disabled = instance.select.disabled;
instance.trigger.setAttribute('aria-disabled', String(instance.select.disabled));
const currentActive = instance.menu.querySelector('.ui-select-option.is-active');
let firstSelected = null;
Array.from(instance.menu.querySelectorAll('.ui-select-option')).forEach(button => {
const option = instance.select.options[Number(button.dataset.index)];
const selected = !!option?.selected;
button.setAttribute('aria-selected', String(selected));
if (selected && !firstSelected) firstSelected = button;
});
if (!currentActive && !instance.menu.contains(UIKitRuntime.shadow.activeElement)) markActive(instance, firstSelected);
}
function updateMultipleSelection(instance, mode) {
if (!instance?.multiple) return;
let changed = false;
Array.from(instance.select.options).forEach(option => {
if (option.disabled) return;
const next = mode === 'all' ? true : mode === 'clear' ? false : !option.selected;
if (option.selected !== next) {
option.selected = next;
changed = true;
}
});
sync(instance);
if (changed) instance.select.dispatchEvent(new Event('change', { bubbles: true }));
}
function buildMenu(instance) {
const fragment = document.createDocumentFragment();
if (instance.multiple) {
const actions = document.createElement('div');
actions.className = 'ui-select-actions';
actions.setAttribute('role', 'group');
actions.setAttribute('aria-label', '批量选择');
[['all', '全选'], ['invert', '反选'], ['clear', '清空']].forEach(([action, label]) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'ui-select-action';
button.dataset.selectAction = action;
button.textContent = label;
button.addEventListener('click', event => {
event.stopPropagation();
updateMultipleSelection(instance, action);
});
actions.appendChild(button);
});
fragment.appendChild(actions);
}
Array.from(instance.select.options).forEach((option, index) => {
const button = document.createElement('button');
button.type = 'button';
button.id = `${instance.uid}-option-${index}`;
button.className = 'ui-select-option';
button.dataset.index = String(index);
button.dataset.value = option.value;
button.setAttribute('role', 'option');
button.disabled = option.disabled;
button.innerHTML = `${iconSVG('check')}`;
button.querySelector('.ui-select-option-label').textContent = option.textContent.trim();
button.addEventListener('mouseenter', () => markActive(instance, button));
button.addEventListener('focus', () => markActive(instance, button));
button.addEventListener('click', () => selectIndex(instance, index));
button.addEventListener('keydown', event => handleOptionKeydown(instance, event));
fragment.appendChild(button);
});
instance.menu.replaceChildren(fragment);
sync(instance);
}
function position(instance) {
if (!instance?.wrapper.classList.contains('is-open')) return;
const rect = instance.trigger.getBoundingClientRect();
if (!rect.width || !rect.height) return close(instance);
const margin = 12;
const width = Math.min(Math.max(rect.width, 180), window.innerWidth - margin * 2);
const below = Math.max(96, window.innerHeight - rect.bottom - margin);
const above = Math.max(96, rect.top - margin);
const openAbove = below < 180 && above > below;
const maxHeight = Math.min(320, openAbove ? above : below);
const left = clampValue(rect.left, margin, window.innerWidth - width - margin);
instance.menu.style.width = `${Math.round(width)}px`;
instance.menu.style.left = `${Math.round(left)}px`;
instance.menu.style.right = 'auto';
instance.menu.style.maxHeight = `${Math.round(maxHeight)}px`;
instance.menu.style.top = openAbove ? 'auto' : `${Math.round(rect.bottom + 6)}px`;
instance.menu.style.bottom = openAbove ? `${Math.round(window.innerHeight - rect.top + 6)}px` : 'auto';
instance.menu.style.transformOrigin = openAbove ? 'bottom center' : 'top center';
}
function open(instance, { focusOption = false, edge = '' } = {}) {
if (!instance || instance.select.disabled) return;
if (active && active !== instance) close(active);
buildMenu(instance);
active = instance;
instance.wrapper.classList.add('is-open');
instance.trigger.setAttribute('aria-expanded', 'true');
instance.menu.classList.add('is-open');
UIKit.mount('menu', instance.menu);
position(instance);
if (focusOption) requestAnimationFrame(() => {
const options = enabledOptions(instance);
const selected = options.find(button => instance.select.options[Number(button.dataset.index)]?.selected);
const target = edge === 'first' ? options[0] : edge === 'last' ? options.at(-1) : (selected || options[0]);
target?.focus({ preventScroll: true });
target?.scrollIntoView({ block: 'nearest' });
});
}
function close(instance = active, { restoreFocus = false } = {}) {
if (!instance) return;
instance.wrapper.classList.remove('is-open');
instance.trigger.setAttribute('aria-expanded', 'false');
instance.trigger.removeAttribute('aria-activedescendant');
instance.menu.classList.remove('is-open');
instance.menu.removeAttribute('style');
instance.wrapper.appendChild(instance.menu);
if (active === instance) active = null;
if (restoreFocus) instance.trigger.focus({ preventScroll: true });
}
function selectIndex(instance, index) {
const option = instance.select.options[index];
if (!option || option.disabled) return;
const changed = instance.multiple ? true : instance.select.selectedIndex !== index;
if (instance.multiple) option.selected = !option.selected;
else instance.select.selectedIndex = index;
sync(instance);
if (instance.multiple) instance.menu.querySelector(`[data-index="${index}"]`)?.focus({ preventScroll: true });
else close(instance, { restoreFocus: true });
if (changed) instance.select.dispatchEvent(new Event('change', { bubbles: true }));
}
function moveFocus(instance, current, delta) {
const options = enabledOptions(instance);
if (!options.length) return;
const index = Math.max(0, options.indexOf(current));
const next = options[(index + delta + options.length) % options.length];
next.focus({ preventScroll: true });
next.scrollIntoView({ block: 'nearest' });
}
function handleOptionKeydown(instance, event) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
moveFocus(instance, event.currentTarget, event.key === 'ArrowDown' ? 1 : -1);
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault();
const options = enabledOptions(instance);
const target = event.key === 'Home' ? options[0] : options.at(-1);
target?.focus({ preventScroll: true });
target?.scrollIntoView({ block: 'nearest' });
} else if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
close(instance, { restoreFocus: true });
} else if (event.key === 'Tab') {
close(instance);
}
}
function enhance(select) {
if (!select || instances.has(select)) return instances.get(select) || null;
const uid = `whut-select-${++sequence}`;
const wrapper = document.createElement('div');
wrapper.className = 'ui-select';
wrapper.dataset.selectId = select.id || uid;
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.id = `${select.id || uid}-trigger`;
trigger.className = 'ui-select-trigger';
trigger.setAttribute('role', 'combobox');
trigger.setAttribute('aria-haspopup', 'listbox');
trigger.setAttribute('aria-expanded', 'false');
trigger.setAttribute('aria-label', select.getAttribute('aria-label') || '请选择');
trigger.innerHTML = `${iconSVG('chevronDown')}`;
const menu = document.createElement('div');
menu.id = `${uid}-menu`;
menu.className = 'ui-select-menu';
menu.setAttribute('role', 'listbox');
menu.setAttribute('aria-label', select.getAttribute('aria-label') || '选项');
if (select.multiple) menu.setAttribute('aria-multiselectable', 'true');
trigger.setAttribute('aria-controls', menu.id);
select.parentNode.insertBefore(wrapper, select);
select.classList.add('ui-select-native');
select.setAttribute('aria-hidden', 'true');
select.tabIndex = -1;
wrapper.append(select, trigger, menu);
const label = Array.from(wrapper.parentElement?.querySelectorAll('label[for]') || []).find(node => node.htmlFor === select.id);
if (label) label.htmlFor = trigger.id;
const instance = { uid, select, multiple: select.multiple, wrapper, trigger, value: trigger.querySelector('.ui-select-value'), menu, observer: null };
instances.set(select, instance);
trigger.addEventListener('click', event => {
event.stopPropagation();
if (wrapper.classList.contains('is-open')) close(instance);
else open(instance);
});
trigger.addEventListener('keydown', event => {
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
event.preventDefault();
const edge = event.key === 'Home' ? 'first' : event.key === 'End' ? 'last' : '';
open(instance, { focusOption: true, edge });
} else if ((event.key === 'Enter' || event.key === ' ') && !wrapper.classList.contains('is-open')) {
event.preventDefault();
open(instance, { focusOption: true });
} else if (event.key === 'Escape' && wrapper.classList.contains('is-open')) {
event.preventDefault();
event.stopPropagation();
close(instance, { restoreFocus: true });
}
});
select.addEventListener('change', () => sync(instance));
if (typeof MutationObserver === 'function') {
instance.observer = new MutationObserver(() => buildMenu(instance));
instance.observer.observe(select, { childList: true, subtree: true, characterData: true, attributes: true });
}
buildMenu(instance);
return instance;
}
function bindGlobals() {
if (globalsBound) return;
globalsBound = true;
document.addEventListener('pointerdown', handleDocumentPointerDown, true);
window.addEventListener('resize', handleWindowResize);
window.addEventListener('scroll', handleWindowScroll, true);
}
function destroyAll() {
if (active) close(active);
handleWindowResize.cancel();
instances.forEach(instance => instance.observer?.disconnect());
instances.clear();
if (globalsBound) {
document.removeEventListener('pointerdown', handleDocumentPointerDown, true);
window.removeEventListener('resize', handleWindowResize);
window.removeEventListener('scroll', handleWindowScroll, true);
globalsBound = false;
}
}
return Object.freeze({
initAll(root = uiGet('whut-helper-panel')) {
root?.querySelectorAll('select[data-ui-select]').forEach(enhance);
bindGlobals();
},
refresh(target) {
const instance = resolve(target);
if (instance) buildMenu(instance);
},
sync(target) { sync(resolve(target)); },
setValue(target, value, { emit = false } = {}) {
const instance = resolve(target);
if (!instance) return false;
if (instance.multiple) {
const values = new Set(Array.from(value instanceof Set ? value : (Array.isArray(value) ? value : [value]), item => String(item)));
let changed = false;
Array.from(instance.select.options).forEach(option => {
const selected = values.has(option.value);
if (option.selected !== selected) {
option.selected = selected;
changed = true;
}
});
sync(instance);
if (emit && changed) instance.select.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
const option = Array.from(instance.select.options).find(item => item.value === String(value));
if (!option) return false;
const changed = instance.select.value !== option.value;
instance.select.value = option.value;
sync(instance);
if (emit && changed) instance.select.dispatchEvent(new Event('change', { bubbles: true }));
return true;
},
closeAll({ restoreFocus = false } = {}) { if (active) close(active, { restoreFocus }); },
destroyAll
});
})();
// ========================================================================
// 模块:应用壳与交互装配
// 职责:创建面板、绑定公共交互,并按当前视图调度业务模块。
// ========================================================================
const APP_TABS = Object.freeze([
{ id: 'monitor', label: '学业监测', shortLabel: '学业', icon: 'academic', active: true },
{ id: 'analysis', label: '成绩分析', shortLabel: '成绩', icon: 'chart' },
{ id: 'schedule', label: '课表查询', shortLabel: '课表', icon: 'calendar' },
{ id: 'gpa', label: 'GPA查询', shortLabel: '绩点', icon: 'trophy' },
{ id: 'print', label: '官方证明', shortLabel: '证明', icon: 'printer' },
{ id: 'eval', label: '一键评教', shortLabel: '评教', icon: 'clipboard' }
]);
const AdvancedPanel = Object.freeze({
enhance({ fab, panel }) {
if (!fab || !panel) throw new Error('AdvancedPanel 缺少悬浮球或面板节点');
const fabDrag = makeDraggable(fab, fab, 'whut_fab_pos');
const panelDrag = makeDraggable(panel, uiGet('panel-drag-handle'), 'whut_panel_pos');
AppResources.add(fabDrag.destroy);
AppResources.add(panelDrag.destroy);
clampFabToViewport(fab);
ButtonControl.enhanceAll(panel);
SelectControl.initAll(panel);
AppResources.add(HoverLiftControl.init(UIKitRuntime.shadow));
bindUIEvents(fab, panel, fabDrag);
return Object.freeze({ fab, panel, isFabDragging: fabDrag });
}
});
function initUI() {
UIKit.ensure();
if (uiGet('whut-fab')) return;
const savedFab = safeStorage.getJSON('whut_fab_pos', { right: '30px', bottom: 'max(30px, env(safe-area-inset-bottom))' });
const savedPanel = safeStorage.getJSON('whut_panel_pos', { left: `calc(50% - ${PANEL_LAYOUT.width / 2}px)`, top: '6vh' });
const fab = document.createElement('button');
fab.id = 'whut-fab';
fab.type = 'button';
fab.innerHTML = `${iconSVG('academic')}`;
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 = `
${APP_TABS.map(tab => ``).join('')}
${DisclosureControl.actions({ resetId: 'h-reset', toggleId: 'analysis-toggle', controlsId: 'analysis-controls', label: '成绩筛选与导出' })}
${UI.state('loading', '正在获取成绩', '请保持教务系统登录状态')}
${UI.state('empty', '课表等待加载', '可查询学生、全校和教室课程表')}
${UI.state('empty', '综合 GPA 等待加载', '切换到本模块后将自动读取')}
${UI.state('loading', '正在加载学业监测', '培养方案与预警信息将自动同步')}
${UI.state('empty', '评教任务等待加载', '切换到本模块后会自动读取,也可点击刷新')}
${UI.state('empty', '官方证明等待加载', '切换到本模块后将自动读取可用材料')}
`;
UIKit.mount('panel', panel);
AdvancedPanel.enhance({ fab, panel });
if (state.rawData.length) { updateFilterUI(); applyFilters(); }
else proactiveFetch();
MonitorModule.init();
AppResources.schedule(() => UpdateModule.autoCheck(), 2500);
}
function clampValue(value, min, max) {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
}
function clampPanelToViewport(panel) {
const margin = PANEL_LAYOUT.viewportMargin;
const rect = panel.getBoundingClientRect();
const maxWidth = Math.max(320, window.innerWidth - margin * 2);
const maxHeight = Math.max(320, window.innerHeight - margin * 2);
const width = Math.min(PANEL_LAYOUT.width, maxWidth);
const height = Math.min(Math.round(window.innerHeight * PANEL_LAYOUT.heightRatio), maxHeight);
let left = parseFloat(panel.style.left);
let top = parseFloat(panel.style.top);
if (!Number.isFinite(left)) left = rect.left;
if (!Number.isFinite(top)) top = rect.top;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
panel.style.width = `${Math.round(width)}px`;
panel.style.height = `${Math.round(height)}px`;
panel.style.left = `${Math.round(clampValue(left, margin, window.innerWidth - width - margin))}px`;
panel.style.top = `${Math.round(clampValue(top, margin, window.innerHeight - height - margin))}px`;
}
function clampFabToViewport(fab) {
const margin = 8;
const rect = fab.getBoundingClientRect();
const size = rect.width || 60;
let left = parseFloat(fab.style.left);
let top = parseFloat(fab.style.top);
if (!Number.isFinite(left)) left = rect.left;
if (!Number.isFinite(top)) top = rect.top;
fab.style.right = 'auto';
fab.style.bottom = 'auto';
fab.style.left = `${Math.round(clampValue(left, margin, window.innerWidth - size - margin))}px`;
fab.style.top = `${Math.round(clampValue(top, margin, window.innerHeight - size - margin))}px`;
}
function saveFabState(fab) {
safeStorage.setJSON('whut_fab_pos', { left: fab.style.left, top: fab.style.top });
}
function placePanelFromFab(panel, fab) {
const fabRect = fab.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const placeLeft = fabRect.left + fabRect.width / 2 > window.innerWidth / 2
? fabRect.right - panelRect.width
: fabRect.left;
const placeTop = fabRect.top + fabRect.height / 2 > window.innerHeight / 2
? fabRect.bottom - panelRect.height
: fabRect.top;
panel.style.left = `${Math.round(placeLeft)}px`;
panel.style.top = `${Math.round(placeTop)}px`;
clampPanelToViewport(panel);
}
function placeFabFromPanel(fab, panel) {
const panelRect = panel.getBoundingClientRect();
const size = fab.getBoundingClientRect().width || 60;
const placeLeft = panelRect.left + panelRect.width / 2 > window.innerWidth / 2
? panelRect.right - size
: panelRect.left;
const placeTop = panelRect.top + panelRect.height / 2 > window.innerHeight / 2
? panelRect.bottom - size
: panelRect.top;
fab.style.display = 'flex';
fab.style.left = `${Math.round(placeLeft)}px`;
fab.style.top = `${Math.round(placeTop)}px`;
fab.style.right = 'auto';
fab.style.bottom = 'auto';
clampFabToViewport(fab);
saveFabState(fab);
}
function openPanelFromFab(fab, panel) {
clampFabToViewport(fab);
panel.classList.add('open');
panel.setAttribute('aria-hidden', 'false');
fab.setAttribute('aria-expanded', 'true');
placePanelFromFab(panel, fab);
fab.style.display = 'none';
savePanelState(panel);
AppResources.schedule(() => panel.focus({ preventScroll: true }), 60);
}
function closePanelToFab(fab, panel) {
SelectControl.closeAll();
clampPanelToViewport(panel);
savePanelState(panel);
placeFabFromPanel(fab, panel);
panel.classList.remove('open');
panel.setAttribute('aria-hidden', 'true');
fab.setAttribute('aria-expanded', 'false');
fab.focus({ preventScroll: true });
}
function makeDraggable(el, handle, storageKey) {
let isDragging = false, startX, startY, initialX, initialY, activePointerId = null;
function dragStart(e) {
if (e.button !== undefined && e.button !== 0) return;
const target = e.target instanceof Element ? e.target : null;
if (!target) return;
const interactive = target.closest('button, textarea, input, select, a');
const isHandleControl = handle === el && interactive === handle;
if (target.closest('.header-close, .helper-tabs') || (interactive && !isHandleControl)) return;
isDragging = false;
activePointerId = e.pointerId;
startX = e.clientX; startY = e.clientY;
const rect = el.getBoundingClientRect();
initialX = rect.left; initialY = rect.top;
el.style.right = 'auto'; el.style.bottom = 'auto'; el.style.left = initialX + 'px'; el.style.top = initialY + 'px';
el.style.transition = 'none';
document.addEventListener('pointermove', drag, { passive: false });
document.addEventListener('pointerup', dragEnd);
document.addEventListener('pointercancel', dragEnd);
}
handle.addEventListener('pointerdown', dragStart);
function drag(e) {
if (activePointerId !== null && e.pointerId !== activePointerId) return;
const dx = e.clientX - startX; const dy = e.clientY - startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
isDragging = true;
el.classList.add('is-dragging');
e.preventDefault();
}
el.style.left = (initialX + dx) + 'px'; el.style.top = (initialY + dy) + 'px';
}
function dragEnd(e) {
if (activePointerId !== null && e.pointerId !== activePointerId) return;
document.removeEventListener('pointermove', drag);
document.removeEventListener('pointerup', dragEnd);
document.removeEventListener('pointercancel', dragEnd);
activePointerId = null;
el.style.transition = '';
el.classList.remove('is-dragging');
if (el.id === 'whut-helper-panel') clampPanelToViewport(el);
if (el.id === 'whut-fab') clampFabToViewport(el);
if (isDragging && storageKey) {
const saved = safeStorage.getJSON(storageKey, {});
const next = { ...saved, left: el.style.left, top: el.style.top };
safeStorage.setJSON(storageKey, next);
}
AppResources.schedule(() => { isDragging = false; }, 50);
}
const getDragging = () => isDragging;
getDragging.destroy = () => {
handle.removeEventListener('pointerdown', dragStart);
document.removeEventListener('pointermove', drag);
document.removeEventListener('pointerup', dragEnd);
document.removeEventListener('pointercancel', dragEnd);
};
return getDragging;
}
function savePanelState(panel) {
const rect = panel.getBoundingClientRect();
const saved = safeStorage.getJSON('whut_panel_pos', {});
safeStorage.setJSON('whut_panel_pos', {
...saved,
left: panel.style.left || `${Math.round(rect.left)}px`,
top: panel.style.top || `${Math.round(rect.top)}px`
});
}
function loadHelperView(targetId) {
VIEW_LOADERS[targetId]?.();
}
function activateHelperTab(tab, { focus = false } = {}) {
if (!tab) return;
SelectControl.closeAll();
const panel = tab.closest('#whut-helper-panel');
const targetId = tab.dataset.target;
const target = panel?.querySelector(`#${targetId}`);
if (!panel || !target) return;
panel.querySelectorAll('.helper-tab').forEach(item => {
const active = item === tab;
item.classList.toggle('active', active);
item.setAttribute('aria-selected', String(active));
item.tabIndex = active ? 0 : -1;
});
panel.querySelectorAll('.tab-view').forEach(view => {
const active = view === target;
view.classList.toggle('active', active);
view.setAttribute('aria-hidden', String(!active));
});
if (focus) tab.focus({ preventScroll: true });
loadHelperView(targetId);
}
function handleHelperTabKeydown(event) {
const keys = ['ArrowLeft', 'ArrowRight', 'Home', 'End'];
if (!keys.includes(event.key)) return;
const tabs = Array.from(event.currentTarget.closest('.helper-tabs').querySelectorAll('.helper-tab'));
const current = tabs.indexOf(event.currentTarget);
if (current < 0) return;
event.preventDefault();
const nextIndex = event.key === 'Home' ? 0
: event.key === 'End' ? tabs.length - 1
: (current + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
activateHelperTab(tabs[nextIndex], { focus: true });
}
function syncThemeButton() {
const button = uiGet('h-theme');
if (!button) return;
const mode = UIKit.getTheme();
const config = {
auto: { icon: 'themeAuto', strokeWidth: 1.8, label: '跟随系统', next: '浅色' },
light: { icon: 'sun', label: '浅色', next: '深色' },
dark: { icon: 'moon', label: '深色', next: '跟随系统' }
}[mode];
button.dataset.themeMode = mode;
button.querySelector('.header-theme-icon').innerHTML = iconSVG(config.icon, 'uikit-icon', config.strokeWidth || 2);
button.title = `当前${config.label};点击切换为${config.next}`;
button.setAttribute('aria-label', `当前${config.label}主题,切换为${config.next}`);
}
function bindShellEvents(fab, panel, isFabDragging) {
fab.addEventListener('click', () => { if (!isFabDragging()) openPanelFromFab(fab, panel); });
syncThemeButton();
uiGet('h-theme').addEventListener('click', () => {
UIKit.cycleTheme();
syncThemeButton();
const labels = { auto: '跟随系统', light: '浅色', dark: '深色' };
Toast.fire({ icon: 'success', title: `已切换为${labels[UIKit.getTheme()]}主题`, timer: 1800 });
});
UIKitRuntime.shadow.addEventListener('uikit-theme-change', syncThemeButton);
uiGet('h-diagnostics').addEventListener('click', () => Diagnostics.openPanel());
uiGet('h-update').addEventListener('click', () => UpdateModule.openPanel());
uiGet('h-version').addEventListener('click', () => UpdateModule.openPanel(true));
uiGet('h-help').addEventListener('click', () => HelpModule.open());
uiGet('h-close').addEventListener('click', () => closePanelToFab(fab, panel));
panel.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
SelectControl.closeAll();
closePanelToFab(fab, panel);
});
const handlePanelResize = debounce(() => {
if (panel.classList.contains('open')) {
clampPanelToViewport(panel);
savePanelState(panel);
} else {
fab.style.display = 'flex';
clampFabToViewport(fab);
saveFabState(fab);
}
}, 120);
AppResources.add(handlePanelResize.cancel);
AppEvents.listen(window, 'resize', handlePanelResize);
uiQueryAll('.helper-tab').forEach(tab => {
tab.addEventListener('click', (e) => {
activateHelperTab(e.currentTarget);
if (e.currentTarget.id === 'tab-schedule') ScheduleModule.revealStudentOnClick();
else ScheduleModule.state.secretClicks = 0;
});
tab.addEventListener('keydown', handleHelperTabKeydown);
});
}
function bindAnalysisEvents() {
const disclosure = DisclosureControl.enhance(uiGet('analysis-toggle'), uiGet('analysis-controls'), { label: '成绩筛选与导出' });
AppResources.add(disclosure.destroy);
const searchControl = SearchControl.enhance(uiGet('h-search'), {
onInput: value => { state.filters.search = value; applyFilters(); }
});
if (searchControl) AppResources.add(searchControl.destroy);
uiGet('h-semester').addEventListener('change', (e) => {
const selected = new Set(Array.from(e.target.selectedOptions, option => option.value));
state.filters.semesters = selected;
state.filters.semesterAll = selected.size === state.semesters.size && state.semesters.size > 0;
applyFilters();
});
uiGet('h-type').addEventListener('change', (e) => {
state.filters.types = new Set(Array.from(e.target.selectedOptions, option => option.value));
state.filters.typeAll = state.filters.types.size === state.types.size && state.types.size > 0;
applyFilters();
});
uiGet('h-sort').addEventListener('change', (e) => { state.filters.sort = e.target.value.trim(); applyFilters(); });
uiGet('h-reset').addEventListener('click', () => {
state.filters = { search: '', semesters: new Set(state.semesters), semesterAll: true, types: new Set(state.types), typeAll: true, sort: 'semester_desc' };
searchControl?.setValue('');
SelectControl.setValue('h-type', state.filters.types);
uiGet('h-sort').value = 'semester_desc';
SelectControl.setValue('h-semester', state.filters.semesters);
SelectControl.sync('h-type');
SelectControl.sync('h-sort');
updateFilterUI(); applyFilters();
});
uiGet('btn-exp-cur-xls').addEventListener('click', ContextualExport.toExcel);
uiGet('h-refresh').addEventListener('click', () => proactiveFetch({ force: true }));
uiGet('btn-exp-cur-img').addEventListener('click', ContextualExport.toImage);
uiGet('h-footer').addEventListener('click', event => {
const trigger = event.target.closest('[data-analysis-gpa]');
if (trigger) void openAnalysisCourseDetails(trigger.dataset.analysisGpa === 'compulsory');
});
renderAnalysisFilterSummary();
}
function bindGPAEvents() {
uiGet('btn-gpa-semester-manager').addEventListener('click', GPAModule.openSemesterManager);
uiGet('btn-exp-gpa-xls').addEventListener('click', GPAModule.exportToExcel);
uiGet('btn-exp-gpa-img').addEventListener('click', GPAModule.exportToImage);
}
function bindMonitorEvents() {
uiGet('btn-monitor-refresh').addEventListener('click', MonitorModule.refresh);
uiGet('btn-monitor-export').addEventListener('click', MonitorModule.exportReport);
uiGet('monitor-content').addEventListener('click', (e) => {
const header = e.target.closest('.monitor-group-header');
if (!header) return;
const id = header.dataset.groupId;
if (!id) return;
const container = uiGet('monitor-content');
const scrollTop = container.scrollTop;
state.monitor.openGroupsTouched = true;
if (state.monitor.openGroups.has(id)) state.monitor.openGroups.delete(id);
else state.monitor.openGroups.add(id);
MonitorModule.render();
const restoreFocus = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (callback => setTimeout(callback, 0));
restoreFocus(() => {
container.scrollTop = scrollTop;
Array.from(container.querySelectorAll('.monitor-group-header')).find(btn => btn.dataset.groupId === id)?.focus({ preventScroll: true });
});
});
}
function bindPrintEvents() {
uiGet('print-container').addEventListener('click', (e) => {
const button = e.target.closest('.btn-dl-pdf');
if (button) PrintModule.downloadPDF(button.dataset.wid, button.dataset.cdmc);
});
}
function fillEvalConfigForm() {
uiGet('pj-cfg-comment').value = state.eval.config.comment;
SelectControl.setValue('pj-cfg-random', state.eval.config.randomScore ? 'true' : 'false');
uiGet('pj-cfg-delay').value = state.eval.config.delay;
}
function bindEvaluationEvents() {
fillEvalConfigForm();
uiGet('pj-config-toggle').addEventListener('click', () => {
const card = uiGet('pj-config-card');
const isOpening = card.classList.contains('collapsed');
if (isOpening) fillEvalConfigForm();
card.classList.toggle('collapsed');
uiGet('pj-config-toggle').setAttribute('aria-expanded', String(isOpening));
});
uiGet('pj-cfg-save').addEventListener('click', async () => {
const result = await AppDialog.fire({
title: '保存评教配置?',
text: '保存后将用于后续一键评教任务。',
icon: 'question',
showCancelButton: true,
confirmButtonText: '保存',
cancelButtonText: '取消'
});
if (!result.isConfirmed) return;
state.eval.config.comment = uiGet('pj-cfg-comment').value;
state.eval.config.randomScore = uiGet('pj-cfg-random').value === 'true';
state.eval.config.delay = parseInt(uiGet('pj-cfg-delay').value) || 1500;
safeStorage.set('whut_pj_comment', state.eval.config.comment);
safeStorage.set('whut_pj_random', state.eval.config.randomScore);
safeStorage.set('whut_pj_delay', state.eval.config.delay);
uiGet('pj-config-card').classList.add('collapsed');
uiGet('pj-config-toggle').setAttribute('aria-expanded', 'false');
Toast.fire({ icon: 'success', title: '评教配置已保存' });
});
uiGet('pj-cfg-reset').addEventListener('click', async () => {
const result = await AppDialog.fire({
title: '恢复默认评教配置?',
text: '当前填写内容会被默认配置覆盖。',
icon: 'warning',
showCancelButton: true,
confirmButtonText: '恢复默认',
cancelButtonText: '取消'
});
if (!result.isConfirmed) return;
state.eval.config = { ...EVAL_DEFAULT_CONFIG };
safeStorage.set('whut_pj_comment', state.eval.config.comment);
safeStorage.set('whut_pj_random', state.eval.config.randomScore);
safeStorage.set('whut_pj_delay', state.eval.config.delay);
fillEvalConfigForm();
Toast.fire({ icon: 'success', title: '已恢复默认配置' });
});
uiGet('pj-refresh-btn').addEventListener('click', () => EvalModule.loadData({ force: true }));
uiGet('pj-run-btn').addEventListener('click', EvalModule.runSelected);
}
function bindUIEvents(fab, panel, isFabDragging) {
bindShellEvents(fab, panel, isFabDragging);
bindAnalysisEvents();
bindGPAEvents();
bindMonitorEvents();
bindPrintEvents();
bindEvaluationEvents();
}
// ========================================================================
// 业务模块:成绩分析
// 职责:接管成绩响应、补全隐藏成绩、筛选统计并生成分析视图。
// ========================================================================
const GradeResponseInterceptor = (() => {
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
let installed = false;
function patchedOpen(method, url) {
this._whutHelperURL = url;
return originalOpen.apply(this, arguments);
}
function patchedSend() {
this.addEventListener('load', function handleGradeResponse() {
if (!this._whutHelperURL?.includes('xscjcx.do') || !this.responseText || state.isFetching) return;
try {
const response = JSON.parse(this.responseText);
const result = response?.datas?.xscjcx;
if (result) processDataPart(result.rows || [], result.totalSize || 0);
} catch (error) {
Diagnostics.warn('grades', '页面成绩响应解析失败', error);
}
}, { once: true });
return originalSend.apply(this, arguments);
}
return Object.freeze({
install() {
if (installed) return;
XMLHttpRequest.prototype.open = patchedOpen;
XMLHttpRequest.prototype.send = patchedSend;
installed = true;
},
destroy() {
if (!installed) return;
if (XMLHttpRequest.prototype.open === patchedOpen) XMLHttpRequest.prototype.open = originalOpen;
if (XMLHttpRequest.prototype.send === patchedSend) XMLHttpRequest.prototype.send = originalSend;
installed = false;
}
});
})();
GradeResponseInterceptor.install();
async function proactiveFetch({ force = false } = {}) {
if (state.isFetching || (!force && state.rawData.length > 0)) return;
state.isFetching = true; const listEl = uiGet('h-list');
ButtonControl.setLoading(uiGet('h-refresh'), true, '刷新中...');
try {
// 移动端保留同一课程的多次考试记录;先获取,再用桌面接口补充元数据。
await MobileScoreModule.load({ force });
if (force) {
state.transcript.isLoaded = false;
state.transcript.lastAttempt = 0;
}
const body = new URLSearchParams({ 'querySetting': JSON.stringify([{ name: "SFYX", value: "1", linkOpt: "AND", builder: "m_value_equal" }]), '*json': '1', 'pageSize': '1000', 'pageNumber': '1', 'order': '-XNXQDM,-KCH,-KXH' });
const res = await API.requestFormJSON({
url: '/jwapp/sys/cjcx/modules/cjcx/xscjcx.do',
data: body,
timeout: 15000,
retries: 1,
dedupeKey: 'grades-all'
});
if (res.datas?.xscjcx?.rows) processDataPart(res.datas.xscjcx.rows, res.datas.xscjcx.totalSize || 0);
else if (!state.rawData.length && listEl) listEl.innerHTML = UI.state('warning', '成绩数据暂未返回', '请确认登录状态,或进入教务系统“成绩查询”页面后重试');
} catch(error) {
Diagnostics.error('grades', '主动获取成绩失败', error);
if (!state.rawData.length && listEl) listEl.innerHTML = UI.state('error', '成绩数据加载异常', '检查网络状态后重新打开成绩分析模块');
} finally {
state.isFetching = false;
ButtonControl.setLoading(uiGet('h-refresh'), false);
if (state.rawData.some(needsScoreFallback)) runHiddenScoreBackfill();
}
}
function renderSemesterOptions() {
const select = uiGet('h-semester');
if (!select) return;
const semesters = Array.from(state.semesters);
const selected = state.filters.semesters;
const allChecked = semesters.length > 0 && selected.size === semesters.length;
select.replaceChildren(...semesters.map(value => {
const option = document.createElement('option');
option.value = value;
option.textContent = value;
option.selected = selected.has(value);
return option;
}));
state.filters.semesterAll = allChecked;
SelectControl.refresh(select);
}
function isHiddenScoreText(item) {
const text = typeof item === 'object' ? item?._scoreText : item;
return String(text ?? '').trim() === '未评教';
}
function isScoreNotice(value) {
return /未评教|补考|缓考|暂缓|待定|未录入|未公布|未发布|暂未|未考试|未考|无成绩|成绩未出|待补/.test(String(value ?? ''));
}
function scoreEvidence(row) {
const display = row?._scoreText ?? formatCourseScoreDisplay(row);
const numeric = row?._score ?? Number(pickNumericCourseScore(row) || NaN);
// 缓考记录中的 0 常是占位值,不能阻止后续来源回填。
if (isScoreNotice(display) && !(Number.isFinite(numeric) && numeric > 0)) return null;
if (Number.isFinite(numeric) && numeric >= 0 && numeric <= 100) {
const grade = String(display).match(/^(优秀|良好|中等|及格|不及格|合格|不合格|通过|不通过|优|良|中)(?:[((]\s*\d+(?:\.\d+)?\s*[))])?$/);
return { text: grade ? `${grade[1]}(${numeric})` : String(numeric), numeric, passed: numeric >= 60 };
}
const text = String(display || '').trim();
const grade = TRANSCRIPT_PARSER?.cleanScore(text.replace(/\s*[((]\d+(?:\.\d+)?[))]$/, ''));
if (!grade || isScoreNotice(text)) return null;
const gpa = gradeTextToGPA(text);
return { text, numeric: null, passed: gpa !== null ? gpa > 0 : /免修|^优$|^良$|^中$/.test(text) };
}
function needsScoreFallback(course) { return !scoreEvidence(course); }
function gradeCourseKey(course) {
return JSON.stringify([String(course.XNXQDM || '').trim(), String(course.KCH || '').trim() || `${normalizeMonitorDedupeToken(course.KCM)}|${formatCreditValue(course.XF)}`]);
}
function applyScoreAttempts(course, rows, source) {
const attempts = rows.map(row => ({ row, score: scoreEvidence(row) })).filter(entry => entry.score);
if (!attempts.length) return false;
const passed = attempts.filter(entry => entry.score.passed);
const ranked = (passed.length ? passed : attempts).slice().sort((a, b) => (b.score.numeric ?? -1) - (a.score.numeric ?? -1));
const best = ranked[0];
const texts = [...new Set(attempts.map(entry => entry.score.text))];
course._scoreText = passed.length ? best.score.text : texts.join(' / ');
course._score = best.score.numeric;
course.ZCJ = best.score.numeric ?? best.score.text;
course.XSZCJMC = course._scoreText;
course._gpa = best.score.numeric !== null ? calcWhutGPAFromScore(best.score.numeric) : gradeTextToGPA(best.score.text);
course.XFJD = course._gpa;
course._gpaExcludedReason = '';
course._scoreResolved = true;
course._scoreAttempts = attempts.map(({ row, score }) => ({ score: score.text, exam: row.examProp || row.KSXZDM_DISPLAY || '', source }));
if (source === 'mobile') course._scoreFixedByMobile = true;
if (source === 'monitor') course._scoreFixedByMonitor = true;
return true;
}
const MobileScoreModule = {
normalize(response) {
return (response?.data?.termScoreList || []).flatMap(term => (term.scoreList || []).map(row => normalizeGradeCourseRow({
...row, WID: row.id, XNXQDM: row.termCode || term.termCode, KCH: row.courseId,
KCM: row.courseName, XF: row.coursePoint, XSZCJ: row.score || row.specificReason || '-',
KCXZDM_DISPLAY: row.majorFlag, KSXZDM_DISPLAY: row.examProp, SFYX: '1', _mobileRow: true
})));
},
async load({ force = false } = {}) {
const cache = state.mobileScores;
if (cache.promise) return cache.promise;
if (cache.loaded && !force) return cache.rows;
cache.promise = (async () => {
try {
const response = await MobileAPI.request('/score/termScore', { termCode: '*', courseNature: '', courseName: '' }, { method: 'POST' });
if (!Array.isArray(response?.data?.termScoreList)) throw new Error('移动成绩数据格式异常');
cache.rows = MobileScoreModule.normalize(response);
cache.loaded = true;
processDataPart([], 0, { backfill: false });
return cache.rows;
} catch (error) {
Diagnostics.warn('grades', '移动成绩读取失败,继续使用其他成绩来源', error);
return [];
} finally { cache.attempted = true; cache.promise = null; }
})();
return cache.promise;
}
};
function sameText(a, b) {
const sa = String(a ?? '').trim();
const sb = String(b ?? '').trim();
return !!sa && !!sb && sa === sb;
}
function hasScoreValue(val) {
return val !== undefined && val !== null && String(val).trim() !== '';
}
function isNumericScore(val) {
return /^-?\d+(\.\d+)?$/.test(String(val ?? '').trim());
}
function pickScoreField(courseObj, keys) {
for (const key of keys) {
const val = courseObj?.[key];
if (hasScoreValue(val)) return String(val).trim();
}
return '';
}
function cleanCourseScore(courseObj) {
return pickScoreField(courseObj, ['XSZCJ', 'ZCJ', 'CJ', 'scoreView', 'score', 'scoreValue', 'point', 'grade', 'gradeText']) || '-';
}
function calcWhutGPAFromScore(score) {
const scoreNum = parseFloat(score);
if (!Number.isFinite(scoreNum)) return null;
if (scoreNum < 60) return 0;
return Math.min(5, (scoreNum - 50) / 10);
}
function formatCreditValue(val) {
const n = parseFloat(val);
if (!Number.isFinite(n)) return '-';
return n.toFixed(2).replace(/\.?0+$/, '');
}
function gradeTextToGPA(text) {
const s = String(text ?? '').trim();
if (!s) return null;
const abbreviation = s.match(/^(优|良|中)(?:[((]|$)/)?.[1];
if (abbreviation) return calcWhutGPAFromScore(TRANSCRIPT_GRADE_NUMERIC[abbreviation]);
if (/不及格|不通过|不合格|Fail/i.test(s)) return 0;
if (/优秀|Excellent/i.test(s)) return 4.5;
if (/良好|Good/i.test(s)) return 3.5;
if (/中等|Average/i.test(s)) return 2.5;
if (/及格/i.test(s)) return 1.5;
if (/通过|合格|Pass/i.test(s)) return 3;
return null;
}
function pickCourseGPA(courseObj, scoreNum, scoreText) {
const rawGPA = toNum(courseObj?.XFJD, NaN);
if (Number.isFinite(rawGPA) && rawGPA >= 0) return rawGPA;
const scoreGPA = calcWhutGPAFromScore(scoreNum);
if (scoreGPA !== null) return scoreGPA;
return gradeTextToGPA([scoreText, courseObj?.XSZCJMC, courseObj?.DJCJ_DISPLAY].filter(Boolean).join(' '));
}
function isPendingScoreForGPA(course) {
if (course?._scoreResolved && !needsScoreFallback(course)) return false;
const text = [
course?._scoreText,
course?.XSZCJMC,
course?.DJCJ_DISPLAY,
course?.CJZT_DISPLAY,
course?.CJZT,
course?.KSXZDM_DISPLAY,
course?.SFJG_DISPLAY,
course?.ZT_DISPLAY
].map(v => String(v ?? '').trim()).filter(Boolean).join(' ');
return /缓考|暂缓|待定|未录入|未公布|未发布|暂未|未考试|未考|未修读|未获得|未选|待修|无成绩|成绩未出/.test(text);
}
function hasEffectiveScoreForGPA(course) {
const scoreText = String(course?._scoreText ?? '').trim();
if (!scoreText || scoreText === '-' || isHiddenScoreText(course)) return false;
if (isPendingScoreForGPA(course)) return false;
return true;
}
const COMPULSORY_GPA_TYPE_CODES = new Set(['01', '05', '09', '10', '11', '13', '15']);
const ELECTIVE_GPA_TYPE_CODES = new Set(['02', '03', '04', '16']);
const COMPULSORY_GPA_TYPE_NAMES = ['专业必修', '体育必修', '大类必修', '学科必修', '实践课', '英语必修', '通识必修'];
const ELECTIVE_GPA_TYPE_NAMES = ['专业选修', '专业限选', '个性课程', '通识选修'];
function normalizeCourseTypeCode(value) {
const text = String(value ?? '').trim();
if (!text) return '';
const match = text.match(/^\d+$/) || text.match(/\b\d{1,2}\b/);
if (!match) return '';
return match[0].padStart(2, '0');
}
function normalizeCourseTypeName(value) {
return String(value ?? '').replace(/\s+/g, '').trim();
}
function isCompulsoryCourseForGPA(courseObj) {
const codeFields = [
courseObj?.KCXZDM,
courseObj?.KCXZDM_CODE,
courseObj?.KCXZDM_VALUE,
courseObj?.courseNatureCode,
courseObj?.natureCode
];
for (const field of codeFields) {
const code = normalizeCourseTypeCode(field);
if (COMPULSORY_GPA_TYPE_CODES.has(code)) return true;
if (ELECTIVE_GPA_TYPE_CODES.has(code)) return false;
}
const nameFields = [
courseObj?.KCXZDM_DISPLAY,
courseObj?.KCXZMC,
courseObj?.KCXZ,
courseObj?.courseNature,
courseObj?.nature,
courseObj?.categoryName
].map(normalizeCourseTypeName).filter(Boolean);
for (const name of nameFields) {
if (ELECTIVE_GPA_TYPE_NAMES.some(typeName => name === typeName || name.includes(typeName))) return false;
if (COMPULSORY_GPA_TYPE_NAMES.some(typeName => name === typeName || name.includes(typeName))) return true;
}
const joined = nameFields.join('');
if (/选修|限选|个性课程/.test(joined)) return false;
if (/必修|实践课/.test(joined)) return true;
return false;
}
function hasValidGPAForStats(course) {
return course?.SFYX === '1'
&& course._credit > 0
&& Number.isFinite(course._gpa)
&& course._gpa >= 0
&& hasEffectiveScoreForGPA(course);
}
function getCourseGPAKey(course) {
return String(course?.KCH || course?._name || course?.KCM || course?._uid || '').trim();
}
function compareCourseForGPA(a, b) {
const ga = Number.isFinite(a?._gpa) ? a._gpa : -Infinity;
const gb = Number.isFinite(b?._gpa) ? b._gpa : -Infinity;
if (ga !== gb) return ga - gb;
const sa = Number.isFinite(a?._score) ? a._score : -Infinity;
const sb = Number.isFinite(b?._score) ? b._score : -Infinity;
if (sa !== sb) return sa - sb;
return String(a?._semester || '').localeCompare(String(b?._semester || ''));
}
function getBestCoursesForGPA(courses) {
const map = new Map();
courses.forEach(course => {
if (!hasValidGPAForStats(course)) return;
const key = getCourseGPAKey(course);
if (!key) return;
const prev = map.get(key);
if (!prev || compareCourseForGPA(course, prev) > 0) map.set(key, course);
});
return Array.from(map.values());
}
function pickNumericCourseScore(courseObj) {
for (const key of ['ZCJ', 'CJ', 'XSZCJ', 'scoreView', 'score', 'scoreValue', 'point']) {
const val = courseObj?.[key];
if (hasScoreValue(val) && isNumericScore(val)) return String(val).trim();
}
return '';
}
function formatCourseScoreDisplay(courseObj) {
const gradeText = pickScoreField(courseObj, ['XSZCJMC', 'DJCJ_DISPLAY']);
const numericScore = pickNumericCourseScore(courseObj);
const cleanScore = cleanCourseScore(courseObj);
if (hasScoreValue(gradeText) && !isHiddenScoreText(gradeText) && !isNumericScore(gradeText)) {
return hasScoreValue(numericScore) && isNumericScore(numericScore) ? `${gradeText}(${numericScore})` : withGradeNumeric(gradeText);
}
if (cleanScore !== '-') {
if (isHiddenScoreText(cleanScore) && hasScoreValue(numericScore)) return numericScore;
return !isNumericScore(cleanScore) && hasScoreValue(numericScore) && isNumericScore(numericScore) ? `${cleanScore}(${numericScore})` : withGradeNumeric(cleanScore);
}
if (isHiddenScoreText(gradeText) && hasScoreValue(numericScore)) return numericScore;
if (hasScoreValue(gradeText)) return withGradeNumeric(gradeText);
if (hasScoreValue(courseObj?.ZCJ)) return String(courseObj.ZCJ).trim();
return '-';
}
function pickAnyField(obj, keys) {
for (const key of keys) {
const val = obj?.[key];
if (val !== undefined && val !== null && val !== '') return val;
}
return '';
}
function monitorChildArrays(node) {
return ['children', 'CHILDREN', 'childList', 'childNodes', 'nodes', 'items', 'data', 'rows', 'list', 'checkCourseVOS', 'courseList', 'courses', 'courseVOS']
.flatMap(key => Array.isArray(node?.[key]) ? [{ key, rows: node[key] }] : []);
}
function looksLikeMonitorCourseRow(row) {
if (!row || typeof row !== 'object') return false;
const hasCourseName = hasScoreValue(pickAnyField(row, ['KCH', 'KCM', 'KCDM', 'KCMC', 'KCBH', 'courseId', 'courseCode', 'courseNo', 'courseName', 'courseTitle']));
const hasCourseMetric = hasScoreValue(pickAnyField(row, ['XF', 'KCXF', 'credit', 'credits', 'courseCredit', 'score', 'scoreView', 'CJ', 'ZCJ', 'XSZCJ']));
const isSelectedCourse = /已选课/.test(String(pickAnyField(row, ['ZT_DISPLAY', 'statusName', 'courseStatus']) || ''));
const hasCategoryChildren = monitorChildArrays(row).some(({ rows }) => rows.some(child => child && typeof child === 'object' && !looksLikeMonitorCourseRowShallow(child)));
return hasCourseName && (hasCourseMetric || isSelectedCourse) && !hasCategoryChildren;
}
function looksLikeMonitorCourseRowShallow(row) {
if (!row || typeof row !== 'object') return false;
const hasCourseName = hasScoreValue(pickAnyField(row, ['KCH', 'KCM', 'KCDM', 'KCMC', 'KCBH', 'courseId', 'courseCode', 'courseNo', 'courseName', 'courseTitle']));
const hasCourseMetric = hasScoreValue(pickAnyField(row, ['XF', 'KCXF', 'credit', 'credits', 'courseCredit', 'score', 'scoreView', 'CJ', 'ZCJ', 'XSZCJ']));
const isSelectedCourse = /已选课/.test(String(pickAnyField(row, ['ZT_DISPLAY', 'statusName', 'courseStatus']) || ''));
return hasCourseName && (hasCourseMetric || isSelectedCourse);
}
function collectMonitorCourseRows(node, moduleName = '') {
const rows = [];
const visit = (node, moduleName = '') => {
if (!node) return;
if (Array.isArray(node)) { node.forEach(item => visit(item, moduleName)); return; }
if (typeof node !== 'object') return;
const currModule = node.name || node.MC || node.courseCategoryName || node.moduleName || moduleName;
const arrays = monitorChildArrays(node);
if (looksLikeMonitorCourseRow(node)) rows.push(normalizeMonitorCourse(node, currModule));
arrays.forEach(({ rows: childRows }) => childRows.forEach(child => visit(child, currModule)));
if (!arrays.length && !looksLikeMonitorCourseRowShallow(node)) Object.values(node).forEach(val => {
if (Array.isArray(val) || (val && typeof val === 'object')) visit(val, currModule);
});
};
visit(node, moduleName);
return rows;
}
function normalizeMonitorDedupeToken(val) {
return String(val ?? '').trim().replace(/[\s\u00A0\u200B-\u200D\uFEFF]/g, '');
}
function monitorCourseDedupeIdentity(row) {
const courseId = normalizeMonitorDedupeToken(row?.courseId);
if (courseId) return `id:${courseId}`;
const courseName = normalizeMonitorDedupeToken(row?.courseName);
const bracketId = courseName.match(/[[\[]([^[\[\]]]+)[\]]]$/)?.[1];
return bracketId ? `id:${normalizeMonitorDedupeToken(bracketId)}` : `name:${courseName}`;
}
function monitorCourseDisplayScore(row) {
if (hasScoreValue(row?.scoreText)) return row.scoreText;
if (hasScoreValue(row?.score)) return row.score;
return '-';
}
function dedupeMonitorCourses(rows, includeModule = true, loose = false) {
const seen = new Set();
return rows.filter(r => {
const key = [
monitorCourseDedupeIdentity(r),
loose ? '' : normalizeMonitorDedupeToken(r.semester),
normalizeMonitorDedupeToken(formatCreditValue(r.credit)),
normalizeMonitorDedupeToken(monitorCourseDisplayScore(r)),
normalizeMonitorDedupeToken(r.replacedCourseId || r.replacedCourseName || '-'),
normalizeMonitorDedupeToken(formatCreditValue(r.replacedCourseCredit)),
includeModule ? normalizeMonitorDedupeToken(r.moduleName) : ''
].join('|');
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function normalizeScoreFallbackRows(xyData) {
const rows = dedupeMonitorCourses([
...collectMonitorCourseRows(xyData?.fanbx || xyData?.FANBX || xyData?.fanBx || xyData?.fanbxTree || xyData?.programTree || xyData?.schemeTree || xyData),
...collectMonitorCourseRows(xyData?.fawbx, '方案外课程'),
...collectMonitorCourseRows(xyData?.fawbxMap, '方案外课程')
]);
return rows.filter(r => (r.courseId || r.courseName) && monitorCourseDisplayScore(r) !== '-');
}
function normalizeMonitorCourse(r, moduleName = '') {
const isGradeRow = r && (r.KCH !== undefined || r.KCM !== undefined || r.CJ !== undefined || r.XF !== undefined);
const statusText = String(r?.SFJG_DISPLAY || r?.ZT_DISPLAY || r?.statusName || '').trim();
const passed = isGradeRow
? (r.SFJG === '1' || r.ZT === '01' || /通过|合格|是/.test(statusText))
: !(r.passed === false || r.passed === 'false' || r.status === '02' || r.status === '0');
const courseId = pickAnyField(r, ['KCH', 'KCDM', 'KCBH', 'courseId', 'courseCode', 'courseNo', 'code']);
const courseName = pickAnyField(r, ['KCM', 'KCMC', 'courseName', 'courseTitle', 'name', 'text']);
const semester = pickAnyField(r, ['CJXNXQDM', 'XKXNXQDM', 'JHXNXQDM', 'XNXQDM', 'pointSchoolYearTermCode', 'courseSelectionSchoolYearTermCode', 'schoolYearTermCode', 'termCode']);
const scoreText = cleanCourseScore(r);
const score = pickNumericCourseScore(r) || (scoreText !== '-' ? scoreText : '');
return {
courseId, courseName, semester, scoreText, score,
credit: pickAnyField(r, ['XF', 'KCXF', 'credit', 'credits', 'courseCredit']),
nature: pickAnyField(r, ['KCXZDM_DISPLAY', 'KCXZDM', 'courseNature', 'nature', 'categoryName']),
passed,
replacedCourseId: pickAnyField(r, ['TDKCH', 'replacedCourseId', 'replaceCourseId']),
replacedCourseName: pickAnyField(r, ['TDKCM', 'replacedCourseName', 'replaceCourseName']),
replacedCourseCredit: pickAnyField(r, ['TDKCXF', 'replacedCourseCredit', 'replaceCourseCredit']),
moduleName,
raw: r || {}
};
}
function findScoreFallback(course) {
const fallbacks = (state.monitor.scoreFallbacks || []).filter(row => {
if (row.semester && course.XNXQDM && !sameText(row.semester, course.XNXQDM)) return false;
if (row.courseId && course.KCH) return sameText(row.courseId, course.KCH);
return sameText(row.courseName, course.KCM) && hasScoreValue(row.credit) && toNum(row.credit, -1) === course._credit;
});
const exact = fallbacks.filter(row => sameText(row.semester, course.XNXQDM));
// 监测无学期时,只有当前课程不存在跨学期同名/重修记录才允许匹配。
if (!exact.length && state.rawData.some(row => row !== course && row.XNXQDM !== course.XNXQDM && (sameText(row.KCH, course.KCH) || sameText(row.KCM, course.KCM)))) return [];
return (exact.length ? exact : fallbacks).map(row => normalizeGradeCourseRow(row.raw || row));
}
function applyScoreFallbacks() {
if (!state.mobileScores.attempted || !state.rawData.length || !state.monitor.scoreFallbacks.length) return false;
let changedCount = 0;
state.rawData.forEach(c => {
if (needsScoreFallback(c) && applyScoreAttempts(c, findScoreFallback(c), 'monitor')) changedCount++;
});
if (changedCount) {
Diagnostics.info('grades', '学业监测成绩回填完成', {
changed: changedCount,
remainingHidden: state.rawData.filter(isHiddenScoreText).length
});
applyFilters();
Toast.fire({ icon: 'success', title: '已从学业监测回填隐藏成绩' });
}
return changedCount > 0;
}
// 成绩回填子模块:移动成绩优先,仍无具体成绩才读取学业监测与官方成绩单。
// 成绩单仅接受课程名、学期和学分全部一致的高可信记录;冲突项保持原状态。
const TRANSCRIPT_GRADE_NUMERIC = Object.freeze({
'优秀': 95, '优': 95,
'良好': 85, '良': 85,
'中等': 75, '中': 75,
'及格': 65,
'不及格': 50
});
function withGradeNumeric(text) {
return Object.hasOwn(TRANSCRIPT_GRADE_NUMERIC, text) ? `${text}(${TRANSCRIPT_GRADE_NUMERIC[text]})` : text;
}
function normalizeTranscriptScore(rawScore) {
const score = TRANSCRIPT_PARSER?.cleanScore?.(rawScore) || String(rawScore ?? '').trim();
if (!score) return null;
if (Object.prototype.hasOwnProperty.call(TRANSCRIPT_GRADE_NUMERIC, score)) {
const numeric = TRANSCRIPT_GRADE_NUMERIC[score];
return { raw: score, display: withGradeNumeric(score), numeric };
}
const numeric = parseFloat(score);
return { raw: score, display: score, numeric: Number.isFinite(numeric) ? numeric : null };
}
// 官方证明服务:统一证明列表查询、PDF 生成、轮询与文件签名校验。
const OfficialDocumentService = Object.freeze({
list: () => API.requestFormJSON({
url: 'https://jwxt.whut.edu.cn/jwapp/sys/zmdynxd/zmdy/queryAllZmdy.do',
retries: 1,
dedupeKey: 'official-document-list'
}),
async fetchPDF(wid) {
const reportId = String(wid || '').trim();
if (!reportId) throw new Error('证明模板标识为空');
await API.requestForm({
url: 'https://jwxt.whut.edu.cn/jwapp/sys/zmdynxd/zmdy/printBefore.do',
data: { wid: reportId },
timeout: 20000,
retries: 1,
dedupeKey: `official-document-prepare:${reportId}`
});
let lastError = 'PDF 尚未生成';
for (let attempt = 0; attempt < 4; attempt++) {
if (attempt) await waitFor(500 * attempt);
const response = await API.request({
method: 'GET',
url: `https://jwxt.whut.edu.cn/jwapp/sys/zmdynxd/zmdy/printZm.do?wid=${encodeURIComponent(reportId)}&_=${Date.now()}`,
headers: { Accept: 'application/pdf,*/*;q=0.8', 'Cache-Control': 'no-cache', Pragma: 'no-cache' },
responseType: 'blob',
timeout: 30000,
retries: 1
});
const raw = response.response;
const blob = raw instanceof Blob ? raw : new Blob([raw || response.responseText || '']);
const signature = String.fromCharCode(...new Uint8Array(await blob.slice(0, 8).arrayBuffer()));
if (signature.startsWith('%PDF-')) return blob;
const text = await blob.slice(0, 500).text();
if (/统一身份认证|login|登录页面|
`;
SelectControl.initAll(uiGet('schedule-content'));
ButtonControl.enhanceAll(uiGet('schedule-content'));
const segmented = SegmentedControl.enhance(uiGet('schedule-modes'), { onChange: mode => ScheduleModule.changeMode(mode) });
AppResources.add(() => segmented.destroy());
segmented.select(s.mode, { emit: false });
ScheduleModule.syncModeFields();
ScheduleModule.syncTermOptions();
s.disclosure = DisclosureControl.enhance(uiGet('schedule-toggle'), uiGet('schedule-controls'), { collapsed: s.collapsed, onChange: collapsed => { s.collapsed = collapsed; } });
AppResources.add(s.disclosure.destroy);
uiGet('schedule-form').addEventListener('submit', event => { event.preventDefault(); ScheduleModule.query(1, s.selected, { refresh: true }); });
uiGet('schedule-reset').addEventListener('click', () => ScheduleModule.resetFilters());
uiGet('schedule-term').addEventListener('change', () => {
ScheduleModule.invalidate();
ScheduleModule.syncCustomTerm();
ScheduleModule.syncFullTermOption();
if (uiGet('schedule-term').value === 'custom') {
uiGet('schedule-term-custom').focus();
ScheduleModule.setStatus('请输入自定义学期,点击“查询”加载。');
} else {
const current = ScheduleModule.currentCalendar();
ScheduleModule.setWeeks(current?.maxWeekNum || 20, ScheduleModule.readTerm() === current?.xnxqdm ? current.todayWeekNum : 1);
ScheduleModule.query();
}
});
uiGet('schedule-week').addEventListener('change', () => ScheduleModule.query(1, s.selected));
uiGet('schedule-form').addEventListener('input', event => {
if (event.target.tagName !== 'INPUT') return;
ScheduleModule.invalidate();
ScheduleModule.syncFullTermOption();
ScheduleModule.setStatus('查询条件已更改,点击“查询”加载。');
});
uiGet('schedule-arranged').addEventListener('change', () => ScheduleModule.query());
uiGet('schedule-results').addEventListener('click', event => {
const trigger = event.target.closest('[data-schedule-detail]');
if (trigger) void ScheduleModule.openCourseDetails(Number(trigger.dataset.scheduleDetail));
});
const generation = ++s.generation;
ScheduleModule.setBusy(true);
try {
const user = await API.getUserInfo({ force: true });
if (generation !== s.generation) return;
uiGet('schedule-student').value = user.userId || '';
s.calendar = ScheduleModule.calendarFromUserInfo(user) || await ScheduleModule.fetchCalendar('', generation);
if (generation !== s.generation) return;
s.currentTerm = s.calendar.xnxqdm;
s.currentCalendar = { ...s.calendar };
ScheduleModule.syncTermOptions(s.currentTerm);
s.lastWeek = s.calendar.todayWeekNum;
ScheduleModule.setWeeks(s.calendar.maxWeekNum, s.calendar.todayWeekNum);
await ScheduleModule.query();
} catch (error) { if (generation === s.generation) ScheduleModule.showError(error); }
finally { if (generation === s.generation) ScheduleModule.setBusy(false); }
},
calendarFromUserInfo(user) {
const welcome = user?.welcomeInfo;
if (!/^\d{4}-\d{4}-[12]$/.test(String(welcome?.xnxqdm || ''))) return null;
const week = clampValue(Math.floor(Number(welcome.classWeek)) || 1, 1, 60);
return { xnxqdm: welcome.xnxqdm, todayWeekNum: week, maxWeekNum: Math.max(20, week), provisional: true, maxWeekKnown: false };
},
currentCalendar() {
const s = ScheduleModule.state;
const current = ScheduleModule.calendarFromUserInfo(state.userInfo) || s.currentCalendar;
const known = s.calendar?.xnxqdm === current?.xnxqdm ? s.calendar : s.currentCalendar;
return current && { ...current, ...(known?.xnxqdm === current.xnxqdm ? { maxWeekNum: known.maxWeekNum } : {}) };
},
termOptions(semesters = state.semesters, currentTerm = ScheduleModule.state.currentTerm) {
return [...new Set([...semesters, currentTerm].map(value => String(value || '').trim()))].filter(value => {
const match = value.match(/^(\d{4})-(\d{4})-([12])$/);
return match && Number(match[2]) === Number(match[1]) + 1;
}).sort((a, b) => b.localeCompare(a));
},
syncTermOptions(selected = uiGet('schedule-term')?.value) {
const select = uiGet('schedule-term');
if (!select) return;
const s = ScheduleModule.state;
const terms = ScheduleModule.termOptions();
select.innerHTML = terms.map(term => ``).join('') + (s.studentUnlocked ? '' : '');
select.value = terms.includes(selected) || selected === 'custom' && s.studentUnlocked ? selected : (terms.includes(s.currentTerm) ? s.currentTerm : terms[0] || '');
SelectControl.refresh(select);
ScheduleModule.syncCustomTerm();
},
syncCustomTerm() {
const input = uiGet('schedule-term-custom');
if (!input) return;
const custom = ScheduleModule.state.studentUnlocked && uiGet('schedule-term').value === 'custom';
input.hidden = !custom;
input.disabled = !custom;
},
readTerm() {
const term = uiGet('schedule-term')?.value.trim() || '';
return term === 'custom' && ScheduleModule.state.studentUnlocked ? uiGet('schedule-term-custom').value.trim() : term;
},
resetFilters() {
['course', 'code', 'teacher', 'room', 'term-custom'].forEach(id => { uiGet(`schedule-${id}`).value = ''; });
uiGet('schedule-student').value = state.userId || '';
SelectControl.setValue('schedule-arranged', '');
const current = ScheduleModule.currentCalendar();
if (current) {
ScheduleModule.state.currentTerm = current.xnxqdm;
ScheduleModule.syncTermOptions(current.xnxqdm);
ScheduleModule.setWeeks(current.maxWeekNum, current.todayWeekNum);
}
ScheduleModule.query();
},
async fetchCalendar(termCode, generation, fallback = null) {
if (termCode) {
try {
return await ScheduleModule.request(async () => {
const response = await API.requestFormJSON({
url: '/jwapp/sys/kcbcxby/modules/xskcb/cxxljc.do',
data: { XN: termCode.slice(0, -2), XQ: termCode.slice(-1) }, timeout: 15000
});
const row = ScheduleModule.desktopNode(response, 'cxxljc').rows[0];
const max = Number(row?.ZZC ?? row?.ZJXZC);
if (!Number.isInteger(max) || max < 1 || max > 60) throw new Error('校历未返回有效周数');
if (row.XN && `${row.XN}-${row.XQ}` !== termCode) throw new Error('校历学期不匹配');
const welcome = ScheduleModule.calendarFromUserInfo(state.userInfo);
return { xnxqdm: termCode, maxWeekNum: max, todayWeekNum: welcome?.xnxqdm === termCode ? clampValue(welcome.todayWeekNum, 1, max) : 1, maxWeekKnown: true };
}, generation);
} catch (error) {
if (generation !== ScheduleModule.state.generation) throw error;
// 已有桌面首页的学期和当前周时,不因移动校历认证失败而阻断本人课表。
if (fallback?.xnxqdm === termCode) return { ...fallback, provisional: false };
}
}
const response = await ScheduleModule.request(() => MobileAPI.request('/common/school/time', termCode ? { xnxqdm: termCode, isNxg: false } : {}), generation);
return ScheduleModule.calendarData(response);
},
calendarData(response) {
const data = response?.data || response;
if (!/^\d{4}-\d{4}-[12]$/.test(String(data?.xnxqdm || ''))) throw new Error('未能获取学年学期,请填写学期后重试');
return { ...data, maxWeekNum: clampValue(Number(data.maxWeekNum) || 20, 1, 60), todayWeekNum: Math.max(1, Number(data.todayWeekNum) || 1), maxWeekKnown: Number.isInteger(Number(data.maxWeekNum)) && Number(data.maxWeekNum) > 0 };
},
setWeeks(max, selected) {
const select = uiGet('schedule-week');
select.innerHTML = '' + Array.from({ length: max }, (_, index) => ``).join('');
select.value = selected === 'all' ? 'all' : String(clampValue(Number(selected) || 1, 1, max));
SelectControl.refresh(select);
ScheduleModule.syncFullTermOption();
},
setCollapsed(collapsed) {
ScheduleModule.state.disclosure?.setCollapsed(collapsed);
},
revealStudentOnClick(now = Date.now()) {
const s = ScheduleModule.state;
if (s.studentUnlocked) return;
s.secretClicks = now - s.secretClickedAt <= 1500 ? s.secretClicks + 1 : 1;
s.secretClickedAt = now;
if (s.secretClicks < 5) return;
s.studentUnlocked = true;
ScheduleModule.syncModeFields();
ScheduleModule.syncTermOptions();
ScheduleModule.syncFullTermOption();
if (s.initialized) {
ScheduleModule.setCollapsed(false);
if (s.mode === 'student') uiGet('schedule-student').focus();
Toast.fire({ icon: 'success', title: '已解锁自定义学期、学号查询与逐周整学期查询' });
}
},
syncModeFields() {
const s = ScheduleModule.state;
uiGet('schedule-content')?.querySelectorAll('[data-schedule-mode]').forEach(el => {
el.hidden = el.dataset.scheduleMode !== s.mode || (s.mode === 'student' && !s.studentUnlocked);
});
const showSearch = s.mode !== 'student' || s.studentUnlocked;
const searchRow = uiGet('schedule-search-row');
if (searchRow) searchRow.hidden = !showSearch;
const defaultQuery = uiGet('schedule-default-query');
if (defaultQuery) defaultQuery.hidden = showSearch;
const target = uiGet(showSearch ? 'schedule-search-actions' : 'schedule-default-query');
const queryButton = uiGet('schedule-query');
if (target && queryButton && queryButton.parentElement !== target) target.appendChild(queryButton);
},
termSourceKey(query, selected) {
if (ScheduleModule.isOwnStudent(query)) return JSON.stringify([state.userId, query.termCode, 'own']);
if (query.mode === 'classroom') return JSON.stringify([state.userId, query.termCode, 'classroom', selected?.classroomNo || '']);
return '';
},
fullTermAvailable(query, selected) {
const key = ScheduleModule.termSourceKey(query, selected);
return ScheduleModule.state.studentUnlocked && ScheduleModule.state.calendar?.maxWeekKnown !== false || !!key && !ScheduleModule.state.failedTermSources.has(key);
},
syncFullTermOption(query = null, selected = ScheduleModule.state.selected) {
const select = uiGet('schedule-week');
if (!select) return;
const s = ScheduleModule.state;
query ||= { mode: s.mode, termCode: ScheduleModule.readTerm(), studentId: uiGet('schedule-student')?.value.trim() };
const allowed = ScheduleModule.fullTermAvailable(query, selected);
const option = select.querySelector('option[value="all"]');
if (option) {
option.disabled = !allowed;
option.textContent = allowed ? '完整学期' : '完整学期(暂不可用)';
}
if (!allowed && select.value === 'all') {
select.value = String(clampValue(s.lastWeek, 1, s.calendar?.maxWeekNum || 20));
if (query.week === 'all') query.week = Number(select.value);
}
SelectControl.refresh(select);
},
request(task, generation = ScheduleModule.state.generation) {
return ScheduleRequests.run(task, {
isCurrent: () => generation === ScheduleModule.state.generation,
onRetry: attempt => ScheduleModule.setStatus(`课表读取失败,正在等待第 ${attempt}/3 次重试...`)
});
},
cachedRequest(cache, pending, key, loader, { refresh = false, generation = ScheduleModule.state.generation, ttl = Infinity } = {}) {
const cached = cache.get(key);
if (!refresh && cached && Date.now() - cached.time < ttl) return Promise.resolve(cached.rows);
if (refresh) cache.delete(key);
if (pending.has(key)) {
const entry = pending.get(key);
entry.generation = generation;
return entry.promise;
}
const entry = { generation };
const request = task => ScheduleRequests.run(task, {
isCurrent: () => entry.generation === ScheduleModule.state.generation,
onRetry: attempt => ScheduleModule.setStatus(`课表读取失败,正在等待第 ${attempt}/3 次重试...`)
});
entry.promise = loader(request).then(rows => {
cache.set(key, { rows, time: Date.now() });
while (cache.size > 300) cache.delete(cache.keys().next().value);
return rows;
}).finally(() => { if (pending.get(key) === entry) pending.delete(key); });
pending.set(key, entry);
return entry.promise;
},
setStatus(text) { SummaryTags.set(uiGet('schedule-status'), Array.isArray(text) ? text : [text]); },
setBusy(busy) {
ScheduleModule.state.busy = busy;
ButtonControl.setLoading(uiGet('schedule-query'), busy, '查询中...');
uiGet('schedule-results')?.setAttribute('aria-busy', String(busy));
},
invalidate() {
const s = ScheduleModule.state;
++s.generation;
s.scrollSync?.destroy(); s.scrollSync = null;
s.selected = null; s.result = null; s.courses = []; s.rows = [];
ScheduleModule.setBusy(false);
uiGet('schedule-results').innerHTML = '';
uiGet('schedule-result-actions').innerHTML = '';
},
changeMode(mode) {
if (!Object.hasOwn(ScheduleModule.modes, mode)) return;
ScheduleModule.invalidate();
ScheduleModule.state.mode = mode;
ScheduleModule.syncModeFields();
ScheduleModule.syncFullTermOption();
ScheduleModule.query();
},
readQuery() {
const value = id => uiGet(`schedule-${id}`).value.trim();
const termCode = ScheduleModule.readTerm();
const match = termCode.match(/^(\d{4})-(\d{4})-([12])$/);
if (!match || Number(match[2]) !== Number(match[1]) + 1) throw new Error('学期格式应为相邻学年,例如 2026-2027-1');
if (!ScheduleModule.state.studentUnlocked && !ScheduleModule.termOptions().includes(termCode)) throw new Error('请选择列表中的学期');
const week = value('week') === 'all' ? 'all' : Number(value('week'));
if (week !== 'all' && (!Number.isInteger(week) || week < 1 || week > 60)) throw new Error('请选择有效周次');
const mode = ScheduleModule.state.mode;
const studentId = value('student');
if (mode === 'student' && !/^\d{5,20}$/.test(studentId)) throw new Error(ScheduleModule.state.studentUnlocked ? '请输入有效学号' : '未获取到当前账号学号,请重新登录教务系统后重试');
return { mode, termCode, week, studentId, courseName: value('course'), courseCode: value('code'), teacherName: value('teacher'), classroomName: value('room'), scheduleStatus: value('arranged') };
},
listRequest(query, pageNumber) {
const shared = { pageNumber, pageSize: ScheduleModule.state.pageSize, termCode: query.termCode };
if (query.mode === 'school') return { path: '/allSchedule/allScheduleList', params: {
...shared, publicElectiveCourses: '', courseCode: query.courseCode, courseName: query.courseName,
teachingAcademy: '', teacherName: query.teacherName, campusNo: '', publicElectiveCoursesType: '',
classroomType: '', sectionStart: '', sectionEnd: '', week: '', weekday: ''
} };
return { path: '/allSchedule/classroomScheduleList', params: { ...shared, campusNo: '', buildingNo: '', classroomName: query.classroomName, scheduleStatus: query.scheduleStatus } };
},
detailRequest(query, selected) {
if (!Number.isInteger(query.week) || query.week < 1 || query.week > 60) throw new Error('课表明细需指定具体周次');
const params = { termCode: query.termCode, week: query.week };
if (query.mode === 'student') return { path: '/allSchedule/studentScheduleDetailList', params: { ...params, studentId: query.studentId } };
if (query.mode === 'school' && selected?.teachClassCode) return { path: '/allSchedule/allScheduleDetailList', params: { ...params, teachClassId: selected.teachClassCode } };
if (query.mode === 'classroom' && selected?.classroomNo) return { path: '/allSchedule/classroomScheduleDetailList', params: { ...params, classroomCode: selected.classroomNo } };
throw new Error('缺少课表标识,请重新选择课程或教室');
},
isOwnStudent(query) {
return query.mode === 'student' && sameText(query.studentId, state.userId);
},
parseTeachingWeeks(row) {
const mask = String(row.SKZC ?? '').trim();
if (/^[01]+$/.test(mask)) return Array.from(mask, (bit, index) => bit === '1' ? index + 1 : 0).filter(Boolean);
// 旧记录未返回位串时允许使用明确的周次说明,无法识别则交给移动课表兜底。
const description = String(row.ZCMC ?? '').normalize('NFKC').trim();
const text = description.replace(/第|周|\s/g, '').replace(/[,,、;;]/g, ',').replace(/[~~—–至]/g, '-');
if (!text) throw new Error('课表记录缺少上课周次');
const weeks = [];
for (const part of text.split(',')) {
const match = part.match(/^(\d{1,2})(?:-(\d{1,2}))?(?:\(?([单双])\)?)?$/);
if (!match) throw new Error('课表周次说明无法识别');
const start = Number(match[1]), end = Number(match[2] || match[1]);
if (start < 1 || end < start || end > 60) throw new Error('课表周次超出有效范围');
for (let week = start; week <= end; week++) if (!match[3] || week % 2 === (match[3] === '单' ? 1 : 0)) weeks.push(week);
}
return [...new Set(weeks)].sort((a, b) => a - b);
},
normalizeOwnTerm(response, termCode) {
return ScheduleModule.normalizeDesktopTerm(response, 'cxxszhxqkb', termCode);
},
desktopNode(response, name, full = true) {
const node = response?.datas?.[name];
if (String(response?.code) !== '0' || !Array.isArray(node?.rows)) throw new Error(response?.msg || '课表接口返回异常');
if (node.extParams?.code != null && String(node.extParams.code) !== '1') throw new Error(node.extParams.msg || '课表查询失败');
// totalSize=-1 是该接口的正常返回值,不表示缺少记录。
if (full && Number(node.totalSize) > node.rows.length) throw new Error('学期课表返回不完整');
return node;
},
normalizeDesktopTerm(response, name, termCode, classroom = null) {
const node = ScheduleModule.desktopNode(response, name);
const rows = node.rows.map(row => {
if (row.XNXQDM && row.XNXQDM !== termCode) throw new Error('课表返回的学期不匹配');
if (classroom && row.JASDM && !sameText(row.JASDM, classroom.classroomNo)) throw new Error('课表返回的教室不匹配');
const weeks = ScheduleModule.parseTeachingWeeks(row);
return {
courseName: row.KCM, courseCode: row.KCH, courseNumber: row.KXH, teachClassCode: row.JXBID,
classroomNo: row.JASDM || classroom?.classroomNo, classroomName: row.JASMC || classroom?.classroomName, teacherNames: row.SKJS, courseClass: row.SKBJ,
weekday: hasScoreValue(row.SKXQ) ? (Number(row.SKXQ) === 0 ? 7 : Number(row.SKXQ)) : NaN,
sectionStart: Number(row.KSJC), sectionEnd: Number(row.JSJC), sectionSystem: 'desktop',
startTime: row.KSSJ, endTime: row.JSSJ, JQXX: row.JQXX,
week: null, weeks, termCode, credit: row.XF, _source: classroom ? 'classroom-term' : 'own-term'
};
}).filter(row => row.weeks.length);
return ScheduleModule.aggregateWeeks(rows);
},
async fetchOwnTerm(query, options = {}) {
if (!ScheduleModule.isOwnStudent(query)) throw new Error('本人课表接口仅用于当前登录账号');
const s = ScheduleModule.state;
const key = ScheduleModule.termSourceKey(query);
return ScheduleModule.cachedRequest(s.termCache, s.termRequests, key, request => request(async () => {
const response = await API.requestFormJSON({
url: '/jwapp/sys/wdkbby/modules/xskcb/cxxszhxqkb.do',
data: { XNXQDM: query.termCode }, timeout: 15000
});
return ScheduleModule.normalizeOwnTerm(response, query.termCode);
}), options);
},
async fetchClassroomTerm(query, selected, options = {}) {
if (!selected?.classroomNo) throw new Error('缺少教室代码');
const s = ScheduleModule.state;
const key = ScheduleModule.termSourceKey(query, selected);
return ScheduleModule.cachedRequest(s.termCache, s.termRequests, key, async request => {
const all = [];
// 官方页面同时读取常规课表和临时安排;均不传 SKZC,一次覆盖整学期。
for (const name of ['jaskcb', 'jasdkkc']) {
const rows = await request(async () => {
const response = await API.requestFormJSON({
url: `/jwapp/sys/kcbcxby/modules/jskcb/${name}.do`,
data: { XNXQDM: query.termCode, JASDM: selected.classroomNo }, timeout: 15000
});
return ScheduleModule.normalizeDesktopTerm(response, name, query.termCode, selected);
});
all.push(...rows);
}
return ScheduleModule.aggregateWeeks(all);
}, options);
},
async fetchWeek(query, selected, options = {}) {
const s = ScheduleModule.state;
const request = ScheduleModule.detailRequest(query, selected);
const key = JSON.stringify([state.userId, request.path, request.params]);
return ScheduleModule.cachedRequest(s.weekCache, s.weekRequests, key, run => run(async () => {
const response = await MobileAPI.request(request.path, request.params);
if (!Array.isArray(response.data)) throw new Error('课表明细格式异常');
return ScheduleModule.normalizeCourses(response.data, query.week);
}), { ...options, ttl: 300000 });
},
async loadDetails(query, selected, { generation = ScheduleModule.state.generation, refresh = false, onProgress = () => {} } = {}) {
const s = ScheduleModule.state;
const key = ScheduleModule.termSourceKey(query, selected);
const direct = ScheduleModule.isOwnStudent(query) || query.mode === 'classroom' && !!selected?.classroomNo;
if (direct && (refresh || !s.failedTermSources.has(key))) {
try {
const courses = await (query.mode === 'classroom'
? ScheduleModule.fetchClassroomTerm(query, selected, { refresh, generation })
: ScheduleModule.fetchOwnTerm(query, { refresh, generation }));
if (generation !== s.generation) return null;
s.failedTermSources.delete(key);
ScheduleModule.syncFullTermOption(query, selected);
return query.week === 'all' ? courses : courses.filter(course => course.weeks.includes(query.week)).map(course => ({ ...course, week: query.week, weeks: [query.week] }));
} catch (error) {
if (generation !== s.generation) return null;
if (error?.code === 'ABORTED') throw error;
s.failedTermSources.add(key);
s.termCache.delete(key);
Diagnostics.warn('schedule', '整学期课表读取失败,改用移动课表', error);
}
}
if (direct && s.failedTermSources.has(key)) {
if (query.week === 'all' && !s.studentUnlocked) query.week = clampValue(s.lastWeek, 1, s.calendar?.maxWeekNum || 20);
if (uiGet('schedule-week')) SelectControl.setValue('schedule-week', String(query.week));
ScheduleModule.syncFullTermOption(query, selected);
s.sourceNotice = '整学期暂不可用,当前使用备用课表';
}
if (query.week === 'all' && (!s.studentUnlocked || s.calendar?.maxWeekKnown === false)) throw new Error('当前课表不支持直接查询完整学期,请选择单周');
// 彩蛋开启后才允许逐周拼接;串行低频读取,每周失败最多重试三次。
const weeks = query.week === 'all' ? Array.from({ length: s.calendar.maxWeekNum }, (_, index) => index + 1) : [query.week];
const rows = [];
for (const week of weeks) {
if (generation !== s.generation) return null;
try { rows.push(await ScheduleModule.fetchWeek({ ...query, week }, selected, { refresh, generation })); }
catch (error) {
if (generation !== s.generation) return null;
throw new Error(`第 ${week} 周读取失败:${error.message || '请重试'}`);
}
if (generation === s.generation) onProgress(rows.length, weeks.length);
}
if (generation !== s.generation) return null;
const all = rows.flat();
return query.week === 'all' ? ScheduleModule.aggregateWeeks(all) : all;
},
async query(page = 1, selected = null, { refresh = false } = {}) {
const s = ScheduleModule.state;
const generation = ++s.generation;
s.scrollSync?.destroy(); s.scrollSync = null;
s.selected = selected; s.result = null; s.courses = [];
s.sourceNotice = '';
ScheduleModule.setBusy(true);
uiGet('schedule-result-actions').innerHTML = '';
uiGet('schedule-results').innerHTML = UI.state('loading', '正在查询课表', '请保持教务系统登录状态');
ScheduleModule.setStatus('正在查询...');
try {
if (s.mode === 'student' && !uiGet('schedule-student').value.trim()) {
const user = await API.getUserInfo();
if (generation !== s.generation) return;
uiGet('schedule-student').value = user.userId || '';
}
const query = ScheduleModule.readQuery();
ScheduleModule.syncFullTermOption(query, selected);
if (query.week !== 'all') s.lastWeek = query.week;
if (!s.calendar || s.calendar.xnxqdm !== query.termCode || s.calendar.provisional) {
const calendar = await ScheduleModule.fetchCalendar(query.termCode, generation, s.calendar);
if (generation !== s.generation) return;
s.calendar = calendar;
if (s.calendar.xnxqdm !== query.termCode) throw new Error('教务系统未返回所选学期的校历');
ScheduleModule.setWeeks(s.calendar.maxWeekNum, query.week);
query.week = uiGet('schedule-week').value === 'all' ? 'all' : Number(uiGet('schedule-week').value);
if (query.week !== 'all') s.lastWeek = query.week;
}
const detail = query.mode === 'student' || !!selected;
if (detail) {
const courses = await ScheduleModule.loadDetails(query, selected, {
generation, refresh,
onProgress: (done, total) => { if (query.week === 'all') ScheduleModule.setStatus(`${query.termCode} · 完整学期 · 已读取 ${done}/${total} 周`); }
});
if (generation !== s.generation) return;
s.result = { ...query, selected, detail };
s.courses = courses;
ScheduleModule.renderWeek();
} else {
const request = ScheduleModule.listRequest(query, page);
const response = await ScheduleModule.request(async () => {
const result = await MobileAPI.request(request.path, request.params);
if (!Array.isArray(result.data?.rows)) throw new Error('课表列表格式异常');
return result;
}, generation);
if (generation !== s.generation) return;
if (!Array.isArray(response.data?.rows)) throw new Error('课表列表格式异常');
s.result = { ...query, selected, detail };
s.rows = response.data.rows; s.total = Math.max(0, Number(response.data.total) || 0); s.page = page;
ScheduleModule.renderList();
}
} catch (error) {
if (generation === s.generation) { s.result = null; ScheduleModule.showError(error); }
} finally { if (generation === s.generation) ScheduleModule.setBusy(false); }
},
showError(error) {
ScheduleModule.setStatus(error?.message || '查询失败,请重试');
uiGet('schedule-results').innerHTML = UI.state('error', '课表查询失败', error?.message || '检查登录状态后点击查询重试');
Diagnostics.warn('schedule', '课表查询失败', error);
},
renderList() {
const s = ScheduleModule.state;
const pages = Math.max(1, Math.ceil(s.total / s.pageSize));
const classroom = s.result.mode === 'classroom';
ScheduleModule.setStatus([s.result.termCode, ScheduleModule.modes[s.result.mode], `共 ${s.total} 条`, `第 ${s.page}/${pages} 页`]);
const list = s.rows.map((row, index) => {
const title = classroom ? row.classroomName : row.courseName;
const meta = classroom
? [row.campusName, row.buildingName, row.classroomNo, String(row.scheduleStatus) === '1' ? '已排课' : '未排课']
: [row.courseCode, row.courseNumber ? `课序号 ${row.courseNumber}` : '', row.teachers, row.teachingAcademyName];
return ``;
}).join('');
uiGet('schedule-results').innerHTML = `${list || UI.state('empty', '暂无匹配结果', '调整学期或搜索条件后重试')}
`;
uiGet('schedule-results').querySelectorAll('[data-schedule-row]').forEach(button => button.addEventListener('click', () => ScheduleModule.query(s.page, s.rows[Number(button.dataset.scheduleRow)])));
uiGet('schedule-prev').addEventListener('click', () => ScheduleModule.query(s.page - 1));
uiGet('schedule-next').addEventListener('click', () => ScheduleModule.query(s.page + 1));
ButtonControl.enhanceAll(uiGet('schedule-results'));
},
normalizeCourses(rows, week) {
const seen = new Set();
return rows.filter(row => row && (!row.week || Number(row.week) === Number(week))).map(row => ({
...row, sectionSystem: 'mobile', week: Number(week), weeks: [Number(week)], weekday: hasScoreValue(row.weekday) ? (Number(row.weekday) === 0 ? 7 : Number(row.weekday)) : NaN, sectionStart: Number(row.sectionStart), sectionEnd: Number(row.sectionEnd)
})).filter(row => {
const key = ScheduleModule.meetingKey(row);
if (seen.has(key)) return false;
seen.add(key); return true;
}).sort((a, b) => a.weekday - b.weekday || a.sectionStart - b.sectionStart);
},
meetingKey(row) {
return JSON.stringify([row.courseCode, row.courseName, row.courseNumber, row.teachClassCode, row.weekday, row.sectionSystem, row.sectionStart, row.sectionEnd, row.classroomNo, row.classroomName, row.place, row.teacherNames, row.courseClass, row.courseAdjustType, row.startTime, row.endTime, row.JQXX]);
},
aggregateWeeks(rows) {
const groups = new Map();
rows.forEach(row => {
const key = ScheduleModule.meetingKey(row);
if (!groups.has(key)) groups.set(key, { ...row, week: null, weeks: [] });
groups.get(key).weeks.push(...(row.weeks || [row.week]));
});
return [...groups.values()].map(row => ({ ...row, weeks: [...new Set(row.weeks)].sort((a, b) => a - b) }))
.sort((a, b) => a.weekday - b.weekday || a.sectionStart - b.sectionStart);
},
weekLabel(week) { return week === 'all' ? '完整学期' : `第 ${week} 周`; },
formatWeeks(weeks) {
const sorted = [...new Set(weeks || [])].filter(week => Number.isInteger(week) && week > 0).sort((a, b) => a - b);
const ranges = [];
for (let i = 0; i < sorted.length; i++) {
const start = sorted[i];
while (i + 1 < sorted.length && sorted[i + 1] === sorted[i] + 1) i++;
ranges.push(start === sorted[i] ? String(start) : `${start}–${sorted[i]}`);
}
return ranges.length ? `第 ${ranges.join('、')} 周` : '周次待定';
},
sectionLabel(section) {
if (!Number.isInteger(section) || section < 1 || section > 16) return '';
if (section <= 5) return `第${section}节`;
if (section <= 7) return `中课${section - 5}`;
if (section <= 12) return `第${section - 2}节`;
if (section === 13) return '晚课';
return `第${section - 3}节`;
},
formatSections(start, end, system = 'desktop') {
if (system === 'mobile') {
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1 || end > 13 || end < start) return '节次待定';
return start === end ? `第${start}节` : `第 ${start}–${end} 节`;
}
const first = ScheduleModule.sectionLabel(start), last = ScheduleModule.sectionLabel(end);
if (!first || !last || end < start) return '节次待定';
if (start === end) return first;
const numericFirst = first.match(/^第(\d+)节$/), numericLast = last.match(/^第(\d+)节$/);
// 跨中课/晚课的安排保留端点名称,不把教务排序序号当作显示节次。
const sameBlock = SCHEDULE_PERIODS.some(period => start >= period.start && end <= period.end);
if (sameBlock && numericFirst && numericLast) return `第 ${numericFirst[1]}–${numericLast[1]} 节`;
if (start === 6 && end === 7) return '中课 1–2';
return `${first}–${last}`;
},
sectionSlots(course) {
const { sectionStart: start, sectionEnd: end, sectionSystem = 'desktop' } = course;
const max = sectionSystem === 'mobile' ? 13 : 16;
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1 || end > max || end < start) return [];
return Array.from({ length: end - start + 1 }, (_, index) => {
const section = start + index;
return sectionSystem === 'mobile' ? (section <= 5 ? section : section <= 10 ? section + 2 : section + 3) : section;
});
},
coursePeriods(course) {
const slots = ScheduleModule.sectionSlots(course);
return SCHEDULE_PERIODS.filter(period => slots.some(slot => slot >= period.start && slot <= period.end));
},
hasConflict(course, courses) {
const weeks = course.weeks || [course.week];
const slots = ScheduleModule.sectionSlots(course);
return courses.some(other => other !== course && other.weekday === course.weekday && ScheduleModule.sectionSlots(other).some(slot => slots.includes(slot)) && (other.weeks || [other.week]).some(week => weeks.includes(week)));
},
courseColor(course) {
const palette = ['#548ac6', '#479a85', '#ad7c40', '#9771be', '#bc718b', '#5b91a6', '#b97b62'];
const identity = String(course.courseCode || course.courseName || '');
const hash = Array.from(identity).reduce((value, char) => (value * 31 + char.charCodeAt(0)) >>> 0, 0);
return palette[hash % palette.length];
},
courseCard(course, courses, fullTerm = false) {
const conflict = ScheduleModule.hasConflict(course, courses);
const section = ScheduleModule.formatSections(course.sectionStart, course.sectionEnd, course.sectionSystem);
const meta = [course.teacherNames, course.classroomName || course.place];
const jqxx = ScheduleModule.detailText(course.JQXX);
return `${escapeHTML(course.courseName || '未命名课程')}${meta.filter(Boolean).map(value => `${escapeHTML(value)}`).join('')}${fullTerm ? `${escapeHTML(ScheduleModule.formatWeeks(course.weeks))}` : ''}${conflict ? '时段重叠' : ''}${jqxx ? `` : ''}`;
},
detailText(value) {
if (value === undefined || value === null) return '';
if (typeof value === 'object') return Object.values(value).some(item => ScheduleModule.detailText(item)) ? JSON.stringify(value, null, 2) : '';
return String(value).trim();
},
courseDetailsHTML(course) {
const labels = [course.courseCode, course.courseClass, course.teacherNames, course.classroomName || course.place, ScheduleModule.days[course.weekday - 1], ScheduleModule.formatSections(course.sectionStart, course.sectionEnd, course.sectionSystem), course.startTime && course.endTime ? `${course.startTime}–${course.endTime}` : '', ScheduleModule.formatWeeks(course.weeks || [course.week])].filter(Boolean);
return `${SummaryTags.render(labels)}
${escapeHTML(ScheduleModule.detailText(course.JQXX))}
`;
},
openCourseDetails(index) {
const course = ScheduleModule.state.courses[index];
if (!course || !ScheduleModule.detailText(course.JQXX)) return;
return AppDialog.details({ title: `${course.courseName || '课程'} · 详情`, html: ScheduleModule.courseDetailsHTML(course), width: 520 });
},
visiblePeriods(courses) {
return SCHEDULE_PERIODS.filter(period => ![6, 13].includes(period.start) || courses.some(course => course.weekday >= 1 && course.weekday <= 7 && ScheduleModule.coursePeriods(course).includes(period)));
},
gridHTML(courses, fullTerm) {
const heads = `节次 / 星期
${ScheduleModule.days.map(day => `${day}
`).join('')}`;
const rows = ScheduleModule.visiblePeriods(courses).map(period => `${period.title}${period.label}
${ScheduleModule.days.map((day, index) => {
const matches = courses.filter(course => course.weekday === index + 1 && ScheduleModule.coursePeriods(course).includes(period));
return `${matches.map(course => ScheduleModule.courseCard(course, courses, fullTerm)).join('')}
`;
}).join('')}`);
// 使用真实行语义,display:contents 让行参与同一网格布局。
return ``;
},
renderWeek() {
const s = ScheduleModule.state;
const query = s.result;
const title = query.mode === 'student' ? (ScheduleModule.isOwnStudent(query) ? '' : `学号 ${query.studentId}`) : (query.selected.courseName || query.selected.classroomName);
const fullTerm = query.week === 'all';
const weekLabel = ScheduleModule.weekLabel(query.week);
ScheduleModule.setStatus([query.termCode, weekLabel, title, `${s.courses.length} 条安排`, s.sourceNotice].filter(Boolean));
const unscheduled = s.courses.filter(course => !Number.isInteger(course.weekday) || course.weekday < 1 || course.weekday > 7 || !ScheduleModule.coursePeriods(course).length);
uiGet('schedule-result-actions').innerHTML = `${query.mode !== 'student' ? `` : ''}${!fullTerm ? `` : ''}`;
uiGet('schedule-results').innerHTML = `${!s.courses.length ? UI.state('empty', fullTerm ? '本学期暂无课程安排' : '本周暂无课程安排', '可切换学期或周次查看') : ''}${ScheduleModule.gridHTML(s.courses, fullTerm)}${unscheduled.length ? `时间待定或未识别${unscheduled.map(course => ScheduleModule.courseCard(course, s.courses, fullTerm)).join('')}
` : ''}`;
const shell = uiGet('schedule-results');
s.scrollSync?.destroy();
s.scrollSync = HorizontalScrollControl.enhance(shell.querySelector('.scroll-sync-top'), shell.querySelector('.schedule-week-scroll'), shell.querySelector('.schedule-week'));
uiGet('schedule-back')?.addEventListener('click', () => ScheduleModule.query(s.page));
const changeWeek = offset => {
SelectControl.setValue('schedule-week', String(query.week + offset));
ScheduleModule.query(s.page, s.selected);
};
uiGet('schedule-week-prev')?.addEventListener('click', () => changeWeek(-1));
uiGet('schedule-week-next')?.addEventListener('click', () => changeWeek(1));
uiGet('schedule-export').addEventListener('click', () => {
exportToBrowser(s.courses.map(course => ({ '学年学期': query.termCode, '周次': fullTerm ? ScheduleModule.formatWeeks(course.weeks) : query.week, '星期': ScheduleModule.days[course.weekday - 1] || '待定', '节次': ScheduleModule.formatSections(course.sectionStart, course.sectionEnd, course.sectionSystem), '课程名称': course.courseName, '课程代码': course.courseCode, '课序号': course.courseNumber, '教师': course.teacherNames, '教室': course.classroomName || course.place, '班级': course.courseClass, '详细信息': ScheduleModule.detailText(course.JQXX) })), `WHUT_${ScheduleModule.modes[query.mode]}_${query.termCode}_${weekLabel.replace(/ /g, '')}`, 'xlsx');
});
ButtonControl.enhanceAll(uiGet('schedule-result-actions'));
}
};
// ========================================================================
// 业务模块:个人学业监测
// 职责:汇总培养方案完成度、课程分类、预警信息与综合表现导出。
// ========================================================================
const MonitorModule = {
init: async () => {
if (state.monitor.isLoaded) {
MonitorModule.render();
MonitorModule.setStatus(`数据已同步${state.monitor.overview.calculatedTime ? `:${state.monitor.overview.calculatedTime}` : ''}`);
return;
}
if (state.monitor.isLoading) {
state.monitor.renderAfterLoad = true;
MonitorModule.setStatus('正在从服务器同步学业监测数据...');
return;
}
await MonitorModule.loadData();
},
setStatus: (msg) => {
const el = uiGet('monitor-status');
if (el) el.textContent = String(msg || '');
},
setBusy: (busy, text) => {
const refreshBtn = uiGet('btn-monitor-refresh');
const exportBtn = uiGet('btn-monitor-export');
if (refreshBtn) ButtonControl.setLoading(refreshBtn, busy, text || '处理中...');
if (exportBtn) exportBtn.disabled = busy;
},
request: async (url, params = {}, opts = {}) => {
const requestOptions = {
url,
data: compactParams(params),
responseType: opts.blob ? 'blob' : undefined,
timeout: opts.timeout || (opts.blob ? 30000 : 15000),
retries: opts.retries || 0,
dedupeKey: opts.dedupeKey
};
if (opts.blob) {
const res = await API.requestForm(requestOptions);
if (!res.response) throw new Error('空文件流');
return res.response;
}
const json = await API.requestFormJSON(requestOptions);
if (!json || json.code === '404') throw new Error('接口不存在');
if (json.code !== undefined && String(json.code) !== '0') throw new Error(json.msg || json.message || `接口返回异常:${json.code}`);
return json;
},
pick: (obj, keys) => {
for (const key of keys) if (obj && obj[key] !== undefined && obj[key] !== null && obj[key] !== '') return obj[key];
return '';
},
pickNum: (obj, keys, fallback = 0) => {
for (const key of keys) {
const n = parseFloat(obj?.[key]);
if (Number.isFinite(n)) return n;
}
return fallback;
},
unwrap: (res, key) => {
const datas = res?.datas ?? res?.data ?? res;
if (!datas) return {};
let node = datas[key] ?? datas;
if (!node && typeof datas === 'object') node = Object.values(datas).find(v => v && (v.rows || v.data || v.fanbx || Array.isArray(v)));
if (node?.rows?.length === 1) {
const row = node.rows[0];
const structuralKeys = ['fanbx', 'FANBX', 'fanBx', 'fawbx', 'fawbxMap', 'children', 'CHILDREN', 'childList', 'moduleList', 'categoryList', 'courseCategoryList', 'checkCourseVOS', 'courseList', 'data'];
if (row && typeof row === 'object' && structuralKeys.some(k => row[k] !== undefined)) node = row;
}
return node || {};
},
rowsOf: (node) => {
if (Array.isArray(node)) return node;
if (Array.isArray(node?.rows)) return node.rows;
if (Array.isArray(node?.data)) return node.data;
if (Array.isArray(node?.list)) return node.list;
return node && typeof node === 'object' ? [node] : [];
},
pickBynf: (...values) => {
for (const val of values) {
if (val === undefined || val === null || val === '') continue;
const text = String(val).trim();
if (text === '*') return '*';
return text.match(/\d{4}/)?.[0] || text;
}
return '';
},
isNewPlanCode: (plan) => {
const pyfadm = String(plan?.PYFADM || plan?.value || '').trim();
return !!pyfadm && !/^\d+$/.test(pyfadm);
},
guessGraduationYear: (userId) => {
const id = String(userId || '');
const gradeStr = id.length === 13 ? id.substring(3, 5) : (id.length === 10 ? id.substring(2, 4) : '');
const startYear = parseInt(`20${gradeStr}`, 10);
return Number.isFinite(startYear) ? String(startYear + 4) : '';
},
buildStudyParams: (userInfo, studentInfo = {}) => {
const queryNode = MonitorModule.unwrap(studentInfo, 'queryXsjbxx');
const jbxx = queryNode.jbxx || {};
const plans = Array.isArray(queryNode.faArr) ? queryNode.faArr : [];
const plan = plans.find(p => p.XDLXDM === '01') || plans[0] || {};
const bynf = MonitorModule.pickBynf(
plan.BYNF,
plan.bynf,
MonitorModule.isNewPlanCode(plan) ? '*' : '',
jbxx.BYNF,
jbxx.YJBYNF,
jbxx.YJBYRQ,
MonitorModule.guessGraduationYear(state.userId || userInfo?.userId)
);
return {
basicRes: studentInfo,
base: compactParams({
from: 'xyjctjapp',
XH: state.userId || userInfo?.userId,
PYFADM: plan.PYFADM || plan.value,
BYNF: bynf,
pageType: 2,
hasByjSearch: false
}),
xy: compactParams({
fromPage: 'grxyjcbg',
from: 'xyjctjapp',
XH: state.userId || userInfo?.userId,
PYFADM: plan.PYFADM || plan.value,
BYNF: bynf,
pageType: 2,
hasByjSearch: false,
SCLX: '04',
XDLX: plan.XDLXDM || '01'
})
};
},
getStudyContext: async (userInfo) => {
if (state.monitor.studyParams?.base?.PYFADM) return state.monitor.studyParams;
const userId = state.userId || userInfo?.userId;
const seedCandidates = Array.from(new Set([MonitorModule.guessGraduationYear(userId), '*', '']));
let studyParams = null;
let lastError = null;
for (const seedBynf of seedCandidates) {
const seedParams = compactParams({
from: 'xyjctjapp',
XH: userId,
BYNF: seedBynf,
pageType: 2,
hasByjSearch: false
});
try {
const basicRes = await MonitorModule.request('/jwapp/sys/byshapp/api/grbg/queryXsjbxx.do', seedParams);
const nextParams = MonitorModule.buildStudyParams(userInfo, basicRes);
if (nextParams.base.PYFADM) {
studyParams = nextParams;
break;
}
lastError = new Error('未获取到培养方案代码 PYFADM');
} catch (e) {
lastError = e;
}
}
if (!studyParams) throw lastError || new Error('未获取到培养方案代码 PYFADM');
state.monitor.studyParams = studyParams;
return studyParams;
},
withBynf: (studyParams, bynf) => ({
...studyParams,
base: compactParams({ ...studyParams.base, BYNF: bynf }),
xy: compactParams({ ...studyParams.xy, BYNF: bynf })
}),
studyParamVariants: (studyParams, userInfo) => {
const currentBynf = studyParams?.xy?.BYNF ?? studyParams?.base?.BYNF ?? '';
const guessedBynf = MonitorModule.guessGraduationYear(state.userId || userInfo?.userId);
return Array.from(new Set([currentBynf, '*', guessedBynf, '']))
.map(bynf => MonitorModule.withBynf(studyParams, bynf));
},
hasMonitorContent: (normalized) => {
return !!(
normalized?.categories?.length ||
normalized?.scoreFallbacks?.length ||
toNum(normalized?.overview?.required, 0) > 0 ||
toNum(normalized?.overview?.earned, 0) > 0
);
},
loadData: async ({ force = false, silent = false, render = true } = {}) => {
if (state.monitor.isLoading) return;
if (state.monitor.isLoaded && !force) {
applyScoreFallbacks();
if (render) MonitorModule.render();
return;
}
state.monitor.isLoading = true;
if (!silent) {
MonitorModule.setStatus('正在从服务器同步学业监测数据...');
const content = uiGet('monitor-content');
if (content && render) content.innerHTML = UI.state('loading', '正在同步学业监测', '培养方案数据可能需要数秒完成');
}
try {
const userInfo = await API.getUserInfo();
const studyParams = await MonitorModule.getStudyContext(userInfo);
let selected = null;
let lastError = null;
for (const candidateParams of MonitorModule.studyParamVariants(studyParams, userInfo)) {
try {
const xyRes = await MonitorModule.request('/jwapp/sys/byshapp/api/grbg/queryXyzhbx.do', candidateParams.xy);
const normalized = MonitorModule.normalize(xyRes, candidateParams.basicRes);
selected = { normalized, studyParams: candidateParams };
if (MonitorModule.hasMonitorContent(normalized)) break;
} catch (e) {
lastError = e;
}
}
if (!selected) throw lastError || new Error('学业监测数据请求失败');
const { normalized, studyParams: selectedStudyParams } = selected;
Object.assign(state.monitor, normalized, {
isLoaded: true,
studyParams: selectedStudyParams
});
Diagnostics.info('monitor', '学业监测数据加载完成', {
categories: state.monitor.categories.length,
warnings: state.monitor.warnings.length,
scoreFallbacks: state.monitor.scoreFallbacks.length,
requiredCredit: state.monitor.overview.required,
earnedCredit: state.monitor.overview.earned
});
applyScoreFallbacks();
if (render) MonitorModule.render();
MonitorModule.setStatus(`数据已同步${state.monitor.overview.calculatedTime ? `:${state.monitor.overview.calculatedTime}` : ''}`);
} catch (e) {
Diagnostics.error('monitor', '学业监测数据加载失败', e);
if (render) uiGet('monitor-content').innerHTML = UI.state('error', '学业监测加载异常', e.message || '稍后重试');
MonitorModule.setStatus('学业监测数据加载失败');
} finally {
state.monitor.isLoading = false;
if (state.monitor.renderAfterLoad && state.monitor.isLoaded) {
state.monitor.renderAfterLoad = false;
MonitorModule.render();
MonitorModule.setStatus(`数据已同步${state.monitor.overview.calculatedTime ? `:${state.monitor.overview.calculatedTime}` : ''}`);
}
}
},
courseState: (course) => {
const raw = course?.raw || {};
const statusText = [raw.ZT_DISPLAY, raw.SFJG_DISPLAY, raw.statusName]
.filter(value => value !== undefined && value !== null && value !== '')
.join(' ').trim();
const hasScore = (hasScoreValue(course?.scoreText) && course.scoreText !== '-') || hasScoreValue(course?.score);
if (/已选课|已选未修|正在修读|修读中/.test(statusText) && !hasScore) return 'selected';
if (/未修读|未获得|未选|待修/.test(statusText) && !hasScore) return 'pending';
if ((course?.passed === false || course?.passed === 'false') && !hasScore) return 'pending';
if (raw.SFJG === '0' || raw.ZT === '02' || raw.status === '02' || raw.status === '0') return 'fail';
if (/不及格|不通过|未通过|不合格|否|失败/.test(statusText)) return 'fail';
const scoreNum = parseFloat(course?.score ?? course?.scoreText);
if (Number.isFinite(scoreNum) && scoreNum >= 0 && scoreNum < 60) return 'fail';
return 'pass';
},
coursePassed: (course) => {
return MonitorModule.courseState(course) === 'pass';
},
categoryNodesOf: (node) => {
if (!node) return [];
if (Array.isArray(node)) return node.filter(item => item && typeof item === 'object' && !looksLikeMonitorCourseRow(item));
const keys = ['children', 'CHILDREN', 'childList', 'childNodes', 'nodes', 'items', 'moduleList', 'categoryList', 'courseCategoryList', 'kcmkList', 'treeData', 'data', 'rows', 'list'];
const nodes = [];
keys.forEach(key => {
if (!Array.isArray(node?.[key])) return;
node[key].forEach(item => {
if (item && typeof item === 'object' && !looksLikeMonitorCourseRow(item)) nodes.push(item);
});
});
return nodes;
},
normalizeCategory: (node, index = 0, path = 'cat') => {
const name = MonitorModule.pick(node, ['MC', 'FLMC', 'KCLBMC', 'KCMKMC', 'courseCategoryName', 'moduleName', 'name', 'text']) || '未命名模块';
const id = String(node?.id || `${path}-${index}`);
const childNodes = MonitorModule.categoryNodesOf(node);
const children = childNodes.map((child, idx) => MonitorModule.normalizeCategory(child, idx, id));
const directRows = [];
monitorChildArrays(node).forEach(({ rows }) => {
rows.forEach(row => {
if (looksLikeMonitorCourseRow(row)) directRows.push(normalizeMonitorCourse(row, name));
});
});
const directRowsUnique = dedupeMonitorCourses(directRows, false, true);
const rows = dedupeMonitorCourses(directRowsUnique.concat(children.flatMap(c => c.rows || [])), false, true);
const requiredKeys = ['creditsRequired', 'YQXF', 'YQZXF', 'ZXF', 'ZDZXF', 'XFYQ', 'requiredCredit', 'requireCredit', 'creditRequire', 'BYXF', 'minCredit', 'minimumCredit', 'requiredCredits', 'planCredit', 'totalCreditRequired'];
const earnedKeys = ['creditsEarned', 'HDXF', 'YHXF', 'YXXF', 'TGXF', 'WCXF', 'earnedCredit', 'completeCredit', 'passCredit', 'earnedCredits', 'completedCredit', 'actualCredit', 'gotCredit', 'totalCreditEarned'];
let required = MonitorModule.pickNum(node, requiredKeys, NaN);
let earned = MonitorModule.pickNum(node, earnedKeys, NaN);
if (!Number.isFinite(required)) required = children.reduce((sum, child) => sum + child.required, 0);
if (!Number.isFinite(earned)) {
earned = children.reduce((sum, child) => sum + child.earned, 0)
+ directRowsUnique.filter(r => MonitorModule.coursePassed(r)).reduce((sum, r) => sum + toNum(r.credit), 0);
}
const creditPassed = !Number.isFinite(required) || required <= 0 || earned + 1e-6 >= required;
return { id, name, required, earned, rows, children, passed: creditPassed };
},
normalize: (xyRes, basicRes) => {
let xyNode = MonitorModule.unwrap(xyRes, 'queryXyzhbx');
if (Array.isArray(xyNode)) xyNode = { data: xyNode };
if (xyNode?.rows && !xyNode.data) xyNode.data = xyNode.rows;
const fanbx = xyNode.fanbx || xyNode.FANBX || xyNode.fanBx || xyNode.fanbxMap || xyNode.fanbxTree || xyNode.programTree || xyNode.schemeTree || {};
let categoryNodes = MonitorModule.categoryNodesOf(fanbx);
if (!categoryNodes.length) categoryNodes = MonitorModule.categoryNodesOf(xyNode);
let categories = categoryNodes.map((node, index) => MonitorModule.normalizeCategory(node, index));
const fawbxRows = dedupeMonitorCourses([
...collectMonitorCourseRows(xyNode.fawbx, '方案外课程'),
...collectMonitorCourseRows(xyNode.fawbxMap, '方案外课程')
]).filter(r => r.courseId || r.courseName);
if (fawbxRows.length) {
categories.push({
id: 'fawbx-group',
name: '方案外/额外课程',
required: 0,
earned: fawbxRows.filter(r => MonitorModule.coursePassed(r)).reduce((sum, r) => sum + toNum(r.credit), 0),
rows: fawbxRows,
children: [],
passed: true
});
}
const scoreFallbacks = normalizeScoreFallbackRows(xyNode);
if (!categories.length && scoreFallbacks.length) {
const grouped = new Map();
scoreFallbacks.forEach(course => {
const key = course.moduleName || course.nature || '其他课程';
const curr = grouped.get(key) || { id: `fallback-${grouped.size}`, name: key, required: 0, earned: 0, rows: [], children: [], passed: true };
curr.rows.push(course);
curr.earned += toNum(course.credit);
curr.passed = curr.passed && MonitorModule.coursePassed(course);
grouped.set(key, curr);
});
categories = Array.from(grouped.values());
}
const basicNode = MonitorModule.unwrap(basicRes, 'queryXsjbxx');
const required = MonitorModule.pickNum(fanbx, ['creditsRequired', 'YQXF', 'YQZXF', 'ZXF', 'ZDZXF', 'XFYQ', 'requiredCredit', 'requireCredit', 'creditRequire', 'BYXF', 'minCredit', 'minimumCredit', 'requiredCredits', 'planCredit', 'totalCreditRequired'], categories.reduce((sum, c) => sum + c.required, 0));
const earned = MonitorModule.pickNum(fanbx, ['creditsEarned', 'creditsSelection', 'YXXF', 'HDXF', 'YHXF', 'TGXF', 'WCXF', 'earnedCredit', 'earnedCredits', 'completedCredit', 'actualCredit', 'gotCredit', 'totalCreditEarned'], categories.reduce((sum, c) => sum + c.earned, 0));
const warnings = MonitorModule.buildWarnings(scoreFallbacks, xyNode, basicNode);
if (!state.monitor.openGroupsTouched) {
state.monitor.openGroups = new Set();
}
return {
overview: {
required,
earned,
passed: (!required || earned + 1e-6 >= required) && warnings.every(w => w.type !== 'danger'),
calculatedTime: MonitorModule.pick(xyNode, ['calculatedTime', 'CZSJ', 'GXSJ', 'JSSJ']) || ''
},
categories,
warnings,
scoreFallbacks
};
},
buildWarnings: (courses, xyNode, basicNode) => {
const warnings = [];
const seen = new Set();
courses.forEach(course => {
const state = MonitorModule.courseState(course);
if (state === 'pass' || state === 'pending' || state === 'selected') return;
const key = `${course.courseId || course.courseName}-${course.semester || ''}`;
if (seen.has(key)) return;
seen.add(key);
warnings.push({
type: 'danger',
title: course.courseName || course.courseId || '未命名课程',
text: `${course.semester || '-'} ${course.scoreText ?? course.score ?? '未通过'}`
});
});
(Array.isArray(xyNode?.fjzbbx) ? xyNode.fjzbbx : []).forEach(item => {
const targets = Array.isArray(item.targets) ? item.targets : [];
const failedTargets = targets.filter(t => t?.passed === false || t?.passed === 'false');
if (!(item.passed === false || item.passed === 'false' || failedTargets.length)) return;
const detail = (failedTargets.length ? failedTargets : targets).map(t => {
const result = t?.result !== undefined && t?.result !== null ? `,当前值 ${t.result}` : '';
return `${t?.name || item.name || '附加指标'}${result}`;
}).join(';');
warnings.push({ type: 'danger', title: item.name || '附加指标提醒', text: detail || '未达标' });
});
const disciplineKeys = ['CFXX', 'CFZT', 'CFQK', 'CFCJ', 'SFCF', 'SFYCF', 'XSCFQK', 'punishment', 'disciplineStatus'];
[basicNode?.jbxx || {}, basicNode || {}].forEach(row => disciplineKeys.forEach(k => {
const val = row?.[k];
if (val !== undefined && val !== null && val !== '' && !/^(0|否|无|正常|false)$/i.test(String(val))) {
warnings.push({ type: 'danger', title: '处分状态提醒', text: `${k}: ${val}` });
}
}));
if (!warnings.length) warnings.push({ type: 'ok', title: '暂无预警', text: '未发现不及格课程或附加指标异常。' });
return warnings;
},
renderCourseRows: (group) => {
const rows = dedupeMonitorCourses(group.rows || [], false, true);
if (!rows.length) return '| 暂无课程明细 |
';
return rows.map(course => {
const state = MonitorModule.courseState(course);
const pass = state === 'pass';
const courseLabel = sameText(course.courseName, course.courseId)
? (course.courseName || course.courseId || '-')
: `${course.courseName || '-'}${course.courseId ? `[${course.courseId}]` : ''}`;
const replacedLabel = (course.replacedCourseName || course.replacedCourseId)
? (sameText(course.replacedCourseName, course.replacedCourseId) ? (course.replacedCourseName || course.replacedCourseId) : `${course.replacedCourseName || '-'}${course.replacedCourseId ? `[${course.replacedCourseId}]` : ''}`)
: '-';
const scoreDisplay = state === 'selected' ? '已选课' : (course.scoreText ?? course.score ?? '-');
const stateSymbol = iconSVG(pass ? 'check' : (state === 'selected' ? 'clock' : (state === 'pending' ? 'minus' : 'close')));
return `
${escapeHTML(courseLabel)} |
${stateSymbol} |
${escapeHTML(scoreDisplay)} |
${escapeHTML(formatCreditValue(course.credit))} |
${escapeHTML(replacedLabel)} |
${escapeHTML(formatCreditValue(course.replacedCourseCredit))} |
`;
}).join('');
},
renderMonitorGroups: (categories) => {
if (!categories.length) return '暂无培养方案模块数据
';
return `${categories.map((group, index) => {
const open = state.monitor.openGroups.has(group.id);
const pct = group.required ? Math.min(100, Math.round((group.earned / group.required) * 100)) : 0;
const bodyId = `monitor-group-body-${index}`;
return `
${open ? `
| 课程名[课程号] | 是否通过 | 成绩 | 学分 | 替代课程名[课程号] | 学分 |
${MonitorModule.renderCourseRows(group)}
` : ''}
`;
}).join('')}
`;
},
render: () => {
const container = uiGet('monitor-content');
if (!container) return;
const { overview, categories, warnings } = state.monitor;
const pct = overview.required ? Math.min(100, Math.round((overview.earned / overview.required) * 100)) : 0;
const warningsHtml = warnings.map(w => `${escapeHTML(w.title)}
${escapeHTML(w.text)}
`).join('');
container.innerHTML = `
毕业资格状态
${overview.passed ? '当前状态正常' : '存在待处理项'}
${overview.passed ? '达标/可继续' : '需关注'}
总学分进度:${formatCreditValue(overview.earned)} / ${overview.required ? formatCreditValue(overview.required) : '-'} 学分${pct}%
${iconSVG('academic')}培养方案模块
${MonitorModule.renderMonitorGroups(categories || [])}
${iconSVG('warning')}预警中心
${warningsHtml}
`;
},
refresh: async () => {
if (state.monitor.isRefreshing) return;
state.monitor.isRefreshing = true;
MonitorModule.setBusy(true, '同步中...');
MonitorModule.setStatus('正在触发服务器重新计算...');
try {
const userInfo = await API.getUserInfo();
const studyParams = await MonitorModule.getStudyContext(userInfo);
const res = await MonitorModule.request('/jwapp/sys/byshapp/api/grbg/refresh.do', studyParams.xy);
const node = MonitorModule.unwrap(res, 'refresh');
const key = node?.ZXJDKEY || node?.refresh || res?.datas?.refresh || res?.ZXJDKEY;
if (!key) throw new Error('未获取到同步任务标识 ZXJDKEY');
await MonitorModule.pollProcess(key);
state.monitor.isLoaded = false;
await MonitorModule.loadData({ force: true });
Toast.fire({ icon: 'success', title: '学业监测数据已同步' });
} catch (e) {
AppDialog.fire('同步失败', e.message, 'error');
MonitorModule.setStatus('手动同步失败');
} finally {
state.monitor.isRefreshing = false;
MonitorModule.setBusy(false);
}
},
pollProcess: (key) => new Promise((resolve, reject) => {
let count = 0;
const timer = AppResources.repeat(async () => {
try {
count++;
const res = await MonitorModule.request('/jwapp/sys/jwpubapp/api/zxjd/queryProcess.do', { ZXJDKEY: key });
const node = MonitorModule.unwrap(res, 'queryProcess');
const data = MonitorModule.rowsOf(node)[0] || node || {};
MonitorModule.setStatus(`后台计算中${data.ZXJD ? `:${String(data.ZXJD)}` : ''}`);
if (data.ZXBZ === '1' || data.ZXBZ === 1 || data.finished === true || data.finished === 'true') {
AppResources.clearRepeat(timer);
resolve();
} else if (count > 80) {
AppResources.clearRepeat(timer);
reject(new Error('后台同步超时,请稍后重试'));
}
} catch (e) {
AppResources.clearRepeat(timer);
reject(e);
}
}, 1500);
}),
exportReport: async () => {
try {
MonitorModule.setBusy(true);
MonitorModule.setStatus('正在生成学业综合表现文件...');
const userInfo = await API.getUserInfo();
const studyParams = await MonitorModule.getStudyContext(userInfo);
const blob = await MonitorModule.request('/jwapp/sys/byshapp/api/grbg/exportFanbxkcxx.do', studyParams.xy, { blob: true });
FileDownload.saveBlob(blob, '学业综合表现.xls');
Toast.fire({ icon: 'success', title: '学业综合表现已导出' });
MonitorModule.setStatus('导出完成');
} catch (e) { AppDialog.fire('导出失败', e.message, 'error'); }
finally { MonitorModule.setBusy(false); }
},
patchHiddenScores: async () => {
if (!state.rawData.some(needsScoreFallback)) return;
return runHiddenScoreBackfill();
}
};
// ========================================================================
// 服务模块:数据导出
// 职责:将当前业务数据导出为 XLSX、CSV 或图片。
// ========================================================================
const MAPPING_GPA_NEW = [ { label: "学年学期", key: "XNXQDM" }, { label: "学号", key: "XH" }, { label: "姓名", key: "XM" }, { label: "性别", key: "XBDM_DISPLAY", fallback: "XBDM" }, { label: "年级", key: "XZNJ_DISPLAY", fallback: "XZNJ" }, { label: "院系", key: "YXDM_DISPLAY", fallback: "YXDM" }, { label: "专业", key: "ZYDM_DISPLAY", fallback: "ZYDM" }, { label: "班级", key: "BJDM_DISPLAY", fallback: "BJMC" }, { label: "班级总人数", key: "BJZRS", fallback: "BY3R3ZRS" }, { label: "专业总人数", key: "ZYZRS" }, { label: "必修课首次成绩平均学分绩点", key: "BXKPJXFJD" }, { label: "必修课首次成绩平均学分绩点班级排名", key: "BXKPJXFJDBJPM" }, { label: "必修课首次成绩平均学分绩点专业排名", key: "BXKPJXFJDZYPM" }, { label: "平均学分绩点", key: "PJXFJD" }, { label: "平均学分绩点班级排名", key: "PJXFJDBJPM" }, { label: "平均学分绩点专业排名", key: "PJXFJDZYPM" }, { label: "操作时间", key: "CZSJ" }, { label: "平均每学期获得学分数", key: "PJMXQHDXF" } ];
function exportToBrowser(data, filename, format) {
if (!data || data.length === 0) return AppDialog.fire('提示', '没有数据可导出', 'info');
if (format === 'xlsx') {
const ws = XLSX.utils.json_to_sheet(data); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Sheet1'); XLSX.writeFile(wb, `${filename}.xlsx`);
} else {
const headers = Object.keys(data[0]);
const rows = [headers.join(',')].concat(data.map(r => headers.map(h => { let c = String(r[h] || ''); if (/[",\n]/.test(c)) c = `"${c.replace(/"/g, '""')}"`; return c; }).join(',')));
const blob = new Blob([new Uint8Array([0xEF, 0xBB, 0xBF]), rows.join('\n')], { type: 'text/csv;charset=utf-8;' });
FileDownload.saveBlob(blob, `${filename}.csv`);
}
}
// ========================================================================
// 模块:使用帮助
// 职责:提供功能教程、使用提示与开发者信息。
// ========================================================================
const HelpModule = Object.freeze({
open() {
return AppDialog.fire({
title: '使用帮助',
eyebrow: 'WHUT教务系统小助手使用指南',
width: 680,
showConfirmButton: false,
showCloseButton: true,
unsafeHTML: `
WHUT 教务系统小助手用于集中查看学业进度、课程成绩与 GPA,并辅助下载官方证明、完成评教和导出数据。请先登录教务系统,再使用需要读取数据的功能。
${iconSVG('rocket')}快速开始
- 登录武汉理工大学教务系统,点击页面悬浮球打开助手。
- 从顶部选择“学业监测”“成绩分析”“课表查询”“GPA 查询”“官方证明”或“一键评教”。
- 等待数据加载完成后再筛选、查看明细、提交操作或导出结果。
- 若数据为空,先确认原教务模块可以正常访问,再手动同步或刷新页面。
${iconSVG('academic')}功能说明
学业监测汇总培养方案完成情况与预警信息,可手动同步并导出综合表现。
成绩分析筛选默认收起,摘要旁可直接重置;学期与课程性质支持多选。点击必修或全科绩点可查看计入课程,支持列表及长图导出。
课表查询学期下拉框包含成绩学期与当前学期,默认查询本人当前周;筛选默认收起,重置恢复当前学期和周次。连点顶部“课表查询”五次可解锁自定义学期和学号。支持详情弹窗、同步横向滚动及 Excel 导出。
GPA 查询查看累计及学年 GPA;点击绩点可展开课程明细,也可导出表格或长图。
官方证明读取教务系统提供的证明材料,并按页面提示生成或下载官方 PDF。
一键评教先检查评分策略、评语与提交间隔;确认无误后再执行自动评教。
主题与诊断右上角可切换主题或打开诊断日志;点击标题下的版本号会立即检查更新。
使用提示:可拖动悬浮球和面板标题栏调整位置,拖动面板边缘调整大小。导出或批量操作期间请保持页面打开;遇到异常时,可从右上角齿轮入口导出诊断日志。
`
});
}
});
// ========================================================================
// 服务模块:脚本更新
// 职责:以远端元数据为版本真源,校验发布清单并呈现安全安装入口。
// ========================================================================
function currentScriptVersion() {
return String(globalThis.GM_info?.script?.version || globalThis.GM?.info?.script?.version || SCRIPT_VERSION).trim();
}
/** 严格比较 MAJOR.MINOR.PATCH;左侧较新、相同、较旧时依次返回 1、0、-1。 */
function compareVersion(left, right) {
const a = String(left || '').trim();
const b = String(right || '').trim();
if (!UPDATE_VERSION_PATTERN.test(a) || !UPDATE_VERSION_PATTERN.test(b)) {
throw new TypeError('版本必须使用 MAJOR.MINOR.PATCH 格式。');
}
const av = a.split('.').map(BigInt);
const bv = b.split('.').map(BigInt);
for (let index = 0; index < 3; index++) {
if (av[index] !== bv[index]) return av[index] > bv[index] ? 1 : -1;
}
return 0;
}
/** 更新产物只接受固定发布站点上的 HTTPS 地址和预期扩展名。 */
function trustedUpdateURL(value, label, suffix = '') {
let url;
try { url = new URL(String(value || '')); }
catch { throw new TypeError(`${label}格式错误。`); }
if (url.protocol !== 'https:' || url.origin !== SCRIPT_UPDATE_ORIGIN || url.username || url.password) {
throw new TypeError(`${label}必须指向 ${SCRIPT_UPDATE_ORIGIN}。`);
}
if (suffix && !url.pathname.endsWith(suffix)) throw new TypeError(`${label}必须以 ${suffix} 结尾。`);
return url.href;
}
function sameUpdateArtifact(left, right) {
const normalize = value => {
const url = new URL(value);
url.search = '';
url.hash = '';
return url.href;
};
return normalize(left) === normalize(right);
}
function requireExpectedArtifact(actual, expected, label) {
if (!sameUpdateArtifact(actual, expected)) throw new Error(`${label}与预期发布地址不一致。`);
return actual;
}
/** 复用统一请求层读取文本;不会执行远端内容,并校验最终重定向地址。 */
async function requestUpdateText(url, { label, suffix, dedupeKey }) {
const requestedURL = trustedUpdateURL(url, label, suffix);
const target = new URL(requestedURL);
target.searchParams.set('_update_check', String(Date.now()));
const response = await API.request({
method: 'GET',
url: target.href,
anonymous: true,
headers: {
Accept: 'text/javascript, application/json, text/plain;q=0.8',
'Cache-Control': 'no-cache',
Pragma: 'no-cache'
},
timeout: 12000,
retries: 1,
dedupeKey
});
const finalURL = trustedUpdateURL(response?.finalUrl || response?.finalURL || target.href, `${label}最终地址`, suffix);
requireExpectedArtifact(finalURL, requestedURL, `${label}最终地址`);
const text = String(response?.responseText ?? (typeof response?.response === 'string' ? response.response : ''));
if (!text.trim()) throw new Error(`${label}返回空内容。`);
return text;
}
function metadataValues(header, name) {
const pattern = new RegExp(`^// @${name}\\s+(.+?)\\s*$`, 'gm');
return [...header.matchAll(pattern)].map(match => match[1].trim());
}
/** 只读取完整 UserScript 头中的唯一版本与发布地址。 */
function parseUpdateMetadata(source) {
const text = String(source || '');
if (!text.startsWith('// ==UserScript==
// @name WHUT教务系统小助手
// @namespace http://tampermonkey.net/
// @version 4.0.0
// @description 支持学生/全校/教室课表、学业监测、GPA 课程明细、官方证明、一键评教和自动更新。
// @author 毫厘
// @match *://jwxt.whut.edu.cn/jwapp/sys/*
// @match *://jwxt.whut.edu.cn/jwmobile/*
// @homepageURL https://scriptcat.org/zh-CN/script-show-page/6588
// @supportURL https://scriptcat.org/zh-CN/script-show-page/6588
// @require https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js
// @require https://cdn.sheetjs.com/xlsx-latest/package/dist/xlsx.full.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js
// @grant GM_xmlhttpRequest
// @grant GM_info
// @grant GM_openInTab
// @noframes
// @connect jwxt.whut.edu.cn
// @connect home.haoli.site
// @run-at document-start
// @license MIT
// ==/UserScript==');
if (endIndex < 0) throw new Error('远端 UserScript 元数据头不完整。');
const header = text.slice(0, endIndex + '// ==/UserScript=='.length);
const requireSingle = name => {
const values = metadataValues(header, name);
if (values.length !== 1) throw new Error(`远端元数据必须包含一个 @${name}。`);
return values[0];
};
const version = requireSingle('version');
compareVersion(version, version);
const updateURL = trustedUpdateURL(requireSingle('updateURL'), '元数据中的 @updateURL', '.meta.js');
const downloadURL = trustedUpdateURL(requireSingle('downloadURL'), '元数据中的 @downloadURL', '.user.js');
return { version, updateURL, downloadURL };
}
/** latest.json 只承担结构化更新说明,版本判定始终以 meta.js 为准。 */
function parseUpdateManifest(source, expectedVersion) {
let manifest;
try { manifest = JSON.parse(String(source || '')); }
catch { throw new Error('更新说明不是有效 JSON。'); }
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) throw new Error('更新说明结构无效。');
if (manifest.schemaVersion !== 1 || String(manifest.scriptId || '') !== SCRIPT_ID) {
throw new Error('更新说明与当前脚本不匹配。');
}
const version = String(manifest.version || '').trim();
compareVersion(version, version);
if (version !== expectedVersion) throw new Error('更新说明版本与元数据不一致。');
const expectedURLs = [
['updateUrl', SCRIPT_UPDATE.metaURL, '.meta.js'],
['downloadUrl', SCRIPT_UPDATE.downloadURL, '.user.js'],
['latestUrl', SCRIPT_UPDATE.infoURL, '.latest.json']
];
for (const [field, expected, suffix] of expectedURLs) {
const actual = trustedUpdateURL(manifest[field], `更新说明 ${field}`, suffix);
requireExpectedArtifact(actual, expected, `更新说明 ${field}`);
}
if (!Array.isArray(manifest.notes)) throw new Error('更新说明缺少 notes 数组。');
return manifest.notes.map(note => String(note).trim()).filter(Boolean).slice(0, 3);
}
function openUpdatePage(url) {
const target = trustedUpdateURL(url || SCRIPT_UPDATE.downloadURL, '安装地址', '.user.js');
requireExpectedArtifact(target, SCRIPT_UPDATE.downloadURL, '安装地址');
if (typeof GM_openInTab === 'function') GM_openInTab(target, { active: true, insert: true, setParent: true });
else window.open(target, '_blank', 'noopener,noreferrer');
}
const UpdateModule = {
getContainer: () => uiGet('update-container') || uiGet('update-container-modal'),
updateHeaderButton: () => {
const btn = uiGet('h-update');
if (!btn) return;
btn.classList.toggle('has-update', !!state.update.hasNew);
btn.classList.toggle('is-checking', !!state.update.isChecking);
const label = btn.querySelector('.header-update-label');
if (label) label.textContent = state.update.isChecking ? '检查中' : (state.update.hasNew ? '有新版' : '检查更新');
},
autoCheck: () => {
const interval = 6 * 60 * 60 * 1000;
const last = parseInt(safeStorage.get('whut_update_last_check', '0'), 10);
if (Date.now() - last < interval) return;
UpdateModule.check(false);
},
showNotice: (message, type = 'info') => {
Toast.fire({ icon: type, title: message, timer: 3200 });
},
handleClick: (e) => {
const checkBtn = e.target.closest('#btn-update-check');
const openBtn = e.target.closest('#btn-update-open');
const logBtn = e.target.closest('#btn-update-log');
if (checkBtn) UpdateModule.check(true);
if (openBtn) {
try { openUpdatePage(openBtn.dataset.url); }
catch (error) {
Diagnostics.warn('update', '安装地址校验失败', error);
UpdateModule.showNotice(error.message || '安装地址无效', 'error');
}
}
if (logBtn) window.open(SCRIPT_UPDATE.projectURL, '_blank', 'noopener,noreferrer');
},
openPanel: (checkNow = false) => {
AppDialog.fire({
title: '脚本更新',
unsafeHTML: '',
width: 620,
showConfirmButton: false,
showCloseButton: true,
didOpen: (modal) => {
const container = modal.querySelector('#update-container-modal');
if (container) container.addEventListener('click', UpdateModule.handleClick);
UpdateModule.render();
if (checkNow) UpdateModule.check(true);
else if (!state.update.isLoaded && !state.update.isChecking) UpdateModule.check(true);
}
});
},
check: async (manual = false) => {
if (state.update.isChecking) return;
state.update.isChecking = true;
state.update.error = '';
UpdateModule.render();
try {
const script = globalThis.GM_info?.script || globalThis.GM?.info?.script || {};
const currentVersion = currentScriptVersion();
compareVersion(currentVersion, currentVersion);
const metadataURL = trustedUpdateURL(script.updateURL || SCRIPT_UPDATE.metaURL, '元数据地址', '.meta.js');
requireExpectedArtifact(metadataURL, SCRIPT_UPDATE.metaURL, '元数据地址');
const metadataText = await requestUpdateText(metadataURL, {
label: '版本元数据',
suffix: '.meta.js',
dedupeKey: 'script-update-meta'
});
const metadata = parseUpdateMetadata(metadataText);
requireExpectedArtifact(metadata.updateURL, SCRIPT_UPDATE.metaURL, '元数据中的 @updateURL');
requireExpectedArtifact(metadata.downloadURL, SCRIPT_UPDATE.downloadURL, '元数据中的 @downloadURL');
let notes = [];
try {
const latestText = await requestUpdateText(SCRIPT_UPDATE.infoURL, {
label: '更新说明',
suffix: '.latest.json',
dedupeKey: 'script-update-latest'
});
notes = parseUpdateManifest(latestText, metadata.version);
} catch (error) {
if (error?.code === 'ABORTED') throw error;
Diagnostics.warn('update', '更新说明读取失败,继续使用元数据版本', error);
}
const info = {
version: metadata.version,
notes,
downloadURL: metadata.downloadURL
};
const hasNew = compareVersion(metadata.version, currentVersion) > 0;
state.update.info = info;
state.update.isLoaded = true;
state.update.hasNew = hasNew;
UpdateModule.render();
UpdateModule.updateHeaderButton();
safeStorage.set('whut_update_last_check', Date.now());
if (!manual && hasNew) {
const notifyKey = `whut_update_notified_${info.version}`;
if (safeStorage.get(notifyKey, '0') !== '1') {
UpdateModule.showNotice(`发现脚本新版本 ${info.version}`, 'info');
safeStorage.set(notifyKey, '1');
}
}
} catch (e) {
Diagnostics.warn('update', '脚本更新检查失败', e);
state.update.error = e.message || '更新检查失败';
state.update.info = null;
state.update.isLoaded = false;
state.update.hasNew = false;
UpdateModule.updateHeaderButton();
UpdateModule.render();
} finally {
state.update.isChecking = false;
UpdateModule.render();
}
},
render: () => {
const container = UpdateModule.getContainer();
if (!container) return;
const current = currentScriptVersion();
const info = state.update.info || {};
const latest = info.version || '-';
const hasInfo = !!info.version;
const versionOrder = hasInfo ? compareVersion(latest, current) : 0;
const hasNew = !state.update.error && hasInfo && versionOrder > 0;
const isAhead = !state.update.error && hasInfo && versionOrder < 0;
state.update.hasNew = hasNew;
UpdateModule.updateHeaderButton();
const statusText = state.update.isChecking
? '正在检查更新...'
: (state.update.error
? `检查失败:${state.update.error}`
: (hasInfo ? (hasNew ? `发现新版本 ${latest}` : (isAhead ? `当前版本 ${current} 高于发布版本 ${latest}` : '当前已是最新版本')) : '尚未检查'));
const statusClass = state.update.error ? 'is-error' : (hasNew ? 'is-new' : 'is-current');
const notes = Array.isArray(info.notes) ? info.notes : [];
const downloadURL = info.downloadURL || SCRIPT_UPDATE.downloadURL;
container.innerHTML = `
${iconSVG('refresh')}脚本更新
通过 meta.js 判断最新版本,并从 latest.json 读取更新说明;安装地址经校验后交由用户脚本管理器确认。
当前版本${escapeHTML(current)}
最新版本${escapeHTML(latest)}
${escapeHTML(statusText)}
${notes.length ? `
更新内容
${notes.map(n => `- ${escapeHTML(n)}
`).join('')}
` : ''}
`;
}
};
// ========================================================================
// 业务模块:官方证明
// 职责:加载可打印材料,并安全生成和下载教务系统 PDF。
// ========================================================================
const PrintModule = {
init: async () => {
if (state.isPrintLoading || state.isPrintLoaded) return;
state.isPrintLoading = true;
const container = uiGet('print-container');
container.innerHTML = UI.state('loading', '正在获取官方证明', '可下载材料将按照类别分组');
try {
await API.getUserInfo();
const res = await OfficialDocumentService.list();
if (res.success && res.datas) { PrintModule.render(res.datas, container); state.isPrintLoaded = true; }
else container.innerHTML = UI.state('warning', '官方证明列表暂未返回', '当前账号可能尚未配置可打印材料');
} catch(e) {
container.innerHTML = UI.state('error', '官方证明加载异常', e.message || '稍后重试');
} finally {
state.isPrintLoading = false;
}
},
render: (categories, container) => {
if (!Array.isArray(categories) || !categories.length) {
container.innerHTML = UI.state('empty', '暂无可打印材料', '后续开放的证明会自动显示在这里');
return;
}
let html = '';
categories.forEach(group => {
html += `${iconSVG('folder')}${escapeHTML(group.CDMC)}
`;
(group.REPORTS || []).forEach(report => { html += `
${escapeHTML(report.CDMC)}
`; });
html += `
`;
});
container.innerHTML = html;
},
downloadPDF: async (wid, cdmc) => {
try {
AppDialog.fire({ title: '正在生成 PDF', text: '后台渲染中,请稍候...', allowOutsideClick: false, didOpen: () => AppDialog.showLoading() });
const blob = await OfficialDocumentService.fetchPDF(wid);
FileDownload.saveBlob(blob, `${state.userName}-${state.userId}-${cdmc}.pdf`);
AppDialog.close();
Toast.fire({ icon: 'success', title: 'PDF 下载成功' });
} catch (e) {
Diagnostics.error('print', '官方证明下载失败', { report: cdmc, error: e });
AppDialog.close();
AppDialog.fire('错误', e.message, 'error');
}
}
};
// ========================================================================
// 模块:应用注册与生命周期
// 职责:集中注册业务模块、按需加载视图,并在页面退出时释放请求与 UI 资源。
// ========================================================================
const VIEW_MODULES = Object.freeze({
schedule: ScheduleModule,
gpa: GPAModule,
evaluation: EvalModule,
monitor: MonitorModule,
print: PrintModule
});
const VIEW_LOADERS = Object.freeze({
'view-schedule': () => VIEW_MODULES.schedule.init(),
'view-gpa': () => VIEW_MODULES.gpa.init(),
'view-eval': () => VIEW_MODULES.evaluation.init(),
'view-monitor': () => VIEW_MODULES.monitor.init(),
'view-print': () => { if (!state.isPrintLoaded) VIEW_MODULES.print.init(); }
});
const AppLifecycle = Object.freeze({
start: () => initUI(),
destroy(reason = 'pagehide') {
API.abortAll(reason);
AppDialog.close();
SelectControl.destroyAll();
GradeResponseInterceptor.destroy();
AppResources.destroy();
AppEvents.destroy();
UIKit.destroy();
}
});
const startApp = () => AppLifecycle.start();
if (document.readyState === 'loading') AppEvents.listen(document, 'DOMContentLoaded', startApp, { once: true });
else startApp();
AppEvents.listen(window, 'pagehide', event => {
if (!event.persisted) AppLifecycle.destroy('pagehide');
});
// ==================== 窗口自由缩放补丁 ====================
// 修复版 v1.0.1:标准空格、独立缩放触点、最大化时隐藏触点。
{
const SIZE_KEY = 'whut_panel_custom_size_v1';
const storedSize = safeStorage.getJSON(SIZE_KEY, {});
let preferred = {
width: Number(storedSize.width) > 0 ? Number(storedSize.width) : PANEL_LAYOUT.width,
height: Number(storedSize.height) > 0 ? Number(storedSize.height) : null
};
let maximized = false;
let restorePosition = null;
let dragSession = null;
let frame = 0;
const bounds = () => {
const v = window.visualViewport;
const margin = PANEL_LAYOUT.viewportMargin;
const width = Math.max(1, (v?.width || window.innerWidth) - margin * 2);
const height = Math.max(1, (v?.height || window.innerHeight) - margin * 2);
const left = (v?.offsetLeft || 0) + margin;
const top = (v?.offsetTop || 0) + margin;
return { left, top, width, height, right: left + width, bottom: top + height,
minWidth: Math.min(360, width), minHeight: Math.min(360, height) };
};
const rememberSize = () => safeStorage.setJSON(SIZE_KEY, preferred);
const px = value => /^-?\d+(?:\.\d+)?px$/.test(value || '') ? Number.parseFloat(value) : NaN;
function applyRect(panel, r) {
// 行内 important 覆盖原脚本移动端媒体查询中的固定尺寸。
const values = { width: r.width, height: r.height, left: r.left, top: r.top };
Object.entries(values).forEach(([key, value]) => panel.style.setProperty(key, `${value}px`, 'important'));
['min-width', 'min-height'].forEach(key => panel.style.setProperty(key, '0px', 'important'));
['max-width', 'max-height'].forEach(key => panel.style.setProperty(key, 'none', 'important'));
panel.style.right = panel.style.bottom = 'auto';
panel.dataset.windowCompact = String(r.width < 480);
}
function resizeRect(start, dx, dy, direction, limit) {
let { left, top, width, height } = start;
const right = left + width;
const bottom = top + height;
if (direction.includes('e')) width = clampValue(width + dx, limit.minWidth, limit.right - left);
if (direction.includes('s')) height = clampValue(height + dy, limit.minHeight, limit.bottom - top);
if (direction.includes('w')) {
left = clampValue(left + dx, limit.left, right - limit.minWidth);
width = right - left;
}
if (direction.includes('n')) {
top = clampValue(top + dy, limit.top, bottom - limit.minHeight);
height = bottom - top;
}
return { left, top, width, height };
}
// 原函数的所有调用点(打开、收起、移动、屏幕旋转)统一使用自定义尺寸。
clampPanelToViewport = function(panel) {
if (!panel) return;
const b = bounds();
const width = maximized ? b.width : clampValue(preferred.width, b.minWidth, b.width);
const height = maximized ? b.height : clampValue(preferred.height || window.innerHeight * PANEL_LAYOUT.heightRatio, b.minHeight, b.height);
let left = px(panel.style.left);
let top = px(panel.style.top);
if (!Number.isFinite(left)) left = b.left + (b.width - width) / 2;
if (!Number.isFinite(top)) top = b.top + (b.height - height) / 2;
applyRect(panel, {
width, height,
left: maximized ? b.left : clampValue(left, b.left, b.right - width),
top: maximized ? b.top : clampValue(top, b.top, b.bottom - height)
});
};
function installWindowResize() {
const panel = uiGet('whut-helper-panel');
if (!panel || panel.dataset.resizableWindow === '1') return;
panel.dataset.resizableWindow = '1';
const controller = new AbortController();
const listen = (target, type, handler, options = {}) => target?.addEventListener(type, handler, { ...options, signal: controller.signal });
const style = document.createElement('style');
style.textContent = `
#whut-helper-panel[data-resizable-window] { resize:none; }
#whut-helper-panel .whut-resize-handle {
position:absolute; z-index:15; padding:0; margin:0; min-width:0; min-height:0;
border:0; border-radius:0; background:transparent; color:var(--uikit-text-muted);
touch-action:none; user-select:none; -webkit-user-select:none;
}
#whut-helper-panel .whut-resize-handle:focus-visible { outline:2px solid var(--uikit-brand); outline-offset:-3px; }
#whut-helper-panel .whut-resize-handle[data-edge=n] { top:0; left:22px; right:22px; height:8px; cursor:ns-resize; }
#whut-helper-panel .whut-resize-handle[data-edge=s] { bottom:0; left:22px; right:36px; height:8px; cursor:ns-resize; }
#whut-helper-panel .whut-resize-handle[data-edge=w] { left:0; top:22px; bottom:22px; width:8px; cursor:ew-resize; }
#whut-helper-panel .whut-resize-handle[data-edge=e] { right:0; top:22px; bottom:36px; width:8px; cursor:ew-resize; }
#whut-helper-panel .whut-resize-handle[data-edge=nw] { top:0; left:0; width:18px; height:18px; cursor:nwse-resize; }
#whut-helper-panel .whut-resize-handle[data-edge=ne] { top:0; right:0; width:18px; height:18px; cursor:nesw-resize; }
#whut-helper-panel .whut-resize-handle[data-edge=sw] { bottom:0; left:0; width:22px; height:22px; cursor:nesw-resize; }
#whut-helper-panel .whut-resize-handle[data-edge=se] { bottom:0; right:0; width:36px; height:36px; cursor:nwse-resize; }
#whut-helper-panel .whut-resize-handle[data-edge=se]::after {
content:''; position:absolute; right:6px; bottom:6px; width:16px; height:16px;
background:repeating-linear-gradient(135deg,transparent 0 4px,currentColor 4px 6px);
clip-path:polygon(100% 0,100% 100%,0 100%); opacity:.65;
}
#whut-helper-panel.is-resizing, #whut-helper-panel.is-resizing * {
user-select:none !important; -webkit-user-select:none !important;
transition:none !important; animation:none !important;
}
#whut-helper-panel.is-window-maximized .whut-resize-handle { display:none; }
#whut-helper-panel.is-window-maximized .helper-header { cursor:default; }
#whut-helper-panel .header-top { flex-wrap:wrap; }
#whut-helper-panel .header-actions { margin-left:auto; }
#whut-helper-panel .header-help { width:32px; padding:0; }
#whut-helper-panel[data-window-compact=true] .header-name { font-size:14px; }
#whut-helper-panel[data-window-compact=true] .helper-footer { grid-template-columns:repeat(2,minmax(0,1fr)); }
#whut-helper-panel[data-window-compact=true] .monitor-toolbar-row { flex-wrap:wrap; }
#whut-helper-panel[data-window-compact=true] .monitor-group-header { grid-template-columns:18px minmax(0,1fr) auto; }
#whut-helper-panel[data-window-compact=true] .monitor-group-credit { grid-column:2/4; grid-row:2; }
#whut-helper-panel[data-window-compact=true] .gpa-flat-grid { grid-template-columns:1fr; }
#whut-helper-panel[data-window-compact=true] .gpa-flat-metric + .gpa-flat-metric { border-left:0; border-top:1px solid var(--uikit-border); padding:12px 2px 2px; }
#whut-helper-panel .helper-toolbar { max-height:36%; overflow:auto; }
@media (pointer:coarse) {
#whut-helper-panel .whut-resize-handle[data-edge=n], #whut-helper-panel .whut-resize-handle[data-edge=s] { height:12px; }
#whut-helper-panel .whut-resize-handle[data-edge=e], #whut-helper-panel .whut-resize-handle[data-edge=w] { width:12px; }
}
`;
UIKitRuntime.shadow.appendChild(style);
function finish(cancel = false) {
if (!dragSession) return;
const d = dragSession;
dragSession = null;
cancelAnimationFrame(frame);
frame = 0;
applyRect(panel, cancel ? d.start : d.next);
panel.classList.remove('is-resizing');
try { d.handle.releasePointerCapture(d.pointerId); } catch (_) { /* 已取消的指针 */ }
if (!cancel) {
preferred = { width: d.next.width, height: d.next.height };
rememberSize();
savePanelState(panel);
}
}
function toggleMaximize() {
finish();
SelectControl.closeAll();
if (!maximized) {
restorePosition = { left: panel.style.left, top: panel.style.top };
} else if (restorePosition) {
panel.style.left = restorePosition.left;
panel.style.top = restorePosition.top;
}
maximized = !maximized;
panel.classList.toggle('is-window-maximized', maximized);
handles.forEach(handle => {
handle.hidden = maximized;
handle.disabled = maximized;
});
clampPanelToViewport(panel);
savePanelState(panel);
}
const header = panel.querySelector('#panel-drag-handle');
const isHeaderControl = event => event.target.closest('button,input,select,textarea,a,.helper-tabs');
listen(header, 'dblclick', event => { if (!isHeaderControl(event)) toggleMaximize(); });
listen(header, 'pointerdown', event => {
// 避免点击按钮里的 SVG 时触发旧版标题栏拖拽;最大化时固定位置。
if (maximized || isHeaderControl(event)) event.stopPropagation();
}, { capture: true });
const names = { n:'上边', s:'下边', e:'右边', w:'左边', nw:'左上角', ne:'右上角', sw:'左下角', se:'右下角' };
const handles = [];
Object.entries(names).forEach(([direction, name]) => {
const handle = document.createElement('button');
handle.type = 'button';
handle.className = 'whut-resize-handle';
handle.dataset.edge = direction;
// 定位与触摸行为直接作用于触点,避免附加样式未生效时挤占内容。
const edges = {
n: { top:'0', left:'22px', right:'22px', height:'12px', cursor:'ns-resize' },
s: { bottom:'0', left:'22px', right:'36px', height:'12px', cursor:'ns-resize' },
w: { left:'0', top:'22px', bottom:'22px', width:'12px', cursor:'ew-resize' },
e: { right:'0', top:'22px', bottom:'36px', width:'12px', cursor:'ew-resize' },
nw: { top:'0', left:'0', width:'22px', height:'22px', cursor:'nwse-resize' },
ne: { top:'0', right:'0', width:'22px', height:'22px', cursor:'nesw-resize' },
sw: { bottom:'0', left:'0', width:'22px', height:'22px', cursor:'nesw-resize' },
se: { bottom:'0', right:'0', width:'36px', height:'36px', cursor:'nwse-resize' }
};
Object.entries({
position:'absolute', inset:'auto', 'z-index':'15',
width:'auto', height:'auto', 'box-sizing':'border-box',
'min-width':'0', 'min-height':'0',
'max-width':'none', 'max-height':'none',
margin:'0', padding:'0', border:'0', 'border-radius':'0',
background:'transparent', 'box-shadow':'none',
appearance:'none', '-webkit-appearance':'none',
'touch-action':'none', 'user-select':'none', '-webkit-user-select':'none',
'pointer-events':'auto',
...edges[direction]
}).forEach(([key, value]) => handle.style.setProperty(key, value, 'important'));
handle.title = `拖动${name}调整窗口大小`;
handle.setAttribute('aria-label', `调整${name}大小(方向键微调)`);
panel.appendChild(handle);
handles.push(handle);
listen(handle, 'pointerdown', event => {
if (maximized || dragSession || event.isPrimary === false || event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
SelectControl.closeAll();
clampPanelToViewport(panel);
panel.classList.add('is-resizing');
const r = panel.getBoundingClientRect();
const start = { left:r.left, top:r.top, width:r.width, height:r.height };
dragSession = { handle, pointerId:event.pointerId, x:event.clientX, y:event.clientY, direction, start, next:start, limit:bounds() };
try { handle.setPointerCapture(event.pointerId); } catch (_) { /* document 监听仍可结束拖动 */ }
});
listen(handle, 'lostpointercapture', () => finish(true));
listen(handle, 'keydown', event => {
if (maximized || !['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(event.key)) return;
event.preventDefault();
event.stopPropagation();
clampPanelToViewport(panel);
const step = event.shiftKey ? 40 : 10;
const r = panel.getBoundingClientRect();
const next = resizeRect(r,
event.key === 'ArrowLeft' ? -step : event.key === 'ArrowRight' ? step : 0,
event.key === 'ArrowUp' ? -step : event.key === 'ArrowDown' ? step : 0,
direction, bounds());
applyRect(panel, next);
preferred = { width:next.width, height:next.height };
rememberSize();
savePanelState(panel);
});
});
listen(document, 'pointermove', event => {
const d = dragSession;
if (!d || d.pointerId !== event.pointerId) return;
event.preventDefault();
d.next = resizeRect(d.start, event.clientX - d.x, event.clientY - d.y, d.direction, d.limit);
if (!frame) frame = requestAnimationFrame(() => { frame = 0; if (dragSession) applyRect(panel, dragSession.next); });
}, { capture: true, passive: false });
listen(document, 'pointerup', event => {
const d = dragSession;
if (!d || d.pointerId !== event.pointerId) return;
d.next = resizeRect(d.start, event.clientX - d.x, event.clientY - d.y, d.direction, d.limit);
finish();
}, { capture: true });
listen(document, 'pointercancel', event => { if (dragSession?.pointerId === event.pointerId) finish(true); }, { capture: true });
listen(document, 'keydown', event => {
if (event.key !== 'Escape' || !dragSession) return;
event.preventDefault();
event.stopPropagation();
finish(true);
}, { capture: true });
listen(window, 'blur', () => finish(true));
const fit = () => { finish(true); clampPanelToViewport(panel); };
listen(window, 'resize', fit);
listen(window.visualViewport, 'resize', fit);
listen(window.visualViewport, 'scroll', fit);
clampPanelToViewport(panel);
AppResources.add(() => {
finish(true);
controller.abort();
style.remove();
handles.forEach(handle => handle.remove());
});
}
installWindowResize();
if (document.readyState === 'loading') {
AppEvents.listen(document, 'DOMContentLoaded', installWindowResize, { once:true });
}
}
// ==================== 窗口缩放补丁结束 ====================
})();