// ==UserScript== // @name SCUT_Lesson_Table // @namespace http://xsjw2018.jw.scut.edu.cn/SCUT_Lesson_table // @version 6.1 // @description 华工教务导出课表 - 多格式多选导出 + 课程合并 // @author WanderLandWalker // @match http://xsjw2018.jw.scut.edu.cn/* // @icon https://www.google.com/s2/favicons?sz=64&domain=scut.edu.cn // @grant GM_addStyle // @license GPL-3.0 License // ==/UserScript== (function() { 'use strict'; var PERIOD_TIMES = { '1': { start: '08:50', end: '09:35' }, '2': { start: '09:40', end: '10:25' }, '3': { start: '10:40', end: '11:25' }, '4': { start: '11:30', end: '12:15' }, '5': { start: '14:00', end: '14:45' }, '6': { start: '14:50', end: '15:35' }, '7': { start: '15:55', end: '16:40' }, '8': { start: '16:45', end: '17:30' }, '9': { start: '19:00', end: '19:45' }, '10': { start: '19:50', end: '20:35' }, '11': { start: '20:40', end: '21:25' } }; var FIELDS = [ { key: 'kcmc', label: '课程名称', group: 'basic', defaultOn: true }, { key: 'kch', label: '课程号', group: 'basic', defaultOn: true }, { key: 'xm', label: '教师', group: 'basic', defaultOn: true }, { key: 'zcmc', label: '教师职称', group: 'basic', defaultOn: false }, { key: 'zfjmc', label: '主辅讲', group: 'basic', defaultOn: false }, { key: 'xqj_mc', label: '星期', group: 'basic', defaultOn: true }, { key: 'jcs', label: '节次', group: 'basic', defaultOn: true }, { key: 'zcd', label: '时间/周次', group: 'basic', defaultOn: true }, { key: 'cdmc', label: '上课地点', group: 'basic', defaultOn: true }, { key: 'xf', label: '学分', group: 'basic', defaultOn: true }, { key: 'jxbmc', label: '教学班', group: 'extended', defaultOn: false }, { key: 'jxbzc', label: '教学班组成', group: 'extended', defaultOn: false }, { key: 'xkbz', label: '选课备注', group: 'extended', defaultOn: false }, { key: 'kcxszc', label: '课程学时组成',group: 'extended', defaultOn: false }, { key: 'zhxs', label: '周学时', group: 'extended', defaultOn: false }, { key: 'zxs', label: '总学时', group: 'extended', defaultOn: false }, { key: 'kczxs', label: '课程总学时', group: 'extended', defaultOn: false }, { key: 'khfsmc', label: '考核方式', group: 'extended', defaultOn: false }, { key: 'ksfsmc', label: '考试方式', group: 'extended', defaultOn: false }, { key: 'skfsmc', label: '授课方式名称',group: 'extended', defaultOn: false }, { key: 'cxbjmc', label: '重修标记', group: 'extended', defaultOn: false }, { key: 'kcxz', label: '课程性质', group: 'extended', defaultOn: false }, { key: 'kcbj', label: '课程标记', group: 'extended', defaultOn: false } ]; var courseData = []; var studentInfo = null; var dataReady = false; var panelCreated = false; var libCache = {}; var $btn, $overlay, $panel, $status; /* ================================================================ Utility ================================================================ */ function getFirstMonday(year) { var d = new Date(year, 0, 1); var day = d.getDay(); var offset = (day === 0 ? 1 : (day <= 1 ? 1 - day : 8 - day)); d.setDate(d.getDate() + offset); return d; } function getWeekDate(year, weekNum) { var mon = getFirstMonday(year); mon.setDate(mon.getDate() + (weekNum - 1) * 7); return mon; } function pad2(n) { return n < 10 ? '0' + n : '' + n; } function toIcalDate(year, weekNum, dayOfWeek, timeStr) { var d = getWeekDate(year, weekNum); d.setDate(d.getDate() + (dayOfWeek - 1)); var parts = timeStr.split(':'); return d.getFullYear() + pad2(d.getMonth() + 1) + pad2(d.getDate()) + 'T' + parts[0] + parts[1] + '00'; } function getDayNum(xqj) { var map = { '1':1,'2':2,'3':3,'4':4,'5':5,'6':6,'7':7, '星期一':1,'星期二':2,'星期三':3,'星期四':4, '星期五':5,'星期六':6,'星期日':7 }; return map[xqj] || parseInt(xqj, 10) || 0; } function escapeHtml(s) { if (!s) return ''; return String(s).replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/\n/g, ' '); } function escapeCsv(s) { if (!s) return ''; s = String(s).replace(/\r?\n/g, ' '); if (s.indexOf(',') >= 0 || s.indexOf('"') >= 0) return '"' + s.replace(/"/g, '""') + '"'; return s; } function escapeIcalText(s) { if (!s) return ''; return s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n'); } function getVal(item, key) { if (key === 'xqj_mc') { var dayNames = { '1':'星期一','2':'星期二','3':'星期三','4':'星期四', '5':'星期五','6':'星期六','7':'星期日' }; return dayNames[item.xqj] || item.xqj || ''; } return item[key] || ''; } function download(content, fileName, mimeType) { var blob = new Blob([content], { type: mimeType || 'text/plain;charset=utf-8' }); var url = URL.createObjectURL(blob); var link = document.createElement('a'); link.download = fileName; link.href = url; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } function getSelectedFields() { if (!$panel) return []; var selected = []; var boxes = $panel.querySelectorAll('.__chk_grid input[type=checkbox]'); for (var i = 0; i < boxes.length; i++) { if (boxes[i].checked) { var key = boxes[i].getAttribute('data-field'); for (var j = 0; j < FIELDS.length; j++) { if (FIELDS[j].key === key) { selected.push(FIELDS[j]); break; } } } } return selected; } function getSelectedFormats() { if (!$panel) return []; var fmts = []; var boxes = $panel.querySelectorAll('.__fmt_grid input[type=checkbox]'); for (var i = 0; i < boxes.length; i++) { if (boxes[i].checked) fmts.push(boxes[i].getAttribute('data-fmt')); } return fmts; } function isMergeEnabled() { var cb = document.getElementById('__scut_merge_cb'); return cb ? cb.checked : false; } function getBaseName() { var semName = ''; if (studentInfo) { semName = (studentInfo.XNMC || '').replace(/\s/g, '') + '_' + (studentInfo.XQMMC || '') + '学期'; } return '课表_' + (semName || 'export'); } /* ================================================================ Week Parsing (advanced: supports 双/单/不连续周次) ================================================================ */ function parseWeekAdvanced(str) { if (!str) return []; var weeks = []; var seen = {}; var cleaned = str.replace(/周/g, ''); var parts = cleaned.split(/[,,]/); for (var i = 0; i < parts.length; i++) { var p = parts[i].trim(); if (!p) continue; var m = p.match(/^(\d+)(?:\s*[-~]\s*(\d+))?(?:\s*\((单|双)\))?$/); if (m) { var start = parseInt(m[1], 10); var end = m[2] ? parseInt(m[2], 10) : start; var parity = m[3] || null; for (var w = start; w <= end; w++) { if (parity === '双' && w % 2 !== 0) continue; if (parity === '单' && w % 2 === 0) continue; if (!seen[w]) { weeks.push(w); seen[w] = true; } } } else { var n = parseInt(p, 10); if (!isNaN(n) && !seen[n]) { weeks.push(n); seen[n] = true; } } } weeks.sort(function(a, b) { return a - b; }); return weeks; } function parseSessions(str) { if (!str) return []; var sessions = []; if (str.indexOf('-') >= 0) { var range = str.split('-'); var a = parseInt(range[0], 10); var b = parseInt(range[1], 10); if (!isNaN(a) && !isNaN(b)) for (var j = a; j <= b; j++) sessions.push(j); } else { var n = parseInt(str, 10); if (!isNaN(n)) sessions.push(n); } return sessions; } function formatWeekStr(weeks) { if (!weeks || weeks.length === 0) return ''; var sorted = weeks.slice().sort(function(a, b) { return a - b; }); var ranges = []; var start = sorted[0]; var end = sorted[0]; for (var i = 1; i < sorted.length; i++) { if (sorted[i] === end + 1) { end = sorted[i]; } else { ranges.push(start === end ? start + '周' : start + '-' + end + '周'); start = sorted[i]; end = sorted[i]; } } ranges.push(start === end ? start + '周' : start + '-' + end + '周'); return ranges.join(','); } /* ================================================================ Course Merge Groups by (kch, xqj, jcs, cdmc), merges weeks & teachers ================================================================ */ function mergeCourses(data) { var groups = {}; for (var i = 0; i < data.length; i++) { var item = data[i]; var key = (item.kch || '') + '|' + (item.xqj || '') + '|' + (item.jcs || ''); if (!groups[key]) groups[key] = []; groups[key].push(item); } var merged = []; var keys = Object.keys(groups); for (var k = 0; k < keys.length; k++) { var group = groups[keys[k]]; if (group.length === 1) { merged.push(group[0]); continue; } var allWeeks = []; var seenWeeks = {}; var teacherSet = {}; var teacherOrder = []; var zfjSet = {}; var zfjOrder = []; var zcmcSet = {}; var locationSet = {}; var locationOrder = []; var base = group[0]; for (var g = 0; g < group.length; g++) { var entry = group[g]; var wArr = parseWeekAdvanced(entry.zcd); for (var w = 0; w < wArr.length; w++) { if (!seenWeeks[wArr[w]]) { allWeeks.push(wArr[w]); seenWeeks[wArr[w]] = true; } } var names = (entry.xm || '').split(/[,,]/); for (var t = 0; t < names.length; t++) { var name = names[t].trim(); if (name && !teacherSet[name]) { teacherSet[name] = true; teacherOrder.push(name); } } if (entry.zfjmc) { var zfjNames = entry.zfjmc.split(/[,,]/); for (var z = 0; z < zfjNames.length; z++) { var zn = zfjNames[z].trim(); if (zn && !zfjSet[zn]) { zfjSet[zn] = true; zfjOrder.push(zn); } } } if (entry.zcmc) { var zcmcNames = entry.zcmc.split(/[,,]/); for (var c = 0; c < zcmcNames.length; c++) { var cn = zcmcNames[c].trim(); if (cn) zcmcSet[cn] = true; } } var loc = (entry.cdmc || '').trim(); if (loc && !locationSet[loc]) { locationSet[loc] = true; locationOrder.push(loc); } } allWeeks.sort(function(a, b) { return a - b; }); var mergedEntry = {}; for (var prop in base) { if (base.hasOwnProperty(prop)) mergedEntry[prop] = base[prop]; } mergedEntry.xm = teacherOrder.join(','); mergedEntry.zcd = formatWeekStr(allWeeks); if (zfjOrder.length > 0) mergedEntry.zfjmc = zfjOrder.join(','); var zcmcArr = Object.keys(zcmcSet); if (zcmcArr.length > 0) mergedEntry.zcmc = zcmcArr.join(','); if (locationOrder.length > 0) mergedEntry.cdmc = locationOrder.join('/'); merged.push(mergedEntry); } return merged; } function getExportData() { return isMergeEnabled() ? mergeCourses(courseData) : courseData; } /* ================================================================ Library Loader (CDN) ================================================================ */ function loadScript(url) { if (libCache[url]) return libCache[url]; libCache[url] = new Promise(function(resolve, reject) { var s = document.createElement('script'); s.src = url; s.onload = function() { resolve(); }; s.onerror = function() { reject(new Error('Failed: ' + url)); }; document.head.appendChild(s); }); return libCache[url]; } function loadXlsx() { return loadScript('https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js'); } function loadJsPdf() { return loadScript('https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js'); } function loadH2C() { return loadScript('https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js'); } /* ================================================================ Preview Table (shared by JPG / PDF / DOCX) ================================================================ */ function createPreviewContainer(fields, data) { var semTitle = ''; if (studentInfo) { semTitle = (studentInfo.XNMC || '') + '学年 第' + (studentInfo.XQMMC || '') + '学期 课表'; } var byDay = {}; for (var d = 1; d <= 7; d++) byDay[d] = []; for (var i = 0; i < data.length; i++) { var dn = getDayNum(data[i].xqj); if (dn >= 1 && dn <= 7) byDay[dn].push(data[i]); } for (var d2 = 1; d2 <= 7; d2++) { byDay[d2].sort(function(a, b) { return (parseSessions(a.jcs)[0] || 0) - (parseSessions(b.jcs)[0] || 0); }); } var ths = ''; for (var h = 0; h < fields.length; h++) ths += '' + escapeHtml(fields[h].label) + ''; var rows = ''; for (var day = 1; day <= 7; day++) { var list = byDay[day]; for (var ci = 0; ci < list.length; ci++) { var tds = ''; for (var fi = 0; fi < fields.length; fi++) { tds += '' + escapeHtml(getVal(list[ci], fields[fi].key)) + ''; } rows += '' + tds + ''; } } var html = '
'; html += '

