fit.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. // 福州理工学院(oaa.fitedu.net) 拾光课程表适配脚本
  2. // 基于正方教务系统接口适配
  3. // 出现问题请提issues或者提交pr更改
  4. /**
  5. * 节次与周次合并去重函数
  6. * @param {Array<Object>} courses 原始解析课程数组
  7. * @returns {Array<Object>} 合并去重后的课程数组
  8. */
  9. function mergeAndDistinctCourses(courses) {
  10. if (!Array.isArray(courses) || courses.length <= 1) return courses;
  11. // 1. 深拷贝并规范周次数据,过滤无效项
  12. const list = courses.map(c => ({
  13. ...c,
  14. name: c.name || '',
  15. teacher: c.teacher || '',
  16. position: c.position || '',
  17. weeks: Array.isArray(c.weeks) ? [...c.weeks].sort((a, b) => a - b) : []
  18. }));
  19. // 阶段 1:合并连续节次与完全重复记录(前提:名称、教师、地点、星期、周次一致)
  20. list.sort((a, b) => {
  21. return a.name.localeCompare(b.name) ||
  22. a.teacher.localeCompare(b.teacher) ||
  23. a.position.localeCompare(b.position) ||
  24. (a.day || 0) - (b.day || 0) ||
  25. a.weeks.join(',').localeCompare(b.weeks.join(',')) ||
  26. (a.startSection || 0) - (b.startSection || 0);
  27. });
  28. const step1Merged = [];
  29. let current = list[0];
  30. for (let i = 1; i < list.length; i++) {
  31. const next = list[i];
  32. const isSameCourseAndWeeks =
  33. current.name === next.name &&
  34. current.teacher === next.teacher &&
  35. current.position === next.position &&
  36. current.day === next.day &&
  37. current.weeks.join(',') === next.weeks.join(',');
  38. const isContinuous = current.endSection + 1 === next.startSection;
  39. const isDuplicate = current.startSection === next.startSection && current.endSection === next.endSection;
  40. if (isSameCourseAndWeeks && isContinuous) {
  41. current.endSection = next.endSection;
  42. } else if (isSameCourseAndWeeks && isDuplicate) {
  43. continue;
  44. } else {
  45. step1Merged.push(current);
  46. current = next;
  47. }
  48. }
  49. step1Merged.push(current);
  50. // 阶段 2:合并同节次的周次(前提:名称、教师、地点、星期、开始/结束节次一致)
  51. step1Merged.sort((a, b) => {
  52. return a.name.localeCompare(b.name) ||
  53. a.teacher.localeCompare(b.teacher) ||
  54. a.position.localeCompare(b.position) ||
  55. (a.day || 0) - (b.day || 0) ||
  56. (a.startSection || 0) - (b.startSection || 0) ||
  57. (a.endSection || 0) - (b.endSection || 0);
  58. });
  59. const step2Merged = [];
  60. let cur = step1Merged[0];
  61. for (let i = 1; i < step1Merged.length; i++) {
  62. const nxt = step1Merged[i];
  63. const isSameCourseAndSection =
  64. cur.name === nxt.name &&
  65. cur.teacher === nxt.teacher &&
  66. cur.position === nxt.position &&
  67. cur.day === nxt.day &&
  68. cur.startSection === nxt.startSection &&
  69. cur.endSection === nxt.endSection;
  70. if (isSameCourseAndSection) {
  71. cur.weeks = Array.from(new Set([...cur.weeks, ...nxt.weeks])).sort((a, b) => a - b);
  72. } else {
  73. step2Merged.push(cur);
  74. cur = nxt;
  75. }
  76. }
  77. step2Merged.push(cur);
  78. return step2Merged;
  79. }
  80. /**
  81. * 解析周次字符串,处理单双周和周次范围
  82. */
  83. function parseWeeks(weekStr) {
  84. if (!weekStr) return [];
  85. const weekSets = weekStr.split(',');
  86. let weeks = [];
  87. for (const set of weekSets) {
  88. const trimmedSet = set.trim();
  89. const rangeMatch = trimmedSet.match(/(\d+)-(\d+)周/);
  90. const singleMatch = trimmedSet.match(/^(\d+)周/);
  91. let start = 0;
  92. let end = 0;
  93. let processed = false;
  94. if (rangeMatch) {
  95. start = Number(rangeMatch[1]);
  96. end = Number(rangeMatch[2]);
  97. processed = true;
  98. } else if (singleMatch) {
  99. start = end = Number(singleMatch[1]);
  100. processed = true;
  101. }
  102. if (processed) {
  103. const isSingle = trimmedSet.includes('(单)');
  104. const isDouble = trimmedSet.includes('(双)');
  105. for (let w = start; w <= end; w++) {
  106. if (isSingle && w % 2 === 0) continue;
  107. if (isDouble && w % 2 !== 0) continue;
  108. weeks.push(w);
  109. }
  110. }
  111. }
  112. return [...new Set(weeks)].sort((a, b) => a - b);
  113. }
  114. /**
  115. * 解析 API 返回的 JSON 数据
  116. */
  117. function parseJsonData(jsonData) {
  118. if (!jsonData || !Array.isArray(jsonData.kbList)) {
  119. return [];
  120. }
  121. const rawCourseList = jsonData.kbList;
  122. const initialCourseList = [];
  123. for (const rawCourse of rawCourseList) {
  124. if (!rawCourse.kcmc || !rawCourse.xm || !rawCourse.cdmc ||
  125. !rawCourse.xqj || !rawCourse.jcs || !rawCourse.zcd) {
  126. continue;
  127. }
  128. const weeksArray = parseWeeks(rawCourse.zcd);
  129. if (weeksArray.length === 0) {
  130. continue;
  131. }
  132. const sectionParts = rawCourse.jcs.split('-');
  133. const startSection = Number(sectionParts[0]);
  134. const endSection = Number(sectionParts[sectionParts.length - 1]);
  135. const day = Number(rawCourse.xqj);
  136. if (isNaN(day) || isNaN(startSection) || isNaN(endSection) ||
  137. day < 1 || day > 7 || startSection > endSection) {
  138. continue;
  139. }
  140. initialCourseList.push({
  141. name: rawCourse.kcmc.trim(),
  142. teacher: rawCourse.xm.trim(),
  143. position: rawCourse.cdmc.trim(),
  144. day: day,
  145. startSection: startSection,
  146. endSection: endSection,
  147. weeks: weeksArray
  148. });
  149. }
  150. return mergeAndDistinctCourses(initialCourseList);
  151. }
  152. async function promptUserToStart() {
  153. return await window.shiguangBridgePromise.showAlert(
  154. "教务系统课表导入",
  155. "导入前请确保您已在浏览器中成功登录教务系统",
  156. "好的,开始导入"
  157. );
  158. }
  159. /**
  160. * 从教务系统获取学年学期选项
  161. * 学年:以选中项为中心,取前2年+后2年,共5个选项
  162. */
  163. async function fetchAcademicOptions() {
  164. const url = "http://oaa.fitedu.net/jwglxt/kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N2151";
  165. try {
  166. const response = await fetch(url, {
  167. method: "GET",
  168. credentials: "include"
  169. });
  170. if (!response.ok) return null;
  171. const htmlText = await response.text();
  172. const parser = new DOMParser();
  173. const doc = parser.parseFromString(htmlText, "text/html");
  174. const allYearOptions = Array.from(doc.querySelectorAll("#xnm option"))
  175. .filter(opt => opt.value !== "")
  176. .map(opt => ({
  177. value: opt.value,
  178. text: opt.textContent.trim(),
  179. selected: opt.selected
  180. }));
  181. const semesterOptions = Array.from(doc.querySelectorAll("#xqm option"))
  182. .filter(opt => opt.value !== "")
  183. .map(opt => ({
  184. value: opt.value,
  185. text: opt.textContent.trim(),
  186. selected: opt.selected
  187. }));
  188. if (allYearOptions.length === 0 || semesterOptions.length === 0) {
  189. return null;
  190. }
  191. const selectedIndex = allYearOptions.findIndex(opt => opt.selected);
  192. if (selectedIndex === -1) {
  193. return {
  194. yearOptions: allYearOptions.slice(0, 5),
  195. semesterOptions,
  196. defaultYearIndex: 0,
  197. defaultSemesterIndex: semesterOptions.findIndex(opt => opt.selected) !== -1
  198. ? semesterOptions.findIndex(opt => opt.selected)
  199. : 0
  200. };
  201. }
  202. const start = Math.max(0, selectedIndex - 2);
  203. const end = Math.min(allYearOptions.length, selectedIndex + 3);
  204. const yearOptions = allYearOptions.slice(start, end);
  205. const newDefaultIndex = selectedIndex - start;
  206. const defaultSemesterIndex = semesterOptions.findIndex(opt => opt.selected);
  207. return {
  208. yearOptions,
  209. semesterOptions,
  210. defaultYearIndex: newDefaultIndex,
  211. defaultSemesterIndex: defaultSemesterIndex !== -1 ? defaultSemesterIndex : 0
  212. };
  213. } catch (e) {
  214. return null;
  215. }
  216. }
  217. /**
  218. * 提示用户选择学年和学期
  219. */
  220. async function selectAcademicYearAndSemester() {
  221. const optionsData = await fetchAcademicOptions();
  222. if (!optionsData) {
  223. window.shiguangBridge.showToast("从教务系统读取学年学期失败,请确保登录状态。");
  224. return null;
  225. }
  226. const { yearOptions, semesterOptions, defaultYearIndex, defaultSemesterIndex } = optionsData;
  227. const yearTexts = yearOptions.map(item => item.text);
  228. const yearIndex = await window.shiguangBridgePromise.showSingleSelection(
  229. "选择学年",
  230. JSON.stringify(yearTexts),
  231. defaultYearIndex
  232. );
  233. if (yearIndex === null || yearIndex === -1) return null;
  234. const selectedYearCode = yearOptions[yearIndex].value;
  235. const semesterTexts = semesterOptions.map(item => item.text);
  236. const semesterIndex = await window.shiguangBridgePromise.showSingleSelection(
  237. "选择学期",
  238. JSON.stringify(semesterTexts),
  239. defaultSemesterIndex
  240. );
  241. if (semesterIndex === null || semesterIndex === -1) return null;
  242. const selectedSemesterCode = semesterOptions[semesterIndex].value;
  243. return {
  244. academicYear: selectedYearCode,
  245. semesterCode: selectedSemesterCode
  246. };
  247. }
  248. /**
  249. * 获取学期开学日期
  250. */
  251. async function fetchSemesterStartDate(academicYear, semesterCode) {
  252. const url = "http://oaa.fitedu.net/jwglxt/kbcx/xskbcxZccx_cxZcByXnxq.html?gnmkdm=N2154";
  253. const requestBody = `xnm=${academicYear}&xqm=${semesterCode}`;
  254. try {
  255. const response = await fetch(url, {
  256. method: "POST",
  257. headers: {
  258. "accept": "application/json, text/javascript, */*; q=0.01",
  259. "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
  260. "x-requested-with": "XMLHttpRequest"
  261. },
  262. body: requestBody,
  263. credentials: "include"
  264. });
  265. if (response.ok) {
  266. const json = await response.json();
  267. if (Array.isArray(json) && json.length > 0) {
  268. const firstWeekObj = json.find(item => String(item.zs) === "1" || String(item.zsmc) === "1") || json[0];
  269. if (firstWeekObj.rq) {
  270. const startDateStr = firstWeekObj.rq.split('/')[0];
  271. if (/^\d{4}-\d{2}-\d{2}$/.test(startDateStr)) {
  272. return startDateStr;
  273. }
  274. }
  275. if (firstWeekObj.zcrq) {
  276. const match = firstWeekObj.zcrq.match(/(\d{4}-\d{2}-\d{2})/);
  277. if (match) return match[1];
  278. }
  279. if (firstWeekObj.ksrq) {
  280. const match = firstWeekObj.ksrq.match(/(\d{4}-\d{2}-\d{2})/);
  281. if (match) return match[1];
  282. }
  283. }
  284. }
  285. } catch (e) {
  286. // 获取失败不影响主流程
  287. }
  288. return null;
  289. }
  290. /**
  291. * 请求和解析课程数据
  292. */
  293. async function fetchAndParseCourses(academicYear, semesterCode) {
  294. const requestBody = `xnm=${academicYear}&xqm=${semesterCode}&kzlx=ck&xsdm=&kclbdm=`;
  295. const targetUrl = "http://oaa.fitedu.net/jwglxt/kbcx/xskbcx_cxXsgrkb.html?gnmkdm=N2151";
  296. const [courseResponse, semesterStartDate] = await Promise.all([
  297. fetch(targetUrl, {
  298. method: "POST",
  299. headers: {
  300. "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
  301. },
  302. body: requestBody,
  303. credentials: "include"
  304. }),
  305. fetchSemesterStartDate(academicYear, semesterCode)
  306. ]);
  307. try {
  308. if (courseResponse.ok) {
  309. const jsonText = await courseResponse.text();
  310. const jsonData = JSON.parse(jsonText);
  311. if (jsonData && jsonData.kbList) {
  312. const parsedCourses = parseJsonData(jsonData);
  313. if (parsedCourses.length > 0) {
  314. return {
  315. courses: parsedCourses,
  316. config: {
  317. semesterStartDate: semesterStartDate,
  318. semesterTotalWeeks: 20
  319. }
  320. };
  321. }
  322. }
  323. }
  324. } catch (e) {
  325. // 请求失败
  326. }
  327. window.shiguangBridge.showToast("未能获取课表数据,请检查网络环境或登录状态。");
  328. return null;
  329. }
  330. async function saveCourses(parsedCourses) {
  331. try {
  332. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(parsedCourses));
  333. return true;
  334. } catch (error) {
  335. window.shiguangBridge.showToast(`课程保存失败: ${error.message}`);
  336. return false;
  337. }
  338. }
  339. async function runImportFlow() {
  340. const alertConfirmed = await promptUserToStart();
  341. if (!alertConfirmed) {
  342. window.shiguangBridge.showToast("用户取消了导入。");
  343. return;
  344. }
  345. const selection = await selectAcademicYearAndSemester();
  346. if (!selection) {
  347. window.shiguangBridge.showToast("未选择学年学期,导入流程终止。");
  348. return;
  349. }
  350. const { academicYear, semesterCode } = selection;
  351. const result = await fetchAndParseCourses(academicYear, semesterCode);
  352. if (result === null) {
  353. return;
  354. }
  355. const { courses, config } = result;
  356. const saveResult = await saveCourses(courses);
  357. if (!saveResult) {
  358. return;
  359. }
  360. try {
  361. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  362. let configMsg = "课表配置更新成功!";
  363. if (config.semesterStartDate) {
  364. configMsg += ` 开学日期:${config.semesterStartDate}`;
  365. }
  366. window.shiguangBridge.showToast(configMsg);
  367. } catch (error) {
  368. window.shiguangBridge.showToast(`课表配置保存失败: ${error.message}`);
  369. }
  370. window.shiguangBridge.showToast(`课程导入成功,共导入 ${courses.length} 门课程!`);
  371. window.shiguangBridge.notifyTaskCompletion();
  372. }
  373. runImportFlow();