neu.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  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.AndroidBridgePromise.showSingleSelection("选择你所在的校区", JSON.stringify(campuses), 2);
  67. return idx !== -1 ? campuses[idx] : false;
  68. } catch(e) {
  69. AndroidBridge.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. // 必要字段检查
  226. const day = item.dayOfWeek;
  227. const startSection = item.beginSection;
  228. const endSection = item.endSection;
  229. if (!day || !startSection || !endSection) continue;
  230. const titleDetail = item.titleDetail;
  231. if (!Array.isArray(titleDetail) || titleDetail.length < 2) {
  232. console.warn("titleDetail 无效,跳过课程:", item);
  233. continue;
  234. }
  235. // 1. 课程名:从 titleDetail[0] 的第一个空格前提取
  236. const title0 = titleDetail[0] || "";
  237. const firstSpaceIdx = title0.indexOf(' ');
  238. const name = firstSpaceIdx !== -1 ? title0.substring(0, firstSpaceIdx) : title0;
  239. if (!name) continue;
  240. // 2. 解析 titleDetail[1] => 周次字符串、教师、地点
  241. const title1 = titleDetail[1] || "";
  242. const tokens = title1.trim().split(/\s+/); // 按空白符分割
  243. if (tokens.length < 1) continue;
  244. const weeksStr = tokens[0]; // 例如 "1-8周"
  245. const teacher = tokens[1] || "";
  246. // 地点:从第2个token开始到末尾,用空格重新拼接
  247. const position = tokens.slice(2).join(' ');
  248. // 3. 解析周次字符串为数字数组
  249. const weeks = parseWeeksString(weeksStr);
  250. if (weeks.length === 0) {
  251. console.warn(`周次解析失败: ${weeksStr}, 课程: ${name}`);
  252. continue;
  253. }
  254. lessons.push({
  255. name: name,
  256. teacher: teacher,
  257. position: position,
  258. day: day,
  259. startSection: startSection,
  260. endSection: endSection,
  261. weeks: weeks,
  262. isCustomTime: false
  263. });
  264. }
  265. return lessons;
  266. }
  267. /**
  268. * 从教务API获取指定学期的课表数据(支持重试)
  269. * @param {string} semesterCode 学期代码,如 "2025-2026-1"
  270. * @param {number} retries 重试次数,默认2次
  271. * @returns {Promise<Array<object>>} 课程对象数组
  272. * @throws 网络或API错误
  273. */
  274. async function fetchCoursesFromAPI(semesterCode, retries=2) {
  275. const url = 'https://jwxt.neu.edu.cn/jwapp/sys/kbapp/api/wdkbcx/getMyScheduleDetail.do';
  276. const xnxqdm = semesterCode;
  277. const xqdm = '01';
  278. for (let i=1; i<=retries; i++) {
  279. try {
  280. const ctrl = new AbortController();
  281. const tid = setTimeout(()=>ctrl.abort(), 10000);
  282. const res = await fetch(url, {
  283. method: 'POST',
  284. headers: { 'Fetch-Api':'true', 'Referer':'https://jwxt.neu.edu.cn/jwapp/sys/kbapp/home/index.html', 'User-Agent': navigator.userAgent, 'Accept':'application/json' },
  285. body: new URLSearchParams({ XNXQDM: xnxqdm, XQDM: xqdm }),
  286. signal: ctrl.signal
  287. });
  288. clearTimeout(tid);
  289. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  290. const data = await res.json();
  291. if (data.code !== '0') throw new Error(`API error ${data.code}`);
  292. const list = data?.datas?.getMyScheduleDetail?.arrangedList || [];
  293. return convertApiResponseToLessons(list);
  294. } catch(e) {
  295. if (i===retries) throw e;
  296. await new Promise(r=>setTimeout(r,2000));
  297. }
  298. }
  299. }
  300. /**
  301. * 调用Android Bridge保存课程列表(覆盖写入)
  302. * @param {Array<object>} lessons 课程对象数组
  303. */
  304. async function SaveCourses(lessons) {
  305. await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(lessons));
  306. }
  307. /**
  308. * 根据校区导入预设的上下课时间表(节次时间)
  309. * @param {string} campus "南湖校区" 或 "浑南校区"
  310. */
  311. async function importTimeSlotsByCampus(campus) {
  312. 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"}];
  313. 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"}];
  314. const slots = campus === "南湖校区" ? nanHu : hunNan;
  315. await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(slots));
  316. }
  317. /**
  318. * 保存课表全局配置(学期总周数、默认课时长度、课间休息、每周起始日)
  319. */
  320. async function SaveConfig() {
  321. const cfg = { semesterTotalWeeks:18, defaultClassDuration:45, defaultBreakDuration:10, firstDayOfWeek:7 };
  322. await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify(cfg));
  323. }
  324. /**
  325. * 主流程:依次选择校区、学期,获取课表,保存,可选导入考试并合并保存
  326. * 最后通知Android任务完成
  327. */
  328. async function runAllDemosSequentially() {
  329. AndroidBridge.showToast("开始导入课表...");
  330. const campus = await showCampusSelection();
  331. if (!campus) { AndroidBridge.showToast("已取消导入"); return; }
  332. const semester = await showSemesterSelection();
  333. if (!semester) { AndroidBridge.showToast("已取消导入"); return; }
  334. AndroidBridge.showToast("正在获取课表数据...");
  335. let lessons;
  336. try {
  337. lessons = await fetchCoursesFromAPI(semester);
  338. if (!lessons.length) { AndroidBridge.showToast("未获取到任何课程"); return; }
  339. console.log(`获取到 ${lessons.length} 门课程`);
  340. } catch(e) {
  341. AndroidBridge.showToast("获取课表失败: "+e.message);
  342. return;
  343. }
  344. await SaveCourses(lessons);
  345. await importTimeSlotsByCampus(campus);
  346. await SaveConfig();
  347. AndroidBridge.showToast("课表导入完成!");
  348. const importExams = await askImportExams();
  349. if (importExams) {
  350. AndroidBridge.showToast("正在获取考试数据...");
  351. try {
  352. const examLessons = await fetchExamsFromAPI(semester);
  353. if (examLessons.length === 0) {
  354. AndroidBridge.showToast("未获取到考试数据");
  355. } else {
  356. const allLessons = [...lessons, ...examLessons];
  357. await SaveCourses(allLessons);
  358. AndroidBridge.showToast(`已导入 ${examLessons.length} 条考试记录(合并至课表)`);
  359. }
  360. } catch(e) {
  361. AndroidBridge.showToast("导入考试失败: "+e.message);
  362. console.error(e);
  363. }
  364. }
  365. AndroidBridge.notifyTaskCompletion();
  366. }
  367. // 启动主流程
  368. runAllDemosSequentially();