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

feat: 添加青海师范大学研究生课表适配 (#584)

Ming пре 2 недеља
родитељ
комит
a214870fdc
3 измењених фајлова са 700 додато и 0 уклоњено
  1. 5 0
      index/root_index.yaml
  2. 9 0
      resources/QHNU/adapters.yaml
  3. 686 0
      resources/QHNU/qhnu_01.js

+ 5 - 0
index/root_index.yaml

@@ -897,3 +897,8 @@ schools:
     name: "西安交通大学"
     initial: "X"
     resource_folder: "XJTU"
+
+  - id: "QHNU"
+    name: "青海师范大学"
+    initial: "Q"
+    resource_folder: "QHNU"

+ 9 - 0
resources/QHNU/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/QHNU/adapters.yaml
+adapters:
+  - adapter_id: "QHNU_01"
+    adapter_name: "青海师范大学研究生课表导入"
+    category: "POSTGRADUATE"
+    asset_js_path: "qhnu_01.js"
+    import_url: "http://one.qhnu.edu.cn/default/portal/index.jsp"
+    maintainer: "shixiaogaoya"
+    description: "完成统一身份认证并进入研究生教务系统后执行导入,在弹窗中选择目标学期。"

+ 686 - 0
resources/QHNU/qhnu_01.js

@@ -0,0 +1,686 @@
+// 青海师范大学研究生教务系统拾光课表适配脚本
+// 使用研究生教务系统的同源学期与课表接口,不处理账号密码或统一身份认证。
+
+(function () {
+    "use strict";
+
+    const EXPECTED_ORIGIN = "https://yjsxt.qhnu.edu.cn";
+    const SEMESTER_PATH = "/adminApi/kernel/config/xqgl/NoAuth";
+    const SCHEDULE_PATH = "/adminApi/cloud-jx/api/xs/stuxkjg/NotAuth";
+    // 来自学校公开前端配置。只读取这一项,不枚举或记录浏览器存储。
+    const TOKEN_STORAGE_KEY = "sys-auth-token";
+
+    const COMMON_TIME_SLOTS = {
+        1: { start: "08:30", end: "09:15" },
+        2: { start: "09:25", end: "10:10" },
+        3: { start: "10:30", end: "11:15" },
+        4: { start: "11:25", end: "12:10" }
+    };
+
+    const CAMPUS_TIME_SLOTS = {
+        chengbei: Object.assign({}, COMMON_TIME_SLOTS, {
+            5: { start: "14:00", end: "14:45" },
+            6: { start: "14:55", end: "15:40" },
+            7: { start: "15:50", end: "16:35" },
+            8: { start: "16:45", end: "17:30" },
+            9: { start: "19:30", end: "20:15" },
+            10: { start: "20:25", end: "21:10" }
+        }),
+        chengxi: Object.assign({}, COMMON_TIME_SLOTS, {
+            5: { start: "14:30", end: "15:15" },
+            6: { start: "15:25", end: "16:10" },
+            7: { start: "16:30", end: "17:15" },
+            8: { start: "17:25", end: "18:10" },
+            9: { start: "19:10", end: "19:55" }
+        })
+    };
+
+    const CHENGBEI_PRESET_TIME_SLOTS = Object.keys(CAMPUS_TIME_SLOTS.chengbei).map(function (number) {
+        const sectionNumber = Number(number);
+        const slot = CAMPUS_TIME_SLOTS.chengbei[sectionNumber];
+        return {
+            number: sectionNumber,
+            startTime: slot.start,
+            endTime: slot.end
+        };
+    }).sort(function (left, right) {
+        return left.number - right.number;
+    });
+
+    const WEEKDAY_MAP = {
+        "一": 1,
+        "二": 2,
+        "三": 3,
+        "四": 4,
+        "五": 5,
+        "六": 6,
+        "日": 7,
+        "天": 7
+    };
+
+    function showToast(message) {
+        if (window.shiguangBridge && typeof window.shiguangBridge.showToast === "function") {
+            window.shiguangBridge.showToast(message);
+        }
+    }
+
+    async function showAlert(title, message, buttonText) {
+        if (!window.shiguangBridgePromise || typeof window.shiguangBridgePromise.showAlert !== "function") {
+            throw new Error("当前环境不支持拾光课表导入弹窗,请在拾光课程表中重试。");
+        }
+        return await window.shiguangBridgePromise.showAlert(title, message, buttonText || "确定");
+    }
+
+    function assertSystemOrigin() {
+        if (window.location.origin !== EXPECTED_ORIGIN) {
+            throw new Error("请先进入青海师范大学研究生教务系统后再导入。");
+        }
+    }
+
+    function getRuntimeToken() {
+        let token = null;
+        try {
+            token = window.localStorage.getItem(TOKEN_STORAGE_KEY);
+        } catch (error) {
+            throw new Error("无法读取登录状态,请重新登录研究生教务系统后重试。");
+        }
+        if (typeof token !== "string" || token.trim() === "") {
+            throw new Error("未检测到有效登录状态,请重新登录研究生教务系统后重试。");
+        }
+        return token.trim();
+    }
+
+    async function requestJson(requestUrl, runtimeToken, resourceLabel) {
+        let response;
+        try {
+            response = await fetch(requestUrl.toString(), {
+                method: "GET",
+                credentials: "include",
+                headers: {
+                    "Accept": "application/json",
+                    "Authorization": "Bearer " + runtimeToken
+                }
+            });
+        } catch (error) {
+            throw new Error(resourceLabel + "请求未能发送,请检查网络后重试。");
+        }
+
+        if (response.status === 401 || response.status === 403) {
+            throw new Error("登录状态已失效或无" + resourceLabel + "访问权限,请重新登录研究生教务系统后重试。");
+        }
+        if (!response.ok) {
+            throw new Error(resourceLabel + "请求失败(HTTP " + response.status + "),请稍后重试。");
+        }
+
+        const responseText = await response.text();
+        if (/^\s*</.test(responseText)) {
+            throw new Error(resourceLabel + "接口返回了登录页面,请重新登录后重试。");
+        }
+
+        try {
+            return JSON.parse(responseText);
+        } catch (error) {
+            throw new Error(resourceLabel + "接口未返回有效 JSON,学校系统接口可能已调整。");
+        }
+    }
+
+    async function fetchAllSemesterOptions(runtimeToken) {
+        return await fetchAllPages(
+            async function (page) {
+                const requestUrl = new URL(SEMESTER_PATH, EXPECTED_ORIGIN);
+                requestUrl.searchParams.set("page", String(page));
+                requestUrl.searchParams.set("size", "999");
+                requestUrl.searchParams.set("sort", "xqdm,desc");
+                const payload = await requestJson(requestUrl, runtimeToken, "学期列表");
+                const pageResult = validatePageResponse(payload, "学期列表");
+                return {
+                    totalElements: pageResult.totalElements,
+                    content: pageResult.content.map(function (semester, index) {
+                        if (!semester || typeof semester !== "object" || Array.isArray(semester)) {
+                            throw new Error("学期列表第 " + (index + 1) + " 项结构异常。");
+                        }
+                        return {
+                            id: requireText(semester.id, "学期列表第 " + (index + 1) + " 项 id"),
+                            xqmc: requireText(semester.xqmc, "学期列表第 " + (index + 1) + " 项名称"),
+                            sfdq: semester.sfdq === null || semester.sfdq === undefined ? "" : String(semester.sfdq).trim()
+                        };
+                    })
+                };
+            },
+            function (semester) { return semester.id; },
+            {
+                empty: "学期列表为空,无法继续导入。",
+                totalChanged: "学期列表分页总数发生变化,请稍后重试。",
+                emptyPage: "学期列表在读取完成前返回空页,请稍后重试。",
+                duplicate: "学期列表出现重复 id,请稍后重试。",
+                overflow: "学期列表记录数超过接口声明总数,请稍后重试。",
+                incomplete: "学期列表未完整返回,请稍后重试。"
+            }
+        );
+    }
+
+    async function selectSemester(runtimeToken) {
+        const semesters = await fetchAllSemesterOptions(runtimeToken);
+        const currentSemester = semesters.find(function (semester) {
+            return String(semester.sfdq) === "0";
+        });
+        const seasonOrder = { "秋": 0, "夏": 1, "春": 2 };
+        const semesterEntries = semesters.map(function (semester, originalIndex) {
+            const match = /^(\d{4})年(春|夏|秋)季学期$/.exec(semester.xqmc);
+            return {
+                semester: semester,
+                originalIndex: originalIndex,
+                year: match ? Number(match[1]) : null,
+                season: match ? match[2] : null
+            };
+        }).sort(function (left, right) {
+            if (left.year !== null && right.year === null) return -1;
+            if (left.year === null && right.year !== null) return 1;
+            if (left.year !== null && right.year !== null) {
+                if (left.year !== right.year) return right.year - left.year;
+                const seasonDifference = seasonOrder[left.season] - seasonOrder[right.season];
+                if (seasonDifference !== 0) return seasonDifference;
+            }
+            return left.originalIndex - right.originalIndex;
+        });
+        const defaultIndex = currentSemester ? semesterEntries.findIndex(function (entry) {
+            return entry.semester === currentSemester;
+        }) : 0;
+        const selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
+            "请选择导入学期",
+            JSON.stringify(semesterEntries.map(function (entry) {
+                return entry.semester === currentSemester ?
+                    entry.semester.xqmc + "(当前)" : entry.semester.xqmc;
+            })),
+            defaultIndex
+        );
+        if (selectedIndex === null || selectedIndex === undefined || selectedIndex === -1) return null;
+        if (!Number.isInteger(selectedIndex) || selectedIndex < 0 || selectedIndex >= semesterEntries.length) {
+            throw new Error("无法识别所选学期,请重新导入。");
+        }
+        return semesterEntries[selectedIndex].semester;
+    }
+
+    async function fetchAllScheduleRecords(semesterCode, runtimeToken) {
+        return await fetchAllPages(
+            async function (page) {
+                const requestUrl = new URL(SCHEDULE_PATH, EXPECTED_ORIGIN);
+                requestUrl.searchParams.set("page", String(page));
+                requestUrl.searchParams.set("size", "999");
+                requestUrl.searchParams.set("sort", "createTime,desc");
+                requestUrl.searchParams.set("xqcode", semesterCode);
+                const payload = await requestJson(requestUrl, runtimeToken, "课表");
+                return validatePageResponse(payload, "课表");
+            },
+            function (record) { return JSON.stringify(record); },
+            {
+                empty: "所选学期暂无课程,未保存空课表。",
+                totalChanged: "课表分页总数发生变化,请稍后重新导入。",
+                emptyPage: "课表分页在读取完成前返回空页,请稍后重新导入。",
+                duplicate: "课表分页未继续前进,请稍后重新导入。",
+                overflow: "课表分页记录数超过接口声明总数,请稍后重新导入。",
+                incomplete: "课表记录未完整返回,请稍后重新导入。"
+            }
+        );
+    }
+
+    async function fetchAllPages(fetchPage, getSignature, messages) {
+        const records = [];
+        const seenSignatures = new Set();
+        let expectedTotal = null;
+        let page = 0;
+
+        while (expectedTotal === null || records.length < expectedTotal) {
+            const pageResult = await fetchPage(page);
+            if (expectedTotal === null) {
+                expectedTotal = pageResult.totalElements;
+                if (expectedTotal === 0 && pageResult.content.length === 0) {
+                    throw new Error(messages.empty);
+                }
+            } else if (pageResult.totalElements !== expectedTotal) {
+                throw new Error(messages.totalChanged);
+            }
+            if (pageResult.content.length === 0) {
+                throw new Error(messages.emptyPage);
+            }
+
+            pageResult.content.forEach(function (record) {
+                const signature = getSignature(record);
+                if (seenSignatures.has(signature)) {
+                    throw new Error(messages.duplicate);
+                }
+                seenSignatures.add(signature);
+                records.push(record);
+            });
+            if (records.length > expectedTotal) {
+                throw new Error(messages.overflow);
+            }
+            page += 1;
+        }
+        if (records.length !== expectedTotal) {
+            throw new Error(messages.incomplete);
+        }
+        return records;
+    }
+
+    function validatePageResponse(payload, resourceLabel) {
+        const label = resourceLabel || "课表";
+        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
+            throw new Error(label + "接口返回结构异常:响应根节点不是对象。");
+        }
+        if (!Array.isArray(payload.content)) {
+            throw new Error(label + "接口返回结构异常:缺少 content 数组。");
+        }
+        const totalElements = payload.totalElements;
+        if (!Number.isInteger(totalElements) || totalElements < 0) {
+            throw new Error(label + "接口返回结构异常:totalElements 无效。");
+        }
+        return {
+            content: payload.content,
+            totalElements: totalElements
+        };
+    }
+
+    function toPositiveInteger(value, fieldLabel) {
+        const number = Number(value);
+        if (!Number.isInteger(number) || number <= 0) {
+            throw new Error(fieldLabel + "不是有效正整数。");
+        }
+        return number;
+    }
+
+    function uniqueSortedPositiveIntegers(values, fieldLabel) {
+        const result = Array.from(new Set(values.map(function (value) {
+            return toPositiveInteger(value, fieldLabel);
+        }))).sort(function (left, right) {
+            return left - right;
+        });
+        if (result.length === 0) {
+            throw new Error(fieldLabel + "为空。");
+        }
+        return result;
+    }
+
+    function parseExplicitWeeks(value, fieldLabel) {
+        const normalized = normalizeDigits(value === null || value === undefined ? "" : value);
+        const matches = normalized.match(/\d+/g);
+        if (!matches) {
+            throw new Error(fieldLabel + "无法解析。");
+        }
+        return uniqueSortedPositiveIntegers(matches, fieldLabel);
+    }
+
+    function parseWeeks(schedule, courseLabel) {
+        const mode = String(schedule.lxfs);
+        const fieldLabel = "课程“" + courseLabel + "”的周次";
+        if (mode === "3" || mode === "4") {
+            return parseExplicitWeeks(schedule.skzc_Dm, fieldLabel);
+        }
+        if (mode !== "0" && mode !== "1" && mode !== "2") {
+            throw new Error(fieldLabel + "模式无法识别。");
+        }
+
+        const startWeek = toPositiveInteger(schedule.kszc, fieldLabel + "起始值");
+        const endWeek = toPositiveInteger(schedule.jszc, fieldLabel + "结束值");
+        if (endWeek < startWeek) {
+            throw new Error(fieldLabel + "起止范围无效。");
+        }
+
+        const weeks = [];
+        for (let week = startWeek; week <= endWeek; week += 1) {
+            if (mode === "1" && week % 2 === 0) continue;
+            if (mode === "2" && week % 2 !== 0) continue;
+            weeks.push(week);
+        }
+        return uniqueSortedPositiveIntegers(weeks, fieldLabel);
+    }
+
+    function normalizeDigits(value) {
+        return String(value).replace(/[0-9]/g, function (digit) {
+            return String.fromCharCode(digit.charCodeAt(0) - 65248);
+        });
+    }
+
+    function expandSectionExpression(expression, courseLabel) {
+        const normalized = normalizeDigits(expression).replace(/\s+/g, "");
+        const pieces = normalized.split(/[,,、]/).filter(Boolean);
+        const sections = [];
+
+        pieces.forEach(function (piece) {
+            const range = piece.match(/^(\d+)[\-~~至](\d+)$/);
+            if (range) {
+                const start = toPositiveInteger(range[1], "课程“" + courseLabel + "”的节次");
+                const end = toPositiveInteger(range[2], "课程“" + courseLabel + "”的节次");
+                if (end < start) {
+                    throw new Error("课程“" + courseLabel + "”的节次范围无效。");
+                }
+                for (let section = start; section <= end; section += 1) sections.push(section);
+                return;
+            }
+            if (!/^\d+$/.test(piece)) {
+                throw new Error("课程“" + courseLabel + "”的节次无法解析。");
+            }
+            sections.push(toPositiveInteger(piece, "课程“" + courseLabel + "”的节次"));
+        });
+        return uniqueSortedPositiveIntegers(sections, "课程“" + courseLabel + "”的节次");
+    }
+
+    function groupContiguousSections(sections) {
+        const groups = [];
+        sections.forEach(function (section) {
+            const last = groups[groups.length - 1];
+            if (last && section === last.end + 1) {
+                last.end = section;
+            } else {
+                groups.push({ start: section, end: section });
+            }
+        });
+        return groups;
+    }
+
+    function parseScheduleFragments(value, courseLabel) {
+        const text = normalizeDigits(value || "");
+        const pattern = /星期([一二三四五六日天])[^0-9星期]*([0-9]+(?:\s*(?:[,,、\-~~至])\s*[0-9]+)*)\s*节/g;
+        const weekdayOccurrences = text.match(/星期[一二三四五六日天]/g) || [];
+        const fragments = [];
+        let matchedOccurrences = 0;
+        let match;
+
+        while ((match = pattern.exec(text)) !== null) {
+            matchedOccurrences += 1;
+            const day = WEEKDAY_MAP[match[1]];
+            const groups = groupContiguousSections(expandSectionExpression(match[2], courseLabel));
+            groups.forEach(function (group) {
+                fragments.push({ day: day, startSection: group.start, endSection: group.end });
+            });
+        }
+        if (fragments.length === 0 || matchedOccurrences !== weekdayOccurrences.length) {
+            throw new Error("课程“" + courseLabel + "”的星期或节次无法从 sjms 解析。");
+        }
+        return fragments;
+    }
+
+    function detectCampus(position) {
+        const hasChengbei = position.indexOf("城北校区") >= 0;
+        const hasChengxi = position.indexOf("城西校区") >= 0;
+        if (hasChengbei && hasChengxi) return "ambiguous";
+        if (hasChengbei) return "chengbei";
+        if (hasChengxi) return "chengxi";
+        return "unknown";
+    }
+
+    function applyCustomTime(position, startSection, endSection, courseLabel) {
+        const campus = detectCampus(position);
+        let slots;
+        if (campus === "ambiguous") {
+            throw new Error("课程“" + courseLabel + "”的地点同时包含两个校区,无法确定作息。");
+        }
+        if (campus === "unknown") {
+            if (startSection > 4 || endSection > 4) {
+                throw new Error("课程“" + courseLabel + "”第 5 节以后未标明校区,无法确定实际时间。");
+            }
+            slots = COMMON_TIME_SLOTS;
+        } else {
+            slots = CAMPUS_TIME_SLOTS[campus];
+        }
+
+        const startSlot = slots[startSection];
+        const endSlot = slots[endSection];
+        if (!startSlot || !endSlot) {
+            if (campus === "chengxi" && (startSection === 10 || endSection === 10)) {
+                throw new Error("课程“" + courseLabel + "”使用城西校区第 10 节,但该节时间尚未确认。");
+            }
+            throw new Error("课程“" + courseLabel + "”包含未收录的节次时间。");
+        }
+        return {
+            isCustomTime: true,
+            customStartTime: startSlot.start,
+            customEndTime: endSlot.end
+        };
+    }
+
+    function requireText(value, fieldLabel) {
+        if (typeof value !== "string" || value.trim() === "") {
+            throw new Error(fieldLabel + "为空。");
+        }
+        return value.trim();
+    }
+
+    function parseCourses(records) {
+        const courses = [];
+        records.forEach(function (record, recordIndex) {
+            if (!record || typeof record !== "object" || Array.isArray(record)) {
+                throw new Error("第 " + (recordIndex + 1) + " 条课程记录结构异常。");
+            }
+            const fallbackName = record.pkxxwh && record.pkxxwh.kcMc;
+            const primaryName = typeof record.kcmc === "string" ? record.kcmc.trim() : record.kcmc;
+            const name = requireText(primaryName || fallbackName, "第 " + (recordIndex + 1) + " 条课程名称");
+            if (!record.pkxxwh || !Array.isArray(record.pkxxwh.jss) || record.pkxxwh.jss.length === 0) {
+                throw new Error("课程“" + name + "”缺少授课安排。");
+            }
+
+            record.pkxxwh.jss.forEach(function (schedule) {
+                if (!schedule || typeof schedule !== "object" || Array.isArray(schedule)) {
+                    throw new Error("课程“" + name + "”的授课安排结构异常。");
+                }
+                const teacher = requireText(schedule.js_Xm, "课程“" + name + "”的教师");
+                const position = requireText(schedule.jsmc, "课程“" + name + "”的地点");
+                const weeks = parseWeeks(schedule, name);
+                const fragments = parseScheduleFragments(schedule.sjms, name);
+
+                fragments.forEach(function (fragment) {
+                    const customTime = applyCustomTime(position, fragment.startSection, fragment.endSection, name);
+                    courses.push({
+                        name: name,
+                        teacher: teacher,
+                        position: position,
+                        day: fragment.day,
+                        startSection: fragment.startSection,
+                        endSection: fragment.endSection,
+                        weeks: weeks.slice(),
+                        isCustomTime: customTime.isCustomTime,
+                        customStartTime: customTime.customStartTime,
+                        customEndTime: customTime.customEndTime
+                    });
+                });
+            });
+        });
+        return dedupeAndSortCourses(courses);
+    }
+
+    function dedupeAndSortCourses(courses) {
+        const seen = new Set();
+        const result = [];
+        courses.forEach(function (course) {
+            const key = JSON.stringify([
+                course.name, course.teacher, course.position, course.day,
+                course.startSection, course.endSection, course.weeks,
+                course.customStartTime, course.customEndTime
+            ]);
+            if (!seen.has(key)) {
+                seen.add(key);
+                result.push(course);
+            }
+        });
+        return result.sort(function (left, right) {
+            return left.day - right.day ||
+                left.startSection - right.startSection ||
+                left.endSection - right.endSection ||
+                left.name.localeCompare(right.name, "zh-CN") ||
+                left.teacher.localeCompare(right.teacher, "zh-CN") ||
+                left.position.localeCompare(right.position, "zh-CN");
+        });
+    }
+
+    function validateDateString(value) {
+        if (typeof value !== "string") return null;
+        const match = value.trim().match(/^(\d{4})-(\d{2})-(\d{2})/);
+        if (!match) return null;
+        const year = Number(match[1]);
+        const month = Number(match[2]);
+        const day = Number(match[3]);
+        const date = new Date(Date.UTC(year, month - 1, day));
+        if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
+            return null;
+        }
+        return match[1] + "-" + match[2] + "-" + match[3];
+    }
+
+    function getConsistentSemester(records) {
+        const candidates = records.map(function (record) {
+            return record && record.xqEntity;
+        }).filter(function (semester) {
+            return semester && typeof semester === "object" && !Array.isArray(semester);
+        });
+        if (candidates.length === 0) return {};
+
+        const result = {};
+        ["ksrq", "jsrq", "xqmc"].forEach(function (field) {
+            const values = candidates.map(function (candidate) {
+                return candidate[field] === null || candidate[field] === undefined ? "" : String(candidate[field]).trim();
+            }).filter(Boolean);
+            const expected = values[0] || "";
+            candidates.forEach(function (candidate) {
+                const actual = candidate[field] === null || candidate[field] === undefined ? "" : String(candidate[field]).trim();
+                if (expected && actual && expected !== actual) {
+                    throw new Error("课表记录中的学期信息不一致,请稍后重新导入。");
+                }
+            });
+            if (expected) result[field] = expected;
+        });
+        return result;
+    }
+
+    function buildCourseConfig(records, courses, selectedSemesterName) {
+        const semester = getConsistentSemester(records);
+        const startDate = validateDateString(semester.ksrq);
+        const endDate = validateDateString(semester.jsrq);
+        const maxCourseWeek = courses.reduce(function (maximum, course) {
+            return Math.max(maximum, course.weeks[course.weeks.length - 1] || 0);
+        }, 0);
+        let dateRangeWeeks = 0;
+        if (startDate && endDate) {
+            const startTime = Date.parse(startDate + "T00:00:00Z");
+            const endTime = Date.parse(endDate + "T00:00:00Z");
+            if (endTime >= startTime) {
+                dateRangeWeeks = Math.ceil((endTime - startTime + 86400000) / 604800000);
+            }
+        }
+        const totalWeeks = Math.max(maxCourseWeek, dateRangeWeeks);
+        if (totalWeeks <= 0) {
+            throw new Error("无法确定学期总周数。");
+        }
+
+        const config = {
+            semesterTotalWeeks: totalWeeks,
+            firstDayOfWeek: 1,
+            defaultClassDuration: 45,
+            defaultBreakDuration: 10
+        };
+        if (startDate) config.semesterStartDate = startDate;
+        return {
+            config: config,
+            semesterName: typeof semester.xqmc === "string" && semester.xqmc.trim() ? semester.xqmc.trim() :
+                (selectedSemesterName || "当前查询学期")
+        };
+    }
+
+    function validateCourses(courses) {
+        if (!Array.isArray(courses) || courses.length === 0) {
+            throw new Error("没有解析到可导入的课程时段。");
+        }
+        courses.forEach(function (course) {
+            if (!Number.isInteger(course.day) || course.day < 1 || course.day > 7 ||
+                !Number.isInteger(course.startSection) || !Number.isInteger(course.endSection) ||
+                course.startSection <= 0 || course.endSection < course.startSection ||
+                !Array.isArray(course.weeks) || course.weeks.length === 0 ||
+                course.isCustomTime !== true ||
+                !/^\d{2}:\d{2}$/.test(course.customStartTime) ||
+                !/^\d{2}:\d{2}$/.test(course.customEndTime)) {
+                throw new Error("课程“" + course.name + "”的导入字段校验失败。");
+            }
+        });
+    }
+
+    function buildCampusSummary(courses) {
+        const campusLabels = new Set();
+        courses.forEach(function (course) {
+            const campus = detectCampus(course.position);
+            if (campus === "chengbei") campusLabels.add("城北校区");
+            if (campus === "chengxi") campusLabels.add("城西校区");
+            if (campus === "unknown") campusLabels.add("未标明校区(仅共同时间节次)");
+        });
+        return Array.from(campusLabels).join("、");
+    }
+
+    function assertBridgeCapabilities() {
+        const promiseBridge = window.shiguangBridgePromise;
+        const bridge = window.shiguangBridge;
+        if (!promiseBridge || typeof promiseBridge.showAlert !== "function" ||
+            typeof promiseBridge.showSingleSelection !== "function" ||
+            typeof promiseBridge.saveCourseConfig !== "function" ||
+            typeof promiseBridge.savePresetTimeSlots !== "function" ||
+            typeof promiseBridge.saveImportedCourses !== "function" ||
+            !bridge || typeof bridge.notifyTaskCompletion !== "function") {
+            throw new Error("当前拾光课表版本不支持所需导入接口,请更新应用后重试。");
+        }
+    }
+
+    async function saveToApp(config, courses) {
+        const configResult = await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
+        if (configResult !== true) {
+            throw new Error("课表配置保存失败,课程尚未保存。");
+        }
+        const presetResult = await window.shiguangBridgePromise.savePresetTimeSlots(
+            JSON.stringify(CHENGBEI_PRESET_TIME_SLOTS)
+        );
+        if (presetResult !== true) {
+            throw new Error("城北基准时间轴保存失败;课表配置可能已更新,课程尚未保存。");
+        }
+        const courseResult = await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
+        if (courseResult !== true) {
+            throw new Error("课程保存失败;课表配置和城北基准时间轴可能已更新,请检查应用内课表后重试。");
+        }
+    }
+
+    async function runImportFlow() {
+        try {
+            assertSystemOrigin();
+            assertBridgeCapabilities();
+            const runtimeToken = getRuntimeToken();
+            const selectedSemester = await selectSemester(runtimeToken);
+            if (!selectedSemester) return;
+
+            showToast("正在读取青海师范大学研究生课表...");
+            const records = await fetchAllScheduleRecords(selectedSemester.id, runtimeToken);
+            const courses = parseCourses(records);
+            validateCourses(courses);
+            const semesterResult = buildCourseConfig(records, courses, selectedSemester.xqmc);
+
+            const shouldSave = await showAlert(
+                "确认导入课程表",
+                "目标学期:" + semesterResult.semesterName + "\n" +
+                "课程时段:" + courses.length + " 个\n" +
+                "涉及校区:" + buildCampusSummary(courses) + "\n\n" +
+                "节次网格以城北作息为基准,城西课程仍按实际时间显示。\n" +
+                "确认后将保存课表配置、基准时间轴和课程。",
+                "确认导入"
+            );
+            if (!shouldSave) return;
+
+            await saveToApp(semesterResult.config, courses);
+            showToast("导入成功,共保存 " + courses.length + " 个课程时段。");
+            window.shiguangBridge.notifyTaskCompletion();
+        } catch (error) {
+            const message = error && error.message ? error.message : "未知错误";
+            showToast("导入失败:" + message);
+            try {
+                await showAlert("导入失败", message, "我知道了");
+            } catch (alertError) {
+                // 没有可用弹窗时仅保留 Toast;不输出可能携带外部数据的错误对象。
+            }
+        }
+    }
+
+    runImportFlow();
+})();