sysu.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. // 中山大学教务系统课表导入器
  2. // 功能:获取教务系统课表 -> 转换为目标 JSON -> 自动下载
  3. const API_URL = "/jwxt/timetable-search/stuTimeTabPrint/studentQuery";
  4. let ACAD_YEAR = "2026-1";
  5. const AVAILABLE_YEARS = [2026, 2027];
  6. const AVAILABLE_SEMESTERS = [1, 2];
  7. // 中山大学各学期实际开课日期。
  8. // 日期以中山大学官方校历为准;尚未公布的学期暂不填写,避免错误导入。
  9. const SEMESTER_START_DATES = {
  10. "2025-1": "2025-09-08",
  11. "2025-2": "2026-03-02",
  12. "2026-1": "2026-09-07"
  13. };
  14. function validateAcademicYear(input) {
  15. if (/^(2026|2027)$/.test(String(input).trim())) {
  16. return false;
  17. }
  18. return "请输入 2026 或 2027。";
  19. }
  20. async function selectSemester() {
  21. if (!window.AndroidBridgePromise || typeof window.AndroidBridgePromise.showPrompt !== "function") {
  22. throw new Error("AndroidBridgePromise.showPrompt 不可用,请在时光课程表 App 内运行此适配器。");
  23. }
  24. const [defaultYear, defaultSemester] = ACAD_YEAR.split("-");
  25. const yearInput = await window.AndroidBridgePromise.showPrompt(
  26. "选择学年",
  27. "请输入学年,例如:2026",
  28. defaultYear || "2026",
  29. "validateAcademicYear"
  30. );
  31. if (yearInput === null) {
  32. return null;
  33. }
  34. const year = String(yearInput).trim();
  35. if (typeof window.AndroidBridgePromise.showSingleSelection !== "function") {
  36. throw new Error("AndroidBridgePromise.showSingleSelection 不可用,请在时光课程表 App 内运行此适配器。");
  37. }
  38. const semesters = ["1(第一学期)", "2(第二学期)"];
  39. const defaultSemesterIndex = defaultSemester === "2" ? 1 : 0;
  40. const semesterIndex = await window.AndroidBridgePromise.showSingleSelection(
  41. "选择学期",
  42. JSON.stringify(semesters),
  43. defaultSemesterIndex
  44. );
  45. if (semesterIndex === null || semesterIndex < 0 || semesterIndex >= semesters.length) {
  46. return null;
  47. }
  48. ACAD_YEAR = `${year}-${semesterIndex + 1}`;
  49. return ACAD_YEAR;
  50. }
  51. // 目标课表时间段
  52. const TIME_SLOTS = [
  53. { number: 1, startTime: "08:00", endTime: "08:45" },
  54. { number: 2, startTime: "08:55", endTime: "09:40" },
  55. { number: 3, startTime: "10:10", endTime: "10:55" },
  56. { number: 4, startTime: "11:05", endTime: "11:50" },
  57. { number: 5, startTime: "14:20", endTime: "15:05" },
  58. { number: 6, startTime: "15:15", endTime: "16:00" },
  59. { number: 7, startTime: "16:30", endTime: "17:15" },
  60. { number: 8, startTime: "17:25", endTime: "18:10" },
  61. { number: 9, startTime: "19:00", endTime: "19:45" },
  62. { number: 10, startTime: "19:55", endTime: "20:40" },
  63. { number: 11, startTime: "20:50", endTime: "21:35" }
  64. ];
  65. // 课表配置(学期开始日期需要根据所选学期调整)
  66. const CONFIG_BASE = {
  67. semesterTotalWeeks: 20,
  68. defaultClassDuration: 45,
  69. defaultBreakDuration: 10
  70. };
  71. function getSemesterStartDate() {
  72. const startDate = SEMESTER_START_DATES[ACAD_YEAR];
  73. if (!startDate) {
  74. throw new Error(`暂未配置 ${ACAD_YEAR} 的实际开课日期,请根据中山大学官方校历更新 SEMESTER_START_DATES。`);
  75. }
  76. return startDate;
  77. }
  78. function buildCourseConfig() {
  79. return {
  80. semesterStartDate: getSemesterStartDate(),
  81. ...CONFIG_BASE
  82. };
  83. }
  84. async function fetchCourses() {
  85. console.log("========================================");
  86. console.log("开始请求中山大学教务系统 API...");
  87. console.log("API:", API_URL);
  88. console.log("学期:", ACAD_YEAR);
  89. console.log("========================================");
  90. const response = await fetch(`${API_URL}?_t=${Date.now()}`, {
  91. method: "POST",
  92. headers: {
  93. "Content-Type": "application/json"
  94. },
  95. credentials: "include",
  96. body: JSON.stringify({
  97. acadYear: ACAD_YEAR,
  98. submitFlag: "1",
  99. nothroughCourseFlag: "1"
  100. })
  101. });
  102. console.log("HTTP 状态码:", response.status);
  103. const responseText = await response.text();
  104. if (!response.ok) {
  105. throw new Error(`API 请求失败:HTTP ${response.status}\n${responseText}`);
  106. }
  107. let json;
  108. try {
  109. json = JSON.parse(responseText);
  110. } catch (error) {
  111. console.error("响应不是合法 JSON:", error);
  112. console.error("原始响应:", responseText);
  113. return [];
  114. }
  115. if (json?.code !== 200) {
  116. throw new Error(`API 返回异常 code:${json?.code}`);
  117. }
  118. const timetable = json?.data?.timetable;
  119. if (!timetable || typeof timetable !== "object" || Array.isArray(timetable)) {
  120. throw new Error("API 返回中不存在有效的 data.timetable");
  121. }
  122. const courses = [];
  123. for (const entries of Object.values(timetable)) {
  124. if (!Array.isArray(entries) || entries.length === 0) {
  125. continue;
  126. }
  127. for (const item of entries) {
  128. const course = normalizeCourse(item);
  129. if (course) {
  130. courses.push(course);
  131. }
  132. }
  133. }
  134. const result = {
  135. courses,
  136. timeSlots: TIME_SLOTS,
  137. config: buildCourseConfig()
  138. };
  139. console.log("========================================");
  140. console.log(`课程转换完成,共 ${courses.length} 条记录`);
  141. console.log("最终 JSON:");
  142. console.log(result);
  143. console.table(courses);
  144. console.log("========================================");
  145. return result;
  146. }
  147. function normalizeCourse(item) {
  148. if (!item || typeof item !== "object") {
  149. return null;
  150. }
  151. const name = cleanCourseName(item.courseName);
  152. const teacher = cleanText(item.teachingStaffName);
  153. const position = cleanText(item.classPlace);
  154. const day = toNumber(item.week);
  155. const startSection = toNumber(item.startClassTimes);
  156. const endSection = toNumber(item.endClassTimes);
  157. const startWeek = toNumber(item.startWeek);
  158. const weeks = parseWeeks(item.timeDetail, startWeek);
  159. if (!name || !day || !startSection || !endSection || weeks.length === 0) {
  160. console.warn("跳过字段不完整的课程记录:", item);
  161. return null;
  162. }
  163. return {
  164. id: crypto.randomUUID(),
  165. name,
  166. teacher,
  167. position,
  168. day,
  169. startSection,
  170. endSection,
  171. color: getCourseColor(name),
  172. weeks
  173. };
  174. }
  175. function cleanCourseName(value) {
  176. const text = cleanText(value);
  177. // 去掉中山大学 API 返回的课程类别前缀:
  178. // "本(专必)高等数学一(I)" -> "高等数学一(I)"
  179. // "本(公必)劳动教育" -> "劳动教育"
  180. return text.replace(/^本\([^)]*\)/, "").trim();
  181. }
  182. function parseWeeks(timeDetail, startWeek = 1) {
  183. const text = cleanText(timeDetail);
  184. if (!text) {
  185. return startWeek ? [startWeek] : [];
  186. }
  187. // 例如:"1-17每周"
  188. const rangeMatch = text.match(/(\d+)\s*-\s*(\d+)/);
  189. if (rangeMatch) {
  190. const start = Number(rangeMatch[1]);
  191. const end = Number(rangeMatch[2]);
  192. if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
  193. return Array.from(
  194. { length: end - start + 1 },
  195. (_, index) => start + index
  196. );
  197. }
  198. }
  199. // 兼容单双周或离散周次,例如:"1,3,5周"、"1、3、5周"
  200. const numbers = text
  201. .match(/\d+/g)
  202. ?.map(Number)
  203. .filter(Number.isFinite) || [];
  204. if (numbers.length > 0) {
  205. return [...new Set(numbers)].sort((a, b) => a - b);
  206. }
  207. return startWeek ? [startWeek] : [];
  208. }
  209. function cleanText(value) {
  210. if (value === null || value === undefined) {
  211. return "";
  212. }
  213. return String(value)
  214. .replace(/\/+$/g, "")
  215. .trim();
  216. }
  217. function toNumber(value) {
  218. const number = Number(value);
  219. return Number.isFinite(number) ? number : null;
  220. }
  221. // 根据课程名称生成稳定的颜色编号,避免同一课程每次导出颜色变化。
  222. function getCourseColor(name) {
  223. const colorCount = 8;
  224. let hash = 0;
  225. for (let i = 0; i < name.length; i++) {
  226. hash = ((hash << 5) - hash) + name.charCodeAt(i);
  227. hash |= 0;
  228. }
  229. return Math.abs(hash) % colorCount + 1;
  230. }
  231. async function runImportFlow() {
  232. try {
  233. if (!window.AndroidBridgePromise || typeof window.AndroidBridgePromise.showAlert !== "function") {
  234. throw new Error("AndroidBridgePromise.showAlert 不可用,请在时光课程表 App 内运行此适配器。");
  235. }
  236. if (!confirmed) {
  237. if (window.AndroidBridge && typeof window.AndroidBridge.showToast === "function") {
  238. window.AndroidBridge.showToast("已取消导入。");
  239. }
  240. return;
  241. }
  242. const selectedSemester = await selectSemester();
  243. if (!selectedSemester) {
  244. if (window.AndroidBridge && typeof window.AndroidBridge.showToast === "function") {
  245. window.AndroidBridge.showToast("已取消导入。");
  246. }
  247. return;
  248. }
  249. console.log(`已选择学期:${ACAD_YEAR}`);
  250. console.log(`学期实际开课日期:${getSemesterStartDate()}`);
  251. const data = await fetchCourses();
  252. if (!data || !Array.isArray(data.courses)) {
  253. throw new Error("课程数据为空或格式不正确。");
  254. }
  255. window.__SYSU_COURSE_JSON__ = data;
  256. window.__SYSU_COURSES__ = data.courses;
  257. console.log("window.__SYSU_COURSE_JSON__ 已更新。");
  258. console.log("准备通过 AndroidBridgePromise 向应用提交数据。");
  259. if (!window.AndroidBridgePromise) {
  260. throw new Error("AndroidBridgePromise 不可用,请在时光课程表 App 内运行此适配器。");
  261. }
  262. if (typeof window.AndroidBridgePromise.saveImportedCourses !== "function") {
  263. throw new Error("saveImportedCourses API 不可用。");
  264. }
  265. if (typeof window.AndroidBridgePromise.savePresetTimeSlots !== "function") {
  266. throw new Error("savePresetTimeSlots API 不可用。");
  267. }
  268. if (typeof window.AndroidBridgePromise.saveCourseConfig !== "function") {
  269. throw new Error("saveCourseConfig API 不可用。");
  270. }
  271. if (window.AndroidBridge && typeof window.AndroidBridge.showToast === "function") {
  272. window.AndroidBridge.showToast(`正在导入 ${data.courses.length} 条课程记录...`);
  273. }
  274. await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(data.courses));
  275. console.log("课程数据提交成功。");
  276. await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(data.timeSlots));
  277. console.log("时间段数据提交成功。");
  278. await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify(data.config));
  279. console.log("课表配置提交成功。");
  280. if (window.AndroidBridge && typeof window.AndroidBridge.showToast === "function") {
  281. window.AndroidBridge.showToast(`成功导入 ${data.courses.length} 条课程记录!`);
  282. }
  283. if (window.AndroidBridge && typeof window.AndroidBridge.notifyTaskCompletion === "function") {
  284. window.AndroidBridge.notifyTaskCompletion();
  285. }
  286. } catch (error) {
  287. console.error("========================================");
  288. console.error("中山大学课表导入失败:", error);
  289. console.error(error.stack);
  290. console.error("========================================");
  291. if (window.AndroidBridge && typeof window.AndroidBridge.showToast === "function") {
  292. window.AndroidBridge.showToast(`导入失败:${error.message}`);
  293. }
  294. }
  295. }
  296. runImportFlow();