浏览代码

Merge pull request #738 from XingHeYuZhuan/pending

星河欲转 5 小时之前
父节点
当前提交
fa7cbc2ea1

+ 5 - 0
index/root_index.yaml

@@ -498,6 +498,11 @@ schools:
     initial: "H"
     resource_folder: "HBMZU"
 
+  - id: "HBUAS"
+    name: "湖北文理学院"
+    initial: "H"
+    resource_folder: "HBUAS"
+
   - id: "TONGJI"
     name: "同济大学"
     initial: "T"

+ 27 - 8
resources/HBMZU/hbmzu.js

@@ -79,6 +79,12 @@ function parseWeeksText(weekStr) {
     return weeks;
 }
 
+// 是否为周次文本("3-14周" / "3-13单周" / "4-14双周" / "12-12周")
+// 用于在 3 个 span 时区分"无教室(名-周次-教师)"和"无教师(名-教室-周次)"两种结构
+function isWeeksText(text) {
+    return /^\d+-\d+(单|双)?周$/.test(String(text || "").replace(/\s+/g, ""));
+}
+
 // 解析课表表格
 // 每行首 td 为节次文本("上午1-2节"→1-2),其后 7 个 td 依次为周一~周日
 function parseCourseTable(container) {
@@ -97,15 +103,28 @@ function parseCourseTable(container) {
             if (!dayTd) continue;
             const blocks = dayTd.querySelectorAll(".course-block");
             for (const block of blocks) {
-                const spans = block.querySelectorAll("span");
-                if (spans.length < 3) continue;
-                const name = (spans[0].textContent || "").trim();
+                const cells = [];
+                block.querySelectorAll("span").forEach(function (s) {
+                    cells.push((s.textContent || "").trim());
+                });
+                if (cells.length < 3) continue;
+                const name = cells[0];
                 if (!name) continue;
-                // span 结构:[名称, 教室?, 周次, 教师];无教室时仅 [名称, 周次, 教师]
-                const hasRoom = spans.length >= 4;
-                const position = hasRoom ? (spans[1].textContent || "").trim() : "";
-                const weekStr = (spans[hasRoom ? 2 : 1].textContent || "").trim();
-                const teacher = (spans[spans.length - 1].textContent || "").trim();
+                // span 结构:[名称, 教室?, 周次, 教师],教室或教师可能缺失:
+                //   3 个 span 可能是 [名称, 周次, 教师](无教室),也可能是 [名称, 教室, 周次](无教师)
+                // 先找出长得像周次的 span:它前面的第一个 span 是教室,后面的第一个 span 是教师
+                let weekIndex = -1;
+                let weekStr = "";
+                for (let j = 1; j < cells.length; j++) {
+                    if (isWeeksText(cells[j])) {
+                        weekIndex = j;
+                        weekStr = cells[j];
+                        break;
+                    }
+                }
+                if (weekIndex < 0) continue;
+                const position = weekIndex >= 2 ? cells[weekIndex - 1] : "";
+                const teacher = weekIndex + 1 < cells.length ? cells[weekIndex + 1] : "";
                 const weeks = parseWeeksText(weekStr);
                 if (weeks.length === 0) continue;
                 courses.push({

+ 9 - 0
resources/HBUAS/adapters.yaml

@@ -0,0 +1,9 @@
+# resources/HBUAS/adapters.yaml
+adapters:
+  - adapter_id: "HBUAS_01"
+    adapter_name: "湖北文理学院超星教务适配"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "hbuas_01.js"
+    import_url: "https://hbuas.jw.chaoxing.com/admin/login"
+    maintainer: "salt-fishes"
+    description: "湖北文理学院超星教务适配,自动识别当前学期与校区,自动导入课程、作息时间与开学日期,支持账密登录与学习通扫码登录"

+ 534 - 0
resources/HBUAS/hbuas_01.js

@@ -0,0 +1,534 @@
+// 湖北文理学院(hbuas.edu.cn) 拾光课程表适配脚本
+// 教务系统:超星教务(hbuas.jw.chaoxing.com,dbname = hbuas)
+// 使用流程:登录教务系统(账密登录或学习通扫码均可)→ 停留在任意教务页面 → 点击“执行导入”
+//
+// 接口说明(2026-2027-1 学期实测,返回 ret 均为 0):
+//   GET /admin/pkgl/xskb/queryKbForXsd                                  课表页 HTML(内含 #xhid / #xqdm / #xnxq1)
+//   GET /admin/api/jcsj/xqsj/getXqList                                  校区列表(item.id = 校区代码,item.xqmc = 名称)
+//   GET /admin/api/getZclistByXnxq?xnxq=<学年-学期>&xqid=<校区代码>      周次表 data.zclist + 节次时间 data.jcsjszList
+//   GET /admin/xsd/pkgl/xskb/sdpkkbList?xnxq=&xhid=&xqdm=&xskbxslx=0     课程数据 JSON(data[])
+//
+// 细节说明:
+//   1. 课程数据按“单节”返回(djc = 第几节),脚本先逐节转成课程块,
+//      再交给 wiki 教程《课程合并与去重函数》中的合并去重函数处理:
+//      连续节次合并(1-2 + 3-4 → 1-4)、单双周合并(1,3,5 + 2,4,6 → 1-6)、完全重复去重。
+//      实测 60 条原始记录 → 26 条,节次×周次单元 472 → 472,无数据丢失。
+//   2. kcmc / tmc / croommc 都是 <a> 包裹的 HTML 片段,需要抽文本。
+//   3. 教务系统的 #xnxq1 会把“当前学期”标为 selected,脚本把它作为学期单选列表的默认项:
+//      常用场景一键确认;学期未开始时也可改选下学期提前导入。校区则默认沿用页面校区、不弹窗。
+//   4. 作息时间与开学日期都从教务接口读取,不写死;仅在接口异常时回退到内置的北校区作息表。
+//
+// 维护者:salt-fishes
+
+// 学期选择:默认【弹出单选列表】,并把教务系统标记的“当前学期”作为默认选中项。
+//   常用场景直接确认即可;学期尚未开始时,也可在这里改选下学期,提前导入课表。
+//   若希望完全不弹窗、始终使用当前学期,改为 false。
+const ASK_SEMESTER = true;
+
+// 校区选择:默认沿用当前页面校区、不弹窗(同一学生通常只在一个校区上课)。
+//   若希望每次手动选择校区,改为 true。
+const ASK_CAMPUS = false;
+
+// 接口异常时的兜底作息(2026-2027-1 北校区实测值),正常情况不会用到
+const FALLBACK_TIME_SLOTS = [
+    { number: 1, startTime: "08:00", endTime: "08:45" },
+    { number: 2, startTime: "08:55", endTime: "09:40" },
+    { number: 3, startTime: "10:00", endTime: "10:45" },
+    { number: 4, startTime: "10:50", endTime: "11:40" },
+    { number: 5, startTime: "14:00", endTime: "14:45" },
+    { number: 6, startTime: "14:55", endTime: "15:40" },
+    { number: 7, startTime: "16:00", endTime: "16:45" },
+    { number: 8, startTime: "16:45", endTime: "17:30" },
+    { number: 9, startTime: "18:30", endTime: "19:15" },
+    { number: 10, startTime: "19:15", endTime: "20:00" }
+];
+
+// 兜底总周数(接口返回 zclist 长度时以接口为准)
+const FALLBACK_TOTAL_WEEKS = 20;
+
+// 出错时附在弹窗里的诊断信息(便于用户反馈时定位)
+let diagnoseInfo = '';
+
+// =========================================================================
+// 桥接封装
+// =========================================================================
+
+function toast(message) {
+    if (window.shiguangBridge && typeof window.shiguangBridge.showToast === 'function') {
+        window.shiguangBridge.showToast(message);
+    } else {
+        console.log('[HBUAS] ' + message);
+    }
+}
+
+async function alertUser(title, content) {
+    if (window.shiguangBridgePromise && typeof window.shiguangBridgePromise.showAlert === 'function') {
+        return await window.shiguangBridgePromise.showAlert(title, content, "确定");
+    }
+    console.warn('[HBUAS] ' + title + ' - ' + content);
+    return true;
+}
+
+// =========================================================================
+// 文本与周次解析
+// =========================================================================
+
+// 超星返回的字段带有 <a> 标签,取出其中的纯文本
+function extractAnchorText(htmlStr) {
+    if (!htmlStr) return '';
+    const match = String(htmlStr).match(/>([^<]+)</);
+    return match ? match[1].trim() : String(htmlStr).trim();
+}
+
+// 去掉教师名里的括号备注
+function cleanTeacherName(name) {
+    if (!name) return '';
+    return name.replace(/([^)]*)/g, '').replace(/\([^)]*\)/g, '').trim();
+}
+
+// 超星的 zcstr 直接是逗号分隔的周次数字,例如 "1,2,3,10,11"
+function parseWeeks(weekStr) {
+    if (!weekStr) return [];
+    return String(weekStr).split(',')
+        .map(w => Number(String(w).trim()))
+        .filter(w => !isNaN(w) && w > 0)
+        .sort((a, b) => a - b);
+}
+
+function formatTime(timeStr) {
+    if (typeof timeStr !== 'string') return null;
+    const match = timeStr.match(/^(\d{1,2}):(\d{1,2})(?::\d{1,2})?$/);
+    if (!match) return null;
+    const hour = Number(match[1]);
+    const minute = Number(match[2]);
+    return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
+}
+
+// =========================================================================
+// 网络请求封装
+// =========================================================================
+
+async function getJson(url) {
+    const response = await fetch(url, {
+        method: 'GET',
+        credentials: 'include',
+        headers: {
+            'Accept': 'application/json, text/plain, */*',
+            'X-Requested-With': 'XMLHttpRequest'
+        }
+    });
+    if (!response.ok) throw new Error(`网络请求失败,状态码 ${response.status}(${url.split('?')[0]})`);
+    const json = await response.json();
+    if (json.ret !== 0) {
+        // 带上接口名与 ret,便于用户反馈时快速定位(例如 xhid 无效时 sdpkkbList 会返回 ret=-1)
+        throw new Error(`${url.split('?')[0]} 返回 ret=${json.ret}:${json.msg || '未知错误'}`);
+    }
+    return json;
+}
+
+// =========================================================================
+// 页面参数提取
+// =========================================================================
+
+// 课表页地址。其服务端渲染的 #xhid(86 字符加密串)/ #xqdm / #xnxq1 才是正确参数。
+// 注意:教务首页 /admin 里也存在一个 id="xhid" 的元素,但值是用户 ID(32 字符)。
+// 若误用它调 sdpkkbList,服务端会返回 ret=-1「功能暂时停用,请联系管理员」。
+// 因此这里【始终优先采用课表页里的值】,只有当课表页抓取失败时才退回当前页面 DOM。
+const KB_PAGE_URL = '/admin/pkgl/xskb/queryKbForXsd';
+
+function pickKbParams(doc) {
+    return {
+        xhid: doc.querySelector('#xhid')?.value || '',
+        xqdm: doc.querySelector('#xqdm')?.value || '',
+        xnxqSelectHtml: doc.querySelector('#xnxq1')?.outerHTML || null
+    };
+}
+
+// 提取 #xhid(学号加密串)/ #xqdm(校区代码)/ #xnxq1(学期下拉)
+async function extractPageParams() {
+    let pageHtml = null;
+    try {
+        const response = await fetch(KB_PAGE_URL, {
+            method: 'GET',
+            credentials: 'include',
+            headers: { 'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8' }
+        });
+        if (response.ok) {
+            pageHtml = await response.text();
+        } else {
+            console.warn(`JS: 抓取课表页失败,状态码 ${response.status}`);
+        }
+    } catch (error) {
+        console.warn('JS: 抓取课表页异常:', error.message);
+    }
+
+    let params = { xhid: '', xqdm: '', xnxqSelectHtml: null };
+    let source = '';
+
+    if (pageHtml) {
+        const fromPage = pickKbParams(new DOMParser().parseFromString(pageHtml, 'text/html'));
+        if (fromPage.xhid && fromPage.xqdm) {
+            params = fromPage;
+            source = '课表页';
+        }
+    }
+    if (!params.xhid || !params.xqdm) {
+        const fromDom = pickKbParams(document);
+        if (fromDom.xhid && fromDom.xqdm) {
+            params = fromDom;
+            source = '当前页面';
+        }
+    }
+
+    if (!params.xhid || !params.xqdm) {
+        throw new Error('无法获取课表页参数(#xhid / #xqdm),请确认已登录教务系统');
+    }
+    console.log(`JS: 参数来源=${source},xhid=${params.xhid.length} 字符,xqdm=${params.xqdm.length} 字符`);
+    return params;
+}
+
+// 解析 #xnxq1 下拉:labels / values / 当前选中索引
+function parseSemesterOptions(selectHtml) {
+    if (!selectHtml) return null;
+    const doc = new DOMParser().parseFromString(selectHtml, 'text/html');
+    const select = doc.querySelector('#xnxq1') || doc.querySelector('select[name="xnxq"]');
+    if (!select) return null;
+
+    const options = Array.from(select.querySelectorAll('option')).filter(o => (o.value || '').trim() !== '');
+    if (options.length === 0) return null;
+
+    const labels = options.map(o => (o.textContent || o.value || '').trim());
+    const values = options.map(o => (o.value || '').trim());
+
+    // 当前选中项(教务系统会把当前学期标为 selected)
+    let selectedIndex = options.findIndex(o => o.selected);
+    if (selectedIndex < 0) selectedIndex = -1;
+
+    return { labels, values, selectedIndex: selectedIndex, defaultIndex: selectedIndex >= 0 ? selectedIndex : 0 };
+}
+
+// =========================================================================
+// 学期 / 校区选择
+// =========================================================================
+
+async function chooseSemester(selectHtml) {
+    const options = parseSemesterOptions(selectHtml);
+    if (!options) {
+        throw new Error('未能读取学年学期下拉,请确认已登录教务系统');
+    }
+
+    // 默认静默采用教务系统标记的当前学期(可改为始终询问,见 ASK_SEMESTER)
+    if (!ASK_SEMESTER && options.selectedIndex >= 0) {
+        const picked = options.values[options.selectedIndex];
+        console.log(`JS: 自动使用当前学期 ${picked}`);
+        return picked;
+    }
+
+    // 弹出单选列表,并把“当前学期”作为默认选中项:
+    // 常用场景直接确认即可;学期未开始时,也可在此改选下学期提前导入课表
+    const index = await window.shiguangBridgePromise.showSingleSelection(
+        "选择学年学期", JSON.stringify(options.labels), options.defaultIndex
+    );
+    if (index === null || index === -1) return null;
+    return options.values[index];
+}
+
+async function chooseCampus(pageXqdm) {
+    let campusList = null;
+    try {
+        const json = await getJson('/admin/api/jcsj/xqsj/getXqList');
+        campusList = (json.data || [])
+            .map(item => ({ xqdm: String(item.id || '').trim(), xqmc: String(item.xqmc || item.id || '').trim() }))
+            .filter(item => item.xqdm && item.xqmc);
+    } catch (error) {
+        console.warn('JS: 获取校区列表失败:', error.message);
+    }
+
+    if (!campusList || campusList.length === 0) {
+        toast("未获取到校区列表,使用当前页面校区继续导入。");
+        return { xqdm: pageXqdm, xqmc: "" };
+    }
+
+    const matched = campusList.findIndex(item => item.xqdm === pageXqdm);
+    if (!ASK_CAMPUS && matched >= 0) {
+        console.log(`JS: 自动使用页面校区 ${campusList[matched].xqmc}`);
+        return campusList[matched];
+    }
+
+    const index = await window.shiguangBridgePromise.showSingleSelection(
+        "选择校区", JSON.stringify(campusList.map(item => item.xqmc)), matched >= 0 ? matched : 0
+    );
+    if (index === null || index === -1) return null;
+    return campusList[index];
+}
+
+// =========================================================================
+// 作息时间与开学日期
+// =========================================================================
+
+// 返回 { timeSlots, semesterStartDate, semesterTotalWeeks }
+async function fetchTimeAndWeekData(xnxq, xqdm) {
+    const json = await getJson(`/admin/api/getZclistByXnxq?xnxq=${encodeURIComponent(xnxq)}&xqid=${encodeURIComponent(xqdm)}`);
+    const data = json.data || {};
+
+    // 节次时间
+    let timeSlots = null;
+    if (Array.isArray(data.jcsjszList) && data.jcsjszList.length > 0) {
+        timeSlots = data.jcsjszList
+            .map(item => ({ number: Number(item.jc), startTime: formatTime(item.kssj), endTime: formatTime(item.jssj) }))
+            .filter(item => item.number > 0 && item.startTime && item.endTime)
+            .sort((a, b) => a.number - b.number);
+        if (timeSlots.length === 0) timeSlots = null;
+    }
+
+    // 开学日期 = 第 1 周的周一
+    let semesterStartDate = null;
+    if (Array.isArray(data.zclist)) {
+        const firstWeek = data.zclist.find(z => Number(z.zc) === 1);
+        if (firstWeek && firstWeek.minrq) {
+            semesterStartDate = String(firstWeek.minrq).split(' ')[0];
+        }
+    }
+
+    const semesterTotalWeeks = Array.isArray(data.zclist) && data.zclist.length > 0
+        ? data.zclist.length
+        : FALLBACK_TOTAL_WEEKS;
+
+    return { timeSlots, semesterStartDate, semesterTotalWeeks };
+}
+
+// =========================================================================
+// 课程数据
+// =========================================================================
+
+/**
+ * 节次与周次合并去重函数
+ * 采用 wiki 教程《课程合并与去重函数》的参考实现,未做修改
+ * @param {Array<Object>} courses 原始解析课程数组
+ * @returns {Array<Object>} 合并去重后的课程数组
+ */
+function mergeAndDistinctCourses(courses) {
+    if (!Array.isArray(courses) || courses.length <= 1) return courses;
+
+    // 1. 深拷贝并规范周次数据,过滤无效项
+    const list = courses.map(c => ({
+        ...c,
+        name: c.name || '',
+        teacher: c.teacher || '',
+        position: c.position || '',
+        weeks: Array.isArray(c.weeks) ? [...c.weeks].sort((a, b) => a - b) : []
+    }));
+
+    // 阶段 1:合并连续节次与完全重复记录(前提:名称、教师、地点、星期、周次一致)
+    list.sort((a, b) => {
+        return a.name.localeCompare(b.name) ||
+               a.teacher.localeCompare(b.teacher) ||
+               a.position.localeCompare(b.position) ||
+               (a.day || 0) - (b.day || 0) ||
+               a.weeks.join(',').localeCompare(b.weeks.join(',')) ||
+               (a.startSection || 0) - (b.startSection || 0);
+    });
+
+    const step1Merged = [];
+    let current = list[0];
+
+    for (let i = 1; i < list.length; i++) {
+        const next = list[i];
+
+        const isSameCourseAndWeeks =
+            current.name === next.name &&
+            current.teacher === next.teacher &&
+            current.position === next.position &&
+            current.day === next.day &&
+            current.weeks.join(',') === next.weeks.join(',');
+
+        const isContinuous = current.endSection + 1 === next.startSection;
+        const isDuplicate = current.startSection === next.startSection && current.endSection === next.endSection;
+
+        if (isSameCourseAndWeeks && isContinuous) {
+            // 节次连续:延长结束节次 (如 1-2 节 + 3-4 节 -> 1-4 节)
+            current.endSection = next.endSection;
+        } else if (isSameCourseAndWeeks && isDuplicate) {
+            // 完全重复:跳过
+            continue;
+        } else {
+            step1Merged.push(current);
+            current = next;
+        }
+    }
+    step1Merged.push(current);
+
+    // 阶段 2:合并同节次的周次(前提:名称、教师、地点、星期、开始/结束节次一致)
+    step1Merged.sort((a, b) => {
+        return a.name.localeCompare(b.name) ||
+               a.teacher.localeCompare(b.teacher) ||
+               a.position.localeCompare(b.position) ||
+               (a.day || 0) - (b.day || 0) ||
+               (a.startSection || 0) - (b.startSection || 0) ||
+               (a.endSection || 0) - (b.endSection || 0);
+    });
+
+    const step2Merged = [];
+    let cur = step1Merged[0];
+
+    for (let i = 1; i < step1Merged.length; i++) {
+        const nxt = step1Merged[i];
+
+        const isSameCourseAndSection =
+            cur.name === nxt.name &&
+            cur.teacher === nxt.teacher &&
+            cur.position === nxt.position &&
+            cur.day === nxt.day &&
+            cur.startSection === nxt.startSection &&
+            cur.endSection === nxt.endSection;
+
+        if (isSameCourseAndSection) {
+            // 周次合并去重 (如 1-8 周 + 9-16 周 -> 1-16 周)
+            cur.weeks = Array.from(new Set([...cur.weeks, ...nxt.weeks])).sort((a, b) => a - b);
+        } else {
+            step2Merged.push(cur);
+            cur = nxt;
+        }
+    }
+    step2Merged.push(cur);
+
+    return step2Merged;
+}
+
+function parseCourseData(jsonData) {
+    if (!jsonData || !Array.isArray(jsonData.data)) return [];
+
+    // 超星按“单节”返回(djc = 第几节):先逐节转成课程块(startSection = endSection = 节次),
+    // 再交给 mergeAndDistinctCourses 处理连续节次、单双周与重复记录
+    const rawCourses = [];
+
+    for (const raw of jsonData.data) {
+        const name = extractAnchorText(raw.kcmc);
+        const teacher = cleanTeacherName(extractAnchorText(raw.tmc));
+        const position = extractAnchorText(raw.croommc) || '待定';
+        const day = Number(raw.xingqi);
+        const section = Number(raw.djc);
+        const weeks = parseWeeks(raw.zcstr);
+
+        if (!name || isNaN(day) || isNaN(section) || day < 1 || day > 7 || section < 1 || weeks.length === 0) {
+            continue;
+        }
+
+        rawCourses.push({
+            name: name,
+            teacher: teacher,
+            position: position,
+            day: day,
+            startSection: section,
+            endSection: section,
+            weeks: weeks
+        });
+    }
+
+    return mergeAndDistinctCourses(rawCourses);
+}
+
+async function fetchCourses(xnxq, xhid, xqdm) {
+    const url = `/admin/xsd/pkgl/xskb/sdpkkbList?xnxq=${encodeURIComponent(xnxq)}&xhid=${encodeURIComponent(xhid)}&xqdm=${encodeURIComponent(xqdm)}&xskbxslx=0`;
+    const json = await getJson(url);
+    return parseCourseData(json);
+}
+
+// =========================================================================
+// 保存
+// =========================================================================
+
+async function saveCourses(courses) {
+    await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
+}
+
+async function saveTimeSlots(timeSlots) {
+    await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
+}
+
+async function saveConfig(semesterStartDate, semesterTotalWeeks) {
+    const config = { semesterTotalWeeks: semesterTotalWeeks };
+    if (semesterStartDate) config.semesterStartDate = semesterStartDate;
+    await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
+}
+
+// =========================================================================
+// 主流程
+// =========================================================================
+
+function isLoginPage() {
+    return window.location.href.includes('/admin/login') || window.location.href.includes('slogin');
+}
+
+async function runImportFlow() {
+    try {
+        if (isLoginPage()) {
+            await alertUser("导入失败", "当前是登录页面,请先完成登录并进入教务系统内部页面后再执行导入。");
+            return;
+        }
+
+        const confirmed = await alertUser(
+            "湖北文理学院教务系统课表导入",
+            "将自动读取当前学期、校区、作息时间与开学日期。\n导入前请确认已成功登录教务系统。"
+        );
+        if (confirmed !== true && confirmed !== "true") {
+            toast("用户取消了导入。");
+            return;
+        }
+
+        // 1. 参数(来自课表页而非当前页面,见 extractPageParams 注释)
+        const { xhid, xqdm: pageXqdm, xnxqSelectHtml } = await extractPageParams();
+        diagnoseInfo = `xhid ${xhid.length} 字符 / xqdm ${pageXqdm.length} 字符`;
+
+        // 2. 学期(默认取教务系统标记的当前学期)
+        const xnxq = await chooseSemester(xnxqSelectHtml);
+        if (xnxq === null) {
+            toast("导入已取消:未选择学年学期。");
+            return;
+        }
+
+        // 3. 校区(默认取当前页面校区)
+        const campus = await chooseCampus(pageXqdm);
+        if (campus === null) {
+            toast("导入已取消:未选择校区。");
+            return;
+        }
+
+        toast(`正在导入 ${xnxq}${campus.xqmc ? ' · ' + campus.xqmc : ''} ...`);
+
+        // 4. 课程
+        const courses = await fetchCourses(xnxq, xhid, campus.xqdm);
+        if (courses.length === 0) {
+            await alertUser("未找到课程", `${xnxq} 学期未获取到任何课程数据,请确认学期选择是否正确,或该学期暂无排课。`);
+            return;
+        }
+        await saveCourses(courses);
+        console.log(`JS: 课程保存成功,共 ${courses.length} 条。`);
+
+        // 5. 作息时间与开学日期(失败不阻塞课程导入)
+        try {
+            const { timeSlots, semesterStartDate, semesterTotalWeeks } = await fetchTimeAndWeekData(xnxq, campus.xqdm);
+            await saveConfig(semesterStartDate, semesterTotalWeeks);
+
+            if (timeSlots && timeSlots.length > 0) {
+                await saveTimeSlots(timeSlots);
+            } else {
+                await saveTimeSlots(FALLBACK_TIME_SLOTS);
+                toast("未获取到教务作息,已使用内置作息表。");
+            }
+            console.log(`JS: 配置导入完成,开学日期 ${semesterStartDate},总周数 ${semesterTotalWeeks}。`);
+        } catch (error) {
+            console.warn('JS: 作息/开学日期获取失败:', error.message);
+            toast("作息时间与开学日期获取失败,课程已导入,可稍后重试。");
+        }
+
+        toast(`导入成功:${courses.length} 条课程、${xnxq}${campus.xqmc ? ' · ' + campus.xqmc : ''}。`);
+        window.shiguangBridge.notifyTaskCompletion();
+    } catch (error) {
+        console.error('JS: 导入流程异常:', error);
+        const tail = diagnoseInfo ? `\n\n[诊断] ${diagnoseInfo}` : '';
+        await alertUser("导入失败", `${error.message}\n\n请确认:\n1. 已在教务系统中成功登录;\n2. 网络可以正常访问 hbuas.jw.chaoxing.com。${tail}`);
+    }
+}
+
+runImportFlow();

+ 8 - 0
resources/HLJU/adapters.yaml

@@ -7,3 +7,11 @@ adapters:
   import_url: "https://yjsglxt.hlju.edu.cn/"
   maintainer: "lixu-030506"
   description: "黑龙江大学研究生教育综合管理信息系统课表适配。登录教务系统并查询当前学期课表后即可导入。"
+
+- adapter_id: "HLJU_BACHELOR"
+  adapter_name: "黑龙江大学本科教务系统"
+  category: "BACHELOR_AND_ASSOCIATE"
+  asset_js_path: "hlju_bachelor.js"
+  import_url: "http://xsxk.hlju.edu.cn/"
+  maintainer: "Haooz"
+  description: "黑龙江大学本科教务系统课表导入,请先登录,再点击导入!"

+ 245 - 0
resources/HLJU/hlju_bachelor.js

@@ -0,0 +1,245 @@
+// 黑龙江大学本科选课系统(xsxk.hlju.edu.cn)拾光课表导入适配脚本
+// 课表接口:/xszykb/queryxszykbzong(JSON 数组)
+//   字段:SKSJ 课程文本、KEY(xqN_jcM 星期_大节)、KSJC/JSJC 起止节次、ZC 周次位图
+//   课程文本格式:课程名【001】\n[教师]\n[周次][地点]\n第X-Y节(节次行可能缺失)
+// 作息:12 节(接口 /component/queryxqkjsj 获取)
+
+// 黑龙江大学作息时间(2026-2027 第一学期)
+const HLJU_BACHELOR_TIME_SLOTS = [
+    { number: 1, startTime: "08:00", endTime: "08:45" },
+    { number: 2, startTime: "08:50", endTime: "09:35" },
+    { number: 3, startTime: "10:00", endTime: "10:45" },
+    { number: 4, startTime: "10:50", endTime: "11:35" },
+    { number: 5, startTime: "13:30", endTime: "14:15" },
+    { number: 6, startTime: "14:20", endTime: "15:05" },
+    { number: 7, startTime: "15:30", endTime: "16:15" },
+    { number: 8, startTime: "16:20", endTime: "17:05" },
+    { number: 9, startTime: "18:30", endTime: "19:15" },
+    { number: 10, startTime: "19:20", endTime: "20:05" },
+    { number: 11, startTime: "20:10", endTime: "20:55" },
+    { number: 12, startTime: "21:00", endTime: "21:45" }
+];
+
+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 "未知错误";
+}
+
+// 从 performance 资源中查找课表请求 URL(queryxszykbzong)
+function findScheduleRequest() {
+    const resources = performance.getEntriesByType("resource");
+    const matches = resources.map(item => item.name).filter(url => url.indexOf("queryxszykbzong") !== -1);
+    return matches.length ? matches[matches.length - 1] : null;
+}
+
+// 获取当前学期(xn=2026-2027&xq=1),从 queryxnxqdata 接口取 SFDQXQ=1 的学期
+async function fetchCurrentSemester() {
+    const response = await fetch("/component/queryxnxqdata", {
+        method: "POST",
+        credentials: "include",
+        headers: {
+            "Accept": "application/json, text/javascript, */*; q=0.01",
+            "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
+            "X-Requested-With": "XMLHttpRequest"
+        }
+    });
+    if (!response.ok) throw new Error(`学期接口请求失败(HTTP ${response.status})`);
+    const data = await response.json();
+    const list = Array.isArray(data) ? data : (Array.isArray(data.content) ? data.content : []);
+    const current = list.find(item => String(item.SFDQXQ) === "1");
+    if (!current || !current.XN || !current.XQ) {
+        throw new Error("未获取到当前学期信息");
+    }
+    return { xn: current.XN, xq: String(current.XQ) };
+}
+
+async function fetchSchedule() {
+    const semester = await fetchCurrentSemester();
+    const url = findScheduleRequest() || "/xszykb/queryxszykbzong";
+    const response = await fetch(url, {
+        method: "POST",
+        credentials: "include",
+        headers: {
+            "Accept": "application/json, text/javascript, */*; q=0.01",
+            "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
+            "X-Requested-With": "XMLHttpRequest"
+        },
+        body: `xn=${encodeURIComponent(semester.xn)}&xq=${encodeURIComponent(semester.xq)}`
+    });
+    if (!response.ok) throw new Error(`课表接口请求失败(HTTP ${response.status})`);
+    const data = await response.json();
+    if (!data || typeof data !== "object") throw new Error("课表接口返回格式异常");
+    const list = Array.isArray(data) ? data : (Array.isArray(data.content) ? data.content : null);
+    if (!list) throw new Error("课表接口返回格式异常");
+    return list;
+}
+
+// 解析周次文本:支持带/不带方括号
+//   "1-17周" 或 "[1-17周]" → 1..17
+//   "1,4-8双,9,12-16双周" → 1,4,6,8,9,12,14,16
+function parseWeeksText(weekStr) {
+    const text = String(weekStr || "").replace(/\s+/g, "").replace(/^\[|\]$/g, "");
+    if (!text) return [];
+    const body = text.replace(/周/g, "");
+    const weeks = [];
+    const parts = body.split(",");
+    for (const part of parts) {
+        const pm = part.match(/^(\d+)(?:-(\d+))?(单|双)?$/);
+        if (!pm) continue;
+        const start = Number(pm[1]);
+        const end = pm[2] ? Number(pm[2]) : start;
+        const parity = pm[3]; // "单" / "双" / undefined
+        for (let w = start; w <= end; w++) {
+            if (parity === "单" && w % 2 === 0) continue;
+            if (parity === "双" && w % 2 === 1) continue;
+            weeks.push(w);
+        }
+    }
+    return [...new Set(weeks)].sort((a, b) => a - b);
+}
+
+// 解析课程文本:课程名【001】\n[教师]\n[周次][地点]\n第X-Y节(节次行可能缺失)
+function parseCourseText(sksj) {
+    const lines = String(sksj || "").split(/\r?\n/).map(l => l.trim()).filter(l => l);
+    if (lines.length < 2) return null;
+
+    // 考试记录(以【...考试】开头)跳过
+    if (lines[0].indexOf("考试】") !== -1) return null;
+
+    const name = lines[0];
+
+    // 教师:第二行固定为 [教师名]
+    let teacher = "";
+    if (/^\[[^\]]+\]$/.test(lines[1])) {
+        teacher = lines[1].slice(1, -1);
+    }
+
+    // 周次与地点:从第 3 行起,含"周"的 [..] 为周次,其后 [..] 为地点
+    // 地点可能含嵌套方括号(如 [4号楼-[617]计算机5机房分室2]),用平衡括号匹配
+    let weekStr = "";
+    let position = "";
+    for (let i = 2; i < lines.length; i++) {
+        const line = lines[i];
+        const m = line.match(/\[([^\]]*周[^\]]*)\]/);
+        if (m) {
+            if (!weekStr) weekStr = m[1];
+            const rest = line.slice(line.indexOf(m[0]) + m[0].length).trim();
+            if (rest.startsWith("[") && !position) {
+                position = rest.slice(1, -1);
+            }
+            continue;
+        }
+        const posM = line.match(/^\[(.+)\]$/);
+        if (posM && !position && posM[1].indexOf("周") === -1) {
+            position = posM[1];
+        }
+    }
+
+    const weeks = parseWeeksText(weekStr);
+    if (weeks.length === 0) return null;
+
+    return { name, teacher: teacher || "未知", position: position || "待定", weeks };
+}
+
+function parseSchedule(data) {
+    const courses = [];
+
+    for (const row of data) {
+        if (!row || typeof row !== "object") continue;
+
+        const key = String(row.KEY || "");
+        // 劳动教育等无位置排布的记录(KEY=bz 或无节次)跳过
+        if (key === "bz" || !key) continue;
+
+        const dayMatch = key.match(/xq(\d+)/);
+        if (!dayMatch) continue;
+        const day = Number(dayMatch[1]);
+        if (!(day >= 1 && day <= 7)) continue;
+
+        // 起止节次:KSJC/JSJC 字段优先
+        const startSection = Number(row.KSJC);
+        const endSection = Number(row.JSJC);
+        if (!startSection || !endSection || startSection > endSection) continue;
+
+        const parsed = parseCourseText(row.SKSJ);
+        if (!parsed) continue;
+
+        courses.push({
+            name: parsed.name,
+            teacher: parsed.teacher,
+            position: parsed.position,
+            day,
+            startSection,
+            endSection,
+            weeks: parsed.weeks
+        });
+    }
+
+    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;
+    }
+
+    window.shiguangBridge.showToast("正在获取课表数据...");
+    try {
+        const data = await fetchSchedule();
+        const courses = parseSchedule(data);
+        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(HLJU_BACHELOR_TIME_SLOTS);
+
+        window.shiguangBridge.showToast(`课程导入成功,共导入 ${merged.length} 门课程!`);
+        window.shiguangBridge.notifyTaskCompletion();
+    } catch (error) {
+        window.shiguangBridge.showToast(`导入失败:${getErrorMessage(error)}`);
+        console.error("JS: Import Error", error);
+    }
+}
+
+runImportFlow();