ujs_zhengfang_v9.0.js 39 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016
  1. // 江苏大学(ujs.edu.cn) 拾光课程表适配脚本
  2. // 基于正方教务系统接口适配
  3. // 出现问题请联系作者或者提交直接pr更改,这更加快速
  4. // 基于GSMC修改
  5. // 作者:洛初 Github@gongfuture
  6. // 2026.03.30 第一版
  7. // 通过正方接口 xskbcx_cxXsgrkb 拉取个人课表,解析课程名、教师、教室、星期、节次和周次(含单双周)。
  8. // 交互上依次询问学年、学期与作息类型(夏令时/冬令时/智能选择,智能选择按固定切令时日期判定)。
  9. // 按课程所在楼栋匹配上午与下午作息,写入课程的自定义时间;导入课程、课表配置与预设时间段。
  10. // 2026.08.23 第二版
  11. // 桥接接口升级至 v2(window.shiguangBridge / window.shiguangBridgePromise)。
  12. // 补充开学日期:从首页日历区块取当前学期起止日期写入 semesterStartDate,总周数改为按实际周次取值;
  13. // 取不到开学日期时跳过配置保存,避免覆盖用户已有设置。
  14. // 课程备注补充重修标记、选课备注(体育项目、微专业等)与周次原文。
  15. // 集中实践课(军训、毕业设计等)无星期节次无法排课,改为弹窗提示手动添加。
  16. // 教师与教室为空不再丢弃课程;修正京江各号楼的楼栋匹配;支持校外 WebVPN 访问。
  17. // 2026.09.07 第三版
  18. // 开学日期改从校历接口 xskbcxZccx_cxZcByXnxq 按所选学年学期获取,历史学期同样能取到,
  19. // 总周数取校历周数,日期解析兼容 zcrq / ksrq 字段兜底;取不到校历时仍跳过配置保存。
  20. // 作息时间不随切令时自动变化,导入后弹窗提示下次切令时日期。
  21. // 新增课程合并去重:连续节次合并、同课同节次的周次合并、完全重复去重。
  22. // 学年学期改为从教务系统读取选项并默认选中当前学期,读取失败时回退手动输入。
  23. // 课程请求与校历请求并行执行,课程解析与配置计算整合进同一函数,结构对齐官方参考脚本。
  24. // 修复:教务页面 zftal 脚本会改写 Array.prototype.filter(回调实参颠倒),
  25. // 导致集中实践课被静默丢弃,改用自实现的 arrayFilter 规避;清理注释残留与冗余日志。
  26. /**
  27. * 数组过滤(原生实现)。
  28. * 教务页面加载的 zftal 脚本会改写 Array.prototype.filter/some/every,
  29. * 把回调实参换成 (index, value)(原生的 value 在前),
  30. * 原生 filter 写法在这种页面上会得到错误结果(例如过滤不掉空选项),
  31. * 所以这里用普通循环自行实现,不依赖任何被改写过的数组方法。
  32. */
  33. function arrayFilter(arr, predicate) {
  34. const result = [];
  35. for (let i = 0; i < arr.length; i++) {
  36. if (predicate(arr[i], i, arr)) {
  37. result.push(arr[i]);
  38. }
  39. }
  40. return result;
  41. }
  42. /**
  43. * 解析周次字符串,处理单双周和周次范围。
  44. */
  45. function parseWeeks(weekStr) {
  46. if (!weekStr) return [];
  47. const weekSets = weekStr.split(',');
  48. let weeks = [];
  49. for (const set of weekSets) {
  50. const trimmedSet = set.trim();
  51. const rangeMatch = trimmedSet.match(/(\d+)-(\d+)周/);
  52. const singleMatch = trimmedSet.match(/^(\d+)周/); // 匹配以数字周结束的
  53. let start = 0;
  54. let end = 0;
  55. let processed = false;
  56. if (rangeMatch) { // 范围, 如 "1-5周"
  57. start = Number(rangeMatch[1]);
  58. end = Number(rangeMatch[2]);
  59. processed = true;
  60. } else if (singleMatch) { // 单个周, 如 "6周"
  61. start = end = Number(singleMatch[1]);
  62. processed = true;
  63. }
  64. if (processed) {
  65. // 确定单双周
  66. const isSingle = trimmedSet.includes('(单)');
  67. const isDouble = trimmedSet.includes('(双)');
  68. for (let w = start; w <= end; w++) {
  69. if (isSingle && w % 2 === 0) continue; // 单周跳过偶数
  70. if (isDouble && w % 2 !== 0) continue; // 双周跳过奇数
  71. weeks.push(w);
  72. }
  73. }
  74. }
  75. // 去重并排序
  76. return [...new Set(weeks)].sort((a, b) => a - b);
  77. }
  78. /**
  79. * 节次与周次合并去重函数(参考官方 wiki 课程合并与去重函数)。
  80. * 正方系统常把同一门课拆成多条记录:连续节次分开返回(1-2 节 + 3-4 节)、
  81. * 单双周分开返回、或完全重复返回,这里统一合并/去重。
  82. * @param {Array<Object>} courses 原始解析课程数组
  83. * @returns {Array<Object>} 合并去重后的课程数组
  84. */
  85. function mergeAndDistinctCourses(courses) {
  86. if (!Array.isArray(courses) || courses.length <= 1) return courses;
  87. // 1. 深拷贝并规范周次数据,过滤无效项
  88. const list = courses.map(c => ({
  89. ...c,
  90. name: c.name || '',
  91. teacher: c.teacher || '',
  92. position: c.position || '',
  93. weeks: Array.isArray(c.weeks) ? [...c.weeks].sort((a, b) => a - b) : []
  94. }));
  95. // 阶段 1:合并连续节次与完全重复记录(前提:名称、教师、地点、星期、周次一致)
  96. list.sort((a, b) => {
  97. return a.name.localeCompare(b.name) ||
  98. a.teacher.localeCompare(b.teacher) ||
  99. a.position.localeCompare(b.position) ||
  100. (a.day || 0) - (b.day || 0) ||
  101. a.weeks.join(',').localeCompare(b.weeks.join(',')) ||
  102. (a.startSection || 0) - (b.startSection || 0);
  103. });
  104. const step1Merged = [];
  105. let current = list[0];
  106. for (let i = 1; i < list.length; i++) {
  107. const next = list[i];
  108. const isSameCourseAndWeeks =
  109. current.name === next.name &&
  110. current.teacher === next.teacher &&
  111. current.position === next.position &&
  112. current.day === next.day &&
  113. current.weeks.join(',') === next.weeks.join(',');
  114. const isContinuous = current.endSection + 1 === next.startSection;
  115. const isDuplicate = current.startSection === next.startSection && current.endSection === next.endSection;
  116. if (isSameCourseAndWeeks && isContinuous) {
  117. // 节次连续:延长结束节次 (如 1-2 节 + 3-4 节 -> 1-4 节)
  118. current.endSection = next.endSection;
  119. } else if (isSameCourseAndWeeks && isDuplicate) {
  120. // 完全重复:跳过
  121. continue;
  122. } else {
  123. step1Merged.push(current);
  124. current = next;
  125. }
  126. }
  127. step1Merged.push(current);
  128. // 阶段 2:合并同节次的周次(前提:名称、教师、地点、星期、开始/结束节次一致)
  129. step1Merged.sort((a, b) => {
  130. return a.name.localeCompare(b.name) ||
  131. a.teacher.localeCompare(b.teacher) ||
  132. a.position.localeCompare(b.position) ||
  133. (a.day || 0) - (b.day || 0) ||
  134. (a.startSection || 0) - (b.startSection || 0) ||
  135. (a.endSection || 0) - (b.endSection || 0);
  136. });
  137. const step2Merged = [];
  138. let cur = step1Merged[0];
  139. for (let i = 1; i < step1Merged.length; i++) {
  140. const nxt = step1Merged[i];
  141. const isSameCourseAndSection =
  142. cur.name === nxt.name &&
  143. cur.teacher === nxt.teacher &&
  144. cur.position === nxt.position &&
  145. cur.day === nxt.day &&
  146. cur.startSection === nxt.startSection &&
  147. cur.endSection === nxt.endSection;
  148. if (isSameCourseAndSection) {
  149. // 周次合并去重 (如 1-8 周 + 9-16 周 -> 1-16 周,单周 + 双周 -> 每周)
  150. cur.weeks = Array.from(new Set([...cur.weeks, ...nxt.weeks])).sort((a, b) => a - b);
  151. } else {
  152. step2Merged.push(cur);
  153. cur = nxt;
  154. }
  155. }
  156. step2Merged.push(cur);
  157. return step2Merged;
  158. }
  159. /**
  160. * 拼接教务系统接口地址。
  161. * 校内直连时 location.origin 就是教务域名,前缀为空;
  162. * 校外经 WebVPN 访问时路径带有 /http/<hex> 前缀,必须保留,否则会变成跨域请求。
  163. */
  164. function buildApiUrl(path) {
  165. const prefixMatch = window.location.pathname.match(/^\/http\/[0-9a-f]+/i);
  166. const prefix = prefixMatch ? prefixMatch[0] : "";
  167. return window.location.origin + prefix + path;
  168. }
  169. /**
  170. * 拼装课程备注。
  171. * xm 只有姓名,kcmc 只有课程名,以下信息只存在于原始字段里,
  172. * 放进备注方便用户核对:重修标记、选课备注(体育项目、微专业等)、周次原文。
  173. */
  174. function buildCourseRemark(rawCourse) {
  175. const parts = [];
  176. const retakeFlag = String(rawCourse.cxbjmc || "").trim();
  177. if (retakeFlag) {
  178. parts.push(retakeFlag);
  179. }
  180. const selectionNote = String(rawCourse.xkbz || "").trim();
  181. if (selectionNote) {
  182. parts.push(selectionNote);
  183. }
  184. const weekDesc = String(rawCourse.zcd || "").trim();
  185. if (weekDesc) {
  186. parts.push(weekDesc);
  187. }
  188. return parts.join(" | ");
  189. }
  190. /**
  191. * 解析集中实践课列表(sjkList)。
  192. * 这类课程(军事技能训练、形势与政策等)只有课程名、教师和起止周,
  193. * 没有星期和节次,无法映射到周课表,只能提示用户手动添加。
  194. */
  195. function parsePracticeCourses(jsonData) {
  196. if (!jsonData || !Array.isArray(jsonData.sjkList)) {
  197. return [];
  198. }
  199. return arrayFilter(
  200. jsonData.sjkList
  201. .map((item) => ({
  202. name: String(item.kcmc || "").trim(),
  203. teacher: String(item.jsxm || "").trim(),
  204. weekDesc: String(item.qsjsz || "").trim()
  205. })),
  206. (item) => item.name
  207. );
  208. }
  209. /**
  210. * 解析 API 返回的 JSON 数据。
  211. * 合并去重后,按课程所在位置匹配作息,写入自定义时间。
  212. */
  213. function parseJsonData(jsonData, isSummerTime) {
  214. // 检查JSON结构:新的数据在 kbList 字段中
  215. if (!jsonData || !Array.isArray(jsonData.kbList)) {
  216. console.warn("JS: JSON 数据结构错误或缺少 kbList 字段。");
  217. return [];
  218. }
  219. const rawCourseList = jsonData.kbList;
  220. const initialCourseList = [];
  221. for (const rawCourse of rawCourseList) {
  222. // 关键字段检查:只有 kcmc(课名), xqj(星期), jcs(节次范围), zcd(周次描述) 是排课必需的。
  223. // xm(教师) 与 cdmc(教室) 在实践课、线上课、未排地点的课程上可能为空,
  224. // 缺这两项不影响排课,不能因此丢弃整门课程。
  225. if (!rawCourse.kcmc || !rawCourse.xqj || !rawCourse.jcs || !rawCourse.zcd) {
  226. continue;
  227. }
  228. const weeksArray = parseWeeks(rawCourse.zcd);
  229. // 周次有效性检查
  230. if (weeksArray.length === 0) {
  231. continue;
  232. }
  233. // 解析节次范围,例如 "1-2"
  234. const sectionParts = rawCourse.jcs.split('-');
  235. const startSection = Number(sectionParts[0]);
  236. const endSection = Number(sectionParts[sectionParts.length - 1]);
  237. const day = Number(rawCourse.xqj); // xqj: 星期几 (周一为1, 周日为7)
  238. // 数字有效性检查
  239. if (isNaN(day) || isNaN(startSection) || isNaN(endSection) || day < 1 || day > 7 || startSection > endSection) {
  240. continue;
  241. }
  242. const remark = buildCourseRemark(rawCourse);
  243. const course = {
  244. name: String(rawCourse.kcmc).trim(),
  245. teacher: String(rawCourse.xm || "").trim(),
  246. position: String(rawCourse.cdmc || "").trim(),
  247. day: day,
  248. startSection: startSection,
  249. endSection: endSection,
  250. weeks: weeksArray
  251. };
  252. if (remark) {
  253. course.remark = remark;
  254. }
  255. initialCourseList.push(course);
  256. }
  257. // 正方常把同一门课拆成多条记录(连续节次、单双周、重复),先合并去重,
  258. // 再按楼栋匹配作息写入自定义时间,最后按星期节次排序。
  259. const mergedCourses = mergeAndDistinctCourses(initialCourseList);
  260. const finalCourseList = applyCustomTimeToCourses(mergedCourses, isSummerTime);
  261. finalCourseList.sort((a, b) =>
  262. a.day - b.day ||
  263. a.startSection - b.startSection ||
  264. a.name.localeCompare(b.name)
  265. );
  266. console.log(`JS: 课程解析完成,原始 ${initialCourseList.length} 条,合并去重后 ${finalCourseList.length} 条。`);
  267. return finalCourseList;
  268. }
  269. /**
  270. * 检查当前是否处于夏令时作息时间段。
  271. * @returns true 夏令时 false 冬令时
  272. *
  273. * 曾尝试抓取教务处作息时间公告页(https://jwc.ujs.edu.cn/index/xl_zuo_xi_shi_jian.htm),
  274. * 但跨域(CORS)无法读取,智能选择回退到预设日期。
  275. * 冬令时是十一假期结束后调整,取 10 月 7 日;夏令时公告历年均为 4 月 7 日起执行。
  276. */
  277. function whetherSummerTimeSlot() {
  278. // 预设日期
  279. const summerStart = new Date(new Date().getFullYear(), 3, 7); // 4月7日
  280. const winterStart = new Date(new Date().getFullYear(), 9, 7); // 10月7日
  281. const now = new Date();
  282. return now >= summerStart && now < winterStart;
  283. }
  284. /**
  285. * 计算本次导入的作息何时失效。
  286. * 作息时间是导入时一次性写死的,不会自动跟随切令时变化,
  287. * 所以这里返回下一个「切到另一种令时」的日期,用于提示用户届时重新导入。
  288. * 夏令时 4 月 7 日起执行,冬令时十一假期后(10 月 7 日)起执行。
  289. */
  290. function getNextTimeSlotSwitchDate(isSummerTime) {
  291. const now = new Date();
  292. const year = now.getFullYear();
  293. const targetLabel = isSummerTime ? "冬令时" : "夏令时";
  294. const candidates = [
  295. { date: new Date(year, 3, 7), label: "夏令时" },
  296. { date: new Date(year, 9, 7), label: "冬令时" },
  297. { date: new Date(year + 1, 3, 7), label: "夏令时" },
  298. { date: new Date(year + 1, 9, 7), label: "冬令时" }
  299. ];
  300. const next = candidates.find((item) => item.date > now && item.label === targetLabel);
  301. return {
  302. label: next.label,
  303. text: `${next.date.getFullYear()}年${next.date.getMonth() + 1}月${next.date.getDate()}日`
  304. };
  305. }
  306. /**
  307. * 检查是否在登录页面。
  308. * 校内直连时地址是 http://jwxt.ujs.edu.cn/sso/jziotlogin,
  309. * 校外经 WebVPN 时地址是 https://webvpn.ujs.edu.cn/http/<hex>/sso/jziotlogin,
  310. * 因此按路径结尾判断,两种入口都能识别。
  311. */
  312. function isLoginPage() {
  313. return window.location.pathname.endsWith("/sso/jziotlogin");
  314. }
  315. function validateYearInput(input) {
  316. if (/^[0-9]{4}$/.test(input)) {
  317. return false;
  318. }
  319. return "请输入四位数字的学年!";
  320. }
  321. async function promptUserToStart() {
  322. return await window.shiguangBridgePromise.showAlert(
  323. "教务系统课表导入",
  324. "导入前请确保您已在浏览器中成功登录教务系统",
  325. "好的,开始导入"
  326. );
  327. }
  328. /**
  329. * 从教务系统课表查询页读取学年学期选项。
  330. * 学年:以系统当前选中项为中心,取前 2 年 + 后 2 年,共 5 个选项;
  331. * 学期:直接取页面全部选项。
  332. * 读取失败返回 null,由调用方回退到手动输入。
  333. */
  334. async function fetchAcademicOptions() {
  335. const url = buildApiUrl("/kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N2151");
  336. try {
  337. const response = await fetch(url, {
  338. method: "GET",
  339. credentials: "include"
  340. });
  341. if (!response.ok) return null;
  342. const doc = new DOMParser().parseFromString(await response.text(), "text/html");
  343. const allYearOptions = arrayFilter(
  344. Array.from(doc.querySelectorAll("#xnm option"))
  345. .map((opt) => ({
  346. value: opt.value,
  347. text: opt.textContent.trim(),
  348. selected: opt.selected
  349. })),
  350. (opt) => opt.value !== ""
  351. );
  352. const semesterOptions = arrayFilter(
  353. Array.from(doc.querySelectorAll("#xqm option"))
  354. .map((opt) => ({
  355. value: opt.value,
  356. // 江大返回的文本只有「1」「2」,直接展示不友好,补成「第一学期」这类写法。
  357. text: /^\d$/.test(opt.textContent.trim())
  358. ? `第${"一二三四五六七八九十"[Number(opt.textContent.trim()) - 1] || opt.textContent.trim()}学期`
  359. : opt.textContent.trim(),
  360. selected: opt.selected
  361. })),
  362. (opt) => opt.value !== ""
  363. );
  364. if (allYearOptions.length === 0 || semesterOptions.length === 0) {
  365. return null;
  366. }
  367. const selectedIndex = allYearOptions.findIndex((opt) => opt.selected);
  368. const start = Math.max(0, selectedIndex === -1 ? 0 : selectedIndex - 2);
  369. const end = Math.min(allYearOptions.length, selectedIndex === -1 ? 5 : selectedIndex + 3);
  370. const yearOptions = allYearOptions.slice(start, end);
  371. const defaultSemesterIndex = semesterOptions.findIndex((opt) => opt.selected);
  372. return {
  373. yearOptions,
  374. semesterOptions,
  375. defaultYearIndex: selectedIndex === -1 ? 0 : selectedIndex - start,
  376. defaultSemesterIndex: defaultSemesterIndex === -1 ? 0 : defaultSemesterIndex
  377. };
  378. } catch (error) {
  379. console.warn("JS: 读取学年学期选项失败:", error);
  380. return null;
  381. }
  382. }
  383. /**
  384. * 提示用户选择学年和学期。
  385. * 优先用教务系统返回的选项(自动默认选中当前学期),
  386. * 读取失败时回退为手动输入学年 + 固定的两学期选择。
  387. * @returns {{academicYear: string, semesterCode: string}} 或 null(取消)
  388. */
  389. async function selectAcademicYearAndSemester() {
  390. const optionsData = await fetchAcademicOptions();
  391. if (optionsData) {
  392. const { yearOptions, semesterOptions, defaultYearIndex, defaultSemesterIndex } = optionsData;
  393. const yearIndex = await window.shiguangBridgePromise.showSingleSelection(
  394. "选择学年",
  395. JSON.stringify(yearOptions.map((item) => item.text)),
  396. defaultYearIndex
  397. );
  398. if (yearIndex === null || yearIndex === -1) return null;
  399. const semesterIndex = await window.shiguangBridgePromise.showSingleSelection(
  400. "选择学期",
  401. JSON.stringify(semesterOptions.map((item) => item.text)),
  402. defaultSemesterIndex
  403. );
  404. if (semesterIndex === null || semesterIndex === -1) return null;
  405. return {
  406. academicYear: yearOptions[yearIndex].value,
  407. semesterCode: semesterOptions[semesterIndex].value
  408. };
  409. }
  410. // 回退:手动输入学年。
  411. const currentYear = new Date().getFullYear().toString();
  412. const currentMonth = new Date().getMonth() + 1; // 月份从0开始,所以加1
  413. // 如果当前月份在8月或之后,默认学年是当前年份,否则是上一年份
  414. const defaultYear = currentMonth >= 8 ? currentYear : (Number(currentYear) - 1).toString();
  415. const academicYear = await window.shiguangBridgePromise.showPrompt(
  416. "选择学年",
  417. "未能从教务系统读取学年学期,请手动输入要导入课程的起始学年(如2025-2026 应该填2025):",
  418. defaultYear,
  419. "validateYearInput"
  420. );
  421. if (academicYear === null) return null;
  422. const semesters = ["第一学期", "第二学期"];
  423. const defaultSemesterIndex = currentMonth >= 8 ? 0 : 1;
  424. const semesterIndex = await window.shiguangBridgePromise.showSingleSelection(
  425. "选择学期",
  426. JSON.stringify(semesters),
  427. defaultSemesterIndex
  428. );
  429. if (semesterIndex === null || semesterIndex === -1) return null;
  430. return {
  431. academicYear,
  432. semesterCode: getSemesterCode(semesterIndex)
  433. };
  434. }
  435. async function selectTimeSlot() {
  436. const timeSlots = ["智能选择" ,"夏令时", "冬令时"];
  437. const timeSlotIndex = await window.shiguangBridgePromise.showSingleSelection(
  438. "选择作息时间",
  439. JSON.stringify(timeSlots),
  440. 0
  441. );
  442. return timeSlotIndex;
  443. }
  444. async function reselectTimeSlot(selectedTimeSlot) {
  445. const options = ["对的对的,就是这个", "不对不对,应该是另外一个"];
  446. const dialogTitle = "当前智能选择结果为: \n " + (selectedTimeSlot ? "夏令时" : "冬令时") + "\n是否更改选择?";
  447. const selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
  448. dialogTitle,
  449. JSON.stringify(options),
  450. 0
  451. );
  452. if (selectedIndex === null || selectedIndex === -1) {
  453. return false;
  454. }
  455. // 选中第 2 项(索引 1)表示“需要改成另外一个”。
  456. return selectedIndex === 1;
  457. }
  458. /**
  459. * 将选择索引转换为 API 所需的学期码。
  460. */
  461. function getSemesterCode(semesterIndex) {
  462. // semesterIndex 3 (第一学期), 12 (第二学期)
  463. return semesterIndex === 0 ? "3" : "12";
  464. }
  465. /**
  466. * 获取指定学年学期的校历周次。
  467. *
  468. * 接口按周次顺序返回数组,每个元素代表一周:
  469. * rq 形如 "2026-08-31/2026-09-06",zs 是周次序号。
  470. * 第 1 周的起始日(周一)就是 semesterStartDate 需要的开学日期,
  471. * 数组长度就是教务排的总周数。
  472. * 与首页日历区块不同,这个接口尊重 xnm/xqm 参数,历史学期同样能取到;
  473. * 尚未排出校历的学期返回空数组。
  474. */
  475. async function fetchSemesterWeeks(academicYear, semesterCode) {
  476. const url = buildApiUrl("/kbcx/xskbcxZccx_cxZcByXnxq.html?gnmkdm=N2154");
  477. try {
  478. const response = await fetch(url, {
  479. "headers": {
  480. "content-type": "application/x-www-form-urlencoded;charset=UTF-8",
  481. "x-requested-with": "XMLHttpRequest"
  482. },
  483. "body": `xnm=${academicYear}&xqm=${semesterCode}`,
  484. "method": "POST",
  485. "credentials": "include"
  486. });
  487. if (!response.ok) {
  488. throw new Error(`状态码 ${response.status}`);
  489. }
  490. const weekList = JSON.parse(await response.text());
  491. if (!Array.isArray(weekList) || weekList.length === 0) {
  492. console.warn("JS: 校历接口未返回周次数据,该学期可能尚未排出校历。");
  493. return null;
  494. }
  495. // 正常情况下数组已按周次排好,仍按 zs 找一次第 1 周,避免顺序变化时取错日期。
  496. const firstWeek = weekList.find((item) => Number(item.zs) === 1) || weekList[0];
  497. // rq 形如 "2026-08-31/2026-09-06",取斜杠前的周一日期;
  498. // 部分正方部署改用 zcrq / ksrq 字段,一并兜底。
  499. const rqDate = String(firstWeek.rq || "").split("/")[0].trim();
  500. const fallbackMatch = String(firstWeek.zcrq || firstWeek.ksrq || "").match(/(\d{4}-\d{2}-\d{2})/);
  501. const startDate = /^\d{4}-\d{2}-\d{2}$/.test(rqDate) ? rqDate : (fallbackMatch ? fallbackMatch[1] : "");
  502. if (!startDate) {
  503. console.warn("JS: 校历接口返回的日期无法识别:", firstWeek);
  504. return null;
  505. }
  506. const range = { startDate: startDate, totalWeeks: weekList.length };
  507. console.log("JS: 校历周次:", range);
  508. return range;
  509. } catch (error) {
  510. console.warn("JS: 获取校历周次失败:", error);
  511. return null;
  512. }
  513. }
  514. /**
  515. * 计算课表配置。
  516. *
  517. * 应用侧的 saveCourseConfig 是整体覆盖而非字段级合并:没有传入的字段会被写成模型默认值,
  518. * 其中 semesterStartDate 的默认值是 null,会把用户已经设置好的开学日期清空。
  519. * 所以拿不到真实开学日期时返回 null,由调用方跳过整个配置保存,宁可不写也不要写坏。
  520. *
  521. * defaultClassDuration / defaultBreakDuration 不显式传入,会被重置为应用默认的 45 / 10 分钟,
  522. * 与江大「45 分钟一节、课间 10 分钟」一致,因此没有副作用。
  523. */
  524. function buildCourseConfig(semesterRange, firstDayOfWeek) {
  525. if (!semesterRange) {
  526. return null;
  527. }
  528. return {
  529. semesterStartDate: semesterRange.startDate,
  530. // 直接采用校历周数。校历含考试周与假期周,比实际上课周多几周,
  531. // 但这个值只是课表的周次上限:多几周空白无害,少了会截断课程。
  532. semesterTotalWeeks: semesterRange.totalWeeks,
  533. firstDayOfWeek: firstDayOfWeek
  534. };
  535. }
  536. /**
  537. * 请求和解析课程数据。
  538. * 课程请求与校历请求并行执行,课表配置一并算好返回,
  539. * 由调用方决定是否保存(拿不到开学日期时 config 为 null)。
  540. */
  541. async function fetchAndParseCourses(academicYear, semesterCode, isSummerTime) {
  542. window.shiguangBridge.showToast("正在请求课表数据...");
  543. const xnmXqmBody = `xnm=${academicYear}&xqm=${semesterCode}&kzlx=ck&xsdm=&kclbdm=`;
  544. const url = buildApiUrl("/kbcx/xskbcx_cxXsgrkb.html?gnmkdm=N2151");
  545. // 并行获取课程数据和校历周次
  546. const [courseResponse, semesterRange] = await Promise.all([
  547. fetch(url, {
  548. method: "POST",
  549. headers: {
  550. "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
  551. },
  552. body: xnmXqmBody,
  553. credentials: "include"
  554. }),
  555. fetchSemesterWeeks(academicYear, semesterCode)
  556. ]);
  557. try {
  558. if (!courseResponse.ok) {
  559. throw new Error(`网络请求失败。状态码: ${courseResponse.status} (${courseResponse.statusText})`);
  560. }
  561. let jsonData;
  562. try {
  563. jsonData = JSON.parse(await courseResponse.text());
  564. } catch (e) {
  565. console.error('JS: JSON 解析失败,可能是会话过期:', e);
  566. window.shiguangBridge.showToast("数据返回格式错误,可能是您未成功登录或会话已过期。");
  567. return null;
  568. }
  569. const courses = parseJsonData(jsonData, isSummerTime);
  570. if (courses.length === 0) {
  571. window.shiguangBridge.showToast("未找到任何课程数据,请检查所选学年学期是否正确或本学期无课,或教务系统需要二次登录。");
  572. return null;
  573. }
  574. // 集中实践课(军训、形势与政策等)没有星期和节次,无法排进周课表,单独取出用于提示。
  575. const practiceCourses = parsePracticeCourses(jsonData);
  576. if (practiceCourses.length > 0) {
  577. console.log(`JS: 检测到 ${practiceCourses.length} 门集中实践课,无法自动导入。`);
  578. }
  579. // qsxqj: 教务系统设置的一周起始星期几,缺失时按周一处理。
  580. const rawFirstDay = Number(jsonData.qsxqj);
  581. const firstDayOfWeek = (rawFirstDay >= 1 && rawFirstDay <= 7) ? rawFirstDay : 1;
  582. return {
  583. courses: courses,
  584. practiceCourses: practiceCourses,
  585. config: buildCourseConfig(semesterRange, firstDayOfWeek)
  586. };
  587. } catch (error) {
  588. window.shiguangBridge.showToast(`请求或解析失败: ${error.message}`);
  589. console.error('JS: Fetch/Parse Error:', error);
  590. return null;
  591. }
  592. }
  593. async function saveCourses(parsedCourses) {
  594. try {
  595. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(parsedCourses));
  596. return true;
  597. } catch (error) {
  598. window.shiguangBridge.showToast(`课程保存失败: ${error.message}`);
  599. console.error('JS: Save Courses Error:', error);
  600. return false;
  601. }
  602. }
  603. /**
  604. * 只在能拿到真实开学日期时写入课表配置。
  605. * 拿不到就完全不调用 saveCourseConfig —— 应用侧是整体覆盖,
  606. * 传入不含 semesterStartDate 的配置会把用户已设置的开学日期清空。
  607. */
  608. async function saveCourseConfigIfPossible(config) {
  609. if (!config) {
  610. window.shiguangBridge.showToast("未取到本学期开学日期,已跳过课表配置,请在应用内手动设置开学日期。");
  611. console.log("JS: 无可用开学日期,跳过 saveCourseConfig 以保留用户现有配置。");
  612. return;
  613. }
  614. try {
  615. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  616. window.shiguangBridge.showToast(
  617. `课表配置更新成功!开学日期 ${config.semesterStartDate},总周数 ${config.semesterTotalWeeks} 周。`
  618. );
  619. } catch (error) {
  620. window.shiguangBridge.showToast(`课表配置保存失败: ${error.message}`);
  621. console.error('JS: Save Config Error:', error);
  622. }
  623. }
  624. // 上午作息时间
  625. // 北固及本部主楼、主A楼、生环楼、汽车能动楼、京江楼
  626. const AMorningTimeSlots = [
  627. { number: 1, startTime: "08:00", endTime: "08:45" },
  628. { number: 2, startTime: "08:55", endTime: "09:40" },
  629. { number: 3, startTime: "10:00", endTime: "10:45" },
  630. { number: 4, startTime: "10:55", endTime: "11:40" },
  631. ];
  632. // 三江楼、材料楼、机械楼、新校区各教学楼
  633. const BMorningTimeSlots = [
  634. { number: 1, startTime: "08:00", endTime: "08:45" },
  635. { number: 2, startTime: "08:55", endTime: "09:40" },
  636. { number: 3, startTime: "10:10", endTime: "10:55" },
  637. { number: 4, startTime: "11:05", endTime: "11:50" },
  638. ];
  639. // 三山楼、讲堂群、实践楼
  640. const CMorningTimeSlots = [
  641. { number: 1, startTime: "08:00", endTime: "08:45" },
  642. { number: 2, startTime: "08:55", endTime: "09:40" },
  643. { number: 3, startTime: "10:20", endTime: "11:05" },
  644. { number: 4, startTime: "11:15", endTime: "12:00" },
  645. ];
  646. // 夏令时
  647. // 下午作息时间
  648. // 北固
  649. const DSummerAfternoonTimeSlots = [
  650. { number: 5, startTime: "14:00", endTime: "14:45" },
  651. { number: 6, startTime: "14:55", endTime: "15:40" },
  652. { number: 7, startTime: "15:50", endTime: "16:35" },
  653. { number: 8, startTime: "16:45", endTime: "17:30" },
  654. ];
  655. // 本部
  656. const ESummerAfternoonTimeSlots = [
  657. { number: 5, startTime: "14:00", endTime: "14:45" },
  658. { number: 6, startTime: "14:55", endTime: "15:40" },
  659. { number: 7, startTime: "16:00", endTime: "16:45" },
  660. { number: 8, startTime: "16:55", endTime: "17:40" },
  661. ];
  662. // 晚上作息时间
  663. const SummerEveningTimeSlots = [
  664. { number: 9, startTime: "19:00", endTime: "19:45" },
  665. { number: 10, startTime: "19:55", endTime: "20:40" },
  666. { number: 11, startTime: "20:50", endTime: "21:35" },
  667. ];
  668. // 冬令时
  669. // 下午作息时间
  670. // 北固
  671. const DWinterAfternoonTimeSlots = [
  672. { number: 5, startTime: "13:30", endTime: "14:15" },
  673. { number: 6, startTime: "14:25", endTime: "15:10" },
  674. { number: 7, startTime: "15:20", endTime: "16:05" },
  675. { number: 8, startTime: "16:15", endTime: "17:00" },
  676. ];
  677. // 本部
  678. const EWinterAfternoonTimeSlots = [
  679. { number: 5, startTime: "13:30", endTime: "14:15" },
  680. { number: 6, startTime: "14:25", endTime: "15:10" },
  681. { number: 7, startTime: "15:30", endTime: "16:15" },
  682. { number: 8, startTime: "16:25", endTime: "17:10" },
  683. ];
  684. // 晚上作息时间
  685. const WinterEveningTimeSlots = [
  686. { number: 9, startTime: "18:30", endTime: "19:15" },
  687. { number: 10, startTime: "19:25", endTime: "20:10" },
  688. { number: 11, startTime: "20:20", endTime: "21:05" },
  689. ];
  690. // 全局默认作息
  691. // 夏令时
  692. const SummerTimeSlots = [...AMorningTimeSlots, ...ESummerAfternoonTimeSlots, ...SummerEveningTimeSlots];
  693. // 冬令时
  694. const WinterTimeSlots = [...AMorningTimeSlots, ...EWinterAfternoonTimeSlots, ...WinterEveningTimeSlots];
  695. function getCampusTypeFromPosition(position) {
  696. const normalized = String(position || "").replace(/\s+/g, " ").trim();
  697. if (!normalized) return null;
  698. // api 不再返回「本部」这类校区前缀,北固的返回格式也未确认,只能先这么写:
  699. // 带「北固」的按北固校区处理,其余一律按本部处理。
  700. const firstPart = normalized.split(" ")[0] || "";
  701. if (firstPart.includes("北固") || normalized.includes("北固")) return "D";
  702. return "E";
  703. }
  704. function getMorningTypeFromPosition(position) {
  705. const text = String(position || "").trim();
  706. // 教务系统返回的是「京江2号楼2101」「京江3号楼3407」这类名称,没有「京江楼」这个写法,
  707. // 所以这里匹配「京江」而不是「京江楼」。
  708. if (text.includes("主A楼") || text.includes("京江")) return "A";
  709. if (text.includes("三江楼")) return "B";
  710. if (text.includes("三山楼") || text.includes("讲堂群")) return "C";
  711. return null;
  712. }
  713. function buildCourseTimeSlotsByPosition(position, isSummerTime) {
  714. const morningType = getMorningTypeFromPosition(position);
  715. const campusType = getCampusTypeFromPosition(position);
  716. // 仅对指定楼宇做自定义。
  717. if (!morningType || !campusType) {
  718. return null;
  719. }
  720. const morningTimeSlots = morningType === "A"
  721. ? AMorningTimeSlots
  722. : morningType === "B"
  723. ? BMorningTimeSlots
  724. : CMorningTimeSlots;
  725. const afternoonTimeSlots = isSummerTime
  726. ? (campusType === "D" ? DSummerAfternoonTimeSlots : ESummerAfternoonTimeSlots)
  727. : (campusType === "D" ? DWinterAfternoonTimeSlots : EWinterAfternoonTimeSlots);
  728. const eveningTimeSlots = isSummerTime ? SummerEveningTimeSlots : WinterEveningTimeSlots;
  729. return [...morningTimeSlots, ...afternoonTimeSlots, ...eveningTimeSlots];
  730. }
  731. function applyCustomTimeToCourses(courses, isSummerTime) {
  732. let customizedCount = 0;
  733. let skippedCount = 0;
  734. const updatedCourses = courses.map((course) => {
  735. const courseTimeSlots = buildCourseTimeSlotsByPosition(course.position, isSummerTime);
  736. if (!courseTimeSlots) {
  737. skippedCount += 1;
  738. return course;
  739. }
  740. const slotMap = new Map(courseTimeSlots.map((slot) => [slot.number, slot]));
  741. const startSlot = slotMap.get(course.startSection);
  742. const endSlot = slotMap.get(course.endSection);
  743. if (!startSlot || !endSlot) {
  744. skippedCount += 1;
  745. console.warn(`JS: 课程 ${course.name} 的节次(${course.startSection}-${course.endSection})未命中自定义时间映射,回退为普通节次。`);
  746. return course;
  747. }
  748. customizedCount += 1;
  749. return {
  750. ...course,
  751. isCustomTime: true,
  752. customStartTime: startSlot.startTime,
  753. customEndTime: endSlot.endTime,
  754. };
  755. });
  756. console.log(`JS: 自定义时间处理完成,命中 ${customizedCount} 门,跳过 ${skippedCount} 门。`);
  757. return updatedCourses;
  758. }
  759. async function importPresetTimeSlots(timeSlots) {
  760. if (timeSlots.length === 0) {
  761. window.shiguangBridge.showToast("警告:时间段为空,未导入时间段信息。");
  762. return;
  763. }
  764. window.shiguangBridge.showToast(`正在导入 ${timeSlots.length} 个预设时间段...`);
  765. try {
  766. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  767. window.shiguangBridge.showToast("预设时间段导入成功!");
  768. } catch (error) {
  769. window.shiguangBridge.showToast("导入时间段失败: " + error.message);
  770. console.error('JS: Save Time Slots Error:', error);
  771. }
  772. }
  773. async function runImportFlow() {
  774. if (isLoginPage()) {
  775. window.shiguangBridge.showToast("导入失败:请先登录教务系统!");
  776. console.log("JS: 检测到当前在登录页面,终止导入。");
  777. return;
  778. }
  779. const alertConfirmed = await promptUserToStart();
  780. if (!alertConfirmed) {
  781. window.shiguangBridge.showToast("用户取消了导入。");
  782. return;
  783. }
  784. const selection = await selectAcademicYearAndSemester();
  785. if (selection === null) {
  786. window.shiguangBridge.showToast("导入已取消。");
  787. return;
  788. }
  789. const { academicYear, semesterCode } = selection;
  790. console.log(`JS: 已选择学年学期: ${academicYear} / ${semesterCode}`);
  791. const timeSlotIndex = await selectTimeSlot();
  792. if (timeSlotIndex === null || timeSlotIndex === -1) {
  793. window.shiguangBridge.showToast("导入已取消。");
  794. return;
  795. }
  796. let isSummerTime;
  797. if (timeSlotIndex === 1) {
  798. isSummerTime = true;
  799. } else if (timeSlotIndex === 2) {
  800. isSummerTime = false;
  801. } else {
  802. isSummerTime = whetherSummerTimeSlot();
  803. const shouldReselect = await reselectTimeSlot(isSummerTime);
  804. if (shouldReselect) {
  805. isSummerTime = !isSummerTime;
  806. }
  807. }
  808. console.log(`JS: 作息类型: ${isSummerTime ? "夏令时" : "冬令时"}`);
  809. const result = await fetchAndParseCourses(academicYear, semesterCode, isSummerTime);
  810. if (result === null) {
  811. console.log("JS: 课程获取或解析失败,流程终止。");
  812. return;
  813. }
  814. const { courses, practiceCourses, config } = result;
  815. // 作息时间在导入时一次性写入,不会自动跟随切令时变化,需要明确告知用户。
  816. // 同时说明只有部分教学楼收录了独立作息,其余楼栋使用默认作息时间。
  817. const nextSwitch = getNextTimeSlotSwitchDate(isSummerTime);
  818. await window.shiguangBridgePromise.showAlert(
  819. "作息时间提示",
  820. `本次按${isSummerTime ? "夏令时" : "冬令时"}导入。作息时间在导入时写入,不会自动跟随学校切换令时。\n` +
  821. `${nextSwitch.text}起学校切换为${nextSwitch.label},届时请重新导入课表,或在应用内手动修改时间段。\n\n` +
  822. "脚本已根据课程所在位置匹配作息时间,部分课程可能与预设时间不符。\n" +
  823. "请在课表页面核对课程时间,如有错误请手动修改课程所在位置或节次信息。\n\n" +
  824. "已收录独立作息的楼栋:主A楼、京江各号楼、三江楼、三山楼、讲堂群。\n" +
  825. "其余楼栋(各学院楼、各实验室、运动场、未排地点等)使用默认作息时间。\n\n" +
  826. "欢迎其他楼栋的同学提供课程时间信息以完善脚本!",
  827. "我知道了"
  828. );
  829. if (practiceCourses.length > 0) {
  830. const practiceList = practiceCourses
  831. .map((item) => {
  832. const teacher = item.teacher ? `(${item.teacher})` : "";
  833. const weekDesc = item.weekDesc ? ` ${item.weekDesc}` : "";
  834. return `· ${item.name}${teacher}${weekDesc}`;
  835. })
  836. .join("\n");
  837. console.log("JS: 集中实践课列表:", practiceCourses);
  838. await window.shiguangBridgePromise.showAlert(
  839. "集中实践课需手动添加",
  840. `本学期有 ${practiceCourses.length} 门集中实践课,教务系统未给出星期和节次,无法自动导入:\n\n` +
  841. practiceList +
  842. "\n\n请按实际安排在应用内手动添加。",
  843. "我知道了"
  844. );
  845. }
  846. const saveResult = await saveCourses(courses);
  847. if (!saveResult) {
  848. console.log("JS: 课程保存失败,流程终止。");
  849. return;
  850. }
  851. await saveCourseConfigIfPossible(config);
  852. await importPresetTimeSlots(isSummerTime ? SummerTimeSlots : WinterTimeSlots);
  853. window.shiguangBridge.showToast(`课程导入成功,共导入 ${courses.length} 门课程!`);
  854. console.log("JS: 整个导入流程执行完毕并成功。");
  855. window.shiguangBridge.notifyTaskCompletion();
  856. }
  857. runImportFlow();