HUSE.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. /**
  2. * 将周次字符串(如 "1-4,6,8-10")解析为有序的周次数字数组。
  3. * 支持连续区间(如 "1-4")和单个周次(如 "6")的混合格式。
  4. * @param {string} weekStr - 原始周次字符串
  5. * @returns {number[]} 去重并升序排列的周次数组
  6. */
  7. function parseWeeks(weekStr) {
  8. const weeks = [];
  9. const parts = weekStr.split(',');
  10. for (const part of parts) {
  11. if (part.includes('-')) {
  12. const [start, end] = part.split('-');
  13. for (let w = parseInt(start); w <= parseInt(end); w++) {
  14. if (!weeks.includes(w)) weeks.push(w);
  15. }
  16. } else {
  17. const w = parseInt(part);
  18. if (!isNaN(w) && !weeks.includes(w)) weeks.push(w);
  19. }
  20. }
  21. return weeks.sort((a, b) => a - b);
  22. }
  23. /**
  24. * 从教务系统返回的 HTML 文档中解析所有课程信息。
  25. * 遍历课表格 #timetable 中每个单元格,提取课程名、教师、教室及周次节次。
  26. * @param {Document} doc - 已解析的 HTML 文档对象
  27. * @returns {object[]} 课程对象数组,每项包含 day/name/teacher/position/weeks/startSection/endSection
  28. */
  29. function extractCoursesFromDoc(doc) {
  30. const courses = [];
  31. const table = doc.getElementById('timetable');
  32. if (!table) throw new Error("未找到课表格元素(#timetable),请确认教务系统页面已正常加载。");
  33. const rows = table.getElementsByTagName('tr');
  34. // 跳过首行(表头)和末行(通常为空白行)
  35. for (let rowIdx = 1; rowIdx < rows.length - 1; rowIdx++) {
  36. const cells = rows[rowIdx].getElementsByTagName('td');
  37. for (let colIdx = 0; colIdx < cells.length; colIdx++) {
  38. const dayOfWeek = colIdx + 1; // 列索引对应星期几(1=周一)
  39. const cell = cells[colIdx];
  40. const contentDivs = cell.querySelectorAll('div.kbcontent');
  41. if (contentDivs.length === 0) continue;
  42. contentDivs.forEach(div => {
  43. const rawHtml = div.innerHTML;
  44. // 跳过空单元格
  45. if (!rawHtml.trim() || rawHtml === '&nbsp;') return;
  46. // 同一格内多门课以 10 个以上连字符分隔
  47. const blocks = rawHtml.split(/-{10,}\s*<br\s*\/?>/i);
  48. blocks.forEach(block => {
  49. if (!block.trim()) return;
  50. const tmp = document.createElement('div');
  51. tmp.innerHTML = block;
  52. const course = {
  53. day: dayOfWeek,
  54. isCustomTime: false
  55. };
  56. // 课程名称取第一个文本节点,fallback 到 innerText 首行
  57. const firstNode = tmp.childNodes[0];
  58. if (firstNode && firstNode.nodeType === Node.TEXT_NODE) {
  59. course.name = firstNode.nodeValue.trim();
  60. } else {
  61. course.name = tmp.innerText.split('\n')[0].trim();
  62. }
  63. // 教师姓名
  64. const teacherEl = tmp.querySelector('font[title="教师"]');
  65. course.teacher = teacherEl ? teacherEl.innerText.trim() : "未知";
  66. // 上课教室
  67. const roomEl = tmp.querySelector('font[title="教室"]');
  68. course.position = roomEl ? roomEl.innerText.trim() : "待定";
  69. // 周次与节次:格式为 "X-Y(周)[A-B节]" 或仅 "X-Y(周)"
  70. const timeEl = tmp.querySelector('font[title="周次(节次)"]');
  71. if (!timeEl) return;
  72. const timeText = timeEl.innerText.trim();
  73. const fullMatch = timeText.match(/(.+?)\(周\)\[(\d+)-(\d+)节\]/);
  74. if (fullMatch) {
  75. course.weeks = parseWeeks(fullMatch[1]);
  76. course.startSection = parseInt(fullMatch[2]);
  77. course.endSection = parseInt(fullMatch[3]);
  78. } else {
  79. const weekOnlyMatch = timeText.match(/(.+?)\(周\)/);
  80. if (weekOnlyMatch) {
  81. // 节次信息缺失时,根据行索引推算默认节次
  82. course.weeks = parseWeeks(weekOnlyMatch[1]);
  83. course.startSection = rowIdx * 2 - 1;
  84. course.endSection = rowIdx * 2;
  85. } else {
  86. return; // 无法识别的时间格式,跳过
  87. }
  88. }
  89. if (course.name && course.weeks && course.weeks.length > 0) {
  90. courses.push(course);
  91. }
  92. });
  93. });
  94. }
  95. }
  96. return courses;
  97. }
  98. /**
  99. * 根据当前日期返回对应学期的作息时间表。
  100. * 通过配置参数动态推算各节课起止时间,支持早、午、晚三段以及
  101. * 普通课间 / 中课间 / 大课间三级课间时长。
  102. * 5月~9月使用夏季作息,其余月份使用春秋冬季作息。
  103. * @returns {object[]} 节次时间数组,每项包含 number/startTime/endTime
  104. */
  105. function getPresetTimeSlots() {
  106. const month = new Date().getMonth() + 1;
  107. const isSummer = month >= 5 && month <= 9;
  108. // ── 作息参数配置 ──────────────────────────────────────────────
  109. const params = isSummer ? {
  110. // 夏季作息参数
  111. morningStartTime: "08:10", // 早上第 1 节开始时间
  112. afternoonStartTime: "14:45", // 中午第 1 节开始时间(第 5 节)
  113. eveningStartTime: "19:30", // 晚上第 1 节开始时间(第 9 节)
  114. classDuration: 45, // 单节课时长(分钟)
  115. normalBreakDuration: 10, // 普通课间时长(分钟)
  116. mediumBreakDuration: 15, // 中课间时长(分钟)
  117. longBreakDuration: 20, // 大课间时长(分钟)
  118. longBreakAfterSlot: 2, // 大课间插入位置(第 N 节课后)
  119. mediumBreakAfterSlot: 6, // 中课间插入位置(第 N 节课后)
  120. } : {
  121. // 春秋冬季作息参数
  122. morningStartTime: "08:20",
  123. afternoonStartTime: "14:30",
  124. eveningStartTime: "19:10",
  125. classDuration: 45,
  126. normalBreakDuration: 10,
  127. mediumBreakDuration: 15,
  128. longBreakDuration: 20,
  129. longBreakAfterSlot: 2,
  130. mediumBreakAfterSlot: 6,
  131. };
  132. // 早上 4 节 / 下午 4 节 / 晚上 3 节
  133. const SESSION_SLOTS = [4, 4, 3];
  134. const SESSION_STARTS = [
  135. params.morningStartTime,
  136. params.afternoonStartTime,
  137. params.eveningStartTime,
  138. ];
  139. const toMinutes = (t) => { const [h, m] = t.split(':').map(Number); return h * 60 + m; };
  140. const toTimeStr = (min) => `${String(Math.floor(min / 60)).padStart(2, '0')}:${String(min % 60).padStart(2, '0')}`;
  141. const { classDuration, normalBreakDuration, mediumBreakDuration, longBreakDuration,
  142. longBreakAfterSlot, mediumBreakAfterSlot } = params;
  143. const result = [];
  144. let slotNumber = 1;
  145. for (let s = 0; s < SESSION_SLOTS.length; s++) {
  146. let cursor = toMinutes(SESSION_STARTS[s]);
  147. const count = SESSION_SLOTS[s];
  148. for (let i = 0; i < count; i++) {
  149. result.push({
  150. number: slotNumber,
  151. startTime: toTimeStr(cursor),
  152. endTime: toTimeStr(cursor + classDuration),
  153. });
  154. cursor += classDuration;
  155. // 非本段末节时,按位置选择课间时长
  156. if (i < count - 1) {
  157. if (slotNumber === longBreakAfterSlot) cursor += longBreakDuration;
  158. else if (slotNumber === mediumBreakAfterSlot) cursor += mediumBreakDuration;
  159. else cursor += normalBreakDuration;
  160. }
  161. slotNumber++;
  162. }
  163. }
  164. return result;
  165. }
  166. /**
  167. * 从教学周历页面推断学期开学日期(YYYY-MM-DD)。
  168. * 解析策略:
  169. * 1) 优先读取第 1 周对应的周一日期;
  170. * 2) 若未命中,则在周历表中收集全部日期并取最小值兜底。
  171. * @returns {Promise<{ semesterStartDate: string|null, totalWeeks: number|null }>} 开学日期和总周数;无法解析时返回 null
  172. */
  173. async function getStartDateandTotalWeeks() {
  174. const response = await fetch('/jsxsd/jxzl/jxzl_query', { method: 'GET' });
  175. if (!response.ok) {
  176. throw new Error(`周历请求失败:${response.status}`);
  177. }
  178. const htmlText = await response.text();
  179. const doc = new DOMParser().parseFromString(htmlText, 'text/html');
  180. const table = doc.querySelector('#kbtable');
  181. if (!table) {
  182. console.log('未找到周历表格 #kbtable');
  183. return { semesterStartDate: null, totalWeeks: null };
  184. }
  185. const parseCNDate = (text) => {
  186. const m = String(text || '').match(/(\d{4})年(\d{1,2})月(\d{1,2})/);
  187. if (!m) return null;
  188. const [, y, mo, d] = m;
  189. return `${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`;
  190. };
  191. let week1Monday = null;
  192. let minDate = null;
  193. let totalWeeks = null;
  194. const rows = table.querySelectorAll('tr');
  195. if(!rows || rows.length === 0) {
  196. console.log('周历表格 #kbtable 中未找到任何行');
  197. return { semesterStartDate: null, totalWeeks: null };
  198. }
  199. rows.forEach((row) => {
  200. const firstCellText = row.cells?.[0]?.textContent?.trim() || '';
  201. const week = /^\d+$/.test(firstCellText) ? Number(firstCellText) : NaN;
  202. if (!Number.isNaN(week)) {
  203. totalWeeks = Math.max(totalWeeks, week);
  204. if (week === 1 && !week1Monday) {
  205. const candidate = parseCNDate(row.cells?.[1]?.getAttribute('title'));
  206. if (candidate) week1Monday = candidate;
  207. }
  208. }
  209. row.querySelectorAll('td[title]').forEach((td) => {
  210. const candidate = parseCNDate(td.getAttribute('title'));
  211. if (!candidate) return;
  212. if (!minDate || candidate < minDate) {
  213. minDate = candidate;
  214. }
  215. });
  216. });
  217. const semesterStartDate = week1Monday || minDate;
  218. console.log('开学日期:', semesterStartDate || '未找到');
  219. console.log('总周数:', totalWeeks);
  220. return { semesterStartDate, totalWeeks };
  221. }
  222. /**
  223. * 返回全局课表基础配置(单节课时长与课间休息时长)。
  224. * @returns {Promise<{ semesterStartDate: string|null, semesterTotalWeeks: number, defaultClassDuration: number, defaultBreakDuration: number }>}
  225. */
  226. async function getCourseConfig() {
  227. const { semesterStartDate, totalWeeks } = await getStartDateandTotalWeeks();
  228. return {
  229. semesterStartDate: semesterStartDate,
  230. semesterTotalWeeks: totalWeeks,
  231. defaultClassDuration: 45,
  232. defaultBreakDuration: 10
  233. };
  234. }
  235. /**
  236. * 课表导入主流程。
  237. * 依次完成:发起请求 → 解析 HTML → 提取课程 → 保存配置/作息/课程 → 通知完成。
  238. * 在浏览器调试环境中仅打印结果,不调用 AndroidBridge。
  239. */
  240. async function runImportFlow() {
  241. const isApp = typeof window.AndroidBridgePromise !== 'undefined';
  242. const hasToast = typeof window.AndroidBridge !== 'undefined';
  243. try {
  244. if (hasToast) {
  245. AndroidBridge.showToast("正在拉取课表,请稍候...");
  246. } else {
  247. console.log("[HUSE] 开始请求课表页面...");
  248. }
  249. const response = await fetch('/jsxsd/xskb/xskb_list.do', { method: 'GET' });
  250. const htmlText = await response.text();
  251. const doc = new DOMParser().parseFromString(htmlText, 'text/html');
  252. // 读取学期列表(当前仅用于记录,实际取最新学期)
  253. const selectEl = doc.getElementById('xnxq01id');
  254. const semesters = [];
  255. const semesterValues = [];
  256. let defaultIndex = 0;
  257. if (selectEl) {
  258. selectEl.querySelectorAll('option').forEach((opt, idx) => {
  259. semesters.push(opt.innerText.trim());
  260. semesterValues.push(opt.value);
  261. if (opt.hasAttribute('selected')) defaultIndex = idx;
  262. });
  263. }
  264. // 始终选取列表首的最新学期
  265. defaultIndex = 0;
  266. console.log(`[HUSE] 共找到 ${semesters.length} 个学期,当前使用:${semesters[defaultIndex] || '未知'}`);
  267. const courses = extractCoursesFromDoc(doc);
  268. if (courses.length === 0) {
  269. const msg = "未解析到任何课程,当前学期可能暂无排课。";
  270. console.warn("[HUSE] " + msg);
  271. if (isApp) {
  272. await window.AndroidBridgePromise.showAlert("提示", msg, "好的");
  273. } else {
  274. alert(msg);
  275. }
  276. return;
  277. }
  278. console.log(`[HUSE] 成功解析 ${courses.length} 门课程。`);
  279. const config = await getCourseConfig();
  280. const timeSlots = getPresetTimeSlots();
  281. // 浏览器调试环境:输出结果后退出,不执行 APP 存储逻辑
  282. if (!isApp) {
  283. console.log("[HUSE] 课表基础配置:", config);
  284. console.log("[HUSE] 作息时间表:", timeSlots);
  285. console.log("[HUSE] 课程列表:", courses);
  286. alert(`解析完成!共获取 ${courses.length} 门课程及作息时间,详情见控制台(F12)。`);
  287. return;
  288. }
  289. // APP 环境:保存课表配置与作息时间
  290. const configSaved = await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify(config));
  291. const slotsSaved = await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  292. if (!configSaved || !slotsSaved) {
  293. // 时间配置保存失败不强制中断,继续尝试导入课程
  294. console.warn("[HUSE] 课表时间配置保存失败,将继续尝试导入课程。");
  295. AndroidBridge.showToast("时间配置保存失败,继续导入课程...");
  296. }
  297. // APP 环境:保存课程数据
  298. const courseSaved = await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(courses));
  299. if (!courseSaved) {
  300. console.error("[HUSE] 课程数据保存失败。");
  301. AndroidBridge.showToast("课程保存失败,请重试!");
  302. return;
  303. }
  304. console.log(`[HUSE] 导入完成,共写入 ${courses.length} 门课程。`);
  305. AndroidBridge.showToast(`成功导入 ${courses.length} 门课程及作息时间!`);
  306. AndroidBridge.notifyTaskCompletion();
  307. } catch (err) {
  308. console.error("[HUSE] 导入流程发生异常:", err);
  309. if (hasToast) {
  310. AndroidBridge.showToast("导入失败:" + err.message);
  311. } else {
  312. alert("导入失败:" + err.message);
  313. }
  314. }
  315. }
  316. runImportFlow();