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

Merge pull request #699 from XingHeYuZhuan/pending

星河欲转 5 дней назад
Родитель
Сommit
9d031fabd7
4 измененных файлов с 294 добавлено и 22 удалено
  1. 5 0
      index/root_index.yaml
  2. 9 0
      resources/HHVTC/adapters.yaml
  3. 221 0
      resources/HHVTC/hhvt.js
  4. 59 22
      resources/XJTU/xjtu.js

+ 5 - 0
index/root_index.yaml

@@ -788,6 +788,11 @@ schools:
     initial: "H"
     resource_folder: "HHTC"
 
+  - id: "HHVTC"
+    name: "怀化职业技术学院"
+    initial: "H"
+    resource_folder: "HHVTC"
+
   - id: "CWXU"
     name: "无锡学院"
     initial: "W"

+ 9 - 0
resources/HHVTC/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/HHVTC/adapters.yaml
+adapters:
+  - adapter_id: "HHVTC"
+    adapter_name: "怀化职业技术学院教务系统"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "hhvt.js"
+    import_url: "http://jwgl.hhvtc.com.cn/"
+    maintainer: "Haooz"
+    description: "怀化职业技术学院青果教务课表导入,请先登录并打开课表查询页面,再点击导入!"

+ 221 - 0
resources/HHVTC/hhvt.js

