qhnu_01.js 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686
  1. // 青海师范大学研究生教务系统拾光课表适配脚本
  2. // 使用研究生教务系统的同源学期与课表接口,不处理账号密码或统一身份认证。
  3. (function () {
  4. "use strict";
  5. const EXPECTED_ORIGIN = "https://yjsxt.qhnu.edu.cn";
  6. const SEMESTER_PATH = "/adminApi/kernel/config/xqgl/NoAuth";
  7. const SCHEDULE_PATH = "/adminApi/cloud-jx/api/xs/stuxkjg/NotAuth";
  8. // 来自学校公开前端配置。只读取这一项,不枚举或记录浏览器存储。
  9. const TOKEN_STORAGE_KEY = "sys-auth-token";
  10. const COMMON_TIME_SLOTS = {
  11. 1: { start: "08:30", end: "09:15" },
  12. 2: { start: "09:25", end: "10:10" },
  13. 3: { start: "10:30", end: "11:15" },
  14. 4: { start: "11:25", end: "12:10" }
  15. };
  16. const CAMPUS_TIME_SLOTS = {
  17. chengbei: Object.assign({}, COMMON_TIME_SLOTS, {
  18. 5: { start: "14:00", end: "14:45" },
  19. 6: { start: "14:55", end: "15:40" },
  20. 7: { start: "15:50", end: "16:35" },
  21. 8: { start: "16:45", end: "17:30" },
  22. 9: { start: "19:30", end: "20:15" },
  23. 10: { start: "20:25", end: "21:10" }
  24. }),
  25. chengxi: Object.assign({}, COMMON_TIME_SLOTS, {
  26. 5: { start: "14:30", end: "15:15" },
  27. 6: { start: "15:25", end: "16:10" },
  28. 7: { start: "16:30", end: "17:15" },
  29. 8: { start: "17:25", end: "18:10" },
  30. 9: { start: "19:10", end: "19:55" }
  31. })
  32. };
  33. const CHENGBEI_PRESET_TIME_SLOTS = Object.keys(CAMPUS_TIME_SLOTS.chengbei).map(function (number) {
  34. const sectionNumber = Number(number);
  35. const slot = CAMPUS_TIME_SLOTS.chengbei[sectionNumber];
  36. return {
  37. number: sectionNumber,
  38. startTime: slot.start,
  39. endTime: slot.end
  40. };
  41. }).sort(function (left, right) {
  42. return left.number - right.number;
  43. });
  44. const WEEKDAY_MAP = {
  45. "一": 1,
  46. "二": 2,
  47. "三": 3,
  48. "四": 4,
  49. "五": 5,
  50. "六": 6,
  51. "日": 7,
  52. "天": 7
  53. };
  54. function showToast(message) {
  55. if (window.shiguangBridge && typeof window.shiguangBridge.showToast === "function") {
  56. window.shiguangBridge.showToast(message);
  57. }
  58. }
  59. async function showAlert(title, message, buttonText) {
  60. if (!window.shiguangBridgePromise || typeof window.shiguangBridgePromise.showAlert !== "function") {
  61. throw new Error("当前环境不支持拾光课表导入弹窗,请在拾光课程表中重试。");
  62. }
  63. return await window.shiguangBridgePromise.showAlert(title, message, buttonText || "确定");
  64. }
  65. function assertSystemOrigin() {
  66. if (window.location.origin !== EXPECTED_ORIGIN) {
  67. throw new Error("请先进入青海师范大学研究生教务系统后再导入。");
  68. }
  69. }
  70. function getRuntimeToken() {
  71. let token = null;
  72. try {
  73. token = window.localStorage.getItem(TOKEN_STORAGE_KEY);
  74. } catch (error) {
  75. throw new Error("无法读取登录状态,请重新登录研究生教务系统后重试。");
  76. }
  77. if (typeof token !== "string" || token.trim() === "") {
  78. throw new Error("未检测到有效登录状态,请重新登录研究生教务系统后重试。");
  79. }
  80. return token.trim();
  81. }
  82. async function requestJson(requestUrl, runtimeToken, resourceLabel) {
  83. let response;
  84. try {
  85. response = await fetch(requestUrl.toString(), {
  86. method: "GET",
  87. credentials: "include",
  88. headers: {
  89. "Accept": "application/json",
  90. "Authorization": "Bearer " + runtimeToken
  91. }
  92. });
  93. } catch (error) {
  94. throw new Error(resourceLabel + "请求未能发送,请检查网络后重试。");
  95. }
  96. if (response.status === 401 || response.status === 403) {
  97. throw new Error("登录状态已失效或无" + resourceLabel + "访问权限,请重新登录研究生教务系统后重试。");
  98. }
  99. if (!response.ok) {
  100. throw new Error(resourceLabel + "请求失败(HTTP " + response.status + "),请稍后重试。");
  101. }
  102. const responseText = await response.text();
  103. if (/^\s*</.test(responseText)) {
  104. throw new Error(resourceLabel + "接口返回了登录页面,请重新登录后重试。");
  105. }
  106. try {
  107. return JSON.parse(responseText);
  108. } catch (error) {
  109. throw new Error(resourceLabel + "接口未返回有效 JSON,学校系统接口可能已调整。");
  110. }
  111. }
  112. async function fetchAllSemesterOptions(runtimeToken) {
  113. return await fetchAllPages(
  114. async function (page) {
  115. const requestUrl = new URL(SEMESTER_PATH, EXPECTED_ORIGIN);
  116. requestUrl.searchParams.set("page", String(page));
  117. requestUrl.searchParams.set("size", "999");
  118. requestUrl.searchParams.set("sort", "xqdm,desc");
  119. const payload = await requestJson(requestUrl, runtimeToken, "学期列表");
  120. const pageResult = validatePageResponse(payload, "学期列表");
  121. return {
  122. totalElements: pageResult.totalElements,
  123. content: pageResult.content.map(function (semester, index) {
  124. if (!semester || typeof semester !== "object" || Array.isArray(semester)) {
  125. throw new Error("学期列表第 " + (index + 1) + " 项结构异常。");
  126. }
  127. return {
  128. id: requireText(semester.id, "学期列表第 " + (index + 1) + " 项 id"),
  129. xqmc: requireText(semester.xqmc, "学期列表第 " + (index + 1) + " 项名称"),
  130. sfdq: semester.sfdq === null || semester.sfdq === undefined ? "" : String(semester.sfdq).trim()
  131. };
  132. })
  133. };
  134. },
  135. function (semester) { return semester.id; },
  136. {
  137. empty: "学期列表为空,无法继续导入。",
  138. totalChanged: "学期列表分页总数发生变化,请稍后重试。",
  139. emptyPage: "学期列表在读取完成前返回空页,请稍后重试。",
  140. duplicate: "学期列表出现重复 id,请稍后重试。",
  141. overflow: "学期列表记录数超过接口声明总数,请稍后重试。",
  142. incomplete: "学期列表未完整返回,请稍后重试。"
  143. }
  144. );
  145. }
  146. async function selectSemester(runtimeToken) {
  147. const semesters = await fetchAllSemesterOptions(runtimeToken);
  148. const currentSemester = semesters.find(function (semester) {
  149. return String(semester.sfdq) === "0";
  150. });
  151. const seasonOrder = { "秋": 0, "夏": 1, "春": 2 };
  152. const semesterEntries = semesters.map(function (semester, originalIndex) {
  153. const match = /^(\d{4})年(春|夏|秋)季学期$/.exec(semester.xqmc);
  154. return {
  155. semester: semester,
  156. originalIndex: originalIndex,
  157. year: match ? Number(match[1]) : null,
  158. season: match ? match[2] : null
  159. };
  160. }).sort(function (left, right) {
  161. if (left.year !== null && right.year === null) return -1;
  162. if (left.year === null && right.year !== null) return 1;
  163. if (left.year !== null && right.year !== null) {
  164. if (left.year !== right.year) return right.year - left.year;
  165. const seasonDifference = seasonOrder[left.season] - seasonOrder[right.season];
  166. if (seasonDifference !== 0) return seasonDifference;
  167. }
  168. return left.originalIndex - right.originalIndex;
  169. });
  170. const defaultIndex = currentSemester ? semesterEntries.findIndex(function (entry) {
  171. return entry.semester === currentSemester;
  172. }) : 0;
  173. const selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
  174. "请选择导入学期",
  175. JSON.stringify(semesterEntries.map(function (entry) {
  176. return entry.semester === currentSemester ?
  177. entry.semester.xqmc + "(当前)" : entry.semester.xqmc;
  178. })),
  179. defaultIndex
  180. );
  181. if (selectedIndex === null || selectedIndex === undefined || selectedIndex === -1) return null;
  182. if (!Number.isInteger(selectedIndex) || selectedIndex < 0 || selectedIndex >= semesterEntries.length) {
  183. throw new Error("无法识别所选学期,请重新导入。");
  184. }
  185. return semesterEntries[selectedIndex].semester;
  186. }
  187. async function fetchAllScheduleRecords(semesterCode, runtimeToken) {
  188. return await fetchAllPages(
  189. async function (page) {
  190. const requestUrl = new URL(SCHEDULE_PATH, EXPECTED_ORIGIN);
  191. requestUrl.searchParams.set("page", String(page));
  192. requestUrl.searchParams.set("size", "999");
  193. requestUrl.searchParams.set("sort", "createTime,desc");
  194. requestUrl.searchParams.set("xqcode", semesterCode);
  195. const payload = await requestJson(requestUrl, runtimeToken, "课表");
  196. return validatePageResponse(payload, "课表");
  197. },
  198. function (record) { return JSON.stringify(record); },
  199. {
  200. empty: "所选学期暂无课程,未保存空课表。",
  201. totalChanged: "课表分页总数发生变化,请稍后重新导入。",
  202. emptyPage: "课表分页在读取完成前返回空页,请稍后重新导入。",
  203. duplicate: "课表分页未继续前进,请稍后重新导入。",
  204. overflow: "课表分页记录数超过接口声明总数,请稍后重新导入。",
  205. incomplete: "课表记录未完整返回,请稍后重新导入。"
  206. }
  207. );
  208. }
  209. async function fetchAllPages(fetchPage, getSignature, messages) {
  210. const records = [];
  211. const seenSignatures = new Set();
  212. let expectedTotal = null;
  213. let page = 0;
  214. while (expectedTotal === null || records.length < expectedTotal) {
  215. const pageResult = await fetchPage(page);
  216. if (expectedTotal === null) {
  217. expectedTotal = pageResult.totalElements;
  218. if (expectedTotal === 0 && pageResult.content.length === 0) {
  219. throw new Error(messages.empty);
  220. }
  221. } else if (pageResult.totalElements !== expectedTotal) {
  222. throw new Error(messages.totalChanged);
  223. }
  224. if (pageResult.content.length === 0) {
  225. throw new Error(messages.emptyPage);
  226. }
  227. pageResult.content.forEach(function (record) {
  228. const signature = getSignature(record);
  229. if (seenSignatures.has(signature)) {
  230. throw new Error(messages.duplicate);
  231. }
  232. seenSignatures.add(signature);
  233. records.push(record);
  234. });
  235. if (records.length > expectedTotal) {
  236. throw new Error(messages.overflow);
  237. }
  238. page += 1;
  239. }
  240. if (records.length !== expectedTotal) {
  241. throw new Error(messages.incomplete);
  242. }
  243. return records;
  244. }
  245. function validatePageResponse(payload, resourceLabel) {
  246. const label = resourceLabel || "课表";
  247. if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
  248. throw new Error(label + "接口返回结构异常:响应根节点不是对象。");
  249. }
  250. if (!Array.isArray(payload.content)) {
  251. throw new Error(label + "接口返回结构异常:缺少 content 数组。");
  252. }
  253. const totalElements = payload.totalElements;
  254. if (!Number.isInteger(totalElements) || totalElements < 0) {
  255. throw new Error(label + "接口返回结构异常:totalElements 无效。");
  256. }
  257. return {
  258. content: payload.content,
  259. totalElements: totalElements
  260. };
  261. }
  262. function toPositiveInteger(value, fieldLabel) {
  263. const number = Number(value);
  264. if (!Number.isInteger(number) || number <= 0) {
  265. throw new Error(fieldLabel + "不是有效正整数。");
  266. }
  267. return number;
  268. }
  269. function uniqueSortedPositiveIntegers(values, fieldLabel) {
  270. const result = Array.from(new Set(values.map(function (value) {
  271. return toPositiveInteger(value, fieldLabel);
  272. }))).sort(function (left, right) {
  273. return left - right;
  274. });
  275. if (result.length === 0) {
  276. throw new Error(fieldLabel + "为空。");
  277. }
  278. return result;
  279. }
  280. function parseExplicitWeeks(value, fieldLabel) {
  281. const normalized = normalizeDigits(value === null || value === undefined ? "" : value);
  282. const matches = normalized.match(/\d+/g);
  283. if (!matches) {
  284. throw new Error(fieldLabel + "无法解析。");
  285. }
  286. return uniqueSortedPositiveIntegers(matches, fieldLabel);
  287. }
  288. function parseWeeks(schedule, courseLabel) {
  289. const mode = String(schedule.lxfs);
  290. const fieldLabel = "课程“" + courseLabel + "”的周次";
  291. if (mode === "3" || mode === "4") {
  292. return parseExplicitWeeks(schedule.skzc_Dm, fieldLabel);
  293. }
  294. if (mode !== "0" && mode !== "1" && mode !== "2") {
  295. throw new Error(fieldLabel + "模式无法识别。");
  296. }
  297. const startWeek = toPositiveInteger(schedule.kszc, fieldLabel + "起始值");
  298. const endWeek = toPositiveInteger(schedule.jszc, fieldLabel + "结束值");
  299. if (endWeek < startWeek) {
  300. throw new Error(fieldLabel + "起止范围无效。");
  301. }
  302. const weeks = [];
  303. for (let week = startWeek; week <= endWeek; week += 1) {
  304. if (mode === "1" && week % 2 === 0) continue;
  305. if (mode === "2" && week % 2 !== 0) continue;
  306. weeks.push(week);
  307. }
  308. return uniqueSortedPositiveIntegers(weeks, fieldLabel);
  309. }
  310. function normalizeDigits(value) {
  311. return String(value).replace(/[0-9]/g, function (digit) {
  312. return String.fromCharCode(digit.charCodeAt(0) - 65248);
  313. });
  314. }
  315. function expandSectionExpression(expression, courseLabel) {
  316. const normalized = normalizeDigits(expression).replace(/\s+/g, "");
  317. const pieces = normalized.split(/[,,、]/).filter(Boolean);
  318. const sections = [];
  319. pieces.forEach(function (piece) {
  320. const range = piece.match(/^(\d+)[\-~~至](\d+)$/);
  321. if (range) {
  322. const start = toPositiveInteger(range[1], "课程“" + courseLabel + "”的节次");
  323. const end = toPositiveInteger(range[2], "课程“" + courseLabel + "”的节次");
  324. if (end < start) {
  325. throw new Error("课程“" + courseLabel + "”的节次范围无效。");
  326. }
  327. for (let section = start; section <= end; section += 1) sections.push(section);
  328. return;
  329. }
  330. if (!/^\d+$/.test(piece)) {
  331. throw new Error("课程“" + courseLabel + "”的节次无法解析。");
  332. }
  333. sections.push(toPositiveInteger(piece, "课程“" + courseLabel + "”的节次"));
  334. });
  335. return uniqueSortedPositiveIntegers(sections, "课程“" + courseLabel + "”的节次");
  336. }
  337. function groupContiguousSections(sections) {
  338. const groups = [];
  339. sections.forEach(function (section) {
  340. const last = groups[groups.length - 1];
  341. if (last && section === last.end + 1) {
  342. last.end = section;
  343. } else {
  344. groups.push({ start: section, end: section });
  345. }
  346. });
  347. return groups;
  348. }
  349. function parseScheduleFragments(value, courseLabel) {
  350. const text = normalizeDigits(value || "");
  351. const pattern = /星期([一二三四五六日天])[^0-9星期]*([0-9]+(?:\s*(?:[,,、\-~~至])\s*[0-9]+)*)\s*节/g;
  352. const weekdayOccurrences = text.match(/星期[一二三四五六日天]/g) || [];
  353. const fragments = [];
  354. let matchedOccurrences = 0;
  355. let match;
  356. while ((match = pattern.exec(text)) !== null) {
  357. matchedOccurrences += 1;
  358. const day = WEEKDAY_MAP[match[1]];
  359. const groups = groupContiguousSections(expandSectionExpression(match[2], courseLabel));
  360. groups.forEach(function (group) {
  361. fragments.push({ day: day, startSection: group.start, endSection: group.end });
  362. });
  363. }
  364. if (fragments.length === 0 || matchedOccurrences !== weekdayOccurrences.length) {
  365. throw new Error("课程“" + courseLabel + "”的星期或节次无法从 sjms 解析。");
  366. }
  367. return fragments;
  368. }
  369. function detectCampus(position) {
  370. const hasChengbei = position.indexOf("城北校区") >= 0;
  371. const hasChengxi = position.indexOf("城西校区") >= 0;
  372. if (hasChengbei && hasChengxi) return "ambiguous";
  373. if (hasChengbei) return "chengbei";
  374. if (hasChengxi) return "chengxi";
  375. return "unknown";
  376. }
  377. function applyCustomTime(position, startSection, endSection, courseLabel) {
  378. const campus = detectCampus(position);
  379. let slots;
  380. if (campus === "ambiguous") {
  381. throw new Error("课程“" + courseLabel + "”的地点同时包含两个校区,无法确定作息。");
  382. }
  383. if (campus === "unknown") {
  384. if (startSection > 4 || endSection > 4) {
  385. throw new Error("课程“" + courseLabel + "”第 5 节以后未标明校区,无法确定实际时间。");
  386. }
  387. slots = COMMON_TIME_SLOTS;
  388. } else {
  389. slots = CAMPUS_TIME_SLOTS[campus];
  390. }
  391. const startSlot = slots[startSection];
  392. const endSlot = slots[endSection];
  393. if (!startSlot || !endSlot) {
  394. if (campus === "chengxi" && (startSection === 10 || endSection === 10)) {
  395. throw new Error("课程“" + courseLabel + "”使用城西校区第 10 节,但该节时间尚未确认。");
  396. }
  397. throw new Error("课程“" + courseLabel + "”包含未收录的节次时间。");
  398. }
  399. return {
  400. isCustomTime: true,
  401. customStartTime: startSlot.start,
  402. customEndTime: endSlot.end
  403. };
  404. }
  405. function requireText(value, fieldLabel) {
  406. if (typeof value !== "string" || value.trim() === "") {
  407. throw new Error(fieldLabel + "为空。");
  408. }
  409. return value.trim();
  410. }
  411. function parseCourses(records) {
  412. const courses = [];
  413. records.forEach(function (record, recordIndex) {
  414. if (!record || typeof record !== "object" || Array.isArray(record)) {
  415. throw new Error("第 " + (recordIndex + 1) + " 条课程记录结构异常。");
  416. }
  417. const fallbackName = record.pkxxwh && record.pkxxwh.kcMc;
  418. const primaryName = typeof record.kcmc === "string" ? record.kcmc.trim() : record.kcmc;
  419. const name = requireText(primaryName || fallbackName, "第 " + (recordIndex + 1) + " 条课程名称");
  420. if (!record.pkxxwh || !Array.isArray(record.pkxxwh.jss) || record.pkxxwh.jss.length === 0) {
  421. throw new Error("课程“" + name + "”缺少授课安排。");
  422. }
  423. record.pkxxwh.jss.forEach(function (schedule) {
  424. if (!schedule || typeof schedule !== "object" || Array.isArray(schedule)) {
  425. throw new Error("课程“" + name + "”的授课安排结构异常。");
  426. }
  427. const teacher = requireText(schedule.js_Xm, "课程“" + name + "”的教师");
  428. const position = requireText(schedule.jsmc, "课程“" + name + "”的地点");
  429. const weeks = parseWeeks(schedule, name);
  430. const fragments = parseScheduleFragments(schedule.sjms, name);
  431. fragments.forEach(function (fragment) {
  432. const customTime = applyCustomTime(position, fragment.startSection, fragment.endSection, name);
  433. courses.push({
  434. name: name,
  435. teacher: teacher,
  436. position: position,
  437. day: fragment.day,
  438. startSection: fragment.startSection,
  439. endSection: fragment.endSection,
  440. weeks: weeks.slice(),
  441. isCustomTime: customTime.isCustomTime,
  442. customStartTime: customTime.customStartTime,
  443. customEndTime: customTime.customEndTime
  444. });
  445. });
  446. });
  447. });
  448. return dedupeAndSortCourses(courses);
  449. }
  450. function dedupeAndSortCourses(courses) {
  451. const seen = new Set();
  452. const result = [];
  453. courses.forEach(function (course) {
  454. const key = JSON.stringify([
  455. course.name, course.teacher, course.position, course.day,
  456. course.startSection, course.endSection, course.weeks,
  457. course.customStartTime, course.customEndTime
  458. ]);
  459. if (!seen.has(key)) {
  460. seen.add(key);
  461. result.push(course);
  462. }
  463. });
  464. return result.sort(function (left, right) {
  465. return left.day - right.day ||
  466. left.startSection - right.startSection ||
  467. left.endSection - right.endSection ||
  468. left.name.localeCompare(right.name, "zh-CN") ||
  469. left.teacher.localeCompare(right.teacher, "zh-CN") ||
  470. left.position.localeCompare(right.position, "zh-CN");
  471. });
  472. }
  473. function validateDateString(value) {
  474. if (typeof value !== "string") return null;
  475. const match = value.trim().match(/^(\d{4})-(\d{2})-(\d{2})/);
  476. if (!match) return null;
  477. const year = Number(match[1]);
  478. const month = Number(match[2]);
  479. const day = Number(match[3]);
  480. const date = new Date(Date.UTC(year, month - 1, day));
  481. if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
  482. return null;
  483. }
  484. return match[1] + "-" + match[2] + "-" + match[3];
  485. }
  486. function getConsistentSemester(records) {
  487. const candidates = records.map(function (record) {
  488. return record && record.xqEntity;
  489. }).filter(function (semester) {
  490. return semester && typeof semester === "object" && !Array.isArray(semester);
  491. });
  492. if (candidates.length === 0) return {};
  493. const result = {};
  494. ["ksrq", "jsrq", "xqmc"].forEach(function (field) {
  495. const values = candidates.map(function (candidate) {
  496. return candidate[field] === null || candidate[field] === undefined ? "" : String(candidate[field]).trim();
  497. }).filter(Boolean);
  498. const expected = values[0] || "";
  499. candidates.forEach(function (candidate) {
  500. const actual = candidate[field] === null || candidate[field] === undefined ? "" : String(candidate[field]).trim();
  501. if (expected && actual && expected !== actual) {
  502. throw new Error("课表记录中的学期信息不一致,请稍后重新导入。");
  503. }
  504. });
  505. if (expected) result[field] = expected;
  506. });
  507. return result;
  508. }
  509. function buildCourseConfig(records, courses, selectedSemesterName) {
  510. const semester = getConsistentSemester(records);
  511. const startDate = validateDateString(semester.ksrq);
  512. const endDate = validateDateString(semester.jsrq);
  513. const maxCourseWeek = courses.reduce(function (maximum, course) {
  514. return Math.max(maximum, course.weeks[course.weeks.length - 1] || 0);
  515. }, 0);
  516. let dateRangeWeeks = 0;
  517. if (startDate && endDate) {
  518. const startTime = Date.parse(startDate + "T00:00:00Z");
  519. const endTime = Date.parse(endDate + "T00:00:00Z");
  520. if (endTime >= startTime) {
  521. dateRangeWeeks = Math.ceil((endTime - startTime + 86400000) / 604800000);
  522. }
  523. }
  524. const totalWeeks = Math.max(maxCourseWeek, dateRangeWeeks);
  525. if (totalWeeks <= 0) {
  526. throw new Error("无法确定学期总周数。");
  527. }
  528. const config = {
  529. semesterTotalWeeks: totalWeeks,
  530. firstDayOfWeek: 1,
  531. defaultClassDuration: 45,
  532. defaultBreakDuration: 10
  533. };
  534. if (startDate) config.semesterStartDate = startDate;
  535. return {
  536. config: config,
  537. semesterName: typeof semester.xqmc === "string" && semester.xqmc.trim() ? semester.xqmc.trim() :
  538. (selectedSemesterName || "当前查询学期")
  539. };
  540. }
  541. function validateCourses(courses) {
  542. if (!Array.isArray(courses) || courses.length === 0) {
  543. throw new Error("没有解析到可导入的课程时段。");
  544. }
  545. courses.forEach(function (course) {
  546. if (!Number.isInteger(course.day) || course.day < 1 || course.day > 7 ||
  547. !Number.isInteger(course.startSection) || !Number.isInteger(course.endSection) ||
  548. course.startSection <= 0 || course.endSection < course.startSection ||
  549. !Array.isArray(course.weeks) || course.weeks.length === 0 ||
  550. course.isCustomTime !== true ||
  551. !/^\d{2}:\d{2}$/.test(course.customStartTime) ||
  552. !/^\d{2}:\d{2}$/.test(course.customEndTime)) {
  553. throw new Error("课程“" + course.name + "”的导入字段校验失败。");
  554. }
  555. });
  556. }
  557. function buildCampusSummary(courses) {
  558. const campusLabels = new Set();
  559. courses.forEach(function (course) {
  560. const campus = detectCampus(course.position);
  561. if (campus === "chengbei") campusLabels.add("城北校区");
  562. if (campus === "chengxi") campusLabels.add("城西校区");
  563. if (campus === "unknown") campusLabels.add("未标明校区(仅共同时间节次)");
  564. });
  565. return Array.from(campusLabels).join("、");
  566. }
  567. function assertBridgeCapabilities() {
  568. const promiseBridge = window.shiguangBridgePromise;
  569. const bridge = window.shiguangBridge;
  570. if (!promiseBridge || typeof promiseBridge.showAlert !== "function" ||
  571. typeof promiseBridge.showSingleSelection !== "function" ||
  572. typeof promiseBridge.saveCourseConfig !== "function" ||
  573. typeof promiseBridge.savePresetTimeSlots !== "function" ||
  574. typeof promiseBridge.saveImportedCourses !== "function" ||
  575. !bridge || typeof bridge.notifyTaskCompletion !== "function") {
  576. throw new Error("当前拾光课表版本不支持所需导入接口,请更新应用后重试。");
  577. }
  578. }
  579. async function saveToApp(config, courses) {
  580. const configResult = await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  581. if (configResult !== true) {
  582. throw new Error("课表配置保存失败,课程尚未保存。");
  583. }
  584. const presetResult = await window.shiguangBridgePromise.savePresetTimeSlots(
  585. JSON.stringify(CHENGBEI_PRESET_TIME_SLOTS)
  586. );
  587. if (presetResult !== true) {
  588. throw new Error("城北基准时间轴保存失败;课表配置可能已更新,课程尚未保存。");
  589. }
  590. const courseResult = await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  591. if (courseResult !== true) {
  592. throw new Error("课程保存失败;课表配置和城北基准时间轴可能已更新,请检查应用内课表后重试。");
  593. }
  594. }
  595. async function runImportFlow() {
  596. try {
  597. assertSystemOrigin();
  598. assertBridgeCapabilities();
  599. const runtimeToken = getRuntimeToken();
  600. const selectedSemester = await selectSemester(runtimeToken);
  601. if (!selectedSemester) return;
  602. showToast("正在读取青海师范大学研究生课表...");
  603. const records = await fetchAllScheduleRecords(selectedSemester.id, runtimeToken);
  604. const courses = parseCourses(records);
  605. validateCourses(courses);
  606. const semesterResult = buildCourseConfig(records, courses, selectedSemester.xqmc);
  607. const shouldSave = await showAlert(
  608. "确认导入课程表",
  609. "目标学期:" + semesterResult.semesterName + "\n" +
  610. "课程时段:" + courses.length + " 个\n" +
  611. "涉及校区:" + buildCampusSummary(courses) + "\n\n" +
  612. "节次网格以城北作息为基准,城西课程仍按实际时间显示。\n" +
  613. "确认后将保存课表配置、基准时间轴和课程。",
  614. "确认导入"
  615. );
  616. if (!shouldSave) return;
  617. await saveToApp(semesterResult.config, courses);
  618. showToast("导入成功,共保存 " + courses.length + " 个课程时段。");
  619. window.shiguangBridge.notifyTaskCompletion();
  620. } catch (error) {
  621. const message = error && error.message ? error.message : "未知错误";
  622. showToast("导入失败:" + message);
  623. try {
  624. await showAlert("导入失败", message, "我知道了");
  625. } catch (alertError) {
  626. // 没有可用弹窗时仅保留 Toast;不输出可能携带外部数据的错误对象。
  627. }
  628. }
  629. }
  630. runImportFlow();
  631. })();