Преглед изворни кода

Merge pull request #436 from XingHeYuZhuan/pending

星河欲转 пре 5 часа
родитељ
комит
4948311469
2 измењених фајлова са 1177 додато и 0 уклоњено
  1. 16 0
      resources/WBU/adapters.yaml
  2. 1161 0
      resources/WBU/wbu_02.js

+ 16 - 0
resources/WBU/adapters.yaml

@@ -6,3 +6,19 @@ adapters:
     import_url: "https://webvpn.wbu.edu.cn/"   # 这里放真实登录页
     maintainer: "Foraes"
     description: "武汉商学院教务系统课表导入适配"
+
+  - adapter_id: "WBU_02"
+    adapter_name: "武汉商学院-校园网访问"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "wbu_02.js"
+    import_url: "https://jwxt.wbu.edu.cn/"
+    maintainer: "HJWqa"
+    description: "武汉商学院教务系统课表导入适配"
+
+  - adapter_id: "WBU_03"
+    adapter_name: "武汉商学院-WebVPN访问"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "wbu_02.js"
+    import_url: "https://webvpn.wbu.edu.cn/"
+    maintainer: "HJWqa"
+    description: "武汉商学院教务系统课表导入适配"

+ 1161 - 0
resources/WBU/wbu_02.js

@@ -0,0 +1,1161 @@
+// 适配目标:武汉商学院-超星综合教学管理系统
+//
+// 逻辑概览:
+// 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();
+})();