' + escapeHtml(semTitle) + '

'; html += '

导出时间:' + new Date().toLocaleString() + '

'; html += ''; html += '' + ths + ''; html += '' + rows + '
'; var container = document.createElement('div'); container.style.cssText = 'position:absolute;left:-9999px;top:0;'; container.innerHTML = html; document.body.appendChild(container); return container; } /* ================================================================ iCal Builder ================================================================ */ function buildIcal(data) { var lines = [ 'BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//SCUT Lesson Table//CN', 'CALSCALE:GREGORIAN','METHOD:PUBLISH','X-WR-CALNAME:SCUT 课表', 'X-WR-TIMEZONE:Asia/Shanghai', 'BEGIN:VTIMEZONE','TZID:Asia/Shanghai', 'BEGIN:STANDARD','DTSTART:19700101T000000','TZOFFSETFROM:+0800','TZOFFSETTO:+0800', 'END:STANDARD','END:VTIMEZONE' ]; var year = 2025; if (studentInfo && studentInfo.XNMC) { var m = studentInfo.XNMC.match(/(\d{4})/); if (m) year = parseInt(m[1], 10); } for (var i = 0; i < data.length; i++) { var item = data[i]; var dayNum = getDayNum(item.xqj); if (!dayNum) continue; var sessions = parseSessions(item.jcs); if (!sessions.length) continue; var startS = sessions[0]; var endS = sessions[sessions.length - 1]; var tStart = PERIOD_TIMES[String(startS)]; var tEnd = PERIOD_TIMES[String(endS)]; if (!tStart || !tEnd) continue; var weeks = parseWeekAdvanced(item.zcd); if (!weeks.length) continue; var name = item.kcmc || '课程'; var teacher = item.xm || ''; var zfj = item.zfjmc || ''; var location = item.cdmc || ''; var kch = item.kch || ''; var xf = item.xf || ''; var kcxz = item.kcxz || ''; var title = name; if (teacher) title += '(' + teacher + ')'; if (zfj) title += '[辅:' + zfj + ']'; var desc = '课程号: ' + kch; if (teacher) desc += '\\n教师: ' + teacher; if (zfj) desc += '\\n主辅讲: ' + zfj; if (xf) desc += '\\n学分: ' + xf; if (kcxz) desc += '\\n课程性质: ' + kcxz; for (var w = 0; w < weeks.length; w++) { var wn = weeks[w]; var dtS = toIcalDate(year, wn, dayNum, tStart.start); var dtE = toIcalDate(year, wn, dayNum, tEnd.end); var uid = 'scut-' + (kch || i) + '-w' + wn + '-d' + dayNum + '-j' + startS + '@scut.edu.cn'; lines.push('BEGIN:VEVENT'); lines.push('DTSTART;TZID=Asia/Shanghai:' + dtS); lines.push('DTEND;TZID=Asia/Shanghai:' + dtE); lines.push('SUMMARY:' + escapeIcalText(title)); lines.push('LOCATION:' + escapeIcalText(location)); lines.push('DESCRIPTION:' + desc); lines.push('UID:' + uid); lines.push('BEGIN:VALARM','TRIGGER:-PT30M','ACTION:DISPLAY','DESCRIPTION:Reminder','END:VALARM'); lines.push('END:VEVENT'); } } lines.push('END:VCALENDAR'); return lines.join('\r\n'); } /* ================================================================ Markdown Builder ================================================================ */ function buildMarkdown(fields, data) { var byDay = {}; for (var d = 1; d <= 7; d++) byDay[d] = []; for (var i = 0; i < data.length; i++) { var dn = getDayNum(data[i].xqj); if (dn >= 1 && dn <= 7) byDay[dn].push(data[i]); } for (var d2 = 1; d2 <= 7; d2++) { byDay[d2].sort(function(a, b) { return (parseSessions(a.jcs)[0] || 0) - (parseSessions(b.jcs)[0] || 0); }); } var lines = []; var semTitle = ''; if (studentInfo) semTitle = (studentInfo.XNMC || '') + ' 第' + (studentInfo.XQMMC || '') + '学期'; lines.push('# ' + (semTitle || '课表')); lines.push(''); var headers = [], seps = []; for (var f = 0; f < fields.length; f++) { headers.push(fields[f].label); seps.push('---'); } lines.push('| ' + headers.join(' | ') + ' |'); lines.push('| ' + seps.join(' | ') + ' |'); for (var day = 1; day <= 7; day++) { var list = byDay[day]; for (var ci = 0; ci < list.length; ci++) { var cells = []; for (var fi = 0; fi < fields.length; fi++) { cells.push(String(getVal(list[ci], fields[fi].key)).replace(/\|/g, '\\|').replace(/\n/g, ' ')); } lines.push('| ' + cells.join(' | ') + ' |'); } } return lines.join('\n'); } /* ================================================================ Export: JSON ================================================================ */ function exportJson(fields, baseName) { var data = getExportData(); var arr = []; for (var i = 0; i < data.length; i++) { var obj = {}; for (var j = 0; j < fields.length; j++) obj[fields[j].label] = getVal(data[i], fields[j].key); arr.push(obj); } var semName = studentInfo ? (studentInfo.XNMC || '') + ' 第' + (studentInfo.XQMMC || '') + '学期' : ''; var result = { semester: semName, merged: isMergeEnabled(), exportTime: new Date().toLocaleString(), courses: arr }; download(JSON.stringify(result, null, 2), baseName + '.json', 'application/json;charset=utf-8'); } /* ================================================================ Export: CSV ================================================================ */ function exportCsv(fields, baseName) { var data = getExportData(); var bom = '\uFEFF'; var lines = []; var headers = []; for (var h = 0; h < fields.length; h++) headers.push(escapeCsv(fields[h].label)); lines.push(headers.join(',')); for (var i = 0; i < data.length; i++) { var row = []; for (var j = 0; j < fields.length; j++) row.push(escapeCsv(getVal(data[i], fields[j].key))); lines.push(row.join(',')); } download(bom + lines.join('\r\n'), baseName + '.csv', 'text/csv;charset=utf-8'); } /* ================================================================ Export: TXT ================================================================ */ function exportTxt(fields, baseName) { var data = getExportData(); var lines = []; var semTitle = ''; if (studentInfo) semTitle = (studentInfo.XNMC || '') + ' 第' + (studentInfo.XQMMC || '') + '学期'; if (semTitle) { lines.push(semTitle + ' 课表'); lines.push(''); } var headers = []; for (var h = 0; h < fields.length; h++) headers.push(fields[h].label); lines.push(headers.join('\t')); lines.push(''); for (var i = 0; i < data.length; i++) { var row = []; for (var j = 0; j < fields.length; j++) row.push(String(getVal(data[i], fields[j].key)).replace(/\t/g, ' ').replace(/\n/g, ' ')); lines.push(row.join('\t')); } download(lines.join('\r\n'), baseName + '.txt', 'text/plain;charset=utf-8'); } /* ================================================================ Export: XLSX ================================================================ */ function exportXlsx(fields, baseName) { if (typeof XLSX === 'undefined') { alert('XLSX 库加载失败,请检查网络'); return; } var data = getExportData(); var wsData = []; var headers = []; for (var h = 0; h < fields.length; h++) headers.push(fields[h].label); wsData.push(headers); for (var i = 0; i < data.length; i++) { var row = []; for (var j = 0; j < fields.length; j++) row.push(getVal(data[i], fields[j].key)); wsData.push(row); } var ws = XLSX.utils.aoa_to_sheet(wsData); var colWidths = []; for (var c = 0; c < fields.length; c++) { var maxLen = fields[c].label.length; for (var r = 0; r < data.length; r++) { var v = String(getVal(data[r], fields[c].key)); if (v.length > maxLen) maxLen = v.length; } colWidths.push({ wch: Math.min(maxLen + 4, 40) }); } ws['!cols'] = colWidths; var wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, '课表'); XLSX.writeFile(wb, baseName + '.xlsx'); } /* ================================================================ Export: PDF ================================================================ */ function exportPdf(fields, baseName) { if (typeof window.jspdf === 'undefined') { alert('jsPDF 库加载失败,请检查网络'); return; } if (typeof html2canvas === 'undefined') { alert('html2canvas 库加载失败,请检查网络'); return; } var data = getExportData(); var container = createPreviewContainer(fields, data); html2canvas(container.firstChild, { scale: 2, useCORS: true }).then(function(canvas) { var jsPDF = window.jspdf.jsPDF; var imgW = canvas.width; var imgH = canvas.height; var isLandscape = imgW > imgH; var pdf = new jsPDF({ orientation: isLandscape ? 'landscape' : 'portrait', unit: 'mm', format: 'a4' }); var pageW = pdf.internal.pageSize.getWidth(); var pageH = pdf.internal.pageSize.getHeight(); var margin = 10; var usableW = pageW - margin * 2; var ratio = usableW / imgW; var scaledH = imgH * ratio; var imgData = canvas.toDataURL('image/jpeg', 0.95); if (scaledH <= pageH - margin * 2) { pdf.addImage(imgData, 'JPEG', margin, margin, usableW, scaledH); } else { var pageUsableH = pageH - margin * 2; var sliceHpx = Math.floor(pageUsableH / ratio); var yPx = 0; var page = 0; while (yPx < imgH) { if (page > 0) pdf.addPage(); var curSliceH = Math.min(sliceHpx, imgH - yPx); var tmpCanvas = document.createElement('canvas'); tmpCanvas.width = imgW; tmpCanvas.height = curSliceH; var ctx = tmpCanvas.getContext('2d'); ctx.drawImage(canvas, 0, yPx, imgW, curSliceH, 0, 0, imgW, curSliceH); var sliceData = tmpCanvas.toDataURL('image/jpeg', 0.95); var sliceScaledH = curSliceH * ratio; pdf.addImage(sliceData, 'JPEG', margin, margin, usableW, sliceScaledH); yPx += curSliceH; page++; } } pdf.save(baseName + '.pdf'); document.body.removeChild(container); }).catch(function() { alert('PDF 渲染失败'); if (container.parentNode) document.body.removeChild(container); }); } /* ================================================================ Export: DOCX ================================================================ */ function exportDocx(fields, baseName) { var data = getExportData(); var semTitle = ''; if (studentInfo) semTitle = (studentInfo.XNMC || '') + '学年 第' + (studentInfo.XQMMC || '') + '学期 课表'; var byDay = {}; for (var d = 1; d <= 7; d++) byDay[d] = []; for (var i = 0; i < data.length; i++) { var dn = getDayNum(data[i].xqj); if (dn >= 1 && dn <= 7) byDay[dn].push(data[i]); } for (var d2 = 1; d2 <= 7; d2++) { byDay[d2].sort(function(a, b) { return (parseSessions(a.jcs)[0] || 0) - (parseSessions(b.jcs)[0] || 0); }); } var xmlRows = ''; var thCells = ''; for (var h = 0; h < fields.length; h++) { thCells += ''; thCells += ''; thCells += '' + escapeHtml(fields[h].label) + ''; } xmlRows += '' + thCells + ''; var rowIdx = 0; for (var day = 1; day <= 7; day++) { var list = byDay[day]; for (var ci = 0; ci < list.length; ci++) { var fill = rowIdx % 2 === 0 ? 'F8F9FA' : 'FFFFFF'; var cells = ''; for (var fi = 0; fi < fields.length; fi++) { cells += ''; cells += ''; cells += escapeHtml(getVal(list[ci], fields[fi].key)); cells += ''; } xmlRows += '' + cells + ''; rowIdx++; } } var docXml = ''; docXml += ''; docXml += ''; docXml += ''; docXml += '' + escapeHtml(semTitle) + ''; docXml += ''; docXml += ''; docXml += ''; docXml += ''; docXml += ''; docXml += ''; docXml += ''; docXml += ''; docXml += ''; docXml += xmlRows; docXml += ''; var ctXml = ''; ctXml += ''; ctXml += ''; ctXml += ''; ctXml += ''; ctXml += ''; var relsXml = ''; relsXml += ''; relsXml += ''; relsXml += ''; var wordRelsXml = ''; wordRelsXml += ''; var zipBin = buildZip([ { name: '[Content_Types].xml', data: ctXml }, { name: '_rels/.rels', data: relsXml }, { name: 'word/_rels/document.xml.rels', data: wordRelsXml }, { name: 'word/document.xml', data: docXml } ]); download(zipBin, baseName + '.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); } /* ================================================================ Minimal ZIP builder ================================================================ */ function buildZip(files) { var localParts = [], centralParts = []; var offset = 0; for (var i = 0; i < files.length; i++) { var nameBytes = strToBytes(files[i].name); var dataBytes = strToBytes(files[i].data); var crc = crc32(dataBytes); var local = []; local.push(u32(0x04034b50)); local.push(u16(20)); local.push(u16(0)); local.push(u16(0)); local.push(u16(0)); local.push(u16(0)); local.push(u32(crc)); local.push(u32(dataBytes.length)); local.push(u32(dataBytes.length)); local.push(u16(nameBytes.length)); local.push(u16(0)); local.push(nameBytes); local.push(dataBytes); var localBin = concatBytes(local); localParts.push(localBin); var central = []; central.push(u32(0x02014b50)); central.push(u16(20)); central.push(u16(20)); central.push(u16(0)); central.push(u16(0)); central.push(u16(0)); central.push(u16(0)); central.push(u32(crc)); central.push(u32(dataBytes.length)); central.push(u32(dataBytes.length)); central.push(u16(nameBytes.length)); central.push(u16(0)); central.push(u16(0)); central.push(u16(0)); central.push(u16(0)); central.push(u32(0)); central.push(u32(offset)); central.push(nameBytes); centralParts.push(concatBytes(central)); offset += localBin.length; } var centralDir = concatBytes(centralParts); var eocd = []; eocd.push(u32(0x06054b50)); eocd.push(u16(0)); eocd.push(u16(0)); eocd.push(u16(files.length)); eocd.push(u16(files.length)); eocd.push(u32(centralDir.length)); eocd.push(u32(offset)); eocd.push(u16(0)); return concatBytes(localParts.concat([centralDir], eocd)).buffer; } function strToBytes(str) { return new TextEncoder().encode(str); } function u16(v) { var b = new Uint8Array(2); b[0] = v & 0xFF; b[1] = (v >> 8) & 0xFF; return b; } function u32(v) { var b = new Uint8Array(4); b[0] = v & 0xFF; b[1] = (v >> 8) & 0xFF; b[2] = (v >> 16) & 0xFF; b[3] = (v >> 24) & 0xFF; return b; } function concatBytes(arrays) { var total = 0; for (var i = 0; i < arrays.length; i++) total += arrays[i].length; var result = new Uint8Array(total); var offset = 0; for (var j = 0; j < arrays.length; j++) { result.set(arrays[j], offset); offset += arrays[j].length; } return result; } var crcTable = null; function crc32(bytes) { if (!crcTable) { crcTable = new Uint32Array(256); for (var i = 0; i < 256; i++) { var c = i; for (var j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); crcTable[i] = c; } } var crc = 0xFFFFFFFF; for (var k = 0; k < bytes.length; k++) crc = crcTable[(crc ^ bytes[k]) & 0xFF] ^ (crc >>> 8); return (crc ^ 0xFFFFFFFF) >>> 0; } /* ================================================================ Export: JPG ================================================================ */ function exportJpg(fields, baseName) { if (typeof html2canvas === 'undefined') { alert('html2canvas 库加载失败,请检查网络'); return; } var data = getExportData(); var container = createPreviewContainer(fields, data); html2canvas(container.firstChild, { scale: 2, useCORS: true }).then(function(canvas) { canvas.toBlob(function(blob) { var url = URL.createObjectURL(blob); var link = document.createElement('a'); link.download = baseName + '.jpg'; link.href = url; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); document.body.removeChild(container); }, 'image/jpeg', 0.92); }).catch(function() { alert('JPG 渲染失败'); if (container.parentNode) document.body.removeChild(container); }); } /* ================================================================ CSS ================================================================ */ function injectStyles() { var css = ''; css += '#__scut_export_btn{position:fixed;bottom:20px;right:20px;z-index:99999;'; css += 'padding:10px 20px;background:#0078d4;color:#fff;border:none;border-radius:6px;'; css += 'font-size:14px;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.3);transition:background .2s;}'; css += '#__scut_export_btn:hover{background:#005fa3;}'; css += '#__scut_export_btn .__scut_dot{display:none;width:8px;height:8px;'; css += 'background:#4caf50;border-radius:50%;margin-left:6px;vertical-align:middle;}'; css += '#__scut_export_overlay{display:none;position:fixed;top:0;left:0;width:100%;height:100%;'; css += 'background:rgba(0,0,0,.45);z-index:100000;}'; css += '#__scut_export_panel{display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);'; css += 'width:700px;max-height:85vh;background:#fff;border-radius:10px;box-shadow:0 4px 20px rgba(0,0,0,.3);'; css += 'z-index:100001;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;flex-direction:column;}'; css += '.__p_head{display:flex;align-items:center;justify-content:space-between;'; css += 'padding:16px 20px;border-bottom:1px solid #e0e0e0;background:#f8f9fa;border-radius:10px 10px 0 0;}'; css += '.__p_head h3{margin:0;font-size:17px;color:#333;}'; css += '.__p_close{background:none;border:none;font-size:22px;cursor:pointer;color:#999;padding:0 4px;line-height:1;}'; css += '.__p_close:hover{color:#333;}'; css += '.__p_body{padding:16px 20px;overflow-y:auto;flex:1;}'; css += '.__sec{margin-bottom:18px;}'; css += '.__sec_title{font-size:13px;font-weight:600;color:#555;margin-bottom:8px;'; css += 'display:flex;align-items:center;justify-content:space-between;}'; css += '.__preset_btn{background:#e8f0fe;color:#1a73e8;border:none;border-radius:4px;'; css += 'padding:2px 10px;font-size:12px;cursor:pointer;margin-left:6px;}'; css += '.__preset_btn:hover{background:#d2e3fc;}'; css += '.__fmt_grid{display:flex;flex-wrap:wrap;gap:8px 16px;margin-bottom:12px;}'; css += '.__fmt_grid label{display:flex;align-items:center;font-size:13px;cursor:pointer;'; css += 'white-space:nowrap;color:#444;padding:5px 12px;border:1px solid #ddd;border-radius:5px;transition:all .15s;}'; css += '.__fmt_grid label:hover{border-color:#90caf9;background:#f5f5f5;}'; css += '.__fmt_grid input[type=checkbox]{margin-right:5px;}'; css += '.__chk_grid{display:flex;flex-wrap:wrap;gap:6px 14px;}'; css += '.__chk_grid label{display:flex;align-items:center;font-size:13px;cursor:pointer;white-space:nowrap;color:#444;}'; css += '.__chk_grid input[type=checkbox]{margin-right:4px;}'; css += '.__merge_sec{margin-bottom:14px;padding:10px 14px;background:#fff8e1;border:1px solid #ffe082;border-radius:6px;}'; css += '.__merge_sec label{display:flex;align-items:center;font-size:13px;cursor:pointer;color:#333;font-weight:500;}'; css += '.__merge_sec input[type=checkbox]{margin-right:6px;}'; css += '.__merge_desc{font-size:11px;color:#888;margin-top:4px;padding-left:20px;line-height:1.5;}'; css += '.__sys_note{font-size:11px;color:#666;margin-top:6px;padding:6px 10px;background:#f0f4f8;border-radius:4px;line-height:1.5;}'; css += '.__lib_tag{font-size:10px;color:#e65100;background:#fff3e0;padding:1px 5px;border-radius:3px;margin-left:4px;}'; css += '.__lib_warn{font-size:11px;color:#ff9800;margin-top:6px;}'; css += '.__status{font-size:12px;color:#888;margin-top:8px;}'; css += '.__p_foot{display:flex;justify-content:flex-end;gap:10px;'; css += 'padding:12px 20px;border-top:1px solid #e0e0e0;background:#f8f9fa;border-radius:0 0 10px 10px;}'; css += '.__btn{padding:8px 22px;border:none;border-radius:5px;font-size:14px;cursor:pointer;}'; css += '.__btn_cancel{background:#e0e0e0;color:#333;}.__btn_cancel:hover{background:#d0d0d0;}'; css += '.__btn_export{background:#0078d4;color:#fff;}.__btn_export:hover{background:#005fa3;}'; var style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); } /* ================================================================ UI ================================================================ */ function createUI() { if (panelCreated) return; panelCreated = true; $btn = document.createElement('button'); $btn.id = '__scut_export_btn'; $btn.innerHTML = '导出课表'; document.body.appendChild($btn); $overlay = document.createElement('div'); $overlay.id = '__scut_export_overlay'; document.body.appendChild($overlay); $panel = document.createElement('div'); $panel.id = '__scut_export_panel'; var basicHtml = '', extHtml = ''; for (var i = 0; i < FIELDS.length; i++) { var f = FIELDS[i]; var checked = f.defaultOn ? 'checked' : ''; var lbl = ''; if (f.group === 'basic') basicHtml += lbl; else extHtml += lbl; } var panelHtml = ''; panelHtml += '

