xcu_01.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. // 许昌学院正方 V9 课表导入
  2. // 参考:resources/JSEI/jsei_01.js(星河欲转)。
  3. function normalizeText(value) {
  4. return String(value == null ? '' : value).replace(/[!-~]/g, c => String.fromCharCode(c.charCodeAt(0) - 65248)).replace(/ /g, ' ').replace(/[~—–]/g, '-');
  5. }
  6. function normalizeTime(value) {
  7. const m = normalizeText(value).trim().match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
  8. if (!m || +m[1] > 23 || +m[2] > 59 || (m[3] && +m[3] > 59)) throw new Error('无法识别时间:' + value);
  9. return m[1].padStart(2, '0') + ':' + m[2];
  10. }
  11. // WebVPN 地址可能在 /jwglxt/ 前带代理路径。
  12. function getBaseUrl() {
  13. const current = new URL(window.location.href);
  14. const index = current.pathname.indexOf('/jwglxt/');
  15. if (index < 0) throw new Error('请先登录并进入教务系统(WebVPN 需先打开教务系统应用)');
  16. return current.origin + current.pathname.slice(0, index) + '/jwglxt/';
  17. }
  18. function parseSemesterConfig(rows) {
  19. if (!Array.isArray(rows) || !rows.length) throw new Error('校历接口未返回周次列表');
  20. let first = null;
  21. const weeks = new Set();
  22. for (const row of rows) {
  23. const m = normalizeText(row.zs == null ? row.zsmc : row.zs).trim().match(/^(?:第)?(\d+)(?:周)?$/);
  24. if (!m || +m[1] < 1) continue;
  25. const week = +m[1];
  26. weeks.add(week);
  27. if (week === 1) first = row;
  28. }
  29. const config = {};
  30. // 连续的校历周次用于计算总周数;开学日期仍取第 1 周。
  31. const sortedWeeks = Array.from(weeks).sort((a, b) => a - b);
  32. const max = sortedWeeks[sortedWeeks.length - 1];
  33. let continuous = sortedWeeks.length > 0;
  34. for (let i = 1; i < sortedWeeks.length; i++) {
  35. if (sortedWeeks[i] - sortedWeeks[i - 1] !== 1) { continuous = false; break; }
  36. }
  37. if (continuous && max >= 10 && max <= 60) config.semesterTotalWeeks = max;
  38. if (first) {
  39. for (const key of ['rq', 'zcrq', 'ksrq']) {
  40. const m = normalizeText(first[key]).match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?=$|[\s/至~\-])/);
  41. if (!m) continue;
  42. const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
  43. if (d.getUTCFullYear() !== +m[1] || d.getUTCMonth() !== +m[2] - 1 || d.getUTCDate() !== +m[3]) continue;
  44. config.semesterStartDate = m[1] + '-' + m[2].padStart(2, '0') + '-' + m[3].padStart(2, '0');
  45. break;
  46. }
  47. }
  48. return config;
  49. }
  50. async function fetchSemesterConfig(xnm, xqm, warnings) {
  51. let config = {};
  52. try { config = parseSemesterConfig(await requestXcu('kbcx/xskbcxZccx_cxZcByXnxq.html', { xnm, xqm }, 'N2154')); }
  53. catch (error) { warnings.push('校历获取失败:' + error.message); }
  54. if (!config.semesterStartDate) warnings.push('开学日期未获取,请在软件内手动设置');
  55. if (!config.semesterTotalWeeks) warnings.push('学期总周数未确认,请在软件内手动设置');
  56. return config;
  57. }
  58. // 默认值:页面变量 → URL 参数 → selected → 第一项。
  59. function getDefaultOptionIndex(options, key) {
  60. const match = value => {
  61. if (typeof value !== 'string' && typeof value !== 'number') return -1;
  62. const text = String(value).trim();
  63. if (!text) return -1;
  64. return options.findIndex(option => option.value === text);
  65. };
  66. let index = match(window[key]);
  67. if (index >= 0) return index;
  68. index = match(new URL(window.location.href).searchParams.get(key));
  69. if (index >= 0) return index;
  70. index = options.findIndex(option => option.selected);
  71. return index >= 0 ? index : 0;
  72. }
  73. function assertNotLoginPage(text) {
  74. if (/login_slogin|用户登录|统一身份认证/i.test(text)) {
  75. throw new Error('登录已过期,请重新登录教务系统后再导入');
  76. }
  77. }
  78. // 学年学期从请求结果读取,不读当前页面的表单。
  79. function parseAcademicOptions(doc) {
  80. const read = selector => Array.from(doc.querySelectorAll(selector))
  81. .filter(option => !option.disabled && String(option.value).trim() !== '')
  82. .map(option => ({ value: String(option.value).trim(), text: option.textContent.trim(), selected: option.selected }));
  83. const allYears = read('#xnm option');
  84. const semesterOptions = read('#xqm option');
  85. if (!allYears.length || !semesterOptions.length) {
  86. // 正常教务页也可能带登录链接。
  87. const html = doc.documentElement ? doc.documentElement.outerHTML : '';
  88. assertNotLoginPage(html || (doc.body ? doc.body.textContent : '') || '');
  89. throw new Error('未获取到学年学期选项,请确认已登录教务系统后重试');
  90. }
  91. const selectedYear = getDefaultOptionIndex(allYears, 'xnm');
  92. const start = Math.max(0, selectedYear - 2);
  93. const yearOptions = allYears.slice(start, selectedYear + 3);
  94. const selectedSemester = getDefaultOptionIndex(semesterOptions, 'xqm');
  95. return { yearOptions, semesterOptions, defaultYearIndex: selectedYear - start, defaultSemesterIndex: selectedSemester };
  96. }
  97. async function fetchAcademicOptions() {
  98. const response = await fetch(getBaseUrl() + 'kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N2151&layout=default', {
  99. method: 'GET', credentials: 'same-origin'
  100. });
  101. if (!response.ok) throw new Error('获取学年学期失败(HTTP ' + response.status + ')');
  102. const doc = new DOMParser().parseFromString(await response.text(), 'text/html');
  103. return parseAcademicOptions(doc);
  104. }
  105. async function selectAcademicYearAndSemester(bridge) {
  106. const options = await fetchAcademicOptions();
  107. const select = async (title, entries, defaultIndex) => {
  108. const result = await bridge.showSingleSelection(title, JSON.stringify(entries.map(item => item.text)), defaultIndex);
  109. if (result == null) return null;
  110. const index = String(result).trim();
  111. if (!/^\d+$/.test(index)) return null;
  112. return entries[+index] || null;
  113. };
  114. const makeSelection = (year, semester) => ({ xnm: year.value, xqm: semester.value, label: year.text + ' / ' + semester.text });
  115. const defaultSelection = makeSelection(options.yearOptions[options.defaultYearIndex], options.semesterOptions[options.defaultSemesterIndex]);
  116. const action = await select('确认导入学期', [
  117. { value: 'default', text: '导入:' + defaultSelection.label },
  118. { value: 'change', text: '更换学年、学期' }
  119. ], 0);
  120. if (!action) return null;
  121. if (action.value === 'default') return defaultSelection;
  122. const year = await select('选择学年', options.yearOptions, options.defaultYearIndex);
  123. if (!year) return null;
  124. const semester = await select('选择学期', options.semesterOptions, options.defaultSemesterIndex);
  125. if (!semester) return null;
  126. return makeSelection(year, semester);
  127. }
  128. // 许昌学院已实测:此接口无需 xqh_id。
  129. async function fetchTimeSlots(xnm, xqm) {
  130. let data;
  131. try {
  132. data = await requestXcu('jzgl/skxxMobile_cxRsdjc.html', { xnm, xqm }, 'N2154');
  133. } catch (error) {
  134. const serverError = String(error.message || error).match(/HTTP 5\d\d\b/);
  135. if (serverError) throw new Error('教务作息接口异常(' + serverError[0] + '),请稍后重试或联系维护者');
  136. throw new Error('获取教务作息失败:' + error.message + '。请检查登录状态,或将该作息请求的响应提供给维护者核对');
  137. }
  138. try { return parseTimeSlots(data); }
  139. catch (error) { throw new Error('作息数据无法解析:' + error.message + '。本次尚未保存,请将此提示或作息接口响应提供给维护者核对'); }
  140. }
  141. // 先按周合并节次,再合并节次相同的周次。
  142. function mergeAndDistinctCourses(courses) {
  143. if (!Array.isArray(courses)) return courses;
  144. if (courses.length <= 1) return courses.map(c => ({ ...c, weeks: [...(c.weeks || [])] }));
  145. const groups = new Map();
  146. for (const course of courses) {
  147. const key = JSON.stringify([course.name, course.teacher, course.position, course.day]);
  148. if (!groups.has(key)) groups.set(key, { course, byWeek: new Map() });
  149. const group = groups.get(key);
  150. for (const week of new Set(course.weeks)) {
  151. if (!group.byWeek.has(week)) group.byWeek.set(week, []);
  152. group.byWeek.get(week).push({ start: course.startSection, end: course.endSection });
  153. }
  154. }
  155. const result = [];
  156. for (const group of groups.values()) {
  157. const bySection = new Map();
  158. for (const [week, intervals] of group.byWeek) {
  159. intervals.sort((a, b) => a.start - b.start || a.end - b.end);
  160. const merged = [];
  161. for (const interval of intervals) {
  162. const previous = merged[merged.length - 1];
  163. if (previous && interval.start <= previous.end + 1) previous.end = Math.max(previous.end, interval.end);
  164. else merged.push({ ...interval });
  165. }
  166. for (const interval of merged) {
  167. const key = interval.start + '-' + interval.end;
  168. if (!bySection.has(key)) bySection.set(key, { ...group.course, startSection: interval.start, endSection: interval.end, weeks: [] });
  169. bySection.get(key).weeks.push(week);
  170. }
  171. }
  172. for (const course of bySection.values()) {
  173. course.weeks.sort((a, b) => a - b);
  174. result.push(course);
  175. }
  176. }
  177. return result;
  178. }
  179. function parseWeeks(value) {
  180. const weeks = new Set();
  181. const text = normalizeText(value).replace(/周/g, '').replace(/、/g, ',');
  182. for (const part of text.split(',')) {
  183. const match = part.trim().match(/^(\d+)\s*(?:-\s*(\d+))?\s*(?:\(([单双])\))?$/);
  184. if (!match) throw new Error('无法识别周次:' + value);
  185. const start = +match[1], end = +(match[2] || match[1]);
  186. if (start < 1 || end < start || end > 60) throw new Error('周次范围异常:' + value);
  187. for (let week = start; week <= end; week++) {
  188. if (match[3] === '单' && week % 2 === 0) continue;
  189. if (match[3] === '双' && week % 2 === 1) continue;
  190. weeks.add(week);
  191. }
  192. }
  193. if (!weeks.size) throw new Error('课程周次为空');
  194. return Array.from(weeks).sort((a, b) => a - b);
  195. }
  196. function joinField(value) {
  197. return Array.isArray(value) ? value.join('、') : String(value || '').trim();
  198. }
  199. function parseCourses(data, warnings = []) {
  200. if (!data || !Array.isArray(data.kbList)) throw new Error('未收到课表数据,请确认登录状态及所在页面');
  201. const courses = [];
  202. for (const row of data.kbList) {
  203. try {
  204. const name = String(row.kcmc || '').trim();
  205. const section = normalizeText(row.jcs || row.jc).trim().match(/^(\d+)\s*(?:-\s*(\d+))?\s*节?$/);
  206. const day = +normalizeText(row.xqj);
  207. if (!name || !section || !/^[1-7]$/.test(normalizeText(row.xqj))) throw new Error('课程排课信息异常:' + (name || '未命名课程'));
  208. const startSection = +section[1], endSection = +(section[2] || section[1]);
  209. if (startSection < 1 || endSection < startSection || endSection > 30) throw new Error('节次异常:' + name);
  210. courses.push({ name, day, weeks: parseWeeks(row.zcd), teacher: joinField(row.xm), position: joinField(row.cdmc), startSection, endSection });
  211. } catch (error) { warnings.push('跳过课程 ' + String(row && row.kcmc || '未命名') + ':' + error.message); }
  212. }
  213. return mergeAndDistinctCourses(courses);
  214. }
  215. function parseTimeSlots(data) {
  216. if (!Array.isArray(data) || !data.length) throw new Error('未收到有效的作息列表');
  217. const byNumber = new Map();
  218. const describe = value => String(value == null ? '缺失' : value).slice(0, 60);
  219. for (let i = 0; i < data.length; i++) {
  220. const row = data[i];
  221. if (!row || typeof row !== 'object') throw new Error('第 ' + (i + 1) + ' 条作息不是有效记录');
  222. const section = normalizeText(row.jcmc).trim();
  223. const number = +section;
  224. if (!/^\d+$/.test(section) || number < 1 || number > 30) {
  225. throw new Error('第 ' + (i + 1) + ' 条作息的节次无法识别(jcmc=' + describe(row.jcmc) + ')');
  226. }
  227. let startTime, endTime;
  228. try { startTime = normalizeTime(row.qssj); endTime = normalizeTime(row.jssj); }
  229. catch (_) { throw new Error('第 ' + number + ' 节时间无法识别(qssj=' + describe(row.qssj) + ',jssj=' + describe(row.jssj) + ')'); }
  230. if (startTime >= endTime) throw new Error('第 ' + number + ' 节起止时间异常:' + startTime + '–' + endTime);
  231. const existing = byNumber.get(number);
  232. if (existing) {
  233. // 相同作息去重,时间不同则报错。
  234. if (existing.startTime === startTime && existing.endTime === endTime) continue;
  235. throw new Error('第 ' + number + ' 节返回不同时间:' + existing.startTime + '–' + existing.endTime + ' 与 ' + startTime + '–' + endTime);
  236. }
  237. byNumber.set(number, { number, startTime, endTime });
  238. }
  239. const slots = Array.from(byNumber.values()).sort((a, b) => a.number - b.number);
  240. for (let i = 1; i < slots.length; i++) {
  241. if (slots[i].startTime < slots[i - 1].endTime) throw new Error('第 ' + slots[i - 1].number + ' 节与第 ' + slots[i].number + ' 节作息时间重叠');
  242. }
  243. return slots;
  244. }
  245. async function requestXcu(path, params, moduleId = 'N2151') {
  246. const response = await fetch(getBaseUrl() + path + '?gnmkdm=' + encodeURIComponent(moduleId), {
  247. method: 'POST', credentials: 'same-origin',
  248. headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
  249. body: new URLSearchParams(params).toString()
  250. });
  251. if (!response.ok) throw new Error('教务请求失败(HTTP ' + response.status + ')');
  252. const text = await response.text();
  253. try { return JSON.parse(text); }
  254. catch (_) {
  255. // 先解析 JSON,避免课程名中的登录关键词被误判。
  256. assertNotLoginPage(text);
  257. throw new Error('教务未返回 JSON 数据,请重新登录后重试');
  258. }
  259. }
  260. async function saveWithConfirmation(bridge, method, data, label) {
  261. const result = await bridge[method](JSON.stringify(data));
  262. if (result !== true && result !== 'true') throw new Error(label + '保存未成功,请重试并核对');
  263. }
  264. async function runImportFlow() {
  265. const bridge = window.shiguangBridgePromise;
  266. let coursesSaved = false;
  267. let saveStage = '课程';
  268. try {
  269. const selection = await selectAcademicYearAndSemester(bridge);
  270. if (!selection) return;
  271. const { xnm, xqm } = selection;
  272. window.shiguangBridge.showToast('正在获取课程、校历和作息…');
  273. const warnings = [];
  274. const [data, config, slots] = await Promise.all([
  275. requestXcu('kbcx/xskbcx_cxXsgrkb.html', { xnm, xqm, kzlx: 'ck', xsdm: '', kclbdm: '', kclxdm: '' }),
  276. fetchSemesterConfig(xnm, xqm, warnings),
  277. fetchTimeSlots(xnm, xqm)
  278. ]);
  279. let courses = parseCourses(data, warnings);
  280. const numbers = new Set(slots.map(slot => slot.number));
  281. courses = courses.filter(course => {
  282. for (let n = course.startSection; n <= course.endSection; n++) {
  283. if (!numbers.has(n)) { warnings.push('跳过课程 ' + course.name + ':第 ' + n + ' 节缺少作息'); return false; }
  284. }
  285. return true;
  286. });
  287. if (!courses.length) throw new Error('没有可导入的有效课程,请核对学期。\n' + warnings.join('\n'));
  288. if (config.semesterTotalWeeks) {
  289. for (const course of courses) {
  290. if (course.weeks[course.weeks.length - 1] > config.semesterTotalWeeks) {
  291. delete config.semesterTotalWeeks;
  292. warnings.push('校历总周数小于课程周次,未保存总周数,请手动核对');
  293. break;
  294. }
  295. }
  296. }
  297. const practices = Array.isArray(data.sjkList) ? data.sjkList : [];
  298. const practiceNote = practices.length ? '\n\n以下实践课没有具体星期和节次,本次不导入,请另行核对安排:\n' + practices.map(row => String(row.kcmc || '未命名实践课') + '(' + String(row.qsjsz || '周次未定') + ')').join('\n') : '';
  299. const message = selection.label + '\n共 ' + courses.length + ' 条排课记录。教务返回的作息如下,请核对:\n' + slots.map(slot => '第' + slot.number + '节 ' + slot.startTime + '–' + slot.endTime).join('\n') + practiceNote + '\n\n开学日期:' + (config.semesterStartDate || '需手动设置') + '\n学期总周数:' + (config.semesterTotalWeeks || '需手动设置') + (warnings.length ? '\n\n注意:\n' + warnings.join('\n') : '');
  300. const confirmed = await bridge.showAlert('核对课程与作息', message, '确认并保存');
  301. if (confirmed !== true && confirmed !== 'true') return;
  302. await saveWithConfirmation(bridge, 'saveImportedCourses', courses, '课程');
  303. coursesSaved = true;
  304. saveStage = '作息';
  305. await saveWithConfirmation(bridge, 'savePresetTimeSlots', slots, '作息');
  306. saveStage = '学期配置';
  307. await saveWithConfirmation(bridge, 'saveCourseConfig', config, '学期配置');
  308. window.shiguangBridge.showToast('已导入 ' + courses.length + ' 条排课记录及作息,请核对学期配置');
  309. window.shiguangBridge.notifyTaskCompletion();
  310. } catch (error) {
  311. await bridge.showAlert('导入未完成', (coursesSaved ? '课程已保存,但' + saveStage + '未保存成功,请重试并核对。\n' : '') + error.message, '确定');
  312. }
  313. }
  314. runImportFlow();