neu.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. // 文件: neu.js
  2. /**
  3. * 显示自定义学年学期选择对话框
  4. * @returns {Promise<{semesterCode: string, xnxqdm: string, xqdm: string} | null>}
  5. * 返回包含学期代码的对象,若取消则返回 null
  6. */
  7. async function showCustomSemesterDialog() {
  8. return new Promise((resolve) => {
  9. const overlay = document.createElement('div');
  10. overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:10000;display:flex;align-items:center;justify-content:center';
  11. const dialog = document.createElement('div');
  12. dialog.style.cssText = 'background:white;padding:20px;border-radius:8px;box-shadow:0 2px 10px rgba(0,0,0,0.3);min-width:280px;text-align:center';
  13. dialog.innerHTML = `
  14. <div style="font-size:18px;margin-bottom:20px;font-weight:bold">选择学年学期</div>
  15. <div style="display:flex;align-items:center;justify-content:center;margin-bottom:20px">
  16. <input type="number" id="startYear" placeholder="起始年份" value="2025" style="width:80px;padding:5px">
  17. <span style="margin:0 5px">—</span>
  18. <input type="number" id="endYear" placeholder="结束年份" value="2026" style="width:80px;padding:5px">
  19. </div>
  20. <div style="margin-bottom:20px">
  21. <select id="termSelect" style="width:100%;padding:5px">
  22. <option value="fall">秋季学期</option>
  23. <option value="spring">春季学期</option>
  24. </select>
  25. </div>
  26. <div style="display:flex;justify-content:space-around">
  27. <button id="confirmBtn" style="padding:5px 15px;background:#4CAF50;color:white;border:none;border-radius:4px;cursor:pointer">确定</button>
  28. <button id="cancelBtn" style="padding:5px 15px;background:#f44336;color:white;border:none;border-radius:4px;cursor:pointer">取消</button>
  29. </div>
  30. `;
  31. overlay.appendChild(dialog);
  32. document.body.appendChild(overlay);
  33. const startYearInput = dialog.querySelector('#startYear');
  34. const endYearInput = dialog.querySelector('#endYear');
  35. const termSelect = dialog.querySelector('#termSelect');
  36. const confirmBtn = dialog.querySelector('#confirmBtn');
  37. const cancelBtn = dialog.querySelector('#cancelBtn');
  38. const cleanup = () => document.body.removeChild(overlay);
  39. confirmBtn.onclick = () => {
  40. const start = parseInt(startYearInput.value, 10);
  41. const end = parseInt(endYearInput.value, 10);
  42. if (isNaN(start) || isNaN(end)) { alert('请输入有效年份'); return; }
  43. const semesterNum = termSelect.value === 'fall' ? '1' : '2';
  44. const semesterCode = `${start}-${end}-${semesterNum}`;
  45. cleanup();
  46. resolve({ semesterCode, xnxqdm: semesterCode, xqdm: '01' });
  47. };
  48. cancelBtn.onclick = () => { cleanup(); resolve(null); };
  49. });
  50. }
  51. /**
  52. * 显示学期选择(封装 showCustomSemesterDialog)
  53. * @returns {Promise<string|false>} 返回学期代码字符串,取消则返回 false
  54. */
  55. async function showSemesterSelection() {
  56. const res = await showCustomSemesterDialog();
  57. return res ? res.semesterCode : false;
  58. }
  59. /**
  60. * 显示校区选择对话框(通过Android原生弹窗)
  61. * @returns {Promise<string|false>} 返回校区名称("南湖校区"或"浑南校区"),取消返回 false
  62. */
  63. async function showCampusSelection() {
  64. const campuses = ["南湖校区", "浑南校区"];
  65. try {
  66. const idx = await window.shiguangBridgePromise.showSingleSelection("选择你所在的校区", JSON.stringify(campuses), 2);
  67. return idx !== -1 ? campuses[idx] : false;
  68. } catch(e) {
  69. window.shiguangBridge.showToast("显示校区列表出错:" + e.message);
  70. return false;
  71. }
  72. }
  73. /**
  74. * 弹窗询问用户是否导入考试时间(测试功能)
  75. * @returns {Promise<boolean>} true-导入,false-不导入
  76. */
  77. async function askImportExams() {
  78. return new Promise((resolve) => {
  79. const overlay = document.createElement('div');
  80. overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:10000;display:flex;align-items:center;justify-content:center';
  81. const dialog = document.createElement('div');
  82. dialog.style.cssText = 'background:white;padding:20px;border-radius:8px;box-shadow:0 2px 10px rgba(0,0,0,0.3);min-width:280px;text-align:center';
  83. dialog.innerHTML = `
  84. <div style="font-size:16px;margin-bottom:10px;font-weight:bold">是否导入考试时间</div>
  85. <div style="font-size:12px;color:gray;margin-bottom:20px">测试功能,周数默认为第15周,需手动调整到对应日期。出错请反馈</div>
  86. <div style="display:flex;justify-content:space-around">
  87. <button id="yesBtn" style="padding:5px 15px;background:#4CAF50;color:white;border:none;border-radius:4px;cursor:pointer">是</button>
  88. <button id="noBtn" style="padding:5px 15px;background:#f44336;color:white;border:none;border-radius:4px;cursor:pointer">否</button>
  89. </div>
  90. `;
  91. overlay.appendChild(dialog);
  92. document.body.appendChild(overlay);
  93. const cleanup = () => document.body.removeChild(overlay);
  94. dialog.querySelector('#yesBtn').onclick = () => { cleanup(); resolve(true); };
  95. dialog.querySelector('#noBtn').onclick = () => { cleanup(); resolve(false); };
  96. });
  97. }
  98. /**
  99. * 解析考试时间描述字符串,提取星期几、开始时间、结束时间
  100. * @param {string} desc 例如 "2026年05月06日 10:10-12:10(星期三第1场)"
  101. * @returns {{day: number|null, startTime: string|null, endTime: string|null}}
  102. * day: 1~7 对应星期一~星期日,无法解析则为 null
  103. */
  104. function parseExamTimeDescription(desc) {
  105. const weekMap = { '星期一': 1, '星期二': 2, '星期三': 3, '星期四': 4, '星期五': 5, '星期六': 6, '星期日': 7 };
  106. let day = null;
  107. let startTime = null;
  108. let endTime = null;
  109. for (const [cn, num] of Object.entries(weekMap)) {
  110. if (desc.includes(cn)) {
  111. day = num;
  112. break;
  113. }
  114. }
  115. const timeMatch = desc.match(/(\d{1,2}:\d{2})-(\d{1,2}:\d{2})/);
  116. if (timeMatch) {
  117. startTime = timeMatch[1];
  118. endTime = timeMatch[2];
  119. }
  120. return { day, startTime, endTime };
  121. }
  122. /**
  123. * 从考试API获取指定学期的考试数据,并转换为课程对象格式
  124. * @param {string} termCode 学期代码,如 "2025-2026-1"
  125. * @returns {Promise<Array<object>>} 课程对象数组,每个对象包含 name, teacher, position, day, weeks, isCustomTime, customStartTime, customEndTime
  126. * @throws 网络或API错误
  127. */
  128. async function fetchExamsFromAPI(termCode) {
  129. const url = `https://jwxt.neu.edu.cn/jwapp/sys/homeapp/api/home/student/exams.do?termCode=${encodeURIComponent(termCode)}`;
  130. const response = await fetch(url, {
  131. method: 'GET',
  132. headers: { 'Fetch-Api': 'true', 'Referer': 'https://jwxt.neu.edu.cn/jwapp/sys/homeapp/home/index.html', 'User-Agent': navigator.userAgent }
  133. });
  134. if (!response.ok) throw new Error(`考试API HTTP ${response.status}`);
  135. const data = await response.json();
  136. if (data.code !== '0') throw new Error(`考试API错误码: ${data.code}`);
  137. const exams = data.datas || [];
  138. const lessons = [];
  139. for (const exam of exams) {
  140. const rawName = exam.courseName || "";
  141. const examType = exam.examType || "考试";
  142. const desc = exam.examTimeDescription || "";
  143. let dateStr = "";
  144. const dateMatch = desc.match(/(\d{2})年(\d{2})月(\d{2})日/);
  145. if (dateMatch) {
  146. dateStr = `${dateMatch[2]}月${dateMatch[3]}日`;
  147. } else {
  148. const simpleMatch = desc.match(/(\d{2})月(\d{2})日/);
  149. if (simpleMatch) dateStr = `${simpleMatch[1]}月${simpleMatch[2]}日`;
  150. }
  151. const name = dateStr ? `${rawName}_${examType}_${dateStr}` : `${rawName}_${examType}`;
  152. const teacher = exam.teachers || "";
  153. const position = exam.examPlace || "";
  154. const { day, startTime, endTime } = parseExamTimeDescription(desc);
  155. if (!day || !startTime || !endTime) {
  156. console.warn("解析考试时间失败,跳过:", desc);
  157. continue;
  158. }
  159. const weeks = [15]; // 考试固定在第15周(测试功能)
  160. lessons.push({
  161. name: name,
  162. teacher: teacher,
  163. position: position,
  164. day: day,
  165. startSection: undefined,
  166. endSection: undefined,
  167. weeks: weeks,
  168. isCustomTime: true,
  169. customStartTime: startTime,
  170. customEndTime: endTime
  171. });
  172. }
  173. return lessons;
  174. }
  175. /**
  176. * 增强版周次解析:支持 "1-8周", "2-6周(双)", "1,3,5周" 等格式
  177. * @param {string} weeksStr 周次字符串,如 "1-8周"
  178. * @returns {number[]} 周次数字数组(已去重、排序)
  179. */
  180. function parseWeeksString(weeksStr) {
  181. if (!weeksStr) return [];
  182. const result = [];
  183. const weekParts = weeksStr.split(/[,,]/).map(part => part.trim());
  184. weekParts.forEach(part => {
  185. // 匹配单个数字周,如 "6周" 或 "6周(单)"
  186. const singleMatch = part.match(/^(\d+)周(?:\(([单双])\))?$/);
  187. if (singleMatch) {
  188. const num = parseInt(singleMatch[1]);
  189. const type = singleMatch[2];
  190. if (!type || (type === '单' && num % 2 === 1) || (type === '双' && num % 2 === 0)) {
  191. result.push(num);
  192. }
  193. return;
  194. }
  195. // 匹配范围周,如 "1-8周" 或 "2-6周(双)"
  196. const rangeMatch = part.match(/^(\d+)-(\d+)周(?:\(([单双])\))?$/);
  197. if (rangeMatch) {
  198. const start = parseInt(rangeMatch[1]);
  199. const end = parseInt(rangeMatch[2]);
  200. const type = rangeMatch[3];
  201. if (!type) {
  202. for (let i = start; i <= end; i++) result.push(i);
  203. } else if (type === '单') {
  204. for (let i = start; i <= end; i++) {
  205. if (i % 2 === 1) result.push(i);
  206. }
  207. } else if (type === '双') {
  208. for (let i = start; i <= end; i++) {
  209. if (i % 2 === 0) result.push(i);
  210. }
  211. }
  212. }
  213. });
  214. return [...new Set(result)].sort((a, b) => a - b);
  215. }
  216. /**
  217. * 将API返回的课表原始数据(arrangedList)转换为标准课程对象数组
  218. * 新逻辑:直接从 titleDetail 解析课程名、周次、教师、地点
  219. * @param {Array} arrangedList API返回的课表列表
  220. * @returns {Array<object>} 课程对象,包含 name, teacher, position, day, startSection, endSection, weeks, isCustomTime(false)
  221. */
  222. function convertApiResponseToLessons(arrangedList) {
  223. const lessons = [];
  224. for (const item of arrangedList) {
  225. const day = item.dayOfWeek;
  226. const startSection = item.beginSection;
  227. const endSection = item.endSection;
  228. if (!day || !startSection || !endSection) continue;
  229. // ---- 1. 获取课程名 ----
  230. let name = item.courseName || "";
  231. // 如果 courseName 为空(极端情况),从 titleDetail 补全
  232. if (!name) {
  233. const td = item.titleDetail || [];
  234. if (td.length >= 2) {
  235. // 实验课 td[0] 通常以 "[实]" 开头,直接使用
  236. if (td[0].includes("[实]") || td[0].includes("实验")) {
  237. name = td[0];
  238. } else {
  239. // 理论课:取 td[1] 空格前的部分(如 "机器学习")
  240. const idx = td[1].indexOf(' ');
  241. name = idx !== -1 ? td[1].substring(0, idx) : td[1];
  242. }
  243. } else if (td.length === 1) {
  244. name = td[0];
  245. }
  246. }
  247. // ---- 2. 获取周次-教师-地点字符串 ----
  248. let weekTeacherPlace = item.titleWeekTeacherClassroomDetail?.[0] || "";
  249. // 若该字段缺失,回退到 titleDetail 的对应项
  250. if (!weekTeacherPlace) {
  251. const td = item.titleDetail || [];
  252. if (td.length >= 3) weekTeacherPlace = td[2]; // 理论课
  253. else if (td.length === 2) weekTeacherPlace = td[1]; // 实验课
  254. }
  255. if (!weekTeacherPlace) continue; // 无有效信息则跳过
  256. // ---- 3. 解析 tokens ----
  257. const tokens = weekTeacherPlace.trim().split(/\s+/);
  258. if (tokens.length < 1) continue;
  259. const weeksStr = tokens[0]; // 如 "1-8周"
  260. const teacher = tokens[1] || "";
  261. const position = tokens.slice(2).join(' ');
  262. // ---- 4. 解析周次数组 ----
  263. const weeks = parseWeeksString(weeksStr);
  264. if (weeks.length === 0) {
  265. console.warn(`周次解析失败: ${weeksStr}, 课程: ${name}`);
  266. continue;
  267. }
  268. lessons.push({
  269. name: name,
  270. teacher: teacher,
  271. position: position,
  272. day: day,
  273. startSection: startSection,
  274. endSection: endSection,
  275. weeks: weeks,
  276. isCustomTime: false
  277. });
  278. }
  279. return lessons;
  280. }
  281. /**
  282. * 从教务API获取指定学期的课表数据(支持重试)
  283. * @param {string} semesterCode 学期代码,如 "2025-2026-1"
  284. * @param {number} retries 重试次数,默认2次
  285. * @returns {Promise<Array<object>>} 课程对象数组
  286. * @throws 网络或API错误
  287. */
  288. async function fetchCoursesFromAPI(semesterCode, retries=2) {
  289. const url = 'https://jwxt.neu.edu.cn/jwapp/sys/kbapp/api/wdkbcx/getMyScheduleDetail.do';
  290. const xnxqdm = semesterCode;
  291. const xqdm = '';//神秘参数,设为空就啥时候都能获得课表数据,尼东教务系统真是神了。
  292. for (let i=1; i<=retries; i++) {
  293. try {
  294. const ctrl = new AbortController();
  295. const tid = setTimeout(()=>ctrl.abort(), 10000);
  296. const res = await fetch(url, {
  297. method: 'POST',
  298. headers: { 'Fetch-Api':'true', 'Referer':'https://jwxt.neu.edu.cn/jwapp/sys/kbapp/home/index.html', 'User-Agent': navigator.userAgent, 'Accept':'application/json' },
  299. body: new URLSearchParams({ XNXQDM: xnxqdm, XQDM: xqdm }),
  300. signal: ctrl.signal
  301. });
  302. clearTimeout(tid);
  303. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  304. const data = await res.json();
  305. if (data.code !== '0') throw new Error(`API error ${data.code}`);
  306. const list = data?.datas?.getMyScheduleDetail?.arrangedList || [];
  307. return convertApiResponseToLessons(list);
  308. } catch(e) {
  309. if (i===retries) throw e;
  310. await new Promise(r=>setTimeout(r,2000));
  311. }
  312. }
  313. }
  314. /**
  315. * 调用Android Bridge保存课程列表(覆盖写入)
  316. * @param {Array<object>} lessons 课程对象数组
  317. */
  318. async function SaveCourses(lessons) {
  319. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(lessons));
  320. }
  321. /**
  322. * 根据校区导入预设的上下课时间表(节次时间)
  323. * @param {string} campus "南湖校区" 或 "浑南校区"
  324. */
  325. async function importTimeSlotsByCampus(campus) {
  326. const hunNan = [{"number":1,"startTime":"08:30","endTime":"09:15"},{"number":2,"startTime":"09:25","endTime":"10:10"},{"number":3,"startTime":"10:30","endTime":"11:15"},{"number":4,"startTime":"11:25","endTime":"12:10"},{"number":5,"startTime":"14:00","endTime":"14:45"},{"number":6,"startTime":"14:55","endTime":"15:40"},{"number":7,"startTime":"16:00","endTime":"16:45"},{"number":8,"startTime":"16:55","endTime":"17:40"},{"number":9,"startTime":"18:30","endTime":"19:15"},{"number":10,"startTime":"19:25","endTime":"20:10"},{"number":11,"startTime":"20:30","endTime":"21:15"},{"number":12,"startTime":"21:15","endTime":"22:10"}];
  327. const nanHu = [{"number":1,"startTime":"08:00","endTime":"08:45"},{"number":2,"startTime":"08:55","endTime":"09:40"},{"number":3,"startTime":"10:00","endTime":"10:45"},{"number":4,"startTime":"10:55","endTime":"11:40"},{"number":5,"startTime":"14:00","endTime":"14:45"},{"number":6,"startTime":"14:55","endTime":"15:40"},{"number":7,"startTime":"16:00","endTime":"16:45"},{"number":8,"startTime":"16:55","endTime":"17:40"},{"number":9,"startTime":"18:30","endTime":"19:15"},{"number":10,"startTime":"19:25","endTime":"20:10"},{"number":11,"startTime":"20:20","endTime":"21:05"},{"number":12,"startTime":"21:15","endTime":"22:00"}];
  328. const slots = campus === "南湖校区" ? nanHu : hunNan;
  329. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(slots));
  330. }
  331. /**
  332. * 保存课表全局配置(学期总周数、默认课时长度、课间休息、每周起始日)
  333. */
  334. async function SaveConfig() {
  335. const cfg = { semesterTotalWeeks:18, defaultClassDuration:45, defaultBreakDuration:10, firstDayOfWeek:7 };
  336. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(cfg));
  337. }
  338. /**
  339. * 主流程:依次选择校区、学期,获取课表,保存,可选导入考试并合并保存
  340. * 最后通知Android任务完成
  341. */
  342. async function runAllDemosSequentially() {
  343. window.shiguangBridge.showToast("开始导入课表...");
  344. const campus = await showCampusSelection();
  345. if (!campus) { window.shiguangBridge.showToast("已取消导入"); return; }
  346. const semester = await showSemesterSelection();
  347. if (!semester) { window.shiguangBridge.showToast("已取消导入"); return; }
  348. window.shiguangBridge.showToast("正在获取课表数据...");
  349. let lessons;
  350. try {
  351. lessons = await fetchCoursesFromAPI(semester);
  352. if (!lessons.length) { window.shiguangBridge.showToast("未获取到任何课程"); return; }
  353. console.log(`获取到 ${lessons.length} 门课程`);
  354. } catch(e) {
  355. window.shiguangBridge.showToast("获取课表失败: "+e.message);
  356. return;
  357. }
  358. await SaveCourses(lessons);
  359. await importTimeSlotsByCampus(campus);
  360. await SaveConfig();
  361. window.shiguangBridge.showToast("课表导入完成!");
  362. const importExams = await askImportExams();
  363. if (importExams) {
  364. window.shiguangBridge.showToast("正在获取考试数据...");
  365. try {
  366. const examLessons = await fetchExamsFromAPI(semester);
  367. if (examLessons.length === 0) {
  368. window.shiguangBridge.showToast("未获取到考试数据");
  369. } else {
  370. const allLessons = [...lessons, ...examLessons];
  371. await SaveCourses(allLessons);
  372. window.shiguangBridge.showToast(`已导入 ${examLessons.length} 条考试记录(合并至课表)`);
  373. }
  374. } catch(e) {
  375. window.shiguangBridge.showToast("导入考试失败: "+e.message);
  376. console.error(e);
  377. }
  378. }
  379. window.shiguangBridge.notifyTaskCompletion();
  380. }
  381. // 启动主流程
  382. runAllDemosSequentially();