Explorar el Código

feat(CJLU): 重构中国计量大学正方教务适配 (#786)

saltfish hace 1 mes
padre
commit
274a351997
Se han modificado 2 ficheros con 422 adiciones y 290 borrados
  1. 5 4
      resources/CJLU/adapters.yaml
  2. 417 286
      resources/CJLU/cjlu.js

+ 5 - 4
resources/CJLU/adapters.yaml

@@ -1,9 +1,10 @@
 # resources/CJLU/adapters.yaml
 adapters:
+  # 原适配脚本作者:ying-ck;本适配由 salt-fishes 按正方教务 v9 规范重构(接口取数、自动获取开学日期与作息)
   - adapter_id: "CJLU"
-    adapter_name: "中国计量大学正方教务"
+    adapter_name: "中国计量大学(正方教务)"
     category: "BACHELOR_AND_ASSOCIATE"
     asset_js_path: "cjlu.js"
-    import_url: "https://jwxt.cjlu.edu.cn/jwglxt/xtgl/login_slogin.html"
-    maintainer: "ying-ck"
-    description: "中国计量大学正方教务系统课表导入,登录后执行导入。"
+    import_url: "https://jwxt.cjlu.edu.cn/xtgl/login_slogin.html"
+    maintainer: "salt-fishes, ying-ck"
+    description: "登录中国计量大学教务系统后即可导入课表,支持选择学年学期,自动获取开学日期与作息时间"

+ 417 - 286
resources/CJLU/cjlu.js

@@ -1,343 +1,474 @@
 // 中国计量大学(cjlu.edu.cn) 拾光课程表适配脚本
-// 基于正方教务系统接口适配
-
-const COURSE_API_PATHS = [
-    "/kbcx/xskbcx_cxXsgrkb.html?gnmkdm=N2151",
-    "/jwglxt/kbcx/xskbcx_cxXsgrkb.html?gnmkdm=N2151"
-];
-
-function req(url, method = "GET", body) {
-    return new Promise((resolve, reject) => {
-        const xhr = new XMLHttpRequest();
-        xhr.open(method, url, true);
-        xhr.withCredentials = true;
-        xhr.setRequestHeader("Accept", "*/*");
-        if (method === "POST") {
-            xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8");
-            xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
-        }
-        xhr.onreadystatechange = function() {
-            if (xhr.readyState !== 4) return;
-            if (xhr.status >= 200 && xhr.status < 300) {
-                resolve(xhr.responseText);
-            } else {
-                reject(new Error(`请求失败: ${xhr.status}`));
-            }
-        };
-        xhr.onerror = function() {
-            reject(new Error("网络请求失败"));
-        };
-        xhr.send(body || null);
+// 教务系统:正方教务(新版 UI zftal-ui-v5),部署在根路径 /xtgl/
+// 登录:正方统一身份认证 https://authserver.cjlu.edu.cn/authserver/login?service=https://jwxt.cjlu.edu.cn/sso/jziotlogin
+// 数据全部通过接口获取,不读取当前页面 DOM,登录后停留在任意教务页面即可导入
+// 参考:《常见教务系统适配案例参考》正方教务 v9 方案(resources/HAUE、resources/JSEI)
+// 原适配脚本作者:ying-ck   维护者:salt-fishes
+
+var CJLU_OPTIONS_URL = 'kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N2151&layout=default';
+
+// 正方模块目录:模块根地址 = 当前路径中最早出现的模块目录之前的部分
+// 兼容根路径(/xtgl)、/jwglxt 子路径与 WebVPN(/http/<hex>/...) 形态
+var CJLU_MODULE_MARKERS = ['/xtgl/', '/kbcx/', '/jzgl/'];
+
+// 返回以斜杠结尾的模块根地址,便于拼接 'kbcx/xxx.html'
+function getBaseUrl() {
+    const path = window.location.pathname || '';
+    let cut = -1;
+    CJLU_MODULE_MARKERS.forEach(function (marker) {
+        const index = path.indexOf(marker);
+        if (index >= 0 && (cut < 0 || index < cut)) cut = index;
     });
+    return window.location.origin + (cut >= 0 ? path.slice(0, cut) : '') + '/';
 }
 
-function isOnCjluJwxt() {
-    return window.location.hostname === "jwxt.cjlu.edu.cn";
+// ==================== 文本 / 时间 ====================
+
+function normalizeText(value) {
+    return String(value == null ? '' : value)
+        .replace(/[!-~]/g, function (c) { return String.fromCharCode(c.charCodeAt(0) - 65248); })
+        .replace(/\u3000/g, ' ')
+        .replace(/[~~—–]/g, '-');
 }
 
-/**
- * 解析周次字符串,处理单双周和周次范围。
- */
-function parseWeeks(weekStr) {
-    if (!weekStr) return [];
+function normalizeTime(value) {
+    const match = normalizeText(value).trim().match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
+    if (!match) throw new Error('无法识别时间:' + value);
+    return String(match[1]).padStart(2, '0') + ':' + match[2];
+}
 
-    const weekSets = weekStr.split(',');
-    let weeks = [];
+// ==================== 桥接封装 ====================
 
-    for (const set of weekSets) {
-        const trimmedSet = set.trim();
-        const rangeMatch = trimmedSet.match(/(\d+)-(\d+)周/);
-        const singleMatch = trimmedSet.match(/^(\d+)周?/);
+function toast(message) {
+    if (window.shiguangBridge && typeof window.shiguangBridge.showToast === 'function') {
+        window.shiguangBridge.showToast(message);
+    }
+}
 
-        let start = 0;
-        let end = 0;
+async function alertUser(title, message, confirmText) {
+    if (window.shiguangBridgePromise && typeof window.shiguangBridgePromise.showAlert === 'function') {
+        const confirmed = await window.shiguangBridgePromise.showAlert(title, message, confirmText || '知道了');
+        return confirmed === true || confirmed === 'true';
+    }
+    alert(title + '\n' + message);
+    return true;
+}
 
-        if (rangeMatch) {
-            start = Number(rangeMatch[1]);
-            end = Number(rangeMatch[2]);
-        } else if (singleMatch) {
-            start = end = Number(singleMatch[1]);
-        } else {
-            continue;
+// ==================== 请求 ====================
+
+async function requestHaue(path, params, moduleId) {
+    const response = await fetch(getBaseUrl() + path + '?gnmkdm=' + encodeURIComponent(moduleId), {
+        method: 'POST',
+        credentials: 'same-origin',
+        headers: {
+            'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
+            'Accept': 'application/json',
+            'X-Requested-With': 'XMLHttpRequest'
+        },
+        body: new URLSearchParams(params).toString()
+    });
+    if (!response.ok) throw new Error('教务请求失败(HTTP ' + response.status + ')');
+    const text = await response.text();
+    try {
+        return JSON.parse(text);
+    } catch (e) {
+        if (/login_slogin|用户登录|统一身份认证/i.test(text)) {
+            throw new Error('登录已过期,请重新登录教务系统后再导入');
         }
+        throw new Error('教务未返回 JSON 数据,请重新登录后重试');
+    }
+}
 
-        const isSingle = trimmedSet.includes('(单)');
-        const isDouble = trimmedSet.includes('(双)');
-
-        for (let w = start; w <= end; w++) {
-            if (isSingle && w % 2 === 0) continue;
-            if (isDouble && w % 2 !== 0) continue;
-            weeks.push(w);
+// ==================== 校历:开学日期 + 总周数 ====================
+
+function parseSemesterConfig(rows) {
+    const config = {};
+    if (!Array.isArray(rows) || rows.length === 0) return config;
+    let first = null, max = 0;
+    const weeks = new Set();
+    rows.forEach(function (row) {
+        const match = normalizeText(row.zs == null ? row.zsmc : row.zs).trim().match(/^(?:第)?(\d+)(?:周)?$/);
+        if (!match || +match[1] < 1) return;
+        const week = +match[1];
+        weeks.add(week);
+        max = Math.max(max, week);
+        if (week === 1) first = row;
+    });
+    const sorted = Array.from(weeks).sort(function (a, b) { return a - b; });
+    let continuous = sorted.length > 0;
+    for (let i = 1; i < sorted.length; i++) {
+        if (sorted[i] - sorted[i - 1] !== 1) { continuous = false; break; }
+    }
+    // 仅接受连续且最大周次在 10~60 的校历,避免把整年周历当成学期
+    if (continuous && max >= 10 && max <= 60) config.semesterTotalWeeks = max;
+    if (first) {
+        // 注意:正方部分页面重写了 Array.prototype.filter/some/every,这里一律用普通循环
+        const keys = ['rq', 'zcrq', 'ksrq'];
+        for (let i = 0; i < keys.length; i++) {
+            const match = normalizeText(first[keys[i]]).match(/(\d{4})-(\d{1,2})-(\d{1,2})/);
+            if (match) {
+                config.semesterStartDate = match[1] + '-' + String(match[2]).padStart(2, '0') + '-' + String(match[3]).padStart(2, '0');
+                break;
+            }
         }
     }
-
-    return [...new Set(weeks)].sort((a, b) => a - b);
+    return config;
 }
 
-/**
- * 解析 API 返回的 JSON 数据。
- */
-function parseJsonData(jsonData) {
-    console.log("JS: parseJsonData 正在解析 JSON 数据...");
-
-    if (!jsonData || !Array.isArray(jsonData.kbList)) {
-        console.warn("JS: JSON 数据结构错误或缺少 kbList 字段。");
-        return [];
+async function fetchSemesterConfig(year, term, warnings) {
+    try {
+        return parseSemesterConfig(await requestHaue('kbcx/xskbcxZccx_cxZcByXnxq.html', { xnm: year, xqm: term }, 'N2154'));
+    } catch (error) {
+        warnings.push('校历获取失败:' + error.message);
+        return {};
     }
+}
 
-    const finalCourseList = [];
-
-    for (const rawCourse of jsonData.kbList) {
-        if (!rawCourse.kcmc || !rawCourse.xqj || !rawCourse.jcs || !rawCourse.zcd) {
-            continue;
+// ==================== 作息 ====================
+
+function parseTimeSlots(data) {
+    if (!Array.isArray(data) || data.length === 0) throw new Error('未收到有效的作息列表');
+    const byNumber = new Map();
+    data.forEach(function (row) {
+        const section = normalizeText(row.jcmc).trim();
+        const number = +section;
+        if (!/^\d+$/.test(section) || number < 1 || number > 30) return;
+        const startTime = normalizeTime(row.qssj);
+        const endTime = normalizeTime(row.jssj);
+        const existing = byNumber.get(number);
+        if (existing) {
+            if (existing.startTime === startTime && existing.endTime === endTime) return;
+            throw new Error('第 ' + number + ' 节返回不同时间:' + existing.startTime + '-' + existing.endTime + ' 与 ' + startTime + '-' + endTime);
         }
+        byNumber.set(number, { number: number, startTime: startTime, endTime: endTime });
+    });
+    const slots = Array.from(byNumber.values()).sort(function (a, b) { return a.number - b.number; });
+    if (slots.length === 0) throw new Error('作息列表无法解析');
+    return slots;
+}
 
-        const weeksArray = parseWeeks(rawCourse.zcd);
-        if (weeksArray.length === 0) {
-            continue;
+async function fetchTimeSlots(year, term, campusId, warnings) {
+    try {
+        const params = { xnm: year, xqm: term };
+        if (campusId) params.xqh_id = campusId;
+        return parseTimeSlots(await requestHaue('jzgl/skxxMobile_cxRsdjc.html', params, 'N2154'));
+    } catch (error) {
+        warnings.push('教务作息获取失败:' + error.message);
+        return null;
+    }
+}
+
+// ==================== 周次 / 课程解析 ====================
+
+function parseWeeks(value) {
+    const weeks = new Set();
+    const text = normalizeText(value).replace(/(/g, '(').replace(/)/g, ')').replace(/周/g, '').replace(/[,、]/g, ',');
+    text.split(',').forEach(function (part) {
+        const match = part.trim().match(/^(\d+)\s*(?:-\s*(\d+))?\s*(?:\(([单双])\))?$/);
+        if (!match) return;
+        const start = +match[1], end = +(match[2] || match[1]);
+        if (start < 1 || end < start || end > 60) return;
+        for (let week = start; week <= end; week++) {
+            if (match[3] === '单' && week % 2 === 0) continue;
+            if (match[3] === '双' && week % 2 === 1) continue;
+            weeks.add(week);
         }
+    });
+    return Array.from(weeks).sort(function (a, b) { return a - b; });
+}
 
-        const sectionParts = rawCourse.jcs.split('-');
-        const startSection = Number(sectionParts[0]);
-        const endSection = Number(sectionParts[sectionParts.length - 1]);
-        const day = Number(rawCourse.xqj);
+// 节次与周次合并去重函数(官方 wiki 实现,逐字未改)
+function mergeAndDistinctCourses(courses) {
+    if (!Array.isArray(courses) || courses.length <= 1) return courses;
+
+    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) : []
+    }));
+
+    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);
+    });
 
-        if (isNaN(day) || isNaN(startSection) || isNaN(endSection) || day < 1 || day > 7 || startSection > endSection) {
+    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) {
+            current.endSection = next.endSection;
+        } else if (isSameCourseAndWeeks && isDuplicate) {
             continue;
+        } else {
+            step1Merged.push(current);
+            current = next;
         }
-
-        finalCourseList.push({
-            name: rawCourse.kcmc.trim(),
-            teacher: (rawCourse.xm || "").trim(),
-            position: (rawCourse.cdmc || "").trim(),
-            day: day,
-            startSection: startSection,
-            endSection: endSection,
-            weeks: weeksArray
-        });
     }
+    step1Merged.push(current);
+
+    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);
+    });
 
-    finalCourseList.sort((a, b) =>
-        a.day - b.day ||
-        a.startSection - b.startSection ||
-        a.name.localeCompare(b.name)
-    );
-
-    console.log(`JS: JSON 数据解析完成,共找到 ${finalCourseList.length} 门课程。`);
-    return finalCourseList;
-}
-
-/**
- * 构建课表配置,从课程数据中推断最大周次。
- */
-function buildCourseConfig(courses) {
-    let maxWeek = 0;
-    for (const course of courses) {
-        for (const week of course.weeks) {
-            if (week > maxWeek) {
-                maxWeek = week;
-            }
+    const step2Merged = [];
+    let merged = step1Merged[0];
+    for (let i = 1; i < step1Merged.length; i++) {
+        const next = step1Merged[i];
+        const isSameCourseAndSection =
+            merged.name === next.name &&
+            merged.teacher === next.teacher &&
+            merged.position === next.position &&
+            merged.day === next.day &&
+            merged.startSection === next.startSection &&
+            merged.endSection === next.endSection;
+        if (isSameCourseAndSection) {
+            merged.weeks = Array.from(new Set([...merged.weeks, ...next.weeks])).sort((a, b) => a - b);
+        } else {
+            step2Merged.push(merged);
+            merged = next;
         }
     }
-    return {
-        semesterStartDate: null,
-        semesterTotalWeeks: maxWeek || 20,
-        firstDayOfWeek: 1
-    };
+    step2Merged.push(merged);
+    return step2Merged;
 }
 
-async function promptUserToStart() {
-    console.log("JS: 流程开始:显示公告。");
-    return await window.shiguangBridgePromise.showAlert(
-        "教务系统课表导入",
-        "导入前请确保您已在浏览器中成功登录中国计量大学教务系统。",
-        "好的,开始导入"
-    );
+function parseCourses(data, warnings) {
+    if (!data || !Array.isArray(data.kbList)) throw new Error('未收到课表数据,请确认登录状态');
+    const courses = [];
+    data.kbList.forEach(function (row) {
+        try {
+            const name = String(row.kcmc || '').trim();
+            const section = normalizeText(row.jcs || row.jc).match(/^(\d+)\s*(?:-\s*(\d+))?\s*节?$/);
+            const day = +normalizeText(row.xqj);
+            if (!name || !section || !/^[1-7]$/.test(String(day))) throw new Error('排课信息异常');
+            const weeks = parseWeeks(row.zcd);
+            if (weeks.length === 0) throw new Error('周次为空');
+            const startSection = +section[1], endSection = +(section[2] || section[1]);
+            if (startSection < 1 || endSection < startSection) throw new Error('节次异常');
+            courses.push({
+                name: name,
+                teacher: String(row.xm || '').trim(),
+                position: String(row.cdmc || '').trim(),
+                day: day,
+                startSection: startSection,
+                endSection: endSection,
+                weeks: weeks
+            });
+        } catch (error) {
+            warnings.push('跳过课程 ' + String(row && row.kcmc || '未命名') + ':' + error.message);
+        }
+    });
+    return mergeAndDistinctCourses(courses);
 }
 
-function validateYearInput(input) {
-    console.log("JS: validateYearInput 被调用,输入: " + input);
-    if (/^[0-9]{4}$/.test(input)) {
-        console.log("JS: validateYearInput 验证通过。");
-        return false;
-    }
-    console.log("JS: validateYearInput 验证失败。");
-    return "请输入四位数字的学年!";
-}
+// ==================== 学年 / 学期选择 ====================
 
-async function getAcademicYear() {
-    const defaultYear = new Date().getFullYear();
-    console.log("JS: 提示用户输入学年。");
-    return await window.shiguangBridgePromise.showPrompt(
-        "选择学年",
-        "请输入要导入课程的起始学年(例如 2026-2027 应输入2026):",
-        String(defaultYear),
-        "validateYearInput"
-    );
+function getDefaultOptionIndex(options, key) {
+    const match = function (value) {
+        const text = String(value == null ? '' : value).trim();
+        if (!text) return -1;
+        for (let i = 0; i < options.length; i++) if (options[i].value === text) return i;
+        return -1;
+    };
+    let index = match(window[key]);
+    if (index >= 0) return index;
+    try {
+        index = match(new URL(window.location.href).searchParams.get(key));
+        if (index >= 0) return index;
+    } catch (e) { /* ignore */ }
+    const selected = options.findIndex(function (option) { return option.selected; });
+    return selected >= 0 ? selected : 0;
 }
 
-async function selectSemester() {
-    const semesters = ["第一学期", "第二学期"];
-    console.log("JS: 提示用户选择学期。");
-    return await window.shiguangBridgePromise.showSingleSelection(
-        "选择学期",
-        JSON.stringify(semesters),
-        0
-    );
+function parseAcademicOptions(doc) {
+    const read = function (selector) {
+        const list = [];
+        const nodes = doc.querySelectorAll(selector);
+        for (let i = 0; i < nodes.length; i++) {
+            const value = String(nodes[i].value).trim();
+            if (nodes[i].disabled || value === '') continue;
+            list.push({ value: value, text: nodes[i].textContent.trim(), selected: nodes[i].selected });
+        }
+        return list;
+    };
+    const years = read('#xnm option');
+    const terms = read('#xqm option');
+    if (years.length === 0 || terms.length === 0) throw new Error('未获取到学年学期选项,请确认已登录教务系统后重试');
+    const selectedYear = getDefaultOptionIndex(years, 'xnm');
+    const start = Math.max(0, selectedYear - 2);
+    const yearOptions = years.slice(start, selectedYear + 3);
+    return {
+        yearOptions: yearOptions,
+        termOptions: terms,
+        defaultYearIndex: selectedYear - start,
+        defaultTermIndex: getDefaultOptionIndex(terms, 'xqm')
+    };
 }
 
-function getSemesterCode(semesterIndex) {
-    return semesterIndex === 0 ? "3" : "12";
+async function fetchAcademicOptions() {
+    const response = await fetch(getBaseUrl() + CJLU_OPTIONS_URL, { method: 'GET', credentials: 'same-origin' });
+    if (!response.ok) throw new Error('获取学年学期失败(HTTP ' + response.status + ')');
+    const html = await response.text();
+    return parseAcademicOptions(new DOMParser().parseFromString(html, 'text/html'));
 }
 
-/**
- * 请求和解析课程数据。
- */
-async function fetchAndParseCourses(academicYear, semesterIndex) {
-    const semesterCode = getSemesterCode(semesterIndex);
-    const requestBody = `xnm=${encodeURIComponent(academicYear)}&xqm=${encodeURIComponent(semesterCode)}&kzlx=ck&xsdm=&kclbdm=&kclxdm=`;
-    let lastError = "";
-    window.shiguangBridge.showToast("正在获取课表数据...");
-
-    for (const url of COURSE_API_PATHS) {
-        try {
-            console.log(`JS: 正在请求课表接口: ${url}`);
-            const jsonText = await req(url, "POST", requestBody);
-            let jsonData;
-            try {
-                jsonData = JSON.parse(jsonText);
-            } catch (parseError) {
-                lastError = `${url} 返回内容不是 JSON`;
-                console.warn(`JS: ${lastError}`, parseError);
-                continue;
-            }
-
-            const parsedCourses = parseJsonData(jsonData);
-            if (parsedCourses.length === 0) {
-                lastError = `${url} 未返回有效课程`;
-                console.warn(`JS: ${lastError}`);
-                continue;
-            }
-
-            return {
-                courses: parsedCourses,
-                config: buildCourseConfig(parsedCourses)
-            };
-        } catch (error) {
-            lastError = `${url} ${error.message}`;
-            console.error(`JS: 课表接口异常: ${url}`, error);
-        }
-    }
-
-    window.shiguangBridge.showToast("未能获取课表数据,请检查登录状态或学年学期。");
-    await window.shiguangBridgePromise.showAlert(
-        "导入失败",
-        `未能获取课表数据。已使用同源相对路径尝试 /kbcx 和 /jwglxt/kbcx 接口。\n最后错误:${lastError || "未知错误"}`,
-        "确定"
-    );
-    return null;
+async function selectAcademicYearAndSemester(bridge) {
+    const options = await fetchAcademicOptions();
+    const select = async function (title, entries, defaultIndex) {
+        const result = await bridge.showSingleSelection(title, JSON.stringify(entries.map(function (item) { return item.text; })), defaultIndex);
+        if (result === null || result === undefined || String(result).trim() === 'null') return null;
+        const index = parseInt(result, 10);
+        return entries[index] || null;
+    };
+    const make = function (year, term) { return { year: year.value, term: term.value, label: year.text + ' / ' + term.text }; };
+    const defaultSelection = make(options.yearOptions[options.defaultYearIndex], options.termOptions[options.defaultTermIndex]);
+    const action = await select('选择导入学期', [
+        { value: 'default', text: '导入默认学期:' + defaultSelection.label },
+        { value: 'change', text: '更换学年、学期' }
+    ], 0);
+    if (!action) return null;
+    if (action.value === 'default') return defaultSelection;
+    const year = await select('选择学年', options.yearOptions, options.defaultYearIndex);
+    if (!year) return null;
+    const term = await select('选择学期', options.termOptions, options.defaultTermIndex);
+    if (!term) return null;
+    return make(year, term);
 }
 
-async function saveCourses(parsedCourses) {
-    window.shiguangBridge.showToast(`正在保存 ${parsedCourses.length} 门课程...`);
-    console.log(`JS: 尝试保存 ${parsedCourses.length} 门课程...`);
-    try {
-        await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(parsedCourses, null, 2));
-        console.log("JS: 课程保存成功!");
-        return true;
-    } catch (error) {
-        window.shiguangBridge.showToast(`课程保存失败: ${error.message}`);
-        console.error('JS: Save Courses Error:', error);
-        return false;
-    }
+// ==================== 保存 ====================
+
+async function saveWithConfirmation(method, data, label) {
+    const result = await window.shiguangBridgePromise[method](JSON.stringify(data));
+    if (result !== true && result !== 'true') throw new Error(label + '保存未成功,请重试');
 }
 
-// 中国计量大学作息时间表
-const TimeSlots = [
-    { number: 1, startTime: "08:00", endTime: "08:45" },
-    { number: 2, startTime: "08:50", endTime: "09:35" },
-    { number: 3, startTime: "09:55", endTime: "10:40" },
-    { number: 4, startTime: "10:45", endTime: "11:30" },
-    { number: 5, startTime: "11:35", endTime: "12:20" },
-    { number: 6, startTime: "13:30", endTime: "14:15" },
-    { number: 7, startTime: "14:20", endTime: "15:05" },
-    { number: 8, startTime: "15:15", endTime: "16:00" },
-    { number: 9, startTime: "16:05", endTime: "16:50" },
-    { number: 10, startTime: "18:00", endTime: "18:45" },
-    { number: 11, startTime: "18:50", endTime: "19:35" },
-    { number: 12, startTime: "19:40", endTime: "20:25" },
-    { number: 13, startTime: "20:35", endTime: "21:20" }
+// 兜底作息(接口失败时使用;来自教务作息接口实测)
+var CJLU_FALLBACK_TIME_SLOTS = [
+    { number: 1, startTime: '08:00', endTime: '08:45' },
+    { number: 2, startTime: '08:50', endTime: '09:35' },
+    { number: 3, startTime: '09:55', endTime: '10:40' },
+    { number: 4, startTime: '10:45', endTime: '11:30' },
+    { number: 5, startTime: '11:35', endTime: '12:20' },
+    { number: 6, startTime: '13:30', endTime: '14:15' },
+    { number: 7, startTime: '14:20', endTime: '15:05' },
+    { number: 8, startTime: '15:15', endTime: '16:00' },
+    { number: 9, startTime: '16:05', endTime: '16:50' },
+    { number: 10, startTime: '18:00', endTime: '18:45' },
+    { number: 11, startTime: '18:50', endTime: '19:35' },
+    { number: 12, startTime: '19:40', endTime: '20:25' }
 ];
 
-async function importPresetTimeSlots(timeSlots) {
-    if (timeSlots.length > 0) {
-        window.shiguangBridge.showToast(`正在导入 ${timeSlots.length} 个预设时间段...`);
-        try {
-            await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
-            window.shiguangBridge.showToast("预设时间段导入成功!");
-        } catch (error) {
-            window.shiguangBridge.showToast("导入时间段失败: " + error.message);
-            console.error('JS: Save Time Slots Error:', error);
-        }
-    }
-}
+// ==================== 流程编排 ====================
 
 async function runImportFlow() {
-    const alertConfirmed = await promptUserToStart();
-    if (!alertConfirmed) {
-        window.shiguangBridge.showToast("用户取消了导入。");
-        return;
-    }
-
-    if (!isOnCjluJwxt()) {
-        const msg = "当前页面不在中国计量大学教务系统域名内,请先打开并登录 https://jwxt.cjlu.edu.cn/jwglxt/ 后再导入。";
-        window.shiguangBridge.showToast("请先进入中国计量大学教务系统。");
-        await window.shiguangBridgePromise.showAlert("导入失败", msg, "确定");
-        return;
-    }
-
-    const academicYear = await getAcademicYear();
-    if (academicYear === null) {
-        window.shiguangBridge.showToast("导入已取消。");
-        console.log("JS: 获取学年失败/取消,流程终止。");
-        return;
-    }
-    console.log(`JS: 已选择学年: ${academicYear}`);
+    const bridge = window.shiguangBridgePromise;
+    let coursesSaved = false;
+    let saveStage = '课程';
+    try {
+        if (!/cjlu\.edu\.cn$/i.test(window.location.hostname)) {
+            await alertUser('请先进入教务系统', '请登录中国计量大学教务系统后,停留在任意教务页面再执行导入。');
+            return;
+        }
 
-    const semesterIndex = await selectSemester();
-    if (semesterIndex === null || semesterIndex === -1) {
-        window.shiguangBridge.showToast("导入已取消。");
-        console.log("JS: 选择学期失败/取消,流程终止。");
-        return;
-    }
-    console.log(`JS: 已选择学期索引: ${semesterIndex}`);
+        const startAlert = await alertUser('中国计量大学课表导入', '导入前请确保已登录教务系统。', '开始导入');
+        if (!startAlert) { toast('导入已取消'); return; }
+
+        const selection = await selectAcademicYearAndSemester(bridge);
+        if (!selection) { toast('导入已取消'); return; }
+
+        const warnings = [];
+        toast('正在获取课程、校历和作息…');
+        const kbData = await requestHaue('kbcx/xskbcx_cxXsgrkb.html', {
+            xnm: selection.year, xqm: selection.term, kzlx: 'ck', xsdm: '', kclbdm: '', kclxdm: ''
+        }, 'N2151');
+        let campusId = '';
+        const kbList = Array.isArray(kbData.kbList) ? kbData.kbList : [];
+        for (let i = 0; i < kbList.length; i++) {
+            if (kbList[i].xqh_id) { campusId = kbList[i].xqh_id; break; }
+        }
 
-    const result = await fetchAndParseCourses(academicYear, semesterIndex);
-    if (result === null) {
-        console.log("JS: 课程获取或解析失败,流程终止。");
-        return;
-    }
-    const { courses, config } = result;
+        const configResult = await Promise.all([
+            fetchSemesterConfig(selection.year, selection.term, warnings),
+            fetchTimeSlots(selection.year, selection.term, campusId, warnings)
+        ]);
+        const config = configResult[0];
+        const slots = configResult[1] || CJLU_FALLBACK_TIME_SLOTS;
+        if (!configResult[1]) warnings.push('已使用内置作息,如需请核对');
+
+        let courses = parseCourses(kbData, warnings);
+
+        // 课程的节次必须在作息里有对应节次,否则跳过并提示(用普通循环,避开被改写的 filter)
+        const numbers = new Set();
+        for (let i = 0; i < slots.length; i++) numbers.add(slots[i].number);
+        const kept = [];
+        for (let i = 0; i < courses.length; i++) {
+            const course = courses[i];
+            let ok = true;
+            for (let n = course.startSection; n <= course.endSection; n++) {
+                if (!numbers.has(n)) { warnings.push('跳过课程 ' + course.name + ':第 ' + n + ' 节缺少作息'); ok = false; break; }
+            }
+            if (ok) kept.push(course);
+        }
+        courses = kept;
+        if (courses.length === 0) throw new Error('没有可导入的有效课程,请核对学期。' + (warnings.length ? '\n' + warnings.join('\n') : ''));
+
+        // 总周数不能小于课程最大周次
+        if (config.semesterTotalWeeks) {
+            const maxWeek = Math.max.apply(null, courses.reduce(function (all, c) { return all.concat(c.weeks); }, [0]));
+            if (maxWeek > config.semesterTotalWeeks) {
+                warnings.push('校历总周数小于课程周次,未保存总周数');
+                delete config.semesterTotalWeeks;
+            }
+        }
 
-    const saveResult = await saveCourses(courses);
-    if (!saveResult) {
-        console.log("JS: 课程保存失败,流程终止。");
-        return;
-    }
+        const practices = Array.isArray(kbData.sjkList) ? kbData.sjkList : [];
+        const practiceNote = practices.length
+            ? '\n\n以下实践课无固定星期/节次,本次不导入:\n' + practices.map(function (row) { return String(row.kcmc || '未命名') + '(' + String(row.qsjsz || '周次未定') + ')'; }).join('\n')
+            : '';
+        const message = selection.label + '\n共 ' + courses.length + ' 条课程时段。\n\n作息:\n' +
+            slots.map(function (s) { return '第' + s.number + '节 ' + s.startTime + '-' + s.endTime; }).join('\n') +
+            '\n\n开学日期:' + (config.semesterStartDate || '需手动设置') +
+            '\n学期总周数:' + (config.semesterTotalWeeks || '需手动设置') +
+            practiceNote + (warnings.length ? '\n\n注意:\n' + warnings.join('\n') : '');
+        const confirmed = await bridge.showAlert('核对课程与作息', message, '确认并保存');
+        if (confirmed !== true && confirmed !== 'true') { toast('导入已取消'); return; }
+
+        await saveWithConfirmation('saveImportedCourses', courses, '课程');
+        coursesSaved = true;
+        saveStage = '作息';
+        await saveWithConfirmation('savePresetTimeSlots', slots, '作息');
+        if (config.semesterStartDate || config.semesterTotalWeeks) {
+            saveStage = '学期配置';
+            await saveWithConfirmation('saveCourseConfig', Object.assign({ firstDayOfWeek: 1 }, config), '学期配置');
+        }
 
-    try {
-        await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
-        window.shiguangBridge.showToast(`课表配置更新成功!总周数:${config.semesterTotalWeeks}周。`);
+        toast('已导入 ' + courses.length + ' 条课程时段及 ' + slots.length + ' 节作息');
+        if (window.shiguangBridge && typeof window.shiguangBridge.notifyTaskCompletion === 'function') {
+            window.shiguangBridge.notifyTaskCompletion();
+        }
     } catch (error) {
-        window.shiguangBridge.showToast(`课表配置保存失败: ${error.message}`);
-        console.error('JS: Save Config Error:', error);
+        await alertUser('导入未完成', (coursesSaved ? '课程已保存,但' + saveStage + '未保存成功,请重试。\n' : '') + (error && error.message ? error.message : String(error)), '确定');
     }
-
-    await importPresetTimeSlots(TimeSlots);
-
-    window.shiguangBridge.showToast(`课程导入成功,共导入 ${courses.length} 门课程!`);
-    console.log("JS: 整个导入流程执行完毕并成功。");
-    window.shiguangBridge.notifyTaskCompletion();
 }
 
 runImportFlow();