jnmc.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. // 济宁医学院教务(乘方教务 · 旧版 .action 接口)适配器
  2. const PRESET_TIME_SLOTS = [
  3. { number: 1, startTime: "08:00", endTime: "08:40" },
  4. { number: 2, startTime: "08:50", endTime: "09:30" },
  5. { number: 3, startTime: "09:50", endTime: "10:30" },
  6. { number: 4, startTime: "10:40", endTime: "11:20" },
  7. { number: 5, startTime: "11:30", endTime: "12:10" },
  8. { number: 6, startTime: "14:00", endTime: "14:40" },
  9. { number: 7, startTime: "14:50", endTime: "15:30" },
  10. { number: 8, startTime: "15:50", endTime: "16:30" },
  11. { number: 9, startTime: "16:40", endTime: "17:20" },
  12. { number: 10, startTime: "17:30", endTime: "18:10" },
  13. { number: 11, startTime: "19:00", endTime: "19:40" },
  14. { number: 12, startTime: "19:50", endTime: "20:30" },
  15. { number: 13, startTime: "20:50", endTime: "21:30" },
  16. { number: 14, startTime: "21:40", endTime: "22:20" }
  17. ];
  18. function uniqueSortedNumbers(numbers) {
  19. return [...new Set(numbers)].sort((a, b) => a - b);
  20. }
  21. // 支持 "10,7,8,9"、"1-4"、"1-4,7" 等周次写法。
  22. function parseWeeks(weekStr) {
  23. if (!weekStr) return [];
  24. const weeks = [];
  25. String(weekStr).replace(/[,、]/g, ",").split(",").forEach(part => {
  26. const text = part.trim().replace(/周/g, "");
  27. const range = text.match(/^(\d+)\s*[-~]\s*(\d+)$/);
  28. if (range) {
  29. const start = parseInt(range[1], 10);
  30. const end = parseInt(range[2], 10);
  31. if (start > 0 && end >= start) {
  32. for (let week = start; week <= end; week += 1) weeks.push(week);
  33. }
  34. return;
  35. }
  36. const week = parseInt(text, 10);
  37. if (!isNaN(week) && week > 0) weeks.push(week);
  38. });
  39. return uniqueSortedNumbers(weeks);
  40. }
  41. function parseSections(sectionStr) {
  42. return uniqueSortedNumbers(
  43. String(sectionStr || "")
  44. .replace(/[,、]/g, ",")
  45. .split(",")
  46. .map(section => parseInt(section.trim(), 10))
  47. .filter(section => !isNaN(section) && section > 0)
  48. );
  49. }
  50. // 反斜杠表示合并教室(如 "B205\206教室"),逗号才是不同地点的分隔符。
  51. function resolvePosition(raw) {
  52. const position = String(raw || "").replace(/\\/g, "/").replace(/\s+/g, " ").trim();
  53. return position || "待定";
  54. }
  55. function cleanTeacher(raw) {
  56. const teacher = String(raw || "")
  57. .split(/[,,、]/)
  58. .map(name => name.trim())
  59. .filter(Boolean)
  60. .join("、");
  61. return teacher || "未知";
  62. }
  63. function courseKey(course) {
  64. return [
  65. course.name,
  66. course.teacher,
  67. course.position,
  68. course.day,
  69. course.startSection,
  70. course.endSection
  71. ].join("__");
  72. }
  73. function addCourse(courseMap, item, weeks) {
  74. const day = parseInt(item.xq, 10);
  75. const sections = parseSections(item.jcdm2);
  76. const allWeeks = uniqueSortedNumbers(weeks);
  77. if (!item.kcmc || sections.length === 0 || allWeeks.length === 0 ||
  78. isNaN(day) || day < 1 || day > 7) return;
  79. const course = {
  80. name: String(item.kcmc).trim(),
  81. teacher: cleanTeacher(item.teaxms),
  82. position: resolvePosition(item.jxcdmc || item.jxcdmcs),
  83. day,
  84. startSection: Math.min(...sections),
  85. endSection: Math.max(...sections),
  86. weeks: allWeeks
  87. };
  88. const key = courseKey(course);
  89. const existing = courseMap.get(key);
  90. if (existing) {
  91. existing.weeks = uniqueSortedNumbers([...existing.weeks, ...course.weeks]);
  92. } else {
  93. courseMap.set(key, course);
  94. }
  95. }
  96. function sortCourses(courses) {
  97. return courses.sort((a, b) =>
  98. a.day - b.day ||
  99. a.startSection - b.startSection ||
  100. a.endSection - b.endSection ||
  101. a.name.localeCompare(b.name)
  102. );
  103. }
  104. // 从整学期课表解析课程,作为周课表接口不可用时的回退。
  105. function parseCourseList(kbxx) {
  106. if (!Array.isArray(kbxx)) throw new Error("课表接口返回格式不正确");
  107. const courseMap = new Map();
  108. kbxx.forEach(item => {
  109. addCourse(courseMap, item, parseWeeks(item.zcs));
  110. });
  111. return sortCourses(Array.from(courseMap.values()));
  112. }
  113. // 日历接口按周返回精确教室;同一课程换教室时,按周次拆成多个课程项。
  114. function parseWeeklyCourseList(rows) {
  115. if (!Array.isArray(rows)) return [];
  116. const courseMap = new Map();
  117. rows.forEach(({ item, week }) => {
  118. const actualWeek = parseInt(week, 10);
  119. if (isNaN(actualWeek) || actualWeek < 1) return;
  120. addCourse(courseMap, item, [actualWeek]);
  121. });
  122. return sortCourses(Array.from(courseMap.values()));
  123. }
  124. function maxWeekOf(kbxx) {
  125. return kbxx.reduce((maxWeek, item) => {
  126. const weeks = parseWeeks(item.zcs);
  127. return Math.max(maxWeek, ...(weeks.length ? weeks : [0]));
  128. }, 0);
  129. }
  130. // 读取课表页中的学期下拉框
  131. function extractSemesterOptions(doc) {
  132. const selectElem = doc.getElementById("xnxqdm");
  133. if (!selectElem) return null;
  134. const semesters = [];
  135. const semesterValues = [];
  136. let defaultIndex = 0;
  137. Array.from(selectElem.querySelectorAll("option")).forEach(option => {
  138. if (!option.value) return;
  139. semesters.push(option.innerText.trim());
  140. semesterValues.push(option.value);
  141. if (option.selected || option.hasAttribute("selected")) defaultIndex = semesters.length - 1;
  142. });
  143. if (semesters.length === 0) return null;
  144. const start = Math.max(0, defaultIndex - 1);
  145. const end = Math.min(semesters.length, defaultIndex + 10);
  146. return {
  147. semesters: semesters.slice(start, end),
  148. semesterValues: semesterValues.slice(start, end),
  149. defaultIndex: defaultIndex - start
  150. };
  151. }
  152. // 导入前提示先登录教务系统
  153. async function promptUserToStart() {
  154. const confirmed = await window.shiguangBridgePromise.showAlert(
  155. "济宁医学院教务导入",
  156. "请先确保已登录教务系统,再继续导入。",
  157. "我已登录"
  158. );
  159. if (!confirmed) {
  160. window.shiguangBridge.showToast("用户取消了导入。");
  161. return null;
  162. }
  163. return true;
  164. }
  165. // 选择学期
  166. async function selectSemester(semesterOptions) {
  167. const selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
  168. "选择学期",
  169. JSON.stringify(semesterOptions.semesters),
  170. semesterOptions.defaultIndex
  171. );
  172. if (selectedIndex === null || selectedIndex === undefined) return null;
  173. const index = Number(selectedIndex);
  174. if (!Number.isInteger(index) || index < 0 || index >= semesterOptions.semesters.length) return null;
  175. return {
  176. label: semesterOptions.semesters[index],
  177. value: semesterOptions.semesterValues[index]
  178. };
  179. }
  180. // 拉取课表页 HTML(含学期列表)
  181. async function fetchSchedulePage() {
  182. const response = await fetch("/xsgrkbcx!getXsgrbkList.action", { method: "GET", credentials: "include" });
  183. if (!response.ok) throw new Error(`无法打开课表页面(HTTP ${response.status})`);
  184. return response.text();
  185. }
  186. // 拉取指定学期课表(HTML 内嵌 var kbxx=[...])
  187. async function fetchCourseData(xnxqdm) {
  188. const response = await fetch(
  189. `/xsgrkbcx!xsAllKbList.action?xnxqdm=${encodeURIComponent(xnxqdm)}`,
  190. { method: "GET", credentials: "include" }
  191. );
  192. if (!response.ok) throw new Error(`课表请求失败(HTTP ${response.status})`);
  193. const htmlText = await response.text();
  194. const match = htmlText.match(/var\s+kbxx\s*=\s*(\[[\s\S]*?\]);/);
  195. if (!match) throw new Error("课表数据解析失败,请检查登录状态");
  196. let kbxx;
  197. try {
  198. kbxx = JSON.parse(match[1]);
  199. } catch (error) {
  200. throw new Error(`课表数据解析失败:${error.message}`);
  201. }
  202. return kbxx;
  203. }
  204. function normalizeDate(raw) {
  205. const match = String(raw || "").match(/^(\d{4}-\d{2}-\d{2})/);
  206. return match ? match[1] : null;
  207. }
  208. function shiftDate(dateText, dayOffset) {
  209. const match = String(dateText || "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
  210. if (!match) return null;
  211. const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
  212. date.setDate(date.getDate() + dayOffset);
  213. return [
  214. date.getFullYear(),
  215. String(date.getMonth() + 1).padStart(2, "0"),
  216. String(date.getDate()).padStart(2, "0")
  217. ].join("-");
  218. }
  219. function extractWeekStartDate(payload) {
  220. const dateRows = Array.isArray(payload) && Array.isArray(payload[1]) ? payload[1] : [];
  221. const monday = dateRows.find(item => parseInt(item.xqmc, 10) === 1) || dateRows[0];
  222. return normalizeDate(monday && monday.rq);
  223. }
  224. // 获取某一周的日历数据,jxcdmc 是该周实际使用的教室。
  225. async function fetchWeeklyCourseData(xnxqdm, week) {
  226. const response = await fetch(
  227. `/xsgrkbcx!getKbRq.action?xnxqdm=${encodeURIComponent(xnxqdm)}&zc=${encodeURIComponent(week)}`,
  228. { method: "GET", credentials: "include" }
  229. );
  230. if (!response.ok) throw new Error(`第${week}周课表请求失败(HTTP ${response.status})`);
  231. const payload = await response.json();
  232. const rows = Array.isArray(payload) && Array.isArray(payload[0]) ? payload[0] : [];
  233. return {
  234. rows: rows.map(item => ({ item, week })),
  235. weekStartDate: extractWeekStartDate(payload)
  236. };
  237. }
  238. async function fetchWeeklyCourses(xnxqdm, kbxx) {
  239. const weeks = Array.from({ length: maxWeekOf(kbxx) }, (_, index) => index + 1);
  240. if (weeks.length === 0) return { rows: [], successfulWeeks: [], semesterStartDate: null };
  241. const results = await Promise.all(weeks.map(async week => {
  242. try {
  243. const data = await fetchWeeklyCourseData(xnxqdm, week);
  244. return { week, ...data };
  245. } catch (error) {
  246. console.warn(`读取第${week}周课表失败`, error);
  247. return { week, rows: null, weekStartDate: null };
  248. }
  249. }));
  250. const datedWeek = results.find(result => result.weekStartDate);
  251. const semesterStartDate = datedWeek
  252. ? shiftDate(datedWeek.weekStartDate, -7 * (datedWeek.week - 1))
  253. : null;
  254. return {
  255. rows: results.flatMap(result => result.rows || []),
  256. successfulWeeks: results.filter(result => result.rows !== null).map(result => result.week),
  257. semesterStartDate
  258. };
  259. }
  260. function mergeWeeklyAndFallbackCourses(kbxx, weeklyData) {
  261. if (weeklyData.rows.length === 0) return parseCourseList(kbxx);
  262. const courseMap = new Map();
  263. parseWeeklyCourseList(weeklyData.rows).forEach(course => {
  264. courseMap.set(courseKey(course), course);
  265. });
  266. const successfulWeeks = new Set(weeklyData.successfulWeeks);
  267. if (successfulWeeks.size === weeklyData.successfulWeeks.length &&
  268. successfulWeeks.size >= maxWeekOf(kbxx)) return sortCourses(Array.from(courseMap.values()));
  269. // 仅为请求失败的周次使用整学期数据,避免覆盖周课表中的精确教室。
  270. kbxx.forEach(item => {
  271. const weeks = parseWeeks(item.zcs).filter(week => !successfulWeeks.has(week));
  272. if (weeks.length > 0) addCourse(courseMap, item, weeks);
  273. });
  274. return sortCourses(Array.from(courseMap.values()));
  275. }
  276. async function saveCourseConfig(config) {
  277. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  278. }
  279. async function saveCourses(courses) {
  280. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  281. }
  282. async function savePresetTimeSlots() {
  283. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(PRESET_TIME_SLOTS));
  284. }
  285. // 主流程:提示 → 选学期 → 拉课表 → 保存课程与作息
  286. async function runImportFlow() {
  287. try {
  288. const alertConfirmed = await promptUserToStart();
  289. if (!alertConfirmed) return;
  290. const pageHtml = await fetchSchedulePage();
  291. const semesterOptions = extractSemesterOptions(new DOMParser().parseFromString(pageHtml, "text/html"));
  292. if (!semesterOptions) throw new Error("未找到学期列表,请先登录教务系统");
  293. const semester = await selectSemester(semesterOptions);
  294. if (!semester) { window.shiguangBridge.showToast("导入已取消"); return; }
  295. window.shiguangBridge.showToast(`正在获取 ${semester.label} 的课表...`);
  296. const allCourseData = await fetchCourseData(semester.value);
  297. const weeklyData = await fetchWeeklyCourses(semester.value, allCourseData);
  298. const courses = mergeWeeklyAndFallbackCourses(allCourseData, weeklyData);
  299. if (courses.length === 0) {
  300. await window.shiguangBridgePromise.showAlert(
  301. "提示",
  302. "该学期没有获取到课程数据,请检查登录状态和所选学期。",
  303. "确定"
  304. );
  305. return;
  306. }
  307. const courseConfig = {
  308. semesterTotalWeeks: Math.max(maxWeekOf(allCourseData), 20),
  309. firstDayOfWeek: 1
  310. };
  311. if (weeklyData.semesterStartDate) {
  312. courseConfig.semesterStartDate = weeklyData.semesterStartDate;
  313. }
  314. await saveCourseConfig(courseConfig);
  315. await saveCourses(courses);
  316. try {
  317. await savePresetTimeSlots();
  318. } catch (error) {
  319. window.shiguangBridge.showToast(`课程已导入,作息时间导入失败:${error.message}`);
  320. }
  321. window.shiguangBridge.showToast(`成功导入 ${courses.length} 门课程!`);
  322. if (!weeklyData.semesterStartDate) {
  323. window.shiguangBridge.showToast("未获取到开学日期,请在应用内手动设置。");
  324. }
  325. window.shiguangBridge.notifyTaskCompletion();
  326. } catch (error) {
  327. await window.shiguangBridgePromise.showAlert(
  328. "导入失败",
  329. error.message || String(error),
  330. "确定"
  331. );
  332. }
  333. }
  334. runImportFlow();