smu_01.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  1. /**
  2. * 拾光课程表适配脚本 - 南方医科大学 (smu.edu.cn)
  3. * 乘方教务系统适配
  4. * 出现问题请提issues或者提交pr更改,这更加快速
  5. */
  6. // 基础配置
  7. const BASE_URL = "https://zhjw.smu.edu.cn/new";
  8. // 校区与作息时间映射
  9. const CAMPUS_CONFIG = {
  10. // 校本部 (00001)
  11. "00001": {
  12. name: "校本部",
  13. timeSlots: [
  14. { number: 1, startTime: "08:00", endTime: "08:40" },
  15. { number: 2, startTime: "08:45", endTime: "09:25" },
  16. { number: 3, startTime: "09:50", endTime: "10:30" },
  17. { number: 4, startTime: "10:35", endTime: "11:15" },
  18. { number: 5, startTime: "11:20", endTime: "12:00" },
  19. { number: 6, startTime: "14:30", endTime: "15:10" },
  20. { number: 7, startTime: "15:15", endTime: "15:55" },
  21. { number: 8, startTime: "16:15", endTime: "16:55" },
  22. { number: 9, startTime: "17:00", endTime: "17:40" },
  23. { number: 10, startTime: "19:30", endTime: "20:10" },
  24. { number: 11, startTime: "20:15", endTime: "20:55" },
  25. { number: 12, startTime: "21:00", endTime: "21:40" },
  26. { number: 13, startTime: "21:45", endTime: "22:25" }
  27. ]
  28. },
  29. // 顺德校区 (00009)
  30. "00009": {
  31. name: "顺德校区",
  32. timeSlots: [
  33. { number: 1, startTime: "08:30", endTime: "09:10" },
  34. { number: 2, startTime: "09:15", endTime: "09:55" },
  35. { number: 3, startTime: "10:20", endTime: "11:00" },
  36. { number: 4, startTime: "11:05", endTime: "11:45" },
  37. { number: 5, startTime: "11:50", endTime: "12:30" },
  38. { number: 6, startTime: "14:00", endTime: "14:40" },
  39. { number: 7, startTime: "14:45", endTime: "15:25" },
  40. { number: 8, startTime: "15:45", endTime: "16:25" },
  41. { number: 9, startTime: "16:30", endTime: "17:10" },
  42. { number: 10, startTime: "19:30", endTime: "20:10" },
  43. { number: 11, startTime: "20:15", endTime: "20:55" },
  44. { number: 12, startTime: "21:00", endTime: "21:40" },
  45. { number: 13, startTime: "21:45", endTime: "22:25" }
  46. ]
  47. }
  48. };
  49. // 周次解析
  50. /**
  51. * 解析周次字符串,例如 "1,2,3,4,5" -> [1, 2, 3, 4, 5]
  52. */
  53. function parseWeeks(weekStr) {
  54. if (!weekStr) return [];
  55. const weeks = weekStr.split(',')
  56. .map(w => Number(w.trim()))
  57. .filter(w => !isNaN(w) && w > 0);
  58. return [...new Set(weeks)].sort((a, b) => a - b);
  59. }
  60. // 合并去重函数
  61. /**
  62. * 节次与周次合并去重函数
  63. */
  64. function mergeAndDistinctCourses(courses) {
  65. if (!Array.isArray(courses) || courses.length <= 1) return courses;
  66. const grouped = {};
  67. courses.forEach(c => {
  68. const name = c.name || '';
  69. const teacher = c.teacher || '';
  70. const position = c.position || '';
  71. const day = c.day || 0;
  72. const isCustom = c.isCustomTime || false;
  73. const cst = c.customStartTime || '';
  74. const cet = c.customEndTime || '';
  75. const key = `${day}|${name}|${teacher}|${position}|${isCustom}|${cst}|${cet}`;
  76. if (!grouped[key]) {
  77. grouped[key] = {
  78. name, teacher, position, day,
  79. isCustomTime: isCustom,
  80. customStartTime: cst,
  81. customEndTime: cet,
  82. weeksMap: {}
  83. };
  84. }
  85. const weeksArr = Array.isArray(c.weeks) ? [...new Set(c.weeks)] : [];
  86. weeksArr.forEach(w => {
  87. if (!grouped[key].weeksMap[w]) grouped[key].weeksMap[w] = [];
  88. grouped[key].weeksMap[w].push({ start: c.startSection, end: c.endSection });
  89. });
  90. });
  91. const finalResult = [];
  92. Object.values(grouped).forEach(group => {
  93. const sortedWeeks = Object.keys(group.weeksMap).map(Number).sort((a, b) => a - b);
  94. const processedWeeksMap = {};
  95. sortedWeeks.forEach(w => {
  96. let parts = group.weeksMap[w];
  97. parts.sort((a, b) => a.start - b.start || a.end - b.end);
  98. const mergedParts = [];
  99. let cur = parts[0];
  100. for (let i = 1; i < parts.length; i++) {
  101. if (cur.end + 1 >= parts[i].start) {
  102. cur.end = Math.max(cur.end, parts[i].end);
  103. } else {
  104. mergedParts.push(cur);
  105. cur = parts[i];
  106. }
  107. }
  108. mergedParts.push(cur);
  109. processedWeeksMap[w] = mergedParts;
  110. });
  111. const sectionsMap = {};
  112. sortedWeeks.forEach(w => {
  113. const parts = processedWeeksMap[w];
  114. const sig = parts.map(p => `${p.start}-${p.end}`).join('|');
  115. if (!sectionsMap[sig]) sectionsMap[sig] = { sections: parts, weeks: [] };
  116. sectionsMap[sig].weeks.push(w);
  117. });
  118. Object.values(sectionsMap).forEach(secGroup => {
  119. secGroup.sections.forEach(sec => {
  120. const baseCourse = {
  121. name: group.name,
  122. teacher: group.teacher,
  123. position: group.position,
  124. day: group.day,
  125. startSection: sec.start,
  126. endSection: sec.end,
  127. weeks: secGroup.weeks
  128. };
  129. if (group.isCustomTime) {
  130. baseCourse.isCustomTime = true;
  131. baseCourse.customStartTime = group.customStartTime;
  132. baseCourse.customEndTime = group.customEndTime;
  133. }
  134. finalResult.push(baseCourse);
  135. });
  136. });
  137. });
  138. finalResult.sort((a, b) => {
  139. return (a.day || 0) - (b.day || 0) ||
  140. (a.startSection || 0) - (b.startSection || 0) ||
  141. a.name.localeCompare(b.name) ||
  142. a.weeks[0] - b.weeks[0];
  143. });
  144. return finalResult;
  145. }
  146. // JSON 解析
  147. /**
  148. * 将教务系统返回的 JSON 转换为拾光标准的 CourseJsonModel 数组
  149. */
  150. function parseJsonData(jsonData) {
  151. if (!jsonData || jsonData.code !== 0 || !Array.isArray(jsonData.data)) {
  152. return { courses: [], campusCodes: [] };
  153. }
  154. let allProcessedCourses = [];
  155. let detectedCampusCodes = new Set();
  156. jsonData.data.forEach(raw => {
  157. if (!(raw.kcmc && raw.xq && raw.ps && raw.pe)) return;
  158. if (raw.kkxqdm) {
  159. detectedCampusCodes.add(String(raw.kkxqdm).trim());
  160. }
  161. const startSec = Number(raw.ps);
  162. const endSec = Number(raw.pe);
  163. let isCustomTime = false;
  164. let customStartTime = "";
  165. let customEndTime = "";
  166. // 【特殊逻辑说明】
  167. // 南方医科大学教务系统从第10节开始(晚间)的起止时间,与常规的“40分钟一节、课间5分钟”基础作息规则不一致。
  168. // 为了保证课表显示的实际上下课时间与教务系统完全吻合,遇到 >=10 节的课程,
  169. // 强制启用 isCustomTime,并直接使用接口返回的真实起止时间(qssj/jssj),不再依赖预设的基础节次时间。
  170. if (startSec >= 10) {
  171. isCustomTime = true;
  172. customStartTime = raw.qssj ? raw.qssj.substring(0, 5) : "";
  173. customEndTime = raw.jssj ? raw.jssj.substring(0, 5) : "";
  174. }
  175. const locationMap = new Map();
  176. if (raw.jxcdmc2) {
  177. const parts = raw.jxcdmc2.split(',');
  178. parts.forEach(part => {
  179. const lastDashIndex = part.lastIndexOf('-');
  180. if (lastDashIndex !== -1) {
  181. const room = part.substring(0, lastDashIndex).trim();
  182. const week = Number(part.substring(lastDashIndex + 1));
  183. if (!isNaN(week)) {
  184. if (!locationMap.has(room)) locationMap.set(room, []);
  185. locationMap.get(room).push(week);
  186. }
  187. }
  188. });
  189. }
  190. if (locationMap.size > 0) {
  191. locationMap.forEach((weeks, room) => {
  192. const courseObj = {
  193. name: raw.kcmc.trim(),
  194. teacher: (raw.teaxms || "未知教师").trim(),
  195. position: room || "不用场地",
  196. day: Number(raw.xq),
  197. startSection: startSec,
  198. endSection: endSec,
  199. weeks: weeks.sort((a, b) => a - b)
  200. };
  201. if (isCustomTime) {
  202. courseObj.isCustomTime = true;
  203. courseObj.customStartTime = customStartTime;
  204. courseObj.customEndTime = customEndTime;
  205. }
  206. allProcessedCourses.push(courseObj);
  207. });
  208. } else if (raw.zc) {
  209. const courseObj = {
  210. name: raw.kcmc.trim(),
  211. teacher: (raw.teaxms || "未知教师").trim(),
  212. position: (raw.jxcdmc || "").trim() || "不用场地",
  213. day: Number(raw.xq),
  214. startSection: startSec,
  215. endSection: endSec,
  216. weeks: parseWeeks(raw.zc)
  217. };
  218. if (isCustomTime) {
  219. courseObj.isCustomTime = true;
  220. courseObj.customStartTime = customStartTime;
  221. courseObj.customEndTime = customEndTime;
  222. }
  223. allProcessedCourses.push(courseObj);
  224. }
  225. });
  226. const filtered = allProcessedCourses.filter(course => {
  227. return course.weeks.length > 0 && course.startSection <= course.endSection;
  228. });
  229. return {
  230. courses: mergeAndDistinctCourses(filtered),
  231. campusCodes: Array.from(detectedCampusCodes)
  232. };
  233. }
  234. // 学年学期选项获取
  235. async function fetchAcademicOptions() {
  236. const url = `${BASE_URL}/student/xlxx/xlxxlist.page`;
  237. try {
  238. const response = await fetch(url, {
  239. method: "GET",
  240. credentials: "include"
  241. });
  242. if (!response.ok) return null;
  243. const htmlText = await response.text();
  244. const parser = new DOMParser();
  245. const doc = parser.parseFromString(htmlText, "text/html");
  246. const allOptions = Array.from(doc.querySelectorAll("#xnxqdm option"))
  247. .filter(opt => opt.value !== "")
  248. .map(opt => ({
  249. value: opt.value,
  250. text: opt.textContent.trim(),
  251. selected: opt.selected
  252. }));
  253. if (allOptions.length === 0) return null;
  254. const selectedIndex = allOptions.findIndex(opt => opt.selected);
  255. if (selectedIndex === -1) {
  256. return {
  257. options: allOptions.slice(0, 7),
  258. defaultIndex: 0
  259. };
  260. }
  261. const start = Math.max(0, selectedIndex - 3);
  262. const end = Math.min(allOptions.length, selectedIndex + 4);
  263. const options = allOptions.slice(start, end);
  264. const newDefaultIndex = selectedIndex - start;
  265. return { options, defaultIndex: newDefaultIndex };
  266. } catch (e) {
  267. return null;
  268. }
  269. }
  270. // 校历数据获取
  271. async function fetchSemesterCalendar(xnxqdm) {
  272. const url = `${BASE_URL}/xlxx/data`;
  273. const requestBody = `xnxqdm=${xnxqdm}`;
  274. try {
  275. const response = await fetch(url, {
  276. method: "POST",
  277. headers: {
  278. "accept": "application/json, text/javascript, */*; q=0.01",
  279. "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
  280. "x-requested-with": "XMLHttpRequest"
  281. },
  282. body: requestBody,
  283. credentials: "include"
  284. });
  285. if (!response.ok) return { startDate: null, totalWeeks: null };
  286. const json = await response.json();
  287. if (!json || json.code !== 0 || !Array.isArray(json.data)) {
  288. return { startDate: null, totalWeeks: null };
  289. }
  290. let earliestDate = null;
  291. let earliestWeek = Infinity;
  292. let maxWeek = 0;
  293. json.data.forEach(monthBlock => {
  294. if (!Array.isArray(monthBlock)) return;
  295. monthBlock.forEach(row => {
  296. if (!Array.isArray(row) || row.length === 0) return;
  297. const weekHead = row[0];
  298. if (!weekHead || weekHead.type !== "zc") return;
  299. const weekNum = Number(weekHead.value);
  300. if (isNaN(weekNum)) return;
  301. if (weekNum > maxWeek) maxWeek = weekNum;
  302. for (let i = 1; i < row.length; i++) {
  303. const cell = row[i];
  304. if (!cell || cell.type !== "day") continue;
  305. if (!cell.fullText) continue;
  306. if (!/^\d{4}-\d{2}-\d{2}$/.test(cell.fullText)) continue;
  307. if (weekNum < earliestWeek ||
  308. (weekNum === earliestWeek && cell.fullText < earliestDate)) {
  309. earliestWeek = weekNum;
  310. earliestDate = cell.fullText;
  311. }
  312. }
  313. });
  314. });
  315. return {
  316. startDate: earliestDate,
  317. totalWeeks: maxWeek > 0 ? maxWeek : null
  318. };
  319. } catch (e) {
  320. return { startDate: null, totalWeeks: null };
  321. }
  322. }
  323. // 保存逻辑模块
  324. async function saveCampusTimeSlotsAndConfig(campusCodes, calendar) {
  325. let finalConfig = CAMPUS_CONFIG["00001"];
  326. let matchedCode = campusCodes.find(code => CAMPUS_CONFIG[code]);
  327. if (matchedCode) {
  328. finalConfig = CAMPUS_CONFIG[matchedCode];
  329. } else if (campusCodes.length > 0) {
  330. const unknownStr = campusCodes.join(", ");
  331. await window.shiguangBridgePromise.showAlert(
  332. "未记录校区提醒",
  333. `检测到课表中包含未记录的校区代码(${unknownStr})。\n系统将默认使用【校本部】作息时间。`,
  334. "好的,继续导入"
  335. );
  336. }
  337. try {
  338. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(finalConfig.timeSlots));
  339. if (calendar && (calendar.startDate || calendar.totalWeeks)) {
  340. const config = {};
  341. if (calendar.startDate) config.semesterStartDate = calendar.startDate;
  342. if (calendar.totalWeeks) config.semesterTotalWeeks = calendar.totalWeeks;
  343. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  344. let msg = `配置更新成功!(使用 ${finalConfig.name} 作息)`;
  345. if (calendar.startDate) msg += ` 开学:${calendar.startDate}`;
  346. if (calendar.totalWeeks) msg += ` 总周数:${calendar.totalWeeks}`;
  347. window.shiguangBridge.showToast(msg);
  348. } else {
  349. window.shiguangBridge.showToast(`作息已更新为 ${finalConfig.name},但未获取到校历信息。`);
  350. }
  351. } catch (e) {
  352. window.shiguangBridge.showToast("保存配置或作息失败: " + e.message);
  353. }
  354. }
  355. // 用户交互
  356. async function promptUserToStart() {
  357. return await window.shiguangBridgePromise.showAlert(
  358. "教务导入说明",
  359. "1. 请确保已在浏览器中成功登录教务系统\n2. 导入过程中请勿关闭页面",
  360. "好的,开始导入"
  361. );
  362. }
  363. async function selectAcademicYearAndSemester() {
  364. const optionsData = await fetchAcademicOptions();
  365. if (!optionsData) {
  366. window.shiguangBridge.showToast("从教务系统读取学年学期失败,请确保登录状态。");
  367. return null;
  368. }
  369. const { options, defaultIndex } = optionsData;
  370. const texts = options.map(item => item.text);
  371. const selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
  372. "选择学年学期",
  373. JSON.stringify(texts),
  374. defaultIndex
  375. );
  376. if (selectedIndex === null || selectedIndex === -1) return null;
  377. return {
  378. xnxqdm: options[selectedIndex].value,
  379. xnxqmc: options[selectedIndex].text
  380. };
  381. }
  382. // 网络请求
  383. async function fetchCourses(xnxqdm) {
  384. window.shiguangBridge.showToast("正在请求教务数据...");
  385. const body = `xnxqdm=${xnxqdm}`;
  386. const url = `${BASE_URL}/student/xsgrkb/getCalendarWeekDatas`;
  387. try {
  388. const response = await fetch(url, {
  389. method: "POST",
  390. headers: { "content-type": "application/x-www-form-urlencoded; charset=UTF-8" },
  391. body: body,
  392. credentials: "include"
  393. });
  394. if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
  395. const jsonData = await response.json();
  396. return parseJsonData(jsonData);
  397. } catch (e) {
  398. window.shiguangBridge.showToast("网络请求失败,请检查登录状态");
  399. return null;
  400. }
  401. }
  402. // 主流程编排
  403. async function runImportFlow() {
  404. const isReady = await promptUserToStart();
  405. if (!isReady) return;
  406. const selection = await selectAcademicYearAndSemester();
  407. if (!selection) {
  408. window.shiguangBridge.showToast("未选择学年学期,导入终止。");
  409. return;
  410. }
  411. const { xnxqdm } = selection;
  412. const [courseData, calendar] = await Promise.all([
  413. fetchCourses(xnxqdm),
  414. fetchSemesterCalendar(xnxqdm)
  415. ]);
  416. if (!courseData || !courseData.courses || courseData.courses.length === 0) {
  417. if (courseData && courseData.courses && courseData.courses.length === 0) {
  418. window.shiguangBridge.showToast("该学期暂无课程数据");
  419. }
  420. return;
  421. }
  422. const { courses, campusCodes } = courseData;
  423. try {
  424. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  425. await saveCampusTimeSlotsAndConfig(campusCodes, calendar);
  426. window.shiguangBridge.showToast(`成功导入 ${courses.length} 门课程!`);
  427. window.shiguangBridge.notifyTaskCompletion();
  428. } catch (e) {
  429. window.shiguangBridge.showToast("保存失败: " + e.message);
  430. }
  431. }
  432. // 启动入口
  433. runImportFlow();