Просмотр исходного кода

feat: 添加四川师范大学教务系统适配

llingqing 1 месяц назад
Родитель
Сommit
30493c4838
3 измененных файлов с 420 добавлено и 1 удалено
  1. 6 1
      index/root_index.yaml
  2. 8 0
      resources/SICNU/adapters.yaml
  3. 406 0
      resources/SICNU/school.js

+ 6 - 1
index/root_index.yaml

@@ -565,4 +565,9 @@ schools:
   - id: "QQHRU"
     name: "齐齐哈尔大学"
     initial: "Q"
-    resource_folder: "QQHRU"  
+    resource_folder: "QQHRU"  
+
+  - id: "SICNU"
+    name: "四川师范大学"
+    initial: "S"
+    resource_folder: "SICNU"

+ 8 - 0
resources/SICNU/adapters.yaml

@@ -0,0 +1,8 @@
+adapters:
+  - adapter_id: "SICNU_01"
+    adapter_name: "四川师范大学教务系统"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "school.js"
+    import_url: "http://202.115.194.60/"
+    maintainer: "llingqing"
+    description: "支持狮子山校区和成龙校区,自动识别学期和课程时间,另可从大学生通进入教务系统(https://zhxg.sicnu.edu.cn/edu/admin/)"

+ 406 - 0
resources/SICNU/school.js

