nwafu.js 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778
  1. // 文件: nwafu.js
  2. // ---------------------------------------------------------------------------
  3. // 学校:西北农林科技大学(NWAFU)
  4. // 系统:ehall 新版教务(金智 jwapp)→「我的本研课表」
  5. // 入口:https://newehall.nwafu.edu.cn/(登录后进入「我的本研课表」再执行导入)
  6. //
  7. // 规范:拾光课程表《适配脚本开发指南 v2》
  8. // · 数据:courses / timeSlots / config / comboSchedule
  9. // · 桥接:window.shiguangBridgePromise(异步)/ window.shiguangBridge(同步)
  10. // · 流程:runImportFlow() 编排,成功后才 notifyTaskCompletion()
  11. // 作息:按学校作息时间表硬编码(第 1-4 节冬夏相同,第 5 节起不同)
  12. // 夏令:当年 05.01 — 当年 10.01;冬令:当年 10.01 — 次年 05.01
  13. // ---------------------------------------------------------------------------
  14. // ==================== 常量 ====================
  15. const APP_CODE = 'wdkbby';
  16. const URL_TERM = '/modules/jshkcb/dqxnxq.do';
  17. const URL_TERM_LIST = '/modules/jshkcb/xnxqcx.do';
  18. const URL_COURSE = '/modules/xskcb/cxxszhxqkb.do';
  19. const URL_CALENDAR = '/modules/xskcb/cxxljc.do';
  20. const URL_SECTIONS = '/modules/jshkcb/cxjcs.do';
  21. const ACTION_TERM = 'dqxnxq';
  22. const ACTION_TERM_LIST = 'xnxqcx';
  23. const ACTION_COURSE = 'cxxszhxqkb';
  24. const ACTION_CALENDAR = 'cxxljc';
  25. const ACTION_SECTIONS = 'cxjcs';
  26. const PORTAL_HINT = 'newehall.nwafu.edu.cn 新门户 →「我的本研课表」';
  27. const DEFAULT_TOTAL_WEEKS = 20;
  28. const MAX_TOTAL_WEEKS = 30;
  29. // ==================== 作息(学校作息时间表) ====================
  30. const SLOTS_WINTER = [
  31. { number: 1, startTime: '08:00', endTime: '08:45' },
  32. { number: 2, startTime: '08:55', endTime: '09:40' },
  33. { number: 3, startTime: '10:10', endTime: '10:55' },
  34. { number: 4, startTime: '11:05', endTime: '11:50' },
  35. { number: 5, startTime: '14:00', endTime: '14:45' },
  36. { number: 6, startTime: '14:55', endTime: '15:40' },
  37. { number: 7, startTime: '16:00', endTime: '16:45' },
  38. { number: 8, startTime: '16:55', endTime: '17:40' },
  39. { number: 9, startTime: '19:00', endTime: '19:45' },
  40. { number: 10, startTime: '19:50', endTime: '20:35' },
  41. { number: 11, startTime: '20:40', endTime: '21:25' }
  42. ];
  43. const SLOTS_SUMMER = [
  44. { number: 1, startTime: '08:00', endTime: '08:45' },
  45. { number: 2, startTime: '08:55', endTime: '09:40' },
  46. { number: 3, startTime: '10:10', endTime: '10:55' },
  47. { number: 4, startTime: '11:05', endTime: '11:50' },
  48. { number: 5, startTime: '14:30', endTime: '15:15' },
  49. { number: 6, startTime: '15:25', endTime: '16:10' },
  50. { number: 7, startTime: '16:30', endTime: '17:15' },
  51. { number: 8, startTime: '17:25', endTime: '18:10' },
  52. { number: 9, startTime: '19:30', endTime: '20:15' },
  53. { number: 10, startTime: '20:20', endTime: '21:05' },
  54. { number: 11, startTime: '21:10', endTime: '21:55' }
  55. ];
  56. // ==================== 工具 ====================
  57. function toast(message) {
  58. try {
  59. window.shiguangBridge.showToast(String(message));
  60. } catch (e) {
  61. console.warn('[NWAFU] showToast 不可用:', e && e.message);
  62. }
  63. }
  64. function log(message) {
  65. try {
  66. console.log('[NWAFU 课表适配] ' + message);
  67. } catch (e) { /* ignore */ }
  68. }
  69. function textOf(value) {
  70. if (value === null || value === undefined) return '';
  71. const result = String(value).replace(/\u00a0/g, ' ').trim();
  72. if (result === 'null' || result === 'undefined') return '';
  73. return result;
  74. }
  75. function uniqueSorted(numbers) {
  76. return Array.from(new Set(numbers.filter((n) => typeof n === 'number' && !isNaN(n) && n > 0)))
  77. .sort((a, b) => a - b);
  78. }
  79. /** 教师字段去重 **/
  80. function cleanTeacher(value) {
  81. const raw = textOf(value);
  82. if (!raw) return '';
  83. const names = raw.split(/[,,、;;]/).map((item) => textOf(item)).filter((item) => item !== '');
  84. return Array.from(new Set(names)).join(',') || raw;
  85. }
  86. // ==================== 教务接口 ====================
  87. function rootPath() {
  88. const config = window._JW_INIT_CONFIG || {};
  89. let root = config.path || config.ROOT_PATH || config.contextPath || '/jwapp';
  90. root = String(root);
  91. if (root.charAt(0) !== '/') root = '/' + root;
  92. return root.replace(/\/+$/, '');
  93. }
  94. function encodeForm(data) {
  95. const pairs = [];
  96. Object.keys(data || {}).forEach((key) => {
  97. const value = data[key];
  98. if (value === null || value === undefined) return;
  99. pairs.push(encodeURIComponent(key) + '=' + encodeURIComponent(String(value)));
  100. });
  101. return pairs.join('&');
  102. }
  103. /** 与教务页面自身(BH_UTILS.doSyncAjax)等价的表单 POST:同源、带 Cookie */
  104. async function api(modulePath, data) {
  105. const response = await fetch(rootPath() + '/sys/' + APP_CODE + modulePath, {
  106. method: 'POST',
  107. credentials: 'include',
  108. headers: {
  109. 'accept': 'application/json, text/javascript, */*; q=0.01',
  110. 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
  111. 'x-requested-with': 'XMLHttpRequest'
  112. },
  113. body: encodeForm(data || {})
  114. });
  115. if (!response || !response.ok) throw new Error('HTTP ' + (response ? response.status : '?'));
  116. const raw = await response.text();
  117. let json;
  118. try {
  119. json = JSON.parse(raw);
  120. } catch (e) {
  121. throw new Error('返回内容不是 JSON(很可能登录已失效)');
  122. }
  123. if (json && json.loginURL) throw new Error('登录状态已失效,请重新登录');
  124. return json;
  125. }
  126. /** 取出 {datas:{<action>:{rows:[...]}}} 中的 rows */
  127. function rowsOf(response, action) {
  128. if (!response || !response.datas) return null;
  129. const node = response.datas[action];
  130. if (!node || !node.rows || typeof node.rows.length !== 'number') return null;
  131. if (node.rows.length === 0 && node.extParams && String(node.extParams.code) !== '1') return null;
  132. return node.rows;
  133. }
  134. function messageOf(response) {
  135. if (!response || !response.datas) return '';
  136. const keys = Object.keys(response.datas);
  137. for (let i = 0; i < keys.length; i++) {
  138. const node = response.datas[keys[i]];
  139. if (node && node.extParams && node.extParams.msg) return String(node.extParams.msg);
  140. }
  141. return '';
  142. }
  143. // ==================== 学年学期 ====================
  144. /** 当前学年学期(dqxnxq.do),用于学期列表的默认选中项 */
  145. async function fetchTerm() {
  146. const response = await api(URL_TERM, {});
  147. const rows = rowsOf(response, ACTION_TERM);
  148. if (!rows || !rows.length) {
  149. throw new Error('未能获取当前学年学期' + (messageOf(response) ? ':' + messageOf(response) : ''));
  150. }
  151. const dm = textOf(rows[0].DM);
  152. if (!dm) throw new Error('学年学期接口未返回学期代码');
  153. return { dm: dm, mc: textOf(rows[0].MC) };
  154. }
  155. /** 学期列表(xnxqcx.do,按学期代码降序 = 最新在前) */
  156. async function fetchTermList() {
  157. const response = await api(URL_TERM_LIST, { '*order': '-DM' });
  158. const rows = rowsOf(response, ACTION_TERM_LIST);
  159. if (!rows || !rows.length) return [];
  160. return sortTermsDesc(rows);
  161. }
  162. /** 规整学期行并降序排列 */
  163. function sortTermsDesc(rows) {
  164. return (rows || [])
  165. .map((row) => ({ dm: textOf(row && row.DM), mc: textOf(row && row.MC) }))
  166. .filter((term) => term.dm)
  167. .sort((a, b) => b.dm.localeCompare(a.dm));
  168. }
  169. /** 学期显示文本 */
  170. function termText(term) {
  171. const mc = textOf(term && term.mc);
  172. return mc || textOf(term && term.dm);
  173. }
  174. /** 默认选中当前学期;列表中找不到则选第一条 */
  175. function defaultTermIndex(list, currentDm) {
  176. if (!Array.isArray(list) || !list.length) return -1;
  177. const key = textOf(currentDm);
  178. if (!key) return 0;
  179. const index = list.findIndex((term) => term.dm === key);
  180. return index >= 0 ? index : 0;
  181. }
  182. /**
  183. * 让用户选择要导入的学期(官方 API:showSingleSelection)。
  184. * 返回 { dm, mc };用户取消返回 null。
  185. */
  186. async function selectTerm() {
  187. let list = [];
  188. try {
  189. list = await fetchTermList();
  190. } catch (e) {
  191. log('学期列表接口失败,退回当前学期: ' + e.message);
  192. }
  193. let currentDm = '';
  194. try {
  195. const current = await fetchTerm();
  196. currentDm = current.dm;
  197. if (!list.length) list = [current];
  198. } catch (e) {
  199. log('当前学期接口失败: ' + e.message);
  200. }
  201. if (!list.length) throw new Error('未能获取学期列表,请先登录教务并打开「我的本研课表」');
  202. if (list.length === 1) return list[0];
  203. const labels = list.map((term) => termText(term));
  204. const selected = await window.shiguangBridgePromise.showSingleSelection(
  205. '选择学期',
  206. JSON.stringify(labels),
  207. defaultTermIndex(list, currentDm)
  208. );
  209. if (selected === null || selected === undefined) return null;
  210. const index = Number(selected);
  211. if (!(index >= 0 && index < list.length)) return null;
  212. return list[index];
  213. }
  214. /** "2026-2027-1" → { xn: "2026-2027", xq: "1" } */
  215. function termCodes(dm) {
  216. const parts = textOf(dm).split('-');
  217. if (parts.length < 3) return null;
  218. const xn = parts[0] + '-' + parts[1];
  219. const xq = parts[2];
  220. if (!/^\d{4}-\d{4}$/.test(xn) || !/^\d+$/.test(xq)) return null;
  221. return { xn: xn, xq: xq };
  222. }
  223. /**
  224. * 校历:所选学期的开学日期 + 校历总周数
  225. * POST {ROOT}/sys/wdkbby/modules/xskcb/cxxljc.do { XN, XQ }
  226. * rows[0].XQKSRQ → 开学日期;rows[0].ZZC → 校历总周数
  227. * 取不到返回 null(不影响课程导入)。
  228. */
  229. async function fetchTermCalendar(dm) {
  230. const codes = termCodes(dm);
  231. if (!codes) return null;
  232. const response = await api(URL_CALENDAR, { XN: codes.xn, XQ: codes.xq });
  233. const rows = rowsOf(response, ACTION_CALENDAR);
  234. if (!rows || !rows.length) return null;
  235. const rawDate = textOf(rows[0].XQKSRQ);
  236. const startDate = /^\d{4}-\d{2}-\d{2}/.test(rawDate) ? rawDate.substring(0, 10) : '';
  237. const totalWeeks = parseInt(textOf(rows[0].ZZC), 10);
  238. if (!startDate && !(totalWeeks > 0)) return null;
  239. return {
  240. startDate: startDate,
  241. totalWeeks: totalWeeks > 0 ? Math.min(totalWeeks, MAX_TOTAL_WEEKS) : 0
  242. };
  243. }
  244. // ==================== 周次解析 ====================
  245. /** 周次位串,例如 "0000111111111111110000"(第 5-18 周)→ [5..18] */
  246. function parseWeekBitString(value) {
  247. const raw = textOf(value);
  248. if (!raw) return null;
  249. const compact = raw.replace(/\s+/g, '');
  250. if (!/^[01]+$/.test(compact)) return null;
  251. if (compact.length < 1 || compact.length > 60) return null;
  252. const weeks = [];
  253. for (let i = 0; i < compact.length; i++) {
  254. if (compact.charAt(i) === '1') weeks.push(i + 1);
  255. }
  256. return weeks;
  257. }
  258. // 周次表达式:6-18周 / 4周 / 7-17周(单) / 4-6周(双),8-13周 / 5-16周,18周
  259. const WEEK_GROUP = '\\d+\\s*(?:[-~至]\\s*\\d+)?\\s*(?:[((]\\s*[单双]\\s*[))])?\\s*周\\s*(?:[((]\\s*[单双]\\s*[))])?';
  260. const WEEK_EXPRESSION = '(?:' + WEEK_GROUP + '\\s*[,,、]?\\s*)+';
  261. /** 解析一段周次内容(可含逗号分隔的多组、单双周标记) */
  262. function parseWeekSegments(expression) {
  263. const raw = textOf(expression);
  264. if (!raw) return [];
  265. const weeks = [];
  266. raw.split(/[,,、]/).forEach((segment) => {
  267. if (!segment) return;
  268. let parity = 0; // 0=全部 1=单周 2=双周
  269. if (segment.indexOf('单') !== -1) parity = 1;
  270. if (segment.indexOf('双') !== -1) parity = 2;
  271. const rangePattern = /(\d+)\s*(?:[-~至]\s*(\d+))?/g;
  272. let match;
  273. while ((match = rangePattern.exec(segment)) !== null) {
  274. const start = parseInt(match[1], 10);
  275. let end = match[2] ? parseInt(match[2], 10) : start;
  276. if (isNaN(start) || start <= 0) continue;
  277. if (isNaN(end) || end < start) end = start;
  278. if (end - start > 60) end = start;
  279. for (let week = start; week <= end; week++) {
  280. if (parity === 1 && week % 2 === 0) continue;
  281. if (parity === 2 && week % 2 === 1) continue;
  282. weeks.push(week);
  283. }
  284. }
  285. });
  286. return uniqueSorted(weeks);
  287. }
  288. /** 只截取周次表达式,避免把「第3节-第4节」当周次 */
  289. function matchWeekExpression(raw) {
  290. const match = new RegExp(WEEK_EXPRESSION).exec(raw);
  291. if (!match || !match[0]) return null;
  292. let start = match.index;
  293. const back = /(?:\d+\s*(?:[-~至]\s*\d+)?\s*[,,、]\s*)+$/.exec(raw.substring(0, start));
  294. if (back) start -= back[0].length;
  295. return { index: start, text: raw.substring(start, match.index + match[0].length) };
  296. }
  297. /** 周次文本:"6-18周" / "4周" / "1-16周(单)" / "1,3,5周" */
  298. function parseWeeksDescription(value) {
  299. const raw = textOf(value);
  300. if (!raw) return [];
  301. const matched = matchWeekExpression(raw);
  302. if (matched) {
  303. const weeks = parseWeekSegments(matched.text);
  304. if (weeks.length) return weeks;
  305. }
  306. const head = raw.split('周')[0] || '';
  307. return parseWeekSegments(head.replace(/^[^\d]*/, ''));
  308. }
  309. // ==================== 接口数据 → 课程 ====================
  310. function buildPosition(row) {
  311. const room = textOf(row.JASMC) || textOf(row.JAS);
  312. if (room) return room;
  313. const building = textOf(row.JXLDM_DISPLAY) || textOf(row.JXLMC);
  314. if (building) return building;
  315. return textOf(row.XXXQMC);
  316. }
  317. function parseCourseRow(row) {
  318. if (!row) return null;
  319. const day = parseInt(textOf(row.SKXQ), 10);
  320. if (!(day >= 1 && day <= 7)) return null;
  321. const baseName = textOf(row.KCM);
  322. if (!baseName) return null;
  323. const alias = textOf(row.TYXMDM_DISPLAY);
  324. const name = alias ? (baseName + '(' + alias + ')') : baseName;
  325. const startSection = parseInt(textOf(row.KSJC), 10);
  326. let endSection = parseInt(textOf(row.JSJC), 10);
  327. if (!(startSection > 0)) return null;
  328. if (isNaN(endSection) || endSection < startSection) endSection = startSection;
  329. let weeks = parseWeekBitString(row.SKZC);
  330. if (!weeks || !weeks.length) weeks = parseWeeksDescription(row.ZCMC);
  331. if (!weeks || !weeks.length) return null; // 周次无法确定:不完整课程,忽略
  332. return {
  333. name: name,
  334. teacher: cleanTeacher(row.SKJS),
  335. position: buildPosition(row),
  336. day: day,
  337. startSection: startSection,
  338. endSection: endSection,
  339. weeks: weeks
  340. };
  341. }
  342. async function fetchCourses(term) {
  343. const response = await api(URL_COURSE, { XNXQDM: term.dm });
  344. const rows = rowsOf(response, ACTION_COURSE);
  345. if (!rows) {
  346. const message = messageOf(response);
  347. throw new Error('课表接口未返回数据' + (message ? ':' + message : ''));
  348. }
  349. if (!rows.length) throw new Error('课表接口返回 0 条记录(该学期可能没有排课)');
  350. const courses = [];
  351. let maxWeekBits = 0;
  352. rows.forEach((row) => {
  353. const bits = textOf(row.SKZC).replace(/\s+/g, '');
  354. if (/^[01]+$/.test(bits) && bits.length > maxWeekBits) maxWeekBits = bits.length;
  355. const course = parseCourseRow(row);
  356. if (course) courses.push(course);
  357. });
  358. if (!courses.length) throw new Error('课表接口返回 ' + rows.length + ' 条记录,但均无法解析出课程');
  359. log('接口返回 ' + rows.length + ' 条排课记录,解析出 ' + courses.length + ' 条(忽略 ' + (rows.length - courses.length) + ' 条不完整记录)');
  360. return { courses: courses, maxWeekBits: maxWeekBits };
  361. }
  362. // ==================== 合并去重(官方参考实现) ====================
  363. /**
  364. * 节次与周次合并去重函数
  365. * 来源:拾光课程表 wiki《课程合并与去重函数》
  366. */
  367. function mergeAndDistinctCourses(courses) {
  368. if (!Array.isArray(courses) || courses.length <= 1) return courses || [];
  369. const list = courses.map((c) => Object.assign({}, c, {
  370. name: c.name || '',
  371. teacher: c.teacher || '',
  372. position: c.position || '',
  373. weeks: Array.isArray(c.weeks) ? c.weeks.slice().sort((a, b) => a - b) : []
  374. }));
  375. // 阶段 1:合并连续节次与完全重复记录
  376. list.sort((a, b) => {
  377. return a.name.localeCompare(b.name) ||
  378. a.teacher.localeCompare(b.teacher) ||
  379. a.position.localeCompare(b.position) ||
  380. (a.day || 0) - (b.day || 0) ||
  381. a.weeks.join(',').localeCompare(b.weeks.join(',')) ||
  382. (a.startSection || 0) - (b.startSection || 0);
  383. });
  384. const step1Merged = [];
  385. let current = list[0];
  386. for (let i = 1; i < list.length; i++) {
  387. const next = list[i];
  388. const isSameCourseAndWeeks =
  389. current.name === next.name &&
  390. current.teacher === next.teacher &&
  391. current.position === next.position &&
  392. current.day === next.day &&
  393. current.weeks.join(',') === next.weeks.join(',');
  394. const isContinuous = current.endSection + 1 === next.startSection;
  395. const isDuplicate = current.startSection === next.startSection && current.endSection === next.endSection;
  396. if (isSameCourseAndWeeks && isContinuous) {
  397. current.endSection = next.endSection;
  398. } else if (isSameCourseAndWeeks && isDuplicate) {
  399. continue;
  400. } else {
  401. step1Merged.push(current);
  402. current = next;
  403. }
  404. }
  405. step1Merged.push(current);
  406. // 阶段 2:合并同节次的周次
  407. step1Merged.sort((a, b) => {
  408. return a.name.localeCompare(b.name) ||
  409. a.teacher.localeCompare(b.teacher) ||
  410. a.position.localeCompare(b.position) ||
  411. (a.day || 0) - (b.day || 0) ||
  412. (a.startSection || 0) - (b.startSection || 0) ||
  413. (a.endSection || 0) - (b.endSection || 0);
  414. });
  415. const step2Merged = [];
  416. let cur = step1Merged[0];
  417. for (let i = 1; i < step1Merged.length; i++) {
  418. const nxt = step1Merged[i];
  419. const isSameCourseAndSection =
  420. cur.name === nxt.name &&
  421. cur.teacher === nxt.teacher &&
  422. cur.position === nxt.position &&
  423. cur.day === nxt.day &&
  424. cur.startSection === nxt.startSection &&
  425. cur.endSection === nxt.endSection;
  426. if (isSameCourseAndSection) {
  427. cur.weeks = Array.from(new Set(cur.weeks.concat(nxt.weeks))).sort((a, b) => a - b);
  428. } else {
  429. step2Merged.push(cur);
  430. cur = nxt;
  431. }
  432. }
  433. step2Merged.push(cur);
  434. return step2Merged;
  435. }
  436. // ==================== 作息方案 ====================
  437. function pad2(value) {
  438. return value < 10 ? ('0' + value) : String(value);
  439. }
  440. /** 生成 "YYYY-MM-DD" */
  441. function dateText(year, month, day) {
  442. return String(year) + '-' + pad2(month) + '-' + pad2(day);
  443. }
  444. /**
  445. * 生成「冬令 + 夏令」两段作息(区间为闭区间,结束日取下一段开始日前一天):
  446. * 夏令 05-01 ~ 09-30;冬令 10-01 ~ 次年 04-30
  447. * 只生成「当前时段 + 下一时段」两条规则,避免同一日期命中多条。
  448. */
  449. function buildComboSchedule(today, winterSlots, summerSlots) {
  450. const winter = winterSlots || SLOTS_WINTER;
  451. const summer = summerSlots || SLOTS_SUMMER;
  452. const date = today || new Date();
  453. const year = date.getFullYear();
  454. const month = date.getMonth() + 1;
  455. let rules;
  456. if (month >= 5 && month <= 9) {
  457. rules = [
  458. { name: '夏令作息', startDate: dateText(year, 5, 1), endDate: dateText(year, 9, 30), timeSlots: summer },
  459. { name: '冬令作息', startDate: dateText(year, 10, 1), endDate: dateText(year + 1, 4, 30), timeSlots: winter }
  460. ];
  461. } else if (month >= 10) {
  462. rules = [
  463. { name: '冬令作息', startDate: dateText(year, 10, 1), endDate: dateText(year + 1, 4, 30), timeSlots: winter },
  464. { name: '夏令作息', startDate: dateText(year + 1, 5, 1), endDate: dateText(year + 1, 9, 30), timeSlots: summer }
  465. ];
  466. } else {
  467. rules = [
  468. { name: '冬令作息', startDate: dateText(year - 1, 10, 1), endDate: dateText(year, 4, 30), timeSlots: winter },
  469. { name: '夏令作息', startDate: dateText(year, 5, 1), endDate: dateText(year, 9, 30), timeSlots: summer }
  470. ];
  471. }
  472. return { name: '西北农林科技大学作息(冬令/夏令)', publicSchedules: rules };
  473. }
  474. /** 分钟数 → "HH:mm" */
  475. function hhmm(minutes) {
  476. const hour = Math.floor(minutes / 60) % 24;
  477. return pad2(hour) + ':' + pad2(minutes % 60);
  478. }
  479. /**
  480. * 课程节次超出作息表时补足(45 分钟上课 + 10 分钟课间)。
  481. * 正常学期不会触发(作息表 11 节),只有教务临时加节时兜底,避免课程引用不存在的节次。
  482. */
  483. function extendSlots(base, maxSection) {
  484. const slots = (base || []).map((slot) => ({
  485. number: slot.number,
  486. startTime: slot.startTime,
  487. endTime: slot.endTime
  488. }));
  489. if (!slots.length) return slots;
  490. const target = Math.min(Math.max(parseInt(maxSection, 10) || slots.length, slots.length), 20);
  491. while (slots.length < target) {
  492. const last = slots[slots.length - 1];
  493. const parts = last.endTime.split(':');
  494. const startMinutes = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10) + 10;
  495. slots.push({
  496. number: slots.length + 1,
  497. startTime: hhmm(startMinutes),
  498. endTime: hhmm(startMinutes + 45)
  499. });
  500. }
  501. return slots;
  502. }
  503. /** 课程里出现的最大的结束节次 */
  504. function maxSectionOf(courses) {
  505. let max = 0;
  506. (courses || []).forEach((course) => {
  507. const end = parseInt(course && course.endSection, 10);
  508. if (end > max) max = end;
  509. });
  510. return max;
  511. }
  512. /**
  513. * 节次结构(cxjcs.do):上午 / 下午 / 晚上各几节。
  514. * 仅用于日志核对硬编码作息是否过期,取不到不影响导入。
  515. */
  516. async function fetchSectionLayout(dm) {
  517. const codes = termCodes(dm);
  518. if (!codes) return null;
  519. const response = await api(URL_SECTIONS, { XN: codes.xn, XQ: codes.xq });
  520. const rows = rowsOf(response, ACTION_SECTIONS);
  521. if (!rows || !rows.length) return null;
  522. const row = rows[0];
  523. const morning = parseInt(textOf(row.SFJJ), 10) || 0;
  524. const afternoon = parseInt(textOf(row.XFJJ), 10) || 0;
  525. const evening = parseInt(textOf(row.WSJJ), 10) || 0;
  526. return { morning: morning, afternoon: afternoon, evening: evening, total: morning + afternoon + evening };
  527. }
  528. /** 课表配置:优先用校历给的开学日期与总周数 */
  529. function buildCourseConfig(calendar, courseWeeks) {
  530. const totalWeeks = (calendar && calendar.totalWeeks > 0) ? calendar.totalWeeks : courseWeeks;
  531. const config = { semesterTotalWeeks: totalWeeks };
  532. if (calendar && calendar.startDate) config.semesterStartDate = calendar.startDate;
  533. return config;
  534. }
  535. // ==================== 保存 ====================
  536. async function saveConfig(config) {
  537. try {
  538. return await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  539. } catch (error) {
  540. toast('保存课表配置失败: ' + error.message);
  541. return false;
  542. }
  543. }
  544. async function saveCourses(courses) {
  545. try {
  546. return await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  547. } catch (error) {
  548. toast('保存课程失败: ' + error.message);
  549. return false;
  550. }
  551. }
  552. // ==================== 主导入流程 ====================
  553. /** 前置确认 */
  554. async function promptUserToStart() {
  555. const confirmed = await window.shiguangBridgePromise.showAlert(
  556. '导入确认',
  557. '将从西北农林科技大学「我的本研课表」导入课程与作息。\n'
  558. + '请先登录 ' + PORTAL_HINT + ',再点击开始导入;\n'
  559. + '导入时可选择要导入的学期(默认当前学期)。',
  560. '开始导入'
  561. );
  562. if (!confirmed) {
  563. toast('已取消导入');
  564. return false;
  565. }
  566. toast('正在读取课表数据…');
  567. return true;
  568. }
  569. function computeTotalWeeks(courses, maxWeekBits) {
  570. let maxWeek = 0;
  571. courses.forEach((course) => {
  572. (course.weeks || []).forEach((week) => {
  573. if (week > maxWeek) maxWeek = week;
  574. });
  575. });
  576. let total = Math.max(maxWeek, maxWeekBits || 0);
  577. if (!total) total = DEFAULT_TOTAL_WEEKS;
  578. return Math.min(Math.max(total, 1), MAX_TOTAL_WEEKS);
  579. }
  580. /** 编排整个导入流程:成功才发送结束信号 */
  581. /**
  582. * 作息保存:先基础作息(冬令),再冬令/夏令组合方案。
  583. * 官方要求 savePresetTimeSlots 必须先成功,否则 saveComboSchedule 会被拒绝。
  584. */
  585. async function saveSchedule(winterSlots, summerSlots) {
  586. try {
  587. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(winterSlots));
  588. await window.shiguangBridgePromise.saveComboSchedule(
  589. JSON.stringify(buildComboSchedule(new Date(), winterSlots, summerSlots))
  590. );
  591. return true;
  592. } catch (e) {
  593. log('作息导入失败(不影响课程): ' + e.message);
  594. return false;
  595. }
  596. }
  597. /** 校历与会话信息:失败都不影响课程导入 */
  598. async function loadExtras(dm) {
  599. let calendar = null;
  600. try {
  601. calendar = await fetchTermCalendar(dm);
  602. } catch (e) {
  603. log('校历接口失败(不影响导入): ' + e.message);
  604. }
  605. let layout = null;
  606. try {
  607. layout = await fetchSectionLayout(dm);
  608. } catch (e) {
  609. log('节次结构接口失败: ' + e.message);
  610. }
  611. if (layout && layout.total > 0) {
  612. log('教务节次结构:上午 ' + layout.morning + ' / 下午 ' + layout.afternoon
  613. + ' / 晚上 ' + layout.evening + ' 节(合计 ' + layout.total + ')');
  614. }
  615. return calendar;
  616. }
  617. /** 编排整个导入流程(只做流程控制):成功才发送结束信号 */
  618. async function runImportFlow() {
  619. try {
  620. if (!(await promptUserToStart())) return;
  621. // 1) 选择学期(默认当前学期;已结束且下学期已公布时可选下学期)
  622. const term = await selectTerm();
  623. if (!term) {
  624. toast('已取消导入');
  625. return;
  626. }
  627. log('选中学期: ' + term.dm + ' / ' + termText(term));
  628. // 2) 该学期课程 + 校历
  629. const fetched = await fetchCourses(term);
  630. const courses = mergeAndDistinctCourses(fetched.courses);
  631. const calendar = await loadExtras(term.dm);
  632. // 3) 配置 → 课程
  633. const config = buildCourseConfig(calendar, computeTotalWeeks(courses, fetched.maxWeekBits));
  634. if (!(await saveConfig(config))) {
  635. toast('课表配置保存失败,导入终止');
  636. return;
  637. }
  638. if (!(await saveCourses(courses))) {
  639. toast('课程保存失败,导入终止');
  640. return;
  641. }
  642. // 4) 作息(节次超出作息表时自动补足)→ 组合作息
  643. const maxSection = maxSectionOf(courses);
  644. if (maxSection > SLOTS_WINTER.length) {
  645. log('课程出现第 ' + maxSection + ' 节,作息自动补足到 ' + maxSection + ' 节');
  646. }
  647. const winterSlots = extendSlots(SLOTS_WINTER, maxSection);
  648. const summerSlots = extendSlots(SLOTS_SUMMER, maxSection);
  649. const scheduleImported = await saveSchedule(winterSlots, summerSlots);
  650. // 5) 完成
  651. toast(
  652. '导入成功:' + courses.length + ' 门课程'
  653. + (scheduleImported ? ',已写入冬令/夏令作息' : ',作息导入失败')
  654. + (calendar && calendar.startDate ? ',开学 ' + calendar.startDate : '')
  655. + '(' + termText(term) + ')'
  656. );
  657. window.shiguangBridge.notifyTaskCompletion();
  658. } catch (error) {
  659. await reportFailure(error);
  660. }
  661. }
  662. /** 失败提示:优先弹窗,弹窗不可用时退回 toast */
  663. async function reportFailure(error) {
  664. const message = '导入失败:' + (error && error.message ? error.message : String(error));
  665. console.error('[NWAFU 课表适配] ' + message);
  666. try {
  667. await window.shiguangBridgePromise.showAlert(
  668. '导入失败',
  669. message + '\n\n请先登录 ' + PORTAL_HINT + ',打开课表页面后重试。',
  670. '知道了'
  671. );
  672. } catch (e) {
  673. toast(message);
  674. }
  675. }
  676. // 启动导入流程
  677. runImportFlow();