csuft.js 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  1. // 中南林业科技大学(csuft.edu.cn) 拾光课程表适配脚本
  2. // 强智教务系统(新版 qz- 周历表界面),通过 WebVPN 访问
  3. //
  4. // 2026-09 页面改版同步:
  5. // 1. 教务域名由 jwgl 变更为 jwxt:http-jwgl-... -> http-jwxt-...
  6. // 2. 课表表格由 #kbtable 改为 table.qz-weeklyTable(layui 周历表)
  7. // 课程结构:td[name=kbDataTd] > ul.courselists > li.courselists-item
  8. // 课程名 .qz-hasCourse-title,其余信息在 .qz-hasCourse-abbrinfo
  9. // (形如 "老师:张三;时间:1-8周[1-2节];地点:树人楼(树人楼北502)")
  10. // 3. 请求方式由 POST 改为 GET,新增 时间模式(kbjcmsid) 等参数
  11. // 4. 跨大节的课会用 rowspan 合并单元格,必须还原网格才能定位星期
  12. // 5. 2026-09 按官方《适配脚本开发指南 v2》迁移桥接 API:
  13. // window.shiguangBridgePromise(异步)+ window.shiguangBridge(同步)
  14. // notifyTaskCompletion 仅在整条流程成功后调用
  15. // 6. 2026-09-30 通过 /jsxsd/jxzl/jxzl_query(教学周历)自动获取开学日期,
  16. // 传入 saveCourseConfig 的 semesterStartDate;失败时静默降级
  17. // 7. 2026-11-01 WebVPN 资源由 http-jwxt-...-80 迁到 https-jwxt-...-443,
  18. // 改为多域名探测(同源 → https-443 → 旧 http-80),避免跨域取不到学期列表
  19. // WebVPN 把目标站点编码进域名(协议-主机-端口),学校换过一次(jwgl→jwxt),
  20. // 2026-11 起主用 https-443;旧 http-80 仍在线,但会 302 到新域名。
  21. // 顺序:当前所在教务页(同源、无跨域)→ 新域名 → 旧域名兜底。
  22. var JWXT_ORIGINS = (function () {
  23. var list = [];
  24. try {
  25. if (/jwxt/i.test(location.hostname)) list.push(location.origin);
  26. } catch (e) { }
  27. list.push("https://https-jwxt-csuft-edu-cn-443.webvpn.csuft.edu.cn");
  28. list.push("https://http-jwxt-csuft-edu-cn-80.webvpn.csuft.edu.cn");
  29. var out = [], seen = {};
  30. for (var i = 0; i < list.length; i++) {
  31. if (list[i] && !seen[list[i]]) { seen[list[i]] = 1; out.push(list[i]); }
  32. }
  33. return out;
  34. })();
  35. // 记住本次会话里可用的域名,后续请求直接优先它,避免反复试探失效域名
  36. var JWXT_GOOD_ORIGIN = null;
  37. function orderedOrigins() {
  38. if (!JWXT_GOOD_ORIGIN) return JWXT_ORIGINS;
  39. var out = [JWXT_GOOD_ORIGIN];
  40. for (var i = 0; i < JWXT_ORIGINS.length; i++) {
  41. if (JWXT_ORIGINS[i] !== JWXT_GOOD_ORIGIN) out.push(JWXT_ORIGINS[i]);
  42. }
  43. return out;
  44. }
  45. // 依次尝试各教务域名,返回第一个「响应正常且内容符合预期」的页面文本。
  46. // 返回 {text, origin} 成功;{error} 全部失败。
  47. async function fetchJwxt(pathAndQuery, options, check) {
  48. var lastError = "";
  49. var origins = orderedOrigins();
  50. for (var i = 0; i < origins.length; i++) {
  51. // 单个域名最多等 15 秒,避免某个已失效的域名把整条导入卡住
  52. var ctrl = (typeof AbortController !== "undefined") ? new AbortController() : null;
  53. var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, 15000) : null;
  54. try {
  55. var opts = Object.assign({ credentials: "include" }, options || {});
  56. if (ctrl) opts.signal = ctrl.signal;
  57. var resp = await fetch(origins[i] + "/jsxsd" + pathAndQuery, opts);
  58. if (!resp.ok) { lastError = "HTTP " + resp.status; continue; }
  59. var text = await resp.text();
  60. if (!check || check(text)) {
  61. JWXT_GOOD_ORIGIN = origins[i];
  62. return { text: text, origin: origins[i] };
  63. }
  64. lastError = "返回页面异常";
  65. } catch (e) {
  66. lastError = (e && e.message) ? e.message : String(e);
  67. } finally {
  68. if (timer) clearTimeout(timer);
  69. }
  70. }
  71. return { error: lastError || "无法访问教务系统" };
  72. }
  73. var WEEK_DAY_MAP = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '日': 7, '天': 7 };
  74. var TIME_SLOTS = [
  75. { number: 1, startTime: "08:00", endTime: "08:45" },
  76. { number: 2, startTime: "08:55", endTime: "09:40" },
  77. { number: 3, startTime: "10:00", endTime: "10:45" },
  78. { number: 4, startTime: "10:55", endTime: "11:40" },
  79. { number: 5, startTime: "14:00", endTime: "14:45" },
  80. { number: 6, startTime: "14:55", endTime: "15:40" },
  81. { number: 7, startTime: "16:00", endTime: "16:45" },
  82. { number: 8, startTime: "16:55", endTime: "17:40" },
  83. { number: 9, startTime: "19:00", endTime: "19:45" },
  84. { number: 10, startTime: "19:55", endTime: "20:40" }
  85. ];
  86. function trimText(s) {
  87. return (s || '').replace(/^[\s\u00a0]+|[\s\u00a0]+$/g, '');
  88. }
  89. function uniqueSort(arr) {
  90. var seen = {}, out = [];
  91. for (var i = 0; i < arr.length; i++) {
  92. if (!seen[arr[i]]) { seen[arr[i]] = 1; out.push(arr[i]); }
  93. }
  94. out.sort(function (a, b) { return a - b; });
  95. return out;
  96. }
  97. // 解析周次:支持 "1-8周"、"2,4,6,8周"、"14周"、"1-8周(单)"、"1-8周(双)"
  98. function parseWeeks(timeStr) {
  99. var weeks = [];
  100. if (!timeStr) return weeks;
  101. var s = String(timeStr).split('[')[0];
  102. var odd = null;
  103. if (/[((]\s*单\s*[))]/.test(s)) odd = true;
  104. else if (/[((]\s*双\s*[))]/.test(s)) odd = false;
  105. s = s.replace(/[((][^(())]*[))]/g, '').replace(/周/g, '').replace(/\s/g, '');
  106. var segs = s.split(/[,,]/);
  107. for (var i = 0; i < segs.length; i++) {
  108. var m = segs[i].match(/^(\d+)(?:\s*[-~]\s*(\d+))?$/);
  109. if (!m) continue;
  110. var a = parseInt(m[1], 10);
  111. var b = m[2] ? parseInt(m[2], 10) : a;
  112. if (isNaN(a) || isNaN(b) || b < a) continue;
  113. for (var w = a; w <= b; w++) {
  114. if (odd === true && w % 2 === 0) continue;
  115. if (odd === false && w % 2 === 1) continue;
  116. weeks.push(w);
  117. }
  118. }
  119. return uniqueSort(weeks);
  120. }
  121. // 把带 rowspan/colspan 的表格还原成二维网格
  122. function buildGrid(table) {
  123. var grid = [];
  124. var trs = table.getElementsByTagName('tr');
  125. for (var r = 0; r < trs.length; r++) {
  126. if (!grid[r]) grid[r] = [];
  127. var cells = trs[r].children;
  128. var c = 0;
  129. for (var i = 0; i < cells.length; i++) {
  130. var cell = cells[i];
  131. while (grid[r][c]) c++;
  132. var rs = parseInt(cell.getAttribute('rowspan'), 10);
  133. var cs = parseInt(cell.getAttribute('colspan'), 10);
  134. if (!rs || rs < 1) rs = 1;
  135. if (!cs || cs < 1) cs = 1;
  136. for (var dr = 0; dr < rs; dr++) {
  137. if (!grid[r + dr]) grid[r + dr] = [];
  138. for (var dc = 0; dc < cs; dc++) grid[r + dr][c + dc] = cell;
  139. }
  140. c += cs;
  141. }
  142. }
  143. return grid;
  144. }
  145. // 去掉地点中重复的楼栋名,如 "树人楼(树人楼北508)" -> "树人楼(北508)"
  146. function dedupePosition(pos) {
  147. if (!pos) return pos;
  148. var m = pos.match(/^(.+?)\s*[((]\s*\1\s*(.+?)[))]\s*$/);
  149. return m ? m[1] + '(' + m[2] + ')' : pos;
  150. }
  151. function textOf(el, selector) {
  152. var t = el.querySelector(selector);
  153. return t ? trimText(t.textContent) : '';
  154. }
  155. function parseSchedule(doc) {
  156. var table = doc.querySelector('table.qz-weeklyTable');
  157. if (!table) return [];
  158. var grid = buildGrid(table);
  159. // 1. 表头行:列号 -> 星期
  160. var colDay = {};
  161. var headerRow = -1;
  162. for (var r = 0; r < grid.length; r++) {
  163. var row = grid[r] || [];
  164. var hit = false;
  165. for (var c = 1; c < row.length; c++) {
  166. var txt = trimText(row[c] ? row[c].textContent : '').replace(/\s/g, '');
  167. var m = txt.match(/^星期([一二三四五六日天])/);
  168. if (m) { colDay[c] = WEEK_DAY_MAP[m[1]]; hit = true; }
  169. }
  170. if (hit) { headerRow = r; break; }
  171. }
  172. if (headerRow < 0) return [];
  173. // 2. 定位每个单元格的星期 与 行对应的节次(rowspan 合并的行会被正确填充)
  174. for (var r2 = headerRow + 1; r2 < grid.length; r2++) {
  175. var row2 = grid[r2] || [];
  176. var label = row2[0];
  177. var lm = label ? trimText(label.textContent).match(/第\s*(\d+)\s*[,,、]\s*(\d+)\s*节/) : null;
  178. var sec = lm ? [parseInt(lm[1], 10), parseInt(lm[2], 10)] : null;
  179. for (var c2 = 1; c2 < row2.length; c2++) {
  180. var cell = row2[c2];
  181. if (!cell || !colDay[c2]) continue;
  182. if (cell.__sgDay === undefined) cell.__sgDay = colDay[c2];
  183. if (cell.__sgSec === undefined) cell.__sgSec = sec;
  184. }
  185. }
  186. // 3. 逐单元格解析课程
  187. var raw = [];
  188. var cells = table.querySelectorAll('td[name="kbDataTd"]');
  189. for (var i = 0; i < cells.length; i++) {
  190. var td = cells[i];
  191. var day = td.__sgDay;
  192. if (!day) continue;
  193. var items = td.querySelectorAll('li.courselists-item') || [];
  194. for (var j = 0; j < items.length; j++) {
  195. var li = items[j];
  196. var name = textOf(li, '.qz-hasCourse-title');
  197. if (!name) continue;
  198. var abbr = textOf(li, '.qz-hasCourse-abbrinfo');
  199. var teacher = (abbr.match(/老师[::]\s*([^;;]*)/) || [])[1] || '';
  200. var position = (abbr.match(/地点[::]\s*([^;;]*)/) || [])[1] || '';
  201. if (teacher) teacher = trimText(teacher);
  202. if (position) position = trimText(dedupePosition(position));
  203. var weeks = [], start = 0, end = 0;
  204. var times = abbr.match(/时间[::]\s*([^;;]*)/g) || [];
  205. for (var k = 0; k < times.length; k++) {
  206. var v = trimText(times[k].replace(/^时间[::]/, ''));
  207. weeks = weeks.concat(parseWeeks(v));
  208. var sm = v.match(/[\[[]\s*(\d+)\s*[-~]\s*(\d+)\s*节\s*[\]]]/);
  209. if (sm) {
  210. var s1 = parseInt(sm[1], 10), e1 = parseInt(sm[2], 10);
  211. start = start ? Math.min(start, s1) : s1;
  212. end = Math.max(end, e1);
  213. }
  214. }
  215. weeks = uniqueSort(weeks);
  216. // 时间字段缺失时,退回使用行首的节次标签
  217. if (!start && td.__sgSec) { start = td.__sgSec[0]; end = td.__sgSec[1]; }
  218. if (!start) continue;
  219. raw.push({
  220. name: name,
  221. teacher: teacher,
  222. weeks: weeks,
  223. position: position,
  224. day: day,
  225. startSection: start,
  226. endSection: end || start
  227. });
  228. }
  229. }
  230. return mergeCourses(raw);
  231. }
  232. // 从教学周历页(jxzl_query)解析开学日期与总周数,返回 {startDate, totalWeeks}。
  233. // 新版强智(qz-)校历每行形如 "第1周 | 07 | 08 | 09 | 10 | 11 | 09月12日 | 09月13日":
  234. // 周一~周五只显示日号,周末才有"月日",所以取最早的带月份单元格作锚点,
  235. // 用 (锚点日期 - 距第1周周一的天数) 反推第一周周一;年份由学期号推断。
  236. function parseTermInfo(html, semId) {
  237. var doc = new DOMParser().parseFromString(html, "text/html");
  238. var pad = function (n) { return (n < 10 ? '0' : '') + n; };
  239. var fullRe = /(\d{4})\s*[-/.年]\s*(\d{1,2})\s*[-/.月]\s*(\d{1,2})/;
  240. var mdRe = /(\d{1,2})\s*月\s*(\d{1,2})/;
  241. var anchors = [], maxWeek = 0;
  242. var table = null, tables = doc.getElementsByTagName('table');
  243. for (var i = 0; i < tables.length; i++) {
  244. if (/第\s*1\s*周/.test(tables[i].textContent)) { table = tables[i]; break; }
  245. }
  246. if (!table) return null;
  247. var rows = table.getElementsByTagName('tr');
  248. for (var r = 0; r < rows.length; r++) {
  249. var cells = rows[r].getElementsByTagName('td');
  250. if (cells.length < 2) continue;
  251. var wm = trimText(cells[0].textContent).match(/第\s*(\d+)\s*周/);
  252. if (!wm) continue;
  253. var w = parseInt(wm[1], 10);
  254. if (w > maxWeek) maxWeek = w;
  255. for (var d = 0; d < 7 && d + 1 < cells.length; d++) {
  256. var t = trimText(cells[d + 1].textContent);
  257. var m = t.match(fullRe) || t.match(mdRe);
  258. if (!m) continue;
  259. anchors.push({
  260. idx: (w - 1) * 7 + d,
  261. year: m.length === 4 ? parseInt(m[1], 10) : null,
  262. month: parseInt(m[m.length - 2], 10),
  263. day: parseInt(m[m.length - 1], 10)
  264. });
  265. }
  266. }
  267. if (!anchors.length) return null;
  268. anchors.sort(function (a, b) { return a.idx - b.idx; });
  269. var a0 = anchors[0];
  270. var y0 = parseInt((String(semId).match(/^(\d{4})/) || [])[1], 10);
  271. var year = a0.year ||
  272. (a0.month >= 8 ? (y0 || new Date().getFullYear())
  273. : (y0 ? y0 + 1 : new Date().getFullYear()));
  274. var start = new Date(year, a0.month - 1, a0.day - a0.idx); // Date 自动处理跨月/负数日
  275. if (isNaN(start.getTime())) return null;
  276. return {
  277. startDate: start.getFullYear() + '-' + pad(start.getMonth() + 1) + '-' + pad(start.getDate()),
  278. totalWeeks: maxWeek || null
  279. };
  280. }
  281. // 按 YSSDUFE 标准姿势获取学期配置:POST 带 xnxq01id 请求所选学期的校历,
  282. // 失败则退回 GET 带学期参数(ZHKU 姿势)→ GET 不带参数(仅当前学期)。
  283. async function fetchTermInfo(semesterId) {
  284. var attempts = [
  285. { path: "/jxzl/jxzl_query", options: { method: "POST",
  286. headers: { "Content-Type": "application/x-www-form-urlencoded" },
  287. body: "xnxq01id=" + encodeURIComponent(semesterId || "") } },
  288. { path: "/jxzl/jxzl_query" + (semesterId ? "?xnxq01id=" + encodeURIComponent(semesterId) : ""),
  289. options: { method: "GET" } }
  290. ];
  291. for (var i = 0; i < attempts.length; i++) {
  292. var got = await fetchJwxt(attempts[i].path, attempts[i].options,
  293. function (t) { return /第\s*1\s*周/.test(t); });
  294. if (!got.text) continue;
  295. var info = parseTermInfo(got.text, semesterId);
  296. if (info && info.startDate) return info;
  297. }
  298. return null;
  299. }
  300. // 兜底:首页月历(/jsxsd/framework/mainv_zltable.jsp?ny=YYYY-MM)用 isActiveWeek
  301. // 标出当前教学周,结合今天的日期即可反推第一周周一。
  302. // 只对「当前学期」有效——月历显示的永远是本学期,历史学期不能用。
  303. function parseActiveWeek(html) {
  304. var m = html.match(/isActiveWeek[^>]*>\s*([^<]*)</);
  305. if (!m) return null;
  306. var w = String(m[1]).match(/第\s*([0-9一二三四五六七八九十百]+)\s*周/);
  307. if (!w) return null;
  308. var s = w[1];
  309. if (/^\d+$/.test(s)) return parseInt(s, 10);
  310. var d = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9 };
  311. if (d[s]) return d[s]; // 一 ~ 九
  312. if (s === '十') return 10;
  313. if (/^十[一二三四五六七八九]$/.test(s)) return 10 + d[s[1]];
  314. if (/^[一二三四五六七八九]十$/.test(s)) return d[s[0]] * 10;
  315. if (/^[一二三四五六七八九]十[一二三四五六七八九]$/.test(s)) return d[s[0]] * 10 + d[s[2]];
  316. return null;
  317. }
  318. async function fetchTermInfoFromMonthCalendar(semesterId) {
  319. var now = new Date();
  320. var pad = function (n) { return (n < 10 ? '0' : '') + n; };
  321. var ny = now.getFullYear() + "-" + pad(now.getMonth() + 1);
  322. var got = await fetchJwxt("/framework/mainv_zltable.jsp?ny=" + encodeURIComponent(ny),
  323. { method: "GET" }, function (t) { return /isActiveWeek/.test(t); });
  324. if (!got.text) return null;
  325. var week = parseActiveWeek(got.text);
  326. if (!week) return null;
  327. var dow = (now.getDay() + 6) % 7; // 周一=0 … 周日=6
  328. var start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - ((week - 1) * 7 + dow));
  329. // 总周数另取:教学周历接口返回 [{"qszc":1,"jszc":20}]
  330. var totalWeeks = null;
  331. if (semesterId) {
  332. var wz = await fetchJwxt("/xskb/jxzlzc_xnxq_ajax?&xnxq01id=" + encodeURIComponent(semesterId),
  333. { method: "GET" }, function (t) { return /jszc/.test(t); });
  334. if (wz.text) {
  335. try {
  336. var arr = JSON.parse(wz.text);
  337. if (arr && arr.length && arr[0].jszc) totalWeeks = parseInt(arr[0].jszc, 10) || null;
  338. } catch (e) { }
  339. }
  340. }
  341. return {
  342. startDate: start.getFullYear() + '-' + pad(start.getMonth() + 1) + '-' + pad(start.getDate()),
  343. totalWeeks: totalWeeks
  344. };
  345. }
  346. function mergeCourses(courses) {
  347. if (courses.length <= 1) return courses;
  348. courses.sort(function (a, b) {
  349. return a.name.localeCompare(b.name) || a.day - b.day || a.startSection - b.startSection;
  350. });
  351. var merged = [];
  352. var cur = courses[0];
  353. for (var i = 1; i < courses.length; i++) {
  354. var n = courses[i];
  355. var sameBase = cur.name === n.name && cur.teacher === n.teacher &&
  356. cur.position === n.position && cur.day === n.day;
  357. if (sameBase && cur.endSection + 1 === n.startSection) {
  358. // 同一天连续节次的同一门课,合并成一条
  359. cur.endSection = n.endSection;
  360. cur.weeks = uniqueSort(cur.weeks.concat(n.weeks));
  361. } else if (sameBase && cur.startSection === n.startSection && cur.endSection === n.endSection) {
  362. // 同一节次被拆成多段周次(如 14 周、15 周各一条),合并周次
  363. cur.weeks = uniqueSort(cur.weeks.concat(n.weeks));
  364. } else {
  365. merged.push(cur);
  366. cur = n;
  367. }
  368. }
  369. merged.push(cur);
  370. return merged;
  371. }
  372. function buildQuery(params) {
  373. var parts = [];
  374. for (var k in params) {
  375. if (params.hasOwnProperty(k)) {
  376. parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(params[k]));
  377. }
  378. }
  379. return parts.join('&');
  380. }
  381. function baseParams(semId) {
  382. var p = {
  383. viweType: "0",
  384. showallprint: "0",
  385. showkchprint: "0",
  386. showkink: "0",
  387. showfzmprint: "0",
  388. baseUrl: "/jsxsd",
  389. xsflMapListJsonStr: "讲课学时,实践学时,讲座学时,实验学时,上机学时,课内实践学时,课程实践学时,",
  390. zc: ""
  391. };
  392. if (semId) p.xnxq01id = semId;
  393. return p;
  394. }
  395. // 课表页的判定特征:带学期下拉框或周历表,避免把登录页/错误页当成数据页
  396. function isKbPage(text) {
  397. return /id=["']xnxq01id["']/.test(text) || /qz-weeklyTable/.test(text);
  398. }
  399. async function fetchPage(params) {
  400. var got = await fetchJwxt("/xskb/xskb_list.do?" + buildQuery(params), { method: "GET" }, isKbPage);
  401. if (!got.text) {
  402. throw new Error(got.error === "返回页面异常"
  403. ? "页面里没有课表或学期信息(登录可能已失效)"
  404. : got.error);
  405. }
  406. return new DOMParser().parseFromString(got.text, "text/html");
  407. }
  408. // "时间模式"参数,部分账号不带上就不会渲染课表
  409. function getKbjcmsid(doc) {
  410. var opt = doc.querySelector('select#kbjcmsid option');
  411. return opt ? trimText(opt.getAttribute('value')) : '';
  412. }
  413. // 从页面表单里读取可选的学年学期列表(如 2025-2026-2),无需手工输入
  414. function readTermList(doc) {
  415. var sel = doc.querySelector('select#xnxq01id');
  416. var options = [];
  417. var selected = 0;
  418. if (sel) {
  419. var opts = sel.getElementsByTagName('option');
  420. for (var i = 0; i < opts.length; i++) {
  421. var v = trimText(opts[i].getAttribute('value')) || trimText(opts[i].textContent);
  422. if (!v) continue;
  423. if (opts[i].getAttribute('selected') !== null) selected = options.length;
  424. options.push(v);
  425. }
  426. }
  427. return { options: options, selected: selected, kbjcmsid: getKbjcmsid(doc) };
  428. }
  429. // 学期列表优先取课表页的下拉框;该页面万一不带下拉框,
  430. // 退回教学周历页(同样有 select#xnxq01id,YSSDUFE 标准姿势)
  431. async function loadTermList() {
  432. var lastError = "";
  433. try {
  434. var info = readTermList(await fetchPage(baseParams("")));
  435. if (info.options.length) return info;
  436. lastError = "课表页未找到学期下拉框";
  437. } catch (e) {
  438. lastError = (e && e.message) ? e.message : String(e);
  439. }
  440. var got = await fetchJwxt("/jxzl/jxzl_query", { method: "GET" },
  441. function (t) { return /id=["']xnxq01id["']/.test(t); });
  442. if (got.text) {
  443. var alt = readTermList(new DOMParser().parseFromString(got.text, "text/html"));
  444. if (alt.options.length) return alt;
  445. } else {
  446. lastError += ";周历页:" + got.error;
  447. }
  448. throw new Error(lastError || "未取到学期列表");
  449. }
  450. async function fetchScheduleDoc(semId, kbjcmsid) {
  451. var p = baseParams(semId);
  452. if (kbjcmsid) p.kbjcmsid = kbjcmsid;
  453. return await fetchPage(p);
  454. }
  455. // showPrompt 的输入校验:v2 约定为全局函数名,返回 false=通过,返回字符串=错误提示
  456. function validateYearInput(input) {
  457. return /^[0-9]{4}$/.test(input) ? false : "请输入四位数字的起始学年!";
  458. }
  459. async function promptUserToStart() {
  460. return window.shiguangBridgePromise.showAlert(
  461. "提示", "导入前请确保已成功登录教务系统。", "开始导入");
  462. }
  463. // 优先从教务页面读取学年学期列表直接选择;取不到再退回"手输学年 + 选学期"
  464. async function chooseTerm() {
  465. try {
  466. window.shiguangBridge.showToast("正在获取学年学期列表...");
  467. const info = await loadTermList();
  468. if (info.options.length) {
  469. const idx = await window.shiguangBridgePromise.showSingleSelection(
  470. "选择学年学期", JSON.stringify(info.options), info.selected);
  471. if (idx === null) return null;
  472. return {
  473. semId: info.options[idx],
  474. kbjcmsid: info.kbjcmsid,
  475. // 教务页默认选中的那个就是当前学期,供开学日期兜底判断
  476. currentTerm: info.options[info.selected] || info.options[0]
  477. };
  478. }
  479. } catch (e) {
  480. window.shiguangBridge.showToast("自动识别学期失败:" + e.message);
  481. }
  482. const year = await window.shiguangBridgePromise.showPrompt(
  483. "选择学年", "请输入要导入的起始学年(例如 2025-2026 应输入 2025):", "", "validateYearInput");
  484. if (year === null) return null;
  485. const semesterIdx = await window.shiguangBridgePromise.showSingleSelection(
  486. "选择学期", JSON.stringify(["第一学期", "第二学期"]), -1);
  487. if (semesterIdx === null) return null;
  488. return {
  489. semId: year + "-" + (parseInt(year, 10) + 1) + "-" + (semesterIdx + 1),
  490. kbjcmsid: ""
  491. };
  492. }
  493. async function fetchAndParseCourses(term) {
  494. window.shiguangBridge.showToast("正在获取课表数据...");
  495. let doc;
  496. try {
  497. doc = await fetchScheduleDoc(term.semId, term.kbjcmsid);
  498. } catch (e) {
  499. window.shiguangBridge.showToast("无法访问教务系统(" + e.message + "),请确认已登录后重试。");
  500. return null;
  501. }
  502. const courses = parseSchedule(doc);
  503. if (courses.length === 0) {
  504. // 区分"页面没拿到"和"页面拿到了但该学期确实没课",后者不能覆盖已有课程
  505. window.shiguangBridge.showToast(!doc.querySelector('table.qz-weeklyTable')
  506. ? "未获取到课表页面,请检查登录状态或学年学期是否正确。"
  507. : "该学年学期暂无课表数据,已保留原有课程。");
  508. return null;
  509. }
  510. return courses;
  511. }
  512. async function saveAll(courses, termInfo) {
  513. try {
  514. var config = { semesterTotalWeeks: (termInfo && termInfo.totalWeeks) || 20, firstDayOfWeek: 1 };
  515. if (termInfo && termInfo.startDate) config.semesterStartDate = termInfo.startDate;
  516. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  517. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(TIME_SLOTS));
  518. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  519. return true;
  520. } catch (error) {
  521. window.shiguangBridge.showToast("保存失败: " + error.message);
  522. return false;
  523. }
  524. }
  525. async function runImportFlow() {
  526. const confirmed = await promptUserToStart();
  527. if (!confirmed) {
  528. window.shiguangBridge.showToast("导入已取消。");
  529. return;
  530. }
  531. const term = await chooseTerm();
  532. if (term === null) {
  533. window.shiguangBridge.showToast("导入已取消。");
  534. return;
  535. }
  536. const courses = await fetchAndParseCourses(term);
  537. if (courses === null) return; // 失败原因已在 fetchAndParseCourses 中提示
  538. const termInfo = await fetchTermInfo(term.semId) ||
  539. (term.semId === term.currentTerm ? await fetchTermInfoFromMonthCalendar(term.semId) : null);
  540. if (!(await saveAll(courses, termInfo))) return;
  541. // 整条流程成功后才发送结束信号
  542. window.shiguangBridge.showToast("成功导入 " + courses.length + " 门课程" +
  543. (termInfo && termInfo.startDate ? ",开学日期 " + termInfo.startDate : ""));
  544. window.shiguangBridge.notifyTaskCompletion();
  545. }
  546. runImportFlow();