@@ -0,0 +1,221 @@
+// 怀化职业技术学院教务系统(jwgl.hhvtc.com.cn)拾光课表导入适配脚本
+// 青果教务:课表表格单元格 .have-content 只有空内容,课程详情在隐藏 div [id^="weekly0"] 内
+//   每个详情 div 含 ul>li:课程名称 / 任课教师 / 上课时间([周次]星期[节次])/ 上课地点 / 合班信息
+// 作息分夏秋(5.1起)与冬春(10.1起)两套,导入时通过选择器让用户选择
+
+// 夏秋作息(5.1 起)
+const HHVT_TIME_SLOTS_SUMMER = [
+    { number: 1, startTime: "08:10", endTime: "08:55" },
+    { number: 2, startTime: "09:05", endTime: "09:50" },
+    { number: 3, startTime: "10:10", endTime: "10:55" },
+    { number: 4, startTime: "11:05", endTime: "11:50" },
+    { number: 5, startTime: "14:30", endTime: "15:15" },
+    { number: 6, startTime: "15:25", endTime: "16:10" },
+    { number: 7, startTime: "16:20", endTime: "17:05" },
+    { number: 8, startTime: "17:15", endTime: "18:00" }
+];
+
+// 冬春作息(10.1 起)
+const HHVT_TIME_SLOTS_WINTER = [
+    { number: 1, startTime: "08:20", endTime: "09:05" },
+    { number: 2, startTime: "09:15", endTime: "10:00" },
+    { number: 3, startTime: "10:20", endTime: "11:05" },
+    { number: 4, startTime: "11:15", endTime: "12:00" },
+    { number: 5, startTime: "14:00", endTime: "14:45" },
+    { number: 6, startTime: "14:55", endTime: "15:40" },
+    { number: 7, startTime: "15:50", endTime: "16:35" },
+    { number: 8, startTime: "16:45", endTime: "17:30" }
+];
+
+function getErrorMessage(error) {
+    if (error && typeof error.message === "string" && error.message.trim()) return error.message;
+    if (typeof error === "string" && error.trim()) return error;
+    try {
+        const serialized = JSON.stringify(error);
+        if (serialized && serialized !== "{}") return serialized;
+    } catch (_) {
+        // Ignore serialization failures.
+    }
+    return "未知错误";
+}
+
+// 收集可访问的 document(frameset 用 <frame>,普通页面用 <iframe>)
+function collectDocs() {
+    const docs = [document];
+    const frames = document.querySelectorAll("iframe, frame");
+    for (const f of frames) {
+        try {
+            if (f.contentDocument) docs.push(f.contentDocument);
+        } catch (_) {
+            // 跨域 frame 无法访问,忽略。
+        }
+    }
+    return docs;
+}
+
+// 从各 frame 中查找课表详情 div([id^="weekly0"])
+// 注入时机早于页面加载完成,故轮询等待(最长 6 秒)
+async function findDetailDivs() {
+    const deadline = Date.now() + 6000;
+    while (Date.now() < deadline) {
+        for (const doc of collectDocs()) {
+            const divs = doc.querySelectorAll('[id^="weekly0"]');
+            if (divs.length > 0) {
+                return { doc, divs };
+            }
+        }
+        await new Promise(resolve => setTimeout(resolve, 300));
+    }
+    return null;
+}
+
+// 解析周次文本:"3周"→[3];"1-18周"→[1..18];"1-8,10-18周"→[1..8,10..18]
+function parseWeeksText(weekStr) {
+    const text = String(weekStr || "").replace(/\s+/g, "");
+    const m = text.match(/\[(.*?)\]/);
+    if (!m) return [];
+    const body = m[1].replace(/周/g, "");
+    const weeks = [];
+    const parts = body.split(",");
+    for (const part of parts) {
+        const range = part.split("-");
+        const start = Number(range[0]);
+        const end = range.length > 1 ? Number(range[1]) : start;
+        if (!start) continue;
+        for (let w = start; w <= end; w++) weeks.push(w);
+    }
+    return [...new Set(weeks)].sort((a, b) => a - b);
+}
+
+// 星期汉字 → 拾光 day(一=1 … 日=7)
+const DAY_MAP = { 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 日: 7 };
+
+// 解析单个详情 div(ul>li 结构)
+function parseDetailDiv(div) {
+    const lis = div.querySelectorAll("li");
+    if (lis.length === 0) return null;
+
+    let name = "";
+    let teacher = "";
+    let position = "";
+    let timeText = "";
+
+    for (const li of lis) {
+        const text = (li.textContent || "").trim();
+        if (text.indexOf("课程名称") !== -1) {
+            name = text.replace(/^.*?[::]/, "").trim();
+        } else if (text.indexOf("任课教师") !== -1) {
+            teacher = text.replace(/^.*?[::]/, "").trim();
+        } else if (text.indexOf("上课时间") !== -1) {
+            timeText = text.replace(/^.*?[::]/, "").trim();
+        } else if (text.indexOf("上课地点") !== -1) {
+            position = text.replace(/^.*?[::]/, "").trim();
+        }
+    }
+
+    if (!name || !timeText) return null;
+
+    // 上课时间:[3周]一[1-2节]
+    const dayMatch = timeText.match(/[一二三四五六日]/);
+    if (!dayMatch) return null;
+    const day = DAY_MAP[dayMatch[0]];
+    const sectionMatch = timeText.match(/\[(\d+)-(\d+)节\]/);
+    if (!sectionMatch) return null;
+    const weeks = parseWeeksText(timeText);
+    if (weeks.length === 0) return null;
+
+    return {
+        name,
+        teacher: teacher || "未知",
+        position: position || "待定",
+        day,
+        startSection: Number(sectionMatch[1]),
+        endSection: Number(sectionMatch[2]),
+        weeks
+    };
+}
+
+// 解析所有详情 div
+function parseCourses(divs) {
+    const courses = [];
+    for (const div of divs) {
+        const course = parseDetailDiv(div);
+        if (course) courses.push(course);
+    }
+    return courses;
+}
+
+// 合并同课程同时间同教室的条目
+function mergeCourses(courses) {
+    const merged = new Map();
+    for (const c of courses) {
+        const key = `${c.name}|${c.teacher}|${c.position}|${c.day}|${c.startSection}|${c.endSection}`;
+        const holder = merged.get(key);
+        if (holder) {
+            holder.weeks = Array.from(new Set([...holder.weeks, ...c.weeks])).sort((a, b) => a - b);
+        } else {
+            merged.set(key, { ...c, weeks: [...c.weeks].sort((a, b) => a - b) });
+        }
+    }
+    return Array.from(merged.values()).sort(
+        (a, b) => a.day - b.day || a.startSection - b.startSection || a.name.localeCompare(b.name)
+    );
+}
+
+// 保存作息时间
+async function saveTimeSlots(timeSlots) {
+    try {
+        await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
+    } catch (error) {
+        console.error("JS: 作息时间保存失败", error);
+    }
+}
+
+async function runImportFlow() {
+    const alertConfirmed = await window.shiguangBridgePromise.showAlert(
+        "课表导入",
+        "导入前请确保已登录并打开课表查询页面。",
+        "好的,开始导入"
+    );
+    if (!alertConfirmed) {
+        window.shiguangBridge.showToast("用户取消了导入。");
+        return;
+    }
+
+    // 作息选择:按当前日期给默认(5.1-9.30 夏秋,10.1-4.30 冬春)
+    const now = new Date();
+    const defaultIdx = (now.getMonth() + 1 >= 5 && now.getMonth() + 1 <= 9) ? 0 : 1;
+    const picked = await window.shiguangBridgePromise.showSingleSelection(
+        "选择作息时间",
+        JSON.stringify(["夏秋作息(5月1日起)", "冬春作息(10月1日起)"]),
+        defaultIdx
+    );
+    if (picked === null || picked < 0) {
+        window.shiguangBridge.showToast("用户取消了导入。");
+        return;
+    }
+    const timeSlots = picked === 0 ? HHVT_TIME_SLOTS_SUMMER : HHVT_TIME_SLOTS_WINTER;
+
+    window.shiguangBridge.showToast("正在获取课表数据...");
+    try {
+        const found = await findDetailDivs();
+        if (!found) throw new Error("未找到课表详情,请确认已登录并打开课表查询页面。");
+
+        const courses = parseCourses(found.divs);
+        if (courses.length === 0) throw new Error("课表中未解析到有效课程,请确认当前学期有课。");
+
+        const merged = mergeCourses(courses);
+
+        window.shiguangBridge.showToast(`正在保存 ${merged.length} 门课程...`);
+        await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(merged, null, 2));
+        await saveTimeSlots(timeSlots);
+
+        window.shiguangBridge.showToast(`课程导入成功,共导入 ${merged.length} 门课程!`);
+        window.shiguangBridge.notifyTaskCompletion();
+    } catch (error) {
+        window.shiguangBridge.showToast(`导入失败:${getErrorMessage(error)}`);
+        console.error("JS: Import Error", error);
+    }
+}
+
+runImportFlow();

