wbu_02.js 47 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161
  1. // 适配目标:武汉商学院-超星综合教学管理系统
  2. //
  3. // 逻辑概览:
  4. // 0) 询问导出学期:可选学期来自页面下拉框 #xnxq1(服务端渲染);学期很多时分页展示
  5. // (每页 4 个 + “更多…”翻页 + “上一页”返回);选非当前学期时用页面已有的
  6. // xhid/xqdm 直接请求该学期 sdpkkbList JSON 并解析,无需跳转
  7. // 1) 统一走接口:sdpkkbList 拉课程 JSON,getZclistByXnxq 取时间段/开学日期;
  8. // 不在课表页(首页/选课页等)时先抓课表页 HTML 拿 xhid/xqdm/学期列表,任意页面都能导
  9. // 2) 课程 JSON 复刻页面逻辑:同格同课程合并周次 -> 相邻节次同内容合并(rowspan)
  10. // 3) 接口失败且当前在课表页时,回退解析页面已渲染的 DOM(td.cell + <a onclick> 链接)
  11. // 4) 教师姓名带工号(如“王老师(20240999)”)时,弹窗询问是否去除,默认保留
  12. // 5) 按 课程/教师/教室/星期/周次 合并连续节次,经 AndroidBridgePromise 保存课程与时间段
  13. // 6) 从 /admin/api/getZclistByXnxq 取周次列表:导出开学日期(第 1 周开始日)与总周数到 config
  14. // 7) 任意弹窗步骤用户点“取消”:立即终止,不解析、不保存、不触发下载
  15. // 8) 检测重复课程(同课程/同教室/同节次/同周次):完全相同→弹“只保留一门”,
  16. // 多教师→弹“合并教师”(选项与标题随类型变化),取消即终止
  17. (function () {
  18. function toast(message) {
  19. if (window.AndroidBridge && typeof window.AndroidBridge.showToast === "function") {
  20. window.AndroidBridge.showToast(message);
  21. }
  22. }
  23. function sleep(ms) {
  24. return new Promise((resolve) => setTimeout(resolve, ms));
  25. }
  26. // 用户点击“取消”时的统一返回值:任意流程取消即终止整个导入
  27. const CANCELED = "CANCELED";
  28. // 桥接端是否支持单选弹窗
  29. function canShowSingleSelection() {
  30. return !!(
  31. window.AndroidBridgePromise &&
  32. typeof window.AndroidBridgePromise.showSingleSelection === "function"
  33. );
  34. }
  35. async function getTargetDocument() {
  36. if (location.href.includes("queryKbForXsd")) {
  37. return document;
  38. }
  39. const iframe = document.querySelector("iframe[src*='queryKbForXsd']");
  40. if (!iframe) return null;
  41. for (let i = 0; i < 20; i += 1) {
  42. try {
  43. const doc = iframe.contentDocument || (iframe.contentWindow && iframe.contentWindow.document);
  44. if (doc && doc.readyState && doc.readyState !== "loading") {
  45. return doc;
  46. }
  47. } catch (e) {
  48. // ignore
  49. }
  50. await sleep(500);
  51. }
  52. return null;
  53. }
  54. async function waitForScheduleData(doc, timeoutMs = 15000) {
  55. const start = Date.now();
  56. while (Date.now() - start < timeoutMs) {
  57. const cells = Array.from(doc.querySelectorAll("td.cell, td[id^='Cell']"));
  58. const filled = cells.filter((cell) => {
  59. const text = (cell.innerText || cell.textContent || "").trim();
  60. return text.length > 0 && /周/.test(text);
  61. });
  62. if (filled.length > 0) {
  63. return true;
  64. }
  65. await sleep(500);
  66. }
  67. return false;
  68. }
  69. function uniqueSortedNumbers(nums) {
  70. const set = new Set(nums.filter((n) => Number.isFinite(n)));
  71. return Array.from(set).sort((a, b) => a - b);
  72. }
  73. function parseWeekText(weekText) {
  74. if (!weekText) return [];
  75. let text = String(weekText).trim();
  76. if (!text) return [];
  77. let oddOnly = false;
  78. let evenOnly = false;
  79. if (text.includes("单")) oddOnly = true;
  80. if (text.includes("双")) evenOnly = true;
  81. text = text.replace(/周/g, "");
  82. text = text.replace(/\s+/g, "");
  83. text = text.replace(/\(.*?\)/g, "");
  84. text = text.replace(/(.*?)/g, "");
  85. // 兼容接口 zc 的写法:1-19(单)/2-18(双)/裸“单”“双”/“改”后缀
  86. text = text.replace(/[((]?单[))]?/g, "");
  87. text = text.replace(/[((]?双[))]?/g, "");
  88. text = text.replace(/改$/g, "");
  89. const weeks = [];
  90. const segments = text.split(",").map((s) => s.trim()).filter(Boolean);
  91. segments.forEach((seg) => {
  92. if (!seg) return;
  93. const rangeMatch = seg.match(/^(\d+)-(\d+)$/);
  94. if (rangeMatch) {
  95. const start = parseInt(rangeMatch[1], 10);
  96. const end = parseInt(rangeMatch[2], 10);
  97. if (!Number.isFinite(start) || !Number.isFinite(end)) return;
  98. for (let w = start; w <= end; w += 1) {
  99. weeks.push(w);
  100. }
  101. return;
  102. }
  103. const single = parseInt(seg, 10);
  104. if (Number.isFinite(single)) weeks.push(single);
  105. });
  106. let filtered = weeks;
  107. if (oddOnly && !evenOnly) {
  108. filtered = weeks.filter((w) => w % 2 === 1);
  109. } else if (evenOnly && !oddOnly) {
  110. filtered = weeks.filter((w) => w % 2 === 0);
  111. }
  112. return uniqueSortedNumbers(filtered);
  113. }
  114. // 接口行周次:优先 zcstr(逗号列表),回退 zc(parseWeekText 已兼容范围/单双/改)
  115. function parseWeeksFromRow(row) {
  116. const zcstr = String(row.zcstr || "").trim();
  117. if (zcstr) {
  118. const weeks = parseWeekText(zcstr + "周");
  119. if (weeks.length) return weeks;
  120. }
  121. return parseWeekText(String(row.zc || "") + "周");
  122. }
  123. function stripHtmlText(text) {
  124. return String(text || "").replace(/<[^>]+>/g, "").trim();
  125. }
  126. function splitCourseBlocks(cellText) {
  127. const text = cellText.replace(/\r/g, "").trim();
  128. if (!text) return [];
  129. return text
  130. .split(/\n{2,}/)
  131. .map((block) => block.trim())
  132. .filter(Boolean);
  133. }
  134. function extractWeeksTextFromLine(line) {
  135. if (!line) return { weeksText: "", rest: line || "" };
  136. const match = line.match(/(\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*)\s*(?:\((单|双)\))?\s*周/);
  137. if (!match) return { weeksText: "", rest: line };
  138. const weeksCore = match[1];
  139. const oddEven = match[2] ? `(${match[2]})` : "";
  140. const weeksText = `${weeksCore}${oddEven}周`;
  141. const rest = line.replace(match[0], "").trim();
  142. return { weeksText, rest };
  143. }
  144. // 从单元格里的 <a> 链接按语义提取文本(武汉商学院课表把课程名/教师/教室渲染成链接)
  145. function extractAnchorText(cell, onclickKeyword, blockText) {
  146. if (!cell) return "";
  147. const anchors = Array.from(
  148. cell.querySelectorAll('a[onclick*="' + onclickKeyword + '"]')
  149. ).sort((a, b) => (b.textContent || "").length - (a.textContent || "").length);
  150. for (const anchor of anchors) {
  151. const text = (anchor.textContent || "").trim();
  152. if (text && blockText.includes(text)) {
  153. return text;
  154. }
  155. }
  156. return "";
  157. }
  158. function parseCourseBlock(cell, block) {
  159. const lines = block
  160. .split(/\n+/)
  161. .map((l) => l.trim())
  162. .filter(Boolean);
  163. if (!lines.length) return null;
  164. // 优先用单元格内的链接解析,避免把“校区名”当成课程名、把“课程名”当成教师。
  165. // 武商院课表单元格文本顺序为:校区 -> 课程名 -> 教师 周次 -> 教室。
  166. const anchorName = extractAnchorText(cell, "openKckb", block);
  167. const anchorTeacher = extractAnchorText(cell, "openJskb", block);
  168. const anchorPosition = extractAnchorText(cell, "openCrkb", block);
  169. const name = anchorName || lines[0] || "";
  170. let teacher = "";
  171. let weeksText = "";
  172. let position = "";
  173. const weekLineIndex = lines.findIndex((l) => /周/.test(l));
  174. if (weekLineIndex >= 0) {
  175. const { weeksText: extractedWeeks, rest } = extractWeeksTextFromLine(
  176. lines[weekLineIndex]
  177. );
  178. weeksText = extractedWeeks;
  179. if (weekLineIndex === 1) {
  180. teacher = rest || lines[1];
  181. }
  182. }
  183. if (!teacher && lines.length > 1) {
  184. teacher = lines[1];
  185. const { weeksText: extractedWeeks, rest } = extractWeeksTextFromLine(teacher);
  186. if (extractedWeeks) {
  187. weeksText = weeksText || extractedWeeks;
  188. teacher = rest;
  189. }
  190. }
  191. if (!weeksText) {
  192. for (const line of lines) {
  193. const { weeksText: extractedWeeks } = extractWeeksTextFromLine(line);
  194. if (extractedWeeks) {
  195. weeksText = extractedWeeks;
  196. break;
  197. }
  198. }
  199. }
  200. if (!position) {
  201. if (weekLineIndex >= 0 && weekLineIndex + 1 < lines.length) {
  202. position = lines[weekLineIndex + 1];
  203. }
  204. if (!position) {
  205. position =
  206. lines.find((l) => l !== name && l !== teacher && !/周/.test(l)) || "";
  207. }
  208. }
  209. return {
  210. name: anchorName || name || "未知课程",
  211. teacher: anchorTeacher || teacher || "",
  212. weeksText,
  213. position: anchorPosition || position || "",
  214. };
  215. }
  216. function padTime(value) {
  217. const text = String(value || "").trim();
  218. const match = text.match(/^(\d{1,2}):(\d{1,2})$/);
  219. if (!match) return text;
  220. const h = match[1].padStart(2, "0");
  221. const m = match[2].padStart(2, "0");
  222. return `${h}:${m}`;
  223. }
  224. function randomColor() {
  225. return Math.floor(Math.random() * 12) + 1;
  226. }
  227. function createId() {
  228. if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
  229. return crypto.randomUUID();
  230. }
  231. return `id-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
  232. }
  233. function mergeCourses(courses) {
  234. const byKey = new Map();
  235. courses.forEach((course) => {
  236. const weeksKey = (course.weeks || []).join(",");
  237. const key = [course.name, course.teacher, course.position, course.day, weeksKey].join("|");
  238. if (!byKey.has(key)) byKey.set(key, []);
  239. byKey.get(key).push({ ...course });
  240. });
  241. const merged = [];
  242. byKey.forEach((items) => {
  243. items.sort((a, b) => a.startSection - b.startSection);
  244. let current = null;
  245. items.forEach((item) => {
  246. if (!current) {
  247. current = { ...item };
  248. return;
  249. }
  250. if (item.startSection === current.endSection + 1) {
  251. current.endSection = Math.max(current.endSection, item.endSection);
  252. } else {
  253. merged.push(current);
  254. current = { ...item };
  255. }
  256. });
  257. if (current) merged.push(current);
  258. });
  259. return merged;
  260. }
  261. function parseScheduleFromDocument(doc) {
  262. const cells = Array.from(doc.querySelectorAll("td.cell"));
  263. const fallbackCells = cells.length ? [] : Array.from(doc.querySelectorAll("td[id^='Cell']"));
  264. const targetCells = cells.length ? cells : fallbackCells;
  265. const courses = [];
  266. const seen = new Set();
  267. targetCells.forEach((cell) => {
  268. const id = cell.getAttribute("id") || "";
  269. const match = id.match(/^Cell(\d)(\d{1,2})$/);
  270. if (!match) return;
  271. const day = parseInt(match[1], 10);
  272. const startSection = parseInt(match[2], 10);
  273. const rowspan = parseInt(cell.getAttribute("rowspan") || "1", 10);
  274. const endSection = startSection + Math.max(rowspan, 1) - 1;
  275. const blocks = splitCourseBlocks(cell.innerText || "");
  276. blocks.forEach((blockText) => {
  277. const parsed = parseCourseBlock(cell, blockText);
  278. if (!parsed) return;
  279. const weeks = parseWeekText(parsed.weeksText);
  280. if (!weeks.length) return;
  281. const key = [
  282. parsed.name,
  283. parsed.teacher,
  284. parsed.position,
  285. day,
  286. startSection,
  287. endSection,
  288. weeks.join(","),
  289. ].join("|");
  290. if (seen.has(key)) return;
  291. seen.add(key);
  292. courses.push({
  293. id: createId(),
  294. name: parsed.name,
  295. teacher: parsed.teacher,
  296. position: parsed.position,
  297. day,
  298. startSection,
  299. endSection,
  300. color: randomColor(),
  301. weeks,
  302. });
  303. });
  304. });
  305. return mergeCourses(courses);
  306. }
  307. function parseTimeSlots(doc) {
  308. const slots = [];
  309. const seenNumbers = new Set();
  310. const timeRegex = /(\d{1,2}:\d{2})/g;
  311. const timeCells = Array.from(
  312. doc.querySelectorAll("td[data-jcindex], td[data-jcIndex]")
  313. );
  314. timeCells.forEach((cell) => {
  315. const text = (cell.innerText || cell.textContent || "").trim();
  316. if (!text) return;
  317. const indexAttr = cell.getAttribute("data-jcindex") || cell.getAttribute("data-jcIndex");
  318. const numberMatch = text.match(/^(\d{1,2})/);
  319. const number = parseInt(indexAttr || (numberMatch && numberMatch[1]) || "", 10);
  320. if (!Number.isFinite(number)) return;
  321. const times = text.match(timeRegex) || [];
  322. if (times.length < 2) return;
  323. if (seenNumbers.has(number)) return;
  324. seenNumbers.add(number);
  325. slots.push({
  326. number,
  327. startTime: padTime(times[0]),
  328. endTime: padTime(times[1]),
  329. });
  330. });
  331. return slots.sort((a, b) => a.number - b.number);
  332. }
  333. // ===== 教师工号处理 =====
  334. // 匹配“姓名(工号)”/“姓名(工号)”结尾的教师名
  335. const TEACHER_ID_RE = /^(.+?)\s*[((](\d{4,})[))]$/;
  336. function hasTeacherId(teacher) {
  337. return TEACHER_ID_RE.test(teacher || "");
  338. }
  339. function stripTeacherId(teacher) {
  340. const match = TEACHER_ID_RE.exec(teacher || "");
  341. return match ? match[1].trim() : teacher;
  342. }
  343. // 若检测到教师姓名带工号,弹窗询问是否保留(参考 school.bak.js 的单选弹窗用法,
  344. // 选项里同时展示“保留”和“去除”后的样子,方便核对脚本解析是否正确)
  345. async function askKeepTeacherId(courses) {
  346. const withId = courses.filter((c) => hasTeacherId(c.teacher));
  347. if (!withId.length) return "keep";
  348. if (!canShowSingleSelection()) {
  349. console.warn("AndroidBridgePromise.showSingleSelection 不可用,教师工号默认保留");
  350. return "keep";
  351. }
  352. const example = withId[0].teacher;
  353. const stripped = stripTeacherId(example);
  354. const options = [
  355. `保留工号(如:${example})`,
  356. `去除工号(如:${stripped})`,
  357. ];
  358. console.log(`检测到 ${withId.length} 门课的教师姓名带工号,即将弹出询问...`);
  359. try {
  360. const selectedIndex = await window.AndroidBridgePromise.showSingleSelection(
  361. `教师姓名带工号(共 ${withId.length} 门课)`,
  362. JSON.stringify(options),
  363. 0
  364. );
  365. if (selectedIndex === null) {
  366. console.log("用户取消工号询问,终止导入");
  367. return CANCELED;
  368. }
  369. if (selectedIndex === 1) {
  370. console.log("用户选择去除工号,例如:" + example + " -> " + stripped);
  371. return "strip";
  372. }
  373. console.log("用户选择保留工号,保持原样");
  374. return "keep";
  375. } catch (error) {
  376. console.error("教师工号询问失败,默认保留工号:", error);
  377. return "keep";
  378. }
  379. }
  380. // ===== 重复/冲突课程处理 =====
  381. // 系统有时会把同一门课拆成多条(同名/同教室/同周次/同节次):
  382. // - 完全相同:教师也相同 -> 只需“只保留一门”
  383. // - 多教师:教师不同(如两位老师同上一门课)-> 用“合并教师”并列成一条
  384. function findDuplicateCourseGroups(courses) {
  385. const groups = new Map();
  386. courses.forEach((c) => {
  387. const key = [
  388. c.name,
  389. c.position,
  390. c.day,
  391. c.startSection,
  392. c.endSection,
  393. (c.weeks || []).join(","),
  394. ].join("|");
  395. if (!groups.has(key)) groups.set(key, []);
  396. groups.get(key).push(c);
  397. });
  398. return Array.from(groups.values())
  399. .filter((g) => g.length > 1)
  400. .map((g) => ({
  401. courses: g,
  402. type: new Set(g.map((c) => c.teacher).filter(Boolean)).size > 1
  403. ? "multiTeacher"
  404. : "identical",
  405. }));
  406. }
  407. // 弹窗询问重复课程处理。选项随重复类型变化:
  408. // 完全相同 -> 显示“只保留一门”;多教师 -> 显示“合并教师”;两类都有则都显示。
  409. // 取消返回 CANCELED 终止导入。
  410. async function askHandleDuplicateCourses(courses) {
  411. const groups = findDuplicateCourseGroups(courses);
  412. if (!groups.length) return "keep";
  413. if (!canShowSingleSelection()) {
  414. console.warn("AndroidBridgePromise.showSingleSelection 不可用,重复课程默认全部保留");
  415. return "keep";
  416. }
  417. const total = groups.reduce((n, g) => n + g.courses.length, 0);
  418. const hasIdentical = groups.some((g) => g.type === "identical");
  419. const hasMulti = groups.some((g) => g.type === "multiTeacher");
  420. const exampleGroup = groups.find((g) => g.type === "multiTeacher") || groups[0];
  421. const example = exampleGroup.courses[0];
  422. const teacherText = Array.from(
  423. new Set(exampleGroup.courses.map((c) => c.teacher).filter(Boolean))
  424. ).join("、");
  425. const options = [`全部保留(${total} 门)`];
  426. let dedupeIndex = -1;
  427. let mergeIndex = -1;
  428. if (hasIdentical) {
  429. dedupeIndex = options.length;
  430. options.push(`只保留一门(去重:如 ${example.name} 只留 1 门)`);
  431. }
  432. if (hasMulti) {
  433. mergeIndex = options.length;
  434. options.push(`合并教师(如:${example.name} -> ${teacherText})`);
  435. }
  436. let title;
  437. if (hasIdentical && hasMulti) {
  438. title = `重复课程处理(${groups.length} 组 / ${total} 门,含相同课程与多教师)`;
  439. } else if (hasMulti) {
  440. title = `多教师重复课程处理(${groups.length} 组 / ${total} 门)`;
  441. } else {
  442. title = `完全相同的重复课程处理(${groups.length} 组 / ${total} 门)`;
  443. }
  444. console.log(
  445. "检测到重复课程:",
  446. groups.map((g) => g.type + "x" + g.courses.length).join(", "),
  447. "即将弹出询问..."
  448. );
  449. try {
  450. const selectedIndex = await window.AndroidBridgePromise.showSingleSelection(
  451. title,
  452. JSON.stringify(options),
  453. 0
  454. );
  455. if (selectedIndex === null) {
  456. console.log("用户取消重复课程处理,终止导入");
  457. return CANCELED;
  458. }
  459. if (selectedIndex === dedupeIndex) {
  460. // 只保留每组第一门(去重)
  461. groups.forEach((g) => {
  462. g.courses.slice(1).forEach((c) => {
  463. const idx = courses.indexOf(c);
  464. if (idx >= 0) courses.splice(idx, 1);
  465. });
  466. });
  467. console.log("已去重,剩余课程:", courses.length);
  468. return "dedupe";
  469. }
  470. if (selectedIndex === mergeIndex) {
  471. // 合并:每组保留一门;多教师组教师并列
  472. const removeIds = new Set();
  473. groups.forEach((g) => {
  474. const first = g.courses[0];
  475. if (g.type === "multiTeacher") {
  476. first.teacher = Array.from(
  477. new Set(g.courses.map((c) => c.teacher).filter(Boolean))
  478. ).join("、");
  479. }
  480. g.courses.slice(1).forEach((c) => removeIds.add(c.id));
  481. });
  482. for (let i = courses.length - 1; i >= 0; i -= 1) {
  483. if (removeIds.has(courses[i].id)) courses.splice(i, 1);
  484. }
  485. console.log("已合并,剩余课程:", courses.length);
  486. return "merge";
  487. }
  488. console.log("用户选择全部保留");
  489. return "keep";
  490. } catch (error) {
  491. console.error("重复课程处理失败,默认全部保留:", error);
  492. return "keep";
  493. }
  494. }
  495. // ===== 学期选择与跨学期数据获取 =====
  496. // 页面下拉框 #xnxq1 的 <option> 就是可选学期(服务端渲染,无需额外接口)
  497. function getCurrentXnxq(doc) {
  498. const fromUrl = new URLSearchParams(location.search).get("xnxq");
  499. if (fromUrl) return fromUrl;
  500. const root = doc || document;
  501. const el = root.querySelector("#xnxq") || document.querySelector("#xnxq");
  502. return ((el && (el.value || el.textContent)) || "").trim();
  503. }
  504. function readSemesterOptions(doc) {
  505. const options = [];
  506. doc.querySelectorAll("#xnxq1 option").forEach((opt) => {
  507. const value = (opt.getAttribute("value") || "").trim();
  508. const text = (opt.textContent || "").trim();
  509. if (value && text) options.push({ value, text });
  510. });
  511. return options;
  512. }
  513. // 学期很多时分页选择:每页最多 SEMESTER_PAGE_SIZE 个学期,
  514. // 末尾放“更多…”翻下一页(最后一页不显示),并支持“上一页”返回
  515. const SEMESTER_PAGE_SIZE = 4;
  516. // 弹窗选择学期(参考 school.bak.js 单选弹窗)。
  517. // 取消返回 CANCELED(终止导入);弹窗不可用/出错时返回 null(用当前学期)。
  518. async function askChooseSemester(doc) {
  519. let options = [];
  520. for (let i = 0; i < 10 && !options.length; i += 1) {
  521. options = readSemesterOptions(doc);
  522. if (!options.length && document !== doc) options = readSemesterOptions(document);
  523. if (!options.length) await sleep(300);
  524. }
  525. if (!options.length) {
  526. console.warn("未找到可选学期下拉框 #xnxq1,跳过学期选择");
  527. return null;
  528. }
  529. if (!canShowSingleSelection()) {
  530. console.warn("AndroidBridgePromise.showSingleSelection 不可用,跳过学期选择");
  531. return null;
  532. }
  533. const current = getCurrentXnxq(doc);
  534. // 按页切分(保持页面原有顺序:最近的学期在前)
  535. const pages = [];
  536. for (let i = 0; i < options.length; i += SEMESTER_PAGE_SIZE) {
  537. pages.push(options.slice(i, i + SEMESTER_PAGE_SIZE));
  538. }
  539. let pageIndex = 0;
  540. while (pageIndex >= 0 && pageIndex < pages.length) {
  541. const page = pages[pageIndex];
  542. const isLastPage = pageIndex === pages.length - 1;
  543. const labels = page.map((o) => o.text);
  544. let moreIndex = -1;
  545. let backIndex = -1;
  546. if (!isLastPage) {
  547. moreIndex = labels.length;
  548. labels.push("更多…");
  549. }
  550. if (pageIndex > 0) {
  551. backIndex = labels.length;
  552. labels.push("上一页");
  553. }
  554. const defaultIndex =
  555. pageIndex === 0
  556. ? Math.max(0, page.findIndex((o) => o.value === current))
  557. : 0;
  558. console.log(
  559. "弹出学期选择(第 " + (pageIndex + 1) + " 页,共 " + pages.length + " 页)..."
  560. );
  561. let selectedIndex;
  562. try {
  563. selectedIndex = await window.AndroidBridgePromise.showSingleSelection(
  564. pageIndex === 0
  565. ? "请选择要导出的学年学期"
  566. : "更多学期(第 " + (pageIndex + 1) + " 页 / 共 " + pages.length + " 页)",
  567. JSON.stringify(labels),
  568. defaultIndex
  569. );
  570. } catch (error) {
  571. console.error("学期选择失败,使用当前学期:", error);
  572. return null;
  573. }
  574. if (selectedIndex === null) {
  575. console.log("用户取消学期选择,终止导入");
  576. return CANCELED;
  577. }
  578. if (selectedIndex === moreIndex) {
  579. pageIndex += 1;
  580. continue;
  581. }
  582. if (selectedIndex === backIndex) {
  583. pageIndex -= 1;
  584. continue;
  585. }
  586. const chosen = page[selectedIndex];
  587. if (!chosen) return null;
  588. console.log("用户选择学期:", chosen.value);
  589. return chosen.value;
  590. }
  591. return null;
  592. }
  593. // 用页面已有的 xhid/xqdm 直接请求指定学期的课表 JSON(同源,无需跳转)。
  594. // xqdmOverride:多校区场景指定校区;缺省用页面默认校区
  595. async function fetchScheduleRows(doc, xnxq, xqdmOverride) {
  596. const root = doc || document;
  597. const xhidEl = root.querySelector("#xhid") || document.querySelector("#xhid");
  598. const xqdmEl = root.querySelector("#xqdm") || document.querySelector("#xqdm");
  599. const xhid = xhidEl ? (xhidEl.value || "") : "";
  600. const xqdm = xqdmOverride || (xqdmEl ? (xqdmEl.value || "") : "");
  601. if (!xhid || !xqdm) {
  602. console.warn("页面缺少 xhid/xqdm,无法请求课表数据");
  603. return null;
  604. }
  605. const params = new URLSearchParams({
  606. xnxq: xnxq,
  607. xhid: xhid,
  608. xqdm: xqdm,
  609. zdzc: "",
  610. zxzc: "",
  611. xskbxslx: "0",
  612. });
  613. const url = "/admin/xsd/pkgl/xskb/sdpkkbList?" + params.toString();
  614. console.log("请求课表数据:", url);
  615. try {
  616. const resp = await fetch(url, {
  617. credentials: "same-origin",
  618. headers: { "X-Requested-With": "XMLHttpRequest" },
  619. });
  620. if (!resp.ok) return null;
  621. const json = await resp.json();
  622. if (json.ret !== 0) return null;
  623. return json.data || [];
  624. } catch (error) {
  625. console.error("请求课表数据失败:", error);
  626. return null;
  627. }
  628. }
  629. // 把接口返回的课表 JSON 行解析为课程
  630. // 复刻页面逻辑:同格同课程合并周次 -> 相邻节次同内容合并(rowspan)
  631. function parseCoursesFromRows(rows) {
  632. const byCell = new Map();
  633. rows.forEach((row) => {
  634. const name = stripHtmlText(row.kcmc);
  635. const teacher = stripHtmlText(row.tmc);
  636. const position = stripHtmlText(row.croommc);
  637. const day = parseInt(row.xingqi, 10);
  638. const djc = parseInt(row.djc, 10);
  639. const weeks = parseWeeksFromRow(row);
  640. if (!name || !Number.isFinite(day) || !Number.isFinite(djc) || !weeks.length) return;
  641. const cellKey = day + "-" + djc;
  642. if (!byCell.has(cellKey)) byCell.set(cellKey, new Map());
  643. const cell = byCell.get(cellKey);
  644. const courseKey = [name, teacher, position].join("|");
  645. if (!cell.has(courseKey)) {
  646. cell.set(courseKey, { name, teacher, position, weeks: new Set(weeks) });
  647. } else {
  648. weeks.forEach((w) => cell.get(courseKey).weeks.add(w));
  649. }
  650. });
  651. let maxDjc = 0;
  652. rows.forEach((row) => {
  653. const djc = parseInt(row.djc, 10);
  654. if (Number.isFinite(djc)) maxDjc = Math.max(maxDjc, djc);
  655. });
  656. const courses = [];
  657. for (let day = 1; day <= 7; day += 1) {
  658. let runStart = null;
  659. let runSig = "";
  660. const flushRun = (endSection) => {
  661. if (runStart === null || !runSig) return;
  662. const cell = byCell.get(day + "-" + runStart);
  663. if (cell) {
  664. cell.forEach((it) => {
  665. courses.push({
  666. id: createId(),
  667. name: it.name,
  668. teacher: it.teacher,
  669. position: it.position,
  670. day,
  671. startSection: runStart,
  672. endSection,
  673. color: randomColor(),
  674. weeks: Array.from(it.weeks).sort((a, b) => a - b),
  675. });
  676. });
  677. }
  678. runStart = null;
  679. runSig = "";
  680. };
  681. for (let s = 1; s <= maxDjc + 1; s += 1) {
  682. const cell = byCell.get(day + "-" + s);
  683. let sig = "";
  684. if (cell) {
  685. sig = Array.from(cell.values())
  686. .map((it) => [
  687. it.name,
  688. it.teacher,
  689. it.position,
  690. Array.from(it.weeks).sort((a, b) => a - b).join(","),
  691. ].join("|"))
  692. .sort()
  693. .join("\u0001");
  694. }
  695. const ended = s === maxDjc + 1;
  696. if (runStart !== null && (ended || !sig || sig !== runSig)) {
  697. flushRun(s - 1);
  698. }
  699. if (!ended && sig && runStart === null) {
  700. runStart = s;
  701. runSig = sig;
  702. }
  703. }
  704. }
  705. return mergeCourses(courses);
  706. }
  707. // ===== 学期配置(开学日期、总周数、时间段)=====
  708. // 数据来自 /admin/api/getZclistByXnxq:
  709. // - zclist 每周 minrq/maxrq,第 1 周的开始日期即开学日期
  710. // - jcsjszList 是各节次时间,可在“不在课表页”时生成时间段
  711. function parseTimeSlotsFromJcsjsz(jcsjszList) {
  712. const slots = [];
  713. (jcsjszList || []).forEach((j) => {
  714. const number = parseInt(j.jc, 10);
  715. if (!Number.isFinite(number)) return;
  716. slots.push({
  717. number,
  718. startTime: padTime(j.kssj),
  719. endTime: padTime(j.jssj),
  720. });
  721. });
  722. return slots.sort((a, b) => a.number - b.number);
  723. }
  724. function parseSemesterConfigFromZclist(json) {
  725. const data = (json && json.data) || {};
  726. const zclist = data.zclist || [];
  727. if (!zclist.length) return null;
  728. const sorted = zclist
  729. .slice()
  730. .sort((a, b) => parseInt(a.zc, 10) - parseInt(b.zc, 10));
  731. const weeks = sorted
  732. .map((w) => parseInt(w.zc, 10))
  733. .filter((n) => Number.isFinite(n));
  734. const semesterTotalWeeks = weeks.length ? Math.max(...weeks) : sorted.length;
  735. const first = sorted[0];
  736. const startDate = first && first.minrq ? String(first.minrq).slice(0, 10) : "";
  737. const timeSlots = parseTimeSlotsFromJcsjsz(data.jcsjszList);
  738. return { semesterStartDate: startDate, semesterTotalWeeks, timeSlots };
  739. }
  740. async function fetchSemesterConfig(doc, xnxq, xqidOverride) {
  741. const root = doc || document;
  742. const xqidEl = root.querySelector("#xqdm") || document.querySelector("#xqdm");
  743. const xqid = xqidOverride || (xqidEl ? (xqidEl.value || "") : "");
  744. const params = new URLSearchParams({
  745. xnxq: xnxq,
  746. role: "",
  747. userId: "",
  748. xqid: xqid,
  749. });
  750. const url = "/admin/api/getZclistByXnxq?" + params.toString();
  751. console.log("请求学期配置:", url);
  752. try {
  753. const resp = await fetch(url, {
  754. credentials: "same-origin",
  755. headers: { "X-Requested-With": "XMLHttpRequest" },
  756. });
  757. if (!resp.ok) return null;
  758. const json = await resp.json();
  759. const config = parseSemesterConfigFromZclist(json);
  760. if (config) console.log("学期配置:", config);
  761. return config;
  762. } catch (error) {
  763. console.error("请求学期配置失败:", error);
  764. return null;
  765. }
  766. }
  767. // ===== 校区(学区)处理 =====
  768. // 只有开学日期/时间段可能随校区变化;若多个校区且结果不一致才询问,一致则直接用默认校区
  769. async function fetchCampusList() {
  770. const url = "/admin/api/jcsj/xqsj/getXqList";
  771. console.log("请求校区列表:", url);
  772. try {
  773. const resp = await fetch(url, {
  774. credentials: "same-origin",
  775. headers: { "X-Requested-With": "XMLHttpRequest" },
  776. });
  777. if (!resp.ok) return [];
  778. const json = await resp.json();
  779. if (json.ret !== 0) return [];
  780. return (json.data || [])
  781. .map((c) => ({ id: c.id, name: c.xqmc }))
  782. .filter((c) => c.id);
  783. } catch (error) {
  784. console.error("请求校区列表失败:", error);
  785. return [];
  786. }
  787. }
  788. // 两个校区配置是否一致(开学日期/总周数/时间段)。
  789. // maxSection:只比较“课程实际用到的节次范围”内的作息,超出的节次差异忽略
  790. function sameSemesterConfig(a, b, maxSection) {
  791. if (!a || !b) return false;
  792. if (a.semesterStartDate !== b.semesterStartDate) return false;
  793. if (a.semesterTotalWeeks !== b.semesterTotalWeeks) return false;
  794. const limit = Number.isFinite(maxSection) && maxSection > 0 ? maxSection : Infinity;
  795. const t1 = (a.timeSlots || []).filter((s) => s.number <= limit);
  796. const t2 = (b.timeSlots || []).filter((s) => s.number <= limit);
  797. if (t1.length !== t2.length) return false;
  798. return t1.every(
  799. (s, i) =>
  800. s.number === t2[i].number &&
  801. s.startTime === t2[i].startTime &&
  802. s.endTime === t2[i].endTime
  803. );
  804. }
  805. // 决定要使用的校区 id:
  806. // - 1 个校区:直接用默认,不询问
  807. // - 多个校区:逐个取配置对比(只在 maxSection 范围内比较),一致则不询问;
  808. // 不一致才弹窗选择(取消返回 CANCELED 终止)
  809. async function resolveCampusId(doc, xnxq, maxSection) {
  810. const root = doc || document;
  811. const xqdmEl = root.querySelector("#xqdm") || document.querySelector("#xqdm");
  812. const defaultId = xqdmEl ? (xqdmEl.value || "") : "";
  813. const campuses = await fetchCampusList();
  814. if (campuses.length <= 1) {
  815. console.log("校区数:", campuses.length, ",使用默认校区:", defaultId);
  816. return defaultId;
  817. }
  818. // 多校区:逐个拉配置对比
  819. const configs = [];
  820. for (const c of campuses) {
  821. const cfg = await fetchSemesterConfig(doc, xnxq, c.id);
  822. configs.push({ campus: c, cfg });
  823. console.log("校区", c.name, "配置:", cfg);
  824. }
  825. const firstCfg = configs[0].cfg;
  826. const allSame = configs.every((x) => sameSemesterConfig(x.cfg, firstCfg, maxSection));
  827. if (allSame) {
  828. console.log("各校区开学日期/时间段一致,无需选择校区,使用默认:", defaultId);
  829. return defaultId;
  830. }
  831. console.log("各校区作息不一致,弹出校区选择...");
  832. if (!canShowSingleSelection()) {
  833. console.warn("AndroidBridgePromise.showSingleSelection 不可用,使用默认校区");
  834. return defaultId;
  835. }
  836. const labels = campuses.map((c) => c.name);
  837. const defaultIndex = Math.max(0, campuses.findIndex((c) => c.id === defaultId));
  838. let selectedIndex;
  839. try {
  840. selectedIndex = await window.AndroidBridgePromise.showSingleSelection(
  841. "检测到多个校区且作息不同,请选择要导出的校区",
  842. JSON.stringify(labels),
  843. defaultIndex
  844. );
  845. } catch (error) {
  846. console.error("校区选择失败,使用默认校区:", error);
  847. return defaultId;
  848. }
  849. if (selectedIndex === null) {
  850. console.log("用户取消校区选择,终止导入");
  851. return CANCELED;
  852. }
  853. const chosen = campuses[selectedIndex];
  854. if (!chosen) return defaultId;
  855. console.log("用户选择校区:", chosen.name, chosen.id);
  856. return chosen.id;
  857. }
  858. // 课程实际用到的最大节次
  859. function maxSectionFromRows(rows) {
  860. let max = 0;
  861. rows.forEach((r) => {
  862. const d = parseInt(r.djc, 10);
  863. if (Number.isFinite(d)) max = Math.max(max, d);
  864. });
  865. return max;
  866. }
  867. function maxSectionFromCourses(courses) {
  868. let max = 0;
  869. courses.forEach((c) => {
  870. if (Number.isFinite(c.endSection)) max = Math.max(max, c.endSection);
  871. });
  872. return max;
  873. }
  874. // ===== 不在课表页时的自动处理 =====
  875. // 首页/选课页等没有课表 DOM:抓一次课表页 HTML 拿 xhid/xqdm/当前学期/学期列表,
  876. // 后续全部走接口,无需跳转
  877. function parseSchedulePageSessionFromHtml(html) {
  878. const inputVal = (id) => {
  879. const m = html.match(new RegExp('<input[^>]*id="' + id + '"[^>]*value="([^"]*)"'));
  880. return m ? m[1].trim() : "";
  881. };
  882. const xhid = inputVal("xhid");
  883. const xqdm = inputVal("xqdm");
  884. const xnxq = inputVal("xnxq");
  885. const semesterOptions = [];
  886. const selMatch = html.match(/<select[^>]*id="xnxq1"[^>]*>([\s\S]*?)<\/select>/);
  887. if (selMatch) {
  888. const optRe = /<option value="([^"]*)"([^>]*)>([^<]*)<\/option>/g;
  889. let m;
  890. while ((m = optRe.exec(selMatch[1]))) {
  891. const value = m[1].trim();
  892. const text = m[3].trim();
  893. if (value && text) semesterOptions.push({ value, text });
  894. }
  895. }
  896. if (!xhid || !xqdm || !semesterOptions.length) return null;
  897. return { xhid, xqdm, xnxq, semesterOptions };
  898. }
  899. async function fetchSchedulePageSession() {
  900. const url = "/admin/xsd/pkgl/xskb/queryKbForXsd";
  901. console.log("自动抓取课表页参数:", url);
  902. try {
  903. const resp = await fetch(url, { credentials: "same-origin" });
  904. if (!resp.ok) return null;
  905. const html = await resp.text();
  906. const session = parseSchedulePageSessionFromHtml(html);
  907. if (session) {
  908. console.log(
  909. "已获取课表页参数: 当前学期=",
  910. session.xnxq,
  911. "学期数=",
  912. session.semesterOptions.length
  913. );
  914. }
  915. return session;
  916. } catch (error) {
  917. console.error("自动抓取课表页参数失败:", error);
  918. return null;
  919. }
  920. }
  921. // 用抓取到的参数构造一个最小“页面对象”,复用现有读学期/读 xhid/xqdm 的逻辑
  922. function makeSessionDoc(session) {
  923. return {
  924. querySelectorAll: (sel) =>
  925. sel === "#xnxq1 option"
  926. ? session.semesterOptions.map((o) => ({
  927. getAttribute: (a) => (a === "value" ? o.value : null),
  928. textContent: o.text,
  929. }))
  930. : [],
  931. querySelector: (sel) => {
  932. if (sel === "#xnxq") return { value: session.xnxq, textContent: session.xnxq };
  933. if (sel === "#xhid") return { value: session.xhid };
  934. if (sel === "#xqdm") return { value: session.xqdm };
  935. return null;
  936. },
  937. };
  938. }
  939. // 收尾:教师工号询问 -> 重复课程询问 -> 保存(任一步取消即终止)
  940. async function finalizeAndSave(courses, timeSlots, semesterConfig) {
  941. // 教师姓名带工号时,弹窗询问是否去除(默认保留)
  942. const teacherIdChoice = await askKeepTeacherId(courses);
  943. if (teacherIdChoice === CANCELED) {
  944. toast("已取消,终止导入");
  945. return;
  946. }
  947. if (teacherIdChoice === "strip") {
  948. courses.forEach((course) => {
  949. course.teacher = stripTeacherId(course.teacher);
  950. });
  951. }
  952. // 内容完全一致的重复/冲突课程:弹窗询问如何处理(全部保留/去重/合并教师)
  953. const dupChoice = await askHandleDuplicateCourses(courses);
  954. if (dupChoice === CANCELED) {
  955. toast("已取消,终止导入");
  956. return;
  957. }
  958. if (dupChoice === "dedupe") {
  959. toast("已去除重复课程");
  960. } else if (dupChoice === "merge") {
  961. toast("已合并重复课程的教师");
  962. }
  963. try {
  964. const result = await window.AndroidBridgePromise.saveImportedCourses(
  965. JSON.stringify(courses)
  966. );
  967. if (result === true) {
  968. if (timeSlots.length) {
  969. await window.AndroidBridgePromise.savePresetTimeSlots(
  970. JSON.stringify(timeSlots)
  971. );
  972. }
  973. if (
  974. semesterConfig &&
  975. window.AndroidBridgePromise &&
  976. typeof window.AndroidBridgePromise.saveCourseConfig === "function"
  977. ) {
  978. await window.AndroidBridgePromise.saveCourseConfig(
  979. JSON.stringify({
  980. semesterStartDate: semesterConfig.semesterStartDate,
  981. semesterTotalWeeks: semesterConfig.semesterTotalWeeks,
  982. })
  983. );
  984. }
  985. toast("课表导出成功");
  986. window.AndroidBridge.notifyTaskCompletion();
  987. } else {
  988. toast("课表导出失败,请查看控制台日志");
  989. }
  990. } catch (error) {
  991. toast("导出失败: " + error.message);
  992. }
  993. }
  994. async function run() {
  995. const doc = await getTargetDocument();
  996. let session = null;
  997. if (!doc) {
  998. // 不在“我的课表”页面(首页/选课页等):自动抓课表页参数(xhid/xqdm/学期列表),全程走接口
  999. session = await fetchSchedulePageSession();
  1000. if (!session) {
  1001. toast("未找到课表页面,请手动打开“我的课表”后重试");
  1002. return;
  1003. }
  1004. }
  1005. // 获取课表前先询问要导出的学期(不在课表页时用抓取到的学期列表)
  1006. const semDoc = doc || makeSessionDoc(session);
  1007. const chosenXnxq = await askChooseSemester(semDoc);
  1008. if (chosenXnxq === CANCELED) {
  1009. toast("已取消,终止导入");
  1010. return;
  1011. }
  1012. const currentXnxq = getCurrentXnxq(semDoc);
  1013. const exportXnxq = chosenXnxq || currentXnxq;
  1014. // 统一走接口拿课程(xqdm 不影响课程列表,先用默认校区)
  1015. const rows = await fetchScheduleRows(semDoc, exportXnxq);
  1016. if (rows && rows.length) {
  1017. const maxSection = maxSectionFromRows(rows);
  1018. const campusId = await resolveCampusId(semDoc, exportXnxq, maxSection);
  1019. if (campusId === CANCELED) {
  1020. toast("已取消,终止导入");
  1021. return;
  1022. }
  1023. const courses = parseCoursesFromRows(rows);
  1024. if (!courses.length) {
  1025. toast("未解析到课程,请确认课表已加载完成");
  1026. return;
  1027. }
  1028. const semesterConfig = await fetchSemesterConfig(semDoc, exportXnxq, campusId);
  1029. // 时间段:优先接口 jcsjszList;课表页时间列作补充
  1030. let timeSlots = (semesterConfig && semesterConfig.timeSlots) || [];
  1031. if (!timeSlots.length && doc) timeSlots = parseTimeSlots(doc);
  1032. toast("已获取 " + exportXnxq + " 的课表");
  1033. await finalizeAndSave(courses, timeSlots, semesterConfig);
  1034. return;
  1035. }
  1036. // 接口拿不到且当前就在课表页:回退解析页面已渲染的 DOM
  1037. if (doc) {
  1038. await waitForScheduleData(doc);
  1039. const courses = parseScheduleFromDocument(doc);
  1040. if (courses.length) {
  1041. const maxSection = maxSectionFromCourses(courses);
  1042. const campusId = await resolveCampusId(semDoc, exportXnxq, maxSection);
  1043. if (campusId === CANCELED) {
  1044. toast("已取消,终止导入");
  1045. return;
  1046. }
  1047. const semesterConfig = await fetchSemesterConfig(semDoc, exportXnxq, campusId);
  1048. let timeSlots = parseTimeSlots(doc);
  1049. if (!timeSlots.length) {
  1050. timeSlots = (semesterConfig && semesterConfig.timeSlots) || [];
  1051. }
  1052. toast("已获取 " + exportXnxq + " 的课表(页面数据)");
  1053. await finalizeAndSave(courses, timeSlots, semesterConfig);
  1054. return;
  1055. }
  1056. }
  1057. toast("所选学期没有可导出的课表数据(" + exportXnxq + ")");
  1058. }
  1059. run();
  1060. })();