|
|
@@ -0,0 +1,778 @@
|
|
|
+// 文件: nwafu.js
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// 学校:西北农林科技大学(NWAFU)
|
|
|
+// 系统:ehall 新版教务(金智 jwapp)→「我的本研课表」
|
|
|
+// 入口:https://newehall.nwafu.edu.cn/(登录后进入「我的本研课表」再执行导入)
|
|
|
+//
|
|
|
+// 规范:拾光课程表《适配脚本开发指南 v2》
|
|
|
+// · 数据:courses / timeSlots / config / comboSchedule
|
|
|
+// · 桥接:window.shiguangBridgePromise(异步)/ window.shiguangBridge(同步)
|
|
|
+// · 流程:runImportFlow() 编排,成功后才 notifyTaskCompletion()
|
|
|
+
|
|
|
+// 作息:按学校作息时间表硬编码(第 1-4 节冬夏相同,第 5 节起不同)
|
|
|
+// 夏令:当年 05.01 — 当年 10.01;冬令:当年 10.01 — 次年 05.01
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+// ==================== 常量 ====================
|
|
|
+
|
|
|
+const APP_CODE = 'wdkbby';
|
|
|
+const URL_TERM = '/modules/jshkcb/dqxnxq.do';
|
|
|
+const URL_TERM_LIST = '/modules/jshkcb/xnxqcx.do';
|
|
|
+const URL_COURSE = '/modules/xskcb/cxxszhxqkb.do';
|
|
|
+const URL_CALENDAR = '/modules/xskcb/cxxljc.do';
|
|
|
+const URL_SECTIONS = '/modules/jshkcb/cxjcs.do';
|
|
|
+const ACTION_TERM = 'dqxnxq';
|
|
|
+const ACTION_TERM_LIST = 'xnxqcx';
|
|
|
+const ACTION_COURSE = 'cxxszhxqkb';
|
|
|
+const ACTION_CALENDAR = 'cxxljc';
|
|
|
+const ACTION_SECTIONS = 'cxjcs';
|
|
|
+const PORTAL_HINT = 'newehall.nwafu.edu.cn 新门户 →「我的本研课表」';
|
|
|
+
|
|
|
+const DEFAULT_TOTAL_WEEKS = 20;
|
|
|
+const MAX_TOTAL_WEEKS = 30;
|
|
|
+
|
|
|
+// ==================== 作息(学校作息时间表) ====================
|
|
|
+
|
|
|
+const SLOTS_WINTER = [
|
|
|
+ { number: 1, startTime: '08:00', endTime: '08:45' },
|
|
|
+ { number: 2, startTime: '08:55', endTime: '09:40' },
|
|
|
+ { number: 3, startTime: '10:10', endTime: '10:55' },
|
|
|
+ { number: 4, startTime: '11:05', endTime: '11:50' },
|
|
|
+ { number: 5, startTime: '14:00', endTime: '14:45' },
|
|
|
+ { number: 6, startTime: '14:55', endTime: '15:40' },
|
|
|
+ { number: 7, startTime: '16:00', endTime: '16:45' },
|
|
|
+ { number: 8, startTime: '16:55', endTime: '17:40' },
|
|
|
+ { number: 9, startTime: '19:00', endTime: '19:45' },
|
|
|
+ { number: 10, startTime: '19:50', endTime: '20:35' },
|
|
|
+ { number: 11, startTime: '20:40', endTime: '21:25' }
|
|
|
+];
|
|
|
+
|
|
|
+const SLOTS_SUMMER = [
|
|
|
+ { number: 1, startTime: '08:00', endTime: '08:45' },
|
|
|
+ { number: 2, startTime: '08:55', endTime: '09:40' },
|
|
|
+ { number: 3, startTime: '10:10', endTime: '10:55' },
|
|
|
+ { number: 4, startTime: '11:05', endTime: '11:50' },
|
|
|
+ { number: 5, startTime: '14:30', endTime: '15:15' },
|
|
|
+ { number: 6, startTime: '15:25', endTime: '16:10' },
|
|
|
+ { number: 7, startTime: '16:30', endTime: '17:15' },
|
|
|
+ { number: 8, startTime: '17:25', endTime: '18:10' },
|
|
|
+ { number: 9, startTime: '19:30', endTime: '20:15' },
|
|
|
+ { number: 10, startTime: '20:20', endTime: '21:05' },
|
|
|
+ { number: 11, startTime: '21:10', endTime: '21:55' }
|
|
|
+];
|
|
|
+
|
|
|
+// ==================== 工具 ====================
|
|
|
+
|
|
|
+function toast(message) {
|
|
|
+ try {
|
|
|
+ window.shiguangBridge.showToast(String(message));
|
|
|
+ } catch (e) {
|
|
|
+ console.warn('[NWAFU] showToast 不可用:', e && e.message);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function log(message) {
|
|
|
+ try {
|
|
|
+ console.log('[NWAFU 课表适配] ' + message);
|
|
|
+ } catch (e) { /* ignore */ }
|
|
|
+}
|
|
|
+
|
|
|
+function textOf(value) {
|
|
|
+ if (value === null || value === undefined) return '';
|
|
|
+ const result = String(value).replace(/\u00a0/g, ' ').trim();
|
|
|
+ if (result === 'null' || result === 'undefined') return '';
|
|
|
+ return result;
|
|
|
+}
|
|
|
+
|
|
|
+function uniqueSorted(numbers) {
|
|
|
+ return Array.from(new Set(numbers.filter((n) => typeof n === 'number' && !isNaN(n) && n > 0)))
|
|
|
+ .sort((a, b) => a - b);
|
|
|
+}
|
|
|
+
|
|
|
+/** 教师字段去重 **/
|
|
|
+function cleanTeacher(value) {
|
|
|
+ const raw = textOf(value);
|
|
|
+ if (!raw) return '';
|
|
|
+ const names = raw.split(/[,,、;;]/).map((item) => textOf(item)).filter((item) => item !== '');
|
|
|
+ return Array.from(new Set(names)).join(',') || raw;
|
|
|
+}
|
|
|
+
|
|
|
+// ==================== 教务接口 ====================
|
|
|
+
|
|
|
+function rootPath() {
|
|
|
+ const config = window._JW_INIT_CONFIG || {};
|
|
|
+ let root = config.path || config.ROOT_PATH || config.contextPath || '/jwapp';
|
|
|
+ root = String(root);
|
|
|
+ if (root.charAt(0) !== '/') root = '/' + root;
|
|
|
+ return root.replace(/\/+$/, '');
|
|
|
+}
|
|
|
+
|
|
|
+function encodeForm(data) {
|
|
|
+ const pairs = [];
|
|
|
+ Object.keys(data || {}).forEach((key) => {
|
|
|
+ const value = data[key];
|
|
|
+ if (value === null || value === undefined) return;
|
|
|
+ pairs.push(encodeURIComponent(key) + '=' + encodeURIComponent(String(value)));
|
|
|
+ });
|
|
|
+ return pairs.join('&');
|
|
|
+}
|
|
|
+
|
|
|
+/** 与教务页面自身(BH_UTILS.doSyncAjax)等价的表单 POST:同源、带 Cookie */
|
|
|
+async function api(modulePath, data) {
|
|
|
+ const response = await fetch(rootPath() + '/sys/' + APP_CODE + modulePath, {
|
|
|
+ method: 'POST',
|
|
|
+ credentials: 'include',
|
|
|
+ headers: {
|
|
|
+ 'accept': 'application/json, text/javascript, */*; q=0.01',
|
|
|
+ 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
|
|
+ 'x-requested-with': 'XMLHttpRequest'
|
|
|
+ },
|
|
|
+ body: encodeForm(data || {})
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!response || !response.ok) throw new Error('HTTP ' + (response ? response.status : '?'));
|
|
|
+
|
|
|
+ const raw = await response.text();
|
|
|
+ let json;
|
|
|
+ try {
|
|
|
+ json = JSON.parse(raw);
|
|
|
+ } catch (e) {
|
|
|
+ throw new Error('返回内容不是 JSON(很可能登录已失效)');
|
|
|
+ }
|
|
|
+ if (json && json.loginURL) throw new Error('登录状态已失效,请重新登录');
|
|
|
+ return json;
|
|
|
+}
|
|
|
+
|
|
|
+/** 取出 {datas:{<action>:{rows:[...]}}} 中的 rows */
|
|
|
+function rowsOf(response, action) {
|
|
|
+ if (!response || !response.datas) return null;
|
|
|
+ const node = response.datas[action];
|
|
|
+ if (!node || !node.rows || typeof node.rows.length !== 'number') return null;
|
|
|
+ if (node.rows.length === 0 && node.extParams && String(node.extParams.code) !== '1') return null;
|
|
|
+ return node.rows;
|
|
|
+}
|
|
|
+
|
|
|
+function messageOf(response) {
|
|
|
+ if (!response || !response.datas) return '';
|
|
|
+ const keys = Object.keys(response.datas);
|
|
|
+ for (let i = 0; i < keys.length; i++) {
|
|
|
+ const node = response.datas[keys[i]];
|
|
|
+ if (node && node.extParams && node.extParams.msg) return String(node.extParams.msg);
|
|
|
+ }
|
|
|
+ return '';
|
|
|
+}
|
|
|
+
|
|
|
+// ==================== 学年学期 ====================
|
|
|
+
|
|
|
+/** 当前学年学期(dqxnxq.do),用于学期列表的默认选中项 */
|
|
|
+async function fetchTerm() {
|
|
|
+ const response = await api(URL_TERM, {});
|
|
|
+ const rows = rowsOf(response, ACTION_TERM);
|
|
|
+ if (!rows || !rows.length) {
|
|
|
+ throw new Error('未能获取当前学年学期' + (messageOf(response) ? ':' + messageOf(response) : ''));
|
|
|
+ }
|
|
|
+ const dm = textOf(rows[0].DM);
|
|
|
+ if (!dm) throw new Error('学年学期接口未返回学期代码');
|
|
|
+ return { dm: dm, mc: textOf(rows[0].MC) };
|
|
|
+}
|
|
|
+
|
|
|
+/** 学期列表(xnxqcx.do,按学期代码降序 = 最新在前) */
|
|
|
+async function fetchTermList() {
|
|
|
+ const response = await api(URL_TERM_LIST, { '*order': '-DM' });
|
|
|
+ const rows = rowsOf(response, ACTION_TERM_LIST);
|
|
|
+ if (!rows || !rows.length) return [];
|
|
|
+ return sortTermsDesc(rows);
|
|
|
+}
|
|
|
+
|
|
|
+/** 规整学期行并降序排列 */
|
|
|
+function sortTermsDesc(rows) {
|
|
|
+ return (rows || [])
|
|
|
+ .map((row) => ({ dm: textOf(row && row.DM), mc: textOf(row && row.MC) }))
|
|
|
+ .filter((term) => term.dm)
|
|
|
+ .sort((a, b) => b.dm.localeCompare(a.dm));
|
|
|
+}
|
|
|
+
|
|
|
+/** 学期显示文本 */
|
|
|
+function termText(term) {
|
|
|
+ const mc = textOf(term && term.mc);
|
|
|
+ return mc || textOf(term && term.dm);
|
|
|
+}
|
|
|
+
|
|
|
+/** 默认选中当前学期;列表中找不到则选第一条 */
|
|
|
+function defaultTermIndex(list, currentDm) {
|
|
|
+ if (!Array.isArray(list) || !list.length) return -1;
|
|
|
+ const key = textOf(currentDm);
|
|
|
+ if (!key) return 0;
|
|
|
+ const index = list.findIndex((term) => term.dm === key);
|
|
|
+ return index >= 0 ? index : 0;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 让用户选择要导入的学期(官方 API:showSingleSelection)。
|
|
|
+ * 返回 { dm, mc };用户取消返回 null。
|
|
|
+ */
|
|
|
+async function selectTerm() {
|
|
|
+ let list = [];
|
|
|
+ try {
|
|
|
+ list = await fetchTermList();
|
|
|
+ } catch (e) {
|
|
|
+ log('学期列表接口失败,退回当前学期: ' + e.message);
|
|
|
+ }
|
|
|
+
|
|
|
+ let currentDm = '';
|
|
|
+ try {
|
|
|
+ const current = await fetchTerm();
|
|
|
+ currentDm = current.dm;
|
|
|
+ if (!list.length) list = [current];
|
|
|
+ } catch (e) {
|
|
|
+ log('当前学期接口失败: ' + e.message);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!list.length) throw new Error('未能获取学期列表,请先登录教务并打开「我的本研课表」');
|
|
|
+ if (list.length === 1) return list[0];
|
|
|
+
|
|
|
+ const labels = list.map((term) => termText(term));
|
|
|
+ const selected = await window.shiguangBridgePromise.showSingleSelection(
|
|
|
+ '选择学期',
|
|
|
+ JSON.stringify(labels),
|
|
|
+ defaultTermIndex(list, currentDm)
|
|
|
+ );
|
|
|
+ if (selected === null || selected === undefined) return null;
|
|
|
+
|
|
|
+ const index = Number(selected);
|
|
|
+ if (!(index >= 0 && index < list.length)) return null;
|
|
|
+ return list[index];
|
|
|
+}
|
|
|
+
|
|
|
+/** "2026-2027-1" → { xn: "2026-2027", xq: "1" } */
|
|
|
+function termCodes(dm) {
|
|
|
+ const parts = textOf(dm).split('-');
|
|
|
+ if (parts.length < 3) return null;
|
|
|
+ const xn = parts[0] + '-' + parts[1];
|
|
|
+ const xq = parts[2];
|
|
|
+ if (!/^\d{4}-\d{4}$/.test(xn) || !/^\d+$/.test(xq)) return null;
|
|
|
+ return { xn: xn, xq: xq };
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 校历:所选学期的开学日期 + 校历总周数
|
|
|
+ * POST {ROOT}/sys/wdkbby/modules/xskcb/cxxljc.do { XN, XQ }
|
|
|
+ * rows[0].XQKSRQ → 开学日期;rows[0].ZZC → 校历总周数
|
|
|
+ * 取不到返回 null(不影响课程导入)。
|
|
|
+ */
|
|
|
+async function fetchTermCalendar(dm) {
|
|
|
+ const codes = termCodes(dm);
|
|
|
+ if (!codes) return null;
|
|
|
+
|
|
|
+ const response = await api(URL_CALENDAR, { XN: codes.xn, XQ: codes.xq });
|
|
|
+ const rows = rowsOf(response, ACTION_CALENDAR);
|
|
|
+ if (!rows || !rows.length) return null;
|
|
|
+
|
|
|
+ const rawDate = textOf(rows[0].XQKSRQ);
|
|
|
+ const startDate = /^\d{4}-\d{2}-\d{2}/.test(rawDate) ? rawDate.substring(0, 10) : '';
|
|
|
+ const totalWeeks = parseInt(textOf(rows[0].ZZC), 10);
|
|
|
+
|
|
|
+ if (!startDate && !(totalWeeks > 0)) return null;
|
|
|
+ return {
|
|
|
+ startDate: startDate,
|
|
|
+ totalWeeks: totalWeeks > 0 ? Math.min(totalWeeks, MAX_TOTAL_WEEKS) : 0
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+// ==================== 周次解析 ====================
|
|
|
+
|
|
|
+/** 周次位串,例如 "0000111111111111110000"(第 5-18 周)→ [5..18] */
|
|
|
+function parseWeekBitString(value) {
|
|
|
+ const raw = textOf(value);
|
|
|
+ if (!raw) return null;
|
|
|
+ const compact = raw.replace(/\s+/g, '');
|
|
|
+ if (!/^[01]+$/.test(compact)) return null;
|
|
|
+ if (compact.length < 1 || compact.length > 60) return null;
|
|
|
+ const weeks = [];
|
|
|
+ for (let i = 0; i < compact.length; i++) {
|
|
|
+ if (compact.charAt(i) === '1') weeks.push(i + 1);
|
|
|
+ }
|
|
|
+ return weeks;
|
|
|
+}
|
|
|
+
|
|
|
+// 周次表达式:6-18周 / 4周 / 7-17周(单) / 4-6周(双),8-13周 / 5-16周,18周
|
|
|
+const WEEK_GROUP = '\\d+\\s*(?:[-~至]\\s*\\d+)?\\s*(?:[((]\\s*[单双]\\s*[))])?\\s*周\\s*(?:[((]\\s*[单双]\\s*[))])?';
|
|
|
+const WEEK_EXPRESSION = '(?:' + WEEK_GROUP + '\\s*[,,、]?\\s*)+';
|
|
|
+
|
|
|
+/** 解析一段周次内容(可含逗号分隔的多组、单双周标记) */
|
|
|
+function parseWeekSegments(expression) {
|
|
|
+ const raw = textOf(expression);
|
|
|
+ if (!raw) return [];
|
|
|
+
|
|
|
+ const weeks = [];
|
|
|
+ raw.split(/[,,、]/).forEach((segment) => {
|
|
|
+ if (!segment) return;
|
|
|
+ let parity = 0; // 0=全部 1=单周 2=双周
|
|
|
+ if (segment.indexOf('单') !== -1) parity = 1;
|
|
|
+ if (segment.indexOf('双') !== -1) parity = 2;
|
|
|
+
|
|
|
+ const rangePattern = /(\d+)\s*(?:[-~至]\s*(\d+))?/g;
|
|
|
+ let match;
|
|
|
+ while ((match = rangePattern.exec(segment)) !== null) {
|
|
|
+ const start = parseInt(match[1], 10);
|
|
|
+ let end = match[2] ? parseInt(match[2], 10) : start;
|
|
|
+ if (isNaN(start) || start <= 0) continue;
|
|
|
+ if (isNaN(end) || end < start) end = start;
|
|
|
+ if (end - start > 60) end = start;
|
|
|
+ for (let week = start; week <= end; week++) {
|
|
|
+ if (parity === 1 && week % 2 === 0) continue;
|
|
|
+ if (parity === 2 && week % 2 === 1) continue;
|
|
|
+ weeks.push(week);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return uniqueSorted(weeks);
|
|
|
+}
|
|
|
+
|
|
|
+/** 只截取周次表达式,避免把「第3节-第4节」当周次 */
|
|
|
+function matchWeekExpression(raw) {
|
|
|
+ const match = new RegExp(WEEK_EXPRESSION).exec(raw);
|
|
|
+ if (!match || !match[0]) return null;
|
|
|
+
|
|
|
+ let start = match.index;
|
|
|
+ const back = /(?:\d+\s*(?:[-~至]\s*\d+)?\s*[,,、]\s*)+$/.exec(raw.substring(0, start));
|
|
|
+ if (back) start -= back[0].length;
|
|
|
+
|
|
|
+ return { index: start, text: raw.substring(start, match.index + match[0].length) };
|
|
|
+}
|
|
|
+
|
|
|
+/** 周次文本:"6-18周" / "4周" / "1-16周(单)" / "1,3,5周" */
|
|
|
+function parseWeeksDescription(value) {
|
|
|
+ const raw = textOf(value);
|
|
|
+ if (!raw) return [];
|
|
|
+
|
|
|
+ const matched = matchWeekExpression(raw);
|
|
|
+ if (matched) {
|
|
|
+ const weeks = parseWeekSegments(matched.text);
|
|
|
+ if (weeks.length) return weeks;
|
|
|
+ }
|
|
|
+ const head = raw.split('周')[0] || '';
|
|
|
+ return parseWeekSegments(head.replace(/^[^\d]*/, ''));
|
|
|
+}
|
|
|
+
|
|
|
+// ==================== 接口数据 → 课程 ====================
|
|
|
+
|
|
|
+function buildPosition(row) {
|
|
|
+ const room = textOf(row.JASMC) || textOf(row.JAS);
|
|
|
+ if (room) return room;
|
|
|
+ const building = textOf(row.JXLDM_DISPLAY) || textOf(row.JXLMC);
|
|
|
+ if (building) return building;
|
|
|
+ return textOf(row.XXXQMC);
|
|
|
+}
|
|
|
+
|
|
|
+function parseCourseRow(row) {
|
|
|
+ if (!row) return null;
|
|
|
+
|
|
|
+ const day = parseInt(textOf(row.SKXQ), 10);
|
|
|
+ if (!(day >= 1 && day <= 7)) return null;
|
|
|
+
|
|
|
+ const baseName = textOf(row.KCM);
|
|
|
+ if (!baseName) return null;
|
|
|
+ const alias = textOf(row.TYXMDM_DISPLAY);
|
|
|
+ const name = alias ? (baseName + '(' + alias + ')') : baseName;
|
|
|
+
|
|
|
+ const startSection = parseInt(textOf(row.KSJC), 10);
|
|
|
+ let endSection = parseInt(textOf(row.JSJC), 10);
|
|
|
+ if (!(startSection > 0)) return null;
|
|
|
+ if (isNaN(endSection) || endSection < startSection) endSection = startSection;
|
|
|
+
|
|
|
+ let weeks = parseWeekBitString(row.SKZC);
|
|
|
+ if (!weeks || !weeks.length) weeks = parseWeeksDescription(row.ZCMC);
|
|
|
+ if (!weeks || !weeks.length) return null; // 周次无法确定:不完整课程,忽略
|
|
|
+
|
|
|
+ return {
|
|
|
+ name: name,
|
|
|
+ teacher: cleanTeacher(row.SKJS),
|
|
|
+ position: buildPosition(row),
|
|
|
+ day: day,
|
|
|
+ startSection: startSection,
|
|
|
+ endSection: endSection,
|
|
|
+ weeks: weeks
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+async function fetchCourses(term) {
|
|
|
+ const response = await api(URL_COURSE, { XNXQDM: term.dm });
|
|
|
+ const rows = rowsOf(response, ACTION_COURSE);
|
|
|
+ if (!rows) {
|
|
|
+ const message = messageOf(response);
|
|
|
+ throw new Error('课表接口未返回数据' + (message ? ':' + message : ''));
|
|
|
+ }
|
|
|
+ if (!rows.length) throw new Error('课表接口返回 0 条记录(该学期可能没有排课)');
|
|
|
+
|
|
|
+ const courses = [];
|
|
|
+ let maxWeekBits = 0;
|
|
|
+ rows.forEach((row) => {
|
|
|
+ const bits = textOf(row.SKZC).replace(/\s+/g, '');
|
|
|
+ if (/^[01]+$/.test(bits) && bits.length > maxWeekBits) maxWeekBits = bits.length;
|
|
|
+ const course = parseCourseRow(row);
|
|
|
+ if (course) courses.push(course);
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!courses.length) throw new Error('课表接口返回 ' + rows.length + ' 条记录,但均无法解析出课程');
|
|
|
+ log('接口返回 ' + rows.length + ' 条排课记录,解析出 ' + courses.length + ' 条(忽略 ' + (rows.length - courses.length) + ' 条不完整记录)');
|
|
|
+ return { courses: courses, maxWeekBits: maxWeekBits };
|
|
|
+}
|
|
|
+
|
|
|
+// ==================== 合并去重(官方参考实现) ====================
|
|
|
+
|
|
|
+/**
|
|
|
+ * 节次与周次合并去重函数
|
|
|
+ * 来源:拾光课程表 wiki《课程合并与去重函数》
|
|
|
+ */
|
|
|
+function mergeAndDistinctCourses(courses) {
|
|
|
+ if (!Array.isArray(courses) || courses.length <= 1) return courses || [];
|
|
|
+
|
|
|
+ const list = courses.map((c) => Object.assign({}, c, {
|
|
|
+ name: c.name || '',
|
|
|
+ teacher: c.teacher || '',
|
|
|
+ position: c.position || '',
|
|
|
+ weeks: Array.isArray(c.weeks) ? c.weeks.slice().sort((a, b) => a - b) : []
|
|
|
+ }));
|
|
|
+
|
|
|
+ // 阶段 1:合并连续节次与完全重复记录
|
|
|
+ list.sort((a, b) => {
|
|
|
+ return a.name.localeCompare(b.name) ||
|
|
|
+ a.teacher.localeCompare(b.teacher) ||
|
|
|
+ a.position.localeCompare(b.position) ||
|
|
|
+ (a.day || 0) - (b.day || 0) ||
|
|
|
+ a.weeks.join(',').localeCompare(b.weeks.join(',')) ||
|
|
|
+ (a.startSection || 0) - (b.startSection || 0);
|
|
|
+ });
|
|
|
+
|
|
|
+ const step1Merged = [];
|
|
|
+ let current = list[0];
|
|
|
+
|
|
|
+ for (let i = 1; i < list.length; i++) {
|
|
|
+ const next = list[i];
|
|
|
+ const isSameCourseAndWeeks =
|
|
|
+ current.name === next.name &&
|
|
|
+ current.teacher === next.teacher &&
|
|
|
+ current.position === next.position &&
|
|
|
+ current.day === next.day &&
|
|
|
+ current.weeks.join(',') === next.weeks.join(',');
|
|
|
+ const isContinuous = current.endSection + 1 === next.startSection;
|
|
|
+ const isDuplicate = current.startSection === next.startSection && current.endSection === next.endSection;
|
|
|
+
|
|
|
+ if (isSameCourseAndWeeks && isContinuous) {
|
|
|
+ current.endSection = next.endSection;
|
|
|
+ } else if (isSameCourseAndWeeks && isDuplicate) {
|
|
|
+ continue;
|
|
|
+ } else {
|
|
|
+ step1Merged.push(current);
|
|
|
+ current = next;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ step1Merged.push(current);
|
|
|
+
|
|
|
+ // 阶段 2:合并同节次的周次
|
|
|
+ step1Merged.sort((a, b) => {
|
|
|
+ return a.name.localeCompare(b.name) ||
|
|
|
+ a.teacher.localeCompare(b.teacher) ||
|
|
|
+ a.position.localeCompare(b.position) ||
|
|
|
+ (a.day || 0) - (b.day || 0) ||
|
|
|
+ (a.startSection || 0) - (b.startSection || 0) ||
|
|
|
+ (a.endSection || 0) - (b.endSection || 0);
|
|
|
+ });
|
|
|
+
|
|
|
+ const step2Merged = [];
|
|
|
+ let cur = step1Merged[0];
|
|
|
+
|
|
|
+ for (let i = 1; i < step1Merged.length; i++) {
|
|
|
+ const nxt = step1Merged[i];
|
|
|
+ const isSameCourseAndSection =
|
|
|
+ cur.name === nxt.name &&
|
|
|
+ cur.teacher === nxt.teacher &&
|
|
|
+ cur.position === nxt.position &&
|
|
|
+ cur.day === nxt.day &&
|
|
|
+ cur.startSection === nxt.startSection &&
|
|
|
+ cur.endSection === nxt.endSection;
|
|
|
+
|
|
|
+ if (isSameCourseAndSection) {
|
|
|
+ cur.weeks = Array.from(new Set(cur.weeks.concat(nxt.weeks))).sort((a, b) => a - b);
|
|
|
+ } else {
|
|
|
+ step2Merged.push(cur);
|
|
|
+ cur = nxt;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ step2Merged.push(cur);
|
|
|
+
|
|
|
+ return step2Merged;
|
|
|
+}
|
|
|
+
|
|
|
+// ==================== 作息方案 ====================
|
|
|
+
|
|
|
+function pad2(value) {
|
|
|
+ return value < 10 ? ('0' + value) : String(value);
|
|
|
+}
|
|
|
+
|
|
|
+/** 生成 "YYYY-MM-DD" */
|
|
|
+function dateText(year, month, day) {
|
|
|
+ return String(year) + '-' + pad2(month) + '-' + pad2(day);
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 生成「冬令 + 夏令」两段作息(区间为闭区间,结束日取下一段开始日前一天):
|
|
|
+ * 夏令 05-01 ~ 09-30;冬令 10-01 ~ 次年 04-30
|
|
|
+ * 只生成「当前时段 + 下一时段」两条规则,避免同一日期命中多条。
|
|
|
+ */
|
|
|
+function buildComboSchedule(today, winterSlots, summerSlots) {
|
|
|
+ const winter = winterSlots || SLOTS_WINTER;
|
|
|
+ const summer = summerSlots || SLOTS_SUMMER;
|
|
|
+ const date = today || new Date();
|
|
|
+ const year = date.getFullYear();
|
|
|
+ const month = date.getMonth() + 1;
|
|
|
+
|
|
|
+ let rules;
|
|
|
+ if (month >= 5 && month <= 9) {
|
|
|
+ rules = [
|
|
|
+ { name: '夏令作息', startDate: dateText(year, 5, 1), endDate: dateText(year, 9, 30), timeSlots: summer },
|
|
|
+ { name: '冬令作息', startDate: dateText(year, 10, 1), endDate: dateText(year + 1, 4, 30), timeSlots: winter }
|
|
|
+ ];
|
|
|
+ } else if (month >= 10) {
|
|
|
+ rules = [
|
|
|
+ { name: '冬令作息', startDate: dateText(year, 10, 1), endDate: dateText(year + 1, 4, 30), timeSlots: winter },
|
|
|
+ { name: '夏令作息', startDate: dateText(year + 1, 5, 1), endDate: dateText(year + 1, 9, 30), timeSlots: summer }
|
|
|
+ ];
|
|
|
+ } else {
|
|
|
+ rules = [
|
|
|
+ { name: '冬令作息', startDate: dateText(year - 1, 10, 1), endDate: dateText(year, 4, 30), timeSlots: winter },
|
|
|
+ { name: '夏令作息', startDate: dateText(year, 5, 1), endDate: dateText(year, 9, 30), timeSlots: summer }
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ return { name: '西北农林科技大学作息(冬令/夏令)', publicSchedules: rules };
|
|
|
+}
|
|
|
+
|
|
|
+/** 分钟数 → "HH:mm" */
|
|
|
+function hhmm(minutes) {
|
|
|
+ const hour = Math.floor(minutes / 60) % 24;
|
|
|
+ return pad2(hour) + ':' + pad2(minutes % 60);
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 课程节次超出作息表时补足(45 分钟上课 + 10 分钟课间)。
|
|
|
+ * 正常学期不会触发(作息表 11 节),只有教务临时加节时兜底,避免课程引用不存在的节次。
|
|
|
+ */
|
|
|
+function extendSlots(base, maxSection) {
|
|
|
+ const slots = (base || []).map((slot) => ({
|
|
|
+ number: slot.number,
|
|
|
+ startTime: slot.startTime,
|
|
|
+ endTime: slot.endTime
|
|
|
+ }));
|
|
|
+ if (!slots.length) return slots;
|
|
|
+
|
|
|
+ const target = Math.min(Math.max(parseInt(maxSection, 10) || slots.length, slots.length), 20);
|
|
|
+ while (slots.length < target) {
|
|
|
+ const last = slots[slots.length - 1];
|
|
|
+ const parts = last.endTime.split(':');
|
|
|
+ const startMinutes = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10) + 10;
|
|
|
+ slots.push({
|
|
|
+ number: slots.length + 1,
|
|
|
+ startTime: hhmm(startMinutes),
|
|
|
+ endTime: hhmm(startMinutes + 45)
|
|
|
+ });
|
|
|
+ }
|
|
|
+ return slots;
|
|
|
+}
|
|
|
+
|
|
|
+/** 课程里出现的最大的结束节次 */
|
|
|
+function maxSectionOf(courses) {
|
|
|
+ let max = 0;
|
|
|
+ (courses || []).forEach((course) => {
|
|
|
+ const end = parseInt(course && course.endSection, 10);
|
|
|
+ if (end > max) max = end;
|
|
|
+ });
|
|
|
+ return max;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 节次结构(cxjcs.do):上午 / 下午 / 晚上各几节。
|
|
|
+ * 仅用于日志核对硬编码作息是否过期,取不到不影响导入。
|
|
|
+ */
|
|
|
+async function fetchSectionLayout(dm) {
|
|
|
+ const codes = termCodes(dm);
|
|
|
+ if (!codes) return null;
|
|
|
+
|
|
|
+ const response = await api(URL_SECTIONS, { XN: codes.xn, XQ: codes.xq });
|
|
|
+ const rows = rowsOf(response, ACTION_SECTIONS);
|
|
|
+ if (!rows || !rows.length) return null;
|
|
|
+
|
|
|
+ const row = rows[0];
|
|
|
+ const morning = parseInt(textOf(row.SFJJ), 10) || 0;
|
|
|
+ const afternoon = parseInt(textOf(row.XFJJ), 10) || 0;
|
|
|
+ const evening = parseInt(textOf(row.WSJJ), 10) || 0;
|
|
|
+ return { morning: morning, afternoon: afternoon, evening: evening, total: morning + afternoon + evening };
|
|
|
+}
|
|
|
+
|
|
|
+/** 课表配置:优先用校历给的开学日期与总周数 */
|
|
|
+function buildCourseConfig(calendar, courseWeeks) {
|
|
|
+ const totalWeeks = (calendar && calendar.totalWeeks > 0) ? calendar.totalWeeks : courseWeeks;
|
|
|
+ const config = { semesterTotalWeeks: totalWeeks };
|
|
|
+ if (calendar && calendar.startDate) config.semesterStartDate = calendar.startDate;
|
|
|
+ return config;
|
|
|
+}
|
|
|
+
|
|
|
+// ==================== 保存 ====================
|
|
|
+
|
|
|
+async function saveConfig(config) {
|
|
|
+ try {
|
|
|
+ return await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
|
|
|
+ } catch (error) {
|
|
|
+ toast('保存课表配置失败: ' + error.message);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function saveCourses(courses) {
|
|
|
+ try {
|
|
|
+ return await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
|
|
|
+ } catch (error) {
|
|
|
+ toast('保存课程失败: ' + error.message);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ==================== 主导入流程 ====================
|
|
|
+
|
|
|
+/** 前置确认 */
|
|
|
+async function promptUserToStart() {
|
|
|
+ const confirmed = await window.shiguangBridgePromise.showAlert(
|
|
|
+ '导入确认',
|
|
|
+ '将从西北农林科技大学「我的本研课表」导入课程与作息。\n'
|
|
|
+ + '请先登录 ' + PORTAL_HINT + ',再点击开始导入;\n'
|
|
|
+ + '导入时可选择要导入的学期(默认当前学期)。',
|
|
|
+ '开始导入'
|
|
|
+ );
|
|
|
+ if (!confirmed) {
|
|
|
+ toast('已取消导入');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ toast('正在读取课表数据…');
|
|
|
+ return true;
|
|
|
+}
|
|
|
+
|
|
|
+function computeTotalWeeks(courses, maxWeekBits) {
|
|
|
+ let maxWeek = 0;
|
|
|
+ courses.forEach((course) => {
|
|
|
+ (course.weeks || []).forEach((week) => {
|
|
|
+ if (week > maxWeek) maxWeek = week;
|
|
|
+ });
|
|
|
+ });
|
|
|
+ let total = Math.max(maxWeek, maxWeekBits || 0);
|
|
|
+ if (!total) total = DEFAULT_TOTAL_WEEKS;
|
|
|
+ return Math.min(Math.max(total, 1), MAX_TOTAL_WEEKS);
|
|
|
+}
|
|
|
+
|
|
|
+/** 编排整个导入流程:成功才发送结束信号 */
|
|
|
+/**
|
|
|
+ * 作息保存:先基础作息(冬令),再冬令/夏令组合方案。
|
|
|
+ * 官方要求 savePresetTimeSlots 必须先成功,否则 saveComboSchedule 会被拒绝。
|
|
|
+ */
|
|
|
+async function saveSchedule(winterSlots, summerSlots) {
|
|
|
+ try {
|
|
|
+ await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(winterSlots));
|
|
|
+ await window.shiguangBridgePromise.saveComboSchedule(
|
|
|
+ JSON.stringify(buildComboSchedule(new Date(), winterSlots, summerSlots))
|
|
|
+ );
|
|
|
+ return true;
|
|
|
+ } catch (e) {
|
|
|
+ log('作息导入失败(不影响课程): ' + e.message);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** 校历与会话信息:失败都不影响课程导入 */
|
|
|
+async function loadExtras(dm) {
|
|
|
+ let calendar = null;
|
|
|
+ try {
|
|
|
+ calendar = await fetchTermCalendar(dm);
|
|
|
+ } catch (e) {
|
|
|
+ log('校历接口失败(不影响导入): ' + e.message);
|
|
|
+ }
|
|
|
+
|
|
|
+ let layout = null;
|
|
|
+ try {
|
|
|
+ layout = await fetchSectionLayout(dm);
|
|
|
+ } catch (e) {
|
|
|
+ log('节次结构接口失败: ' + e.message);
|
|
|
+ }
|
|
|
+ if (layout && layout.total > 0) {
|
|
|
+ log('教务节次结构:上午 ' + layout.morning + ' / 下午 ' + layout.afternoon
|
|
|
+ + ' / 晚上 ' + layout.evening + ' 节(合计 ' + layout.total + ')');
|
|
|
+ }
|
|
|
+ return calendar;
|
|
|
+}
|
|
|
+
|
|
|
+/** 编排整个导入流程(只做流程控制):成功才发送结束信号 */
|
|
|
+async function runImportFlow() {
|
|
|
+ try {
|
|
|
+ if (!(await promptUserToStart())) return;
|
|
|
+
|
|
|
+ // 1) 选择学期(默认当前学期;已结束且下学期已公布时可选下学期)
|
|
|
+ const term = await selectTerm();
|
|
|
+ if (!term) {
|
|
|
+ toast('已取消导入');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ log('选中学期: ' + term.dm + ' / ' + termText(term));
|
|
|
+
|
|
|
+ // 2) 该学期课程 + 校历
|
|
|
+ const fetched = await fetchCourses(term);
|
|
|
+ const courses = mergeAndDistinctCourses(fetched.courses);
|
|
|
+ const calendar = await loadExtras(term.dm);
|
|
|
+
|
|
|
+ // 3) 配置 → 课程
|
|
|
+ const config = buildCourseConfig(calendar, computeTotalWeeks(courses, fetched.maxWeekBits));
|
|
|
+ if (!(await saveConfig(config))) {
|
|
|
+ toast('课表配置保存失败,导入终止');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!(await saveCourses(courses))) {
|
|
|
+ toast('课程保存失败,导入终止');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4) 作息(节次超出作息表时自动补足)→ 组合作息
|
|
|
+ const maxSection = maxSectionOf(courses);
|
|
|
+ if (maxSection > SLOTS_WINTER.length) {
|
|
|
+ log('课程出现第 ' + maxSection + ' 节,作息自动补足到 ' + maxSection + ' 节');
|
|
|
+ }
|
|
|
+ const winterSlots = extendSlots(SLOTS_WINTER, maxSection);
|
|
|
+ const summerSlots = extendSlots(SLOTS_SUMMER, maxSection);
|
|
|
+ const scheduleImported = await saveSchedule(winterSlots, summerSlots);
|
|
|
+
|
|
|
+ // 5) 完成
|
|
|
+ toast(
|
|
|
+ '导入成功:' + courses.length + ' 门课程'
|
|
|
+ + (scheduleImported ? ',已写入冬令/夏令作息' : ',作息导入失败')
|
|
|
+ + (calendar && calendar.startDate ? ',开学 ' + calendar.startDate : '')
|
|
|
+ + '(' + termText(term) + ')'
|
|
|
+ );
|
|
|
+ window.shiguangBridge.notifyTaskCompletion();
|
|
|
+ } catch (error) {
|
|
|
+ await reportFailure(error);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** 失败提示:优先弹窗,弹窗不可用时退回 toast */
|
|
|
+async function reportFailure(error) {
|
|
|
+ const message = '导入失败:' + (error && error.message ? error.message : String(error));
|
|
|
+ console.error('[NWAFU 课表适配] ' + message);
|
|
|
+ try {
|
|
|
+ await window.shiguangBridgePromise.showAlert(
|
|
|
+ '导入失败',
|
|
|
+ message + '\n\n请先登录 ' + PORTAL_HINT + ',打开课表页面后重试。',
|
|
|
+ '知道了'
|
|
|
+ );
|
|
|
+ } catch (e) {
|
|
|
+ toast(message);
|
|
|
+ }
|
|
|
+}
|
|
|
+// 启动导入流程
|
|
|
+runImportFlow();
|