ujs_yjs_v1.0.js 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044
  1. // 江苏大学(ujs.edu.cn)研究生管理系统 拾光课程表适配脚本
  2. // 基于正方研究生管理系统(研究生版 Gmis / pyxx)适配
  3. // 出现问题请联系作者或者提交直接pr更改,这更加快速
  4. // 作者:洛初 Github@gongfuture
  5. // 2026.09.10 第一版
  6. // 研究生系统没有对外直连入口,需先经统一身份认证(ehall 门户或 http://yjsgll.ujs.edu.cn/pyxx/)登录。
  7. // 课表从学生课表查询页 pygl/kbcx_xs.aspx 的 DataGrid 里解析:
  8. // 单元格文本自带「第3-18周 单双周:星期二 上3,4 星期五 上1,2」,星期、节次、周次都能直接取到。
  9. // 作息时间取自课表页下方的作息表(上午/下午/晚上),由服务端按当前令时渲染,脚本原样导入,
  10. // 因此不再像本科脚本那样询问夏令时/冬令时,也不做楼栋匹配(研究生系统全校一套作息)。(后续看十一再调整)
  11. // 2026.10.07 第二版
  12. // 作息改为写入「组合作息方案」(comboSchedule):夏令时 / 冬令时两套作息按日期区间自动切换,
  13. // 不再从课表页抓取作息表,也不再需要用户手动选择令时。
  14. // 夏令时 4/7 起、冬令时 10/7 起,与学校历年切令时公告一致,写死在方案规则里。
  15. // 研究生全校作息统一(都在研究生楼一带),不存在本科那种按楼栋分叉的作息,
  16. // 因此可以完全依赖组合作息,课程不再逐条写自定义时间:翻到哪一周,应用就用那一周的日期去匹配规则。
  17. // 作息数据取自教务处公布的江大作息:上午三江楼(B)一套,下午/晚上本部(E)夏令时与冬令时各一套,两季上午相同。
  18. // 开学日期:研究生系统没有校历接口,改用「我的课表」接口 App_Ajax/GetkcHandler.ashx 逐周回查,
  19. // 找到某门课第一次出现的日期,再按(起始周-1)*7+(星期-1)反推开学日期;反推不到则跳过配置保存。
  20. // 组合作息的日期区间按「本学期起止日期 ∩ 各令时日期区间」生成,保证整个学期被规则完整覆盖;
  21. // 拿不到开学日期时回退为当年固定的 4/7–10/6、10/7–次年 4/6 两个区间。
  22. /**
  23. * 数组过滤(原生实现)。
  24. * 部分教务页面会改写 Array.prototype.filter,这里用普通循环自行实现,不依赖被改写过的数组方法。
  25. */
  26. function arrayFilter(arr, predicate) {
  27. const result = [];
  28. for (let i = 0; i < arr.length; i++) {
  29. if (predicate(arr[i], i, arr)) {
  30. result.push(arr[i]);
  31. }
  32. }
  33. return result;
  34. }
  35. /**
  36. * 拼接教务系统接口地址。
  37. * 校内直连时 location.origin 就是教务域名,前缀为空;
  38. * 校外经 WebVPN 访问时路径带有 /http/<hex> 前缀,必须保留,否则会变成跨域请求。
  39. */
  40. function buildApiUrl(path) {
  41. const prefixMatch = window.location.pathname.match(/^\/http\/[0-9a-f]+/i);
  42. const prefix = prefixMatch ? prefixMatch[0] : "";
  43. return window.location.origin + prefix + path;
  44. }
  45. /**
  46. * 当前是否在研究生管理系统内。
  47. * 校内直连域名是 yjsgll.ujs.edu.cn,校外经 WebVPN 时是 webvpn.ujs.edu.cn/http/<hex>/pyxx/。
  48. */
  49. function isGraduateSystemPage() {
  50. const host = window.location.hostname;
  51. if (host.indexOf("yjsgll.ujs.edu.cn") !== -1) return true;
  52. return /\/http\/[0-9a-f]+\/pyxx\//i.test(window.location.pathname);
  53. }
  54. /**
  55. * 是否停在登录页。
  56. * 未登录访问会被重定向到统一身份认证 https://pass.ujs.edu.cn/cas/login?service=.../pyxx/loginCAS.aspx。
  57. */
  58. function isLoginPage() {
  59. const host = window.location.hostname;
  60. if (host.indexOf("pass.ujs.edu.cn") !== -1) return true;
  61. return window.location.pathname.indexOf("/cas/login") !== -1;
  62. }
  63. /**
  64. * 解析周次文本,返回周次数组。
  65. * 研究生系统的写法是「第3-18周」,也可能出现「第3周」;
  66. * 单双周标记要先把「单双周」这个词本身剔掉,否则会被误判成单周。
  67. */
  68. function parseWeeks(weekText) {
  69. if (!weekText) return [];
  70. const cleaned = String(weekText).replace(/单双周/g, "");
  71. const oddOnly = cleaned.indexOf("单") !== -1;
  72. const evenOnly = cleaned.indexOf("双") !== -1;
  73. const weeks = [];
  74. const rangePattern = /(\d+)\s*-\s*(\d+)\s*周|(\d+)\s*周/g;
  75. let match;
  76. while ((match = rangePattern.exec(cleaned)) !== null) {
  77. const start = Number(match[1] || match[3]);
  78. const end = match[2] ? Number(match[2]) : start;
  79. for (let week = start; week <= end; week++) {
  80. if (oddOnly && week % 2 === 0) continue;
  81. if (evenOnly && week % 2 !== 0) continue;
  82. weeks.push(week);
  83. }
  84. }
  85. return Array.from(new Set(weeks)).sort((a, b) => a - b);
  86. }
  87. /**
  88. * 节次与周次合并去重(参考官方 wiki 课程合并与去重函数)。
  89. * 课表里同一门课的每个上课时段都会重复一份完整文本,
  90. * 解析后会得到多条完全相同的记录,这里统一合并/去重。
  91. */
  92. function mergeAndDistinctCourses(courses) {
  93. if (!Array.isArray(courses) || courses.length <= 1) return courses;
  94. const list = courses.map(c => ({
  95. ...c,
  96. name: c.name || "",
  97. teacher: c.teacher || "",
  98. position: c.position || "",
  99. weeks: Array.isArray(c.weeks) ? [...c.weeks].sort((a, b) => a - b) : []
  100. }));
  101. // 阶段 1:合并连续节次与完全重复记录(前提:名称、教师、地点、星期、周次一致)
  102. list.sort((a, b) => {
  103. return a.name.localeCompare(b.name) ||
  104. a.teacher.localeCompare(b.teacher) ||
  105. a.position.localeCompare(b.position) ||
  106. (a.day || 0) - (b.day || 0) ||
  107. a.weeks.join(",").localeCompare(b.weeks.join(",")) ||
  108. (a.startSection || 0) - (b.startSection || 0);
  109. });
  110. const step1Merged = [];
  111. let current = list[0];
  112. for (let i = 1; i < list.length; i++) {
  113. const next = list[i];
  114. const isSameCourseAndWeeks =
  115. current.name === next.name &&
  116. current.teacher === next.teacher &&
  117. current.position === next.position &&
  118. current.day === next.day &&
  119. current.weeks.join(",") === next.weeks.join(",");
  120. const isContinuous = current.endSection + 1 === next.startSection;
  121. const isDuplicate = current.startSection === next.startSection && current.endSection === next.endSection;
  122. if (isSameCourseAndWeeks && isContinuous) {
  123. current.endSection = next.endSection;
  124. } else if (isSameCourseAndWeeks && isDuplicate) {
  125. continue;
  126. } else {
  127. step1Merged.push(current);
  128. current = next;
  129. }
  130. }
  131. step1Merged.push(current);
  132. // 阶段 2:合并同节次的周次(前提:名称、教师、地点、星期、开始/结束节次一致)
  133. step1Merged.sort((a, b) => {
  134. return a.name.localeCompare(b.name) ||
  135. a.teacher.localeCompare(b.teacher) ||
  136. a.position.localeCompare(b.position) ||
  137. (a.day || 0) - (b.day || 0) ||
  138. (a.startSection || 0) - (b.startSection || 0) ||
  139. (a.endSection || 0) - (b.endSection || 0);
  140. });
  141. const step2Merged = [];
  142. let cur = step1Merged[0];
  143. for (let i = 1; i < step1Merged.length; i++) {
  144. const nxt = step1Merged[i];
  145. const isSameCourseAndSection =
  146. cur.name === nxt.name &&
  147. cur.teacher === nxt.teacher &&
  148. cur.position === nxt.position &&
  149. cur.day === nxt.day &&
  150. cur.startSection === nxt.startSection &&
  151. cur.endSection === nxt.endSection;
  152. if (isSameCourseAndSection) {
  153. cur.weeks = Array.from(new Set([...cur.weeks, ...nxt.weeks])).sort((a, b) => a - b);
  154. } else {
  155. step2Merged.push(cur);
  156. cur = nxt;
  157. }
  158. }
  159. step2Merged.push(cur);
  160. return step2Merged;
  161. }
  162. /**
  163. * 取单元格文本。
  164. * 课表每个格子里是 <br> 分行的文本,DOMParser 解析出来的文档没有 innerText,
  165. * 先把 <br> 换成换行再取 textContent。
  166. */
  167. function readCellText(cell) {
  168. const clone = cell.cloneNode(true);
  169. Array.from(clone.querySelectorAll("br")).forEach(br => br.replaceWith("\n"));
  170. return clone.textContent.replace(/ /g, " ").replace(/\r/g, "");
  171. }
  172. /**
  173. * 把「上3,4」「晚9,10,11」里的节次换算成全天连续节次。
  174. * 研究生系统文本里的节次就是全天连续编号(晚上从 9 开始),
  175. * 但个别部署可能写成段内编号(下1,2 / 晚1,2,3),这里两种写法都兼容。
  176. */
  177. function normalizeSection(periodPrefix, number) {
  178. if (periodPrefix === "下") return number <= 4 ? number + 4 : number;
  179. if (periodPrefix === "晚") return number <= 3 ? number + 8 : number;
  180. return number;
  181. }
  182. const CHINESE_WEEKDAY_MAP = {
  183. "一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "日": 7, "天": 7
  184. };
  185. /**
  186. * 解析一个课表单元格的文本。
  187. * 典型内容:
  188. * 课程:ESP(专门用途英语)
  189. * 班级:ESP(专门用途英语)5班理工
  190. * (Y206(M) )
  191. * 第3-18周 单双周:星期二 上3,4 星期五 上1,2
  192. * 主讲教师:史莹娟
  193. * 一门课在同一格里会列出全部上课时段(上面的例子就是周二 + 周五两段),
  194. * 所以一个单元格可能产出多条课程记录。
  195. */
  196. function parseCourseCellText(text) {
  197. const lines = String(text).split("\n").map(line => line.trim()).filter(line => line.length > 0);
  198. let name = "";
  199. let className = "";
  200. let teacher = "";
  201. let position = "";
  202. let scheduleLine = "";
  203. for (const line of lines) {
  204. if (/^课程[::]/.test(line)) {
  205. name = line.replace(/^课程[::]\s*/, "").trim();
  206. continue;
  207. }
  208. if (/^班级[::]/.test(line)) {
  209. className = line.replace(/^班级[::]\s*/, "").trim();
  210. continue;
  211. }
  212. if (/^主讲教师[::]/.test(line)) {
  213. teacher = line.replace(/^主讲教师[::]\s*/, "").trim();
  214. continue;
  215. }
  216. if (/^[((].*[))]$/.test(line)) {
  217. // 教室整行被括号包住,去掉最外层括号(教室内名可能自带括号,如 Y206(M))
  218. position = line.replace(/^[((]/, "").replace(/[))]$/, "").trim();
  219. continue;
  220. }
  221. if (line.indexOf("周") !== -1) {
  222. scheduleLine = scheduleLine ? `${scheduleLine} ${line}` : line;
  223. }
  224. }
  225. // 名称、周次、上课时段是排课必需的,缺一不可
  226. if (!name || !scheduleLine) return [];
  227. const weeks = parseWeeks(scheduleLine);
  228. if (weeks.length === 0) return [];
  229. const sessionPattern = /星期([一二三四五六日天])\s*([上下晚])\s*([0-9,,、\s]+)/g;
  230. const records = [];
  231. let match;
  232. while ((match = sessionPattern.exec(scheduleLine)) !== null) {
  233. const day = CHINESE_WEEKDAY_MAP[match[1]];
  234. if (!day) continue;
  235. const numbers = arrayFilter(
  236. match[3].split(/[^0-9]+/).map(item => Number(item)),
  237. (item) => !isNaN(item) && item > 0
  238. );
  239. if (numbers.length === 0) continue;
  240. const sections = numbers.map(number => normalizeSection(match[2], number)).sort((a, b) => a - b);
  241. const startSection = sections[0];
  242. const endSection = sections[sections.length - 1];
  243. if (startSection < 1 || endSection > 20) continue;
  244. const remarkParts = [];
  245. if (className) remarkParts.push(`班级:${className}`);
  246. if (scheduleLine) remarkParts.push(scheduleLine.trim());
  247. records.push({
  248. name: name,
  249. teacher: teacher,
  250. position: position,
  251. day: day,
  252. startSection: startSection,
  253. endSection: endSection,
  254. weeks: weeks,
  255. remark: remarkParts.join(" | ")
  256. });
  257. }
  258. return records;
  259. }
  260. /**
  261. * 解析课表页 #MainWork_DataGrid1 里的全部课程。
  262. * 每个单元格自带完整文本(含星期、节次、周次),不必按表格行列还原,
  263. * 重复出现的记录交给 mergeAndDistinctCourses 去重。
  264. */
  265. function parseCoursesFromGrid(doc) {
  266. const grid = doc.querySelector("#MainWork_DataGrid1");
  267. if (!grid) return [];
  268. const courses = [];
  269. for (const cell of Array.from(grid.querySelectorAll("td"))) {
  270. const text = readCellText(cell);
  271. if (text.indexOf("课程") === -1) continue;
  272. courses.push(...parseCourseCellText(text));
  273. }
  274. return mergeAndDistinctCourses(courses);
  275. }
  276. /**
  277. * 研究生楼作息时间(江苏大学教务处公布的作息,两季对比如下)。
  278. *
  279. * 上午一年四季不变,与本科三江楼(B)那套一致;
  280. * 下午/晚上分夏令时与冬令时两套,与本科本部(E)那套一致:
  281. * 夏令时:下午 14:00 开始,晚上 19:00 开始;
  282. * 冬令时:下午 13:30 开始,晚上 18:30 开始。
  283. *
  284. * 节次是全天连续编号(上午 1-4、下午 5-8、晚上 9-11),与课表文本里的「上3,4」「晚9,10,11」一致。
  285. */
  286. const MorningTimeSlots = [
  287. { number: 1, startTime: "08:00", endTime: "08:45" },
  288. { number: 2, startTime: "08:55", endTime: "09:40" },
  289. { number: 3, startTime: "10:10", endTime: "10:55" },
  290. { number: 4, startTime: "11:05", endTime: "11:50" }
  291. ];
  292. // 夏令时 下午
  293. const SummerAfternoonTimeSlots = [
  294. { number: 5, startTime: "14:00", endTime: "14:45" },
  295. { number: 6, startTime: "14:55", endTime: "15:40" },
  296. { number: 7, startTime: "16:00", endTime: "16:45" },
  297. { number: 8, startTime: "16:55", endTime: "17:40" }
  298. ];
  299. // 夏令时 晚上
  300. const SummerEveningTimeSlots = [
  301. { number: 9, startTime: "19:00", endTime: "19:45" },
  302. { number: 10, startTime: "19:55", endTime: "20:40" },
  303. { number: 11, startTime: "20:50", endTime: "21:35" }
  304. ];
  305. // 冬令时 下午
  306. const WinterAfternoonTimeSlots = [
  307. { number: 5, startTime: "13:30", endTime: "14:15" },
  308. { number: 6, startTime: "14:25", endTime: "15:10" },
  309. { number: 7, startTime: "15:30", endTime: "16:15" },
  310. { number: 8, startTime: "16:25", endTime: "17:10" }
  311. ];
  312. // 冬令时 晚上
  313. const WinterEveningTimeSlots = [
  314. { number: 9, startTime: "18:30", endTime: "19:15" },
  315. { number: 10, startTime: "19:25", endTime: "20:10" },
  316. { number: 11, startTime: "20:20", endTime: "21:05" }
  317. ];
  318. // 两季各一套完整作息(上午、下午、晚上依次顺延,共 11 节)
  319. const SummerTimeSlots = [...MorningTimeSlots, ...SummerAfternoonTimeSlots, ...SummerEveningTimeSlots];
  320. const WinterTimeSlots = [...MorningTimeSlots, ...WinterAfternoonTimeSlots, ...WinterEveningTimeSlots];
  321. /**
  322. * 组合作息方案名称。
  323. */
  324. const COMBO_SCHEDULE_NAME = "江苏大学研究生作息(夏令时 / 冬令时自动切换)";
  325. /**
  326. * 判断当前是否处于夏令时作息。
  327. * 学校历年都是 4 月 7 日起执行夏令时、10 月 7 日起执行冬令时(十一假期结束后切回)。
  328. * 这里只用于决定「基础时间段」写入哪一套,真正生效的时间由组合作息按日期决定。
  329. */
  330. function isSummerTimeNow() {
  331. const now = new Date();
  332. const summerStart = new Date(now.getFullYear(), 3, 7); // 4月7日
  333. const winterStart = new Date(now.getFullYear(), 9, 7); // 10月7日
  334. return now >= summerStart && now < winterStart;
  335. }
  336. /**
  337. * 把日期字符串("YYYY-MM-DD")加减天数,返回同样格式的字符串。
  338. */
  339. function shiftDate(dateStr, days) {
  340. const date = new Date(`${dateStr}T00:00:00`);
  341. date.setDate(date.getDate() + days);
  342. return formatDate(date);
  343. }
  344. /**
  345. * 构造组合作息方案(comboSchedule)。
  346. *
  347. * 规则按日期区间匹配:夏令时 4/7–10/6,冬令时 10/7–次年 4/6。
  348. * 两者首尾相接,全年任意一天都能命中一条,不存在空档。
  349. *
  350. * 一个学期可能横跨两套令时区间,也可能跨年:
  351. * 秋季学期(9 月开学、次年 1 月结束):当年夏令时尾巴 + 当年冬令时;
  352. * 春季学期(2 月开学、7 月结束):上一年冬令时尾巴 + 当年夏令时。
  353. * 所以这里不假设"学期属于某一年",而是把学期覆盖到的每一年都生成一对区间,
  354. * 再逐个与学期区间求交集,命中的才写进方案——这样任何学期都能被完整覆盖。
  355. *
  356. * 拿到开学日期时,方案只覆盖本学期,避免与其它学期的作息方案互相干扰;
  357. * 拿不到开学日期时退回全年固定的两个区间(以导入当天的"夏令时年份"为基准)。
  358. *
  359. * @param {string|null} semesterStartDate 本学期开学日期 "YYYY-MM-DD",取不到传 null
  360. * @param {number} totalWeeks 本学期总周数
  361. */
  362. function buildComboSchedule(semesterStartDate, totalWeeks) {
  363. // 本学期日期区间(含最后一周的最后一天)
  364. const semesterEnd = semesterStartDate
  365. ? shiftDate(semesterStartDate, Math.max(1, totalWeeks) * 7 - 1)
  366. : null;
  367. /**
  368. * 求「本学期」与「某个令时区间」的交集,无交集返回 null。
  369. */
  370. const intersect = (seasonStart, seasonEnd) => {
  371. if (!semesterStartDate || !semesterEnd) return null;
  372. const start = semesterStartDate > seasonStart ? semesterStartDate : seasonStart;
  373. const end = semesterEnd < seasonEnd ? semesterEnd : seasonEnd;
  374. return start <= end ? { startDate: start, endDate: end } : null;
  375. };
  376. // 学期覆盖到的年份范围:往前多取一年,兜住"1 月结束的秋季学期"和"2 月开学的春季学期"
  377. const now = new Date();
  378. const month = now.getMonth() + 1;
  379. const fallbackSummerYear = (month >= 8 || month < 4) ? now.getFullYear() : now.getFullYear() - 1;
  380. const firstYear = semesterStartDate
  381. ? Number(semesterStartDate.slice(0, 4))
  382. : fallbackSummerYear;
  383. const lastYear = semesterEnd ? Number(semesterEnd.slice(0, 4)) : firstYear;
  384. const publicSchedules = [];
  385. for (let year = firstYear - 1; year <= lastYear; year++) {
  386. // 该年度的夏令时:year/4/7 – year/10/6;冬令时:year/10/7 – (year+1)/4/6
  387. const summerRange = intersect(`${year}-04-07`, `${year}-10-06`);
  388. if (summerRange) {
  389. publicSchedules.push({
  390. name: "夏令时",
  391. startDate: summerRange.startDate,
  392. endDate: summerRange.endDate,
  393. defaultClassDuration: 45,
  394. defaultBreakDuration: 10,
  395. timeSlots: SummerTimeSlots
  396. });
  397. }
  398. const winterRange = intersect(`${year}-10-07`, `${year + 1}-04-06`);
  399. if (winterRange) {
  400. publicSchedules.push({
  401. name: "冬令时",
  402. startDate: winterRange.startDate,
  403. endDate: winterRange.endDate,
  404. defaultClassDuration: 45,
  405. defaultBreakDuration: 10,
  406. timeSlots: WinterTimeSlots
  407. });
  408. }
  409. }
  410. // 拿不到开学日期,或学期与所有令时区间都没有交集(例如纯夏季小学期)时,退回全年固定区间
  411. if (publicSchedules.length === 0) {
  412. publicSchedules.push({
  413. name: "夏令时",
  414. startDate: `${fallbackSummerYear}-04-07`,
  415. endDate: `${fallbackSummerYear}-10-06`,
  416. defaultClassDuration: 45,
  417. defaultBreakDuration: 10,
  418. timeSlots: SummerTimeSlots
  419. });
  420. publicSchedules.push({
  421. name: "冬令时",
  422. startDate: `${fallbackSummerYear}-10-07`,
  423. endDate: `${fallbackSummerYear + 1}-04-06`,
  424. defaultClassDuration: 45,
  425. defaultBreakDuration: 10,
  426. timeSlots: WinterTimeSlots
  427. });
  428. }
  429. // 按开始日期排序,保证规则顺序与时间顺序一致(应用取第一条命中的规则)
  430. publicSchedules.sort((a, b) => a.startDate.localeCompare(b.startDate));
  431. return {
  432. name: COMBO_SCHEDULE_NAME,
  433. publicSchedules: publicSchedules
  434. };
  435. }
  436. /**
  437. * 读取下拉框当前选中项的值。
  438. * DOMParser 解析出的文档不一定支持 select.value,这里按 selected 属性自行判断。
  439. */
  440. function getSelectedOptionValue(select) {
  441. if (!select || !select.options || select.options.length === 0) return "";
  442. for (const option of Array.from(select.options)) {
  443. if (option.hasAttribute("selected")) return option.value;
  444. }
  445. return select.options[0].value;
  446. }
  447. /**
  448. * 收集表单里可提交的字段,模拟一次完整回发。
  449. * ASP.NET 的 __VIEWSTATE 等方法不能少,逐个手写容易漏,这里整体搬运后按需覆盖。
  450. */
  451. function collectPostbackFields(doc, overrides) {
  452. const params = new URLSearchParams();
  453. const form = doc.querySelector("#form1") || doc.querySelector("form");
  454. if (!form) return params;
  455. for (const el of Array.from(form.querySelectorAll("input[name], select[name], textarea[name]"))) {
  456. const tag = el.tagName.toLowerCase();
  457. const name = el.getAttribute("name");
  458. if (!name) continue;
  459. if (tag === "select") {
  460. params.set(name, getSelectedOptionValue(el));
  461. continue;
  462. }
  463. const type = (el.getAttribute("type") || "text").toLowerCase();
  464. if (type === "submit" || type === "image" || type === "button" || type === "reset") {
  465. // 图片按钮/提交按钮需要 .x / .y,或者完全不提交,这里跳过由调用方补充
  466. continue;
  467. }
  468. if (type === "checkbox" || type === "radio") {
  469. if (!el.hasAttribute("checked")) continue;
  470. }
  471. params.set(name, el.getAttribute("value") || "");
  472. }
  473. Object.keys(overrides || {}).forEach(key => params.set(key, overrides[key]));
  474. return params;
  475. }
  476. /**
  477. * 以 form 表单方式回发并返回响应 HTML。
  478. */
  479. async function postForm(url, params) {
  480. const response = await fetch(url, {
  481. method: "POST",
  482. headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" },
  483. body: params.toString(),
  484. credentials: "include"
  485. });
  486. if (!response.ok) {
  487. throw new Error(`回发失败,状态码 ${response.status}`);
  488. }
  489. return await response.text();
  490. }
  491. /**
  492. * 读取指定学期的课表页文档。
  493. * 课表页默认渲染系统当前学期;要查历史/其它学期得按页面自身的逻辑回发两次:
  494. * 先把学期下拉框回发一次(换出新的 __VIEWSTATE),再点一次「查询」按钮重新绑定课表。
  495. * @param {string} semesterValue 学期下拉框的 value,为空表示用默认(当前)学期
  496. */
  497. async function fetchTimetableDocument(semesterValue) {
  498. const url = buildApiUrl("/pyxx/pygl/kbcx_xs.aspx");
  499. const pageResponse = await fetch(url, { method: "GET", credentials: "include" });
  500. const pageDoc = new DOMParser().parseFromString(await pageResponse.text(), "text/html");
  501. const semesterSelect = pageDoc.querySelector("#MainWork_drpxq");
  502. if (!semesterSelect) {
  503. throw new Error("NOT_LOGGED_IN");
  504. }
  505. const currentValue = getSelectedOptionValue(semesterSelect);
  506. if (!semesterValue || semesterValue === currentValue) {
  507. return pageDoc;
  508. }
  509. // 第一步:学期下拉框回发,拿到与新选中项对应的 __VIEWSTATE
  510. const firstParams = collectPostbackFields(pageDoc, {
  511. "__EVENTTARGET": "ctl00$MainWork$drpxq",
  512. "__EVENTARGUMENT": "",
  513. "ctl00$MainWork$drpxq": semesterValue
  514. });
  515. const afterChangeDoc = new DOMParser().parseFromString(await postForm(url, firstParams), "text/html");
  516. // 第二步:点「查询」按钮,课表才按新学期的选项重新绑定
  517. const secondParams = collectPostbackFields(afterChangeDoc, {
  518. "__EVENTTARGET": "",
  519. "__EVENTARGUMENT": "",
  520. "ctl00$MainWork$drpxq": semesterValue,
  521. "ctl00$MainWork$btnSearch.x": "5",
  522. "ctl00$MainWork$btnSearch.y": "5"
  523. });
  524. const resultDoc = new DOMParser().parseFromString(await postForm(url, secondParams), "text/html");
  525. if (!resultDoc.querySelector("#MainWork_DataGrid1")) {
  526. throw new Error("POSTBACK_FAILED");
  527. }
  528. return resultDoc;
  529. }
  530. /**
  531. * 读取学期下拉框的全部选项。
  532. */
  533. function readSemesterOptions(doc) {
  534. const select = doc.querySelector("#MainWork_drpxq");
  535. if (!select) return null;
  536. const options = Array.from(select.options)
  537. .map(option => ({
  538. value: option.value,
  539. text: option.textContent.trim(),
  540. selected: option.hasAttribute("selected")
  541. }))
  542. .filter(option => option.text);
  543. if (options.length === 0) return null;
  544. let defaultIndex = options.findIndex(option => option.selected);
  545. if (defaultIndex === -1) defaultIndex = 0;
  546. return { options: options, defaultIndex: defaultIndex };
  547. }
  548. function formatDate(date) {
  549. const pad = (value) => String(value).padStart(2, "0");
  550. return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
  551. }
  552. function addDays(date, days) {
  553. const result = new Date(date.getFullYear(), date.getMonth(), date.getDate());
  554. result.setDate(result.getDate() + days);
  555. return result;
  556. }
  557. /**
  558. * 查询某天「我的课表」接口,判断目标课程当天是否上课。
  559. * App_Ajax/GetkcHandler.ashx 是首页「我的课表」按日期查询用的接口,
  560. * 传入日期就返回当天课程,可以用来反推开学日期。
  561. */
  562. async function hasCourseOn(date, courseName, studentId) {
  563. const url = buildApiUrl(
  564. `/pyxx/App_Ajax/GetkcHandler.ashx?kcdate=${formatDate(date)}&xh=${encodeURIComponent(studentId)}`
  565. );
  566. const response = await fetch(url, { method: "GET", credentials: "include" });
  567. if (!response.ok) return false;
  568. const doc = new DOMParser().parseFromString(await response.text(), "text/html");
  569. const text = (doc.body ? doc.body.textContent : "").replace(/\s+/g, "");
  570. return text.indexOf(String(courseName).replace(/\s+/g, "")) !== -1;
  571. }
  572. /**
  573. * 找到参照日期:距离今天最近的一个「目标星期」的日期(今天或之前)。
  574. * 星期编号 1-7 对应周一到周日。
  575. */
  576. function findReferenceDate(day) {
  577. const today = new Date();
  578. const jsDay = today.getDay(); // 0=周日
  579. const todayDay = jsDay === 0 ? 7 : jsDay;
  580. let diff = todayDay - day;
  581. if (diff < 0) diff += 7;
  582. return addDays(today, -diff);
  583. }
  584. /**
  585. * 挑选用于探测的课程:起始周最早、周次跨度最短的那门,
  586. * 跨度短意味着要核对的周次少。
  587. */
  588. function pickProbeCourse(courses) {
  589. let best = null;
  590. for (const course of courses) {
  591. if (!course.weeks || course.weeks.length === 0) continue;
  592. const weeks = course.weeks;
  593. const span = weeks[weeks.length - 1] - weeks[0];
  594. if (!best ||
  595. weeks[0] < best.weeks[0] ||
  596. (weeks[0] === best.weeks[0] && span < best.span)) {
  597. best = { name: course.name, day: course.day, weeks: weeks, span: span };
  598. }
  599. }
  600. return best;
  601. }
  602. /**
  603. * 反推本学期开学日期。
  604. *
  605. * 研究生系统没有校历接口,只能借「我的课表」接口逐周回查:
  606. * 以目标课程所在星期的最近一个日期为参照,按周展开探测(区间大约覆盖前后一个学期),
  607. * 再把每个命中日期当作「该课第一次上课的那天」,用课程自己的周次表去核对:
  608. * 前一周必须没课,且课程周次表里的每一周都应当对得上。
  609. * 核对通过后,开学日期 = 首次上课日期 - ((起始周 - 1) * 7 + (星期 - 1)),按周一为一周之始。
  610. *
  611. * 校验通过多个候选时取离今天最近的那个,避免把上一学年的同名课程认成本学期。
  612. * 任何一步拿不到结果就返回 null,由调用方跳过配置保存。
  613. */
  614. async function deriveSemesterStartDate(courses, studentId) {
  615. if (!studentId) {
  616. console.warn("JS: 未取到学号,跳过开学日期推算。");
  617. return null;
  618. }
  619. const target = pickProbeCourse(courses);
  620. if (!target) {
  621. console.warn("JS: 没有可用于探测的课程,跳过开学日期推算。");
  622. return null;
  623. }
  624. const reference = findReferenceDate(target.day);
  625. const maxBackWeeks = 40;
  626. const maxForwardWeeks = 12;
  627. const offsets = [0];
  628. for (let week = 1; week <= maxBackWeeks; week++) {
  629. offsets.push(-week);
  630. if (week <= maxForwardWeeks) offsets.push(week);
  631. }
  632. const observed = new Map();
  633. const batchSize = 2;
  634. let hitBatchIndex = -1;
  635. for (let index = 0; index < offsets.length; index += batchSize) {
  636. const batch = offsets.slice(index, index + batchSize);
  637. const results = await Promise.all(
  638. batch.map(async offset => ({
  639. offset: offset,
  640. hit: await hasCourseOn(addDays(reference, offset * 7), target.name, studentId)
  641. }))
  642. );
  643. for (const item of results) {
  644. observed.set(item.offset, item.hit);
  645. }
  646. if (hitBatchIndex === -1 && results.some(item => item.hit)) {
  647. hitBatchIndex = index / batchSize;
  648. }
  649. // 找到命中后再多探一批,够核对课程周次就够了,不必把整个区间探完
  650. if (hitBatchIndex !== -1 && index / batchSize > hitBatchIndex) {
  651. break;
  652. }
  653. // 稍作等待防止风控
  654. const delay = 300 + Math.floor(Math.random() * 200);
  655. await new Promise(resolve => setTimeout(resolve, delay));
  656. }
  657. const hitOffsets = Array.from(observed.keys())
  658. .filter(offset => observed.get(offset))
  659. .sort((a, b) => Math.abs(a) - Math.abs(b) || a - b);
  660. if (hitOffsets.length === 0) {
  661. console.warn(`JS: 前后约一个学期内未探到课程「${target.name}」,跳过开学日期推算。`);
  662. return null;
  663. }
  664. const firstWeek = target.weeks[0];
  665. // 单双周课程每隔一周才上课,用课程自己的周次间隔做「上一次课」的核对距离,
  666. // 否则会把「课程中途的某一次课」误判成起始周(相邻一周本来就该没课)。
  667. const weekStep = target.weeks.length > 1 ? target.weeks[1] - target.weeks[0] : 1;
  668. for (const candidate of hitOffsets) {
  669. // 起始周之前的那一次课必须没有课,否则这个候选只是课程中途的一次课
  670. if (observed.get(candidate - weekStep) === true) continue;
  671. // 用课程自己的周次表核对:课程上课的每一周都应探到课
  672. let consistent = true;
  673. let checked = 0;
  674. for (const week of target.weeks) {
  675. const offset = candidate + (week - firstWeek);
  676. const seen = observed.get(offset);
  677. if (seen === undefined) continue;
  678. checked += 1;
  679. if (!seen) {
  680. consistent = false;
  681. break;
  682. }
  683. }
  684. if (!consistent || checked < 1) continue;
  685. const firstClassDate = addDays(reference, candidate * 7);
  686. const startDate = addDays(firstClassDate, -((firstWeek - 1) * 7 + (target.day - 1)));
  687. // 推算出的开学日期必须是周一,否则说明数据对不上,宁可不写
  688. if (startDate.getDay() !== 1) continue;
  689. console.log(`JS: 探测课程「${target.name}」首次上课日期 ${formatDate(firstClassDate)}(第 ${firstWeek} 周),推算开学日期 ${formatDate(startDate)}。`);
  690. return formatDate(startDate);
  691. }
  692. console.warn(`JS: 课程「${target.name}」的探测结果与周次表对不上,跳过开学日期推算。`);
  693. return null;
  694. }
  695. /**
  696. * 计算本学期总周数:取所有课程周次的最大值。
  697. * 研究生系统没有校历,这个值只是课表的周次上限,多算几周无空白无妨,少算会截断课程。
  698. */
  699. function calculateTotalWeeks(courses) {
  700. let maxWeek = 0;
  701. for (const course of courses) {
  702. for (const week of course.weeks) {
  703. if (week > maxWeek) maxWeek = week;
  704. }
  705. }
  706. return maxWeek;
  707. }
  708. async function promptUserToStart() {
  709. return await window.shiguangBridgePromise.showAlert(
  710. "研究生系统课表导入",
  711. "导入前请确保您已在浏览器中登录江苏大学研究生管理系统(可从综合服务门户点「研究生管理系统」进入,或直接访问 http://yjsgll.ujs.edu.cn/pyxx/)。",
  712. "好的,开始导入"
  713. );
  714. }
  715. async function saveCourses(parsedCourses) {
  716. try {
  717. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(parsedCourses));
  718. return true;
  719. } catch (error) {
  720. window.shiguangBridge.showToast(`课程保存失败: ${error.message}`);
  721. console.error("JS: Save Courses Error:", error);
  722. return false;
  723. }
  724. }
  725. /**
  726. * 只在能拿到真实开学日期时写入课表配置。
  727. * 应用侧的 saveCourseConfig 是整体覆盖而非字段级合并:没有传入的字段会被写成模型默认值,
  728. * 其中 semesterStartDate 的默认值是 null,会把用户已经设置好的开学日期清空。
  729. * 所以拿不到开学日期时返回 null,宁可不写也不要写坏。
  730. */
  731. async function saveCourseConfigIfPossible(config) {
  732. if (!config) {
  733. window.shiguangBridge.showToast("未取到本学期开学日期,已跳过课表配置,请在应用内手动设置开学日期。");
  734. console.log("JS: 无可用开学日期,跳过 saveCourseConfig 以保留用户现有配置。");
  735. return;
  736. }
  737. try {
  738. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  739. window.shiguangBridge.showToast(
  740. `课表配置更新成功!开学日期 ${config.semesterStartDate},总周数 ${config.semesterTotalWeeks} 周。`
  741. );
  742. } catch (error) {
  743. window.shiguangBridge.showToast(`课表配置保存失败: ${error.message}`);
  744. console.error("JS: Save Config Error:", error);
  745. }
  746. }
  747. /**
  748. * 提交基础时间段(组合作息的骨架)。
  749. * 必须成功,否则后面的 saveComboSchedule 会被应用直接拒绝(顺序强校验)。
  750. */
  751. async function importPresetTimeSlots(timeSlots) {
  752. if (timeSlots.length === 0) {
  753. window.shiguangBridge.showToast("警告:时间段为空,未导入时间段信息。");
  754. return false;
  755. }
  756. window.shiguangBridge.showToast(`正在导入 ${timeSlots.length} 个预设时间段...`);
  757. try {
  758. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  759. return true;
  760. } catch (error) {
  761. window.shiguangBridge.showToast("导入时间段失败: " + error.message);
  762. console.error("JS: Save Time Slots Error:", error);
  763. return false;
  764. }
  765. }
  766. /**
  767. * 提交组合作息方案:夏令时 / 冬令时按日期区间自动切换。
  768. * 应用会以 importPresetTimeSlots 提交的基础时间段为骨架做差量合并,
  769. * 本方案每个模板都提交完整 1-11 节,所以合并结果与模板本身一致。
  770. * 调用后本次导入任务不再允许调用 savePresetTimeSlots,因此顺序必须固定为「先时间段、后方案」。
  771. */
  772. async function importComboSchedule(comboSchedule) {
  773. window.shiguangBridge.showToast("正在导入夏令时/冬令时组合作息方案...");
  774. try {
  775. await window.shiguangBridgePromise.saveComboSchedule(JSON.stringify(comboSchedule));
  776. console.log("JS: 组合作息方案已导入:", comboSchedule);
  777. return true;
  778. } catch (error) {
  779. window.shiguangBridge.showToast("导入作息方案失败: " + error.message);
  780. console.error("JS: Save Combo Schedule Error:", error);
  781. return false;
  782. }
  783. }
  784. async function runImportFlow() {
  785. if (!isGraduateSystemPage()) {
  786. window.shiguangBridge.showToast("导入失败:请先进入江苏大学研究生管理系统!");
  787. console.log("JS: 当前不在研究生管理系统页面,终止导入。");
  788. return;
  789. }
  790. if (isLoginPage()) {
  791. window.shiguangBridge.showToast("导入失败:请先登录研究生管理系统!");
  792. console.log("JS: 检测到当前在登录页面,终止导入。");
  793. return;
  794. }
  795. const alertConfirmed = await promptUserToStart();
  796. if (!alertConfirmed) {
  797. window.shiguangBridge.showToast("用户取消了导入。");
  798. return;
  799. }
  800. window.shiguangBridge.showToast("正在读取课表数据...");
  801. // 先取一次默认(当前)学期,用于学期列表与课表
  802. let defaultDoc;
  803. try {
  804. defaultDoc = await fetchTimetableDocument("");
  805. } catch (error) {
  806. if (error.message === "NOT_LOGGED_IN") {
  807. window.shiguangBridge.showToast("未登录或登录已过期,请先登录研究生管理系统。");
  808. } else {
  809. window.shiguangBridge.showToast(`读取课表失败: ${error.message}`);
  810. }
  811. console.error("JS: 读取课表页失败:", error);
  812. return;
  813. }
  814. const semesterData = readSemesterOptions(defaultDoc);
  815. if (!semesterData) {
  816. window.shiguangBridge.showToast("未能读取学期列表,请确认已登录并停留在研究生管理系统。");
  817. return;
  818. }
  819. const semesterIndex = await window.shiguangBridgePromise.showSingleSelection(
  820. "选择学期",
  821. JSON.stringify(semesterData.options.map(item => item.text)),
  822. semesterData.defaultIndex
  823. );
  824. if (semesterIndex === null || semesterIndex === -1) {
  825. window.shiguangBridge.showToast("导入已取消。");
  826. return;
  827. }
  828. const semesterValue = semesterData.options[semesterIndex].value;
  829. const semesterText = semesterData.options[semesterIndex].text;
  830. let timetableDoc = defaultDoc;
  831. if (semesterIndex !== semesterData.defaultIndex) {
  832. window.shiguangBridge.showToast(`正在切换到 ${semesterText} ...`);
  833. try {
  834. timetableDoc = await fetchTimetableDocument(semesterValue);
  835. } catch (error) {
  836. window.shiguangBridge.showToast(`切换学期失败: ${error.message}`);
  837. console.error("JS: 切换学期失败:", error);
  838. return;
  839. }
  840. }
  841. const courses = parseCoursesFromGrid(timetableDoc);
  842. if (courses.length === 0) {
  843. window.shiguangBridge.showToast(`${semesterText} 未找到任何课程,请确认所选学期是否正确。`);
  844. console.log("JS: 课表为空,终止导入。");
  845. return;
  846. }
  847. console.log(`JS: 解析到 ${courses.length} 门课程。`);
  848. // 学号取自课表页隐藏域,用来查「我的课表」接口
  849. const studentId = (timetableDoc.querySelector("#MainWork_WUCpyjhdy_HFfilename") || {}).value || "";
  850. window.shiguangBridge.showToast("正在推算开学日期...");
  851. const semesterStartDate = await deriveSemesterStartDate(courses, studentId);
  852. const totalWeeks = calculateTotalWeeks(courses);
  853. const config = semesterStartDate
  854. ? {
  855. semesterStartDate: semesterStartDate,
  856. semesterTotalWeeks: totalWeeks,
  857. firstDayOfWeek: 1
  858. }
  859. : null;
  860. // 基础时间段(骨架)用导入当下的令时那一套;方案里两个模板都会提交完整 11 节,
  861. // 骨架实际只是用来满足「先时间段、后方案」的顺序要求,不会影响最终显示。
  862. const baseTimeSlots = isSummerTimeNow() ? SummerTimeSlots : WinterTimeSlots;
  863. const comboSchedule = buildComboSchedule(semesterStartDate, totalWeeks);
  864. const saveResult = await saveCourses(courses);
  865. if (!saveResult) {
  866. console.log("JS: 课程保存失败,流程终止。");
  867. return;
  868. }
  869. await saveCourseConfigIfPossible(config);
  870. // 顺序不可调换:saveComboSchedule 依赖 savePresetTimeSlots 成功写入的基础时间段。
  871. const timeSlotSaved = await importPresetTimeSlots(baseTimeSlots);
  872. if (timeSlotSaved) {
  873. await importComboSchedule(comboSchedule);
  874. } else {
  875. window.shiguangBridge.showToast("时间段导入失败,已跳过组合作息方案。");
  876. console.log("JS: 基础时间段未写入成功,跳过 saveComboSchedule。");
  877. }
  878. const scheduleBrief = comboSchedule.publicSchedules
  879. .map((rule) => `${rule.name} ${rule.startDate} ~ ${rule.endDate}`)
  880. .join("\n");
  881. await window.shiguangBridgePromise.showAlert(
  882. "导入完成",
  883. `已导入 ${semesterText} 共 ${courses.length} 门课程。\n\n` +
  884. `作息已写入组合作息方案(全天 ${baseTimeSlots.length} 节),将于每年 4 月 7 日切换到夏令时、` +
  885. "10 月 7 日切换到冬令时,无需重新导入。\n\n" +
  886. `本次方案的生效区间:\n${scheduleBrief}\n\n` +
  887. (semesterStartDate
  888. ? `开学日期由课表数据反推得到:${semesterStartDate},请在应用内核对。`
  889. : "未能反推开学日期(作息方案已退回全年固定区间),请在应用内手动设置开学日期。"),
  890. "我知道了"
  891. );
  892. window.shiguangBridge.showToast(`课程导入成功,共导入 ${courses.length} 门课程!`);
  893. console.log("JS: 整个导入流程执行完毕并成功。");
  894. window.shiguangBridge.notifyTaskCompletion();
  895. }
  896. runImportFlow();