Prechádzať zdrojové kódy

Merge pull request #884 from ShiGuangSchedule/pending

星河欲转 2 týždňov pred
rodič
commit
d3a225534d

+ 10 - 1
index/root_index.yaml

@@ -1312,4 +1312,13 @@ schools:
     name: "山东水利职业学院"
     initial: "S"
     resource_folder: "SDWCVC"
-    
+
+  - id: "HBDFXY"
+    name: "河北东方学院"
+    initial: "H"
+    resource_folder: "HBDFXY"
+
+  - id: "ZUST"
+    name: "浙江科技大学"
+    initial: "Z"
+    resource_folder: "ZUST"

+ 8 - 0
resources/HBDFXY/adapters.yaml

@@ -0,0 +1,8 @@
+adapters:
+  - adapter_id: "HBDFXY_01"
+    adapter_name: "强智教务"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "hbdfxy_01.js"
+    import_url: "http://219.148.95.56:10000/jsxsd/"
+    maintainer: "小漫君"
+    description: "登录教务后导入,支持选择学期,自动读取教学周历并写入学校十节作息,课程按教务节次排课。无上课时间的课程会单独提示,不生成虚构安排。"

+ 234 - 0
resources/HBDFXY/hbdfxy_01.js

@@ -0,0 +1,234 @@
+// 河北东方学院 · 强智教务;适配作者:小漫君
+// 接口路线参考本仓库 HNPTC/hnptc_01.js、QFNU/qfnu_01.js(MIT,见根 LICENSE)。
+// 本校返回详细课表 #timetable 与教学周历 #kbtable,不解析门户的当前周课表。
+// 作息依据:2026-10-20 用户提供并确认的十节作息,与教务五组大节边界一致。
+// 先写入学校作息,课程只提交教务原节次,不附带课程独立时间。
+// 未排时间的课程仅提示,不生成课程时段。
+(async function () {
+    'use strict';
+
+    const ORIGIN = 'http://219.148.95.56:10000';
+    const TIMETABLE = '/jsxsd/xskb/xskb_list.do';
+    const CALENDAR = '/jsxsd/jxzl/jxzl_query';
+    const TIME_SLOTS = [
+        { 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:00', endTime: '14:45' },
+        { number: 6, startTime: '14:55', endTime: '15:40' },
+        { number: 7, startTime: '16:00', endTime: '16:45' },
+        { number: 8, startTime: '16:55', endTime: '17:40' },
+        { number: 9, startTime: '19:00', endTime: '19:45' },
+        { number: 10, startTime: '19:55', endTime: '20:40' }
+    ];
+    const bridge = window.shiguangBridge;
+    const ui = window.shiguangBridgePromise;
+
+    function text(element) {
+        return element ? element.textContent.trim() : '';
+    }
+
+    function successful(value) {
+        return value === true || value === 'true';
+    }
+
+    async function requestDoc(path, params) {
+        const response = await fetch(ORIGIN + path, {
+            credentials: 'include',
+            ...(params ? {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+                body: new URLSearchParams(params).toString()
+            } : {})
+        });
+        if (!response.ok) throw new Error('教务请求失败(HTTP ' + response.status + ')');
+        const doc = new DOMParser().parseFromString(await response.text(), 'text/html');
+        if (doc.querySelector('#userAccount, #userPassword')) {
+            throw new Error('登录已失效,请重新登录教务系统后导入。');
+        }
+        return doc;
+    }
+
+    function semesters(doc) {
+        const select = doc.querySelector('select[name="xnxq01id"]');
+        if (!select) throw new Error('未读取到学期列表,请确认已登录教务系统。');
+        return Array.from(select.options).filter(o => o.value.trim()).map(o => ({
+            value: o.value.trim(), label: text(o), selected: o.selected
+        }));
+    }
+
+    function requireSemester(doc, value) {
+        const selected = semesters(doc).find(o => o.selected);
+        if (!selected || selected.value !== value) {
+            throw new Error('教务返回的学期与所选学期不一致,已停止导入。');
+        }
+    }
+
+    function expandWeeks(source) {
+        const result = new Set();
+        // 单双周按各片段分别处理,不能用一个片段的标记过滤整个周次串。
+        for (const part of source.split(/[,,、;;]/)) {
+            const match = part.trim().match(/^(\d+)(?:\s*[-~~–—至]\s*(\d+))?\s*(?:[((]?(单|双)(?:周)?[))]?)?$/);
+            if (!match) throw new Error('发现无法识别的课程周次,请核对教务课表。');
+            const start = Number(match[1]);
+            const end = match[2] ? Number(match[2]) : start;
+            if (start < 1 || end < start || end > 60) throw new Error('课程周次范围异常。');
+            for (let week = start; week <= end; week++) {
+                if (!match[3] || week % 2 === (match[3] === '单' ? 1 : 0)) result.add(week);
+            }
+        }
+        if (!result.size) throw new Error('课程缺少有效周次。');
+        return Array.from(result).sort((a, b) => a - b);
+    }
+
+    function parseTime(source) {
+        const match = source.match(/^(.+?)\s*[((]周[))]\s*\[\s*(\d+)(?:\s*[-~~–—]\s*(\d+))?\s*节\s*\]$/);
+        if (!match) throw new Error('课程周次或节次格式异常,请核对教务课表。');
+        const start = Number(match[2]);
+        const end = match[3] ? Number(match[3]) : start;
+        if (start < 1 || end < start || end > TIME_SLOTS.length) {
+            throw new Error('课程节次超出学校作息范围,请核对教务课表。');
+        }
+        return { weeks: expandWeeks(match[1]), start, end };
+    }
+
+    function parseTimetable(doc) {
+        const table = doc.querySelector('#timetable');
+        if (!table) throw new Error('未读取到整学期课表,请确认登录状态。');
+        const headings = Array.from(table.rows[0].cells).slice(1).map(text);
+        if (headings.join(',') !== '星期一,星期二,星期三,星期四,星期五,星期六,星期日') {
+            throw new Error('课表星期列结构发生变化,已停止导入。');
+        }
+        const merged = new Map();
+        for (const row of Array.from(table.rows).slice(1)) {
+            const blocks = row.querySelectorAll('div.kbcontent');
+            if (!Array.from(blocks).some(block => text(block))) continue;
+            if (row.cells.length !== 8) throw new Error('课表星期列结构发生变化,已停止导入。');
+            for (let day = 1; day <= 7; day++) {
+                // 只读取详细版,忽略同格的 kbcontent1 简表,保留教师与完整节次。
+                for (const block of row.cells[day].querySelectorAll('div.kbcontent')) {
+                    if (!text(block)) continue;
+                    for (const html of block.innerHTML.split(/[-—–]{6,}\s*(?:<br\s*\/?>)?/i)) {
+                        if (!html.replace(/<br\s*\/?>|&nbsp;|\s/gi, '')) continue;
+                        // DOMParser 创建惰性文档,不执行教务页面中的脚本。
+                        const item = new DOMParser().parseFromString(html, 'text/html');
+                        const name = text(item.querySelector('font:not([title]):not([name])'));
+                        const time = parseTime(text(item.querySelector('font[title="周次(节次)"]')));
+                        if (!name) throw new Error('课表中存在没有名称的课程。');
+                        const course = {
+                            name,
+                            teacher: text(item.querySelector('font[title="教师"]')),
+                            position: text(item.querySelector('font[title="教室"]')),
+                            day, startSection: time.start, endSection: time.end,
+                            weeks: time.weeks
+                        };
+                        const key = JSON.stringify([
+                            course.name, course.teacher, course.position, day,
+                            time.start, time.end
+                        ]);
+                        const previous = merged.get(key);
+                        if (previous) {
+                            previous.weeks = Array.from(new Set(previous.weeks.concat(course.weeks))).sort((a, b) => a - b);
+                        } else {
+                            merged.set(key, course);
+                        }
+                    }
+                }
+            }
+        }
+        return Array.from(merged.values());
+    }
+
+    function parseCalendar(doc, semester) {
+        requireSemester(doc, semester);
+        const table = doc.querySelector('#kbtable');
+        if (!table) throw new Error('未读取到所选学期的教学周历。');
+        const rows = Array.from(table.rows).filter(row => /^\d+$/.test(text(row.cells[0])));
+        const weeks = rows.map(row => Number(text(row.cells[0])));
+        if (!weeks.length || weeks.some((week, i) => week !== i + 1)) {
+            throw new Error('教学周历周次不连续,已停止导入。');
+        }
+        let startDate;
+        for (let i = 0; i < rows.length; i++) {
+            const title = rows[i].cells[1] ? rows[i].cells[1].getAttribute('title') : '';
+            const match = String(title || '').match(/^(\d{4})年(\d{2})月(\d{2})(?:日)?$/);
+            if (!match) throw new Error('教学周历未提供完整的周一日期。');
+            const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
+            const formatted = date.toISOString().slice(0, 10);
+            if (formatted !== match[1] + '-' + match[2] + '-' + match[3] || date.getUTCDay() !== 1) {
+                throw new Error('教学周历日期异常。');
+            }
+            if (i === 0) startDate = date;
+            if (date.getTime() !== startDate.getTime() + i * 7 * 86400000) {
+                throw new Error('教学周历日期与周次不一致。');
+            }
+        }
+        return {
+            semesterStartDate: startDate.toISOString().slice(0, 10),
+            semesterTotalWeeks: weeks.length,
+            firstDayOfWeek: 1
+        };
+    }
+
+    function unscheduledNames(doc) {
+        const table = doc.querySelector('#dataTables');
+        if (!table) return [];
+        const header = Array.from(table.rows).find(row => Array.from(row.cells).some(cell => text(cell) === '课程名称'));
+        if (!header) throw new Error('无课表课程清单结构发生变化。');
+        const index = Array.from(header.cells).findIndex(cell => text(cell) === '课程名称');
+        return Array.from(new Set(Array.from(table.rows).slice(header.rowIndex + 1)
+            .filter(row => row.cells.length === header.cells.length)
+            .map(row => text(row.cells[index])).filter(Boolean)));
+    }
+
+    async function runImport() {
+        if (window.location.origin !== ORIGIN || !/^\/jsxsd(?:\/|$)/.test(window.location.pathname)) {
+            throw new Error('请在河北东方学院教务系统登录后执行导入。');
+        }
+        bridge.showToast('正在读取学期信息…');
+        const initial = await requestDoc(TIMETABLE);
+        const options = semesters(initial);
+        if (!options.length) throw new Error('教务未提供可导入的学期。');
+        const picked = await ui.showSingleSelection('选择学期', JSON.stringify(options.map(o => o.label)),
+            Math.max(0, options.findIndex(o => o.selected)));
+        if (picked === null || picked === undefined || picked === 'null') return;
+        const index = Number(picked);
+        if (!Number.isInteger(index) || index < 0 || index >= options.length) return;
+        const semester = options[index].value;
+        const mode = initial.querySelector('select[name="kbjcmsid"]');
+        if (!mode || !mode.value) throw new Error('课表未提供节次模式。');
+        bridge.showToast('正在读取整学期课表与教学周历…');
+        // 对照实际 Form1;zc 留空以取全部周次,节次模式从当前会话取得。
+        const doc = await requestDoc(TIMETABLE, {
+            cj0701id: '', zc: '', demo: '', xnxq01id: semester, kbjcmsid: mode.value, sfFD: '1'
+        });
+        requireSemester(doc, semester);
+        const courses = parseTimetable(doc);
+        if (!courses.length) throw new Error('所选学期没有已排上课时间的课程。');
+        const config = {
+            ...parseCalendar(await requestDoc(CALENDAR, { xnxq01id: semester }), semester),
+            defaultClassDuration: 45,
+            defaultBreakDuration: 10
+        };
+        if (courses.some(course => course.weeks.some(week => week > config.semesterTotalWeeks))) {
+            throw new Error('课程周次超出所选学期教学周历,已停止导入。');
+        }
+        const unscheduled = unscheduledNames(doc);
+        if (unscheduled.length && !successful(await ui.showAlert('以下课程尚无上课时间',
+            unscheduled.join('、') + '\n教务未提供这些课程的上课时间,本次仅导入已排课课程。', '继续导入'))) return;
+        // 全部请求、解析与校验通过后再提交;任一步保存失败均不通知成功。
+        if (!successful(await ui.saveCourseConfig(JSON.stringify(config)))) throw new Error('课表配置保存失败。');
+        if (!successful(await ui.savePresetTimeSlots(JSON.stringify(TIME_SLOTS)))) throw new Error('学校作息保存失败。');
+        if (!successful(await ui.saveImportedCourses(JSON.stringify(courses)))) throw new Error('课程保存失败。');
+        bridge.showToast('导入成功:' + semester + ',共 ' + courses.length + ' 条课程时段');
+        bridge.notifyTaskCompletion();
+    }
+
+    try {
+        await runImport();
+    } catch (error) {
+        const message = error instanceof Error ? error.message : '教务导入失败,请稍后重试。';
+        await ui.showAlert('导入失败', message, '知道了');
+    }
+})();

+ 1 - 1
resources/YNUFE/adapters.yaml

@@ -6,4 +6,4 @@ adapters:
     asset_js_path: "ynufe.js"
     import_url: "https://xjwis.ynufe.edu.cn/jsxsd/"
     maintainer: "AxelPLN"
-    description: "由于学校已于 2026 年上半年启用 aTrust 验证,登录教务系统之前必须使用云财小程序内登记的手机号完成短信验证,完成验证后再使用教务系统账号登录,若无法完成验证请确认是否在小程序登记了正确的手机号码或者向教务处反馈,对此不便开发者表示无能为力。登录教务系统后直接按下面的“执行导入”即可。"
+    description: "登录教务系统后直接按下面的“执行导入”即可,无需打开课表页面。"

+ 151 - 38
resources/YNUFE/ynufe.js

@@ -448,6 +448,53 @@ function generateTimeSlots(campusIdx = 0) {
 
 // ========== 网络请求功能 ==========
 
+/**
+ * POST 教学周历页面(获取当前学期的开学日期与最大周次)
+ * @param {string} semesterId 形如 "2025-2026-2"
+ */
+async function requestSemesterDetailPage(semesterId) {
+    const response = await fetch(`/jsxsd/jxzl/jxzl_query`, {
+        method: "POST",
+        headers: { "Content-Type": "application/x-www-form-urlencoded" },
+        body: `xnxq01id=${semesterId}`,
+        credentials: "include"
+    });
+    return await response.text();
+}
+
+/**
+ * 获取当前学期的开学日期 + 最大周次
+ * @param {string} semesterId 形如 "2025-2026-2"
+ * @returns {Promise<{startDate: string|null, totalWeeks: number|null}>}
+ */
+async function fetchSemesterInfo(semesterId) {
+    const html = await requestSemesterDetailPage(semesterId);
+    const doc = new DOMParser().parseFromString(html, "text/html");
+    const timetable = doc.getElementById("kbtable");
+    if (!timetable) return { startDate: null, totalWeeks: null };
+
+    const rows = Array.from(timetable.querySelectorAll("tr")).filter(r => r.querySelector("td"));
+    if (rows.length === 0) return { startDate: null, totalWeeks: null };
+
+    const firstDateCell = rows[0].querySelector("td[title]");
+    const title = firstDateCell?.getAttribute("title") || "";
+    const match = title.match(/^(\d{4})年(\d{2})月(\d{2})$/);
+    const startDate = match ? `${match[1]}-${match[2]}-${match[3]}` : null;
+
+    // 最大周次:所有行第一个 td 中能解析为整数的最大值
+    let totalWeeks = null;
+    for (const row of rows) {
+        const firstTd = row.querySelector("td");
+        if (!firstTd) continue;
+        const n = parseInt(firstTd.textContent.trim(), 10);
+        if (!isNaN(n) && n > 0) {
+            if (totalWeeks === null || n > totalWeeks) totalWeeks = n;
+        }
+    }
+
+    return { startDate, totalWeeks };
+}
+
 /**
  * 获取学期列表
  * @returns {Promise<Object>} 学期列表数据
@@ -463,14 +510,17 @@ async function getSemesterList() {
     let semesters = [];
     let semesterValues = [];
     let defaultIndex = 0;
+    let currentSemesterValue = null;
 
     if (selectElem) {
         const options = selectElem.querySelectorAll('option');
         options.forEach((opt, index) => {
+            const isCurrentSemester = opt.hasAttribute('selected') || opt.selected;
             semesters.push(opt.innerText.trim());
             semesterValues.push(opt.value);
-            if (opt.hasAttribute('selected') || opt.selected) {
+            if (isCurrentSemester) {
                 defaultIndex = index;
+                currentSemesterValue = opt.value;
             }
         });
     }
@@ -500,9 +550,10 @@ async function getSemesterList() {
 
         semesterRecords.forEach((record) => {
             if (record.startYear !== null && record.startYear >= minStartYear) {
-                filteredSemesters.push(record.label);
+                const isCurrentSemester = record.value === currentSemesterValue;
+                filteredSemesters.push(isCurrentSemester ? `${record.label}(当前学期)` : record.label);
                 filteredSemesterValues.push(record.value);
-                if (record.index === defaultIndex) {
+                if (isCurrentSemester || record.index === defaultIndex) {
                     filteredDefaultIndex = filteredSemesters.length - 1;
                 }
             }
@@ -526,9 +577,16 @@ async function getSemesterList() {
  * @param {string} semesterValue 学期参数值
  * @returns {Promise<string>} 课程表HTML
  */
-async function fetchScheduleForSemester(semesterValue) {
+async function fetchScheduleForSemester(semesterValue, semesterPageHtml = '', timeModeValue = null) {
     let formData = new URLSearchParams();
+    formData.append('jx0404id', '');
+    formData.append('cj0701id', '');
+    formData.append('zc', '');
+    formData.append('demo', '');
     formData.append('xnxq01id', semesterValue);
+    formData.append('kbjcmsid', timeModeValue === null
+        ? getTimeModeValue(semesterPageHtml, option => option.textContent.trim() === '全部')
+        : timeModeValue);
 
     const postResponse = await fetch('/jsxsd/xskb/xskb_list.do', {
         method: 'POST',
@@ -539,12 +597,39 @@ async function fetchScheduleForSemester(semesterValue) {
     return await postResponse.text();
 }
 
+/**
+ * 根据文本匹配时间模式选项 value
+ * @param {string} html 页面HTML
+ * @param {Function} matcher 选项匹配函数
+ * @returns {string} 时间模式 value
+ */
+function getTimeModeValue(html, matcher) {
+    const sourceDoc = html
+        ? new DOMParser().parseFromString(html, 'text/html')
+        : document;
+    const modeSelect = sourceDoc.getElementById('kbjcmsid');
+    if (!modeSelect) return '';
+
+    const option = Array.from(modeSelect.options).find(matcher);
+    return option ? option.value : '';
+}
+
+/**
+ * 判断课表页面是否存在课程
+ * @param {string} html 课表HTML
+ * @returns {boolean} 是否存在课程
+ */
+function hasScheduleCourses(html) {
+    const doc = new DOMParser().parseFromString(html, 'text/html');
+    return Array.from(doc.querySelectorAll('#kbtable div.kbcontent'))
+        .some(course => course.textContent.trim().length > 6);
+}
+
 // ========== 主要功能函数 ==========
 
 /**
  * 获取和解析课程数据
  * @param {string} html 课程表HTML
- * @returns {Array|null} 课程数组或null
  */
 async function fetchAndParseCourses(html) {
     try {
@@ -579,7 +664,6 @@ async function fetchAndParseCourses(html) {
 /**
  * 保存课程数据到拾光课程表
  * @param {Array} courses 课程数组
- * @returns {boolean} 保存是否成功
  */
 async function saveCourses(courses) {
     try {
@@ -596,7 +680,6 @@ async function saveCourses(courses) {
 /**
  * 导入预设时间段到拾光课程表
  * @param {number} campusIdx 校区索引
- * @returns {boolean} 导入是否成功
  */
 async function importPresetTimeSlots(campusIdx = 0) {
     try {
@@ -611,6 +694,19 @@ async function importPresetTimeSlots(campusIdx = 0) {
     }
 }
 
+// 导入剩余配置
+async function saveAppConfig(semesterStartDate, totalWeeks) {
+    const config = {
+        "semesterTotalWeeks": totalWeeks || 20,
+        "firstDayOfWeek": 1,
+        "defaultClassDuration": 40
+    };
+    if (semesterStartDate) {
+        config.semesterStartDate = semesterStartDate;
+    }
+    return await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
+}
+
 // ========== 主执行流程 ==========
 
 /**
@@ -641,36 +737,47 @@ async function importYnufeCourseSchedule() {
         
         let targetHtml = htmlText;
         let selectedCampusIdx = 0; // 默认龙泉校区
+        let semesterStartDate = null;
+        let totalWeeks = null;
 
         if (semesters && semesters.length > 0) {
-            // 循环直到用户选择学期才进行下一步(强制不可取消)
-            let selectedIdx = null;
-            while (true) {
-                selectedIdx = await window.shiguangBridgePromise.showSingleSelection(
-                    "选择学期", 
-                    JSON.stringify(semesters), 
-                    -1
-                );
-                if (selectedIdx !== null && selectedIdx !== -1) {
-                    break;
-                }
-                window.shiguangBridge.showToast("必须选择一个学期才能继续导入!");
+            // 选择学期,允许用户取消导入
+            const selectedIdx = await window.shiguangBridgePromise.showSingleSelection(
+                "选择学期", 
+                JSON.stringify(semesters), 
+                defaultIndex
+            );
+            if (selectedIdx === null || selectedIdx === -1) {
+                window.shiguangBridge.showToast("用户取消了导入");
+                return false;
             }
+
+            const semesterId = semesterValues[selectedIdx];
+
+            // 获取当前学期的开学日期与最大周次
+            const semesterInfo = await fetchSemesterInfo(semesterId);
+            semesterStartDate = semesterInfo.startDate;
+            totalWeeks = semesterInfo.totalWeeks;
             
             // 获取对应学期的课表HTML
             window.shiguangBridge.showToast(`正在获取 [${semesters[selectedIdx]}] 的课表...`);
-            targetHtml = await fetchScheduleForSemester(semesterValues[selectedIdx]);
+            targetHtml = await fetchScheduleForSemester(semesterId, htmlText);
 
-            const campuses = ["龙泉校区(默认)", "安宁校区"];
-            selectedCampusIdx = await window.shiguangBridgePromise.showSingleSelection(
-                "选择校区",
-                JSON.stringify(campuses),
-                0
-            );
-            // 如果用户未选择,或者点击了取消,默认设为龙泉校区
-            if (selectedCampusIdx === null || selectedCampusIdx === -1) {
-                selectedCampusIdx = 0;
+            // 时间模式列表始终包含所有校区,必须使用当前所选学期的页面实际请求安宁校区模式
+            const anningModeValue = getTimeModeValue(targetHtml, option => {
+                return option.textContent.trim().includes('安宁校区');
+            });
+            if (anningModeValue) {
+                const anningScheduleHtml = await fetchScheduleForSemester(
+                    semesterId,
+                    targetHtml,
+                    anningModeValue
+                );
+                if (hasScheduleCourses(anningScheduleHtml)) {
+                    selectedCampusIdx = 1;
+                }
             }
+            console.log(`根据时间模式自动选择${selectedCampusIdx === 1 ? '安宁校区' : '龙泉校区'}时间表`);
         }
 
         // 获取和解析课程数据
@@ -684,8 +791,8 @@ async function importYnufeCourseSchedule() {
             if (targetHtml && targetHtml.includes('kbtable')) {
                 // 找到了课表元素但没有课程,是真的空课表
                 console.log('检测到空课表');
-                window.shiguangBridge.showToast('当前课表为空');
-                courses = []; // 返回空数组
+                window.shiguangBridge.showToast('当前学期课表为空!');
+                return false;
             } else {
                 // 找不到课表元素,解析失败
                 window.shiguangBridge.showToast('获取课表失败,请检查网络和页面状态');
@@ -698,8 +805,19 @@ async function importYnufeCourseSchedule() {
         // 保存课程数据
         const saveResult = await saveCourses(courses);
         if (!saveResult) {
-            window.shiguangBridge.showToast('保存课程失败');
-            throw new Error('保存课程数据失败');
+            return;
+        }
+
+        // 提前提示导入结果,避免被导入完成通知立即覆盖
+        if (courses.length > 0) {
+            const campusName = selectedCampusIdx === 1 ? "安宁校区" : "龙泉校区";
+            window.shiguangBridge.showToast(`成功导入 ${courses.length} 门课程!(${campusName})`);
+        }
+
+        // 保存开学日期与最大周数
+        const configResult = await saveAppConfig(semesterStartDate, totalWeeks);
+        if (!configResult) {
+            return;
         }
 
         // 导入时间段配置
@@ -709,11 +827,6 @@ async function importYnufeCourseSchedule() {
             throw new Error('导入时间段失败');
         }
 
-        // 成功
-        if (courses.length > 0) {
-            const campusName = selectedCampusIdx === 1 ? "安宁校区" : "龙泉校区";
-            window.shiguangBridge.showToast(`成功导入 ${courses.length} 门课程!(${campusName})`);
-        }
         console.log('课程导入完成');
         return true;
 

+ 8 - 0
resources/ZUST/adapters.yaml

@@ -0,0 +1,8 @@
+adapters:
+  - adapter_id: "ZUST_01"
+    adapter_name: "浙江科技大学(正方教务)"
+    category: "BACHELOR_AND_ASSOCIATE"
+    asset_js_path: "zust_01.js"
+    import_url: "https://https-authserver-zust-edu-cn-443.webvpn.zust.edu.cn/authserver/login?appid=https://https-authserver-zust-edu-cn-443.webvpn.zust.edu.cn/authserver/logout?service=${redirect_url}&service=https://webvpn.zust.edu.cn/callback/cas/TKBCDGsz"
+    maintainer: "小漫君"
+    description: "登录 WebVPN 后点击教务系统,进入教学管理信息服务平台再导入。支持选择学年学期,自动读取校历和课程所属校区作息,保留原节次。"

+ 278 - 0
resources/ZUST/zust_01.js

@@ -0,0 +1,278 @@
+// 浙江科技大学(zust.edu.cn)正方 V9 教务适配
+// 适配作者 / 维护者:小漫君
+// 接口及桥接选择参考 resources/JSEI/jsei_01.js(星河欲转);保留原 CJLU(ying-ck、salt-fishes)方案来源。
+// 本文件沿用仓库 MIT 许可证。
+
+function zustToast(message) {
+    window.shiguangBridge.showToast(message);
+}
+
+function zustConfirmed(value) {
+    return value === true || value === 'true';
+}
+
+function zustText(value) {
+    return String(value == null ? '' : value).trim();
+}
+
+function zustBaseUrl() {
+    const host = window.location.hostname;
+    if (host !== 'https-newjwxt-zust-edu-cn-443.webvpn.zust.edu.cn' && host !== 'newjwxt.zust.edu.cn') {
+        throw new Error('请登录 WebVPN,点击“教务系统”,进入教学管理信息服务平台后再导入。');
+    }
+    return window.location.origin + '/jwglxt/';
+}
+
+async function zustRequest(path, params, json = true) {
+    const url = new URL(zustBaseUrl() + path);
+    for (const key in params) url.searchParams.set(key, params[key]);
+    const response = await fetch(url.toString(), {
+        method: 'GET',
+        credentials: 'same-origin',
+        headers: { Accept: json ? 'application/json' : 'text/html' }
+    });
+    if (!response.ok) throw new Error('教务请求失败(HTTP ' + response.status + '),请稍后重试。');
+    const text = await response.text();
+    if (/authserver\/|login_slogin|site-nav\//i.test(response.url || '') || /<title[^>]*>\s*统一身份认证/.test(text)) {
+        throw new Error('登录已过期,请通过 WebVPN 重新进入教务系统。');
+    }
+    if (!json) return text;
+    try {
+        return JSON.parse(text);
+    } catch (error) {
+        throw new Error('教务未返回有效数据,请确认已经进入教务系统并重新登录。');
+    }
+}
+
+async function zustSelect(title, entries, defaultIndex) {
+    const labels = [];
+    for (const entry of entries) labels.push(entry.label);
+    const value = await window.shiguangBridgePromise.showSingleSelection(title, JSON.stringify(labels), defaultIndex);
+    if (value === null || value === undefined || value === 'null' || value === -1 || value === '-1') return null;
+    if (!/^\d+$/.test(String(value)) || !entries[Number(value)]) throw new Error('学期或校区选择无效,请重新导入。');
+    return entries[Number(value)];
+}
+
+async function zustFetchAcademicOptions() {
+    const html = await zustRequest('kbcx/xskbcx_cxXskbcxIndex.html', { gnmkdm: 'N2151', layout: 'default' }, false);
+    const responseDoc = new DOMParser().parseFromString(html, 'text/html');
+    const read = function (selector) {
+        const entries = [];
+        for (const option of responseDoc.querySelectorAll(selector + ' option')) {
+            if (!option.disabled && option.value.trim()) entries.push({ value: option.value.trim(), label: option.textContent.trim(), selected: option.selected });
+        }
+        if (entries.length === 0) throw new Error('未取得教务返回的学年学期列表,请确认登录状态。');
+        return entries;
+    };
+    return { years: read('#xnm'), terms: read('#xqm') };
+}
+
+async function zustFetchCourses(semester) {
+    const params = { gnmkdm: 'N2151', xnm: semester.year, xqm: semester.term, kzlx: 'ck', xsdm: '', kclbdm: '', kclxdm: '' };
+    return zustRequest('kbcx/xskbcx_cxXsgrkb.html', params);
+}
+
+async function zustChooseSemester() {
+    const options = await zustFetchAcademicOptions();
+    let yearIndex = 0, termIndex = 0;
+    for (let i = 0; i < options.years.length; i++) if (options.years[i].selected) yearIndex = i;
+    for (let i = 0; i < options.terms.length; i++) if (options.terms[i].selected) termIndex = i;
+    const make = function (year, term) { return { year: year.value, term: term.value, label: year.label + ' 第 ' + term.label + ' 学期' }; };
+    const defaultSelection = make(options.years[yearIndex], options.terms[termIndex]);
+    const action = await zustSelect('选择导入学期', [
+        { label: defaultSelection.label, value: 'current' },
+        { label: '更换学年、学期', value: 'change' }
+    ], 0);
+    if (!action) return null;
+    if (action.value === 'current') return defaultSelection;
+    const year = await zustSelect('选择学年', options.years, yearIndex);
+    if (!year) return null;
+    const term = await zustSelect('选择学期', options.terms, termIndex);
+    return term ? make(year, term) : null;
+}
+
+function zustParseWeeks(value) {
+    const text = zustText(value).replace(/[第周\s]/g, '').replace(/(/g, '(').replace(/)/g, ')').replace(/[,、]/g, ',');
+    const weeks = new Set();
+    for (const part of text.split(',')) {
+        const match = part.match(/^(\d+)(?:-(\d+))?(?:\(([单双])\))?$/);
+        if (!match) throw new Error('无法识别课程周次,请核对教务课表。');
+        const start = Number(match[1]), end = Number(match[2] || match[1]);
+        if (start < 1 || end < start || end > 60) throw new Error('课程周次超出有效范围。');
+        for (let week = start; week <= end; week++) {
+            if (match[3] === '单' && week % 2 === 0) continue;
+            if (match[3] === '双' && week % 2 === 1) continue;
+            weeks.add(week);
+        }
+    }
+    if (weeks.size === 0) throw new Error('课程周次为空。');
+    return Array.from(weeks).sort((a, b) => a - b);
+}
+
+function zustParseCourses(data, semester) {
+    if (!data || !Array.isArray(data.kbList) || !data.xsxx ||
+        zustText(data.xsxx.XNM) !== semester.year || zustText(data.xsxx.XQM) !== semester.term) {
+        throw new Error('课表响应与所选学期不一致,请重新进入教务系统。');
+    }
+    const courses = [], campuses = new Map();
+    // 接口已返回连续节次,保留每条记录的周次与地点,不合并换教室后的课程。
+    for (const row of data.kbList) {
+        const name = zustText(row.kcmc), day = Number(row.xqj);
+        const section = zustText(row.jcs).match(/^(\d+)(?:-(\d+))?$/);
+        const campusId = zustText(row.xqh_id), campusName = zustText(row.xqmc);
+        if (!name || !Number.isInteger(day) || day < 1 || day > 7 || !section || !campusId || !campusName) {
+            throw new Error('课程名称、星期、节次或校区缺失,请核对教务课表。');
+        }
+        const startSection = Number(section[1]), endSection = Number(section[2] || section[1]);
+        if (startSection < 1 || endSection < startSection || endSection > 30) throw new Error('课程节次无效。');
+        const course = {
+            name: name,
+            teacher: zustText(row.xm),
+            position: campusName + (zustText(row.cdmc) ? ' ' + zustText(row.cdmc) : ''),
+            day: day,
+            startSection: startSection,
+            endSection: endSection,
+            weeks: zustParseWeeks(row.zcd)
+        };
+        if (zustText(row.xf) !== '') {
+            const credit = Number(row.xf);
+            if (!Number.isFinite(credit) || credit < 0) throw new Error('课程学分无效。');
+            course.credit = credit;
+        }
+        courses.push({ course: course, campusId: campusId });
+        campuses.set(campusId, campusName);
+    }
+    if (courses.length === 0) throw new Error('该学期没有固定时间的课程,请更换学期或等待排课。');
+    if (!Array.isArray(data.sjkList)) throw new Error('教务未返回完整的未排课信息,请重试。');
+    return { courses: courses, campuses: campuses, unscheduled: data.sjkList };
+}
+
+function zustParseCalendar(rows) {
+    if (!Array.isArray(rows) || rows.length === 0) throw new Error('未取得所选学期的校历。');
+    const weeks = [];
+    for (const row of rows) {
+        if (!/^\d+$/.test(zustText(row.zs))) throw new Error('校历周次格式异常。');
+        const match = zustText(row.rq).match(/^(\d{4}-\d{2}-\d{2})\/(\d{4}-\d{2}-\d{2})$/);
+        if (!match) throw new Error('校历日期格式异常。');
+        weeks.push({ number: Number(row.zs), start: match[1], end: match[2] });
+    }
+    weeks.sort((a, b) => a.number - b.number);
+    if (weeks.length > 60) throw new Error('校历周数超出有效范围。');
+    let firstDate = null;
+    for (let i = 0; i < weeks.length; i++) {
+        const week = weeks[i], start = new Date(week.start + 'T00:00:00Z'), end = new Date(week.end + 'T00:00:00Z');
+        if (week.number !== i + 1 || isNaN(start.getTime()) || isNaN(end.getTime()) ||
+            start.toISOString().slice(0, 10) !== week.start || end.toISOString().slice(0, 10) !== week.end ||
+            start.getUTCDay() !== 1 || end < start || end - start > 6 * 86400000 ||
+            (i < weeks.length - 1 && end - start !== 6 * 86400000)) throw new Error('校历周次或日期不连续。');
+        if (i === 0) firstDate = start;
+        if (start - firstDate !== i * 7 * 86400000) throw new Error('校历日期与教学周不对应。');
+    }
+    // 校历接口的范围包含假期;如实保存,不从课程最大周次猜测总周数。
+    return { semesterStartDate: weeks[0].start, semesterTotalWeeks: weeks.length, firstDayOfWeek: 1 };
+}
+
+function zustClock(value) {
+    const match = zustText(value).match(/^(\d{2}):(\d{2})(?::00)?$/);
+    if (!match || Number(match[1]) > 23 || Number(match[2]) > 59) throw new Error('作息时间格式异常。');
+    return match[1] + ':' + match[2];
+}
+
+function zustParseTimeSlots(rows, campusId) {
+    if (!Array.isArray(rows) || rows.length === 0) throw new Error('未取得课程所属校区的作息。');
+    const slots = [], numbers = new Map();
+    for (const row of rows) {
+        if (zustText(row.xqh_id) !== campusId || !/^\d+$/.test(zustText(row.jcmc))) throw new Error('作息校区或节次与请求不一致。');
+        const slot = { number: Number(row.jcmc), startTime: zustClock(row.qssj), endTime: zustClock(row.jssj) };
+        const previous = numbers.get(slot.number);
+        if (previous) {
+            if (previous.startTime !== slot.startTime || previous.endTime !== slot.endTime) throw new Error('同一节次返回了不同作息。');
+            continue;
+        }
+        numbers.set(slot.number, slot);
+        slots.push(slot);
+    }
+    slots.sort((a, b) => a.number - b.number);
+    for (let i = 0; i < slots.length; i++) {
+        const slot = slots[i];
+        if (slot.number !== i + 1 || slot.number > 30 || slot.startTime >= slot.endTime ||
+            (i > 0 && slot.startTime < slots[i - 1].endTime)) throw new Error('作息编号不连续或时间重叠。');
+    }
+    // 安吉第 5 节在接口中保留为 12:10-13:10,校方说明其为午休,不删除或压缩编号。
+    return slots;
+}
+
+async function zustSave(method, value, label) {
+    const result = await window.shiguangBridgePromise[method](JSON.stringify(value));
+    if (!zustConfirmed(result)) throw new Error(label + '保存未成功,请重新导入。');
+}
+
+async function zustRunImport() {
+    let savedStage = '';
+    try {
+        zustBaseUrl();
+        zustToast('正在读取学年学期列表…');
+        const semester = await zustChooseSemester();
+        if (!semester) { zustToast('导入已取消'); return; }
+        zustToast('正在读取整学期课表、校历和校区作息…');
+        const data = await zustFetchCourses(semester);
+        const parsed = zustParseCourses(data, semester);
+        const config = zustParseCalendar(await zustRequest('kbcx/xskbcxZccx_cxZcByXnxq.html', {
+            gnmkdm: 'N2154', xnm: semester.year, xqm: semester.term
+        }));
+        const schedules = new Map(), campusOptions = [];
+        for (const [id, name] of parsed.campuses) {
+            schedules.set(id, zustParseTimeSlots(await zustRequest('jzgl/skxxMobile_cxRsdjc.html', {
+                gnmkdm: 'N2154', xnm: semester.year, xqm: semester.term, xqh_id: id
+            }), id));
+            campusOptions.push({ value: id, label: name });
+        }
+        let campus = campusOptions[0];
+        if (campusOptions.length > 1) {
+            campus = await zustSelect('选择课表基础作息校区', campusOptions, 0);
+            if (!campus) { zustToast('导入已取消'); return; }
+        }
+        const slots = schedules.get(campus.value), courses = [];
+        let customCount = 0;
+        for (const item of parsed.courses) {
+            const course = item.course, ownSlots = schedules.get(item.campusId);
+            let different = false;
+            for (let n = course.startSection; n <= course.endSection; n++) {
+                if (!ownSlots[n - 1]) throw new Error('课程节次超出所属校区作息,请核对教务课表。');
+                if (!slots[n - 1] || ownSlots[n - 1].startTime !== slots[n - 1].startTime || ownSlots[n - 1].endTime !== slots[n - 1].endTime) different = true;
+            }
+            for (const week of course.weeks) if (week > config.semesterTotalWeeks) throw new Error('课程周次超出所选学期校历。');
+            if (different) {
+                course.isCustomTime = true;
+                course.customStartTime = ownSlots[course.startSection - 1].startTime;
+                course.customEndTime = ownSlots[course.endSection - 1].endTime;
+                customCount++;
+            }
+            courses.push(course);
+        }
+        let note = semester.label + '\n' + courses.length + ' 条课程时段\n基础作息:' + campus.label + ',共 ' + slots.length + ' 节\n第一周周一:' + config.semesterStartDate +
+            '\n教务校历:' + config.semesterTotalWeeks + ' 周(范围可能包含假期)';
+        note += '\n\n作息时间:';
+        for (const slot of slots) note += '\n第 ' + slot.number + ' 节 ' + slot.startTime + '-' + slot.endTime +
+            (campus.value === '3' && slot.number === 5 ? '(午休预留,保留原编号)' : '');
+        if (customCount) note += '\n另有 ' + customCount + ' 条跨校区课程按所属校区真实时间导入,并保留原节次。';
+        if (parsed.unscheduled.length) {
+            note += '\n\n以下课程尚无固定上课时间,本次不导入:';
+            for (const row of parsed.unscheduled) note += '\n' + zustText(row.kcmc) + '(' + zustText(row.qsjsz) + ')';
+        }
+        const confirmed = await window.shiguangBridgePromise.showAlert('核对导入信息', note, '确认导入');
+        if (!zustConfirmed(confirmed)) { zustToast('导入已取消'); return; }
+        await zustSave('saveCourseConfig', config, '学期配置');
+        savedStage = '学期配置';
+        await zustSave('savePresetTimeSlots', slots, '作息');
+        savedStage = '学期配置和作息';
+        await zustSave('saveImportedCourses', courses, '课程');
+        zustToast('已导入 ' + courses.length + ' 条课程时段及 ' + slots.length + ' 节作息');
+        window.shiguangBridge.notifyTaskCompletion();
+    } catch (error) {
+        await window.shiguangBridgePromise.showAlert('导入未完成',
+            (savedStage ? savedStage + '已保存,后续步骤未完成,请重新导入。\n' : '') + (error && error.message ? error.message : String(error)), '知道了');
+    }
+}
+
+zustRunImport();