scuec.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617
  1. // ==========================================
  2. // 文件: scuec.js
  3. // 中南民族大学教务系统课程表导入脚本
  4. // 开发规范: 结构化编程 + async/await 流程控制树
  5. // ==========================================
  6. // ========== 第一部分:工具函数 ==========
  7. /**
  8. * 检查是否在正确的教务系统页面
  9. */
  10. function isOnSchedulePage() {
  11. const url = window.location.href;
  12. return /jiaowu|jwgl|course|schedule|curriculum/i.test(url) ||
  13. document.querySelector('table.CourseFormTable') !== null;
  14. }
  15. /**
  16. * 解析周次字符串
  17. */
  18. function parseWeeks(weekStr) {
  19. const weeks = [];
  20. if (!weekStr) return weeks;
  21. const normalized = String(weekStr)
  22. .replace(/周/g, '')
  23. .replace(/\s+/g, '');
  24. const parts = normalized.split(/[,,、;;]/).filter(Boolean);
  25. for (const part of parts) {
  26. const match = part.match(/^(\d+)(?:[-~](\d+))?(?:\((单|双)\))?$/);
  27. if (!match) continue;
  28. const start = Number(match[1]);
  29. const end = match[2] ? Number(match[2]) : start;
  30. const parity = match[3];
  31. for (let i = start; i <= end; i++) {
  32. if (parity === '单' && i % 2 === 0) continue;
  33. if (parity === '双' && i % 2 === 1) continue;
  34. weeks.push(i);
  35. }
  36. }
  37. return Array.from(new Set(weeks)).sort((a, b) => a - b);
  38. }
  39. /**
  40. * 将 HTML 转成纯文本,不依赖被页面覆盖的 document.createElement。
  41. */
  42. function htmlToText(html) {
  43. if (!html) return '';
  44. return String(html)
  45. .replace(/<br\s*\/?>/gi, '\n')
  46. .replace(/<[^>]+>/g, '')
  47. .replace(/&nbsp;/gi, '\u00a0')
  48. .replace(/&amp;/gi, '&')
  49. .replace(/&lt;/gi, '<')
  50. .replace(/&gt;/gi, '>')
  51. .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCharCode(Number('0x' + hex)))
  52. .replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(Number(dec)));
  53. }
  54. /**
  55. * 清理文本:移除HTML标签但保留文本内容
  56. * 特别处理空标签和多余空格
  57. */
  58. function cleanHTML(html) {
  59. if (!html) return '';
  60. return htmlToText(html)
  61. .replace(/\u00a0/g, ' ')
  62. .replace(/\s+/g, ' ')
  63. .trim();
  64. }
  65. /**
  66. * 智能分割文本为行
  67. * 支持 \n, <br>, <hr> 分隔符
  68. */
  69. function smartSplitLines(html, separator = '<br') {
  70. if (!html) return [];
  71. let parts = [];
  72. // 如果指定了分隔符,先用分隔符分割
  73. if (separator === '<hr') {
  74. parts = html.split(/<hr\s*\/?>/i);
  75. } else if (separator === '<br') {
  76. parts = html.split(/<br\s*\/?>/i);
  77. } else {
  78. parts = [html];
  79. }
  80. // 对每个部分清理并分行
  81. let lines = [];
  82. parts.forEach(part => {
  83. let cleaned = cleanHTML(part);
  84. if (cleaned) {
  85. // 再按空行分割
  86. let subLines = cleaned.split(/\n+/).map(l => l.trim()).filter(l => l !== '');
  87. lines.push(...subLines);
  88. }
  89. });
  90. return lines;
  91. }
  92. /**
  93. * 解析单个课程信息(更加健壮)
  94. */
  95. function parseSingleCourse(courseHTML) {
  96. if (!courseHTML || courseHTML.trim() === '') {
  97. return null;
  98. }
  99. try {
  100. const lines = htmlToText(courseHTML)
  101. .replace(/\u00a0/g, ' ')
  102. .split(/\n+/)
  103. .map((line) => line.replace(/\s+/g, ' ').trim())
  104. .filter(Boolean);
  105. if (lines.length === 0) {
  106. return null;
  107. }
  108. const firstLine = lines[0];
  109. const weekMatch = firstLine.match(/\d+(?:\s*[-~]\s*\d+)?周(?:\((?:单|双)\))?/);
  110. const sectionMatch = firstLine.match(/[((]第(\d+)(?:\s*[-~]\s*(\d+))?节[))]/);
  111. const weekToken = weekMatch ? weekMatch[0] : '';
  112. const weeks = parseWeeks(weekToken);
  113. if (weeks.length === 0) {
  114. return null;
  115. }
  116. const weekIndex = weekMatch ? weekMatch.index : firstLine.length;
  117. const sectionIndex = sectionMatch ? sectionMatch.index : firstLine.length;
  118. const nameEnd = Math.min(weekIndex, sectionIndex);
  119. let name = firstLine
  120. .slice(0, nameEnd)
  121. .replace(/\[\d+\]\s*$/, '')
  122. .trim();
  123. if (!name) {
  124. return null;
  125. }
  126. let startSection = sectionMatch ? Number(sectionMatch[1]) : 0;
  127. let endSection = sectionMatch && sectionMatch[2]
  128. ? Number(sectionMatch[2])
  129. : startSection;
  130. const customTimeMatch = firstLine.match(/[((](\d{1,2}:\d{2})\s*[-~]\s*(\d{1,2}:\d{2})[))]/);
  131. const rest = lines.slice(1);
  132. const positionLine = rest.find((line) => /[楼馆场室厅]/.test(line));
  133. const teacherLine = rest.find((line) => line !== positionLine);
  134. const result = {
  135. name: name,
  136. teacher: teacherLine || rest.filter((line) => line !== positionLine).join(' '),
  137. position: positionLine || '待定',
  138. startSection: startSection,
  139. endSection: endSection,
  140. weeks: weeks
  141. };
  142. if (customTimeMatch) {
  143. result.isCustomTime = true;
  144. result.customStartTime = customTimeMatch[1];
  145. result.customEndTime = customTimeMatch[2];
  146. }
  147. return result;
  148. } catch (error) {
  149. console.error('[ERROR] 解析课程出错:', error);
  150. return null;
  151. }
  152. }
  153. /**
  154. * 从单个单元格中提取所有课程(支持 <hr> 分隔的多个课程)
  155. */
  156. function extractCoursesFromCell(cellElement, dayIndex) {
  157. if (!cellElement) return [];
  158. try {
  159. const cellHTML = cellElement.innerHTML || '';
  160. const cellText = cellElement.textContent || '';
  161. if (!cellText || cellText.replace(/\u00a0/g, '').trim() === '') {
  162. return [];
  163. }
  164. // 按 <hr> 分割
  165. const courseParts = cellHTML.split(/<hr\s*\/?>/i);
  166. const courses = [];
  167. console.log(`[DEBUG] 单元格分解为 ${courseParts.length} 个课程块`);
  168. courseParts.forEach((part, idx) => {
  169. const courseInfo = parseSingleCourse(part);
  170. if (courseInfo) {
  171. courseInfo.day = dayIndex + 1;
  172. courses.push(courseInfo);
  173. console.log(`[DEBUG] 块${idx + 1}: ${courseInfo.name}`);
  174. }
  175. });
  176. return courses;
  177. } catch (error) {
  178. console.error('[ERROR] 提取单元格课程失败:', error);
  179. return [];
  180. }
  181. }
  182. /**
  183. * 从表格中提取所有课程
  184. */
  185. function extractCoursesFromTable() {
  186. const courses = [];
  187. const courseMap = new Map();
  188. try {
  189. const table = document.querySelector('table.CourseFormTable');
  190. if (!table) {
  191. console.error('[ERROR] 找不到课程表');
  192. return null;
  193. }
  194. const rows = Array.from(table.rows);
  195. if (rows.length < 2) {
  196. console.error('[ERROR] 表格行数不足');
  197. return null;
  198. }
  199. console.log(`[INFO] 开始解析课程表(共 ${rows.length} 行)`);
  200. const headerRow = rows[0];
  201. const headers = Array.from(headerRow.cells).map(cell => cell.textContent.trim());
  202. const dayColumns = headers.slice(2);
  203. const pendingRowspans = new Array(dayColumns.length).fill(0);
  204. console.log(`[INFO] 日期列: ${dayColumns.join(', ')}`);
  205. // 遍历数据行
  206. for (let rowIndex = 1; rowIndex < rows.length; rowIndex++) {
  207. const row = rows[rowIndex];
  208. const cells = Array.from(row.cells);
  209. if (cells.length === 0) continue;
  210. // 检查"未安排时间课程"部分
  211. const captionCell = cells.find(cell => cell.querySelector('table.NoFitCourse'));
  212. if (captionCell) {
  213. console.log('[INFO] 检测到未安排课程表');
  214. const unscheduledCourses = extractUnscheduledCourses(captionCell);
  215. if (unscheduledCourses) {
  216. courses.push(...unscheduledCourses);
  217. }
  218. break;
  219. }
  220. // 获取行的节次信息
  221. const sectionCell = cells[1];
  222. let dayStartSection = 0;
  223. if (sectionCell) {
  224. const sectionText = sectionCell.textContent.trim();
  225. const sectionMatch = sectionText.match(/第(\d+)节/);
  226. if (sectionMatch) {
  227. dayStartSection = Number(sectionMatch[1]);
  228. }
  229. }
  230. const dayCells = cells.slice(2);
  231. let dayCellPointer = 0;
  232. // 遍历每天的课程,跳过被上方 rowspan 占用的列
  233. for (let dayIndex = 0; dayIndex < dayColumns.length; dayIndex++) {
  234. if (pendingRowspans[dayIndex] > 0) {
  235. pendingRowspans[dayIndex]--;
  236. continue;
  237. }
  238. const courseCell = dayCells[dayCellPointer];
  239. if (!courseCell) continue;
  240. dayCellPointer++;
  241. const cellCourses = extractCoursesFromCell(courseCell, dayIndex);
  242. cellCourses.forEach(courseInfo => {
  243. if (courseInfo.startSection === 0 && courseInfo.endSection === 0) {
  244. courseInfo.startSection = dayStartSection;
  245. courseInfo.endSection = dayStartSection;
  246. }
  247. const courseKey = `${courseInfo.day}-${courseInfo.name}-${courseInfo.teacher}-${courseInfo.position}-${courseInfo.weeks.join(',')}`;
  248. if (courseMap.has(courseKey)) {
  249. const existing = courseMap.get(courseKey);
  250. existing.startSection = Math.min(existing.startSection, courseInfo.startSection);
  251. existing.endSection = Math.max(existing.endSection, courseInfo.endSection);
  252. } else {
  253. courseMap.set(courseKey, courseInfo);
  254. }
  255. });
  256. const rowspan = Math.max(Number(courseCell.getAttribute('rowspan') || '1'), 1);
  257. const colspan = Math.max(Number(courseCell.getAttribute('colspan') || '1'), 1);
  258. if (rowspan > 1) {
  259. for (let offset = 0; offset < colspan && dayIndex + offset < dayColumns.length; offset++) {
  260. pendingRowspans[dayIndex + offset] = Math.max(
  261. pendingRowspans[dayIndex + offset],
  262. rowspan - 1
  263. );
  264. }
  265. }
  266. if (colspan > 1) {
  267. dayIndex += colspan - 1;
  268. }
  269. }
  270. }
  271. const courseList = Array.from(courseMap.values());
  272. courseList.sort((a, b) => {
  273. if (a.day !== b.day) return a.day - b.day;
  274. if (a.startSection !== b.startSection) return a.startSection - b.startSection;
  275. return a.endSection - b.endSection;
  276. });
  277. courses.push(...courseList);
  278. console.log(`[INFO] ✓ 成功提取 ${courses.length} 门课程`);
  279. return courses;
  280. } catch (error) {
  281. console.error('[ERROR] 解析课程表失败:', error);
  282. return null;
  283. }
  284. }
  285. /**
  286. * 提取未安排时间的课程
  287. */
  288. function extractUnscheduledCourses(element) {
  289. try {
  290. const table = element.querySelector('table.NoFitCourse');
  291. if (!table) return null;
  292. const courses = [];
  293. const rows = table.querySelectorAll('tbody tr');
  294. console.log(`[INFO] 未安排课程表有 ${rows.length} 行`);
  295. rows.forEach((row) => {
  296. const cells = row.querySelectorAll('td');
  297. if (cells.length >= 3) {
  298. const courseName = cells[0].textContent.trim();
  299. const weekStr = cells[1].textContent.trim();
  300. const teacher = cells[2].textContent.trim();
  301. const weeks = parseWeeks(weekStr);
  302. if (courseName && weeks.length > 0) {
  303. courses.push({
  304. name: courseName,
  305. teacher: teacher,
  306. position: '待定',
  307. day: 0,
  308. startSection: 0,
  309. endSection: 0,
  310. weeks: weeks
  311. });
  312. console.log(`[INFO] 未安排课程: ${courseName}`);
  313. }
  314. }
  315. });
  316. return courses.length > 0 ? courses : null;
  317. } catch (error) {
  318. console.error('[ERROR] 解析未安排课程失败:', error);
  319. return null;
  320. }
  321. }
  322. /**
  323. * 生成时间段配置
  324. */
  325. function generateTimeSlots() {
  326. const fallback = [
  327. { "number": 1, "startTime": "08:00", "endTime": "08:45" },
  328. { "number": 2, "startTime": "08:55", "endTime": "09:40" },
  329. { "number": 3, "startTime": "10:00", "endTime": "10:45" },
  330. { "number": 4, "startTime": "10:55", "endTime": "11:40" },
  331. { "number": 5, "startTime": "14:10", "endTime": "14:55" },
  332. { "number": 6, "startTime": "15:05", "endTime": "15:50" },
  333. { "number": 7, "startTime": "16:00", "endTime": "16:45" },
  334. { "number": 8, "startTime": "16:55", "endTime": "17:40" },
  335. { "number": 9, "startTime": "18:40", "endTime": "19:25" },
  336. { "number": 10, "startTime": "19:30", "endTime": "20:15" },
  337. { "number": 11, "startTime": "20:20", "endTime": "21:05" }
  338. ];
  339. const table = document.querySelector('table.CourseFormTable');
  340. if (!table) return fallback;
  341. const slots = [];
  342. const rows = Array.from(table.rows);
  343. for (const row of rows) {
  344. const sectionCell = row.cells[1];
  345. if (!sectionCell) continue;
  346. const text = sectionCell.textContent.trim();
  347. const numberMatch = text.match(/第(\d+)节/);
  348. const timeMatch = text.match(/(\d{1,2}:\d{2})\s*~\s*(\d{1,2}:\d{2})/);
  349. if (!numberMatch || !timeMatch) continue;
  350. slots.push({
  351. number: Number(numberMatch[1]),
  352. startTime: timeMatch[1],
  353. endTime: timeMatch[2]
  354. });
  355. }
  356. return slots.length > 0 ? slots.sort((a, b) => a.number - b.number) : fallback;
  357. }
  358. // ========== 第二部分:业务函数 ==========
  359. /**
  360. * 业务函数: 从页面获取课程数据
  361. */
  362. async function fetchCoursesFromPage() {
  363. console.log('\n[步骤1] 开始从页面提取课程数据...');
  364. try {
  365. const courses = extractCoursesFromTable();
  366. if (!courses || courses.length === 0) {
  367. console.error('[ERROR] 未找到课程数据');
  368. return null;
  369. }
  370. console.log(`[步骤1] ✓ 成功提取 ${courses.length} 门课程\n`);
  371. console.log('课程详情:');
  372. courses.forEach((c, i) => {
  373. console.log(` ${i + 1}. ${c.name} | 师:${c.teacher} | 地:${c.position} | 周:${c.weeks.join(',')} | 第${c.startSection}-${c.endSection}节 | 星期${c.day}`);
  374. });
  375. console.log();
  376. return courses;
  377. } catch (error) {
  378. console.error('[步骤1] ✗ 提取课程失败:', error);
  379. throw error;
  380. }
  381. }
  382. /**
  383. * 业务函数: 显示确认弹窗
  384. */
  385. async function showConfirmDialog(courseCount) {
  386. console.log('[步骤2] 显示确认弹窗...');
  387. try {
  388. const confirmed = await window.shiguangBridgePromise.showAlert(
  389. "导入课程表",
  390. `检测到 ${courseCount} 门课程,是否导入?`,
  391. "确认导入"
  392. );
  393. if (confirmed) {
  394. console.log('[步骤2] ✓ 用户确认导入\n');
  395. return true;
  396. } else {
  397. console.log('[步骤2] ✗ 用户取消导入\n');
  398. return false;
  399. }
  400. } catch (error) {
  401. console.error('[步骤2] ✗ 显示弹窗失败:', error);
  402. throw error;
  403. }
  404. }
  405. /**
  406. * 业务函数: 保存课程
  407. */
  408. async function saveCourses(courses) {
  409. console.log('[步骤3] 开始保存课程数据...');
  410. try {
  411. window.shiguangBridge.showToast('正在保存课程...');
  412. const result = await window.shiguangBridgePromise.saveImportedCourses(
  413. JSON.stringify(courses)
  414. );
  415. if (result === true) {
  416. console.log(`[步骤3] ✓ 成功保存 ${courses.length} 门课程\n`);
  417. window.shiguangBridge.showToast(`成功导入 ${courses.length} 门课程!`);
  418. return true;
  419. } else {
  420. console.error('[步骤3] ✗ 课程保存失败');
  421. window.shiguangBridge.showToast('课程保存失败');
  422. throw new Error('课程保存失败');
  423. }
  424. } catch (error) {
  425. console.error('[步骤3] ✗ 保存课程出错:', error);
  426. throw error;
  427. }
  428. }
  429. /**
  430. * 业务函数: 保存时间段配置
  431. */
  432. async function saveTimeSlots() {
  433. console.log('[步骤4] 开始保存时间段配置...');
  434. try {
  435. window.shiguangBridge.showToast('正在保存时间段配置...');
  436. const timeSlots = generateTimeSlots();
  437. const result = await window.shiguangBridgePromise.savePresetTimeSlots(
  438. JSON.stringify(timeSlots)
  439. );
  440. if (result === true) {
  441. console.log('[步骤4] ✓ 时间段配置保存成功\n');
  442. window.shiguangBridge.showToast('时间段配置成功!');
  443. return true;
  444. } else {
  445. console.error('[步骤4] ✗ 时间段配置保存失败');
  446. window.shiguangBridge.showToast('时间段配置失败');
  447. throw new Error('时间段配置失败');
  448. }
  449. } catch (error) {
  450. console.error('[步骤4] ✗ 保存时间段出错:', error);
  451. throw error;
  452. }
  453. }
  454. // ========== 第三部分:流程控制树 ==========
  455. /**
  456. * 主流程: 导入课程表
  457. */
  458. async function runImportFlow() {
  459. console.log('\n╔════════════════════════════════════════╗');
  460. console.log('║ 开始导入中南民族大学课程表 ║');
  461. console.log('╚════════════════════════════════════════╝\n');
  462. try {
  463. const courses = await fetchCoursesFromPage();
  464. if (!courses) {
  465. window.shiguangBridge.showToast('未找到课程数据');
  466. console.log('❌ 流程终止: 无课程数据\n');
  467. return false;
  468. }
  469. const userConfirmed = await showConfirmDialog(courses.length);
  470. if (!userConfirmed) {
  471. console.log('❌ 流程终止: 用户取消导入\n');
  472. return false;
  473. }
  474. const coursesSaved = await saveCourses(courses);
  475. if (!coursesSaved) {
  476. console.log('❌ 流程终止: 课程保存失败\n');
  477. return false;
  478. }
  479. const timeSlotsSaved = await saveTimeSlots();
  480. if (!timeSlotsSaved) {
  481. console.log('❌ 流程终止: 时间段配置失败\n');
  482. return false;
  483. }
  484. console.log('[步骤5] 发送完成信号...');
  485. window.shiguangBridge.notifyTaskCompletion();
  486. window.shiguangBridge.showToast('课程表导入完成!');
  487. console.log('\n╔════════════════════════════════════════╗');
  488. console.log('║ 导入流程完成 ✓ ║');
  489. console.log('╚════════════════════════════════════════╝\n');
  490. return true;
  491. } catch (error) {
  492. console.error('\n❌ 导入流程出错:', error);
  493. console.log('╚════════════════════════════════════════╝\n');
  494. window.shiguangBridge.showToast('导入失败: ' + error.message);
  495. return false;
  496. }
  497. }
  498. // ========== 第四部分:程序入口 ==========
  499. if (isOnSchedulePage() || document.querySelector('table.CourseFormTable')) {
  500. console.log('✓ 检测到中南民族大学教务系统课程表页面');
  501. setTimeout(() => {
  502. runImportFlow();
  503. }, 1000);
  504. } else {
  505. console.log('✗ 当前不在课程表页面');
  506. window.shiguangBridge.showToast('请先在教务系统打开课程表页面!');
  507. }