wbu_02.js 53 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306
  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) 按 课程/教师/教室/星期/周次 合并连续节次,经 shiguangBridgePromise 保存课程与时间段
  13. // 6) 从 /admin/api/getZclistByXnxq 取周次列表:导出开学日期(第 1 周开始日)与总周数到 config
  14. // 7) 任意弹窗步骤用户点“取消”:立即终止,不解析、不保存、不触发下载
  15. // 8) 检测重复课程(同课程/同教室/同节次/同周次):完全相同→弹“只保留一门”,
  16. // 多教师→弹“合并教师”(选项与标题随类型变化),取消即终止
  17. (function () {
  18. function toast(message) {
  19. if (window.shiguangBridge && typeof window.shiguangBridge.showToast === "function") {
  20. window.shiguangBridge.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.shiguangBridgePromise &&
  32. typeof window.shiguangBridgePromise.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. // 官方参考:节次与周次合并去重函数
  262. // 来源:拾光课程表适配教程《课程合并与去重函数》
  263. // 放在现有去重/合并流程之后,作为最后一道清洗,确保单双周、连续节次、完全重复都收敛。
  264. function mergeAndDistinctCourses(courses) {
  265. if (!Array.isArray(courses) || courses.length <= 1) return courses;
  266. // 1. 深拷贝并规范周次数据,过滤无效项
  267. const list = courses.map(c => ({
  268. ...c,
  269. name: c.name || '',
  270. teacher: c.teacher || '',
  271. position: c.position || '',
  272. weeks: Array.isArray(c.weeks) ? [...c.weeks].sort((a, b) => a - b) : []
  273. }));
  274. // 阶段 1:合并连续节次与完全重复记录(前提:名称、教师、地点、星期、周次一致)
  275. list.sort((a, b) => {
  276. return a.name.localeCompare(b.name) ||
  277. a.teacher.localeCompare(b.teacher) ||
  278. a.position.localeCompare(b.position) ||
  279. (a.day || 0) - (b.day || 0) ||
  280. a.weeks.join(',').localeCompare(b.weeks.join(',')) ||
  281. (a.startSection || 0) - (b.startSection || 0);
  282. });
  283. const step1Merged = [];
  284. let current = list[0];
  285. for (let i = 1; i < list.length; i++) {
  286. const next = list[i];
  287. const isSameCourseAndWeeks =
  288. current.name === next.name &&
  289. current.teacher === next.teacher &&
  290. current.position === next.position &&
  291. current.day === next.day &&
  292. current.weeks.join(',') === next.weeks.join(',');
  293. const isContinuous = current.endSection + 1 === next.startSection;
  294. const isDuplicate = current.startSection === next.startSection && current.endSection === next.endSection;
  295. if (isSameCourseAndWeeks && isContinuous) {
  296. // 节次连续:延长结束节次 (如 1-2 节 + 3-4 节 -> 1-4 节)
  297. current.endSection = next.endSection;
  298. } else if (isSameCourseAndWeeks && isDuplicate) {
  299. // 完全重复:跳过
  300. continue;
  301. } else {
  302. step1Merged.push(current);
  303. current = next;
  304. }
  305. }
  306. step1Merged.push(current);
  307. // 阶段 2:合并同节次的周次(前提:名称、教师、地点、星期、开始/结束节次一致)
  308. step1Merged.sort((a, b) => {
  309. return a.name.localeCompare(b.name) ||
  310. a.teacher.localeCompare(b.teacher) ||
  311. a.position.localeCompare(b.position) ||
  312. (a.day || 0) - (b.day || 0) ||
  313. (a.startSection || 0) - (b.startSection || 0) ||
  314. (a.endSection || 0) - (b.endSection || 0);
  315. });
  316. const step2Merged = [];
  317. let cur = step1Merged[0];
  318. for (let i = 1; i < step1Merged.length; i++) {
  319. const nxt = step1Merged[i];
  320. const isSameCourseAndSection =
  321. cur.name === nxt.name &&
  322. cur.teacher === nxt.teacher &&
  323. cur.position === nxt.position &&
  324. cur.day === nxt.day &&
  325. cur.startSection === nxt.startSection &&
  326. cur.endSection === nxt.endSection;
  327. if (isSameCourseAndSection) {
  328. // 周次合并去重 (如 1-8 周 + 9-16 周 -> 1-16 周)
  329. cur.weeks = Array.from(new Set([...cur.weeks, ...nxt.weeks])).sort((a, b) => a - b);
  330. } else {
  331. step2Merged.push(cur);
  332. cur = nxt;
  333. }
  334. }
  335. step2Merged.push(cur);
  336. return step2Merged;
  337. }
  338. function parseScheduleFromDocument(doc) {
  339. const cells = Array.from(doc.querySelectorAll("td.cell"));
  340. const fallbackCells = cells.length ? [] : Array.from(doc.querySelectorAll("td[id^='Cell']"));
  341. const targetCells = cells.length ? cells : fallbackCells;
  342. const courses = [];
  343. const seen = new Set();
  344. targetCells.forEach((cell) => {
  345. const id = cell.getAttribute("id") || "";
  346. const match = id.match(/^Cell(\d)(\d{1,2})$/);
  347. if (!match) return;
  348. const day = parseInt(match[1], 10);
  349. const startSection = parseInt(match[2], 10);
  350. const rowspan = parseInt(cell.getAttribute("rowspan") || "1", 10);
  351. const endSection = startSection + Math.max(rowspan, 1) - 1;
  352. const blocks = splitCourseBlocks(cell.innerText || "");
  353. blocks.forEach((blockText) => {
  354. const parsed = parseCourseBlock(cell, blockText);
  355. if (!parsed) return;
  356. const weeks = parseWeekText(parsed.weeksText);
  357. if (!weeks.length) return;
  358. const key = [
  359. parsed.name,
  360. parsed.teacher,
  361. parsed.position,
  362. day,
  363. startSection,
  364. endSection,
  365. weeks.join(","),
  366. ].join("|");
  367. if (seen.has(key)) return;
  368. seen.add(key);
  369. courses.push({
  370. id: createId(),
  371. name: parsed.name,
  372. teacher: parsed.teacher,
  373. position: parsed.position,
  374. day,
  375. startSection,
  376. endSection,
  377. color: randomColor(),
  378. weeks,
  379. });
  380. });
  381. });
  382. return mergeCourses(courses);
  383. }
  384. function parseTimeSlots(doc) {
  385. const slots = [];
  386. const seenNumbers = new Set();
  387. const timeRegex = /(\d{1,2}:\d{2})/g;
  388. const timeCells = Array.from(
  389. doc.querySelectorAll("td[data-jcindex], td[data-jcIndex]")
  390. );
  391. timeCells.forEach((cell) => {
  392. const text = (cell.innerText || cell.textContent || "").trim();
  393. if (!text) return;
  394. const indexAttr = cell.getAttribute("data-jcindex") || cell.getAttribute("data-jcIndex");
  395. const numberMatch = text.match(/^(\d{1,2})/);
  396. const number = parseInt(indexAttr || (numberMatch && numberMatch[1]) || "", 10);
  397. if (!Number.isFinite(number)) return;
  398. const times = text.match(timeRegex) || [];
  399. if (times.length < 2) return;
  400. if (seenNumbers.has(number)) return;
  401. seenNumbers.add(number);
  402. slots.push({
  403. number,
  404. startTime: padTime(times[0]),
  405. endTime: padTime(times[1]),
  406. });
  407. });
  408. return slots.sort((a, b) => a.number - b.number);
  409. }
  410. // ===== 教师工号处理 =====
  411. // 匹配“姓名(工号)”/“姓名(工号)”结尾的教师名
  412. const TEACHER_ID_RE = /^(.+?)\s*[((](\d{4,})[))]$/;
  413. function hasTeacherId(teacher) {
  414. return TEACHER_ID_RE.test(teacher || "");
  415. }
  416. function stripTeacherId(teacher) {
  417. const match = TEACHER_ID_RE.exec(teacher || "");
  418. return match ? match[1].trim() : teacher;
  419. }
  420. // 若检测到教师姓名带工号,弹窗询问是否保留(参考 school.bak.js 的单选弹窗用法,
  421. // 选项里同时展示“保留”和“去除”后的样子,方便核对脚本解析是否正确)
  422. async function askKeepTeacherId(courses) {
  423. const withId = courses.filter((c) => hasTeacherId(c.teacher));
  424. if (!withId.length) return "keep";
  425. if (!canShowSingleSelection()) {
  426. console.warn("shiguangBridgePromise.showSingleSelection 不可用,教师工号默认保留");
  427. return "keep";
  428. }
  429. const example = withId[0].teacher;
  430. const stripped = stripTeacherId(example);
  431. const options = [
  432. `保留工号(如:${example})`,
  433. `去除工号(如:${stripped})`,
  434. ];
  435. console.log(`检测到 ${withId.length} 门课的教师姓名带工号,即将弹出询问...`);
  436. try {
  437. const selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
  438. `教师姓名带工号(共 ${withId.length} 门课)`,
  439. JSON.stringify(options),
  440. 0
  441. );
  442. if (selectedIndex === null) {
  443. console.log("用户取消工号询问,终止导入");
  444. return CANCELED;
  445. }
  446. if (selectedIndex === 1) {
  447. console.log("用户选择去除工号,例如:" + example + " -> " + stripped);
  448. return "strip";
  449. }
  450. console.log("用户选择保留工号,保持原样");
  451. return "keep";
  452. } catch (error) {
  453. console.error("教师工号询问失败,默认保留工号:", error);
  454. return "keep";
  455. }
  456. }
  457. // ===== 重复/冲突课程处理 =====
  458. // 系统有时会把同一门课拆成多条(同名/同教室/同周次/同节次):
  459. // - 完全相同:教师也相同 -> 只需“只保留一门”
  460. // - 多教师:教师不同(如两位老师同上一门课)-> 用“合并教师”并列成一条
  461. function findDuplicateCourseGroups(courses) {
  462. const groups = new Map();
  463. courses.forEach((c) => {
  464. const key = [
  465. c.name,
  466. c.position,
  467. c.day,
  468. c.startSection,
  469. c.endSection,
  470. (c.weeks || []).join(","),
  471. ].join("|");
  472. if (!groups.has(key)) groups.set(key, []);
  473. groups.get(key).push(c);
  474. });
  475. return Array.from(groups.values())
  476. .filter((g) => g.length > 1)
  477. .map((g) => ({
  478. courses: g,
  479. type: new Set(g.map((c) => c.teacher).filter(Boolean)).size > 1
  480. ? "multiTeacher"
  481. : "identical",
  482. }));
  483. }
  484. // 弹窗询问重复课程处理。选项随重复类型变化:
  485. // 完全相同 -> 显示“只保留一门”;多教师 -> 显示“合并教师”;两类都有则都显示。
  486. // 取消返回 CANCELED 终止导入。
  487. async function askHandleDuplicateCourses(courses) {
  488. const groups = findDuplicateCourseGroups(courses);
  489. if (!groups.length) return "keep";
  490. if (!canShowSingleSelection()) {
  491. console.warn("shiguangBridgePromise.showSingleSelection 不可用,重复课程默认全部保留");
  492. return "keep";
  493. }
  494. const total = groups.reduce((n, g) => n + g.courses.length, 0);
  495. const hasIdentical = groups.some((g) => g.type === "identical");
  496. const hasMulti = groups.some((g) => g.type === "multiTeacher");
  497. const exampleGroup = groups.find((g) => g.type === "multiTeacher") || groups[0];
  498. const example = exampleGroup.courses[0];
  499. const teacherText = Array.from(
  500. new Set(exampleGroup.courses.map((c) => c.teacher).filter(Boolean))
  501. ).join("、");
  502. const options = [`全部保留(${total} 门)`];
  503. let dedupeIndex = -1;
  504. let mergeIndex = -1;
  505. if (hasIdentical) {
  506. dedupeIndex = options.length;
  507. options.push(`只保留一门(去重:如 ${example.name} 只留 1 门)`);
  508. }
  509. if (hasMulti) {
  510. mergeIndex = options.length;
  511. options.push(`合并教师(如:${example.name} -> ${teacherText})`);
  512. }
  513. let title;
  514. if (hasIdentical && hasMulti) {
  515. title = `重复课程处理(${groups.length} 组 / ${total} 门,含相同课程与多教师)`;
  516. } else if (hasMulti) {
  517. title = `多教师重复课程处理(${groups.length} 组 / ${total} 门)`;
  518. } else {
  519. title = `完全相同的重复课程处理(${groups.length} 组 / ${total} 门)`;
  520. }
  521. console.log(
  522. "检测到重复课程:",
  523. groups.map((g) => g.type + "x" + g.courses.length).join(", "),
  524. "即将弹出询问..."
  525. );
  526. try {
  527. const selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
  528. title,
  529. JSON.stringify(options),
  530. 0
  531. );
  532. if (selectedIndex === null) {
  533. console.log("用户取消重复课程处理,终止导入");
  534. return CANCELED;
  535. }
  536. if (selectedIndex === dedupeIndex) {
  537. // 只保留每组第一门(去重)
  538. groups.forEach((g) => {
  539. g.courses.slice(1).forEach((c) => {
  540. const idx = courses.indexOf(c);
  541. if (idx >= 0) courses.splice(idx, 1);
  542. });
  543. });
  544. console.log("已去重,剩余课程:", courses.length);
  545. return "dedupe";
  546. }
  547. if (selectedIndex === mergeIndex) {
  548. // 合并:每组保留一门;多教师组教师并列
  549. const removeIds = new Set();
  550. groups.forEach((g) => {
  551. const first = g.courses[0];
  552. if (g.type === "multiTeacher") {
  553. first.teacher = Array.from(
  554. new Set(g.courses.map((c) => c.teacher).filter(Boolean))
  555. ).join("、");
  556. }
  557. g.courses.slice(1).forEach((c) => removeIds.add(c.id));
  558. });
  559. for (let i = courses.length - 1; i >= 0; i -= 1) {
  560. if (removeIds.has(courses[i].id)) courses.splice(i, 1);
  561. }
  562. console.log("已合并,剩余课程:", courses.length);
  563. return "merge";
  564. }
  565. console.log("用户选择全部保留");
  566. return "keep";
  567. } catch (error) {
  568. console.error("重复课程处理失败,默认全部保留:", error);
  569. return "keep";
  570. }
  571. }
  572. // ===== 学期选择与跨学期数据获取 =====
  573. // 页面下拉框 #xnxq1 的 <option> 就是可选学期(服务端渲染,无需额外接口)
  574. function getCurrentXnxq(doc) {
  575. const fromUrl = new URLSearchParams(location.search).get("xnxq");
  576. if (fromUrl) return fromUrl;
  577. const root = doc || document;
  578. const el = root.querySelector("#xnxq") || document.querySelector("#xnxq");
  579. return ((el && (el.value || el.textContent)) || "").trim();
  580. }
  581. function readSemesterOptions(doc) {
  582. const options = [];
  583. doc.querySelectorAll("#xnxq1 option").forEach((opt) => {
  584. const value = (opt.getAttribute("value") || "").trim();
  585. const text = (opt.textContent || "").trim();
  586. if (value && text) options.push({ value, text });
  587. });
  588. return options;
  589. }
  590. // 学期很多时分页选择:每页最多 SEMESTER_PAGE_SIZE 个学期,
  591. // 末尾放“更多…”翻下一页(最后一页不显示),并支持“上一页”返回
  592. const SEMESTER_PAGE_SIZE = 4;
  593. // 弹窗选择学期(参考 school.bak.js 单选弹窗)。
  594. // 取消返回 CANCELED(终止导入);弹窗不可用/出错时返回 null(用当前学期)。
  595. async function askChooseSemester(doc) {
  596. let options = [];
  597. for (let i = 0; i < 10 && !options.length; i += 1) {
  598. options = readSemesterOptions(doc);
  599. if (!options.length && document !== doc) options = readSemesterOptions(document);
  600. if (!options.length) await sleep(300);
  601. }
  602. if (!options.length) {
  603. console.warn("未找到可选学期下拉框 #xnxq1,跳过学期选择");
  604. return null;
  605. }
  606. if (!canShowSingleSelection()) {
  607. console.warn("shiguangBridgePromise.showSingleSelection 不可用,跳过学期选择");
  608. return null;
  609. }
  610. const current = getCurrentXnxq(doc);
  611. // 按页切分(保持页面原有顺序:最近的学期在前)
  612. const pages = [];
  613. for (let i = 0; i < options.length; i += SEMESTER_PAGE_SIZE) {
  614. pages.push(options.slice(i, i + SEMESTER_PAGE_SIZE));
  615. }
  616. let pageIndex = 0;
  617. while (pageIndex >= 0 && pageIndex < pages.length) {
  618. const page = pages[pageIndex];
  619. const isLastPage = pageIndex === pages.length - 1;
  620. const labels = page.map((o) => o.text);
  621. let moreIndex = -1;
  622. let backIndex = -1;
  623. if (!isLastPage) {
  624. moreIndex = labels.length;
  625. labels.push("更多…");
  626. }
  627. if (pageIndex > 0) {
  628. backIndex = labels.length;
  629. labels.push("上一页");
  630. }
  631. const defaultIndex =
  632. pageIndex === 0
  633. ? Math.max(0, page.findIndex((o) => o.value === current))
  634. : 0;
  635. console.log(
  636. "弹出学期选择(第 " + (pageIndex + 1) + " 页,共 " + pages.length + " 页)..."
  637. );
  638. let selectedIndex;
  639. try {
  640. selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
  641. pageIndex === 0
  642. ? "请选择要导出的学年学期"
  643. : "更多学期(第 " + (pageIndex + 1) + " 页 / 共 " + pages.length + " 页)",
  644. JSON.stringify(labels),
  645. defaultIndex
  646. );
  647. } catch (error) {
  648. console.error("学期选择失败,使用当前学期:", error);
  649. return null;
  650. }
  651. if (selectedIndex === null) {
  652. console.log("用户取消学期选择,终止导入");
  653. return CANCELED;
  654. }
  655. if (selectedIndex === moreIndex) {
  656. pageIndex += 1;
  657. continue;
  658. }
  659. if (selectedIndex === backIndex) {
  660. pageIndex -= 1;
  661. continue;
  662. }
  663. const chosen = page[selectedIndex];
  664. if (!chosen) return null;
  665. console.log("用户选择学期:", chosen.value);
  666. return chosen.value;
  667. }
  668. return null;
  669. }
  670. // 用页面已有的 xhid/xqdm 直接请求指定学期的课表 JSON(同源,无需跳转)。
  671. // xqdmOverride:多校区场景指定校区;缺省用页面默认校区
  672. async function fetchScheduleRows(doc, xnxq, xqdmOverride) {
  673. const root = doc || document;
  674. const xhidEl = root.querySelector("#xhid") || document.querySelector("#xhid");
  675. const xqdmEl = root.querySelector("#xqdm") || document.querySelector("#xqdm");
  676. const xhid = xhidEl ? (xhidEl.value || "") : "";
  677. const xqdm = xqdmOverride || (xqdmEl ? (xqdmEl.value || "") : "");
  678. if (!xhid || !xqdm) {
  679. console.warn("页面缺少 xhid/xqdm,无法请求课表数据");
  680. return null;
  681. }
  682. const params = new URLSearchParams({
  683. xnxq: xnxq,
  684. xhid: xhid,
  685. xqdm: xqdm,
  686. zdzc: "",
  687. zxzc: "",
  688. xskbxslx: "0",
  689. });
  690. const url = "/admin/xsd/pkgl/xskb/sdpkkbList?" + params.toString();
  691. console.log("请求课表数据:", url);
  692. try {
  693. const resp = await fetch(url, {
  694. credentials: "same-origin",
  695. headers: { "X-Requested-With": "XMLHttpRequest" },
  696. });
  697. if (!resp.ok) return null;
  698. const json = await resp.json();
  699. if (json.ret !== 0) return null;
  700. return json.data || [];
  701. } catch (error) {
  702. console.error("请求课表数据失败:", error);
  703. return null;
  704. }
  705. }
  706. // 把接口返回的课表 JSON 行解析为课程
  707. // 复刻页面逻辑:同格同课程合并周次 -> 相邻节次同内容合并(rowspan)
  708. function parseCoursesFromRows(rows) {
  709. const byCell = new Map();
  710. rows.forEach((row) => {
  711. const name = stripHtmlText(row.kcmc);
  712. const teacher = stripHtmlText(row.tmc);
  713. const position = stripHtmlText(row.croommc);
  714. const day = parseInt(row.xingqi, 10);
  715. const djc = parseInt(row.djc, 10);
  716. const weeks = parseWeeksFromRow(row);
  717. if (!name || !Number.isFinite(day) || !Number.isFinite(djc) || !weeks.length) return;
  718. const cellKey = day + "-" + djc;
  719. if (!byCell.has(cellKey)) byCell.set(cellKey, new Map());
  720. const cell = byCell.get(cellKey);
  721. const courseKey = [name, teacher, position].join("|");
  722. if (!cell.has(courseKey)) {
  723. cell.set(courseKey, { name, teacher, position, weeks: new Set(weeks) });
  724. } else {
  725. weeks.forEach((w) => cell.get(courseKey).weeks.add(w));
  726. }
  727. });
  728. let maxDjc = 0;
  729. rows.forEach((row) => {
  730. const djc = parseInt(row.djc, 10);
  731. if (Number.isFinite(djc)) maxDjc = Math.max(maxDjc, djc);
  732. });
  733. const courses = [];
  734. for (let day = 1; day <= 7; day += 1) {
  735. let runStart = null;
  736. let runSig = "";
  737. const flushRun = (endSection) => {
  738. if (runStart === null || !runSig) return;
  739. const cell = byCell.get(day + "-" + runStart);
  740. if (cell) {
  741. cell.forEach((it) => {
  742. courses.push({
  743. id: createId(),
  744. name: it.name,
  745. teacher: it.teacher,
  746. position: it.position,
  747. day,
  748. startSection: runStart,
  749. endSection,
  750. color: randomColor(),
  751. weeks: Array.from(it.weeks).sort((a, b) => a - b),
  752. });
  753. });
  754. }
  755. runStart = null;
  756. runSig = "";
  757. };
  758. for (let s = 1; s <= maxDjc + 1; s += 1) {
  759. const cell = byCell.get(day + "-" + s);
  760. let sig = "";
  761. if (cell) {
  762. sig = Array.from(cell.values())
  763. .map((it) => [
  764. it.name,
  765. it.teacher,
  766. it.position,
  767. Array.from(it.weeks).sort((a, b) => a - b).join(","),
  768. ].join("|"))
  769. .sort()
  770. .join("\u0001");
  771. }
  772. const ended = s === maxDjc + 1;
  773. if (runStart !== null && (ended || !sig || sig !== runSig)) {
  774. flushRun(s - 1);
  775. }
  776. if (!ended && sig && runStart === null) {
  777. runStart = s;
  778. runSig = sig;
  779. }
  780. }
  781. }
  782. return mergeCourses(courses);
  783. }
  784. // ===== 学期配置(开学日期、总周数、时间段)=====
  785. // 数据来自 /admin/api/getZclistByXnxq:
  786. // - zclist 每周 minrq/maxrq,第 1 周的开始日期即开学日期
  787. // - jcsjszList 是各节次时间,可在“不在课表页”时生成时间段
  788. function parseTimeSlotsFromJcsjsz(jcsjszList) {
  789. const slots = [];
  790. (jcsjszList || []).forEach((j) => {
  791. const number = parseInt(j.jc, 10);
  792. if (!Number.isFinite(number)) return;
  793. slots.push({
  794. number,
  795. startTime: padTime(j.kssj),
  796. endTime: padTime(j.jssj),
  797. });
  798. });
  799. return slots.sort((a, b) => a.number - b.number);
  800. }
  801. function parseSemesterConfigFromZclist(json) {
  802. const data = (json && json.data) || {};
  803. const zclist = data.zclist || [];
  804. if (!zclist.length) return null;
  805. const sorted = zclist
  806. .slice()
  807. .sort((a, b) => parseInt(a.zc, 10) - parseInt(b.zc, 10));
  808. const weeks = sorted
  809. .map((w) => parseInt(w.zc, 10))
  810. .filter((n) => Number.isFinite(n));
  811. const semesterTotalWeeks = weeks.length ? Math.max(...weeks) : sorted.length;
  812. const first = sorted[0];
  813. const startDate = first && first.minrq ? String(first.minrq).slice(0, 10) : "";
  814. const timeSlots = parseTimeSlotsFromJcsjsz(data.jcsjszList);
  815. return { semesterStartDate: startDate, semesterTotalWeeks, timeSlots };
  816. }
  817. async function fetchSemesterConfig(doc, xnxq, xqidOverride) {
  818. const root = doc || document;
  819. const xqidEl = root.querySelector("#xqdm") || document.querySelector("#xqdm");
  820. const xqid = xqidOverride || (xqidEl ? (xqidEl.value || "") : "");
  821. const params = new URLSearchParams({
  822. xnxq: xnxq,
  823. role: "",
  824. userId: "",
  825. xqid: xqid,
  826. });
  827. const url = "/admin/api/getZclistByXnxq?" + params.toString();
  828. console.log("请求学期配置:", url);
  829. try {
  830. const resp = await fetch(url, {
  831. credentials: "same-origin",
  832. headers: { "X-Requested-With": "XMLHttpRequest" },
  833. });
  834. if (!resp.ok) return null;
  835. const json = await resp.json();
  836. const config = parseSemesterConfigFromZclist(json);
  837. if (config) console.log("学期配置:", config);
  838. return config;
  839. } catch (error) {
  840. console.error("请求学期配置失败:", error);
  841. return null;
  842. }
  843. }
  844. // ===== 校区(学区)处理 =====
  845. // 只有开学日期/时间段可能随校区变化;若多个校区且结果不一致才询问,一致则直接用默认校区
  846. async function fetchCampusList() {
  847. const url = "/admin/api/jcsj/xqsj/getXqList";
  848. console.log("请求校区列表:", url);
  849. try {
  850. const resp = await fetch(url, {
  851. credentials: "same-origin",
  852. headers: { "X-Requested-With": "XMLHttpRequest" },
  853. });
  854. if (!resp.ok) return [];
  855. const json = await resp.json();
  856. if (json.ret !== 0) return [];
  857. return (json.data || [])
  858. .map((c) => ({ id: c.id, name: c.xqmc }))
  859. .filter((c) => c.id);
  860. } catch (error) {
  861. console.error("请求校区列表失败:", error);
  862. return [];
  863. }
  864. }
  865. // 两个校区配置是否一致(开学日期/总周数/时间段)。
  866. // maxSection:只比较“课程实际用到的节次范围”内的作息,超出的节次差异忽略
  867. function sameSemesterConfig(a, b, maxSection) {
  868. if (!a || !b) return false;
  869. if (a.semesterStartDate !== b.semesterStartDate) return false;
  870. if (a.semesterTotalWeeks !== b.semesterTotalWeeks) return false;
  871. const limit = Number.isFinite(maxSection) && maxSection > 0 ? maxSection : Infinity;
  872. const t1 = (a.timeSlots || []).filter((s) => s.number <= limit);
  873. const t2 = (b.timeSlots || []).filter((s) => s.number <= limit);
  874. if (t1.length !== t2.length) return false;
  875. return t1.every(
  876. (s, i) =>
  877. s.number === t2[i].number &&
  878. s.startTime === t2[i].startTime &&
  879. s.endTime === t2[i].endTime
  880. );
  881. }
  882. // 决定要使用的校区 id:
  883. // - 1 个校区:直接用默认,不询问
  884. // - 多个校区:逐个取配置对比(只在 maxSection 范围内比较),一致则不询问;
  885. // 不一致才弹窗选择(取消返回 CANCELED 终止)
  886. async function resolveCampusId(doc, xnxq, maxSection) {
  887. const root = doc || document;
  888. const xqdmEl = root.querySelector("#xqdm") || document.querySelector("#xqdm");
  889. const defaultId = xqdmEl ? (xqdmEl.value || "") : "";
  890. const campuses = await fetchCampusList();
  891. if (campuses.length <= 1) {
  892. console.log("校区数:", campuses.length, ",使用默认校区:", defaultId);
  893. return defaultId;
  894. }
  895. // 多校区:逐个拉配置对比
  896. const configs = [];
  897. for (const c of campuses) {
  898. const cfg = await fetchSemesterConfig(doc, xnxq, c.id);
  899. configs.push({ campus: c, cfg });
  900. console.log("校区", c.name, "配置:", cfg);
  901. }
  902. const firstCfg = configs[0].cfg;
  903. const allSame = configs.every((x) => sameSemesterConfig(x.cfg, firstCfg, maxSection));
  904. if (allSame) {
  905. console.log("各校区开学日期/时间段一致,无需选择校区,使用默认:", defaultId);
  906. return defaultId;
  907. }
  908. console.log("各校区作息不一致,弹出校区选择...");
  909. if (!canShowSingleSelection()) {
  910. console.warn("shiguangBridgePromise.showSingleSelection 不可用,使用默认校区");
  911. return defaultId;
  912. }
  913. const labels = campuses.map((c) => c.name);
  914. const defaultIndex = Math.max(0, campuses.findIndex((c) => c.id === defaultId));
  915. let selectedIndex;
  916. try {
  917. selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
  918. "检测到多个校区且作息不同,请选择要导出的校区",
  919. JSON.stringify(labels),
  920. defaultIndex
  921. );
  922. } catch (error) {
  923. console.error("校区选择失败,使用默认校区:", error);
  924. return defaultId;
  925. }
  926. if (selectedIndex === null) {
  927. console.log("用户取消校区选择,终止导入");
  928. return CANCELED;
  929. }
  930. const chosen = campuses[selectedIndex];
  931. if (!chosen) return defaultId;
  932. console.log("用户选择校区:", chosen.name, chosen.id);
  933. return chosen.id;
  934. }
  935. // 课程实际用到的最大节次
  936. function maxSectionFromRows(rows) {
  937. let max = 0;
  938. rows.forEach((r) => {
  939. const d = parseInt(r.djc, 10);
  940. if (Number.isFinite(d)) max = Math.max(max, d);
  941. });
  942. return max;
  943. }
  944. function maxSectionFromCourses(courses) {
  945. let max = 0;
  946. courses.forEach((c) => {
  947. if (Number.isFinite(c.endSection)) max = Math.max(max, c.endSection);
  948. });
  949. return max;
  950. }
  951. // ===== 不在课表页时的自动处理 =====
  952. // 首页/选课页等没有课表 DOM:抓一次课表页 HTML 拿 xhid/xqdm/当前学期/学期列表,
  953. // 后续全部走接口,无需跳转
  954. function parseSchedulePageSessionFromHtml(html) {
  955. const inputVal = (id) => {
  956. const m = html.match(new RegExp('<input[^>]*id="' + id + '"[^>]*value="([^"]*)"'));
  957. return m ? m[1].trim() : "";
  958. };
  959. const xhid = inputVal("xhid");
  960. const xqdm = inputVal("xqdm");
  961. const xnxq = inputVal("xnxq");
  962. const semesterOptions = [];
  963. const selMatch = html.match(/<select[^>]*id="xnxq1"[^>]*>([\s\S]*?)<\/select>/);
  964. if (selMatch) {
  965. const optRe = /<option value="([^"]*)"([^>]*)>([^<]*)<\/option>/g;
  966. let m;
  967. while ((m = optRe.exec(selMatch[1]))) {
  968. const value = m[1].trim();
  969. const text = m[3].trim();
  970. if (value && text) semesterOptions.push({ value, text });
  971. }
  972. }
  973. if (!xhid || !xqdm || !semesterOptions.length) return null;
  974. return { xhid, xqdm, xnxq, semesterOptions };
  975. }
  976. // 首页上没有“当前学年学期”输入框时,从校区接口拿到当前学年学期(dataXnxq)。
  977. // 直接请求无参 queryKbForXsd 时,若当前学期未发布会返回“当前学年学期课表未发布”,
  978. // 但加上 xnxq 参数后仍能打开课表页并拿到 xhid/xqdm/历史学期下拉框。
  979. async function fetchCurrentDataXnxq() {
  980. try {
  981. const resp = await fetch("/admin/api/jcsj/xqsj/getXqList", {
  982. credentials: "same-origin",
  983. headers: { "X-Requested-With": "XMLHttpRequest" },
  984. });
  985. if (!resp.ok) return "";
  986. const json = await resp.json();
  987. if (json.ret !== 0) return "";
  988. const xnxq = (json.data || [])
  989. .map((c) => c && c.dataXnxq)
  990. .find(Boolean) || "";
  991. if (xnxq) console.log("从校区接口获取当前学年学期:", xnxq);
  992. return xnxq;
  993. } catch (error) {
  994. console.warn("获取当前学年学期失败:", error);
  995. return "";
  996. }
  997. }
  998. async function fetchSchedulePageSession() {
  999. const base = "/admin/xsd/pkgl/xskb/queryKbForXsd";
  1000. const candidates = [];
  1001. const urlXnxq = new URLSearchParams(location.search).get("xnxq");
  1002. if (urlXnxq) candidates.push(base + "?xnxq=" + encodeURIComponent(urlXnxq));
  1003. const currentXnxq = await fetchCurrentDataXnxq();
  1004. if (currentXnxq) {
  1005. candidates.push(base + "?xnxq=" + encodeURIComponent(currentXnxq));
  1006. }
  1007. // 最后兜底尝试无参(若本学期已发布,无参页面也能正常打开)
  1008. candidates.push(base);
  1009. const seen = new Set();
  1010. for (const url of candidates) {
  1011. if (seen.has(url)) continue;
  1012. seen.add(url);
  1013. console.log("自动抓取课表页参数:", url);
  1014. try {
  1015. const resp = await fetch(url, { credentials: "same-origin" });
  1016. if (!resp.ok) continue;
  1017. const html = await resp.text();
  1018. const session = parseSchedulePageSessionFromHtml(html);
  1019. if (session) {
  1020. console.log(
  1021. "已获取课表页参数: 当前学期=",
  1022. session.xnxq,
  1023. "学期数=",
  1024. session.semesterOptions.length
  1025. );
  1026. return session;
  1027. }
  1028. } catch (error) {
  1029. console.error("自动抓取课表页参数失败:", url, error);
  1030. }
  1031. }
  1032. return null;
  1033. }
  1034. // 用抓取到的参数构造一个最小“页面对象”,复用现有读学期/读 xhid/xqdm 的逻辑
  1035. function makeSessionDoc(session) {
  1036. return {
  1037. querySelectorAll: (sel) =>
  1038. sel === "#xnxq1 option"
  1039. ? session.semesterOptions.map((o) => ({
  1040. getAttribute: (a) => (a === "value" ? o.value : null),
  1041. textContent: o.text,
  1042. }))
  1043. : [],
  1044. querySelector: (sel) => {
  1045. if (sel === "#xnxq") return { value: session.xnxq, textContent: session.xnxq };
  1046. if (sel === "#xhid") return { value: session.xhid };
  1047. if (sel === "#xqdm") return { value: session.xqdm };
  1048. return null;
  1049. },
  1050. };
  1051. }
  1052. // 收尾:教师工号询问 -> 重复课程询问 -> 保存(任一步取消即终止)
  1053. async function finalizeAndSave(courses, timeSlots, semesterConfig) {
  1054. // 教师姓名带工号时,弹窗询问是否去除(默认保留)
  1055. const teacherIdChoice = await askKeepTeacherId(courses);
  1056. if (teacherIdChoice === CANCELED) {
  1057. toast("已取消,终止导入");
  1058. return;
  1059. }
  1060. if (teacherIdChoice === "strip") {
  1061. courses.forEach((course) => {
  1062. course.teacher = stripTeacherId(course.teacher);
  1063. });
  1064. }
  1065. // 内容完全一致的重复/冲突课程:弹窗询问如何处理(全部保留/去重/合并教师)
  1066. const dupChoice = await askHandleDuplicateCourses(courses);
  1067. if (dupChoice === CANCELED) {
  1068. toast("已取消,终止导入");
  1069. return;
  1070. }
  1071. if (dupChoice === "dedupe") {
  1072. toast("已去除重复课程");
  1073. } else if (dupChoice === "merge") {
  1074. toast("已合并重复课程的教师");
  1075. }
  1076. // 最后走一层官方参考的合并去重函数:
  1077. // 合并连续节次、合并同节次单双周、清理完全重复记录。
  1078. courses = mergeAndDistinctCourses(courses);
  1079. console.log("官方合并去重后课程数:", courses.length);
  1080. try {
  1081. const result = await window.shiguangBridgePromise.saveImportedCourses(
  1082. JSON.stringify(courses)
  1083. );
  1084. if (result === true) {
  1085. if (timeSlots.length) {
  1086. await window.shiguangBridgePromise.savePresetTimeSlots(
  1087. JSON.stringify(timeSlots)
  1088. );
  1089. }
  1090. if (
  1091. semesterConfig &&
  1092. window.shiguangBridgePromise &&
  1093. typeof window.shiguangBridgePromise.saveCourseConfig === "function"
  1094. ) {
  1095. await window.shiguangBridgePromise.saveCourseConfig(
  1096. JSON.stringify({
  1097. semesterStartDate: semesterConfig.semesterStartDate,
  1098. semesterTotalWeeks: semesterConfig.semesterTotalWeeks,
  1099. firstDayOfWeek: 1,
  1100. })
  1101. );
  1102. }
  1103. toast("课表导出成功");
  1104. window.shiguangBridge.notifyTaskCompletion();
  1105. } else {
  1106. toast("课表导出失败,请查看控制台日志");
  1107. }
  1108. } catch (error) {
  1109. toast("导出失败: " + error.message);
  1110. }
  1111. }
  1112. async function run() {
  1113. let doc = await getTargetDocument();
  1114. let session = null;
  1115. if (!doc) {
  1116. // 不在“我的课表”页面(首页/选课页等):自动抓课表页参数(xhid/xqdm/学期列表),全程走接口
  1117. session = await fetchSchedulePageSession();
  1118. if (!session) {
  1119. toast("未找到课表页面,请手动打开“我的课表”后重试");
  1120. return;
  1121. }
  1122. } else if (!doc.querySelector("#xnxq1")) {
  1123. // 当前课表页无学期下拉框(例如无参打开时报“当前学年学期课表未发布”):
  1124. // 自动带 xnxq 参数重新抓一份含历史学期列表的课表页,改用接口导出。
  1125. session = await fetchSchedulePageSession();
  1126. if (session) {
  1127. console.log("当前课表页无学期下拉框,改用自动抓取的课表页参数(含历史学期)");
  1128. doc = null;
  1129. }
  1130. }
  1131. // 获取课表前先询问要导出的学期(不在课表页时用抓取到的学期列表)
  1132. const semDoc = doc || makeSessionDoc(session);
  1133. const chosenXnxq = await askChooseSemester(semDoc);
  1134. if (chosenXnxq === CANCELED) {
  1135. toast("已取消,终止导入");
  1136. return;
  1137. }
  1138. const currentXnxq = getCurrentXnxq(semDoc);
  1139. const exportXnxq = chosenXnxq || currentXnxq;
  1140. // 统一走接口拿课程(xqdm 不影响课程列表,先用默认校区)
  1141. const rows = await fetchScheduleRows(semDoc, exportXnxq);
  1142. if (rows && rows.length) {
  1143. const maxSection = maxSectionFromRows(rows);
  1144. const campusId = await resolveCampusId(semDoc, exportXnxq, maxSection);
  1145. if (campusId === CANCELED) {
  1146. toast("已取消,终止导入");
  1147. return;
  1148. }
  1149. const courses = parseCoursesFromRows(rows);
  1150. if (!courses.length) {
  1151. toast("未解析到课程,请确认课表已加载完成");
  1152. return;
  1153. }
  1154. const semesterConfig = await fetchSemesterConfig(semDoc, exportXnxq, campusId);
  1155. // 时间段:优先接口 jcsjszList;课表页时间列作补充
  1156. let timeSlots = (semesterConfig && semesterConfig.timeSlots) || [];
  1157. if (!timeSlots.length && doc) timeSlots = parseTimeSlots(doc);
  1158. toast("已获取 " + exportXnxq + " 的课表");
  1159. await finalizeAndSave(courses, timeSlots, semesterConfig);
  1160. return;
  1161. }
  1162. // 接口拿不到且当前就在课表页:回退解析页面已渲染的 DOM
  1163. if (doc) {
  1164. await waitForScheduleData(doc);
  1165. const courses = parseScheduleFromDocument(doc);
  1166. if (courses.length) {
  1167. const maxSection = maxSectionFromCourses(courses);
  1168. const campusId = await resolveCampusId(semDoc, exportXnxq, maxSection);
  1169. if (campusId === CANCELED) {
  1170. toast("已取消,终止导入");
  1171. return;
  1172. }
  1173. const semesterConfig = await fetchSemesterConfig(semDoc, exportXnxq, campusId);
  1174. let timeSlots = parseTimeSlots(doc);
  1175. if (!timeSlots.length) {
  1176. timeSlots = (semesterConfig && semesterConfig.timeSlots) || [];
  1177. }
  1178. toast("已获取 " + exportXnxq + " 的课表(页面数据)");
  1179. await finalizeAndSave(courses, timeSlots, semesterConfig);
  1180. return;
  1181. }
  1182. }
  1183. toast("所选学期没有可导出的课表数据(" + exportXnxq + ")");
  1184. }
  1185. run();
  1186. })();