neu.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. // 文件: neu.js
  2. /**
  3. * ============================================================
  4. * 1. 用户交互层(使用桥接 API)
  5. * ============================================================
  6. */
  7. /**
  8. * 显示公告并等待用户确认
  9. * @returns {Promise<boolean>} true-继续,false-取消
  10. */
  11. async function showStartAlert() {
  12. const confirmed = await window.shiguangBridgePromise.showAlert(
  13. "课表导入",
  14. "请确保您已登录教务系统(jwxt.neu.edu.cn),且网络连接正常。",
  15. "开始导入"
  16. );
  17. if (!confirmed) {
  18. window.shiguangBridge.showToast("已取消导入");
  19. return false;
  20. }
  21. return true;
  22. }
  23. /**
  24. * 全局验证函数:校验学年输入
  25. */
  26. function validateYearInput(input) {
  27. if (/^[0-9]{4}$/.test(input)) {
  28. return false; // 验证通过
  29. }
  30. return "请输入四位数字的年份!";
  31. }
  32. /**
  33. * 获取学年(起始年份)
  34. * @returns {Promise<string|null>} 四位年份字符串,取消返回 null
  35. */
  36. async function getAcademicYear() {
  37. const year = await window.shiguangBridgePromise.showPrompt(
  38. "选择学年",
  39. "请输入起始年份(如 2025):",
  40. new Date().getFullYear().toString(),
  41. "validateYearInput"
  42. );
  43. if (year === null) {
  44. window.shiguangBridge.showToast("已取消导入");
  45. return null;
  46. }
  47. return year;
  48. }
  49. /**
  50. * 选择学期(秋季/春季)
  51. * @returns {Promise<{termType: string}|null>}
  52. */
  53. async function selectSemester() {
  54. const items = ["秋季学期 (1)", "春季学期 (2)"];
  55. const idx = await window.shiguangBridgePromise.showSingleSelection(
  56. "选择学期",
  57. JSON.stringify(items),
  58. -1
  59. );
  60. if (idx === null) {
  61. window.shiguangBridge.showToast("已取消导入");
  62. return null;
  63. }
  64. const termType = idx === 0 ? "1" : "2";
  65. return { termType };
  66. }
  67. /**
  68. * 选择校区
  69. * @returns {Promise<string|null>} "南湖校区" 或 "浑南校区"
  70. */
  71. async function selectCampus() {
  72. const campuses = ["南湖校区", "浑南校区"];
  73. const idx = await window.shiguangBridgePromise.showSingleSelection(
  74. "选择校区",
  75. JSON.stringify(campuses),
  76. -1
  77. );
  78. if (idx === null) {
  79. window.shiguangBridge.showToast("已取消导入");
  80. return null;
  81. }
  82. return campuses[idx];
  83. }
  84. /**
  85. * 询问是否导入考试时间
  86. * @returns {Promise<boolean>} true-导入,false-不导入
  87. */
  88. async function askImportExams() {
  89. const confirmed = await window.shiguangBridgePromise.showAlert(
  90. "导入考试时间",
  91. "是否导入考试时间?\n(测试功能,考试固定在第15周,出错请反馈)",
  92. "导入"
  93. );
  94. return confirmed;
  95. }
  96. /**
  97. * ============================================================
  98. * 2. 数据解析层
  99. * ============================================================
  100. */
  101. /**
  102. * 增强版周次解析:支持 "1-8周", "2-6周(双)", "1,3,5周" 等格式
  103. */
  104. function parseWeeksString(weeksStr) {
  105. if (!weeksStr) return [];
  106. const result = [];
  107. const weekParts = weeksStr.split(/[,,]/).map(part => part.trim());
  108. weekParts.forEach(part => {
  109. // 匹配单个数字周,如 "6周" 或 "6周(单)"
  110. const singleMatch = part.match(/^(\d+)周(?:\(([单双])\))?$/);
  111. if (singleMatch) {
  112. const num = parseInt(singleMatch[1]);
  113. const type = singleMatch[2];
  114. if (!type || (type === '单' && num % 2 === 1) || (type === '双' && num % 2 === 0)) {
  115. result.push(num);
  116. }
  117. return;
  118. }
  119. // 匹配范围周,如 "1-8周" 或 "2-6周(双)"
  120. const rangeMatch = part.match(/^(\d+)-(\d+)周(?:\(([单双])\))?$/);
  121. if (rangeMatch) {
  122. const start = parseInt(rangeMatch[1]);
  123. const end = parseInt(rangeMatch[2]);
  124. const type = rangeMatch[3];
  125. if (!type) {
  126. for (let i = start; i <= end; i++) result.push(i);
  127. } else if (type === '单') {
  128. for (let i = start; i <= end; i++) {
  129. if (i % 2 === 1) result.push(i);
  130. }
  131. } else if (type === '双') {
  132. for (let i = start; i <= end; i++) {
  133. if (i % 2 === 0) result.push(i);
  134. }
  135. }
  136. }
  137. });
  138. return [...new Set(result)].sort((a, b) => a - b);
  139. }
  140. /**
  141. * 将API返回的课表原始数据转换为标准课程对象数组
  142. * 直接使用 titleWeekTeacherClassroomDetail 数组,为每个安排生成独立课程
  143. */
  144. function convertApiResponseToLessons(arrangedList) {
  145. const lessons = [];
  146. for (const item of arrangedList) {
  147. const day = item.dayOfWeek;
  148. const startSection = parseInt(item.beginSection, 10);
  149. const endSection = parseInt(item.endSection, 10);
  150. if (!day || isNaN(startSection) || isNaN(endSection)) continue;
  151. const name = item.courseName || "";
  152. if (!name) continue;
  153. const details = item.titleWeekTeacherClassroomDetail || [];
  154. if (details.length === 0) {
  155. console.warn(`课程 "${name}" 无上课安排,已跳过`);
  156. continue;
  157. }
  158. for (const detail of details) {
  159. if (!detail) continue;
  160. const tokens = detail.trim().split(/\s+/);
  161. if (tokens.length < 1) continue;
  162. const weeksStr = tokens[0];
  163. const teacher = tokens[1] || "";
  164. const position = tokens.slice(2).join(' ');
  165. const weeks = parseWeeksString(weeksStr);
  166. if (weeks.length === 0) {
  167. console.warn(`周次解析失败: "${weeksStr}",课程: ${name}`);
  168. continue;
  169. }
  170. lessons.push({
  171. name: name,
  172. teacher: teacher,
  173. position: position,
  174. day: day,
  175. startSection: startSection,
  176. endSection: endSection,
  177. weeks: weeks,
  178. isCustomTime: false
  179. });
  180. }
  181. }
  182. return lessons;
  183. }
  184. /**
  185. * 解析考试时间描述字符串
  186. */
  187. function parseExamTimeDescription(desc) {
  188. const weekMap = { '星期一': 1, '星期二': 2, '星期三': 3, '星期四': 4, '星期五': 5, '星期六': 6, '星期日': 7 };
  189. let day = null;
  190. let startTime = null;
  191. let endTime = null;
  192. for (const [cn, num] of Object.entries(weekMap)) {
  193. if (desc.includes(cn)) {
  194. day = num;
  195. break;
  196. }
  197. }
  198. const timeMatch = desc.match(/(\d{1,2}:\d{2})-(\d{1,2}:\d{2})/);
  199. if (timeMatch) {
  200. startTime = timeMatch[1];
  201. endTime = timeMatch[2];
  202. }
  203. return { day, startTime, endTime };
  204. }
  205. /**
  206. * 从考试API获取考试数据
  207. */
  208. async function fetchExamsFromAPI(termCode) {
  209. const url = `https://jwxt.neu.edu.cn/jwapp/sys/homeapp/api/home/student/exams.do?termCode=${encodeURIComponent(termCode)}`;
  210. const response = await fetch(url, {
  211. method: 'GET',
  212. headers: {
  213. 'Fetch-Api': 'true',
  214. 'Referer': 'https://jwxt.neu.edu.cn/jwapp/sys/homeapp/home/index.html',
  215. 'User-Agent': navigator.userAgent
  216. }
  217. });
  218. if (!response.ok) throw new Error(`考试API HTTP ${response.status}`);
  219. const data = await response.json();
  220. if (data.code !== '0') throw new Error(`考试API错误码: ${data.code}`);
  221. const exams = data.datas || [];
  222. const lessons = [];
  223. for (const exam of exams) {
  224. const rawName = exam.courseName || "";
  225. const examType = exam.examType || "考试";
  226. const desc = exam.examTimeDescription || "";
  227. let dateStr = "";
  228. const dateMatch = desc.match(/(\d{2})年(\d{2})月(\d{2})日/);
  229. if (dateMatch) {
  230. dateStr = `${dateMatch[2]}月${dateMatch[3]}日`;
  231. } else {
  232. const simpleMatch = desc.match(/(\d{2})月(\d{2})日/);
  233. if (simpleMatch) dateStr = `${simpleMatch[1]}月${simpleMatch[2]}日`;
  234. }
  235. const name = dateStr ? `${rawName}_${examType}_${dateStr}` : `${rawName}_${examType}`;
  236. const teacher = exam.teachers || "";
  237. const position = exam.examPlace || "";
  238. const { day, startTime, endTime } = parseExamTimeDescription(desc);
  239. if (!day || !startTime || !endTime) {
  240. console.warn("解析考试时间失败,跳过:", desc);
  241. continue;
  242. }
  243. lessons.push({
  244. name: name,
  245. teacher: teacher,
  246. position: position,
  247. day: day,
  248. startSection: undefined,
  249. endSection: undefined,
  250. weeks: [15],
  251. isCustomTime: true,
  252. customStartTime: startTime,
  253. customEndTime: endTime
  254. });
  255. }
  256. return lessons;
  257. }
  258. /**
  259. * ============================================================
  260. * 3. 网络请求层
  261. * ============================================================
  262. */
  263. /**
  264. * 从教务API获取课表数据(支持重试)
  265. */
  266. async function fetchCoursesFromAPI(semesterCode, retries = 2) {
  267. const url = 'https://jwxt.neu.edu.cn/jwapp/sys/kbapp/api/wdkbcx/getMyScheduleDetail.do';
  268. const xnxqdm = semesterCode;
  269. const xqdm = ''; // 神秘参数,设为空可获取课表数据
  270. for (let i = 1; i <= retries; i++) {
  271. try {
  272. const ctrl = new AbortController();
  273. const tid = setTimeout(() => ctrl.abort(), 10000);
  274. const res = await fetch(url, {
  275. method: 'POST',
  276. headers: {
  277. 'Fetch-Api': 'true',
  278. 'Referer': 'https://jwxt.neu.edu.cn/jwapp/sys/kbapp/home/index.html',
  279. 'User-Agent': navigator.userAgent,
  280. 'Accept': 'application/json'
  281. },
  282. body: new URLSearchParams({ XNXQDM: xnxqdm, XQDM: xqdm }),
  283. signal: ctrl.signal
  284. });
  285. clearTimeout(tid);
  286. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  287. const data = await res.json();
  288. if (data.code !== '0') throw new Error(`API error ${data.code}`);
  289. const list = data?.datas?.getMyScheduleDetail?.arrangedList || [];
  290. return convertApiResponseToLessons(list);
  291. } catch (e) {
  292. if (i === retries) throw e;
  293. await new Promise(r => setTimeout(r, 2000));
  294. }
  295. }
  296. }
  297. /**
  298. * ============================================================
  299. * 4. 数据保存层(使用桥接 API)
  300. * ============================================================
  301. */
  302. /**
  303. * 保存课程列表
  304. */
  305. async function saveCourses(lessons) {
  306. try {
  307. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(lessons));
  308. window.shiguangBridge.showToast(`成功导入 ${lessons.length} 门课程`);
  309. return true;
  310. } catch (error) {
  311. window.shiguangBridge.showToast(`保存课程失败: ${error.message}`);
  312. return false;
  313. }
  314. }
  315. /**
  316. * 保存预设时间段(根据校区)
  317. */
  318. async function saveTimeSlots(campus) {
  319. const hunNan = [
  320. { "number": 1, "startTime": "08:30", "endTime": "09:15" },
  321. { "number": 2, "startTime": "09:25", "endTime": "10:10" },
  322. { "number": 3, "startTime": "10:30", "endTime": "11:15" },
  323. { "number": 4, "startTime": "11:25", "endTime": "12:10" },
  324. { "number": 5, "startTime": "14:00", "endTime": "14:45" },
  325. { "number": 6, "startTime": "14:55", "endTime": "15:40" },
  326. { "number": 7, "startTime": "16:00", "endTime": "16:45" },
  327. { "number": 8, "startTime": "16:55", "endTime": "17:40" },
  328. { "number": 9, "startTime": "18:30", "endTime": "19:15" },
  329. { "number": 10, "startTime": "19:25", "endTime": "20:10" },
  330. { "number": 11, "startTime": "20:30", "endTime": "21:15" },
  331. { "number": 12, "startTime": "21:15", "endTime": "22:10" }
  332. ];
  333. const nanHu = [
  334. { "number": 1, "startTime": "08:00", "endTime": "08:45" },
  335. { "number": 2, "startTime": "08:55", "endTime": "09:40" },
  336. { "number": 3, "startTime": "10:00", "endTime": "10:45" },
  337. { "number": 4, "startTime": "10:55", "endTime": "11:40" },
  338. { "number": 5, "startTime": "14:00", "endTime": "14:45" },
  339. { "number": 6, "startTime": "14:55", "endTime": "15:40" },
  340. { "number": 7, "startTime": "16:00", "endTime": "16:45" },
  341. { "number": 8, "startTime": "16:55", "endTime": "17:40" },
  342. { "number": 9, "startTime": "18:30", "endTime": "19:15" },
  343. { "number": 10, "startTime": "19:25", "endTime": "20:10" },
  344. { "number": 11, "startTime": "20:20", "endTime": "21:05" },
  345. { "number": 12, "startTime": "21:15", "endTime": "22:00" }
  346. ];
  347. const slots = campus === "南湖校区" ? nanHu : hunNan;
  348. try {
  349. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(slots));
  350. return true;
  351. } catch (error) {
  352. window.shiguangBridge.showToast(`导入时间段失败: ${error.message}`);
  353. return false;
  354. }
  355. }
  356. /**
  357. * 保存课表配置(不包含 semesterStartDate)
  358. */
  359. async function saveConfig() {
  360. const config = {
  361. semesterTotalWeeks: 18,
  362. defaultClassDuration: 45,
  363. defaultBreakDuration: 10,
  364. firstDayOfWeek: 7
  365. };
  366. try {
  367. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  368. return true;
  369. } catch (error) {
  370. window.shiguangBridge.showToast(`保存配置失败: ${error.message}`);
  371. return false;
  372. }
  373. }
  374. /**
  375. * ============================================================
  376. * 5. 主流程(符合规范的结构化编排)
  377. * ============================================================
  378. */
  379. async function runImportFlow() {
  380. window.shiguangBridge.showToast("开始导入课表...");
  381. // 1. 公告确认
  382. const alertConfirmed = await showStartAlert();
  383. if (!alertConfirmed) return;
  384. // 2. 获取学年
  385. const academicYear = await getAcademicYear();
  386. if (academicYear === null) return;
  387. // 3. 选择学期
  388. const semesterResult = await selectSemester();
  389. if (semesterResult === null) return;
  390. const { termType } = semesterResult;
  391. const startYear = parseInt(academicYear, 10);
  392. const endYear = startYear + 1;
  393. const semesterCode = `${startYear}-${endYear}-${termType}`;
  394. // 4. 选择校区
  395. const campus = await selectCampus();
  396. if (campus === null) return;
  397. // 5. 获取课表数据
  398. window.shiguangBridge.showToast("正在获取课表数据...");
  399. let lessons;
  400. try {
  401. lessons = await fetchCoursesFromAPI(semesterCode);
  402. if (!lessons.length) {
  403. window.shiguangBridge.showToast("未获取到任何课程");
  404. return;
  405. }
  406. console.log(`获取到 ${lessons.length} 条课程内容`);
  407. } catch (e) {
  408. window.shiguangBridge.showToast("获取课表失败: " + e.message);
  409. return;
  410. }
  411. // 6. 保存课程
  412. const saveResult = await saveCourses(lessons);
  413. if (!saveResult) return;
  414. // 7. 保存时间段
  415. await saveTimeSlots(campus);
  416. // 8. 保存配置(不包含 semesterStartDate)
  417. const configResult = await saveConfig();
  418. if (!configResult) return;
  419. window.shiguangBridge.showToast("课表导入完成!");
  420. // 9. [可选] 导入考试时间
  421. const importExams = await askImportExams();
  422. if (importExams) {
  423. window.shiguangBridge.showToast("正在获取考试数据...");
  424. try {
  425. const examLessons = await fetchExamsFromAPI(semesterCode);
  426. if (examLessons.length === 0) {
  427. window.shiguangBridge.showToast("未获取到考试数据");
  428. } else {
  429. const allLessons = [...lessons, ...examLessons];
  430. await saveCourses(allLessons);
  431. window.shiguangBridge.showToast(`已导入 ${examLessons.length} 条考试记录`);
  432. }
  433. } catch (e) {
  434. window.shiguangBridge.showToast("导入考试失败: " + e.message);
  435. console.error(e);
  436. // 考试导入失败不影响主流程完成
  437. }
  438. }
  439. // 10. 流程完全成功,发送结束信号
  440. window.shiguangBridge.showToast("所有任务已完成!");
  441. window.shiguangBridge.notifyTaskCompletion();
  442. }
  443. // 启动导入流程
  444. runImportFlow();