Procházet zdrojové kódy

feat(SMU): 新增南方医科大学乘方教务适配 (#901)

nekojackie před 1 týdnem
rodič
revize
78c4b3104b
3 změnil soubory, kde provedl 541 přidání a 0 odebrání
  1. 5 0
      index/root_index.yaml
  2. 9 0
      resources/SMU/adapters.yaml
  3. 527 0
      resources/SMU/smu_01.js

+ 5 - 0
index/root_index.yaml

@@ -1342,4 +1342,9 @@ schools:
     name: "西安石油大学"
     initial: "X"
     resource_folder: "XSYU"
+    
+  - id: "SMU"
+    name: "南方医科大学"
+    initial: "N"
+    resource_folder: "SMU"
     

+ 9 - 0
resources/SMU/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/SMU/adapters.yaml
+adapters:
+  - adapter_id: "SMU"
+    adapter_name: "南方医科大学乘方教务"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "smu_01.js"
+    import_url: "https://zhjw.smu.edu.cn"
+    maintainer: "NekoJackie"
+    description: "南方医科大学乘方教务,登录后进入教务系统执行导入,支持校本部与顺德校区作息,支持自动获取开学日期"

+ 527 - 0
resources/SMU/smu_01.js

@@ -0,0 +1,527 @@
+/**
+ * 拾光课程表适配脚本 - 南方医科大学 (smu.edu.cn)
+ * 乘方教务系统适配
+ * 出现问题请提issues或者提交pr更改,这更加快速
+ */
+
+// 基础配置
+
+const BASE_URL = "https://zhjw.smu.edu.cn/new";
+
+// 校区与作息时间映射
+const CAMPUS_CONFIG = {
+    // 校本部 (00001)
+    "00001": {
+        name: "校本部",
+        timeSlots: [
+            { number: 1, startTime: "08:00", endTime: "08:40" },
+            { number: 2, startTime: "08:45", endTime: "09:25" },
+            { number: 3, startTime: "09:50", endTime: "10:30" },
+            { number: 4, startTime: "10:35", endTime: "11:15" },
+            { number: 5, startTime: "11:20", endTime: "12:00" },
+            { number: 6, startTime: "14:30", endTime: "15:10" },
+            { number: 7, startTime: "15:15", endTime: "15:55" },
+            { number: 8, startTime: "16:15", endTime: "16:55" },
+            { number: 9, startTime: "17:00", endTime: "17:40" },
+            { number: 10, startTime: "19:30", endTime: "20:10" },
+            { number: 11, startTime: "20:15", endTime: "20:55" },
+            { number: 12, startTime: "21:00", endTime: "21:40" },
+            { number: 13, startTime: "21:45", endTime: "22:25" }
+        ]
+    },
+    // 顺德校区 (00009)
+    "00009": {
+        name: "顺德校区",
+        timeSlots: [
+            { number: 1, startTime: "08:30", endTime: "09:10" },
+            { number: 2, startTime: "09:15", endTime: "09:55" },
+            { number: 3, startTime: "10:20", endTime: "11:00" },
+            { number: 4, startTime: "11:05", endTime: "11:45" },
+            { number: 5, startTime: "11:50", endTime: "12:30" },
+            { number: 6, startTime: "14:00", endTime: "14:40" },
+            { number: 7, startTime: "14:45", endTime: "15:25" },
+            { number: 8, startTime: "15:45", endTime: "16:25" },
+            { number: 9, startTime: "16:30", endTime: "17:10" },
+            { number: 10, startTime: "19:30", endTime: "20:10" },
+            { number: 11, startTime: "20:15", endTime: "20:55" },
+            { number: 12, startTime: "21:00", endTime: "21:40" },
+            { number: 13, startTime: "21:45", endTime: "22:25" }
+        ]
+    }
+};
+
+// 周次解析
+
+/**
+ * 解析周次字符串,例如 "1,2,3,4,5" -> [1, 2, 3, 4, 5]
+ */
+function parseWeeks(weekStr) {
+    if (!weekStr) return [];
+    const weeks = weekStr.split(',')
+        .map(w => Number(w.trim()))
+        .filter(w => !isNaN(w) && w > 0);
+    return [...new Set(weeks)].sort((a, b) => a - b);
+}
+
+// 合并去重函数
+
+/**
+ * 节次与周次合并去重函数
+ */
+function mergeAndDistinctCourses(courses) {
+    if (!Array.isArray(courses) || courses.length <= 1) return courses;
+
+    const grouped = {};
+    courses.forEach(c => {
+        const name = c.name || '';
+        const teacher = c.teacher || '';
+        const position = c.position || '';
+        const day = c.day || 0;
+        const isCustom = c.isCustomTime || false;
+        const cst = c.customStartTime || '';
+        const cet = c.customEndTime || '';
+
+        const key = `${day}|${name}|${teacher}|${position}|${isCustom}|${cst}|${cet}`;
+
+        if (!grouped[key]) {
+            grouped[key] = { 
+                name, teacher, position, day, 
+                isCustomTime: isCustom, 
+                customStartTime: cst, 
+                customEndTime: cet, 
+                weeksMap: {} 
+            };
+        }
+
+        const weeksArr = Array.isArray(c.weeks) ? [...new Set(c.weeks)] : [];
+        weeksArr.forEach(w => {
+            if (!grouped[key].weeksMap[w]) grouped[key].weeksMap[w] = [];
+            grouped[key].weeksMap[w].push({ start: c.startSection, end: c.endSection });
+        });
+    });
+
+    const finalResult = [];
+
+    Object.values(grouped).forEach(group => {
+        const sortedWeeks = Object.keys(group.weeksMap).map(Number).sort((a, b) => a - b);
+        const processedWeeksMap = {};
+
+        sortedWeeks.forEach(w => {
+            let parts = group.weeksMap[w];
+            parts.sort((a, b) => a.start - b.start || a.end - b.end);
+
+            const mergedParts = [];
+            let cur = parts[0];
+            for (let i = 1; i < parts.length; i++) {
+                if (cur.end + 1 >= parts[i].start) {
+                    cur.end = Math.max(cur.end, parts[i].end);
+                } else {
+                    mergedParts.push(cur);
+                    cur = parts[i];
+                }
+            }
+            mergedParts.push(cur);
+            processedWeeksMap[w] = mergedParts;
+        });
+
+        const sectionsMap = {};
+
+        sortedWeeks.forEach(w => {
+            const parts = processedWeeksMap[w];
+            const sig = parts.map(p => `${p.start}-${p.end}`).join('|');
+            if (!sectionsMap[sig]) sectionsMap[sig] = { sections: parts, weeks: [] };
+            sectionsMap[sig].weeks.push(w);
+        });
+
+        Object.values(sectionsMap).forEach(secGroup => {
+            secGroup.sections.forEach(sec => {
+                const baseCourse = {
+                    name: group.name,
+                    teacher: group.teacher,
+                    position: group.position,
+                    day: group.day,
+                    startSection: sec.start,
+                    endSection: sec.end,
+                    weeks: secGroup.weeks
+                };
+
+                if (group.isCustomTime) {
+                    baseCourse.isCustomTime = true;
+                    baseCourse.customStartTime = group.customStartTime;
+                    baseCourse.customEndTime = group.customEndTime;
+                }
+
+                finalResult.push(baseCourse);
+            });
+        });
+    });
+
+    finalResult.sort((a, b) => {
+        return (a.day || 0) - (b.day || 0) ||
+               (a.startSection || 0) - (b.startSection || 0) ||
+               a.name.localeCompare(b.name) ||
+               a.weeks[0] - b.weeks[0];
+    });
+
+    return finalResult;
+}
+
+// JSON 解析
+
+/**
+ * 将教务系统返回的 JSON 转换为拾光标准的 CourseJsonModel 数组
+ */
+function parseJsonData(jsonData) {
+    if (!jsonData || jsonData.code !== 0 || !Array.isArray(jsonData.data)) {
+        return { courses: [], campusCodes: [] };
+    }
+
+    let allProcessedCourses = [];
+    let detectedCampusCodes = new Set();
+
+    jsonData.data.forEach(raw => {
+        if (!(raw.kcmc && raw.xq && raw.ps && raw.pe)) return;
+
+        if (raw.kkxqdm) {
+            detectedCampusCodes.add(String(raw.kkxqdm).trim());
+        }
+
+        const startSec = Number(raw.ps);
+        const endSec = Number(raw.pe);
+
+        let isCustomTime = false;
+        let customStartTime = "";
+        let customEndTime = "";
+
+        // 【特殊逻辑说明】
+        // 南方医科大学教务系统从第10节开始(晚间)的起止时间,与常规的“40分钟一节、课间5分钟”基础作息规则不一致。
+        // 为了保证课表显示的实际上下课时间与教务系统完全吻合,遇到 >=10 节的课程,
+        // 强制启用 isCustomTime,并直接使用接口返回的真实起止时间(qssj/jssj),不再依赖预设的基础节次时间。
+        if (startSec >= 10) {
+            isCustomTime = true;
+            customStartTime = raw.qssj ? raw.qssj.substring(0, 5) : "";
+            customEndTime = raw.jssj ? raw.jssj.substring(0, 5) : "";
+        }
+
+        const locationMap = new Map();
+        if (raw.jxcdmc2) {
+            const parts = raw.jxcdmc2.split(',');
+            parts.forEach(part => {
+                const lastDashIndex = part.lastIndexOf('-');
+                if (lastDashIndex !== -1) {
+                    const room = part.substring(0, lastDashIndex).trim();
+                    const week = Number(part.substring(lastDashIndex + 1));
+                    if (!isNaN(week)) {
+                        if (!locationMap.has(room)) locationMap.set(room, []);
+                        locationMap.get(room).push(week);
+                    }
+                }
+            });
+        }
+
+        if (locationMap.size > 0) {
+            locationMap.forEach((weeks, room) => {
+                const courseObj = {
+                    name: raw.kcmc.trim(),
+                    teacher: (raw.teaxms || "未知教师").trim(),
+                    position: room || "不用场地",
+                    day: Number(raw.xq),
+                    startSection: startSec,
+                    endSection: endSec,
+                    weeks: weeks.sort((a, b) => a - b)
+                };
+                if (isCustomTime) {
+                    courseObj.isCustomTime = true;
+                    courseObj.customStartTime = customStartTime;
+                    courseObj.customEndTime = customEndTime;
+                }
+                allProcessedCourses.push(courseObj);
+            });
+        } else if (raw.zc) {
+            const courseObj = {
+                name: raw.kcmc.trim(),
+                teacher: (raw.teaxms || "未知教师").trim(),
+                position: (raw.jxcdmc || "").trim() || "不用场地",
+                day: Number(raw.xq),
+                startSection: startSec,
+                endSection: endSec,
+                weeks: parseWeeks(raw.zc)
+            };
+            if (isCustomTime) {
+                courseObj.isCustomTime = true;
+                courseObj.customStartTime = customStartTime;
+                courseObj.customEndTime = customEndTime;
+            }
+            allProcessedCourses.push(courseObj);
+        }
+    });
+
+    const filtered = allProcessedCourses.filter(course => {
+        return course.weeks.length > 0 && course.startSection <= course.endSection;
+    });
+
+    return {
+        courses: mergeAndDistinctCourses(filtered),
+        campusCodes: Array.from(detectedCampusCodes)
+    };
+}
+
+// 学年学期选项获取
+
+async function fetchAcademicOptions() {
+    const url = `${BASE_URL}/student/xlxx/xlxxlist.page`;
+
+    try {
+        const response = await fetch(url, {
+            method: "GET",
+            credentials: "include"
+        });
+
+        if (!response.ok) return null;
+
+        const htmlText = await response.text();
+        const parser = new DOMParser();
+        const doc = parser.parseFromString(htmlText, "text/html");
+
+        const allOptions = Array.from(doc.querySelectorAll("#xnxqdm option"))
+            .filter(opt => opt.value !== "")
+            .map(opt => ({
+                value: opt.value,
+                text: opt.textContent.trim(),
+                selected: opt.selected
+            }));
+
+        if (allOptions.length === 0) return null;
+
+        const selectedIndex = allOptions.findIndex(opt => opt.selected);
+
+        if (selectedIndex === -1) {
+            return {
+                options: allOptions.slice(0, 7),
+                defaultIndex: 0
+            };
+        }
+
+        const start = Math.max(0, selectedIndex - 3);
+        const end = Math.min(allOptions.length, selectedIndex + 4);
+        const options = allOptions.slice(start, end);
+        const newDefaultIndex = selectedIndex - start;
+
+        return { options, defaultIndex: newDefaultIndex };
+
+    } catch (e) {
+        return null;
+    }
+}
+
+// 校历数据获取
+
+async function fetchSemesterCalendar(xnxqdm) {
+    const url = `${BASE_URL}/xlxx/data`;
+    const requestBody = `xnxqdm=${xnxqdm}`;
+
+    try {
+        const response = await fetch(url, {
+            method: "POST",
+            headers: {
+                "accept": "application/json, text/javascript, */*; q=0.01",
+                "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
+                "x-requested-with": "XMLHttpRequest"
+            },
+            body: requestBody,
+            credentials: "include"
+        });
+
+        if (!response.ok) return { startDate: null, totalWeeks: null };
+
+        const json = await response.json();
+        if (!json || json.code !== 0 || !Array.isArray(json.data)) {
+            return { startDate: null, totalWeeks: null };
+        }
+
+        let earliestDate = null;
+        let earliestWeek = Infinity;
+        let maxWeek = 0;
+
+        json.data.forEach(monthBlock => {
+            if (!Array.isArray(monthBlock)) return;
+
+            monthBlock.forEach(row => {
+                if (!Array.isArray(row) || row.length === 0) return;
+
+                const weekHead = row[0];
+                if (!weekHead || weekHead.type !== "zc") return;
+                const weekNum = Number(weekHead.value);
+                if (isNaN(weekNum)) return;
+
+                if (weekNum > maxWeek) maxWeek = weekNum;
+
+                for (let i = 1; i < row.length; i++) {
+                    const cell = row[i];
+                    if (!cell || cell.type !== "day") continue;
+                    if (!cell.fullText) continue;
+                    if (!/^\d{4}-\d{2}-\d{2}$/.test(cell.fullText)) continue;
+
+                    if (weekNum < earliestWeek ||
+                        (weekNum === earliestWeek && cell.fullText < earliestDate)) {
+                        earliestWeek = weekNum;
+                        earliestDate = cell.fullText;
+                    }
+                }
+            });
+        });
+
+        return {
+            startDate: earliestDate,
+            totalWeeks: maxWeek > 0 ? maxWeek : null
+        };
+
+    } catch (e) {
+        return { startDate: null, totalWeeks: null };
+    }
+}
+
+// 保存逻辑模块
+
+async function saveCampusTimeSlotsAndConfig(campusCodes, calendar) {
+    let finalConfig = CAMPUS_CONFIG["00001"];
+    
+    let matchedCode = campusCodes.find(code => CAMPUS_CONFIG[code]);
+    
+    if (matchedCode) {
+        finalConfig = CAMPUS_CONFIG[matchedCode];
+    } else if (campusCodes.length > 0) {
+        const unknownStr = campusCodes.join(", ");
+        await window.shiguangBridgePromise.showAlert(
+            "未记录校区提醒",
+            `检测到课表中包含未记录的校区代码(${unknownStr})。\n系统将默认使用【校本部】作息时间。`,
+            "好的,继续导入"
+        );
+    }
+
+    try {
+        await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(finalConfig.timeSlots));
+
+        if (calendar && (calendar.startDate || calendar.totalWeeks)) {
+            const config = {};
+            if (calendar.startDate) config.semesterStartDate = calendar.startDate;
+            if (calendar.totalWeeks) config.semesterTotalWeeks = calendar.totalWeeks;
+
+            await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
+            
+            let msg = `配置更新成功!(使用 ${finalConfig.name} 作息)`;
+            if (calendar.startDate) msg += ` 开学:${calendar.startDate}`;
+            if (calendar.totalWeeks) msg += ` 总周数:${calendar.totalWeeks}`;
+            window.shiguangBridge.showToast(msg);
+        } else {
+            window.shiguangBridge.showToast(`作息已更新为 ${finalConfig.name},但未获取到校历信息。`);
+        }
+
+    } catch (e) {
+        window.shiguangBridge.showToast("保存配置或作息失败: " + e.message);
+    }
+}
+
+// 用户交互
+
+async function promptUserToStart() {
+    return await window.shiguangBridgePromise.showAlert(
+        "教务导入说明",
+        "1. 请确保已在浏览器中成功登录教务系统\n2. 导入过程中请勿关闭页面",
+        "好的,开始导入"
+    );
+}
+
+async function selectAcademicYearAndSemester() {
+    const optionsData = await fetchAcademicOptions();
+
+    if (!optionsData) {
+        window.shiguangBridge.showToast("从教务系统读取学年学期失败,请确保登录状态。");
+        return null;
+    }
+
+    const { options, defaultIndex } = optionsData;
+    const texts = options.map(item => item.text);
+
+    const selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
+        "选择学年学期",
+        JSON.stringify(texts),
+        defaultIndex
+    );
+
+    if (selectedIndex === null || selectedIndex === -1) return null;
+
+    return {
+        xnxqdm: options[selectedIndex].value,
+        xnxqmc: options[selectedIndex].text
+    };
+}
+
+// 网络请求
+
+async function fetchCourses(xnxqdm) {
+    window.shiguangBridge.showToast("正在请求教务数据...");
+
+    const body = `xnxqdm=${xnxqdm}`;
+    const url = `${BASE_URL}/student/xsgrkb/getCalendarWeekDatas`;
+
+    try {
+        const response = await fetch(url, {
+            method: "POST",
+            headers: { "content-type": "application/x-www-form-urlencoded; charset=UTF-8" },
+            body: body,
+            credentials: "include"
+        });
+
+        if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
+
+        const jsonData = await response.json();
+        return parseJsonData(jsonData);
+    } catch (e) {
+        window.shiguangBridge.showToast("网络请求失败,请检查登录状态");
+        return null;
+    }
+}
+
+// 主流程编排
+
+async function runImportFlow() {
+    const isReady = await promptUserToStart();
+    if (!isReady) return;
+
+    const selection = await selectAcademicYearAndSemester();
+    if (!selection) {
+        window.shiguangBridge.showToast("未选择学年学期,导入终止。");
+        return;
+    }
+
+    const { xnxqdm } = selection;
+
+    const [courseData, calendar] = await Promise.all([
+        fetchCourses(xnxqdm),
+        fetchSemesterCalendar(xnxqdm)
+    ]);
+
+    if (!courseData || !courseData.courses || courseData.courses.length === 0) {
+        if (courseData && courseData.courses && courseData.courses.length === 0) {
+            window.shiguangBridge.showToast("该学期暂无课程数据");
+        }
+        return;
+    }
+
+    const { courses, campusCodes } = courseData;
+
+    try {
+        await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
+        await saveCampusTimeSlotsAndConfig(campusCodes, calendar);
+
+        window.shiguangBridge.showToast(`成功导入 ${courses.length} 门课程!`);
+        window.shiguangBridge.notifyTaskCompletion();
+
+    } catch (e) {
+        window.shiguangBridge.showToast("保存失败: " + e.message);
+    }
+}
+
+// 启动入口
+runImportFlow();