+ 59 - 22
resources/XJTU/xjtu.js

@@ -33,6 +33,22 @@ const WINTER_TIME_SLOTS = [
     { number: 11, startTime: "21:10", endTime: "22:00" }
 ];
 
+// 周参数
+const FIRST_DAY_OF_WEEK = 1; // 每周第一天是 1
+const WEEK_DAYS = 7; // 一周有 7 天
+
+// 教学或总周数参数
+const DEFAULT_NUM_OF_WEEKS = 16;
+const MAX_NUM_OF_WEEKS = 32;
+
+// 课程参数
+const TOTAL_SECTION = SUMMER_TIME_SLOTS.length; // 根据作息表推断每天课程数
+const CLASS_DURATION = 50; // 每节课50分钟
+const BREAK_DURATION = 10; // 课间休息10分钟
+// 课程参数自检断言
+if (SUMMER_TIME_SLOTS.length !== WINTER_TIME_SLOTS.length) {
+    throw new Error(":( 配置错误: 夏/冬作息表节数不一致, 请联系开发者"); // 开发用断言, 裸错误
+}
 // -------- 用户交互 --------
 
 /**
@@ -139,7 +155,6 @@ async function promptUserToStart() {
         window.shiguangBridge.showToast("'_? 用户取消了导入");
         return false;
     }
-    window.shiguangBridge.showToast(">>> 开始流程");
     return true;
 }
 
@@ -210,7 +225,13 @@ async function getTermInfo() {
             semesterStartDate = dateMatch[0];
         }
         // ZJXZC: 教学周数(首选); ZZC: 学期总周数(含考试周, 次选)
-        termWeeks = parseInt(row?.ZJXZC, 10) || parseInt(row?.ZZC, 10) || null;
+        const parseWeeksField = (v) => {
+            const s = String(v ?? "");
+            if (!/^\d+$/.test(s)) return null;
+            const n = parseInt(s, 10);
+            return (n >= 1 && n <= MAX_NUM_OF_WEEKS) ? n : null;
+        };
+        termWeeks = parseWeeksField(row?.ZJXZC) || parseWeeksField(row?.ZZC) || null;
     } catch (e) {
         console.warn("获取学期开始日期失败(不影响导入):", e.message);
     }
@@ -231,6 +252,7 @@ async function getCourseRows(term) {
     const rows = res?.datas?.xskcb?.rows;
     console.log(`获取到 ${Array.isArray(rows) ? rows.length : 0} 条课表原始数据`);
     if (!Array.isArray(rows) || rows.length === 0) {
+        // 已保证 `rows.length > 0`
         throw new Error(":( 该学期暂无课程数据(可能尚未排课)");
     }
     return rows;
@@ -255,6 +277,22 @@ function parseWeekBitmap(name, bitmap) {
     return weeks;
 }
 
+/**
+ * 检查上课日期(星期几), 开始/结束节次是否为整数, 若教务修改接口返回非纯`Int`字段, 应当panic
+ * 格式校验: 仅允许数字传入
+ */
+function parseStrictUnsizedInt(name, field, value) {
+    const s = String(value ?? "");
+    if (!/^\d+$/.test(s)) {
+        throw new Error(`:( 课程 ${name} 数据异常: ${field}非法 (${value})`);
+    }
+    const result = parseInt(s, 10);
+    if (result < 1) {
+        throw new Error(`:( 课程 ${name} 数据异常: ${field} 中 ${value} 不能小于 1`);
+    }
+    return result;
+}
+
 /**
  * 统一教师字段格式: 拆分, 去空白, 排序后重组
  * 避免 "张三,李四" 与 "李四,张三" 因顺序不同而漏合并
@@ -283,16 +321,16 @@ function parseCourseRow(row) {
         throw new Error(":( 课表数据异常: 某条课程缺少课程名");
     }
 
-    const day = parseInt(row.SKXQ, 10);
-    if (!Number.isInteger(day) || day < 1 || day > 7) {
+    const day = parseStrictUnsizedInt(name, "SKXQ", row.SKXQ);
+    // `parseStrictUnsizedInt`解析, 确保 day >= 1
+    if (day > WEEK_DAYS) {
         throw new Error(`:( 课程 ${name} 数据异常: 星期值非法 (${row.SKXQ})`);
     }
 
-    const startSection = parseInt(row.KSJC, 10);
-    const endSection = parseInt(row.JSJC, 10);
-    const validInt = (n) => Number.isInteger(n) && n >= 1;
-    // 出现第12节或基础值非法: 教务格式不可信, panic
-    if (!validInt(startSection) || !validInt(endSection) || startSection > endSection || endSection > 11) {
+    const startSection = parseStrictUnsizedInt(name, "KSJC", row.KSJC);
+    const endSection = parseStrictUnsizedInt(name, "JSJC", row.JSJC);
+    // 出现第12节或开始节次大于结束节次: 教务格式不可信, panic
+    if (startSection > endSection || endSection > TOTAL_SECTION) {
         throw new Error(`:( 课程 ${name} 数据异常: 节次非法 (${row.KSJC}-${row.JSJC})`);
     }
 
@@ -366,7 +404,7 @@ function parseAllCourses(rows, termWeeks) {
             if (week > maxCourseWeek) maxCourseWeek = week;
         }
     }
-    const totalWeeks = Math.max(termWeeks || 0, maxCourseWeek) || 16;
+    const totalWeeks = Math.max(termWeeks || 0, maxCourseWeek) || DEFAULT_NUM_OF_WEEKS;
     console.log(`学期总周数: ${totalWeeks} (接口值: ${termWeeks}, 课程最大周: ${maxCourseWeek})`);
 
     // 作息时间: 按当前日期选择(5月-9月为夏季作息)
@@ -391,10 +429,10 @@ function parseAllCourses(rows, termWeeks) {
 async function saveConfig(semesterStartDate, totalWeeks) {
     const configData = {
         semesterStartDate: semesterStartDate,
-        semesterTotalWeeks: totalWeeks || 16,
-        defaultClassDuration: 50,
-        defaultBreakDuration: 10,
-        firstDayOfWeek: 1
+        semesterTotalWeeks: totalWeeks || DEFAULT_NUM_OF_WEEKS,
+        defaultClassDuration: CLASS_DURATION,
+        defaultBreakDuration: BREAK_DURATION,
+        firstDayOfWeek: FIRST_DAY_OF_WEEK
     };
     const success = await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(configData));
     if (!success) {
@@ -430,21 +468,14 @@ async function saveCourses(courses) {
  */
 async function importOnce() {
     try {
-        window.shiguangBridge.showToast(">>> 正在获取学期信息");
         const termInfo = await getTermInfo();
         // 用户在学期选择弹窗取消: 静默退出, 不走 catch 的失败提示
         if (!termInfo) return "cancel";
 
-        window.shiguangBridge.showToast(">>> 正在获取课表数据");
         const rows = await getCourseRows(termInfo.term);
 
         const { courses, timeSlots, totalWeeks, seasonTip } = parseAllCourses(rows, termInfo.termWeeks);
 
-        if (courses.length === 0) {
-            window.shiguangBridge.showToast("'_? 未解析出可导入的课程, 教务数据格式可能已变更");
-            return "cancel";
-        }
-
         // saveCourseConfig 为整体覆盖语义: 无开学日期时跳过写入, 避免清空用户已有配置
         // (开学日期是周次推算的起点, 不应错误, 由用户在App中手动设置)
         // 警告并入最终成功提示, 避免被后续 toast 顶掉
@@ -486,8 +517,14 @@ async function importOnce() {
 }
 
 async function runImportFlow() {
+    let isReady;
+    try {
+        isReady = await promptUserToStart();
+    } catch (err) {
+        console.error(":( 确认弹窗异常:", err);
+        return; // bridge 不通, 放弃
+    }
     // 开始确认仅询问一次, 重试时跳过
-    const isReady = await promptUserToStart();
     if (!isReady) return;
 
     while (true) {