sysu.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622
  1. // 中山大学教务系统课表导入器
  2. // 功能:获取教务系统课表 -> 转换为目标 JSON -> 通过时光课程表 V2 Bridge 导入
  3. const API_URL = "/jwxt/timetable-search/stuTimeTabPrint/studentQuery";
  4. const CALENDAR_API_URL = "/jwxt/base-info/school-calender";
  5. let ACAD_YEAR = "2026-1";
  6. function validateYearInput(input) {
  7. return /^[0-9]{4}$/.test(String(input).trim())
  8. ? false
  9. : "请输入四位数字的学年!";
  10. }
  11. async function getAcademicYear() {
  12. const defaultYear = ACAD_YEAR.split("-")[0];
  13. const yearSelection = await window.shiguangBridgePromise.showPrompt(
  14. "选择学年",
  15. "请输入要导入课程的学年(如 2026):",
  16. defaultYear || "2026",
  17. "validateYearInput"
  18. );
  19. return yearSelection;
  20. }
  21. async function selectSemester() {
  22. if (!window.shiguangBridgePromise) {
  23. throw new Error(
  24. "shiguangBridgePromise 不可用,请在时光课程表 App 内运行此适配器。"
  25. );
  26. }
  27. if (typeof window.shiguangBridgePromise.showPrompt !== "function") {
  28. throw new Error(
  29. "shiguangBridgePromise.showPrompt 不可用,请在时光课程表 App 内运行此适配器。"
  30. );
  31. }
  32. if (typeof window.shiguangBridgePromise.showSingleSelection !== "function") {
  33. throw new Error(
  34. "shiguangBridgePromise.showSingleSelection 不可用,请在时光课程表 App 内运行此适配器。"
  35. );
  36. }
  37. // 先输入学年
  38. const yearSelection = await getAcademicYear();
  39. if (yearSelection === null) {
  40. return null;
  41. }
  42. const year = String(yearSelection).trim();
  43. // 再选择学期
  44. const semesters = [
  45. "1(第一学期)",
  46. "2(第二学期)"
  47. ];
  48. const [, defaultSemester] = ACAD_YEAR.split("-");
  49. const semesterIndex =
  50. await window.shiguangBridgePromise.showSingleSelection(
  51. "选择学期",
  52. JSON.stringify(semesters),
  53. defaultSemester === "2" ? 1 : 0
  54. );
  55. if (
  56. semesterIndex === null ||
  57. semesterIndex < 0 ||
  58. semesterIndex >= semesters.length
  59. ) {
  60. return null;
  61. }
  62. ACAD_YEAR = `${year}-${semesterIndex + 1}`;
  63. return ACAD_YEAR;
  64. }
  65. // ========================================
  66. // 目标课表时间段
  67. // ========================================
  68. const TIME_SLOTS = [
  69. {
  70. number: 1,
  71. startTime: "08:00",
  72. endTime: "08:45"
  73. },
  74. {
  75. number: 2,
  76. startTime: "08:55",
  77. endTime: "09:40"
  78. },
  79. {
  80. number: 3,
  81. startTime: "10:10",
  82. endTime: "10:55"
  83. },
  84. {
  85. number: 4,
  86. startTime: "11:05",
  87. endTime: "11:50"
  88. },
  89. {
  90. number: 5,
  91. startTime: "14:20",
  92. endTime: "15:05"
  93. },
  94. {
  95. number: 6,
  96. startTime: "15:15",
  97. endTime: "16:00"
  98. },
  99. {
  100. number: 7,
  101. startTime: "16:30",
  102. endTime: "17:15"
  103. },
  104. {
  105. number: 8,
  106. startTime: "17:25",
  107. endTime: "18:10"
  108. },
  109. {
  110. number: 9,
  111. startTime: "19:00",
  112. endTime: "19:45"
  113. },
  114. {
  115. number: 10,
  116. startTime: "19:55",
  117. endTime: "20:40"
  118. },
  119. {
  120. number: 11,
  121. startTime: "20:50",
  122. endTime: "21:35"
  123. }
  124. ];
  125. // ========================================
  126. // 课表配置
  127. // ========================================
  128. const CONFIG_BASE = {
  129. semesterTotalWeeks: 20,
  130. defaultClassDuration: 45,
  131. defaultBreakDuration: 10
  132. };
  133. // ========================================
  134. // 自动获取学期实际开学日期
  135. // ========================================
  136. async function fetchSemesterStartDate() {
  137. const url =
  138. `${CALENDAR_API_URL}?academicYear=${encodeURIComponent(ACAD_YEAR)}` +
  139. `&weekly=1&_t=${Math.floor(Date.now() / 1000)}`;
  140. const response = await fetch(url, {
  141. method: "GET",
  142. credentials: "include"
  143. });
  144. if (!response.ok) {
  145. throw new Error(
  146. `获取学期开始日期失败:HTTP ${response.status}`
  147. );
  148. }
  149. const json = await response.json();
  150. const startDate = json?.data?.startTime;
  151. if (!startDate) {
  152. throw new Error(
  153. `教务系统未返回 ${ACAD_YEAR} 第 1 周的开始日期。`
  154. );
  155. }
  156. return startDate;
  157. }
  158. async function buildCourseConfig() {
  159. const semesterStartDate =
  160. await fetchSemesterStartDate();
  161. return {
  162. semesterStartDate,
  163. ...CONFIG_BASE
  164. };
  165. }
  166. // 请求课程数据
  167. async function fetchCourses() {
  168. const url = `${API_URL}?_t=${Date.now()}`;
  169. const payload = {
  170. acadYear: ACAD_YEAR,
  171. submitFlag: "1",
  172. nothroughCourseFlag: "1"
  173. };
  174. const response = await fetch(url, {
  175. method: "POST",
  176. headers: {
  177. "Content-Type": "application/json"
  178. },
  179. credentials: "include",
  180. body: JSON.stringify(payload)
  181. });
  182. const responseText = await response.text();
  183. if (!response.ok) {
  184. throw new Error(
  185. `API 请求失败:HTTP ${response.status}\n${responseText}`
  186. );
  187. }
  188. let json;
  189. try {
  190. json = JSON.parse(responseText);
  191. } catch {
  192. throw new Error("教务系统返回的课程数据不是有效的 JSON。\n");
  193. }
  194. if (json?.code !== 200) {
  195. throw new Error(
  196. `API 返回异常 code:${json?.code}`
  197. );
  198. }
  199. const timetable = json?.data?.timetable;
  200. if (
  201. !timetable ||
  202. typeof timetable !== "object" ||
  203. Array.isArray(timetable)
  204. ) {
  205. throw new Error(
  206. "API 返回中不存在有效的 data.timetable"
  207. );
  208. }
  209. const courses = [];
  210. // timetable 的 key:
  211. // 第一位 = 星期
  212. // 后两位 = 起始节次
  213. //
  214. // 例如:
  215. // 11 -> 周一第1节
  216. // 13 -> 周一第3节
  217. // 21 -> 周二第1节
  218. for (const entries of Object.values(timetable)) {
  219. if (!Array.isArray(entries) || entries.length === 0) {
  220. continue;
  221. }
  222. for (const item of entries) {
  223. const course = normalizeCourse(item);
  224. if (course) {
  225. courses.push(course);
  226. }
  227. }
  228. }
  229. // 自动获取学期配置
  230. const config = await buildCourseConfig();
  231. const result = {
  232. courses,
  233. timeSlots: TIME_SLOTS,
  234. config
  235. };
  236. return result;
  237. }
  238. // 课程数据转换
  239. function normalizeCourse(item) {
  240. if (!item || typeof item !== "object") {
  241. return null;
  242. }
  243. const name = cleanCourseName(item.courseName);
  244. const teacher = cleanText(item.teachingStaffName);
  245. const position = cleanText(item.classPlace);
  246. const day = toNumber(item.week);
  247. const startSection = toNumber(item.startClassTimes);
  248. const endSection = toNumber(item.endClassTimes);
  249. const startWeek = toNumber(item.startWeek);
  250. const weeks = parseWeeks(item.timeDetail, startWeek);
  251. if (
  252. !name ||
  253. !day ||
  254. !startSection ||
  255. !endSection ||
  256. weeks.length === 0
  257. ) {
  258. console.warn(
  259. "跳过字段不完整的课程记录:",
  260. item
  261. );
  262. return null;
  263. }
  264. return {
  265. id: crypto.randomUUID(),
  266. name,
  267. teacher,
  268. position,
  269. day,
  270. startSection,
  271. endSection,
  272. weeks
  273. };
  274. }
  275. // 清理课程名称
  276. function cleanCourseName(value) {
  277. const text = cleanText(value);
  278. // 去掉中山大学 API 返回的课程类别前缀
  279. //
  280. // 本(专必)高等数学一(I)
  281. // ↓
  282. // 高等数学一(I)
  283. //
  284. // 本(公必)劳动教育
  285. // ↓
  286. // 劳动教育
  287. return text
  288. .replace(/^本\([^)]*\)/, "")
  289. .trim();
  290. }
  291. // 解析上课周次
  292. function parseWeeks(
  293. timeDetail,
  294. startWeek = 1
  295. ) {
  296. const text =
  297. cleanText(timeDetail);
  298. if (!text) {
  299. return startWeek
  300. ? [startWeek]
  301. : [];
  302. }
  303. // 例如:
  304. // 1-17每周
  305. const rangeMatch =
  306. text.match(/(\d+)\s*-\s*(\d+)/);
  307. if (rangeMatch) {
  308. const start =
  309. Number(rangeMatch[1]);
  310. const end =
  311. Number(rangeMatch[2]);
  312. if (
  313. Number.isFinite(start) &&
  314. Number.isFinite(end) &&
  315. end >= start
  316. ) {
  317. return Array.from(
  318. {
  319. length:
  320. end - start + 1
  321. },
  322. (_, index) =>
  323. start + index
  324. );
  325. }
  326. }
  327. // 兼容:
  328. //
  329. // 1,3,5周
  330. // 1、3、5周
  331. const numbers =
  332. text
  333. .match(/\d+/g)
  334. ?.map(Number)
  335. .filter(Number.isFinite) || [];
  336. if (numbers.length > 0) {
  337. return [
  338. ...new Set(numbers)
  339. ].sort(
  340. (a, b) => a - b
  341. );
  342. }
  343. return startWeek
  344. ? [startWeek]
  345. : [];
  346. }
  347. // 清理字符串
  348. function cleanText(value) {
  349. if (value === null || value === undefined) {
  350. return "";
  351. }
  352. return String(value).replace(/\/+$/g, "").trim();
  353. }
  354. // 数字转换
  355. function toNumber(value) {
  356. const number = Number(value);
  357. return Number.isFinite(number) ? number : null;
  358. }
  359. // 主导入流程
  360. async function runImportFlow() {
  361. try {
  362. // 提示用户选择学期
  363. if (
  364. window.shiguangBridge &&
  365. typeof window.shiguangBridge.showToast ===
  366. "function"
  367. ) {
  368. window.shiguangBridge.showToast(
  369. "请选择要导入的学期..."
  370. );
  371. }
  372. const selectedSemester =
  373. await selectSemester();
  374. if (!selectedSemester) {
  375. if (
  376. window.shiguangBridge &&
  377. typeof window.shiguangBridge.showToast ===
  378. "function"
  379. ) {
  380. window.shiguangBridge.showToast(
  381. "已取消导入。"
  382. );
  383. }
  384. return;
  385. }
  386. // 获取课程
  387. const data =
  388. await fetchCourses();
  389. if (
  390. !data ||
  391. !Array.isArray(data.courses)
  392. ) {
  393. throw new Error(
  394. "课程数据为空或格式不正确。"
  395. );
  396. }
  397. // 检查 V2 Bridge
  398. if (
  399. !window.shiguangBridgePromise
  400. ) {
  401. throw new Error(
  402. "shiguangBridgePromise 不可用,请在时光课程表 App 内运行此适配器。"
  403. );
  404. }
  405. if (
  406. typeof window.shiguangBridgePromise
  407. .saveImportedCourses !==
  408. "function"
  409. ) {
  410. throw new Error(
  411. "saveImportedCourses API 不可用。"
  412. );
  413. }
  414. if (
  415. typeof window.shiguangBridgePromise
  416. .savePresetTimeSlots !==
  417. "function"
  418. ) {
  419. throw new Error(
  420. "savePresetTimeSlots API 不可用。"
  421. );
  422. }
  423. if (
  424. typeof window.shiguangBridgePromise
  425. .saveCourseConfig !==
  426. "function"
  427. ) {
  428. throw new Error(
  429. "saveCourseConfig API 不可用。"
  430. );
  431. }
  432. // 开始导入
  433. if (
  434. window.shiguangBridge &&
  435. typeof window.shiguangBridge.showToast ===
  436. "function"
  437. ) {
  438. window.shiguangBridge.showToast(
  439. `正在导入 ${data.courses.length} 条课程记录...`
  440. );
  441. }
  442. // 保存课程
  443. await window.shiguangBridgePromise
  444. .saveImportedCourses(
  445. JSON.stringify(
  446. data.courses
  447. )
  448. );
  449. // 保存时间段
  450. await window.shiguangBridgePromise
  451. .savePresetTimeSlots(
  452. JSON.stringify(
  453. data.timeSlots
  454. )
  455. );
  456. // 保存课表配置
  457. await window.shiguangBridgePromise
  458. .saveCourseConfig(
  459. JSON.stringify(
  460. data.config
  461. )
  462. );
  463. // 导入完成
  464. if (
  465. window.shiguangBridge &&
  466. typeof window.shiguangBridge.showToast ===
  467. "function"
  468. ) {
  469. window.shiguangBridge.showToast(
  470. `成功导入 ${data.courses.length} 条课程记录!`
  471. );
  472. }
  473. // 通知 App 导入流程结束
  474. if (
  475. window.shiguangBridge &&
  476. typeof window.shiguangBridge
  477. .notifyTaskCompletion ===
  478. "function"
  479. ) {
  480. window.shiguangBridge
  481. .notifyTaskCompletion();
  482. }
  483. } catch (error) {
  484. if (
  485. window.shiguangBridge &&
  486. typeof window.shiguangBridge.showToast ===
  487. "function"
  488. ) {
  489. window.shiguangBridge.showToast(
  490. `导入失败:${error.message}`
  491. );
  492. }
  493. }
  494. }
  495. // 启动
  496. runImportFlow();