| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161 |
- // 适配目标:武汉商学院-超星综合教学管理系统
- //
- // 逻辑概览:
- // 0) 询问导出学期:可选学期来自页面下拉框 #xnxq1(服务端渲染);学期很多时分页展示
- // (每页 4 个 + “更多…”翻页 + “上一页”返回);选非当前学期时用页面已有的
- // xhid/xqdm 直接请求该学期 sdpkkbList JSON 并解析,无需跳转
- // 1) 统一走接口:sdpkkbList 拉课程 JSON,getZclistByXnxq 取时间段/开学日期;
- // 不在课表页(首页/选课页等)时先抓课表页 HTML 拿 xhid/xqdm/学期列表,任意页面都能导
- // 2) 课程 JSON 复刻页面逻辑:同格同课程合并周次 -> 相邻节次同内容合并(rowspan)
- // 3) 接口失败且当前在课表页时,回退解析页面已渲染的 DOM(td.cell + <a onclick> 链接)
- // 4) 教师姓名带工号(如“王老师(20240999)”)时,弹窗询问是否去除,默认保留
- // 5) 按 课程/教师/教室/星期/周次 合并连续节次,经 AndroidBridgePromise 保存课程与时间段
- // 6) 从 /admin/api/getZclistByXnxq 取周次列表:导出开学日期(第 1 周开始日)与总周数到 config
- // 7) 任意弹窗步骤用户点“取消”:立即终止,不解析、不保存、不触发下载
- // 8) 检测重复课程(同课程/同教室/同节次/同周次):完全相同→弹“只保留一门”,
- // 多教师→弹“合并教师”(选项与标题随类型变化),取消即终止
- (function () {
- function toast(message) {
- if (window.AndroidBridge && typeof window.AndroidBridge.showToast === "function") {
- window.AndroidBridge.showToast(message);
- }
- }
- function sleep(ms) {
- return new Promise((resolve) => setTimeout(resolve, ms));
- }
- // 用户点击“取消”时的统一返回值:任意流程取消即终止整个导入
- const CANCELED = "CANCELED";
- // 桥接端是否支持单选弹窗
- function canShowSingleSelection() {
- return !!(
- window.AndroidBridgePromise &&
- typeof window.AndroidBridgePromise.showSingleSelection === "function"
- );
- }
- async function getTargetDocument() {
- if (location.href.includes("queryKbForXsd")) {
- return document;
- }
- const iframe = document.querySelector("iframe[src*='queryKbForXsd']");
- if (!iframe) return null;
- for (let i = 0; i < 20; i += 1) {
- try {
- const doc = iframe.contentDocument || (iframe.contentWindow && iframe.contentWindow.document);
- if (doc && doc.readyState && doc.readyState !== "loading") {
- return doc;
- }
- } catch (e) {
- // ignore
- }
- await sleep(500);
- }
- return null;
- }
- async function waitForScheduleData(doc, timeoutMs = 15000) {
- const start = Date.now();
- while (Date.now() - start < timeoutMs) {
- const cells = Array.from(doc.querySelectorAll("td.cell, td[id^='Cell']"));
- const filled = cells.filter((cell) => {
- const text = (cell.innerText || cell.textContent || "").trim();
- return text.length > 0 && /周/.test(text);
- });
- if (filled.length > 0) {
- return true;
- }
- await sleep(500);
- }
- return false;
- }
- function uniqueSortedNumbers(nums) {
- const set = new Set(nums.filter((n) => Number.isFinite(n)));
- return Array.from(set).sort((a, b) => a - b);
- }
- function parseWeekText(weekText) {
- if (!weekText) return [];
- let text = String(weekText).trim();
- if (!text) return [];
- let oddOnly = false;
- let evenOnly = false;
- if (text.includes("单")) oddOnly = true;
- if (text.includes("双")) evenOnly = true;
- text = text.replace(/周/g, "");
- text = text.replace(/\s+/g, "");
- text = text.replace(/\(.*?\)/g, "");
- text = text.replace(/(.*?)/g, "");
- // 兼容接口 zc 的写法:1-19(单)/2-18(双)/裸“单”“双”/“改”后缀
- text = text.replace(/[((]?单[))]?/g, "");
- text = text.replace(/[((]?双[))]?/g, "");
- text = text.replace(/改$/g, "");
- const weeks = [];
- const segments = text.split(",").map((s) => s.trim()).filter(Boolean);
- segments.forEach((seg) => {
- if (!seg) return;
- const rangeMatch = seg.match(/^(\d+)-(\d+)$/);
- if (rangeMatch) {
- const start = parseInt(rangeMatch[1], 10);
- const end = parseInt(rangeMatch[2], 10);
- if (!Number.isFinite(start) || !Number.isFinite(end)) return;
- for (let w = start; w <= end; w += 1) {
- weeks.push(w);
- }
- return;
- }
- const single = parseInt(seg, 10);
- if (Number.isFinite(single)) weeks.push(single);
- });
- let filtered = weeks;
- if (oddOnly && !evenOnly) {
- filtered = weeks.filter((w) => w % 2 === 1);
- } else if (evenOnly && !oddOnly) {
- filtered = weeks.filter((w) => w % 2 === 0);
- }
- return uniqueSortedNumbers(filtered);
- }
- // 接口行周次:优先 zcstr(逗号列表),回退 zc(parseWeekText 已兼容范围/单双/改)
- function parseWeeksFromRow(row) {
- const zcstr = String(row.zcstr || "").trim();
- if (zcstr) {
- const weeks = parseWeekText(zcstr + "周");
- if (weeks.length) return weeks;
- }
- return parseWeekText(String(row.zc || "") + "周");
- }
- function stripHtmlText(text) {
- return String(text || "").replace(/<[^>]+>/g, "").trim();
- }
- function splitCourseBlocks(cellText) {
- const text = cellText.replace(/\r/g, "").trim();
- if (!text) return [];
- return text
- .split(/\n{2,}/)
- .map((block) => block.trim())
- .filter(Boolean);
- }
- function extractWeeksTextFromLine(line) {
- if (!line) return { weeksText: "", rest: line || "" };
- const match = line.match(/(\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*)\s*(?:\((单|双)\))?\s*周/);
- if (!match) return { weeksText: "", rest: line };
- const weeksCore = match[1];
- const oddEven = match[2] ? `(${match[2]})` : "";
- const weeksText = `${weeksCore}${oddEven}周`;
- const rest = line.replace(match[0], "").trim();
- return { weeksText, rest };
- }
- // 从单元格里的 <a> 链接按语义提取文本(武汉商学院课表把课程名/教师/教室渲染成链接)
- function extractAnchorText(cell, onclickKeyword, blockText) {
- if (!cell) return "";
- const anchors = Array.from(
- cell.querySelectorAll('a[onclick*="' + onclickKeyword + '"]')
- ).sort((a, b) => (b.textContent || "").length - (a.textContent || "").length);
- for (const anchor of anchors) {
- const text = (anchor.textContent || "").trim();
- if (text && blockText.includes(text)) {
- return text;
- }
- }
- return "";
- }
- function parseCourseBlock(cell, block) {
- const lines = block
- .split(/\n+/)
- .map((l) => l.trim())
- .filter(Boolean);
- if (!lines.length) return null;
- // 优先用单元格内的链接解析,避免把“校区名”当成课程名、把“课程名”当成教师。
- // 武商院课表单元格文本顺序为:校区 -> 课程名 -> 教师 周次 -> 教室。
- const anchorName = extractAnchorText(cell, "openKckb", block);
- const anchorTeacher = extractAnchorText(cell, "openJskb", block);
- const anchorPosition = extractAnchorText(cell, "openCrkb", block);
- const name = anchorName || lines[0] || "";
- let teacher = "";
- let weeksText = "";
- let position = "";
- const weekLineIndex = lines.findIndex((l) => /周/.test(l));
- if (weekLineIndex >= 0) {
- const { weeksText: extractedWeeks, rest } = extractWeeksTextFromLine(
- lines[weekLineIndex]
- );
- weeksText = extractedWeeks;
- if (weekLineIndex === 1) {
- teacher = rest || lines[1];
- }
- }
- if (!teacher && lines.length > 1) {
- teacher = lines[1];
- const { weeksText: extractedWeeks, rest } = extractWeeksTextFromLine(teacher);
- if (extractedWeeks) {
- weeksText = weeksText || extractedWeeks;
- teacher = rest;
- }
- }
- if (!weeksText) {
- for (const line of lines) {
- const { weeksText: extractedWeeks } = extractWeeksTextFromLine(line);
- if (extractedWeeks) {
- weeksText = extractedWeeks;
- break;
- }
- }
- }
- if (!position) {
- if (weekLineIndex >= 0 && weekLineIndex + 1 < lines.length) {
- position = lines[weekLineIndex + 1];
- }
- if (!position) {
- position =
- lines.find((l) => l !== name && l !== teacher && !/周/.test(l)) || "";
- }
- }
- return {
- name: anchorName || name || "未知课程",
- teacher: anchorTeacher || teacher || "",
- weeksText,
- position: anchorPosition || position || "",
- };
- }
- function padTime(value) {
- const text = String(value || "").trim();
- const match = text.match(/^(\d{1,2}):(\d{1,2})$/);
- if (!match) return text;
- const h = match[1].padStart(2, "0");
- const m = match[2].padStart(2, "0");
- return `${h}:${m}`;
- }
- function randomColor() {
- return Math.floor(Math.random() * 12) + 1;
- }
- function createId() {
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
- return crypto.randomUUID();
- }
- return `id-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
- }
- function mergeCourses(courses) {
- const byKey = new Map();
- courses.forEach((course) => {
- const weeksKey = (course.weeks || []).join(",");
- const key = [course.name, course.teacher, course.position, course.day, weeksKey].join("|");
- if (!byKey.has(key)) byKey.set(key, []);
- byKey.get(key).push({ ...course });
- });
- const merged = [];
- byKey.forEach((items) => {
- items.sort((a, b) => a.startSection - b.startSection);
- let current = null;
- items.forEach((item) => {
- if (!current) {
- current = { ...item };
- return;
- }
- if (item.startSection === current.endSection + 1) {
- current.endSection = Math.max(current.endSection, item.endSection);
- } else {
- merged.push(current);
- current = { ...item };
- }
- });
- if (current) merged.push(current);
- });
- return merged;
- }
- function parseScheduleFromDocument(doc) {
- const cells = Array.from(doc.querySelectorAll("td.cell"));
- const fallbackCells = cells.length ? [] : Array.from(doc.querySelectorAll("td[id^='Cell']"));
- const targetCells = cells.length ? cells : fallbackCells;
- const courses = [];
- const seen = new Set();
- targetCells.forEach((cell) => {
- const id = cell.getAttribute("id") || "";
- const match = id.match(/^Cell(\d)(\d{1,2})$/);
- if (!match) return;
- const day = parseInt(match[1], 10);
- const startSection = parseInt(match[2], 10);
- const rowspan = parseInt(cell.getAttribute("rowspan") || "1", 10);
- const endSection = startSection + Math.max(rowspan, 1) - 1;
- const blocks = splitCourseBlocks(cell.innerText || "");
- blocks.forEach((blockText) => {
- const parsed = parseCourseBlock(cell, blockText);
- if (!parsed) return;
- const weeks = parseWeekText(parsed.weeksText);
- if (!weeks.length) return;
- const key = [
- parsed.name,
- parsed.teacher,
- parsed.position,
- day,
- startSection,
- endSection,
- weeks.join(","),
- ].join("|");
- if (seen.has(key)) return;
- seen.add(key);
- courses.push({
- id: createId(),
- name: parsed.name,
- teacher: parsed.teacher,
- position: parsed.position,
- day,
- startSection,
- endSection,
- color: randomColor(),
- weeks,
- });
- });
- });
- return mergeCourses(courses);
- }
- function parseTimeSlots(doc) {
- const slots = [];
- const seenNumbers = new Set();
- const timeRegex = /(\d{1,2}:\d{2})/g;
- const timeCells = Array.from(
- doc.querySelectorAll("td[data-jcindex], td[data-jcIndex]")
- );
- timeCells.forEach((cell) => {
- const text = (cell.innerText || cell.textContent || "").trim();
- if (!text) return;
- const indexAttr = cell.getAttribute("data-jcindex") || cell.getAttribute("data-jcIndex");
- const numberMatch = text.match(/^(\d{1,2})/);
- const number = parseInt(indexAttr || (numberMatch && numberMatch[1]) || "", 10);
- if (!Number.isFinite(number)) return;
- const times = text.match(timeRegex) || [];
- if (times.length < 2) return;
- if (seenNumbers.has(number)) return;
- seenNumbers.add(number);
- slots.push({
- number,
- startTime: padTime(times[0]),
- endTime: padTime(times[1]),
- });
- });
- return slots.sort((a, b) => a.number - b.number);
- }
- // ===== 教师工号处理 =====
- // 匹配“姓名(工号)”/“姓名(工号)”结尾的教师名
- const TEACHER_ID_RE = /^(.+?)\s*[((](\d{4,})[))]$/;
- function hasTeacherId(teacher) {
- return TEACHER_ID_RE.test(teacher || "");
- }
- function stripTeacherId(teacher) {
- const match = TEACHER_ID_RE.exec(teacher || "");
- return match ? match[1].trim() : teacher;
- }
- // 若检测到教师姓名带工号,弹窗询问是否保留(参考 school.bak.js 的单选弹窗用法,
- // 选项里同时展示“保留”和“去除”后的样子,方便核对脚本解析是否正确)
- async function askKeepTeacherId(courses) {
- const withId = courses.filter((c) => hasTeacherId(c.teacher));
- if (!withId.length) return "keep";
- if (!canShowSingleSelection()) {
- console.warn("AndroidBridgePromise.showSingleSelection 不可用,教师工号默认保留");
- return "keep";
- }
- const example = withId[0].teacher;
- const stripped = stripTeacherId(example);
- const options = [
- `保留工号(如:${example})`,
- `去除工号(如:${stripped})`,
- ];
- console.log(`检测到 ${withId.length} 门课的教师姓名带工号,即将弹出询问...`);
- try {
- const selectedIndex = await window.AndroidBridgePromise.showSingleSelection(
- `教师姓名带工号(共 ${withId.length} 门课)`,
- JSON.stringify(options),
- 0
- );
- if (selectedIndex === null) {
- console.log("用户取消工号询问,终止导入");
- return CANCELED;
- }
- if (selectedIndex === 1) {
- console.log("用户选择去除工号,例如:" + example + " -> " + stripped);
- return "strip";
- }
- console.log("用户选择保留工号,保持原样");
- return "keep";
- } catch (error) {
- console.error("教师工号询问失败,默认保留工号:", error);
- return "keep";
- }
- }
- // ===== 重复/冲突课程处理 =====
- // 系统有时会把同一门课拆成多条(同名/同教室/同周次/同节次):
- // - 完全相同:教师也相同 -> 只需“只保留一门”
- // - 多教师:教师不同(如两位老师同上一门课)-> 用“合并教师”并列成一条
- function findDuplicateCourseGroups(courses) {
- const groups = new Map();
- courses.forEach((c) => {
- const key = [
- c.name,
- c.position,
- c.day,
- c.startSection,
- c.endSection,
- (c.weeks || []).join(","),
- ].join("|");
- if (!groups.has(key)) groups.set(key, []);
- groups.get(key).push(c);
- });
- return Array.from(groups.values())
- .filter((g) => g.length > 1)
- .map((g) => ({
- courses: g,
- type: new Set(g.map((c) => c.teacher).filter(Boolean)).size > 1
- ? "multiTeacher"
- : "identical",
- }));
- }
- // 弹窗询问重复课程处理。选项随重复类型变化:
- // 完全相同 -> 显示“只保留一门”;多教师 -> 显示“合并教师”;两类都有则都显示。
- // 取消返回 CANCELED 终止导入。
- async function askHandleDuplicateCourses(courses) {
- const groups = findDuplicateCourseGroups(courses);
- if (!groups.length) return "keep";
- if (!canShowSingleSelection()) {
- console.warn("AndroidBridgePromise.showSingleSelection 不可用,重复课程默认全部保留");
- return "keep";
- }
- const total = groups.reduce((n, g) => n + g.courses.length, 0);
- const hasIdentical = groups.some((g) => g.type === "identical");
- const hasMulti = groups.some((g) => g.type === "multiTeacher");
- const exampleGroup = groups.find((g) => g.type === "multiTeacher") || groups[0];
- const example = exampleGroup.courses[0];
- const teacherText = Array.from(
- new Set(exampleGroup.courses.map((c) => c.teacher).filter(Boolean))
- ).join("、");
- const options = [`全部保留(${total} 门)`];
- let dedupeIndex = -1;
- let mergeIndex = -1;
- if (hasIdentical) {
- dedupeIndex = options.length;
- options.push(`只保留一门(去重:如 ${example.name} 只留 1 门)`);
- }
- if (hasMulti) {
- mergeIndex = options.length;
- options.push(`合并教师(如:${example.name} -> ${teacherText})`);
- }
- let title;
- if (hasIdentical && hasMulti) {
- title = `重复课程处理(${groups.length} 组 / ${total} 门,含相同课程与多教师)`;
- } else if (hasMulti) {
- title = `多教师重复课程处理(${groups.length} 组 / ${total} 门)`;
- } else {
- title = `完全相同的重复课程处理(${groups.length} 组 / ${total} 门)`;
- }
- console.log(
- "检测到重复课程:",
- groups.map((g) => g.type + "x" + g.courses.length).join(", "),
- "即将弹出询问..."
- );
- try {
- const selectedIndex = await window.AndroidBridgePromise.showSingleSelection(
- title,
- JSON.stringify(options),
- 0
- );
- if (selectedIndex === null) {
- console.log("用户取消重复课程处理,终止导入");
- return CANCELED;
- }
- if (selectedIndex === dedupeIndex) {
- // 只保留每组第一门(去重)
- groups.forEach((g) => {
- g.courses.slice(1).forEach((c) => {
- const idx = courses.indexOf(c);
- if (idx >= 0) courses.splice(idx, 1);
- });
- });
- console.log("已去重,剩余课程:", courses.length);
- return "dedupe";
- }
- if (selectedIndex === mergeIndex) {
- // 合并:每组保留一门;多教师组教师并列
- const removeIds = new Set();
- groups.forEach((g) => {
- const first = g.courses[0];
- if (g.type === "multiTeacher") {
- first.teacher = Array.from(
- new Set(g.courses.map((c) => c.teacher).filter(Boolean))
- ).join("、");
- }
- g.courses.slice(1).forEach((c) => removeIds.add(c.id));
- });
- for (let i = courses.length - 1; i >= 0; i -= 1) {
- if (removeIds.has(courses[i].id)) courses.splice(i, 1);
- }
- console.log("已合并,剩余课程:", courses.length);
- return "merge";
- }
- console.log("用户选择全部保留");
- return "keep";
- } catch (error) {
- console.error("重复课程处理失败,默认全部保留:", error);
- return "keep";
- }
- }
- // ===== 学期选择与跨学期数据获取 =====
- // 页面下拉框 #xnxq1 的 <option> 就是可选学期(服务端渲染,无需额外接口)
- function getCurrentXnxq(doc) {
- const fromUrl = new URLSearchParams(location.search).get("xnxq");
- if (fromUrl) return fromUrl;
- const root = doc || document;
- const el = root.querySelector("#xnxq") || document.querySelector("#xnxq");
- return ((el && (el.value || el.textContent)) || "").trim();
- }
- function readSemesterOptions(doc) {
- const options = [];
- doc.querySelectorAll("#xnxq1 option").forEach((opt) => {
- const value = (opt.getAttribute("value") || "").trim();
- const text = (opt.textContent || "").trim();
- if (value && text) options.push({ value, text });
- });
- return options;
- }
- // 学期很多时分页选择:每页最多 SEMESTER_PAGE_SIZE 个学期,
- // 末尾放“更多…”翻下一页(最后一页不显示),并支持“上一页”返回
- const SEMESTER_PAGE_SIZE = 4;
- // 弹窗选择学期(参考 school.bak.js 单选弹窗)。
- // 取消返回 CANCELED(终止导入);弹窗不可用/出错时返回 null(用当前学期)。
- async function askChooseSemester(doc) {
- let options = [];
- for (let i = 0; i < 10 && !options.length; i += 1) {
- options = readSemesterOptions(doc);
- if (!options.length && document !== doc) options = readSemesterOptions(document);
- if (!options.length) await sleep(300);
- }
- if (!options.length) {
- console.warn("未找到可选学期下拉框 #xnxq1,跳过学期选择");
- return null;
- }
- if (!canShowSingleSelection()) {
- console.warn("AndroidBridgePromise.showSingleSelection 不可用,跳过学期选择");
- return null;
- }
- const current = getCurrentXnxq(doc);
- // 按页切分(保持页面原有顺序:最近的学期在前)
- const pages = [];
- for (let i = 0; i < options.length; i += SEMESTER_PAGE_SIZE) {
- pages.push(options.slice(i, i + SEMESTER_PAGE_SIZE));
- }
- let pageIndex = 0;
- while (pageIndex >= 0 && pageIndex < pages.length) {
- const page = pages[pageIndex];
- const isLastPage = pageIndex === pages.length - 1;
- const labels = page.map((o) => o.text);
- let moreIndex = -1;
- let backIndex = -1;
- if (!isLastPage) {
- moreIndex = labels.length;
- labels.push("更多…");
- }
- if (pageIndex > 0) {
- backIndex = labels.length;
- labels.push("上一页");
- }
- const defaultIndex =
- pageIndex === 0
- ? Math.max(0, page.findIndex((o) => o.value === current))
- : 0;
- console.log(
- "弹出学期选择(第 " + (pageIndex + 1) + " 页,共 " + pages.length + " 页)..."
- );
- let selectedIndex;
- try {
- selectedIndex = await window.AndroidBridgePromise.showSingleSelection(
- pageIndex === 0
- ? "请选择要导出的学年学期"
- : "更多学期(第 " + (pageIndex + 1) + " 页 / 共 " + pages.length + " 页)",
- JSON.stringify(labels),
- defaultIndex
- );
- } catch (error) {
- console.error("学期选择失败,使用当前学期:", error);
- return null;
- }
- if (selectedIndex === null) {
- console.log("用户取消学期选择,终止导入");
- return CANCELED;
- }
- if (selectedIndex === moreIndex) {
- pageIndex += 1;
- continue;
- }
- if (selectedIndex === backIndex) {
- pageIndex -= 1;
- continue;
- }
- const chosen = page[selectedIndex];
- if (!chosen) return null;
- console.log("用户选择学期:", chosen.value);
- return chosen.value;
- }
- return null;
- }
- // 用页面已有的 xhid/xqdm 直接请求指定学期的课表 JSON(同源,无需跳转)。
- // xqdmOverride:多校区场景指定校区;缺省用页面默认校区
- async function fetchScheduleRows(doc, xnxq, xqdmOverride) {
- const root = doc || document;
- const xhidEl = root.querySelector("#xhid") || document.querySelector("#xhid");
- const xqdmEl = root.querySelector("#xqdm") || document.querySelector("#xqdm");
- const xhid = xhidEl ? (xhidEl.value || "") : "";
- const xqdm = xqdmOverride || (xqdmEl ? (xqdmEl.value || "") : "");
- if (!xhid || !xqdm) {
- console.warn("页面缺少 xhid/xqdm,无法请求课表数据");
- return null;
- }
- const params = new URLSearchParams({
- xnxq: xnxq,
- xhid: xhid,
- xqdm: xqdm,
- zdzc: "",
- zxzc: "",
- xskbxslx: "0",
- });
- const url = "/admin/xsd/pkgl/xskb/sdpkkbList?" + params.toString();
- console.log("请求课表数据:", url);
- try {
- const resp = await fetch(url, {
- credentials: "same-origin",
- headers: { "X-Requested-With": "XMLHttpRequest" },
- });
- if (!resp.ok) return null;
- const json = await resp.json();
- if (json.ret !== 0) return null;
- return json.data || [];
- } catch (error) {
- console.error("请求课表数据失败:", error);
- return null;
- }
- }
- // 把接口返回的课表 JSON 行解析为课程
- // 复刻页面逻辑:同格同课程合并周次 -> 相邻节次同内容合并(rowspan)
- function parseCoursesFromRows(rows) {
- const byCell = new Map();
- rows.forEach((row) => {
- const name = stripHtmlText(row.kcmc);
- const teacher = stripHtmlText(row.tmc);
- const position = stripHtmlText(row.croommc);
- const day = parseInt(row.xingqi, 10);
- const djc = parseInt(row.djc, 10);
- const weeks = parseWeeksFromRow(row);
- if (!name || !Number.isFinite(day) || !Number.isFinite(djc) || !weeks.length) return;
- const cellKey = day + "-" + djc;
- if (!byCell.has(cellKey)) byCell.set(cellKey, new Map());
- const cell = byCell.get(cellKey);
- const courseKey = [name, teacher, position].join("|");
- if (!cell.has(courseKey)) {
- cell.set(courseKey, { name, teacher, position, weeks: new Set(weeks) });
- } else {
- weeks.forEach((w) => cell.get(courseKey).weeks.add(w));
- }
- });
- let maxDjc = 0;
- rows.forEach((row) => {
- const djc = parseInt(row.djc, 10);
- if (Number.isFinite(djc)) maxDjc = Math.max(maxDjc, djc);
- });
- const courses = [];
- for (let day = 1; day <= 7; day += 1) {
- let runStart = null;
- let runSig = "";
- const flushRun = (endSection) => {
- if (runStart === null || !runSig) return;
- const cell = byCell.get(day + "-" + runStart);
- if (cell) {
- cell.forEach((it) => {
- courses.push({
- id: createId(),
- name: it.name,
- teacher: it.teacher,
- position: it.position,
- day,
- startSection: runStart,
- endSection,
- color: randomColor(),
- weeks: Array.from(it.weeks).sort((a, b) => a - b),
- });
- });
- }
- runStart = null;
- runSig = "";
- };
- for (let s = 1; s <= maxDjc + 1; s += 1) {
- const cell = byCell.get(day + "-" + s);
- let sig = "";
- if (cell) {
- sig = Array.from(cell.values())
- .map((it) => [
- it.name,
- it.teacher,
- it.position,
- Array.from(it.weeks).sort((a, b) => a - b).join(","),
- ].join("|"))
- .sort()
- .join("\u0001");
- }
- const ended = s === maxDjc + 1;
- if (runStart !== null && (ended || !sig || sig !== runSig)) {
- flushRun(s - 1);
- }
- if (!ended && sig && runStart === null) {
- runStart = s;
- runSig = sig;
- }
- }
- }
- return mergeCourses(courses);
- }
- // ===== 学期配置(开学日期、总周数、时间段)=====
- // 数据来自 /admin/api/getZclistByXnxq:
- // - zclist 每周 minrq/maxrq,第 1 周的开始日期即开学日期
- // - jcsjszList 是各节次时间,可在“不在课表页”时生成时间段
- function parseTimeSlotsFromJcsjsz(jcsjszList) {
- const slots = [];
- (jcsjszList || []).forEach((j) => {
- const number = parseInt(j.jc, 10);
- if (!Number.isFinite(number)) return;
- slots.push({
- number,
- startTime: padTime(j.kssj),
- endTime: padTime(j.jssj),
- });
- });
- return slots.sort((a, b) => a.number - b.number);
- }
- function parseSemesterConfigFromZclist(json) {
- const data = (json && json.data) || {};
- const zclist = data.zclist || [];
- if (!zclist.length) return null;
- const sorted = zclist
- .slice()
- .sort((a, b) => parseInt(a.zc, 10) - parseInt(b.zc, 10));
- const weeks = sorted
- .map((w) => parseInt(w.zc, 10))
- .filter((n) => Number.isFinite(n));
- const semesterTotalWeeks = weeks.length ? Math.max(...weeks) : sorted.length;
- const first = sorted[0];
- const startDate = first && first.minrq ? String(first.minrq).slice(0, 10) : "";
- const timeSlots = parseTimeSlotsFromJcsjsz(data.jcsjszList);
- return { semesterStartDate: startDate, semesterTotalWeeks, timeSlots };
- }
- async function fetchSemesterConfig(doc, xnxq, xqidOverride) {
- const root = doc || document;
- const xqidEl = root.querySelector("#xqdm") || document.querySelector("#xqdm");
- const xqid = xqidOverride || (xqidEl ? (xqidEl.value || "") : "");
- const params = new URLSearchParams({
- xnxq: xnxq,
- role: "",
- userId: "",
- xqid: xqid,
- });
- const url = "/admin/api/getZclistByXnxq?" + params.toString();
- console.log("请求学期配置:", url);
- try {
- const resp = await fetch(url, {
- credentials: "same-origin",
- headers: { "X-Requested-With": "XMLHttpRequest" },
- });
- if (!resp.ok) return null;
- const json = await resp.json();
- const config = parseSemesterConfigFromZclist(json);
- if (config) console.log("学期配置:", config);
- return config;
- } catch (error) {
- console.error("请求学期配置失败:", error);
- return null;
- }
- }
- // ===== 校区(学区)处理 =====
- // 只有开学日期/时间段可能随校区变化;若多个校区且结果不一致才询问,一致则直接用默认校区
- async function fetchCampusList() {
- const url = "/admin/api/jcsj/xqsj/getXqList";
- console.log("请求校区列表:", url);
- try {
- const resp = await fetch(url, {
- credentials: "same-origin",
- headers: { "X-Requested-With": "XMLHttpRequest" },
- });
- if (!resp.ok) return [];
- const json = await resp.json();
- if (json.ret !== 0) return [];
- return (json.data || [])
- .map((c) => ({ id: c.id, name: c.xqmc }))
- .filter((c) => c.id);
- } catch (error) {
- console.error("请求校区列表失败:", error);
- return [];
- }
- }
- // 两个校区配置是否一致(开学日期/总周数/时间段)。
- // maxSection:只比较“课程实际用到的节次范围”内的作息,超出的节次差异忽略
- function sameSemesterConfig(a, b, maxSection) {
- if (!a || !b) return false;
- if (a.semesterStartDate !== b.semesterStartDate) return false;
- if (a.semesterTotalWeeks !== b.semesterTotalWeeks) return false;
- const limit = Number.isFinite(maxSection) && maxSection > 0 ? maxSection : Infinity;
- const t1 = (a.timeSlots || []).filter((s) => s.number <= limit);
- const t2 = (b.timeSlots || []).filter((s) => s.number <= limit);
- if (t1.length !== t2.length) return false;
- return t1.every(
- (s, i) =>
- s.number === t2[i].number &&
- s.startTime === t2[i].startTime &&
- s.endTime === t2[i].endTime
- );
- }
- // 决定要使用的校区 id:
- // - 1 个校区:直接用默认,不询问
- // - 多个校区:逐个取配置对比(只在 maxSection 范围内比较),一致则不询问;
- // 不一致才弹窗选择(取消返回 CANCELED 终止)
- async function resolveCampusId(doc, xnxq, maxSection) {
- const root = doc || document;
- const xqdmEl = root.querySelector("#xqdm") || document.querySelector("#xqdm");
- const defaultId = xqdmEl ? (xqdmEl.value || "") : "";
- const campuses = await fetchCampusList();
- if (campuses.length <= 1) {
- console.log("校区数:", campuses.length, ",使用默认校区:", defaultId);
- return defaultId;
- }
- // 多校区:逐个拉配置对比
- const configs = [];
- for (const c of campuses) {
- const cfg = await fetchSemesterConfig(doc, xnxq, c.id);
- configs.push({ campus: c, cfg });
- console.log("校区", c.name, "配置:", cfg);
- }
- const firstCfg = configs[0].cfg;
- const allSame = configs.every((x) => sameSemesterConfig(x.cfg, firstCfg, maxSection));
- if (allSame) {
- console.log("各校区开学日期/时间段一致,无需选择校区,使用默认:", defaultId);
- return defaultId;
- }
- console.log("各校区作息不一致,弹出校区选择...");
- if (!canShowSingleSelection()) {
- console.warn("AndroidBridgePromise.showSingleSelection 不可用,使用默认校区");
- return defaultId;
- }
- const labels = campuses.map((c) => c.name);
- const defaultIndex = Math.max(0, campuses.findIndex((c) => c.id === defaultId));
- let selectedIndex;
- try {
- selectedIndex = await window.AndroidBridgePromise.showSingleSelection(
- "检测到多个校区且作息不同,请选择要导出的校区",
- JSON.stringify(labels),
- defaultIndex
- );
- } catch (error) {
- console.error("校区选择失败,使用默认校区:", error);
- return defaultId;
- }
- if (selectedIndex === null) {
- console.log("用户取消校区选择,终止导入");
- return CANCELED;
- }
- const chosen = campuses[selectedIndex];
- if (!chosen) return defaultId;
- console.log("用户选择校区:", chosen.name, chosen.id);
- return chosen.id;
- }
- // 课程实际用到的最大节次
- function maxSectionFromRows(rows) {
- let max = 0;
- rows.forEach((r) => {
- const d = parseInt(r.djc, 10);
- if (Number.isFinite(d)) max = Math.max(max, d);
- });
- return max;
- }
- function maxSectionFromCourses(courses) {
- let max = 0;
- courses.forEach((c) => {
- if (Number.isFinite(c.endSection)) max = Math.max(max, c.endSection);
- });
- return max;
- }
- // ===== 不在课表页时的自动处理 =====
- // 首页/选课页等没有课表 DOM:抓一次课表页 HTML 拿 xhid/xqdm/当前学期/学期列表,
- // 后续全部走接口,无需跳转
- function parseSchedulePageSessionFromHtml(html) {
- const inputVal = (id) => {
- const m = html.match(new RegExp('<input[^>]*id="' + id + '"[^>]*value="([^"]*)"'));
- return m ? m[1].trim() : "";
- };
- const xhid = inputVal("xhid");
- const xqdm = inputVal("xqdm");
- const xnxq = inputVal("xnxq");
- const semesterOptions = [];
- const selMatch = html.match(/<select[^>]*id="xnxq1"[^>]*>([\s\S]*?)<\/select>/);
- if (selMatch) {
- const optRe = /<option value="([^"]*)"([^>]*)>([^<]*)<\/option>/g;
- let m;
- while ((m = optRe.exec(selMatch[1]))) {
- const value = m[1].trim();
- const text = m[3].trim();
- if (value && text) semesterOptions.push({ value, text });
- }
- }
- if (!xhid || !xqdm || !semesterOptions.length) return null;
- return { xhid, xqdm, xnxq, semesterOptions };
- }
- async function fetchSchedulePageSession() {
- const url = "/admin/xsd/pkgl/xskb/queryKbForXsd";
- console.log("自动抓取课表页参数:", url);
- try {
- const resp = await fetch(url, { credentials: "same-origin" });
- if (!resp.ok) return null;
- const html = await resp.text();
- const session = parseSchedulePageSessionFromHtml(html);
- if (session) {
- console.log(
- "已获取课表页参数: 当前学期=",
- session.xnxq,
- "学期数=",
- session.semesterOptions.length
- );
- }
- return session;
- } catch (error) {
- console.error("自动抓取课表页参数失败:", error);
- return null;
- }
- }
- // 用抓取到的参数构造一个最小“页面对象”,复用现有读学期/读 xhid/xqdm 的逻辑
- function makeSessionDoc(session) {
- return {
- querySelectorAll: (sel) =>
- sel === "#xnxq1 option"
- ? session.semesterOptions.map((o) => ({
- getAttribute: (a) => (a === "value" ? o.value : null),
- textContent: o.text,
- }))
- : [],
- querySelector: (sel) => {
- if (sel === "#xnxq") return { value: session.xnxq, textContent: session.xnxq };
- if (sel === "#xhid") return { value: session.xhid };
- if (sel === "#xqdm") return { value: session.xqdm };
- return null;
- },
- };
- }
- // 收尾:教师工号询问 -> 重复课程询问 -> 保存(任一步取消即终止)
- async function finalizeAndSave(courses, timeSlots, semesterConfig) {
- // 教师姓名带工号时,弹窗询问是否去除(默认保留)
- const teacherIdChoice = await askKeepTeacherId(courses);
- if (teacherIdChoice === CANCELED) {
- toast("已取消,终止导入");
- return;
- }
- if (teacherIdChoice === "strip") {
- courses.forEach((course) => {
- course.teacher = stripTeacherId(course.teacher);
- });
- }
- // 内容完全一致的重复/冲突课程:弹窗询问如何处理(全部保留/去重/合并教师)
- const dupChoice = await askHandleDuplicateCourses(courses);
- if (dupChoice === CANCELED) {
- toast("已取消,终止导入");
- return;
- }
- if (dupChoice === "dedupe") {
- toast("已去除重复课程");
- } else if (dupChoice === "merge") {
- toast("已合并重复课程的教师");
- }
- try {
- const result = await window.AndroidBridgePromise.saveImportedCourses(
- JSON.stringify(courses)
- );
- if (result === true) {
- if (timeSlots.length) {
- await window.AndroidBridgePromise.savePresetTimeSlots(
- JSON.stringify(timeSlots)
- );
- }
- if (
- semesterConfig &&
- window.AndroidBridgePromise &&
- typeof window.AndroidBridgePromise.saveCourseConfig === "function"
- ) {
- await window.AndroidBridgePromise.saveCourseConfig(
- JSON.stringify({
- semesterStartDate: semesterConfig.semesterStartDate,
- semesterTotalWeeks: semesterConfig.semesterTotalWeeks,
- })
- );
- }
- toast("课表导出成功");
- window.AndroidBridge.notifyTaskCompletion();
- } else {
- toast("课表导出失败,请查看控制台日志");
- }
- } catch (error) {
- toast("导出失败: " + error.message);
- }
- }
- async function run() {
- const doc = await getTargetDocument();
- let session = null;
- if (!doc) {
- // 不在“我的课表”页面(首页/选课页等):自动抓课表页参数(xhid/xqdm/学期列表),全程走接口
- session = await fetchSchedulePageSession();
- if (!session) {
- toast("未找到课表页面,请手动打开“我的课表”后重试");
- return;
- }
- }
- // 获取课表前先询问要导出的学期(不在课表页时用抓取到的学期列表)
- const semDoc = doc || makeSessionDoc(session);
- const chosenXnxq = await askChooseSemester(semDoc);
- if (chosenXnxq === CANCELED) {
- toast("已取消,终止导入");
- return;
- }
- const currentXnxq = getCurrentXnxq(semDoc);
- const exportXnxq = chosenXnxq || currentXnxq;
- // 统一走接口拿课程(xqdm 不影响课程列表,先用默认校区)
- const rows = await fetchScheduleRows(semDoc, exportXnxq);
- if (rows && rows.length) {
- const maxSection = maxSectionFromRows(rows);
- const campusId = await resolveCampusId(semDoc, exportXnxq, maxSection);
- if (campusId === CANCELED) {
- toast("已取消,终止导入");
- return;
- }
- const courses = parseCoursesFromRows(rows);
- if (!courses.length) {
- toast("未解析到课程,请确认课表已加载完成");
- return;
- }
- const semesterConfig = await fetchSemesterConfig(semDoc, exportXnxq, campusId);
- // 时间段:优先接口 jcsjszList;课表页时间列作补充
- let timeSlots = (semesterConfig && semesterConfig.timeSlots) || [];
- if (!timeSlots.length && doc) timeSlots = parseTimeSlots(doc);
- toast("已获取 " + exportXnxq + " 的课表");
- await finalizeAndSave(courses, timeSlots, semesterConfig);
- return;
- }
- // 接口拿不到且当前就在课表页:回退解析页面已渲染的 DOM
- if (doc) {
- await waitForScheduleData(doc);
- const courses = parseScheduleFromDocument(doc);
- if (courses.length) {
- const maxSection = maxSectionFromCourses(courses);
- const campusId = await resolveCampusId(semDoc, exportXnxq, maxSection);
- if (campusId === CANCELED) {
- toast("已取消,终止导入");
- return;
- }
- const semesterConfig = await fetchSemesterConfig(semDoc, exportXnxq, campusId);
- let timeSlots = parseTimeSlots(doc);
- if (!timeSlots.length) {
- timeSlots = (semesterConfig && semesterConfig.timeSlots) || [];
- }
- toast("已获取 " + exportXnxq + " 的课表(页面数据)");
- await finalizeAndSave(courses, timeSlots, semesterConfig);
- return;
- }
- }
- toast("所选学期没有可导出的课表数据(" + exportXnxq + ")");
- }
- run();
- })();
|