@@ -0,0 +1,406 @@
+// school.js - 教务系统适配
+//
+// 适配说明:
+//   此脚本通过调用教务系统已有的 AJAX 接口获取课程数据,
+//   解析 scheduleText 文本字段提取排课信息,
+//   然后转换为拾光 App 所需的格式并导出。
+//
+// 使用方式:
+//   1. 登录教务系统,进入课表页面
+//   2. 点击扩展图标 → "开始测试"
+//   3. 等待数据采集完成,弹出下载
+
+const CONTEXT_PATH = window.CONTEXT_PATH || '/student';
+const BIZ_TYPE_ID = window.bizTypeId || 2;
+
+// ============================================================
+// 工具函数
+// ============================================================
+
+/**
+ * 从页面全局变量中获取当前学期信息
+ */
+function getCurrentSemesterInfo() {
+    if (window.currentSemester) {
+        const cs = window.currentSemester;
+        const startDate = cs.startDate
+            ? `${cs.startDate.year}-${String(cs.startDate.monthOfYear).padStart(2, '0')}-${String(cs.startDate.dayOfMonth).padStart(2, '0')}`
+            : null;
+        const endDate = cs.endDate
+            ? `${cs.endDate.year}-${String(cs.endDate.monthOfYear).padStart(2, '0')}-${String(cs.endDate.dayOfMonth).padStart(2, '0')}`
+            : null;
+        return {
+            id: cs.id,
+            name: cs.nameZh || cs.name,
+            startDate: startDate,
+            endDate: endDate,
+            weekStartOnSunday: cs.weekStartOnSunday || false,
+            schoolYear: cs.schoolYear,
+        };
+    }
+
+    if (window.semesters && window.semesters.length > 0) {
+        const semesterId = document.getElementById('allSemesters')?.value;
+        const sem = semesterId
+            ? window.semesters.find(s => s.id == semesterId)
+            : window.semesters[0];
+        if (sem) {
+            return {
+                id: sem.id,
+                name: sem.nameZh || sem.name,
+                startDate: sem.startDate,
+                endDate: sem.endDate,
+                weekStartOnSunday: sem.weekStartOnSunday || false,
+                schoolYear: sem.schoolYear,
+            };
+        }
+    }
+
+    return null;
+}
+
+/**
+ * 计算学期总周数
+ */
+function calculateTotalWeeks(startDateStr, endDateStr) {
+    if (!startDateStr || !endDateStr) return 20;
+    const start = new Date(startDateStr);
+    const end = new Date(endDateStr);
+    const diffDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
+    return Math.ceil(diffDays / 7);
+}
+
+// 星期映射
+const DAY_MAP = {
+    '星期一': 1, '星期二': 2, '星期三': 3, '星期四': 4,
+    '星期五': 5, '星期六': 6, '星期日': 7,
+};
+
+// ============================================================
+// 核心:解析 scheduleText 文本
+// ============================================================
+
+/**
+ * 解析周次字符串,如 "2~15" → [2,3,...,15], "1" → [1]
+ */
+function parseWeekRange(weekStr) {
+    const weeks = [];
+    if (weekStr.includes('~')) {
+        const parts = weekStr.split('~');
+        const start = parseInt(parts[0], 10);
+        const end = parseInt(parts[1], 10);
+        for (let i = start; i <= end; i++) {
+            weeks.push(i);
+        }
+    } else {
+        weeks.push(parseInt(weekStr, 10));
+    }
+    return weeks;
+}
+
+/**
+ * 解析单条排课文本行
+ *
+ * 格式示例 (有教室):
+ *   "1周 星期三 8~9节 成龙 1-A407"
+ *   "2~15周 星期三 1~2节 成龙 1-B214"
+ * 格式示例 (无教室,如网课/讲座):
+ *   "11~12周 星期三 3~4节"
+ */
+function parseScheduleLine(line, lesson) {
+    if (!line || !line.trim()) return [];
+
+    // 正则匹配:
+    //   (\d+(?:~\d+)?)周          - 周次 (如 "1" 或 "2~15")
+    //   (星期[一二三四五六日])       - 星期几
+    //   (\d+)~(\d+)节              - 节次范围
+    //   (?:\s+(\S+))?(?:\s+(\S+))? - 可选的校区和教室 (有些课没有)
+    const pattern = /(\d+(?:~\d+)?)周\s+(星期[一二三四五六日])\s+(\d+)~(\d+)节(?:\s+(\S+))?(?:\s+(\S+))?/;
+    const match = line.match(pattern);
+
+    if (!match) {
+        console.warn('[教务适配] 无法解析排课行:', line);
+        return [];
+    }
+
+    const weekStr = match[1];
+    const dayText = match[2];
+    const startSection = parseInt(match[3], 10);
+    const endSection = parseInt(match[4], 10);
+    const campus = match[5] || '';      // 可选
+    const classroom = match[6] || '';   // 可选
+
+    const weeks = parseWeekRange(weekStr);
+    const day = DAY_MAP[dayText] || 1;
+
+    // 课程名: lesson.course.nameZh
+    const courseName = (lesson.course && lesson.course.nameZh) || lesson.nameZh || '未知课程';
+
+    // 教师: lesson.teacherAssignmentString
+    const teacher = lesson.teacherAssignmentString || '';
+
+    // 教室位置
+    const position = [campus, classroom].filter(Boolean).join(' ');
+
+    return [{
+        name: courseName,
+        teacher: teacher,
+        position: position,
+        day: day,
+        startSection: startSection,
+        endSection: endSection,
+        weeks: weeks,
+        isCustomTime: false,
+    }];
+}
+
+/**
+ * 从 lesson 的 scheduleText 中解析所有排课条目
+ */
+function parseLessonScheduleText(lesson) {
+    const courses = [];
+
+    const scheduleText = lesson.scheduleText;
+    if (!scheduleText) return courses;
+
+    let rawText = '';
+    if (scheduleText.dateTimePlaceText && scheduleText.dateTimePlaceText.text) {
+        rawText = scheduleText.dateTimePlaceText.text;
+    } else if (scheduleText.dateTimePlacePersonText && scheduleText.dateTimePlacePersonText.text) {
+        rawText = scheduleText.dateTimePlacePersonText.text;
+    } else if (scheduleText.dateTimeText && scheduleText.dateTimeText.text) {
+        rawText = scheduleText.dateTimeText.text;
+    }
+
+    if (!rawText) return courses;
+
+    // 按 "; \n" 或 ";\n" 拆分各行
+    const lines = rawText.split(/;\s*\n\s*/).filter(line => line.trim());
+
+    for (const line of lines) {
+        const parsed = parseScheduleLine(line.trim(), lesson);
+        courses.push(...parsed);
+    }
+
+    return courses;
+}
+
+// ============================================================
+// 合并相同课程
+// ============================================================
+
+/**
+ * 合并相同课程的周次
+ */
+function mergeCourseWeeks(courses) {
+    const map = new Map();
+
+    for (const course of courses) {
+        const key = JSON.stringify({
+            name: course.name,
+            teacher: course.teacher,
+            position: course.position,
+            day: course.day,
+            startSection: course.startSection,
+            endSection: course.endSection,
+        });
+
+        if (map.has(key)) {
+            const existing = map.get(key);
+            const mergedWeeks = new Set([...existing.weeks, ...course.weeks]);
+            existing.weeks = Array.from(mergedWeeks).sort((a, b) => a - b);
+        } else {
+            map.set(key, { ...course });
+        }
+    }
+
+    return Array.from(map.values());
+}
+
+// ============================================================
+// 数据获取
+// ============================================================
+
+/**
+ * 通过 AJAX 获取课程数据
+ */
+function fetchCourseData(semesterId) {
+    return new Promise((resolve, reject) => {
+        const url = CONTEXT_PATH + '/for-std/course-table/get-data';
+        const data = {
+            bizTypeId: BIZ_TYPE_ID,
+            semesterId: semesterId,
+            dataId: window.studentIds ? window.studentIds[0] : window.personId,
+        };
+
+        console.log('[教务适配] 正在请求课程数据...', { url, data });
+
+        $.ajax({
+            url: url,
+            type: 'GET',
+            data: data,
+            success: function (res) {
+                console.log('[教务适配] 成功获取课程数据,包含 ' + (res.lessons?.length || 0) + ' 门课程');
+                resolve(res);
+            },
+            error: function (jqXHR, textStatus, errorThrown) {
+                console.error('[教务适配] 获取课程数据失败:', textStatus, errorThrown);
+                reject(new Error('获取课程数据失败: ' + textStatus));
+            },
+        });
+    });
+}
+
+// ============================================================
+// 时间段配置 (狮子山|成龙校区课表)
+// ============================================================
+
+/**
+ * 获取上课时间段配置
+ */
+function getDefaultTimeSlots() {
+    return [
+        { number: 1,  startTime: "08:10", endTime: "08:55" },
+        { number: 2,  startTime: "09:00", endTime: "09:45" },
+        { number: 3,  startTime: "10:10", endTime: "10:55" },
+        { number: 4,  startTime: "11:05", endTime: "11:45" },
+        { number: 5,  startTime: "11:50", endTime: "12:35" },
+        { number: 6,  startTime: "12:40", endTime: "13:25" },
+        { number: 7,  startTime: "13:30", endTime: "14:10" },
+        { number: 8,  startTime: "14:10", endTime: "14:55" },
+        { number: 9,  startTime: "15:00", endTime: "15:45" },
+        { number: 10,  startTime: "16:10", endTime: "16:55" },
+        { number: 11, startTime: "17:00", endTime: "17:45" },
+        { number: 12, startTime: "18:00", endTime: "18:45" },
+        { number: 13, startTime: "19:10", endTime: "19:55" },
+        { number: 14, startTime: "20:00", endTime: "20:45" },
+        { number: 15, startTime: "20:50", endTime: "21:35" },
+    ];
+}
+
+// ============================================================
+// 课表配置
+// ============================================================
+
+function getCourseConfig(semesterInfo) {
+    const totalWeeks = semesterInfo?.startDate && semesterInfo?.endDate
+        ? calculateTotalWeeks(semesterInfo.startDate, semesterInfo.endDate)
+        : 20;
+
+    return {
+        semesterStartDate: semesterInfo?.startDate || null,
+        semesterTotalWeeks: totalWeeks,
+        defaultClassDuration: 45,
+        defaultBreakDuration: 10,
+        firstDayOfWeek: 1,
+    };
+}
+
+// ============================================================
+// 主流程
+// ============================================================
+
+async function main() {
+    try {
+        console.log('========================================');
+        console.log('[教务适配] 开始执行 SICNU 教务系统适配');
+        console.log('========================================');
+
+        // 1. 获取学期信息
+        const semesterInfo = getCurrentSemesterInfo();
+        if (!semesterInfo) {
+            throw new Error('无法获取学期信息,请确认已在课表页面');
+        }
+        console.log('[教务适配] 当前学期:', semesterInfo.name, '(id=' + semesterInfo.id + ')');
+        console.log('[教务适配] 学期日期:', semesterInfo.startDate, '~', semesterInfo.endDate);
+
+        // 2. 获取课程数据
+        AndroidBridge.showToast('正在采集课程数据...');
+        const data = await fetchCourseData(semesterInfo.id);
+
+        if (!data || !data.lessons || data.lessons.length === 0) {
+            throw new Error('未获取到课程数据,请确认已登录并选择了正确的学期');
+        }
+
+        // 3. 解析每门课的 scheduleText
+        console.log('[教务适配] 开始解析 ' + data.lessons.length + ' 门课程...');
+        let allCourses = [];
+
+        for (const lesson of data.lessons) {
+            const courses = parseLessonScheduleText(lesson);
+            if (courses.length > 0) {
+                const courseName = (lesson.course && lesson.course.nameZh) || lesson.nameZh;
+                console.log('[教务适配] ' + courseName + ': 解析出 ' + courses.length + ' 条排课记录');
+                allCourses.push(...courses);
+            } else {
+                const courseName = (lesson.course && lesson.course.nameZh) || lesson.nameZh || '(未知)';
+                console.warn('[教务适配] ' + courseName + ' (id=' + lesson.id + '): 未能解析出排课记录');
+                // 输出 lesson 的 scheduleText 原始内容供调试
+                if (lesson.scheduleText) {
+                    console.log('  scheduleText 内容:', JSON.stringify(lesson.scheduleText));
+                } else {
+                    console.log('  lesson 没有 scheduleText 字段, keys:', Object.keys(lesson));
+                }
+            }
+        }
+
+        console.log('[教务适配] 解析得到 ' + allCourses.length + ' 条课程记录');
+
+        // 4. 合并相同课程的周次
+        allCourses = mergeCourseWeeks(allCourses);
+        console.log('[教务适配] 合并后共 ' + allCourses.length + ' 条课程记录');
+
+        if (allCourses.length === 0) {
+            throw new Error('未能解析出任何课程数据。请查看控制台日志。');
+        }
+
+        // 输出课程样本
+        console.log('[教务适配] 课程样本 (前5条):');
+        allCourses.slice(0, 5).forEach((c, i) => {
+            console.log(`  ${i + 1}. ${c.name} | ${c.teacher} | ${c.position} | 周${c.day} | ${c.startSection}-${c.endSection}节 | 周次:${c.weeks.join(',')}`);
+        });
+
+        // 5. 时间段配置
+        const timeSlots = getDefaultTimeSlots();
+
+        // 6. 课表配置
+        const config = getCourseConfig(semesterInfo);
+
+        console.log('[教务适配] 准备导入:');
+        console.log('  - 课程数:', allCourses.length);
+        console.log('  - 时间段数:', timeSlots.length);
+        console.log('  - 学期总周数:', config.semesterTotalWeeks);
+        console.log('  - 开学日期:', config.semesterStartDate);
+
+        // 7. 导入数据
+        AndroidBridge.showToast(`正在导入 ${allCourses.length} 门课程...`);
+
+        const courseResult = await window.AndroidBridgePromise.saveImportedCourses(
+            JSON.stringify(allCourses)
+        );
+        console.log('[教务适配] 课程导入结果:', courseResult);
+
+        const timeSlotResult = await window.AndroidBridgePromise.savePresetTimeSlots(
+            JSON.stringify(timeSlots)
+        );
+        console.log('[教务适配] 时间段导入结果:', timeSlotResult);
+
+        const configResult = await window.AndroidBridgePromise.saveCourseConfig(
+            JSON.stringify(config)
+        );
+        console.log('[教务适配] 配置导入结果:', configResult);
+
+        // 8. 触发下载
+        console.log('[教务适配] ✅ 所有数据导入完成,触发下载...');
+        AndroidBridge.showToast('课程数据采集完成!请保存下载的文件。');
+        AndroidBridge.notifyTaskCompletion();
+
+    } catch (error) {
+        console.error('[教务适配] ❌ 执行失败:', error.message);
+        console.error(error.stack);
+        AndroidBridge.showToast('采集失败: ' + error.message);
+    }
+}
+
+// 启动
+main();