tsu_01.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. // 依赖接口:
  2. // POST /frame/droplist/getDropLists.action 学期列表(JSON)
  3. // GET /student/xkjg.wdkb.jsp?menucode=S20301 内部学号 xh(GBK HTML)
  4. // GET /student/wsxk.xskcb10319.jsp?params=base64 课表数据(GBK HTML)
  5. // POST /frame/desk/showLessonScheduleInfosV14.action 第一周周一日期
  6. // ===== 作息时间 =====
  7. // 夏令时(每年 5月1日–10月7日):作为基础时间段(骨架)提交
  8. const SUMMER_TIME_SLOTS = [
  9. { number: 1, startTime: "08:00", endTime: "08:45" },
  10. { number: 2, startTime: "08:55", endTime: "09:40" },
  11. { number: 3, startTime: "10:00", endTime: "10:45" },
  12. { number: 4, startTime: "10:55", endTime: "11:40" },
  13. { number: 5, startTime: "14:30", endTime: "15:15" },
  14. { number: 6, startTime: "15:25", endTime: "16:10" },
  15. { number: 7, startTime: "16:30", endTime: "17:15" },
  16. { number: 8, startTime: "17:25", endTime: "18:10" },
  17. { number: 9, startTime: "19:00", endTime: "19:45" },
  18. { number: 10, startTime: "19:55", endTime: "20:40" }
  19. ];
  20. // 冬令时(每年 10月8日–次年4月30日)差量:仅下午 5–8 节整体提前 30 分钟,
  21. // 上午 1–4 节与晚间 9–10 节由骨架自动补齐
  22. const WINTER_DIFF_SLOTS = [
  23. { number: 5, startTime: "14:00", endTime: "14:45" },
  24. { number: 6, startTime: "14:55", endTime: "15:40" },
  25. { number: 7, startTime: "16:00", endTime: "16:45" },
  26. { number: 8, startTime: "16:55", endTime: "17:40" }
  27. ];
  28. // ===== 日期工具 =====
  29. function formatDate(date) {
  30. const year = date.getFullYear();
  31. const month = String(date.getMonth() + 1).padStart(2, "0");
  32. const day = String(date.getDate()).padStart(2, "0");
  33. return `${year}-${month}-${day}`;
  34. }
  35. // 夏令时:本年 5月1日–10月7日;冬令时:10月8日–次年4月30日(1–4月导入时自上一年起算)
  36. function buildComboDateRanges() {
  37. const now = new Date();
  38. const year = now.getFullYear();
  39. const month = now.getMonth() + 1;
  40. const winterStartYear = month <= 4 ? year - 1 : year;
  41. return {
  42. summer: { startDate: `${year}-05-01`, endDate: `${year}-10-07` },
  43. winter: { startDate: `${winterStartYear}-10-08`, endDate: `${winterStartYear + 1}-04-30` }
  44. };
  45. }
  46. // 开学日期弹窗默认值的兜底:本周周一(周日归上一周)
  47. function getMondayOfCurrentWeek() {
  48. const now = new Date();
  49. const offset = now.getDay() === 0 ? -6 : 1 - now.getDay();
  50. const monday = new Date(now);
  51. monday.setDate(now.getDate() + offset);
  52. return formatDate(monday);
  53. }
  54. // showPrompt 的全局验证函数:false=通过,字符串=错误文案
  55. function validateDateInput(input) {
  56. if (!/^\d{4}-\d{2}-\d{2}$/.test(input)) {
  57. return "请输入 YYYY-MM-DD 格式的日期!";
  58. }
  59. const date = new Date(input + "T00:00:00");
  60. if (isNaN(date.getTime()) || formatDate(date) !== input) {
  61. return "该日期不存在,请检查!";
  62. }
  63. return false;
  64. }
  65. // ===== 周次解析(支持 12-16 / 8 / 1-8单 / 9-17双 / 1,3,5)=====
  66. function parseWeeks(weekStr) {
  67. const weeks = [];
  68. weekStr.split(",").forEach(group => {
  69. const g = group.trim();
  70. const isSingle = g.includes("单");
  71. const isDouble = g.includes("双");
  72. const rangeMatch = g.match(/(\d+)\s*-\s*(\d+)/);
  73. if (rangeMatch) {
  74. for (let i = parseInt(rangeMatch[1]); i <= parseInt(rangeMatch[2]); i++) {
  75. if (isSingle && i % 2 === 0) continue;
  76. if (isDouble && i % 2 !== 0) continue;
  77. weeks.push(i);
  78. }
  79. } else {
  80. const num = parseInt(g.replace(/[^\d]/g, ""));
  81. if (!isNaN(num)) weeks.push(num);
  82. }
  83. });
  84. return Array.from(new Set(weeks)).sort((a, b) => a - b);
  85. }
  86. // ===== 课表 HTML 解析 =====
  87. // 输入:xskcb10319.jsp 返回的 GBK 解码后 HTML
  88. // 结构:每个星期列是 td.td;每门课一个 <div style='padding-bottom:5px...'>,
  89. // div 内按 <br> 分行依次为 课程名/教师/周次[起止节次]/地点
  90. function parseCourseTableHtml(htmlText) {
  91. const doc = new DOMParser().parseFromString(htmlText, "text/html");
  92. const rawItems = [];
  93. doc.querySelectorAll("tr").forEach(row => {
  94. const cells = row.querySelectorAll("td.td");
  95. cells.forEach((cell, dayIndex) => {
  96. const day = dayIndex + 1; // 一行内 td.td 依次对应 星期一~星期日
  97. const divs = cell.querySelectorAll("div[style*='padding-bottom:5px']");
  98. divs.forEach(div => {
  99. const lines = Array.from(div.childNodes)
  100. .map(n => n.textContent.trim())
  101. .filter(t => t.length > 0);
  102. if (lines.length < 3) return;
  103. const timeMatch = lines[2].match(/^(.+?)\s*\[(\d+)-(\d+)\]\s*$/);
  104. if (!timeMatch) return;
  105. rawItems.push({
  106. name: lines[0],
  107. teacher: lines[1] || "",
  108. position: lines[3] || "未知地点",
  109. day: day,
  110. startSection: parseInt(timeMatch[2]),
  111. endSection: parseInt(timeMatch[3]),
  112. weeks: parseWeeks(timeMatch[1])
  113. });
  114. });
  115. });
  116. });
  117. // 完全重复项去重(青果网格可能对跨节次课程重复渲染)
  118. const seen = new Set();
  119. const uniqueItems = rawItems.filter(item => {
  120. const key = `${item.name}|${item.teacher}|${item.position}|${item.day}|${item.startSection}|${item.endSection}|${item.weeks.join(",")}`;
  121. if (seen.has(key)) return false;
  122. seen.add(key);
  123. return true;
  124. });
  125. // 合并去重:同(名|师|地|星期)按周次展开为节次矩阵,再按连续节次聚合
  126. const groupMap = new Map();
  127. uniqueItems.forEach(item => {
  128. const key = `${item.name}|${item.teacher}|${item.position}|${item.day}`;
  129. if (!groupMap.has(key)) groupMap.set(key, {});
  130. const weekMap = groupMap.get(key);
  131. item.weeks.forEach(w => {
  132. if (!weekMap[w]) weekMap[w] = new Set();
  133. for (let s = item.startSection; s <= item.endSection; s++) weekMap[w].add(s);
  134. });
  135. });
  136. const finalCourses = [];
  137. groupMap.forEach((weekMap, key) => {
  138. const [name, teacher, position, day] = key.split("|");
  139. const patternMap = new Map();
  140. Object.keys(weekMap).forEach(w => {
  141. const week = parseInt(w);
  142. const sections = Array.from(weekMap[week]).sort((a, b) => a - b);
  143. if (sections.length === 0) return;
  144. let start = sections[0];
  145. for (let i = 0; i < sections.length; i++) {
  146. if (i === sections.length - 1 || sections[i + 1] !== sections[i] + 1) {
  147. const pKey = `${start}-${sections[i]}`;
  148. if (!patternMap.has(pKey)) patternMap.set(pKey, new Set());
  149. patternMap.get(pKey).add(week);
  150. if (i < sections.length - 1) start = sections[i + 1];
  151. }
  152. }
  153. });
  154. patternMap.forEach((weekSet, pKey) => {
  155. const [sStart, sEnd] = pKey.split("-").map(Number);
  156. finalCourses.push({
  157. name, teacher, position,
  158. day: parseInt(day),
  159. startSection: sStart,
  160. endSection: sEnd,
  161. weeks: Array.from(weekSet).sort((a, b) => a - b)
  162. });
  163. });
  164. });
  165. return finalCourses;
  166. }
  167. // ===== 接口封装 =====
  168. const JWXT_BASE = window.location.origin;
  169. // 学期列表 → 用户选择 → { xn, xq, name }
  170. async function selectSemester() {
  171. window.shiguangBridge.showToast("正在获取学期列表...");
  172. const resp = await fetch(JWXT_BASE + "/frame/droplist/getDropLists.action", {
  173. method: "POST",
  174. headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
  175. body: "comboBoxName=StMsXnxqDxDesc&paramValue=&isYXB=0&isCDDW=0&isXQ=0&isDJKSLB=0&isZY=0",
  176. credentials: "include"
  177. });
  178. const list = await resp.json();
  179. if (!Array.isArray(list) || list.length === 0) throw new Error("学期列表为空");
  180. const names = list.map(item => item.name);
  181. const selectedIndex = await window.shiguangBridgePromise.showSingleSelection("选择导入学期", JSON.stringify(names), 0);
  182. if (selectedIndex === null) return null;
  183. const [xn, xq] = list[selectedIndex].code.split("-");
  184. return { xn, xq, name: list[selectedIndex].name };
  185. }
  186. // 内部学号(≠登录账号):wdkb.jsp 的隐藏域,实时 DOM 兜底
  187. async function getInternalStudentId() {
  188. const resp = await fetch(JWXT_BASE + "/student/xkjg.wdkb.jsp?menucode=S20301", { credentials: "include" });
  189. const html = new TextDecoder("gbk").decode(await resp.arrayBuffer());
  190. const tagMatch = html.match(/<input[^>]*id=["']xh["'][^>]*>/i);
  191. if (tagMatch) {
  192. const valueMatch = tagMatch[0].match(/value=["']([^"']+)["']/i);
  193. if (valueMatch) return valueMatch[1];
  194. }
  195. let fromDom = null;
  196. (function walk(win) {
  197. if (fromDom) return;
  198. try {
  199. const el = win.document.getElementById("xh");
  200. if (el && el.value) { fromDom = el.value; return; }
  201. } catch (e) {}
  202. for (let i = 0; i < win.frames.length; i++) { try { walk(win.frames[i]); } catch (e) {} }
  203. })(window);
  204. return fromDom;
  205. }
  206. // 课表 HTML(GBK)
  207. async function fetchCourseHtml(xn, xq, xh) {
  208. const params = btoa(`xn=${xn}&xq=${xq}&xh=${xh}`);
  209. const resp = await fetch(`${JWXT_BASE}/student/wsxk.xskcb10319.jsp?params=${params}`, { credentials: "include" });
  210. return new TextDecoder("gbk").decode(await resp.arrayBuffer());
  211. }
  212. // 第一周周一日期(教学日历接口,jxz=1 即第 1 周,周一列头日期即开学日期)
  213. // 注意:泰山该接口返回 UTF-8(课表接口才是 GBK);失败返回 null,由调用方兜底
  214. async function fetchSemesterStartDate(xn, xq) {
  215. const resp = await fetch(`${JWXT_BASE}/frame/desk/showLessonScheduleInfosV14.action?xn=${xn}&xq=${xq}&jxz=1`, {
  216. method: "POST",
  217. headers: { "x-requested-with": "XMLHttpRequest" },
  218. credentials: "include"
  219. });
  220. const html = await resp.text();
  221. const match = html.match(/<br\s*\/?>\s*(\d{2})-(\d{2})/);
  222. if (!match) return null;
  223. const year = xq === "1" ? String(parseInt(xn) + 1) : String(xn);
  224. return `${year}-${match[1]}-${match[2]}`;
  225. }
  226. // ===== 导入步骤 =====
  227. async function saveCourseConfig(startDate) {
  228. try {
  229. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify({
  230. semesterStartDate: startDate
  231. }));
  232. window.shiguangBridge.showToast("开学日期已保存");
  233. return true;
  234. } catch (error) {
  235. window.shiguangBridge.showToast("保存开学日期失败: " + error.message);
  236. return false;
  237. }
  238. }
  239. async function importTimeSlots() {
  240. try {
  241. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(SUMMER_TIME_SLOTS));
  242. return true;
  243. } catch (error) {
  244. window.shiguangBridge.showToast("导入时间段失败: " + error.message);
  245. return false;
  246. }
  247. }
  248. async function importComboSchedule() {
  249. const ranges = buildComboDateRanges();
  250. const comboSchedule = {
  251. name: "泰山学院夏冬作息方案",
  252. publicSchedules: [
  253. {
  254. name: "夏令时",
  255. startDate: ranges.summer.startDate,
  256. endDate: ranges.summer.endDate,
  257. timeSlots: [] // 与骨架一致,全部由骨架补齐
  258. },
  259. {
  260. name: "冬令时",
  261. startDate: ranges.winter.startDate,
  262. endDate: ranges.winter.endDate,
  263. timeSlots: WINTER_DIFF_SLOTS // 仅提交下午 5–8 节差异
  264. }
  265. ]
  266. };
  267. try {
  268. await window.shiguangBridgePromise.saveComboSchedule(JSON.stringify(comboSchedule));
  269. return true;
  270. } catch (error) {
  271. window.shiguangBridge.showToast("组合作息导入失败: " + error.message);
  272. return false;
  273. }
  274. }
  275. // ===== 流程编排 =====
  276. // notifyTaskCompletion() 只在成功后调用;任何取消/失败立即终止
  277. async function runImportFlow() {
  278. const confirmed = await window.shiguangBridgePromise.showAlert(
  279. "青果教务导入",
  280. "将从教务接口直接获取课表数据,请确保已在当前浏览器登录教务系统。",
  281. "开始导入"
  282. );
  283. if (!confirmed) {
  284. window.shiguangBridge.showToast("导入已取消。");
  285. return;
  286. }
  287. // 1. 学期列表 + 用户选择(列表拉取失败大概率是未登录)
  288. let semester;
  289. try {
  290. semester = await selectSemester();
  291. } catch (error) {
  292. window.shiguangBridge.showToast("获取学期列表失败,请确认已登录教务系统");
  293. return;
  294. }
  295. if (semester === null) {
  296. window.shiguangBridge.showToast("导入已取消。");
  297. return;
  298. }
  299. // 2. 内部学号
  300. let studentId;
  301. try {
  302. studentId = await getInternalStudentId();
  303. } catch (error) {
  304. studentId = null;
  305. }
  306. if (!studentId) {
  307. window.shiguangBridge.showToast("获取学号失败,请稍后重试或向维护者反馈");
  308. return;
  309. }
  310. // 3. 拉取并解析课表
  311. window.shiguangBridge.showToast(`正在抓取「${semester.name}」课表数据...`);
  312. let courses;
  313. try {
  314. const html = await fetchCourseHtml(semester.xn, semester.xq, studentId);
  315. courses = parseCourseTableHtml(html);
  316. } catch (error) {
  317. window.shiguangBridge.showToast("导入失败:" + error.message);
  318. return;
  319. }
  320. if (!courses || courses.length === 0) {
  321. window.shiguangBridge.showToast("未解析到课程数据,请确认所选学期有课程安排");
  322. return;
  323. }
  324. // 4. 开学日期:接口自动获取 → 用户确认(取消则终止)
  325. let apiStartDate = null;
  326. try {
  327. apiStartDate = await fetchSemesterStartDate(semester.xn, semester.xq);
  328. } catch (error) {
  329. apiStartDate = null;
  330. }
  331. const startDate = await window.shiguangBridgePromise.showPrompt(
  332. "确认开学日期",
  333. "请确认本学期第一周周一的日期(用于校准周数)",
  334. apiStartDate || getMondayOfCurrentWeek(),
  335. "validateDateInput"
  336. );
  337. if (startDate === null) {
  338. window.shiguangBridge.showToast("导入已取消。");
  339. return;
  340. }
  341. // 5. 保存开学日期
  342. const configSaved = await saveCourseConfig(startDate);
  343. if (!configSaved) return;
  344. // 6. 保存课程
  345. try {
  346. const saveResult = await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  347. if (saveResult !== true) {
  348. window.shiguangBridge.showToast("导入失败:课程数据未能保存");
  349. return;
  350. }
  351. } catch (error) {
  352. window.shiguangBridge.showToast("导入失败:" + error.message);
  353. return;
  354. }
  355. // 7. 时间段(骨架)→ 组合作息
  356. const timeSlotSaved = await importTimeSlots();
  357. if (timeSlotSaved) {
  358. await importComboSchedule();
  359. } else {
  360. window.shiguangBridge.showToast("时间段导入失败,跳过组合作息。");
  361. }
  362. // 8. 全部成功
  363. window.shiguangBridge.showToast(`导入成功,共 ${courses.length} 门课程,夏/冬令时将按日期自动切换`);
  364. window.shiguangBridge.notifyTaskCompletion();
  365. }
  366. runImportFlow();