导出课表

'; panelHtml += '
'; // Merge toggle panelHtml += '
'; panelHtml += ''; panelHtml += '
'; panelHtml += '同一课程号 + 同星期 + 同节次的条目将合并为一条,'; panelHtml += '教师名/上课地点用逗号连接,周次自动合并(支持不连续周次如 "1-4周,9-12周")。'; panelHtml += '
'; // Format selection panelHtml += '
'; panelHtml += '
导出格式
'; panelHtml += '
'; var fmtDefs = [ { key: 'json', label: 'JSON', lib: '' }, { key: 'csv', label: 'CSV', lib: '' }, { key: 'txt', label: 'TXT', lib: '' }, { key: 'md', label: 'Markdown (.md)', lib: '' }, { key: 'ical', label: 'iCal (.ics)', lib: '' }, { key: 'docx', label: 'DOCX (Word)', lib: '' }, { key: 'pdf', label: 'PDF', lib: '需 jsPDF + html2canvas' }, { key: 'xlsx', label: 'XLSX (Excel)', lib: '需 SheetJS' }, { key: 'jpg', label: 'JPG', lib: '需 html2canvas' } ]; for (var fi = 0; fi < fmtDefs.length; fi++) { var def = fmtDefs[fi]; var libNote = def.lib ? ' ' + def.lib + '' : ''; panelHtml += ''; } panelHtml += '
'; panelHtml += ''; panelHtml += '
'; // Field selection panelHtml += '
'; panelHtml += '
基础字段
'; panelHtml += '
' + basicHtml + '
'; panelHtml += '
'; panelHtml += '
扩展字段
'; panelHtml += '
' + extHtml + '
'; panelHtml += '
等待课表数据加载...
'; panelHtml += '
'; panelHtml += '
'; panelHtml += ''; panelHtml += ''; panelHtml += '
'; $panel.innerHTML = panelHtml; document.body.appendChild($panel); $status = document.getElementById('__scut_status'); var closeFn = function() { $overlay.style.display = 'none'; $panel.style.display = 'none'; }; document.getElementById('__scut_pclose').onclick = closeFn; document.getElementById('__scut_pcancel').onclick = closeFn; $overlay.onclick = closeFn; $btn.onclick = function() { $overlay.style.display = 'block'; $panel.style.display = 'flex'; if (dataReady) { var mergeInfo = isMergeEnabled() ? ' (合并后 ' + mergeCourses(courseData).length + ' 条)' : ''; $status.textContent = '已捕获 ' + courseData.length + ' 条课程数据' + mergeInfo; $status.style.color = '#4caf50'; } else { $status.textContent = '等待课表数据加载...请先查询课表'; $status.style.color = '#f44336'; } }; document.getElementById('__scut_fmt_all').onclick = function() { var boxes = $panel.querySelectorAll('.__fmt_grid input[type=checkbox]'); for (var i = 0; i < boxes.length; i++) boxes[i].checked = true; }; document.getElementById('__scut_fmt_none').onclick = function() { var boxes = $panel.querySelectorAll('.__fmt_grid input[type=checkbox]'); for (var i = 0; i < boxes.length; i++) boxes[i].checked = false; }; var mergeCb = document.getElementById('__scut_merge_cb'); mergeCb.onchange = function() { if (dataReady) { var mergeInfo = this.checked ? ' (合并后 ' + mergeCourses(courseData).length + ' 条)' : ''; $status.textContent = '已捕获 ' + courseData.length + ' 条课程数据' + mergeInfo; $status.style.color = '#4caf50'; } }; var presets = $panel.querySelectorAll('.__preset_btn[data-preset]'); for (var p = 0; p < presets.length; p++) { presets[p].onclick = function() { var preset = this.getAttribute('data-preset'); var containerId = preset.indexOf('basic') >= 0 ? '__scut_basic' : '__scut_ext'; var checked = preset.indexOf('_all') >= 0; var boxes = document.getElementById(containerId).querySelectorAll('input[type=checkbox]'); for (var b = 0; b < boxes.length; b++) boxes[b].checked = checked; }; } /* ================================================================ Export Button Handler ================================================================ */ document.getElementById('__scut_pexport').onclick = function() { console.log('[SCUT Export] Button clicked, dataReady=', dataReady, 'courseData.length=', courseData.length); if (!dataReady || courseData.length === 0) { alert('暂无课表数据,请先查询课表!'); return; } var selectedFormats = getSelectedFormats(); console.log('[SCUT Export] Selected formats:', selectedFormats); if (selectedFormats.length === 0) { alert('请至少选择一种导出格式!'); return; } var selectedFields = getSelectedFields(); console.log('[SCUT Export] Fields:', selectedFields.length); if (selectedFields.length === 0) { alert('请至少选择一个导出字段!'); return; } console.log('[SCUT Export] Fields:', selectedFields.length, 'Formats:', selectedFormats.length); var baseName = getBaseName(); var needXlsx = selectedFormats.indexOf('xlsx') >= 0; var needJsPdf = selectedFormats.indexOf('pdf') >= 0; var needH2C = selectedFormats.indexOf('pdf') >= 0 || selectedFormats.indexOf('jpg') >= 0; var warnEl = document.getElementById('__scut_lib_warn'); try { var promises = []; if (needXlsx) promises.push(loadXlsx()); if (needJsPdf) promises.push(loadJsPdf()); if (needH2C) promises.push(loadH2C()); if (promises.length > 0) { warnEl.style.display = 'block'; warnEl.textContent = '正在加载依赖库,请稍候...'; Promise.all(promises).then(function() { warnEl.style.display = 'none'; doExport(selectedFormats, selectedFields, baseName); }).catch(function() { warnEl.textContent = '部分库加载失败,对应格式可能无法使用。'; doExport(selectedFormats, selectedFields, baseName); }); } else { doExport(selectedFormats, selectedFields, baseName); } } catch (e) { console.error('[SCUT Export] Error:', e); alert('导出出错: ' + e.message); } }; } /* ================================================================ doExport ================================================================ */ function doExport(formats, fields, baseName) { var errors = []; for (var i = 0; i < formats.length; i++) { try { switch (formats[i]) { case 'json': exportJson(fields, baseName); break; case 'csv': exportCsv(fields, baseName); break; case 'txt': exportTxt(fields, baseName); break; case 'md': var data = getExportData(); download(buildMarkdown(fields, data), baseName + '.md', 'text/markdown;charset=utf-8'); break; case 'ical': var d = getExportData(); download(buildIcal(d), baseName + '.ics', 'text/calendar;charset=utf-8'); break; case 'xlsx': exportXlsx(fields, baseName); break; case 'docx': exportDocx(fields, baseName); break; case 'pdf': exportPdf(fields, baseName); break; case 'jpg': exportJpg(fields, baseName); break; } } catch (e) { errors.push(formats[i] + ': ' + e.message); } } if (errors.length > 0) alert('部分导出失败:\n' + errors.join('\n')); $overlay.style.display = 'none'; $panel.style.display = 'none'; } function showDataReady() { if (!panelCreated) { setTimeout(showDataReady, 200); return; } var dot = $btn.querySelector('.__scut_dot'); if (dot) dot.style.display = 'inline-block'; if ($status) { $status.textContent = '已捕获 ' + courseData.length + ' 条课程数据'; $status.style.color = '#4caf50'; } } /* ================================================================ AJAX Interceptor ================================================================ */ function attach() { injectStyles(); createUI(); if (typeof jQuery === 'undefined') { console.warn('[SCUT Export] jQuery not found, retrying...'); setTimeout(attach, 1000); return; } var origAjax = jQuery.ajax; jQuery.ajax = function(settings) { var origSuccess = settings.success; settings = settings || {}; var url = settings.url || ''; if (url.indexOf('xskbcx_cxXsgrkb') >= 0) { settings.success = function(data) { if (data && data.kbList) { courseData = data.kbList; studentInfo = data.xsxx || null; dataReady = true; showDataReady(); console.log('[SCUT Export] Captured', courseData.length, 'courses'); } if (origSuccess) origSuccess.apply(this, arguments); }; } return origAjax.call(this, settings); }; jQuery.ajax.apply = Function.prototype.apply; // Auto-click the search button after page load setTimeout(function() { var searchBtn = document.getElementById('search_go'); if (searchBtn) { console.log('[SCUT Export] Auto-clicking search button'); searchBtn.click(); } }, 500); } /* ================================================================ Init ================================================================ */ if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', attach); } else { attach(); } })();