cmc_01.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. // 成都医学院教务(乘方教务)适配器
  2. // 流程:选一次学期(课表/考试/上课任务/开学日期共用)→ 拉上课任务与课表 → 询问是否导入考试 → 保存配置、课程与作息
  3. // 接口:
  4. // GET /new/student/xsgrkb/week.page 课表页(学期下拉 + 作息表)
  5. // POST /new/student/xsgrkb/getCalendarWeekDatas 整学期课程数据
  6. // POST /new/student/xsksrw/paginateXsksrw 学生考试任务
  7. // POST /new/student/xskcrw/skrwDatas 上课任务(学分与选修类别)
  8. // POST /new/xlxx/getDatesOfWeek 第1周日期(开学日期)
  9. // 周次字符串
  10. function parseWeeks(weekStr) {
  11. if (!weekStr) return [];
  12. const weeks = weekStr.split(",").map(w => parseInt(w.trim(), 10)).filter(w => !isNaN(w) && w > 0);
  13. return [...new Set(weeks)].sort((a, b) => a - b);
  14. }
  15. // 按周场地字符串
  16. function parseVenueWeeks(jxcdmc2) {
  17. const venueMap = new Map();
  18. String(jxcdmc2 || "").split(",").forEach(part => {
  19. const match = part.trim().match(/^(.*?)-(\d+)$/);
  20. if (!match) return;
  21. const week = parseInt(match[2], 10);
  22. const key = match[1].trim() || "不用场地";
  23. if (!venueMap.has(key)) venueMap.set(key, []);
  24. venueMap.get(key).push(week);
  25. });
  26. return venueMap;
  27. }
  28. // 课程地点
  29. function resolvePosition(item) {
  30. const primary = String(item.jxcdmc || "").trim();
  31. if (primary) return primary;
  32. if (String(item.bapjxcd || "") === "1") return "不用场地";
  33. return "待定";
  34. }
  35. // 教师名
  36. function cleanTeacherName(raw) {
  37. return [...new Set(String(raw || "").replace(/\[[^\]]*\]/g, "").split(",").map(n => n.trim()).filter(Boolean))].join(",");
  38. }
  39. // 课表接口数据
  40. function parseCourseList(apiJson, slotMap, taskMap) {
  41. if (!apiJson) throw new Error("课表接口无响应");
  42. if (apiJson.code !== 0) {
  43. const message = String(apiJson.message || "").trim();
  44. throw new Error(message || `课表接口返回错误(code=${apiJson.code})`);
  45. }
  46. if (!Array.isArray(apiJson.data)) throw new Error("课表接口返回格式不正确");
  47. const courseMap = new Map();
  48. apiJson.data.forEach(item => {
  49. const day = parseInt(item.xq, 10);
  50. const startSection = parseInt(item.ps, 10);
  51. const endSection = parseInt(item.pe, 10);
  52. const allWeeks = parseWeeks(item.zc);
  53. if (!item.kcmc || !allWeeks.length || isNaN(day) || isNaN(startSection) || isNaN(endSection) ||
  54. day < 1 || day > 7 || startSection > endSection) return;
  55. const teacher = cleanTeacherName(item.teaxms) || "未知";
  56. const info = taskMap.get(item.kcrwdm);
  57. const name = `${item.kcmc.trim()}${info && info.tag ? `(${info.tag})` : ""}`;
  58. const actualStart = String(item.qssj || "").slice(0, 5);
  59. const actualEnd = String(item.jssj || "").slice(0, 5);
  60. const expectedStart = slotMap[startSection] && slotMap[startSection].start;
  61. const expectedEnd = slotMap[endSection] && slotMap[endSection].end;
  62. const isCustomTime = actualStart && actualEnd && (actualStart !== expectedStart || actualEnd !== expectedEnd);
  63. const venues = parseVenueWeeks(item.jxcdmc2);
  64. const venueEntries = venues.size > 0
  65. ? Array.from(venues.entries(), ([position, weeks]) => ({ position, weeks: [...new Set(weeks)].sort((a, b) => a - b) }))
  66. : [{ position: resolvePosition(item), weeks: allWeeks }];
  67. venueEntries.forEach(({ position, weeks }) => {
  68. const course = { name, teacher, position, day, startSection, endSection, weeks };
  69. if (info) course.credit = info.credit;
  70. if (isCustomTime) {
  71. course.isCustomTime = true;
  72. course.customStartTime = actualStart;
  73. course.customEndTime = actualEnd;
  74. }
  75. const key = [course.name, teacher, position, day,
  76. isCustomTime ? `${actualStart}${actualEnd}` : `${startSection}-${endSection}`].join("__");
  77. const existing = courseMap.get(key);
  78. if (existing) existing.weeks = [...new Set([...existing.weeks, ...course.weeks])].sort((a, b) => a - b);
  79. else courseMap.set(key, course);
  80. });
  81. });
  82. return Array.from(courseMap.values()).sort((a, b) =>
  83. a.day - b.day || a.startSection - b.startSection || a.endSection - b.endSection || a.name.localeCompare(b.name)
  84. );
  85. }
  86. // 考试安排数据
  87. function parseExamList(rows, slots) {
  88. const exams = [];
  89. rows.forEach(item => {
  90. const day = parseInt(item.xq, 10);
  91. const week = parseInt(item.zc, 10);
  92. const [startTime, endTime] = String(item.kssj || "").split("--").map(part => part.trim().slice(0, 5));
  93. const validTime = startTime && endTime && /^\d{2}:\d{2}$/.test(startTime) && /^\d{2}:\d{2}$/.test(endTime);
  94. if (!item.kcmc || isNaN(day) || day < 1 || day > 7 || isNaN(week) || week < 1 || !validTime) return;
  95. const examType = String(item.kslbmc || "").trim().replace(/考试$/, "");
  96. const exam = {
  97. name: `${item.kcmc.trim()}${examType ? `(${examType})` : ""}`,
  98. teacher: "",
  99. position: String(item.kscdmc || "").trim() || "待定",
  100. day,
  101. weeks: [week]
  102. };
  103. const matched = slots && slots.find(s => s.startTime === startTime && s.endTime === endTime);
  104. if (matched) {
  105. exam.startSection = exam.endSection = matched.number;
  106. } else {
  107. exam.isCustomTime = true;
  108. exam.customStartTime = startTime;
  109. exam.customEndTime = endTime;
  110. }
  111. exams.push(exam);
  112. });
  113. return exams;
  114. }
  115. // 任选课类别标识
  116. function resolveElectiveTag(task) {
  117. if (String(task.xdfsmc || "").trim() !== "任选") return "";
  118. if (String(task.kcflmc || "").includes("艺术")) return "艺术";
  119. if (String(task.kcdlmc || "").includes("通识")) return "通识";
  120. if (String(task.kcdlmc || "").includes("专业")) return "专业";
  121. return "";
  122. }
  123. // 上课任务数据
  124. function parseTaskList(rows) {
  125. const map = new Map();
  126. rows.forEach(item => map.set(item.kcrwdm, { credit: item.xf, tag: resolveElectiveTag(item) }));
  127. return map;
  128. }
  129. // 从 week.page 源码提取作息表
  130. function parseBusinessHoursFromHtml(htmlText) {
  131. const match = htmlText.match(/var\s+businessHours\s*=\s*\$\.parseJSON\('(\[.*?\])'\);/);
  132. const slots = [];
  133. const map = {};
  134. if (match) {
  135. JSON.parse(match[1]).forEach(item => {
  136. const number = parseInt(item.jcdm, 10);
  137. const startTime = String(item.qssj || "").slice(0, 5);
  138. const endTime = String(item.jssj || "").slice(0, 5);
  139. if (isNaN(number) || !startTime || !endTime) return;
  140. slots.push({ number, startTime, endTime });
  141. map[number] = { start: startTime, end: endTime };
  142. });
  143. slots.sort((a, b) => a.number - b.number);
  144. }
  145. return { slots, map };
  146. }
  147. // 插入午间段期中考试时间
  148. function withLunchSlot(slots) {
  149. return slots
  150. .map(s => s.number >= 6 ? { ...s, number: s.number + 1 } : s)
  151. .concat([{ number: 6, startTime: "12:15", endTime: "14:15" }])
  152. .sort((a, b) => a.number - b.number);
  153. }
  154. // 读取页面中的学期下拉框
  155. function extractSemesterOptions(doc) {
  156. const selectElem = doc.getElementById("xnxqdm");
  157. if (!selectElem) return null;
  158. const semesters = [];
  159. const semesterValues = [];
  160. let defaultIndex = 0;
  161. Array.from(selectElem.querySelectorAll("option")).forEach(option => {
  162. if (!option.value) return;
  163. semesters.push(option.innerText.trim());
  164. semesterValues.push(option.value);
  165. if (option.selected || option.hasAttribute("selected")) defaultIndex = semesters.length - 1;
  166. });
  167. if (semesters.length === 0) return null;
  168. const start = Math.max(0, defaultIndex - 1);
  169. const end = Math.min(semesters.length, defaultIndex + 10);
  170. return {
  171. semesters: semesters.slice(start, end),
  172. semesterValues: semesterValues.slice(start, end),
  173. defaultIndex: defaultIndex - start
  174. };
  175. }
  176. // 导入前提示用户先登录教务系统
  177. async function promptUserToStart() {
  178. return await window.shiguangBridgePromise.showAlert(
  179. "成都医学院教务导入",
  180. "请先确保已登录教务系统,再继续导入。",
  181. "我已登录"
  182. );
  183. }
  184. // 从页面已有学期中选择目标学期
  185. async function selectSemester(semesterOptions) {
  186. const selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
  187. "选择学期",
  188. JSON.stringify(semesterOptions.semesters),
  189. semesterOptions.defaultIndex
  190. );
  191. if (selectedIndex === null || selectedIndex < 0) return null;
  192. return {
  193. label: semesterOptions.semesters[selectedIndex],
  194. value: semesterOptions.semesterValues[selectedIndex]
  195. };
  196. }
  197. // 询问是否同时导入考试
  198. async function askImportExams() {
  199. return await window.shiguangBridgePromise.showAlert(
  200. "导入考试安排",
  201. "是否同时导入本学期的考试安排?\n(期中/期末/补考将显示在课表对应日期)",
  202. "确定导入"
  203. );
  204. }
  205. // 获取课表页 HTML(含学期列表与作息表)
  206. async function fetchSchedulePage() {
  207. const response = await fetch("/new/student/xsgrkb/week.page", { method: "GET", credentials: "include" });
  208. if (!response.ok) throw new Error(`无法打开课表页面(HTTP ${response.status})`);
  209. return response.text();
  210. }
  211. // 乘方统一表单 POST(课表/考试/任务/开学日期共用),附带 JSON 请求头与会话
  212. async function postForm(url, formData) {
  213. const response = await fetch(url, {
  214. method: "POST",
  215. headers: {
  216. "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
  217. "X-Requested-With": "XMLHttpRequest"
  218. },
  219. credentials: "include",
  220. body: formData.toString()
  221. });
  222. if (!response.ok) throw new Error(`请求失败(HTTP ${response.status})`);
  223. return response;
  224. }
  225. // 请求指定学期的课程数据
  226. async function fetchCourseData(xnxqdm) {
  227. const year = parseInt(xnxqdm.slice(0, 4), 10);
  228. const formData = new URLSearchParams();
  229. formData.append("xnxqdm", xnxqdm);
  230. formData.append("zc", "");
  231. formData.append("d1", `${year}-08-01 00:00:00`);
  232. formData.append("d2", `${year + 1}-08-31 23:59:59`);
  233. return (await postForm("/new/student/xsgrkb/getCalendarWeekDatas", formData)).json();
  234. }
  235. // 请求第 1 周日期(开学日期)
  236. async function fetchSemesterStartDate(xnxqdm) {
  237. const formData = new URLSearchParams();
  238. formData.append("xnxqdm", xnxqdm);
  239. formData.append("zc", "1");
  240. const rows = await (await postForm("/new/xlxx/getDatesOfWeek", formData)).json();
  241. return rows.find(item => item.xqmc === "1").rq;
  242. }
  243. // 分页拉取 rows/total 数据(考试/任务共用)
  244. async function fetchPagedRows(url, baseParams, sort) {
  245. const allRows = [];
  246. let page = 1;
  247. for (;;) {
  248. const formData = new URLSearchParams(baseParams);
  249. formData.append("page", String(page));
  250. formData.append("rows", "100");
  251. formData.append("sort", sort);
  252. formData.append("order", "asc");
  253. const json = await (await postForm(url, formData)).json();
  254. const rows = Array.isArray(json.rows) ? json.rows : [];
  255. allRows.push(...rows);
  256. const total = parseInt(json.total, 10);
  257. if (!total || allRows.length >= total || rows.length === 0) break;
  258. page += 1;
  259. }
  260. return allRows;
  261. }
  262. // 分页拉取指定学期的全部考试任务
  263. async function fetchExamData(xnxqdm) {
  264. return fetchPagedRows("/new/student/xsksrw/paginateXsksrw", { xnxqdm, ksaplxdm: "", kslbdm: "" }, "zc,xq,jcdm2");
  265. }
  266. // 分页拉取指定学期的全部上课任务
  267. async function fetchTaskData(xnxqdm) {
  268. return fetchPagedRows("/new/student/xskcrw/skrwDatas", { xnxqdm, kcdldm: "", kcfldm: "", kcmc: "" }, "kcrwdm,xdfsdm");
  269. }
  270. // 保存课表配置(学期 20 周、单节 40 分钟)
  271. async function saveConfig(semesterStartDate) {
  272. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify({
  273. semesterStartDate,
  274. semesterTotalWeeks: 20,
  275. defaultClassDuration: 40
  276. }));
  277. }
  278. // 保存课程
  279. async function saveCourses(courses) {
  280. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  281. }
  282. // 保存作息时间
  283. async function saveTimeSlots(timeSlots) {
  284. if (timeSlots.length === 0) return;
  285. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  286. }
  287. // 编排导入流程:提示 → 选学期 → 拉任务与课表 → 询问考试 → 保存配置、课程与作息
  288. async function runImportFlow() {
  289. try {
  290. const confirmed = await promptUserToStart();
  291. if (!confirmed) { window.shiguangBridge.showToast("导入已取消"); return; }
  292. const pageHtml = await fetchSchedulePage();
  293. const semesterOptions = extractSemesterOptions(new DOMParser().parseFromString(pageHtml, "text/html"));
  294. if (!semesterOptions) throw new Error("未找到学期列表,请先登录教务系统");
  295. const semester = await selectSemester(semesterOptions);
  296. if (!semester) { window.shiguangBridge.showToast("导入已取消"); return; }
  297. const { slots, map: slotMap } = parseBusinessHoursFromHtml(pageHtml);
  298. window.shiguangBridge.showToast(`正在获取 ${semester.label} 的课表...`);
  299. let taskMap = new Map();
  300. try {
  301. taskMap = parseTaskList(await fetchTaskData(semester.value));
  302. } catch (error) {
  303. window.shiguangBridge.showToast(`上课任务获取失败,学分与类别标识已跳过:${error.message}`);
  304. }
  305. const courses = parseCourseList(await fetchCourseData(semester.value), slotMap, taskMap);
  306. if (courses.length === 0) {
  307. await window.shiguangBridgePromise.showAlert(
  308. "提示",
  309. "该学期没有获取到课程数据,请检查登录状态和所选学期。",
  310. "确定"
  311. );
  312. return;
  313. }
  314. const exams = [];
  315. let timeSlots = slots;
  316. if (await askImportExams()) {
  317. timeSlots = withLunchSlot(slots);
  318. courses.forEach(c => {
  319. if (c.startSection >= 6) c.startSection += 1;
  320. if (c.endSection >= 6) c.endSection += 1;
  321. });
  322. window.shiguangBridge.showToast("正在获取考试安排...");
  323. exams.push(...parseExamList(await fetchExamData(semester.value), timeSlots));
  324. if (exams.length === 0) window.shiguangBridge.showToast("该学期暂时没有考试安排");
  325. }
  326. try {
  327. await saveConfig(await fetchSemesterStartDate(semester.value));
  328. } catch (error) {
  329. window.shiguangBridge.showToast(`课表配置导入失败,已跳过:${error.message}`);
  330. }
  331. await saveCourses([...courses, ...exams]);
  332. try {
  333. await saveTimeSlots(timeSlots);
  334. } catch (error) {
  335. window.shiguangBridge.showToast(`课程已导入,作息时间导入失败:${error.message}`);
  336. }
  337. const examTip = exams.length > 0 ? `成功导入 ${exams.length} 门考试` : "导入完成";
  338. window.shiguangBridge.showToast(examTip);
  339. window.shiguangBridge.notifyTaskCompletion();
  340. } catch (error) {
  341. await window.shiguangBridgePromise.showAlert(
  342. "导入失败",
  343. error.message || String(error),
  344. "确定"
  345. );
  346. }
  347. }
  348. runImportFlow();